mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(ui): give the right panel and tab rail an overlay scrollbar
The detail panel (Info / Outline / Changes / Files), the remote SFTP listing and the tab rail all scrolled with gpui's `overflow_y_scroll()`, which paints nothing: a deep file tree or a long tab list gave no hint that there was more content, or where in it you were (issue #185). Hang gpui-component's `Scrollbar` over each of those containers through one shared helper (`ui::scrollbar`). The handle stays app-owned so `scroll_to_item` keeps working — that rules out gpui-component's `overflow_y_scrollbar()`, which mints its own handle internally. Theme the bar from the active theme's own background→foreground mix ladder rather than the stock fixed greys, and keep the track transparent so the thumb floats instead of laying a slab down the panel edge. Show mode follows the platform: auto-hide on macOS (and anywhere else the OS uses overlay scrollbars), permanently visible on Windows and Linux. gpui-component's own default picks `Hover` there, which only reveals the bar within 16px of the edge — that is the "no scrollbar at all" the issue reports from Windows.
This commit is contained in:
+2
-1
@@ -543,7 +543,8 @@ pub struct Tty7App {
|
||||
pub(crate) right_panel_tab: RightPanelTab,
|
||||
pub(crate) sidebar_collapsed: bool,
|
||||
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
|
||||
/// row into view.
|
||||
/// row into view — and so the rail's overlay scrollbar has an offset to
|
||||
/// track and drag (see [`crate::ui::scrollbar`]).
|
||||
pub(crate) sidebar_scroll: gpui::ScrollHandle,
|
||||
/// `Some` while a tab / group is being dragged to a new position, in either
|
||||
/// the strip or the rail: the frozen geometry the live preview reflow is
|
||||
|
||||
+8
-3
@@ -1120,11 +1120,12 @@ impl Tty7App {
|
||||
} else {
|
||||
self.file_tree.search_rows()
|
||||
};
|
||||
v_flex()
|
||||
let column = v_flex()
|
||||
.id("right-panel-tree-rows")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.right_panel.tree_scroll)
|
||||
.px_1()
|
||||
.pb_1()
|
||||
// Keyboard nav (arrows / enter / rename) followed the tree out of the
|
||||
@@ -1136,8 +1137,12 @@ impl Tty7App {
|
||||
.children(
|
||||
rows.iter()
|
||||
.flat_map(|row| self.render_tree_row(row, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
);
|
||||
crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"right-panel-tree-scrollbar",
|
||||
column,
|
||||
&self.right_panel.tree_scroll,
|
||||
)
|
||||
}
|
||||
|
||||
/// One row (plus, when an inline edit targets it, the edit input row).
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod perf;
|
||||
pub mod presets;
|
||||
pub mod reorder;
|
||||
pub mod right_panel;
|
||||
pub mod scrollbar;
|
||||
pub mod settings;
|
||||
pub mod sftp;
|
||||
pub mod ssh_connect;
|
||||
|
||||
+20
-8
@@ -32,6 +32,7 @@ use crate::ui::app::{
|
||||
CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset,
|
||||
tile_trailing_inset_sm,
|
||||
};
|
||||
use crate::ui::scrollbar::with_vertical_scrollbar;
|
||||
|
||||
/// Bounds for the panel's width, mirroring the rail's: a floor so the tree never
|
||||
/// becomes an ellipsis parade, and a ceiling as a fraction of the window so a
|
||||
@@ -77,6 +78,13 @@ pub(crate) struct RightPanelState {
|
||||
/// already watching finishes connecting — and the loop reads this on each
|
||||
/// reschedule so it picks the change up on the next tick.
|
||||
pub(crate) procs_forwards: bool,
|
||||
/// Scroll position of the shared body container (Info / Outline / Changes),
|
||||
/// owned here rather than left to gpui's element-id state so the overlay
|
||||
/// scrollbar has a handle to read the offset from and to drag.
|
||||
pub(crate) scroll: gpui::ScrollHandle,
|
||||
/// The Files tab's local tree scrolls in its own container (it carries the
|
||||
/// tree's focus handle and key bindings), so it needs its own handle.
|
||||
pub(crate) tree_scroll: gpui::ScrollHandle,
|
||||
}
|
||||
|
||||
/// How often the Info tab re-queries processes and ports while it's open. Fast
|
||||
@@ -465,18 +473,22 @@ impl Tty7App {
|
||||
/// The body's scrolling area, so every tab shares one scroll container and
|
||||
/// one content inset.
|
||||
fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement {
|
||||
let body = div()
|
||||
.id("right-panel-body")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.right_panel.scroll)
|
||||
.child(inner);
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(title)
|
||||
.child(
|
||||
div()
|
||||
.id("right-panel-body")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.child(inner),
|
||||
)
|
||||
.child(with_vertical_scrollbar(
|
||||
"right-panel-body-scrollbar",
|
||||
body,
|
||||
&self.right_panel.scroll,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! The overlay scrollbar the app's own scroll areas wear (issue #185).
|
||||
//!
|
||||
//! gpui's `overflow_y_scroll()` scrolls but paints nothing, so a long file tree
|
||||
//! or tab list gave no hint that there was more content — or where in it you
|
||||
//! were. gpui-component ships the [`Scrollbar`] element for exactly this; the
|
||||
//! only thing missing was a house shape for hanging it off our containers.
|
||||
//!
|
||||
//! Why not gpui-component's own `overflow_y_scrollbar()` wrapper: it mints its
|
||||
//! own `ScrollHandle` internally via `use_keyed_state`, which leaves nothing for
|
||||
//! `scroll_to_item` to aim at. Our lists need programmatic scrolling (activating
|
||||
//! a tab pulls its row into view), so the handle stays app-owned and the
|
||||
//! scrollbar is layered on top of it.
|
||||
//!
|
||||
//! Appearance (thumb colour, whether it auto-hides) comes from the theme — see
|
||||
//! the scrollbar block in [`crate::ui::theme::apply_theme`].
|
||||
//!
|
||||
//! One behaviour worth knowing: while the bar is live it claims mouse-downs in
|
||||
//! the 16px strip along the container's right edge, so the trailing few pixels
|
||||
//! of a row stop being clickable there. That is inherent to an overlay
|
||||
//! scrollbar (macOS's own behave the same way) and the reason the bar is only
|
||||
//! permanently live on the platforms whose scrollbars are permanently visible.
|
||||
|
||||
use gpui::{AnyElement, ElementId, ScrollHandle, div, prelude::*};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::v_flex;
|
||||
|
||||
/// Wrap a scrolling column so a vertical scrollbar floats over its right edge.
|
||||
///
|
||||
/// `scroll_area` must be the element that carries `.overflow_y_scroll()` and
|
||||
/// `.track_scroll(handle)`; this only adds the positioned parent the scrollbar
|
||||
/// measures against and the absolute layer it paints into. The scrollbar draws
|
||||
/// *over* the content rather than reserving a gutter, so adopting it never
|
||||
/// reflows the wrapped list.
|
||||
///
|
||||
/// `id` names the scrollbar's element state (hover/drag/fade), so it must be
|
||||
/// unique per scroll area — the helper can't fall back to `Location::caller`
|
||||
/// the way [`Scrollbar::vertical`] does, since every call site would then share
|
||||
/// this function's line.
|
||||
pub(crate) fn with_vertical_scrollbar(
|
||||
id: impl Into<ElementId>,
|
||||
scroll_area: impl IntoElement,
|
||||
handle: &ScrollHandle,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(scroll_area)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.child(Scrollbar::vertical(handle).id(id)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
+10
-1
@@ -115,6 +115,9 @@ pub(crate) struct SftpPanelState {
|
||||
editing_path_sub: Vec<Subscription>,
|
||||
/// Bumped on every (re)open so a stale poll loop exits.
|
||||
pub(crate) poll_gen: u64,
|
||||
/// Scroll position of the remote listing, owned here so the Files tab's
|
||||
/// overlay scrollbar has a handle to read and drag (see `ui::scrollbar`).
|
||||
scroll: gpui::ScrollHandle,
|
||||
_subs: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -144,6 +147,7 @@ impl SftpPanelState {
|
||||
editing_path: None,
|
||||
editing_path_sub: Vec::new(),
|
||||
poll_gen: 0,
|
||||
scroll: gpui::ScrollHandle::new(),
|
||||
_subs: vec![sub],
|
||||
}
|
||||
}
|
||||
@@ -957,7 +961,11 @@ impl Tty7App {
|
||||
.child(breadcrumb)
|
||||
.child(filter)
|
||||
.children(form)
|
||||
.child(list)
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"sftp-list-scrollbar",
|
||||
list,
|
||||
&self.sftp_panel.scroll,
|
||||
))
|
||||
// FR-T5: a Finder drop uploads onto the current directory.
|
||||
.on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| {
|
||||
this.sftp_upload_paths(paths.paths().to_vec(), cx);
|
||||
@@ -1204,6 +1212,7 @@ impl Tty7App {
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.sftp_panel.scroll)
|
||||
.px(px(CONTENT_INSET - 6.))
|
||||
.pb(px(4.));
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ impl Tty7App {
|
||||
// An id + `overflow_y_scroll` makes the row column scroll on its own
|
||||
// when the tabs outgrow the window height, leaving the "+" footer
|
||||
// pinned (same pattern the settings panel uses). `track_scroll` lets
|
||||
// `activate` pull the selected row into view.
|
||||
// `activate` pull the selected row into view — and feeds the overlay
|
||||
// scrollbar the wrapper below hangs over this column.
|
||||
.id("tab-sidebar-list")
|
||||
.track_scroll(&self.sidebar_scroll)
|
||||
.flex_1()
|
||||
@@ -962,7 +963,11 @@ impl Tty7App {
|
||||
.on_double_click(|_, window, _| window.titlebar_double_click())
|
||||
})
|
||||
.child(top_bar)
|
||||
.child(list),
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"tab-sidebar-scrollbar",
|
||||
list,
|
||||
&self.sidebar_scroll,
|
||||
)),
|
||||
)
|
||||
.child(handle)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use gpui::{
|
||||
App, Background, Hsla, Menu, MenuItem, OsAction, Pixels, Point, SystemMenuType, Window,
|
||||
WindowBackgroundAppearance, linear_color_stop, linear_gradient, point, px, rgb,
|
||||
};
|
||||
use gpui_component::scroll::ScrollbarShow;
|
||||
use gpui_component::{Theme, ThemeMode};
|
||||
|
||||
use crate::core::actions::*;
|
||||
@@ -374,6 +375,11 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
}
|
||||
let m = theme.neutrals();
|
||||
let active = theme.active_palette();
|
||||
// Read before `Theme::global_mut` borrows `cx`. macOS reports the overlay /
|
||||
// legacy scroller preference here; Windows reports the accessibility
|
||||
// "always show scrollbars" setting (false by default) and Linux always
|
||||
// false — which is exactly the platform split we want below.
|
||||
let auto_hide_scrollbars = cx.should_auto_hide_scrollbars();
|
||||
|
||||
// Never `Opaque`: on macOS 26 (Tahoe) flipping a window's opacity after
|
||||
// creation doesn't reach the compositor — the window keeps compositing
|
||||
@@ -470,6 +476,38 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
t.caret = rgb(m.caret).into();
|
||||
t.selection = rgb(m.selection).into(); // text selection highlight
|
||||
|
||||
// Overlay scrollbars (right panel, file tree, tab rail — see
|
||||
// `ui::scrollbar`). gpui-component's `Scrollbar` paints the thumb from
|
||||
// `tokens.scrollbar_thumb{,_hover}` and the track from the plain
|
||||
// `scrollbar` field; the stock values are fixed neutral greys per light/dark
|
||||
// mode, so on a tinted theme the thumb reads as a foreign grey. Derive both
|
||||
// from this theme's own foreground instead — the same background→foreground
|
||||
// mix ladder the borders and chips use.
|
||||
//
|
||||
// The track stays fully transparent: the thumb floats over the content the
|
||||
// way macOS overlay scrollbars do, and a filled channel would put a vertical
|
||||
// slab down the edge of every panel.
|
||||
let scrollbar_thumb: Hsla = rgb(presets::mix(m.background, m.foreground, 0.26)).into();
|
||||
let scrollbar_thumb_hover: Hsla = rgb(presets::mix(m.background, m.foreground, 0.42)).into();
|
||||
t.scrollbar = gpui::transparent_black();
|
||||
t.scrollbar_thumb = scrollbar_thumb;
|
||||
t.scrollbar_thumb_hover = scrollbar_thumb_hover;
|
||||
t.tokens.scrollbar = gpui::transparent_black().into();
|
||||
t.tokens.scrollbar_thumb = scrollbar_thumb.into();
|
||||
t.tokens.scrollbar_thumb_hover = scrollbar_thumb_hover.into();
|
||||
|
||||
// Follow the platform: auto-hide (fade in on scroll, out when idle) where
|
||||
// the OS uses overlay scrollbars — the macOS default — and stay permanently
|
||||
// visible where it doesn't, which is Windows and Linux. gpui-component's own
|
||||
// `sync_scrollbar_appearance` picks `Hover` for that second case, meaning the
|
||||
// bar only appears once the pointer is within 16px of the panel edge; that's
|
||||
// the "no scrollbar at all" report from issue #185 on Windows.
|
||||
t.scrollbar_show = if auto_hide_scrollbars {
|
||||
ScrollbarShow::Scrolling
|
||||
} else {
|
||||
ScrollbarShow::Always
|
||||
};
|
||||
|
||||
// Round every gpui-component widget (buttons, inputs, selects, switches,
|
||||
// segmented controls, menus) to match the shell's own hand-rolled chrome,
|
||||
// which uses `rounded_lg` (8px) for tab chips, title-bar tiles and the
|
||||
|
||||
Reference in New Issue
Block a user