aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-06 20:59:57 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-06 20:59:57 +0300
commitb83737bddb369777412a2e05c3bdd3518f49d122 (patch)
tree6f40731d79ef272d029c216536da2ba51ffe1504 /src
parent94b90043f98499a1ed3594dd9ea42b2aef62b93a (diff)
fix: keep pane scrollbar gutter stable
Diffstat (limited to 'src')
-rw-r--r--src/layout.rs3
-rw-r--r--src/ui/panes.rs116
2 files changed, 94 insertions, 25 deletions
diff --git a/src/layout.rs b/src/layout.rs
index 56254f04..be407589 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -32,7 +32,8 @@ pub struct PaneInfo {
pub rect: Rect,
/// Inner rect (content area, excluding borders). Used for selection.
pub inner_rect: Rect,
- /// Reserved scrollbar lane, when scrollback is visible.
+ /// Visible scrollbar lane, when scrollback is present. `inner_rect` may still
+ /// exclude a stable hidden gutter when this is `None`.
pub scrollbar_rect: Option<Rect>,
pub is_focused: bool,
}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 7a4a2c74..82be8bf1 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -11,6 +11,32 @@ use super::widgets::panel_contrast_fg;
use crate::app::state::Palette;
use crate::app::{AppState, Mode};
use crate::layout::PaneInfo;
+use crate::pane::PaneRuntime;
+
+fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) {
+ if pane_inner.width <= 4 {
+ return (pane_inner, None);
+ }
+
+ let inner_rect = Rect::new(
+ pane_inner.x,
+ pane_inner.y,
+ pane_inner.width.saturating_sub(1),
+ pane_inner.height,
+ );
+ let gutter = Rect::new(
+ pane_inner.x + pane_inner.width.saturating_sub(1),
+ pane_inner.y,
+ 1,
+ pane_inner.height,
+ );
+ let scrollbar_rect = rt
+ .scroll_metrics()
+ .filter(|metrics| should_show_scrollbar(*metrics))
+ .map(|_| gutter);
+
+ (inner_rect, scrollbar_rect)
+}
/// Compute pane layout info and optionally resize pane runtimes to match.
pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> Vec<PaneInfo> {
@@ -29,18 +55,7 @@ pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool)
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| should_show_scrollbar(metrics) && area.width > 1)
- {
- inner_rect.width = inner_rect.width.saturating_sub(1);
- scrollbar_rect = Some(Rect::new(
- area.x + area.width.saturating_sub(1),
- area.y,
- 1,
- area.height,
- ));
- }
+ (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area);
if resize_panes {
rt.resize(inner_rect.height, inner_rect.width);
}
@@ -74,18 +89,7 @@ pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool)
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| should_show_scrollbar(metrics) && 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),
- pane_inner.y,
- 1,
- pane_inner.height,
- ));
- }
+ (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner);
if resize_panes {
rt.resize(inner_rect.height, inner_rect.width);
}
@@ -222,6 +226,70 @@ mod tests {
use crate::workspace::Workspace;
#[tokio::test]
+ async fn pane_scrollbar_gutter_is_reserved_before_scrollback_exists() {
+ let mut app = AppState::test_new();
+ let mut workspace = Workspace::test_new("test");
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
+ );
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+
+ let area = Rect::new(10, 3, 40, 8);
+ let infos = compute_pane_infos(&app, area, false);
+ let info = &infos[0];
+
+ assert_eq!(info.rect, area);
+ assert_eq!(info.scrollbar_rect, None);
+ assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8));
+ }
+
+ #[tokio::test]
+ async fn zoomed_pane_scrollbar_gutter_is_reserved_before_scrollback_exists() {
+ let mut app = AppState::test_new();
+ let mut workspace = Workspace::test_new("test");
+ workspace.zoomed = true;
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
+ );
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+
+ let area = Rect::new(10, 3, 40, 8);
+ let infos = compute_pane_infos(&app, area, false);
+ let info = &infos[0];
+
+ assert_eq!(info.rect, area);
+ assert_eq!(info.scrollbar_rect, None);
+ assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8));
+ }
+
+ #[tokio::test]
+ async fn tiny_pane_does_not_reserve_scrollbar_gutter() {
+ let mut app = AppState::test_new();
+ let mut workspace = Workspace::test_new("test");
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ PaneRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"),
+ );
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+
+ let area = Rect::new(10, 3, 4, 8);
+ let infos = compute_pane_infos(&app, area, false);
+ let info = &infos[0];
+
+ assert_eq!(info.rect, area);
+ assert_eq!(info.scrollbar_rect, None);
+ assert_eq!(info.inner_rect, area);
+ }
+
+ #[tokio::test]
async fn pane_scrollbar_reserves_last_column_from_terminal_area() {
let mut app = AppState::test_new();
let mut workspace = Workspace::test_new("test");