From 2139497f91ab8f996bf22f584d8aed314e3d5c03 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 14 Oct 2026 15:09:35 -0400 Subject: [PATCH 1/2] Move to custom window chrome The title bar and the app's own top bar become one 40 pt strip: the window controls, the real app icon and wordmark, then the trail and the view settings. The four-square logo is gone. macOS keeps its stoplights, in a transparent titlebar, centred in the strip. Windows paints its caption buttons on the right and hit-tests them as the system's own, so snap layouts still work. On Linux the compositor keeps drawing the frame where it offers one (Hyprland, KDE); where it asks the client to (GNOME on Wayland), the strip draws close, minimize and maximize on the side the desktop's button layout says, and the window gets a resize ring and a border until it is tiled. The treemap still owns the keyboard from the first frame; the minimum size and the Wayland app_id are unchanged. --- AGENTS.md | 1 + crates/disktree-app/src/chrome.rs | 696 ++++++++++++++++++++++++++++++ crates/disktree-app/src/main.rs | 24 +- crates/disktree-app/src/state.rs | 23 +- crates/disktree-app/src/tests.rs | 239 ++++++++++ crates/disktree-app/src/ui.rs | 40 ++ crates/disktree-app/src/views.rs | 107 ++--- 7 files changed, 1065 insertions(+), 65 deletions(-) create mode 100644 crates/disktree-app/src/chrome.rs diff --git a/AGENTS.md b/AGENTS.md index a567ce80..d45f6d41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,7 @@ and `cargo build --release` directly; CI runs the gate on both systems. | the mosaic's painting or labels | `crates/disktree-app/src/treemap_view.rs` | | layout of a screen | `crates/disktree-app/src/views.rs` | | colours derived from the theme | `crates/disktree-app/src/palette.rs` | +| the title strip, window controls, the client-side frame | `crates/disktree-app/src/chrome.rs` | ## Verification expectations diff --git a/crates/disktree-app/src/chrome.rs b/crates/disktree-app/src/chrome.rs new file mode 100644 index 00000000..1ac96108 --- /dev/null +++ b/crates/disktree-app/src/chrome.rs @@ -0,0 +1,696 @@ +//! The window's title strip, and the frame around the window where disktree +//! has to draw one. +//! +//! The native title bar is gone wherever the platform lets it go: the strip +//! is the title bar, the masthead and the view settings in one band. Each +//! end of it may hold window controls — the stoplights on the left on +//! macOS, painted caption buttons on the right on Windows, and, under a Linux +//! compositor that leaves the frame to the client, whichever buttons its +//! `button-layout` asks for on whichever side it names. That is the one +//! platform decision: [`Frame`] says what each end holds, and the strip lays +//! the brand and its contents out between them. +//! +//! The window title is still set and kept current (see `state.rs`): the +//! Window menu, Mission Control, the taskbar and Waybar read it. It is only +//! no longer painted. + +use std::sync::{Arc, LazyLock}; + +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + AnyElement, Context, CursorStyle, Decorations, Div, FontWeight, Hsla, + Image, ImageFormat, InteractiveElement as _, IntoElement as _, MouseButton, + MouseDownEvent, MouseMoveEvent, ParentElement as _, ResizeEdge, Stateful, + StatefulInteractiveElement as _, Styled as _, Tiling, Window, WindowButton, + WindowButtonLayout, WindowControlArea, WindowControls, div, img, px, rgb, + svg, white, +}; +use gpui_omarchy::{ActiveTheme as _, IconName, Theme}; + +use crate::state::Disktree; +use crate::ui::{chrome, space}; + +/// Which system the window is on, as far as its frame is concerned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + Windows, + Linux, +} + +impl Platform { + pub const fn current() -> Self { + if cfg!(target_os = "macos") { + Self::MacOs + } else if cfg!(windows) { + Self::Windows + } else { + Self::Linux + } + } +} + +/// What one end of the strip holds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Controls { + /// Nothing: the strip's own padding. + None, + /// The macOS stoplights. The system draws them; the strip leaves room. + Stoplights, + /// Window buttons disktree paints, in this order. + Buttons(Vec), +} + +/// The window's frame: what each end of the strip holds, and whether +/// disktree draws the edges too. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame { + pub platform: Platform, + pub leading: Controls, + pub trailing: Controls, + /// Set when disktree draws the whole frame — a Linux compositor that + /// offers no server-side decorations, such as GNOME's — with the sides + /// the compositor has tiled, which keep no resize ring. + pub client_side: Option, +} + +/// GPUI's own fallback when the desktop's setting cannot be read: every +/// button, on the right. It only ever reports none off Linux. +const FALLBACK_LAYOUT: WindowButtonLayout = WindowButtonLayout { + left: [None; 3], + right: [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close), + ], +}; + +impl Frame { + /// The frame this window has now. + pub fn detect(window: &Window, cx: &gpui_kit::App) -> Self { + Self::new( + Platform::current(), + window.window_decorations(), + cx.button_layout(), + window.window_controls(), + window.is_fullscreen(), + ) + } + + pub fn new( + platform: Platform, + decorations: Decorations, + layout: Option, + controls: WindowControls, + fullscreen: bool, + ) -> Self { + let none = Self { + platform, + leading: Controls::None, + trailing: Controls::None, + client_side: None, + }; + match platform { + // In full screen AppKit takes the stoplights away with the menu + // bar, and the room kept for them would be a dead gap. + Platform::MacOs if fullscreen => none, + Platform::MacOs => Self { + leading: Controls::Stoplights, + ..none + }, + // GPUI removes the caption and draws no buttons in its place. + Platform::Windows => Self { + trailing: Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]), + ..none + }, + // Hyprland and KDE grant server-side decorations: Hyprland draws + // no frame by design and KDE draws its own, so the strip keeps + // out of both. + Platform::Linux => match decorations { + Decorations::Server => none, + Decorations::Client { tiling } => { + let layout = layout.unwrap_or(FALLBACK_LAYOUT); + // A button for something the compositor will not do + // would do nothing; close is always honoured. + let side = |buttons: [Option; 3]| { + let shown: Vec<_> = buttons + .into_iter() + .flatten() + .filter(|button| match button { + WindowButton::Minimize => controls.minimize, + WindowButton::Maximize => controls.maximize, + WindowButton::Close => true, + }) + .collect(); + if shown.is_empty() { + Controls::None + } else { + Controls::Buttons(shown) + } + }; + Self { + leading: side(layout.left), + trailing: side(layout.right), + client_side: Some(tiling), + ..none + } + } + }, + } + } + + /// Whether dragging the strip has to move the window by asking for it. + /// Windows does it from the caption area it is told about, and a + /// compositor that draws the frame has its own way to move a window. + const fn moves_itself(&self) -> bool { + match self.platform { + Platform::MacOs => true, + Platform::Windows => false, + Platform::Linux => self.client_side.is_some(), + } + } +} + +/// The app icon, from the same file the Dock, the desktop entry and the +/// installer use, so the window shows the mark people already know. +static MARK: LazyLock> = LazyLock::new(|| { + Arc::new(Image::from_bytes( + ImageFormat::Svg, + include_bytes!("../../../assets/disktree.svg").to_vec(), + )) +}); + +/// Windows' own close-button red, which it uses whatever the theme. +const CLOSE_RED: u32 = 0x00c4_2b1c; + +/// The strip: window controls at either end, the brand, then `content` — +/// the trail and the settings on explore, nothing on the other screens, +/// which carry their own heading. +pub fn strip( + frame: &Frame, + wordmark: bool, + content: impl gpui_kit::IntoElement, + window: &Window, + cx: &Context<'_, Disktree>, +) -> Stateful
{ + let theme = cx.omarchy().clone(); + let leading = controls(frame, &frame.leading, "leading", window, cx); + let trailing = controls(frame, &frame.trailing, "trailing", window, cx); + div() + .id("title-strip") + .debug_selector(|| "title-strip".into()) + .flex() + .flex_row() + .items_center() + .flex_shrink_0() + // The band, then the divider under it: a divider inside the band + // would leave an odd height the stoplights cannot centre in. + .min_h(chrome::STRIP + px(1.)) + .border_b_1() + .border_color(theme.divider()) + .when(frame.leading == Controls::None, |strip| strip.pl(space::LG)) + .when(frame.trailing == Controls::None, |strip| strip.pr(space::LG)) + .children(leading) + .child( + drag_area(frame, "strip-brand", cx) + .flex_shrink_0() + .child(brand(wordmark, &theme)), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .flex_1() + .min_w_0() + .ml(space::LG) + .when(frame.trailing != Controls::None, |row| { + row.mr(space::SM) + }) + .child(content), + ) + .children(trailing) +} + +/// Part of the strip that moves the window: everything that is not a +/// control. On Windows the system is told where its caption is and does +/// the rest — moving, snapping, maximizing on a double click and the system +/// menu on a right click. +pub fn drag_area( + frame: &Frame, + id: &'static str, + cx: &Context<'_, Disktree>, +) -> Stateful
{ + let area = div().id(id).debug_selector(move || id.into()); + let platform = frame.platform; + if platform == Platform::Windows { + return area.window_control_area(WindowControlArea::Drag); + } + if !frame.moves_itself() { + return area; + } + area.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, _| { + this.strip_drag = event.click_count < 2; + if event.click_count >= 2 { + if platform == Platform::MacOs { + // Zoom, minimize or nothing, as System Settings says. + window.titlebar_double_click(); + } else { + window.zoom_window(); + } + } + }), + ) + .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, _| { + // The move starts on the first motion, not on the press, so that + // a second press can still arrive as a double click. + if this.strip_drag && event.pressed_button == Some(MouseButton::Left) { + this.strip_drag = false; + window.start_window_move(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| this.strip_drag = false), + ) + .when(platform == Platform::Linux, |area| { + area.on_mouse_down(MouseButton::Right, |event, window, _| { + window.show_window_menu(event.position); + }) + }) +} + +/// The icon and the name, centred on the band. +fn brand(wordmark: bool, theme: &Theme) -> Div { + div() + .flex() + .flex_row() + .items_center() + .gap(chrome::LOCKUP_GAP) + .child(img(MARK.clone()).size(chrome::MARK).flex_shrink_0()) + .when(wordmark, |brand| { + brand.child( + div() + .text_size(chrome::WORDMARK) + .line_height(chrome::WORDMARK) + .font_weight(FontWeight::BOLD) + .text_color(theme.bright) + .whitespace_nowrap() + .child("disktree"), + ) + }) +} + +/// One end of the strip. +fn controls( + frame: &Frame, + controls: &Controls, + end: &'static str, + window: &Window, + cx: &Context<'_, Disktree>, +) -> Option { + let selector = if end == "leading" { + "window-controls-leading" + } else { + "window-controls-trailing" + }; + let theme = cx.omarchy().clone(); + let maximized = window.is_maximized(); + match controls { + Controls::None => None, + Controls::Stoplights => Some( + drag_area(frame, selector, cx) + .w(chrome::STOPLIGHT_ZONE) + .h(chrome::STRIP) + .flex_shrink_0() + .into_any_element(), + ), + Controls::Buttons(buttons) => { + let row = div() + .id(selector) + .debug_selector(move || selector.into()) + .flex() + .flex_row() + .items_center() + .flex_shrink_0(); + let row = + if frame.platform == Platform::Windows { + row.children(buttons.iter().map(|&button| { + caption_button(button, maximized, &theme) + })) + } else { + row.gap(space::SM).px(space::SM).children( + buttons.iter().map(|&button| { + window_button(button, maximized, &theme) + }), + ) + }; + Some(row.into_any_element()) + } + } +} + +/// A Windows caption button. It has no click handler: the system hit-tests +/// it as the real button, so hovering maximize offers snap layouts and the +/// press does what Windows does. +fn caption_button( + button: WindowButton, + maximized: bool, + theme: &Theme, +) -> Stateful
{ + let group = button.id(); + let area = match button { + WindowButton::Minimize => WindowControlArea::Min, + WindowButton::Maximize => WindowControlArea::Max, + WindowButton::Close => WindowControlArea::Close, + }; + let close = button == WindowButton::Close; + let hover = theme.hover_fill(); + let ink = theme.foreground; + let lit = if close { white() } else { ink }; + div() + .id(button.id()) + .group(group) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .w(chrome::CAPTION_BUTTON) + .h(chrome::STRIP) + .window_control_area(area) + .hover(move |style| { + if close { + style.bg(rgb(CLOSE_RED)) + } else { + style.bg(hover) + } + }) + .child(glyph(button, maximized, ink, group, lit)) +} + +/// A painted Linux window button. Square, like every other surface here. +fn window_button( + button: WindowButton, + maximized: bool, + theme: &Theme, +) -> Stateful
{ + let group = button.id(); + let rest = theme.foreground.opacity(0.08); + let hover = theme.foreground.opacity(0.16); + div() + .id(button.id()) + .group(group) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .size(chrome::WINDOW_BUTTON) + .bg(rest) + .hover(move |style| style.bg(hover)) + .on_click(move |_, window, _| match button { + WindowButton::Minimize => window.minimize_window(), + WindowButton::Maximize => window.zoom_window(), + WindowButton::Close => window.remove_window(), + }) + .child(glyph(button, maximized, theme.bright, group, theme.bright)) +} + +/// The mark on a window button, drawn rather than taken from a font so it +/// looks the same everywhere; `lit` is its colour while `group` is hovered. +fn glyph( + button: WindowButton, + maximized: bool, + ink: Hsla, + group: &'static str, + lit: Hsla, +) -> AnyElement { + match button { + WindowButton::Minimize => div() + .w(chrome::GLYPH) + .h(px(1.)) + .bg(ink) + .group_hover(group, move |style| style.bg(lit)) + .into_any_element(), + // Restore: the window it would go back to, behind the one it is. + WindowButton::Maximize if maximized => div() + .relative() + .size(chrome::GLYPH) + .child( + div() + .absolute() + .top_0() + .right_0() + .size(chrome::GLYPH_BEHIND) + .border_t_1() + .border_r_1() + .border_color(ink), + ) + .child( + div() + .absolute() + .bottom_0() + .left_0() + .size(chrome::GLYPH_BEHIND) + .border_1() + .border_color(ink), + ) + .into_any_element(), + WindowButton::Maximize => div() + .size(chrome::GLYPH) + .border_1() + .border_color(ink) + .group_hover(group, move |style| style.border_color(lit)) + .into_any_element(), + WindowButton::Close => svg() + .path(IconName::Close.path()) + .size(chrome::CLOSE_GLYPH) + .text_color(ink) + .group_hover(group, move |style| style.text_color(lit)) + .into_any_element(), + } +} + +/// Wrap the window's content in the frame a client-side window needs: a +/// ring for the shadow and the resize grip on every side the compositor has +/// not tiled, and a hairline border. Anywhere else the content is the whole +/// window. +pub fn client_frame( + frame: &Frame, + content: Stateful
, + window: &mut Window, + theme: &Theme, +) -> AnyElement { + let Some(tiling) = frame.client_side else { + return content.into_any_element(); + }; + let ring = chrome::RESIZE_RING; + if window.client_inset() != Some(ring) { + window.set_client_inset(ring); + } + let open = |tiled: bool| if tiled { px(0.) } else { ring }; + let mut outer = div() + .id("client-frame") + .debug_selector(|| "client-frame".into()) + .relative() + .size_full() + .pt(open(tiling.top)) + .pb(open(tiling.bottom)) + .pl(open(tiling.left)) + .pr(open(tiling.right)) + .child( + div() + .size_full() + .border_1() + .border_color(theme.border) + .when(!tiling.is_tiled(), gpui_kit::Styled::shadow_lg) + .child(content), + ); + for (edge, sides) in [ + (ResizeEdge::Top, [tiling.top]), + (ResizeEdge::Bottom, [tiling.bottom]), + (ResizeEdge::Left, [tiling.left]), + (ResizeEdge::Right, [tiling.right]), + ] { + if !sides[0] { + outer = outer.child(grip(edge, ring)); + } + } + for (edge, a, b) in [ + (ResizeEdge::TopLeft, tiling.top, tiling.left), + (ResizeEdge::TopRight, tiling.top, tiling.right), + (ResizeEdge::BottomLeft, tiling.bottom, tiling.left), + (ResizeEdge::BottomRight, tiling.bottom, tiling.right), + ] { + if !a && !b { + outer = outer.child(grip(edge, ring)); + } + } + outer.into_any_element() +} + +/// One resize handle in the ring, with the cursor that says so. +fn grip(edge: ResizeEdge, ring: gpui_kit::Pixels) -> Stateful
{ + let (cursor, id) = match edge { + ResizeEdge::Top => (CursorStyle::ResizeUpDown, "grip-top"), + ResizeEdge::Bottom => (CursorStyle::ResizeUpDown, "grip-bottom"), + ResizeEdge::Left => (CursorStyle::ResizeLeftRight, "grip-left"), + ResizeEdge::Right => (CursorStyle::ResizeLeftRight, "grip-right"), + ResizeEdge::TopLeft => { + (CursorStyle::ResizeUpLeftDownRight, "grip-top-left") + } + ResizeEdge::BottomRight => { + (CursorStyle::ResizeUpLeftDownRight, "grip-bottom-right") + } + ResizeEdge::TopRight => { + (CursorStyle::ResizeUpRightDownLeft, "grip-top-right") + } + ResizeEdge::BottomLeft => { + (CursorStyle::ResizeUpRightDownLeft, "grip-bottom-left") + } + }; + let grip = div() + .id(id) + .debug_selector(move || id.into()) + .absolute() + .cursor(cursor) + .on_mouse_down(MouseButton::Left, move |_, window, _| { + window.start_window_resize(edge); + }); + // Edges run between the corners; corners are a ring-sized square. + match edge { + ResizeEdge::Top => grip.top_0().left(ring).right(ring).h(ring), + ResizeEdge::Bottom => grip.bottom_0().left(ring).right(ring).h(ring), + ResizeEdge::Left => grip.left_0().top(ring).bottom(ring).w(ring), + ResizeEdge::Right => grip.right_0().top(ring).bottom(ring).w(ring), + ResizeEdge::TopLeft => grip.top_0().left_0().size(ring), + ResizeEdge::TopRight => grip.top_0().right_0().size(ring), + ResizeEdge::BottomLeft => grip.bottom_0().left_0().size(ring), + ResizeEdge::BottomRight => grip.bottom_0().right_0().size(ring), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame( + platform: Platform, + decorations: Decorations, + layout: Option, + ) -> Frame { + Frame::new( + platform, + decorations, + layout, + WindowControls::default(), + false, + ) + } + + const CLIENT: Decorations = Decorations::Client { + tiling: Tiling { + top: false, + left: false, + right: false, + bottom: false, + }, + }; + + #[test] + fn macos_leaves_room_for_the_stoplights_on_the_left() { + let mac = frame(Platform::MacOs, Decorations::Server, None); + assert_eq!(mac.leading, Controls::Stoplights); + assert_eq!(mac.trailing, Controls::None); + assert!(mac.client_side.is_none()); + let full = Frame::new( + Platform::MacOs, + Decorations::Server, + None, + WindowControls::default(), + true, + ); + assert_eq!(full.leading, Controls::None, "no dead gap in full screen"); + } + + #[test] + fn windows_paints_its_caption_buttons_on_the_right() { + let windows = frame(Platform::Windows, Decorations::Server, None); + assert_eq!(windows.leading, Controls::None); + assert_eq!( + windows.trailing, + Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]) + ); + } + + #[test] + fn a_linux_compositor_that_draws_the_frame_gets_no_controls() { + let hyprland = frame(Platform::Linux, Decorations::Server, None); + assert_eq!(hyprland.leading, Controls::None); + assert_eq!(hyprland.trailing, Controls::None); + assert!(hyprland.client_side.is_none()); + } + + #[test] + fn a_client_side_frame_follows_the_desktops_button_layout() { + // GNOME's "close,minimize:maximize": two on the left, one on the + // right, in that order. + let layout = WindowButtonLayout { + left: [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None, + ], + right: [Some(WindowButton::Maximize), None, None], + }; + let gnome = frame(Platform::Linux, CLIENT, Some(layout)); + assert_eq!( + gnome.leading, + Controls::Buttons(vec![ + WindowButton::Close, + WindowButton::Minimize + ]) + ); + assert_eq!( + gnome.trailing, + Controls::Buttons(vec![WindowButton::Maximize]) + ); + assert!(gnome.client_side.is_some()); + + // Unreadable: every button on the right. + let fallback = frame(Platform::Linux, CLIENT, None); + assert_eq!(fallback.leading, Controls::None); + assert_eq!( + fallback.trailing, + Controls::Buttons(vec![ + WindowButton::Minimize, + WindowButton::Maximize, + WindowButton::Close, + ]) + ); + } + + #[test] + fn buttons_for_what_the_compositor_will_not_do_are_left_out() { + let controls = WindowControls { + fullscreen: false, + maximize: false, + minimize: false, + window_menu: true, + }; + let frame = Frame::new(Platform::Linux, CLIENT, None, controls, false); + assert_eq!( + frame.trailing, + Controls::Buttons(vec![WindowButton::Close]) + ); + } +} diff --git a/crates/disktree-app/src/main.rs b/crates/disktree-app/src/main.rs index 92fa2f76..0a15a2d7 100644 --- a/crates/disktree-app/src/main.rs +++ b/crates/disktree-app/src/main.rs @@ -13,6 +13,7 @@ mod app_menu; mod appearance; +mod chrome; mod git; mod marks; mod palette; @@ -161,8 +162,22 @@ fn run() -> Result<()> { ) .into(), ), - ..Default::default() + // The title strip is the title bar: macOS and + // Windows give it the top of the window, and + // AppKit puts its stoplights into it, centred on + // the band. Linux reads neither; there the + // compositor decides (see `chrome::Frame`). + appears_transparent: true, + traffic_light_position: Some(gpui_kit::point( + px(ui::chrome::STOPLIGHTS.0), + px(ui::chrome::STOPLIGHTS.1), + )), }), + // On macOS the strip moves the window itself, so + // AppKit does not hold back clicks on the trail and + // the settings to see whether they are a double + // click on the title bar. + app_owns_titlebar_drag: true, // Wayland app id. Hyprland reports it as the window // class, and the desktop entry's StartupWMClass and // the documented window rule both match `disktree`. @@ -179,6 +194,13 @@ fn run() -> Result<()> { appearance::follow(window); } cx.new(|cx| { + // A desktop that moves its window buttons to + // the other side moves them here too. + cx.observe_button_layout_changed( + window, + |_, _, cx| cx.notify(), + ) + .detach(); let mut app = Disktree::new( root_for_app.clone(), options.clone(), diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index ee351f3a..ac98d6db 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -30,7 +30,7 @@ use gpui_kit::{ MouseMoveEvent, NavigationDirection, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Size, Window, px, size, }; -use gpui_omarchy::Status; +use gpui_omarchy::{ActiveTheme as _, Status}; use rustc_hash::{FxHashMap, FxHashSet}; use crate::git::GitState; @@ -378,8 +378,17 @@ pub struct Disktree { /// Focus to move on the next occasion a window is in hand. Key handling /// has no window, and opening or closing the dialog must move focus. pub focus_request: Option, - /// What the titlebar says, so it is only set when it changes. + /// What the titlebar says, so it is only set when it changes. The strip + /// paints no title, but the Window menu, Mission Control, the taskbar + /// and Waybar still read it. window_title: String, + /// The left button went down on the title strip and has not moved yet: + /// the first motion starts a window move. + pub strip_drag: bool, + /// A window frame to draw instead of asking the platform. The + /// window-harness tests pin one per layout: their window has no + /// compositor to ask. + pub chrome_pin: Option, /// The window's `rem` in pixels, read each frame. The mosaic is laid out /// in pixels, so its header band and label thresholds are scaled by this /// to follow interface zoom like the rest of the interface. @@ -511,6 +520,8 @@ impl Disktree { confirm_focus: cx.focus_handle(), focus_request: None, window_title: String::new(), + strip_drag: false, + chrome_pin: None, rem: crate::ui::BASE_REM, run: None, run_epoch: 0, @@ -3202,7 +3213,13 @@ impl Render for Disktree { window.set_window_title(&title); self.window_title = title; } - crate::views::root(self, window, cx) + let frame = self + .chrome_pin + .clone() + .unwrap_or_else(|| crate::chrome::Frame::detect(window, cx)); + let root = crate::views::root(self, &frame, window, cx); + let theme = cx.omarchy().clone(); + crate::chrome::client_frame(&frame, root, window, &theme) } } diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index 3d7f151b..a9938411 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -1844,3 +1844,242 @@ fn the_power_menu_offers_only_what_the_cpus_can_tell_apart( draw(cx); assert!(read(&view, cx, |app| app.power_menu.is_none())); } + +/// Every frame the title strip can be drawn in, as the platforms report +/// them: the window harness has no compositor to ask, so each is pinned. +fn window_frames() -> Vec<(&'static str, crate::chrome::Frame)> { + use crate::chrome::{Frame, Platform}; + use gpui_kit::{ + Decorations, Tiling, WindowButton, WindowButtonLayout, WindowControls, + }; + + let frame = |platform, decorations, layout, fullscreen| { + Frame::new( + platform, + decorations, + layout, + WindowControls::default(), + fullscreen, + ) + }; + let floating = Decorations::Client { + tiling: Tiling::default(), + }; + // Close, minimize, maximize on the left: the layout that puts the + // controls where macOS has its stoplights. + let on_the_left = WindowButtonLayout { + left: [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + ], + right: [None; 3], + }; + vec![ + ( + "macOS", + frame(Platform::MacOs, Decorations::Server, None, false), + ), + ( + "macOS full screen", + frame(Platform::MacOs, Decorations::Server, None, true), + ), + ( + "Windows", + frame(Platform::Windows, Decorations::Server, None, false), + ), + ( + "Hyprland or KDE", + frame(Platform::Linux, Decorations::Server, None, false), + ), + ( + "GNOME, controls right", + frame(Platform::Linux, floating, None, false), + ), + ( + "GNOME, controls left", + frame(Platform::Linux, floating, Some(on_the_left), false), + ), + ( + "GNOME, maximized", + frame( + Platform::Linux, + Decorations::Client { + tiling: Tiling::tiled(), + }, + None, + false, + ), + ), + ] +} + +#[gpui_kit::test] +fn the_title_strip_draws_on_every_window_frame(cx: &mut TestAppContext) { + use crate::chrome::Controls; + use crate::ui::chrome; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + for (name, frame) in window_frames() { + for screen in [Screen::Explore, Screen::Review, Screen::Done] { + update(&view, cx, |app, cx| { + app.chrome_pin = Some(frame.clone()); + app.screen = screen; + cx.notify(); + }); + draw(cx); + + let strip = cx.debug_bounds("title-strip").expect(name); + // The band the stoplights centre in, then the divider below it. + assert_eq!( + strip.size.height, + chrome::STRIP + px(1.), + "{name}, {screen:?}" + ); + let brand = cx.debug_bounds("strip-brand").expect(name); + assert!(brand.size.width > px(0.), "{name}: the brand is drawn"); + + // The brand stays clear of the controls at either end. + match &frame.leading { + Controls::None => assert!(brand.left() > strip.left()), + Controls::Stoplights => assert!( + brand.left() - strip.left() >= chrome::STOPLIGHT_ZONE, + "{name}: brand at {:?}", + brand.left() - strip.left() + ), + Controls::Buttons(_) => { + let controls = + cx.debug_bounds("window-controls-leading").expect(name); + assert!(controls.left() >= strip.left()); + assert!(brand.left() >= controls.right(), "{name}"); + } + } + if let Controls::Buttons(_) = &frame.trailing { + let controls = + cx.debug_bounds("window-controls-trailing").expect(name); + assert!( + (strip.right() - controls.right()).abs() <= px(1.), + "{name}: the controls sit at the right edge" + ); + assert!(brand.right() < controls.left(), "{name}"); + } + } + } +} + +#[gpui_kit::test] +fn a_client_side_frame_keeps_a_resize_ring_until_it_is_tiled( + cx: &mut TestAppContext, +) { + use crate::ui::chrome; + + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + let frames = window_frames(); + let pin = |name: &str| { + frames + .iter() + .find(|(each, _)| *each == name) + .map(|(_, frame)| frame.clone()) + }; + + update(&view, cx, |app, cx| { + app.chrome_pin = pin("GNOME, controls right"); + cx.notify(); + }); + draw(cx); + let root = cx.debug_bounds("disktree-root").expect("root"); + let frame = cx.debug_bounds("client-frame").expect("client frame"); + // The ring, then the window's hairline border. + let inset = chrome::RESIZE_RING + px(1.); + assert_eq!(root.left() - frame.left(), inset); + assert_eq!(root.top() - frame.top(), inset); + for grip in ["grip-top", "grip-bottom-right", "grip-left"] { + assert!(cx.debug_bounds(grip).is_some(), "{grip}"); + } + + // Maximized or tiled, the compositor owns the edges: no ring, no grips. + update(&view, cx, |app, cx| { + app.chrome_pin = pin("GNOME, maximized"); + cx.notify(); + }); + draw(cx); + let root = cx.debug_bounds("disktree-root").expect("root"); + let frame = cx.debug_bounds("client-frame").expect("client frame"); + assert_eq!(root.left() - frame.left(), px(1.)); + assert_eq!(root.top() - frame.top(), px(1.)); + assert!(cx.debug_bounds("grip-top").is_none()); + + // Where the compositor draws the frame, the content is the window. + update(&view, cx, |app, cx| { + app.chrome_pin = pin("Hyprland or KDE"); + cx.notify(); + }); + draw(cx); + assert!(cx.debug_bounds("client-frame").is_none()); +} + +#[gpui_kit::test] +fn the_wordmark_gives_way_in_a_narrow_window(cx: &mut TestAppContext) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames().first().map(|(_, f)| f.clone()); + cx.notify(); + }); + let brand_width = |cx: &mut Window| { + draw(cx); + cx.debug_bounds("strip-brand").expect("brand").size.width + }; + + cx.simulate_resize(gpui_kit::size(px(1440.), px(900.))); + let wide = brand_width(cx); + cx.simulate_resize(gpui_kit::size(px(900.), px(600.))); + let narrow = brand_width(cx); + assert!( + narrow < wide / 2., + "only the mark is left at 900 pt: {narrow:?} of {wide:?}" + ); + // The trail keeps the crumb you are in. + assert!(cx.debug_bounds("title-strip").is_some()); +} + +#[gpui_kit::test] +fn a_press_on_the_strip_arms_a_move_and_a_click_disarms_it( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames().first().map(|(_, f)| f.clone()); + cx.notify(); + }); + draw(cx); + let empty = cx.debug_bounds("strip-drag").expect("empty strip"); + let at = empty.center(); + let none = gpui_kit::Modifiers::none(); + let left = gpui_kit::MouseButton::Left; + + // A press arms the move. The move itself, on the first motion with + // the button down, cannot run here: the test platform leaves + // `start_window_move` unimplemented. + cx.simulate_mouse_down(at, left, none); + assert!(read(&view, cx, |app| app.strip_drag)); + + // Released without moving, it was a click, and leaves nothing armed + // for the next time the pointer passes over with a button held. + cx.simulate_mouse_up(at, left, none); + assert!(!read(&view, cx, |app| app.strip_drag)); + + // The keyboard still belongs to the treemap. + let focused = cx.update(|window, cx| { + let focus = view.read(cx).focus.clone(); + focus.is_focused(window) + }); + assert!(focused, "a press on the strip leaves the treemap focused"); +} diff --git a/crates/disktree-app/src/ui.rs b/crates/disktree-app/src/ui.rs index ea715d25..1ba13e7c 100644 --- a/crates/disktree-app/src/ui.rs +++ b/crates/disktree-app/src/ui.rs @@ -102,6 +102,46 @@ pub mod size { pub const KEY_LANE: Rems = Rems(7.0); } +/// The title strip across the top of the window. +/// +/// Pixels, not rems: these line up with what the system draws or measures — +/// the macOS stoplights, Windows' caption buttons, a compositor's resize +/// grip — and none of those follow interface zoom. The strip's own contents +/// (the trail, the settings) stay in rems and scale as they always have. +pub mod chrome { + use gpui_kit::{Pixels, px}; + + /// The band's height. Even, so a 14 pt stoplight centres on a whole + /// point; the divider below it is drawn under the band, not inside it. + pub const STRIP: Pixels = px(40.); + /// Where macOS puts the close button, from the window's top-left: + /// 16 in, and `(40 - 14) / 2` down, so the stoplights centre on y=20. + pub const STOPLIGHTS: (f32, f32) = (16., 13.); + /// Room left for the stoplights: they end at x=76, and the brand keeps + /// 20 clear of them. + pub const STOPLIGHT_ZONE: Pixels = px(96.); + /// A Windows caption button: the system's own 46 wide, the strip tall. + pub const CAPTION_BUTTON: Pixels = px(46.); + /// A painted Linux window button, square like the rest of the app. + pub const WINDOW_BUTTON: Pixels = px(24.); + /// The glyph inside either kind of button. + pub const GLYPH: Pixels = px(10.); + /// Each of the two windows in the restore glyph. + pub const GLYPH_BEHIND: Pixels = px(8.); + /// The close cross, a little larger: its strokes are diagonal, so at + /// the same box it reads smaller than the square beside it. + pub const CLOSE_GLYPH: Pixels = px(12.); + /// The app icon beside the wordmark. + pub const MARK: Pixels = px(20.); + /// The wordmark, bold, and as tall as its line so it centres by cap + /// height rather than by the font's ascent. + pub const WORDMARK: Pixels = px(17.); + /// The mark and the wordmark. + pub const LOCKUP_GAP: Pixels = px(8.); + /// The ring a client-side window keeps for its shadow and resize grip. + pub const RESIZE_RING: Pixels = px(10.); +} + /// Interface zoom steps, as a factor of the 16 px default rem. pub const ZOOM_STEPS: [f32; 7] = [0.75, 0.875, 1.0, 1.125, 1.25, 1.5, 1.75]; diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index c23e91b3..487b1a9e 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -25,6 +25,7 @@ use gpui_omarchy::{ use gpui_kit::prelude::FluentBuilder as _; +use crate::chrome::{self, Frame}; use crate::palette; use crate::state::{ ColorMode, Crumb, Disktree, PANEL_REMS, Screen, panel_width, @@ -40,10 +41,12 @@ const LIST_LIMIT: usize = 1200; /// The whole window. pub fn root( app: &mut Disktree, + frame: &Frame, window: &mut Window, cx: &mut Context<'_, Disktree>, ) -> Stateful
{ let theme = cx.omarchy().clone(); + let strip = title_strip(app, frame, window, cx); let body = match app.screen { Screen::Explore => explore(app, window, cx), Screen::Review => review(app, window, cx), @@ -104,6 +107,7 @@ pub fn root( .text_color(theme.foreground) .font_family(theme.font) .text_size(text::BODY) + .child(strip) .child(body); if let Some(tip) = cursor_tooltip(app, window, cx) { root = root.child(tip); @@ -338,8 +342,8 @@ fn delete_dialog( fn explore( app: &mut Disktree, - window: &mut Window, - cx: &mut Context<'_, Disktree>, + window: &Window, + cx: &Context<'_, Disktree>, ) -> Div { let theme = cx.omarchy().clone(); let mosaic: Mosaic = app.prepare(); @@ -364,7 +368,6 @@ fn explore( .flex_col() .flex_1() .min_h_0() - .child(top_bar(app, &theme, window, cx)) .child( div() .id("explore-body") @@ -438,30 +441,50 @@ impl gpui_kit::Render for NoGhost { } } -// ── top bar ───────────────────────────────────────────────────────────── +// ── title strip ───────────────────────────────────────────────────────── -/// The app, what the whole scan found, and the controls that decide what -/// is measured, on the edge they own. -fn top_bar( +/// Width, in rem, below which the wordmark gives the trail its room: the +/// settings do not shrink, and at the minimum window the stoplights' zone +/// would otherwise squeeze the trail to nothing. The mark stays. +const WORDMARK_SHOWN_REMS: f32 = 70.0; + +/// The window's title strip. On explore it carries what the whole window +/// is about — where you are and how it is measured — after the brand; the +/// other screens have a heading of their own, so it is only the brand there. +fn title_strip( app: &Disktree, - theme: &Theme, + frame: &Frame, window: &mut Window, cx: &mut Context<'_, Disktree>, -) -> Div { - div() - .flex() - .flex_row() - .items_center() - .gap(space::LG) - .px(space::LG) - .py(space::MD) - .border_b_1() - .border_color(theme.divider()) - .child(logo(theme)) - // Where you are is navigation, and it belongs to the whole window. - .child(trail(app, theme, cx)) - .child(div().flex_1()) - .child(view_settings(app, window, cx)) +) -> Stateful
{ + let theme = cx.omarchy().clone(); + let explore = app.screen == Screen::Explore; + let width_rems = window.viewport_size().width.as_f32() / app.rem; + let wordmark = !explore || width_rems >= WORDMARK_SHOWN_REMS; + let content = if explore { + div() + .flex() + .flex_row() + .items_center() + .flex_1() + .min_w_0() + .gap(space::LG) + // Where you are is navigation, and it belongs to the whole window. + .child(trail(app, &theme, cx)) + .child( + chrome::drag_area(frame, "strip-drag", cx) + .flex_1() + .self_stretch(), + ) + .child(view_settings(app, window, cx)) + } else { + div() + .flex() + .flex_1() + .self_stretch() + .child(chrome::drag_area(frame, "strip-drag", cx).flex_1()) + }; + chrome::strip(frame, wordmark, content, window, cx) } /// The trail, from `/`. Above the scanned root, a crumb widens the scan; @@ -741,44 +764,6 @@ fn sibling_menu( panel } -/// Four tiles in category colours, and the name. -fn logo(theme: &Theme) -> Div { - let tile = |category| { - div() - .size(space::SM) - .bg(palette::category_accent(theme, category)) - }; - let column = |top, bottom| { - div() - .flex() - .flex_col() - .gap(space::XXS) - .child(tile(top)) - .child(tile(bottom)) - }; - div() - .flex() - .flex_row() - .items_center() - .gap(space::SM) - .flex_shrink_0() - .child( - div() - .flex() - .flex_row() - .gap(space::XXS) - .child(column(Category::Code, Category::Synced)) - .child(column(Category::AgentScratch, Category::Toolchain)), - ) - .child( - div() - .text_size(text::HEADING) - .font_weight(FontWeight::BOLD) - .text_color(theme.bright) - .child("disktree"), - ) -} - /// How the mosaic is measured and drawn, as real controls with visible state. /// /// Every control hands the keyboard straight back to the treemap: arrows, From aae91c592b15a025f7c665195974eecce4ed241c Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Wed, 14 Oct 2026 22:22:00 -0400 Subject: [PATCH 2/2] Place overlays inside a client-side frame, and drag from the whole strip Under a floating GNOME frame the content sits inside the resize ring and border, so the cursor tooltip and the panel's resize handle, which work in window coordinates, landed 11 px off the pointer. The frame now says how far the content is inset and both take it off. The brand's drag area takes the band's full height and the padding beside it, and the space between the trail and the settings is one drag area instead of a spacer with gaps, so nothing in the strip looks like title bar without moving the window. A press that leaves the strip before its first motion no longer stays armed for a later drag across it, and a compositor that takes the frame back gets the whole surface again. --- crates/disktree-app/src/chrome.rs | 134 +++++++++++++++++++++-------- crates/disktree-app/src/palette.rs | 10 +++ crates/disktree-app/src/state.rs | 6 ++ crates/disktree-app/src/tests.rs | 35 +++++++- crates/disktree-app/src/views.rs | 30 +++++-- 5 files changed, 172 insertions(+), 43 deletions(-) diff --git a/crates/disktree-app/src/chrome.rs b/crates/disktree-app/src/chrome.rs index 1ac96108..025b40c6 100644 --- a/crates/disktree-app/src/chrome.rs +++ b/crates/disktree-app/src/chrome.rs @@ -18,15 +18,16 @@ use std::sync::{Arc, LazyLock}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::{ - AnyElement, Context, CursorStyle, Decorations, Div, FontWeight, Hsla, - Image, ImageFormat, InteractiveElement as _, IntoElement as _, MouseButton, - MouseDownEvent, MouseMoveEvent, ParentElement as _, ResizeEdge, Stateful, - StatefulInteractiveElement as _, Styled as _, Tiling, Window, WindowButton, - WindowButtonLayout, WindowControlArea, WindowControls, div, img, px, rgb, - svg, white, + AnyElement, Context, CursorStyle, Decorations, Div, Edges, FontWeight, + Hsla, Image, ImageFormat, InteractiveElement as _, IntoElement as _, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels, + ResizeEdge, Stateful, StatefulInteractiveElement as _, Styled as _, Tiling, + Window, WindowButton, WindowButtonLayout, WindowControlArea, + WindowControls, div, img, px, rgb, svg, white, }; use gpui_omarchy::{ActiveTheme as _, IconName, Theme}; +use crate::palette; use crate::state::Disktree; use crate::ui::{chrome, space}; @@ -163,6 +164,40 @@ impl Frame { } } + /// The ring kept on each side for the shadow and the resize grip: none + /// on a side the compositor has tiled, nor anywhere it draws the frame. + fn ring(&self) -> Edges { + let open = |tiled: bool| { + if tiled { px(0.) } else { chrome::RESIZE_RING } + }; + self.client_side + .map_or_else(Edges::default, |tiling| Edges { + top: open(tiling.top), + right: open(tiling.right), + bottom: open(tiling.bottom), + left: open(tiling.left), + }) + } + + /// How far the content sits inside the window on each side: the ring, + /// then the hairline border, where disktree draws the frame. The + /// pointer and the window's size come in window coordinates, and + /// anything placed against them in the content has to take this off. + pub fn content_edges(&self) -> Edges { + let border = if self.client_side.is_some() { + px(1.) + } else { + px(0.) + }; + let ring = self.ring(); + Edges { + top: ring.top + border, + right: ring.right + border, + bottom: ring.bottom + border, + left: ring.left + border, + } + } + /// Whether dragging the strip has to move the window by asking for it. /// Windows does it from the caption area it is told about, and a /// compositor that draws the frame has its own way to move a window. @@ -198,8 +233,23 @@ pub fn strip( cx: &Context<'_, Disktree>, ) -> Stateful
{ let theme = cx.omarchy().clone(); - let leading = controls(frame, &frame.leading, "leading", window, cx); - let trailing = controls(frame, &frame.trailing, "trailing", window, cx); + let maximized = window.is_maximized(); + let leading = controls( + frame, + &frame.leading, + "window-controls-leading", + maximized, + &theme, + cx, + ); + let trailing = controls( + frame, + &frame.trailing, + "window-controls-trailing", + maximized, + &theme, + cx, + ); div() .id("title-strip") .debug_selector(|| "title-strip".into()) @@ -212,12 +262,21 @@ pub fn strip( .min_h(chrome::STRIP + px(1.)) .border_b_1() .border_color(theme.divider()) - .when(frame.leading == Controls::None, |strip| strip.pl(space::LG)) .when(frame.trailing == Controls::None, |strip| strip.pr(space::LG)) .children(leading) + // The brand's area takes the band's full height and the padding + // either side of it, so the strip has no dead margin around the + // mark that looks like title bar and does not move the window. .child( drag_area(frame, "strip-brand", cx) + .flex() + .items_center() + .self_stretch() .flex_shrink_0() + .when(frame.leading == Controls::None, |area| { + area.pl(space::LG) + }) + .pr(space::LG) .child(brand(wordmark, &theme)), ) .child( @@ -225,9 +284,9 @@ pub fn strip( .flex() .flex_row() .items_center() + .self_stretch() .flex_1() .min_w_0() - .ml(space::LG) .when(frame.trailing != Controls::None, |row| { row.mr(space::SM) }) @@ -279,6 +338,13 @@ pub fn drag_area( MouseButton::Left, cx.listener(|this, _, _, _| this.strip_drag = false), ) + // Motion only reaches the area while the pointer is over it, so a + // press that leaves before its first motion is released elsewhere; it + // must not stay armed for the next drag that crosses the strip. + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, _| this.strip_drag = false), + ) .when(platform == Platform::Linux, |area| { area.on_mouse_down(MouseButton::Right, |event, window, _| { window.show_window_menu(event.position); @@ -311,17 +377,11 @@ fn brand(wordmark: bool, theme: &Theme) -> Div { fn controls( frame: &Frame, controls: &Controls, - end: &'static str, - window: &Window, + selector: &'static str, + maximized: bool, + theme: &Theme, cx: &Context<'_, Disktree>, ) -> Option { - let selector = if end == "leading" { - "window-controls-leading" - } else { - "window-controls-trailing" - }; - let theme = cx.omarchy().clone(); - let maximized = window.is_maximized(); match controls { Controls::None => None, Controls::Stoplights => Some( @@ -342,12 +402,12 @@ fn controls( let row = if frame.platform == Platform::Windows { row.children(buttons.iter().map(|&button| { - caption_button(button, maximized, &theme) + caption_button(button, maximized, theme) })) } else { row.gap(space::SM).px(space::SM).children( buttons.iter().map(|&button| { - window_button(button, maximized, &theme) + window_button(button, maximized, theme) }), ) }; @@ -401,8 +461,7 @@ fn window_button( theme: &Theme, ) -> Stateful
{ let group = button.id(); - let rest = theme.foreground.opacity(0.08); - let hover = theme.foreground.opacity(0.16); + let (rest, hover) = palette::window_button(theme); div() .id(button.id()) .group(group) @@ -487,22 +546,27 @@ pub fn client_frame( theme: &Theme, ) -> AnyElement { let Some(tiling) = frame.client_side else { + // A compositor that takes the frame back takes the whole surface: + // an inset left over would draw its frame inside the shadow ring. + if window.client_inset().is_some_and(|inset| inset > px(0.)) { + window.set_client_inset(px(0.)); + } return content.into_any_element(); }; let ring = chrome::RESIZE_RING; if window.client_inset() != Some(ring) { window.set_client_inset(ring); } - let open = |tiled: bool| if tiled { px(0.) } else { ring }; + let open = frame.ring(); let mut outer = div() .id("client-frame") .debug_selector(|| "client-frame".into()) .relative() .size_full() - .pt(open(tiling.top)) - .pb(open(tiling.bottom)) - .pl(open(tiling.left)) - .pr(open(tiling.right)) + .pt(open.top) + .pb(open.bottom) + .pl(open.left) + .pr(open.right) .child( div() .size_full() @@ -511,13 +575,13 @@ pub fn client_frame( .when(!tiling.is_tiled(), gpui_kit::Styled::shadow_lg) .child(content), ); - for (edge, sides) in [ - (ResizeEdge::Top, [tiling.top]), - (ResizeEdge::Bottom, [tiling.bottom]), - (ResizeEdge::Left, [tiling.left]), - (ResizeEdge::Right, [tiling.right]), + for (edge, tiled) in [ + (ResizeEdge::Top, tiling.top), + (ResizeEdge::Bottom, tiling.bottom), + (ResizeEdge::Left, tiling.left), + (ResizeEdge::Right, tiling.right), ] { - if !sides[0] { + if !tiled { outer = outer.child(grip(edge, ring)); } } @@ -535,7 +599,7 @@ pub fn client_frame( } /// One resize handle in the ring, with the cursor that says so. -fn grip(edge: ResizeEdge, ring: gpui_kit::Pixels) -> Stateful
{ +fn grip(edge: ResizeEdge, ring: Pixels) -> Stateful
{ let (cursor, id) = match edge { ResizeEdge::Top => (CursorStyle::ResizeUpDown, "grip-top"), ResizeEdge::Bottom => (CursorStyle::ResizeUpDown, "grip-bottom"), diff --git a/crates/disktree-app/src/palette.rs b/crates/disktree-app/src/palette.rs index 1f5ad7e7..805dc7ec 100644 --- a/crates/disktree-app/src/palette.rs +++ b/crates/disktree-app/src/palette.rs @@ -149,6 +149,16 @@ pub fn hatch(theme: &Theme) -> Hsla { } } +/// A window button disktree paints where a Linux compositor leaves the +/// frame to it, at rest and under the pointer: a quiet square that reads as +/// a control on any theme without competing with the trail beside it. +pub fn window_button(theme: &Theme) -> (Hsla, Hsla) { + ( + theme.foreground.opacity(0.08), + theme.foreground.opacity(0.16), + ) +} + /// Linear interpolation between two colours, in RGB: interpolating hue /// would drag a colour around the wheel on its way to a grey. pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla { diff --git a/crates/disktree-app/src/state.rs b/crates/disktree-app/src/state.rs index ac98d6db..be8b87ca 100644 --- a/crates/disktree-app/src/state.rs +++ b/crates/disktree-app/src/state.rs @@ -389,6 +389,10 @@ pub struct Disktree { /// window-harness tests pin one per layout: their window has no /// compositor to ask. pub chrome_pin: Option, + /// How far the content sits inside the window, from this frame's + /// [`crate::chrome::Frame`]: what turns a pointer position into one in + /// the root's coordinates. + pub content_edges: gpui_kit::Edges, /// The window's `rem` in pixels, read each frame. The mosaic is laid out /// in pixels, so its header band and label thresholds are scaled by this /// to follow interface zoom like the rest of the interface. @@ -522,6 +526,7 @@ impl Disktree { window_title: String::new(), strip_drag: false, chrome_pin: None, + content_edges: gpui_kit::Edges::default(), rem: crate::ui::BASE_REM, run: None, run_epoch: 0, @@ -3217,6 +3222,7 @@ impl Render for Disktree { .chrome_pin .clone() .unwrap_or_else(|| crate::chrome::Frame::detect(window, cx)); + self.content_edges = frame.content_edges(); let root = crate::views::root(self, &frame, window, cx); let theme = cx.omarchy().clone(); crate::chrome::client_frame(&frame, root, window, &theme) diff --git a/crates/disktree-app/src/tests.rs b/crates/disktree-app/src/tests.rs index a9938411..c4432120 100644 --- a/crates/disktree-app/src/tests.rs +++ b/crates/disktree-app/src/tests.rs @@ -1943,7 +1943,8 @@ fn the_title_strip_draws_on_every_window_frame(cx: &mut TestAppContext) { // The brand stays clear of the controls at either end. match &frame.leading { - Controls::None => assert!(brand.left() > strip.left()), + // The brand's area runs to the edge, padding and all. + Controls::None => assert_eq!(brand.left(), strip.left()), Controls::Stoplights => assert!( brand.left() - strip.left() >= chrome::STOPLIGHT_ZONE, "{name}: brand at {:?}", @@ -2083,3 +2084,35 @@ fn a_press_on_the_strip_arms_a_move_and_a_click_disarms_it( }); assert!(focused, "a press on the strip leaves the treemap focused"); } + +#[gpui_kit::test] +fn the_tooltip_follows_the_pointer_inside_a_client_side_frame( + cx: &mut TestAppContext, +) { + cx.update(gpui_omarchy::init); + let temp = fixture(); + let (view, cx) = view_over(temp.path(), cx); + // Floating under GNOME: the content sits inside the resize ring. + update(&view, cx, |app, cx| { + app.chrome_pin = window_frames() + .into_iter() + .find(|(name, _)| *name == "GNOME, controls right") + .map(|(_, frame)| frame); + cx.notify(); + }); + draw(cx); + let origin = read(&view, cx, |app| app.treemap_origin.get()); + let at = origin + Point::new(px(40.), px(40.)); + cx.simulate_mouse_move(at, None, gpui_kit::Modifiers::default()); + draw(cx); + + let tip = cx.debug_bounds("cursor-tooltip").expect("a tooltip"); + // Beside the pointer by the tooltip's gap, wherever the root starts. + let gap = crate::ui::space::MD.0 * crate::ui::BASE_REM; + assert_eq!( + tip.left(), + at.x + px(gap), + "{tip:?} for a pointer at {at:?}" + ); + assert_eq!(tip.top(), at.y + px(gap)); +} diff --git a/crates/disktree-app/src/views.rs b/crates/disktree-app/src/views.rs index 487b1a9e..d7b08778 100644 --- a/crates/disktree-app/src/views.rs +++ b/crates/disktree-app/src/views.rs @@ -379,9 +379,16 @@ fn explore( // pointer from here, wherever it goes in the window. .on_drag_move(cx.listener( |this, event: &DragMoveEvent, window, cx| { + // The pointer is in window coordinates; the + // panel's right edge is the content's, inset by a + // client-side frame. + let inset = &this.content_edges; this.panel_rems = panel_width( - event.event.position.x.as_f32(), - window.viewport_size().width.as_f32(), + (event.event.position.x - inset.left).as_f32(), + (window.viewport_size().width + - inset.left + - inset.right) + .as_f32(), this.rem, ); cx.notify(); @@ -468,12 +475,14 @@ fn title_strip( .items_center() .flex_1() .min_w_0() - .gap(space::LG) // Where you are is navigation, and it belongs to the whole window. .child(trail(app, &theme, cx)) + // The space between the trail and the settings is all title + // bar: a gap there would look like it and not move the window. .child( chrome::drag_area(frame, "strip-drag", cx) .flex_1() + .min_w(space::XXL) .self_stretch(), ) .child(view_settings(app, window, cx)) @@ -3264,16 +3273,22 @@ pub fn cursor_tooltip( let content = hover_tooltip(app, cx)?; let origin = app.treemap_origin.get(); let window_size = window.bounds().size; + // The tooltip is placed in the root's coordinates, which a client-side + // frame insets from the window's by its resize ring and border. + let inset = &app.content_edges; + let content_width = (window_size.width - inset.left - inset.right).as_f32(); + let content_height = + (window_size.height - inset.top - inset.bottom).as_f32(); // Flip to the other side of the pointer rather than overflow the window. - let anchor_x = origin.x.as_f32() + pointer.x.as_f32(); - let anchor_y = origin.y.as_f32() + pointer.y.as_f32(); - let x = if anchor_x + gap + width > window_size.width.as_f32() { + let anchor_x = (origin.x + pointer.x - inset.left).as_f32(); + let anchor_y = (origin.y + pointer.y - inset.top).as_f32(); + let x = if anchor_x + gap + width > content_width { (anchor_x - gap - width).max(edge) } else { anchor_x + gap }; - let y = if anchor_y + gap + height > window_size.height.as_f32() { + let y = if anchor_y + gap + height > content_height { (anchor_y - gap - height).max(edge) } else { anchor_y + gap @@ -3281,6 +3296,7 @@ pub fn cursor_tooltip( Some( card_surface(cx) + .debug_selector(|| "cursor-tooltip".into()) .absolute() .left(px(x)) .top(px(y))