aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 13:28:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 13:28:58 +0300
commit78166728a1eaf55c41f2a546da035e9edefadf1a (patch)
tree0fea36962c618ad9f1c58d9092f4f5d17ee86325 /src
parent741fbbe8b30774247f8a68831d8df469672e1947 (diff)
feat: add interactive pane scrollbars
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs120
-rw-r--r--src/app/state.rs3
-rw-r--r--src/pane.rs52
-rw-r--r--src/ui.rs119
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);
diff --git a/src/ui.rs b/src/ui.rs
index 5e58159a..114905a7 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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);
+ }
+}