diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 13:28:58 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-30 13:28:58 +0300 |
| commit | 78166728a1eaf55c41f2a546da035e9edefadf1a (patch) | |
| tree | 0fea36962c618ad9f1c58d9092f4f5d17ee86325 /src | |
| parent | 741fbbe8b30774247f8a68831d8df469672e1947 (diff) | |
feat: add interactive pane scrollbars
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 120 | ||||
| -rw-r--r-- | src/app/state.rs | 3 | ||||
| -rw-r--r-- | src/pane.rs | 52 | ||||
| -rw-r--r-- | src/ui.rs | 119 |
4 files changed, 288 insertions, 6 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 68fbe260..bc607221 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -910,6 +910,20 @@ impl AppState { }); return None; } + + if let Some((pane_id, offset_from_bottom)) = + 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 }, + }); + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } + return None; + } } if in_sidebar { @@ -993,6 +1007,13 @@ 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) + { + self.set_pane_scroll_offset(*pane_id, offset_from_bottom); + } + } DragTarget::SidebarDivider => { self.sidebar_width_auto = false; self.set_manual_sidebar_width(mouse.column); @@ -1017,7 +1038,13 @@ impl AppState { MouseEventKind::ScrollUp if !in_sidebar => { self.selection = None; - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + self.scroll_pane_up(info.id, 3); + } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + self.scroll_pane_up(info.id, 3); + } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { rt.scroll_up(3); } @@ -1025,7 +1052,13 @@ impl AppState { } MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; - if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + self.scroll_pane_down(info.id, 3); + } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { + self.focus_pane(info.id); + self.scroll_pane_down(info.id, 3); + } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.focused_runtime() { rt.scroll_down(3); } @@ -1241,6 +1274,89 @@ impl AppState { && row < p.inner_rect.y + p.inner_rect.height }) } + + fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { + self.view.pane_infos.iter().find(|p| { + col >= p.rect.x + && col < p.rect.x + p.rect.width + && row >= p.rect.y + && row < p.rect.y + p.rect.height + }) + } + + fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != pane_id { + ws.layout.focus_pane(pane_id); + } + } + } + + fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.scroll_up(lines); + } + } + } + + fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.scroll_down(lines); + } + } + } + + fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { + if let Some(rt) = ws.runtimes.get(&pane_id) { + rt.set_scroll_offset_from_bottom(offset_from_bottom); + } + } + } + + fn scrollbar_target_at(&self, col: u16, row: u16) -> Option<(crate::layout::PaneId, usize)> { + 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| { + col >= track.x + && col < track.x + track.width + && row >= track.y + && row < track.y + track.height + }) + })?; + let rt = ws.runtimes.get(&info.id)?; + let metrics = rt.scroll_metrics()?; + if metrics.max_offset_from_bottom == 0 { + return None; + } + let track = crate::ui::pane_scrollbar_rect(info)?; + Some(( + info.id, + crate::ui::scrollbar_offset_from_row(metrics, track, row), + )) + } + + fn scrollbar_offset_for_pane_row( + &self, + pane_id: crate::layout::PaneId, + row: u16, + ) -> Option<usize> { + let ws = self.active.and_then(|i| self.workspaces.get(i))?; + let info = self + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id)?; + let track = crate::ui::pane_scrollbar_rect(info)?; + let rt = ws.runtimes.get(&pane_id)?; + let metrics = rt.scroll_metrics()?; + if metrics.max_offset_from_bottom == 0 { + return None; + } + Some(crate::ui::scrollbar_offset_from_row(metrics, track, row)) + } } // Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App diff --git a/src/app/state.rs b/src/app/state.rs index b1350e48..a52b008e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -395,6 +395,9 @@ pub(crate) enum DragTarget { direction: Direction, area: Rect, }, + PaneScrollbar { + pane_id: crate::layout::PaneId, + }, SidebarDivider, } diff --git a/src/pane.rs b/src/pane.rs index d1d5bae0..6c346f20 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -93,6 +93,13 @@ pub struct PaneRuntime { detect_handle: tokio::task::AbortHandle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollMetrics { + pub offset_from_bottom: usize, + pub max_offset_from_bottom: usize, + pub viewport_rows: usize, +} + impl Drop for PaneRuntime { fn drop(&mut self) { // Abort detection task immediately. @@ -108,12 +115,19 @@ fn trim_trailing_blank_rows(rows: &mut Vec<String>) { } } -fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usize) -> String { +fn max_scrollback(parser: &mut vt100::Parser<PtyResponses>) -> usize { let screen = parser.screen_mut(); let original_scrollback = screen.scrollback(); - screen.set_scrollback(usize::MAX); let max_scrollback = screen.scrollback(); + screen.set_scrollback(original_scrollback); + max_scrollback +} + +fn recent_text_from_parser(parser: &mut vt100::Parser<PtyResponses>, lines: usize) -> String { + let max_scrollback = max_scrollback(parser); + let screen = parser.screen_mut(); + let original_scrollback = screen.scrollback(); let (_, cols) = screen.size(); screen.set_scrollback(0); @@ -462,6 +476,27 @@ impl PaneRuntime { } } + /// Set scrollback offset measured from the live bottom of the terminal. + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { + if let Ok(mut p) = self.parser.write() { + p.screen_mut().set_scrollback(lines); + } + } + + pub fn scroll_metrics(&self) -> Option<ScrollMetrics> { + 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, + }) + } + pub fn visible_text(&self) -> String { let Ok(content) = self.screen_content.read() else { return String::new(); @@ -508,6 +543,19 @@ mod tests { } #[test] + fn max_scrollback_reports_clamped_history_without_changing_position() { + let responses = PtyResponses::new(); + let mut parser = vt100::Parser::new_with_callbacks(3, 10, 100, responses); + parser.process(b"a\r\nb\r\nc\r\nd\r\ne"); + parser.screen_mut().set_scrollback(1); + + let max = max_scrollback(&mut parser); + + assert_eq!(max, 2); + assert_eq!(parser.screen().scrollback(), 1); + } + + #[test] fn trim_trailing_blank_rows_drops_empty_viewport_tail() { let mut rows = vec!["hello".to_string(), "".to_string(), " ".to_string()]; trim_trailing_blank_rows(&mut rows); @@ -1,8 +1,11 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::{Constraint, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs}, + widgets::{ + Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, + ScrollbarOrientation, ScrollbarState, Tabs, + }, Frame, }; use tui_term::widget::PseudoTerminal; @@ -619,6 +622,7 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let pt = PseudoTerminal::new(parser.screen()); frame.render_widget(pt, info.inner_rect); } + render_pane_scrollbar(app, frame, info, rt); // Dim unfocused panes only in navigate mode let should_dim = !info.is_focused && multi_pane && !terminal_active; @@ -673,6 +677,86 @@ fn dim_color(color: Color) -> Color { } } +pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> { + let inner = info.inner_rect; + if inner.width == 0 || inner.height == 0 { + return None; + } + Some(Rect::new( + inner.x + inner.width.saturating_sub(1), + inner.y, + 1, + inner.height, + )) +} + +pub(crate) fn scrollbar_offset_from_row( + metrics: crate::pane::ScrollMetrics, + track: Rect, + row: u16, +) -> usize { + if metrics.max_offset_from_bottom == 0 || track.height <= 1 { + return metrics.max_offset_from_bottom; + } + + 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 top_position = (ratio * metrics.max_offset_from_bottom as f32).round() as usize; + metrics.max_offset_from_bottom.saturating_sub(top_position) +} + +fn render_pane_scrollbar( + app: &AppState, + frame: &mut Frame, + info: &PaneInfo, + rt: &crate::pane::PaneRuntime, +) { + let Some(metrics) = rt.scroll_metrics() else { + return; + }; + let Some(track) = pane_scrollbar_rect(info) else { + return; + }; + if metrics.max_offset_from_bottom == 0 { + 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 (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, + ); +} + fn render_selection_highlight( selection: &Option<crate::selection::Selection>, frame: &mut Frame, @@ -1655,3 +1739,34 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V } spans } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pane_scrollbar_rect_uses_rightmost_inner_column() { + let info = PaneInfo { + id: crate::layout::PaneId::from_raw(1), + rect: Rect::new(0, 0, 12, 8), + inner_rect: Rect::new(1, 1, 10, 6), + is_focused: true, + }; + + assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); + } + + #[test] + fn scrollbar_offset_mapping_hits_top_middle_and_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); + + assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20); + assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); + assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0); + } +} |
