diff --git a/src/server/headless.rs b/src/server/headless.rs index 337bbec7..7bd7abce 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1962,6 +1962,40 @@ mod tests { ); } + #[tokio::test] + async fn virtual_render_hides_focused_pane_cursor_while_scrolled_back() { + let mut state = AppState::test_new(); + let mut ws = crate::workspace::Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + ws.tabs[0].runtimes.insert(pane_id, runtime); + + state.workspaces = vec![ws]; + state.active = Some(0); + state.selected = 0; + state.mode = crate::app::Mode::Terminal; + + let area = Rect::new(0, 0, 80, 24); + let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let runtime = state.workspaces[0] + .runtime(pane_id) + .expect("pane runtime after initial render"); + runtime.scroll_up(6); + assert!(crate::ui::pane_is_scrolled_back(runtime)); + + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); + + assert!( + cursor.as_ref().is_none_or(|cursor| !cursor.visible), + "cursor: {cursor:?}" + ); + } + #[test] fn latest_active_client_drives_shared_size_theme_and_fallback() { let mut server = test_headless_server(); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index f1749657..45542a7a 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -257,11 +257,11 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option { .pane_infos .iter() .find(|info| info.is_focused)?; - let rt = ws.runtimes.get(&info.id)?; + let rt = ws.runtime(info.id)?; let cursor = rt.cursor_state(info.inner_rect, true)?; Some(CursorState { x: cursor.x, y: cursor.y, - visible: cursor.visible, + visible: cursor.visible && !crate::ui::pane_is_scrolled_back(rt), }) } diff --git a/src/ui.rs b/src/ui.rs index 83a9d987..aac94a63 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -63,6 +63,7 @@ pub(crate) use self::{ mobile_switcher_areas, mobile_switcher_max_scroll, mobile_switcher_target_at, mobile_switcher_workspace_doc_range, MobileSwitcherTarget, }, + panes::pane_is_scrolled_back, tabs::compute_tab_bar_view, widgets::{centered_popup_rect, modal_stack_areas}, }; diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 20ac9165..818ee9fd 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -13,6 +13,11 @@ use crate::app::{AppState, Mode}; use crate::layout::PaneInfo; use crate::pane::PaneRuntime; +pub(crate) fn pane_is_scrolled_back(rt: &PaneRuntime) -> bool { + rt.scroll_metrics() + .is_some_and(|metrics| metrics.offset_from_bottom > 0) +} + fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { if pane_inner.width <= 4 { return pane_inner; @@ -176,7 +181,8 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { frame.render_widget(block, info.rect); } - rt.render(frame, info.inner_rect, info.is_focused && terminal_active); + let show_cursor = info.is_focused && terminal_active && !pane_is_scrolled_back(rt); + rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); let should_dim = !info.is_focused && multi_pane && !terminal_active;