diff --git a/crates/forgetty-gtk/src/terminal.rs b/crates/forgetty-gtk/src/terminal.rs index 77bcd75..e670d65 100644 --- a/crates/forgetty-gtk/src/terminal.rs +++ b/crates/forgetty-gtk/src/terminal.rs @@ -1194,10 +1194,19 @@ pub fn create_terminal( drawing_area.add_controller(scroll_controller); } + // --- Scrollbar (declared before connect_resize so the resize handler can update + // the adjustment directly under `updating_scrollbar` to coordinate with the + // 16 ms scrollbar poll; see FIX-015). + let adjustment = gtk4::Adjustment::new(0.0, 0.0, 0.0, 1.0, 10.0, 0.0); + let scrollbar = gtk4::Scrollbar::new(gtk4::Orientation::Vertical, Some(&adjustment)); + scrollbar.set_vexpand(true); + scrollbar.set_visible(false); + // --- Resize handler --- { let state = Rc::clone(&state); let cell_measured_resize = Rc::clone(&cell_measured); + let adj_resize = adjustment.clone(); drawing_area.connect_resize(move |da, width, height| { if !*cell_measured_resize.borrow() { return; @@ -1223,8 +1232,18 @@ pub fn create_terminal( s.cols = new_cols; s.rows = new_rows; s.terminal.resize(new_rows, new_cols); - let (_, off, _) = s.terminal.scrollbar_state(); + // Snapshot scrollbar state once after the resize and become the canonical + // writer of the adjustment for this event — closes the FIX-015 race where + // the 16 ms scrollbar poll could race with the resize handler and trigger + // a divergent viewport scroll via connect_value_changed. + let (total, off, len) = s.terminal.scrollbar_state(); s.viewport_offset = off; + s.updating_scrollbar = true; + adj_resize.set_lower(0.0); + adj_resize.set_upper(total as f64); + adj_resize.set_page_size(len as f64); + adj_resize.set_value(off as f64); + s.updating_scrollbar = false; if let (Some(ref dc), Some(pane_id)) = (s.daemon_client.clone(), s.daemon_pane_id) { let _ = dc.resize_pane(pane_id, new_rows as u16, new_cols as u16); } @@ -1234,12 +1253,7 @@ pub fn create_terminal( }); } - // --- Scrollbar --- - let adjustment = gtk4::Adjustment::new(0.0, 0.0, 0.0, 1.0, 10.0, 0.0); - let scrollbar = gtk4::Scrollbar::new(gtk4::Orientation::Vertical, Some(&adjustment)); - scrollbar.set_vexpand(true); - scrollbar.set_visible(false); - + // --- Scrollbar value-changed (user scroll via scrollbar widget) --- { let state = Rc::clone(&state); let da_scroll = drawing_area.clone(); @@ -2326,11 +2340,6 @@ fn draw_terminal( // Clone search state for rendering let search = s.search.clone(); - // Query viewport offset for converting absolute selection rows to screen rows. - // Selection coordinates are stored as absolute scrollback positions; we need - // the viewport offset to map them back to screen-space for drawing. - let viewport_offset = s.viewport_offset as usize; - // Build font description using the current zoom level let font_desc = font_description_with_size(&s.config, s.font_size); @@ -2360,6 +2369,15 @@ fn draw_terminal( } } + // FIX-015 step 3: re-read libghostty's scrollbar state at the top of every paint + // so the cells we draw and the viewport offset we use for absolute→screen row + // projection (selection, search highlight) are consistent within one frame. + // `scrollbar_state()` is a single FFI struct read — sub-µs — far under the + // per-frame paint budget. + let (_sb_total, sb_offset, _sb_len) = s.terminal.scrollbar_state(); + s.viewport_offset = sb_offset; + let viewport_offset = sb_offset as usize; + let cell_w = s.cell_width; let cell_h = s.cell_height; diff --git a/crates/forgetty-session/src/manager.rs b/crates/forgetty-session/src/manager.rs index f07bf43..056c396 100644 --- a/crates/forgetty-session/src/manager.rs +++ b/crates/forgetty-session/src/manager.rs @@ -889,14 +889,7 @@ impl SessionManager { size: PtySize, cwd: Option, ) -> Result { - self.split_pane_with_ratio_and_pane_id( - pane_id, - direction, - ratio, - size, - cwd, - PaneId::new(), - ) + self.split_pane_with_ratio_and_pane_id(pane_id, direction, ratio, size, cwd, PaneId::new()) } /// Like `split_pane_with_ratio`, but uses the supplied `new_pane_id` instead of diff --git a/crates/forgetty-vt/src/terminal.rs b/crates/forgetty-vt/src/terminal.rs index 8a3734a..3715a10 100644 --- a/crates/forgetty-vt/src/terminal.rs +++ b/crates/forgetty-vt/src/terminal.rs @@ -609,9 +609,16 @@ impl Terminal { } let first_sync = cache.screen.generation() == 0; - tracing::trace!("sync_screen: dirty={dirty}, first_sync={first_sync}"); - - if dirty == ffi::GHOSTTY_RENDER_STATE_DIRTY_FALSE && !first_sync { + tracing::trace!( + "sync_screen: dirty={dirty}, first_sync={first_sync}, screen_dirty={}", + cache.screen_dirty + ); + + // `cache.screen_dirty` forces re-extraction even when libghostty's render-state + // dirty flag is clear: resize and viewport scroll mutate the libghostty viewport + // but don't always set the render-state dirty flag, and a stale cache in the new + // geometry/offset is the FIX-015 root cause. + if dirty == ffi::GHOSTTY_RENDER_STATE_DIRTY_FALSE && !first_sync && !cache.screen_dirty { return; }