From 41e009f9b7b46d335d41b365e41f9ad57d571ecb Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 31 Mar 2026 20:49:05 +0300 Subject: fix alt-screen scrollback handling --- src/app/input.rs | 66 +++++++++++--- src/app/state.rs | 1 + src/pane.rs | 56 ++++++++++-- src/ui.rs | 271 +++++++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 324 insertions(+), 70 deletions(-) (limited to 'src') diff --git a/src/app/input.rs b/src/app/input.rs index 9827d4d2..05667ab9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -10,6 +10,12 @@ use tracing::warn; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ScrollbarClickTarget { + Thumb { grab_row_offset: u16 }, + Track { offset_from_bottom: usize }, +} + use super::state::{ key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode, }; @@ -926,14 +932,23 @@ impl AppState { return None; } - if let Some((pane_id, offset_from_bottom)) = + if let Some((pane_id, target)) = self.scrollbar_target_at(mouse.column, mouse.row) { self.focus_pane(pane_id); - self.set_pane_scroll_offset(pane_id, offset_from_bottom); - self.drag = Some(DragState { - target: DragTarget::PaneScrollbar { pane_id }, - }); + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::PaneScrollbar { + pane_id, + grab_row_offset, + }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_pane_scroll_offset(pane_id, offset_from_bottom); + } + } if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } @@ -1022,10 +1037,15 @@ impl AppState { ws.layout.set_ratio_at(&path, ratio); } } - DragTarget::PaneScrollbar { pane_id } => { - if let Some(offset_from_bottom) = - self.scrollbar_offset_for_pane_row(*pane_id, mouse.row) - { + DragTarget::PaneScrollbar { + pane_id, + grab_row_offset, + } => { + if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( + *pane_id, + mouse.row, + *grab_row_offset, + ) { self.set_pane_scroll_offset(*pane_id, offset_from_bottom); } } @@ -1331,7 +1351,11 @@ impl AppState { } } - fn scrollbar_target_at(&self, col: u16, row: u16) -> Option<(crate::layout::PaneId, usize)> { + fn scrollbar_target_at( + &self, + col: u16, + row: u16, + ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { let ws = self.active.and_then(|i| self.workspaces.get(i))?; let info = self.view.pane_infos.iter().find(|info| { crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { @@ -1347,16 +1371,23 @@ impl AppState { return None; } let track = crate::ui::pane_scrollbar_rect(info)?; - Some(( - info.id, - crate::ui::scrollbar_offset_from_row(metrics, track, row), - )) + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) + } else { + Some(( + info.id, + ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }, + )) + } } fn scrollbar_offset_for_pane_row( &self, pane_id: crate::layout::PaneId, row: u16, + grab_row_offset: u16, ) -> Option { let ws = self.active.and_then(|i| self.workspaces.get(i))?; let info = self @@ -1370,7 +1401,12 @@ impl AppState { if metrics.max_offset_from_bottom == 0 { return None; } - Some(crate::ui::scrollbar_offset_from_row(metrics, track, row)) + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) } } diff --git a/src/app/state.rs b/src/app/state.rs index caedb419..cd45bbb6 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -397,6 +397,7 @@ pub(crate) enum DragTarget { }, PaneScrollbar { pane_id: crate::layout::PaneId, + grab_row_offset: u16, }, SidebarDivider, } diff --git a/src/pane.rs b/src/pane.rs index c6b8e175..f25c6a42 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -100,6 +100,12 @@ pub struct ScrollMetrics { pub viewport_rows: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollState { + pub metrics: ScrollMetrics, + pub alternate_screen: bool, +} + impl Drop for PaneRuntime { fn drop(&mut self) { // Abort detection task immediately. @@ -124,7 +130,7 @@ fn max_scrollback(parser: &mut vt100::Parser) -> usize { max_scrollback } -fn recent_text_from_parser(parser: &mut vt100::Parser, lines: usize) -> String { +fn parser_rows(parser: &mut vt100::Parser, lines: usize) -> Vec { let max_scrollback = max_scrollback(parser); let screen = parser.screen_mut(); let original_scrollback = screen.scrollback(); @@ -150,7 +156,10 @@ fn recent_text_from_parser(parser: &mut vt100::Parser, lines: usiz screen.set_scrollback(original_scrollback); rows.extend(visible_rows); trim_trailing_blank_rows(&mut rows); + rows +} +fn recent_text_from_rows(rows: &[String], lines: usize) -> String { let start = rows.len().saturating_sub(lines); let text = rows[start..].join("\n"); if text.is_empty() { @@ -160,6 +169,11 @@ fn recent_text_from_parser(parser: &mut vt100::Parser, lines: usiz } } +fn recent_text_from_parser(parser: &mut vt100::Parser, lines: usize) -> String { + let rows = parser_rows(parser, lines); + recent_text_from_rows(&rows, lines) +} + fn wait_for_processes_to_exit(pids: &[u32], timeout: std::time::Duration) -> bool { let deadline = std::time::Instant::now() + timeout; loop { @@ -324,6 +338,7 @@ impl PaneRuntime { Ok(n) => { if let Ok(mut p) = parser.write() { p.process(&buf[..n]); + // Snapshot live screen content for detection. // Always reads at scrollback 0 (current view), // without touching the user's scroll position. @@ -557,20 +572,27 @@ impl PaneRuntime { } } - pub fn scroll_metrics(&self) -> Option { + pub fn scroll_state(&self) -> Option { let Ok(mut parser) = self.parser.write() else { return None; }; let max_offset_from_bottom = max_scrollback(&mut parser); let screen = parser.screen(); let (viewport_rows, _) = screen.size(); - Some(ScrollMetrics { - offset_from_bottom: screen.scrollback(), - max_offset_from_bottom, - viewport_rows: viewport_rows as usize, + Some(ScrollState { + metrics: ScrollMetrics { + offset_from_bottom: screen.scrollback(), + max_offset_from_bottom, + viewport_rows: viewport_rows as usize, + }, + alternate_screen: screen.alternate_screen(), }) } + pub fn scroll_metrics(&self) -> Option { + self.scroll_state().map(|state| state.metrics) + } + pub fn visible_text(&self) -> String { let Ok(content) = self.screen_content.read() else { return String::new(); @@ -636,6 +658,28 @@ mod tests { assert_eq!(rows, vec!["hello".to_string()]); } + #[test] + fn alternate_screen_accumulates_its_own_scrollback() { + let responses = PtyResponses::new(); + let mut parser = vt100::Parser::new_with_callbacks(2, 10, 100, responses); + parser.process(b"\x1b[?1049h1\r\n2\r\n3"); + + assert!(parser.screen().alternate_screen()); + assert_eq!(max_scrollback(&mut parser), 1); + assert_eq!(recent_text_from_parser(&mut parser, 3), "1\n2\n3\n"); + } + + #[test] + fn top_anchored_scroll_regions_feed_scrollback() { + let responses = PtyResponses::new(); + let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses); + parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4\r\n5"); + parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX"); + + assert!(parser.screen().alternate_screen()); + assert_eq!(max_scrollback(&mut parser), 1); + } + #[test] fn claude_working_is_sticky_for_short_gap() { let now = std::time::Instant::now(); diff --git a/src/ui.rs b/src/ui.rs index 64564487..df9216a1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,11 +1,8 @@ use ratatui::{ - layout::{Constraint, Layout, Margin, Rect}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{ - Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, - ScrollbarOrientation, ScrollbarState, Tabs, - }, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs}, Frame, }; use tui_term::widget::PseudoTerminal; @@ -124,10 +121,14 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let mut inner_rect = area; let mut scrollbar_rect = None; if let Some(rt) = ws.runtimes.get(&focused_id) { - if rt - .scroll_metrics() - .is_some_and(|metrics| metrics.max_offset_from_bottom > 0 && area.width > 1) - { + let detected_agent = ws + .panes + .get(&focused_id) + .and_then(|pane| pane.detected_agent); + if rt.scroll_state().is_some_and(|state| { + should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent) + && area.width > 1 + }) { inner_rect.width = inner_rect.width.saturating_sub(1); scrollbar_rect = Some(Rect::new( area.x + area.width.saturating_sub(1), @@ -167,10 +168,11 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let mut inner_rect = pane_inner; let mut scrollbar_rect = None; if let Some(rt) = ws.runtimes.get(&info.id) { - if rt - .scroll_metrics() - .is_some_and(|metrics| metrics.max_offset_from_bottom > 0 && pane_inner.width > 1) - { + let detected_agent = ws.panes.get(&info.id).and_then(|pane| pane.detected_agent); + if rt.scroll_state().is_some_and(|state| { + should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent) + && pane_inner.width > 1 + }) { inner_rect.width = inner_rect.width.saturating_sub(1); scrollbar_rect = Some(Rect::new( pane_inner.x + pane_inner.width.saturating_sub(1), @@ -633,7 +635,9 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { // Draw terminal content if let Ok(parser) = rt.parser.read() { - let pt = PseudoTerminal::new(parser.screen()); + let show_cursor = parser.screen().scrollback() == 0; + let pt = PseudoTerminal::new(parser.screen()) + .cursor(tui_term::widget::Cursor::default().visibility(show_cursor)); frame.render_widget(pt, info.inner_rect); } render_pane_scrollbar(app, frame, info, rt); @@ -695,25 +699,125 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option { info.scrollbar_rect } -pub(crate) fn scrollbar_offset_from_row( +fn should_show_scrollbar( + metrics: crate::pane::ScrollMetrics, + alternate_screen: bool, + detected_agent: Option, +) -> bool { + if metrics.max_offset_from_bottom == 0 { + return false; + } + + if !alternate_screen { + return true; + } + + metrics.offset_from_bottom > 0 || detected_agent.is_some() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ScrollbarThumb { + pub top: u16, + pub len: u16, +} + +pub(crate) fn scrollbar_thumb( + metrics: crate::pane::ScrollMetrics, + track: Rect, +) -> Option { + if metrics.max_offset_from_bottom == 0 || track.height == 0 { + return None; + } + + let track_height = track.height as usize; + let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows; + if total_rows == 0 { + return None; + } + + let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32) + .round() + .max(1.0) + .min(track_height as f32) as usize; + let max_thumb_top = track_height.saturating_sub(thumb_len); + let scrolled_from_top = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom); + let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 { + 0 + } else { + ((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32) + .round() + .clamp(0.0, max_thumb_top as f32) as usize + }; + + Some(ScrollbarThumb { + top: track.y + thumb_top as u16, + len: thumb_len as u16, + }) +} + +pub(crate) fn scrollbar_thumb_grab_offset( metrics: crate::pane::ScrollMetrics, track: Rect, row: u16, +) -> Option { + let thumb = scrollbar_thumb(metrics, track)?; + (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top) +} + +fn scrollbar_offset_from_thumb_top( + metrics: crate::pane::ScrollMetrics, + track: Rect, + thumb_top: usize, ) -> usize { - if metrics.max_offset_from_bottom == 0 || track.height <= 1 { - return metrics.max_offset_from_bottom; + if metrics.max_offset_from_bottom == 0 { + return 0; } - let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); - let row_offset = clamped_row.saturating_sub(track.y) as f32; - let max_row = track.height.saturating_sub(1) as f32; - let ratio = if max_row == 0.0 { - 0.0 - } else { - row_offset / max_row + let thumb_len = scrollbar_thumb(metrics, track) + .map(|thumb| thumb.len as usize) + .unwrap_or(1); + let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize); + if max_thumb_top == 0 { + return 0; + } + + let desired_top = thumb_top.min(max_thumb_top); + let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32 + / max_thumb_top as f32) + .round() as usize; + metrics + .max_offset_from_bottom + .saturating_sub(scrolled_from_top) +} + +pub(crate) fn scrollbar_offset_from_row( + metrics: crate::pane::ScrollMetrics, + track: Rect, + row: u16, +) -> usize { + let thumb = match scrollbar_thumb(metrics, track) { + Some(thumb) => thumb, + None => return 0, }; - let top_position = (ratio * metrics.max_offset_from_bottom as f32).round() as usize; - metrics.max_offset_from_bottom.saturating_sub(top_position) + let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); + let row_offset = clamped_row.saturating_sub(track.y) as usize; + let thumb_center = (thumb.len as usize) / 2; + let desired_top = row_offset.saturating_sub(thumb_center); + scrollbar_offset_from_thumb_top(metrics, track, desired_top) +} + +pub(crate) fn scrollbar_offset_from_drag_row( + metrics: crate::pane::ScrollMetrics, + track: Rect, + row: u16, + grab_row_offset: u16, +) -> usize { + let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); + let row_offset = clamped_row.saturating_sub(track.y) as usize; + let desired_top = row_offset.saturating_sub(grab_row_offset as usize); + scrollbar_offset_from_thumb_top(metrics, track, desired_top) } fn render_pane_scrollbar( @@ -732,34 +836,27 @@ fn render_pane_scrollbar( return; } - let content_length = metrics.max_offset_from_bottom + metrics.viewport_rows; - let position = metrics - .max_offset_from_bottom - .saturating_sub(metrics.offset_from_bottom); - let mut scrollbar_state = ScrollbarState::new(content_length) - .position(position) - .viewport_content_length(metrics.viewport_rows); + let Some(thumb) = scrollbar_thumb(metrics, track) else { + return; + }; + let (track_color, thumb_color, thumb_symbol) = if info.is_focused { (app.palette.overlay0, app.palette.overlay1, "▐") } else { (app.palette.surface_dim, app.palette.overlay0, "▕") }; - let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None) - .track_symbol(Some("▕")) - .track_style(Style::default().fg(track_color)) - .thumb_symbol(thumb_symbol) - .thumb_style(Style::default().fg(thumb_color)); - - frame.render_stateful_widget( - scrollbar, - track.inner(Margin { - vertical: 0, - horizontal: 0, - }), - &mut scrollbar_state, - ); + + let buf = frame.buffer_mut(); + for y in track.y..track.y + track.height { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol("▕"); + cell.set_style(Style::default().fg(track_color)); + } + for y in thumb.top..thumb.top + thumb.len { + let cell = &mut buf[(track.x, y)]; + cell.set_symbol(thumb_symbol); + cell.set_style(Style::default().fg(thumb_color)); + } } fn render_selection_highlight( @@ -1747,6 +1844,67 @@ mod tests { assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); } + #[test] + fn alternate_screen_scrollbar_stays_hidden_for_unidentified_live_bottom() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + + assert!(!should_show_scrollbar(metrics, true, None)); + } + + #[test] + fn alternate_screen_scrollbar_shows_for_agents_at_live_bottom() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + + assert!(should_show_scrollbar( + metrics, + true, + Some(crate::detect::Agent::Codex) + )); + } + + #[test] + fn alternate_screen_scrollbar_shows_when_user_scrolled_up() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 3, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + + assert!(should_show_scrollbar(metrics, true, None)); + } + + #[test] + fn normal_screen_scrollbar_shows_with_scrollback() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + + assert!(should_show_scrollbar(metrics, false, None)); + } + + #[test] + fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + let track = Rect::new(9, 4, 1, 5); + + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + assert_eq!(thumb.top + thumb.len, track.y + track.height); + } + #[test] fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { let metrics = crate::pane::ScrollMetrics { @@ -1760,4 +1918,19 @@ mod tests { assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0); } + + #[test] + fn dragging_from_current_thumb_row_preserves_offset() { + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: 7, + max_offset_from_bottom: 20, + viewport_rows: 5, + }; + let track = Rect::new(9, 4, 1, 8); + let thumb = scrollbar_thumb(metrics, track).expect("thumb"); + let row = thumb.top + thumb.len / 2; + let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab"); + + assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7); + } } -- cgit v1.2.3