diff --git a/AGENTS.md b/AGENTS.md index 50bdab5..3acf6ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,7 +221,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `typography::Ellipsis` shapes its line with the text style's own run, so a strike or an underline set on its box shows (`lists::ListItem::struck`, a closed task's title). - Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns). - The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS. -- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width yields before a header's own parts; the host's header items keep one control's width and scroll when short. +- A window whose sidebar runs to its top is `shell::ColumnShell`: each column's header drags the window; the first leaves the traffic lights room, and drawn controls lead the first header in the macOS manner or end the last. A set width is a flex basis, not a width, so it holds its header's own parts, which a width let run past it, and yields when the shell runs short; the host's header items keep one control's width and scroll when short. A resizable column drags by its border toward the filling column, its right before it and its left after it; the handle draws that border and occludes the header under it, and a drag sets off from the width drawn, not the owner's. - Taffy counts a flex item's text in its least width whatever width the item sets. A box that must add none lays its content absolutely inside a filling box (`shell::ColumnShell`'s title and body). - A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`). - A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency. diff --git a/src/shell/columns.rs b/src/shell/columns.rs index 45ce616..e036ae4 100644 --- a/src/shell/columns.rs +++ b/src/shell/columns.rs @@ -1,22 +1,48 @@ -use std::rc::Rc; +use std::{cell::Cell, rc::Rc}; use gpui::{ - AnyElement, App, DefiniteLength, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, + AnyElement, App, Axis, DefiniteLength, Div, DragMoveEvent, ElementId, EmptyView, EntityId, + FontWeight, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, RenderOnce, SharedString, Styled, Window, div, prelude::*, }; use smallvec::SmallVec; use super::chrome::{OnClose, WindowControls, controls_style, drag_region, route_close}; use crate::{ - layout::on_axis, + layout::{on_axis, resize_handle}, theme::{ActiveTheme, ControlSize, Platform, TextSize}, typography::Ellipsis, }; +type OnResize = Rc; + +/// A width the owner keeps, which a drag on one border moves. +struct Resize { + width: Pixels, + min: Pixels, + max: Pixels, + on_resize: OnResize, +} + +/// The border a column's width is dragged by. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Side { + Left, + Right, +} + +struct BorderDrag { + owner: EntityId, + side: Side, + /// The width drawn when the drag set off. + start: Rc>>, +} + /// One column of a `ColumnShell`: a header that drags the window, then its body. pub struct ShellColumn { id: ElementId, width: Option, + resize: Option, leading: SmallVec<[AnyElement; 2]>, title: Option, actions: SmallVec<[AnyElement; 2]>, @@ -29,6 +55,7 @@ impl ShellColumn { Self { id: id.into(), width: None, + resize: None, leading: SmallVec::new(), title: None, actions: SmallVec::new(), @@ -36,12 +63,35 @@ impl ShellColumn { } } - /// A set width instead of the rest; it yields before a header's fixed parts. + /// A set width instead of the rest; it holds its header's fixed parts and yields when the shell runs short. pub fn width(mut self, width: impl Into) -> Self { self.width = Some(width.into()); self } + /// A set width the owner keeps: a drag on the border toward the filling column moves it within `min` and `max`, and the owner hears each new width. + pub fn resizable( + mut self, + width: Pixels, + min: Pixels, + max: Pixels, + on_resize: impl Fn(Pixels, &mut Window, &mut App) + 'static, + ) -> Self { + assert!( + min <= max, + "shell column {:?}: limits {min:?} to {max:?} run backwards", + self.id + ); + self.width = Some(width.into()); + self.resize = Some(Resize { + width, + min, + max, + on_resize: Rc::new(on_resize), + }); + self + } + /// Header content before the title. pub fn leading(mut self, item: impl IntoElement) -> Self { self.leading.push(item.into_any_element()); @@ -132,9 +182,19 @@ impl RenderOnce for ColumnShell { .child(controls) }; let room = system && !window.is_fullscreen(); + // Each border lies toward the column that fills, so a drag on it moves one width. + let fill = self + .columns + .iter() + .position(|column| column.width.is_none()); let mut columns = Vec::with_capacity(count); for (ix, column) in self.columns.into_iter().enumerate() { let (first, last) = (ix == 0, ix + 1 == count); + let side = match fill { + Some(fill) if ix > fill => Some(Side::Left), + Some(fill) if ix == fill => None, + _ => (!last).then_some(Side::Right), + }; let lead = (!system && first && style == Platform::Mac).then(controls); let trail = (!system && last && style != Platform::Mac).then(controls); let flush = trail.is_some() && style == Platform::Windows; @@ -157,30 +217,48 @@ impl RenderOnce for ColumnShell { ) .children(trail); let colors = &cx.theme().colors; - columns.push( - div() - .flex() - .flex_col() - .h_full() - .map(|box_| match column.width { - Some(width) => box_.w(width), - None => box_.flex_1(), - }) - .when(!last, |box_| box_.border_r_1().border_color(colors.border)) - .debug_selector(move || format!("column-shell-{ix}")) - .child(header) - .child( - // Absolute, so only the header sets the least width. - div().flex_1().min_h_0().relative().child( - div() - .absolute() - .inset_0() - .flex() - .flex_col() - .children(column.body), - ), + let box_ = div() + .relative() + .flex() + .flex_col() + .h_full() + // A basis, not a width, so the header's fixed parts set the least width. + .map(|box_| match column.width { + Some(width) => box_.flex_basis(width), + None => box_.flex_1(), + }) + // A resizing column's handle draws its border. + .map(|box_| match (side, column.resize.is_some()) { + (Some(Side::Left), false) => box_.border_l_1(), + (Some(Side::Right), false) => box_.border_r_1(), + _ => box_, + }) + .border_color(colors.border) + .debug_selector(move || format!("column-shell-{ix}")) + .child(header) + .child( + // Absolute, so only the header sets the least width. + div().flex_1().min_h_0().relative().child( + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .children(column.body), ), - ); + ); + columns.push(match column.resize { + Some(resize) => { + let side = side.unwrap_or_else(|| { + panic!( + "column shell {:?}: column {ix} resizes, but no border lies toward a filling column", + self.id + ) + }); + resizing(box_, column.id, ix, side, resize, window, cx) + } + None => box_, + }); } let theme = cx.theme(); let ground = if self.glass { @@ -198,6 +276,69 @@ impl RenderOnce for ColumnShell { } } +/// `box_` with a handle on its `side` border, whose drag moves its width. +fn resizing( + box_: Div, + id: ElementId, + ix: usize, + side: Side, + resize: Resize, + window: &mut Window, + cx: &mut App, +) -> Div { + let Resize { + width, + min, + max, + on_resize, + } = resize; + let pressed = window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::); + let owner = pressed.entity_id(); + let drag = BorderDrag { + owner, + side, + start: Rc::default(), + }; + let at = pressed.clone(); + let handle = resize_handle((id, "border"), Axis::Horizontal, cx) + .absolute() + .top_0() + .map(|handle| match side { + Side::Left => handle.left_0().justify_start(), + Side::Right => handle.right_0().justify_end(), + }) + // Over a header, so a press drags the border, not the window. + .occlude() + .debug_selector(move || format!("column-shell-border-{ix}")) + .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { + at.update(cx, |at, _| *at = Some(event.position.x)) + }) + .on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView)); + box_.on_drag_move(move |event: &DragMoveEvent, window, cx| { + let drag = event.drag(cx); + if drag.owner != owner { + return; + } + let Some(anchor) = *pressed.read(cx) else { + log::error!("column shell: a drag on column {ix} with no press behind it"); + return; + }; + // The drawn width, which a header's own parts may hold past the owner's. + let start = drag.start.get().unwrap_or(event.bounds.size.width); + drag.start.set(Some(start)); + let moved = event.event.position.x - anchor; + let next = match drag.side { + Side::Left => start - moved, + Side::Right => start + moved, + } + .clamp(min, max); + if next != width { + on_resize(next, window, cx); + } + }) + .child(handle) +} + /// A header strip at the title bar's height that drags the window. fn header(id: ElementId, window: &mut Window, cx: &mut App) -> gpui::Stateful { drag_region((id, "header"), window, cx) diff --git a/src/shell/tests.rs b/src/shell/tests.rs index 38879ea..7b80500 100644 --- a/src/shell/tests.rs +++ b/src/shell/tests.rs @@ -1,3 +1,5 @@ +mod border; + use gpui::{ AppContext as _, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, TestAppContext, VisualTestContext, Window, div, prelude::*, px, size, diff --git a/src/shell/tests/border.rs b/src/shell/tests/border.rs new file mode 100644 index 0000000..9c54e2a --- /dev/null +++ b/src/shell/tests/border.rs @@ -0,0 +1,168 @@ +use gpui::{ + Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render, + TestAppContext, VisualTestContext, Window, div, point, prelude::*, px, +}; + +use crate::shell::{ColumnShell, ShellColumn}; +use crate::theme::{Platform, Theme}; + +/// Two set columns round a filling one; this view keeps their widths. +struct Sided { + platform: Platform, + widths: [Pixels; 2], + heard: Vec, +} + +impl Render for Sided { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let side = |id: &'static str, ix: usize| { + let view = view.clone(); + ShellColumn::new(id).title(id).resizable( + self.widths[ix], + px(100.0), + px(400.0), + move |width, _, cx| { + view.update(cx, |view, cx| { + view.widths[ix] = width; + view.heard.push(width); + cx.notify(); + }) + }, + ) + }; + div().w(px(1000.0)).h(px(400.0)).flex().flex_col().child( + ColumnShell::new("shell") + .platform(self.platform) + .column(side("left", 0)) + .column(ShellColumn::new("main").title("main")) + .column(side("right", 1)), + ) + } +} + +fn sided( + platform: Platform, + widths: [Pixels; 2], + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + let (view, cx) = cx.add_window_view(|_, _| Sided { + platform, + widths, + heard: Vec::new(), + }); + cx.run_until_parked(); + (view, cx) +} + +fn column(ix: usize, cx: &mut VisualTestContext) -> gpui::Bounds { + let name: &'static str = ["column-shell-0", "column-shell-1", "column-shell-2"][ix]; + cx.debug_bounds(name).expect("a column") +} + +/// Presses at `from`, moves past gpui's drag threshold, then by `by`, and lets go. +fn drag(from: Point, by: Pixels, cx: &mut VisualTestContext) { + let none = Modifiers::none(); + cx.simulate_mouse_down(from, MouseButton::Left, none); + let set_off = from + point(px(0.0), px(4.0)); + cx.simulate_mouse_move(set_off, Some(MouseButton::Left), none); + cx.run_until_parked(); + let to = set_off + point(by, px(0.0)); + cx.simulate_mouse_move(to, Some(MouseButton::Left), none); + cx.run_until_parked(); + cx.simulate_mouse_up(to, MouseButton::Left, none); + cx.run_until_parked(); +} + +fn heard(view: &Entity, cx: &mut VisualTestContext) -> Option { + view.read_with(cx, |view, _| view.heard.last().copied()) +} + +/// A column before the filling one drags by its right border, one after it by its left. +#[gpui::test] +fn a_border_toward_the_filling_column_drags_its_width(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx); + let left = cx.debug_bounds("column-shell-border-0").expect("a border"); + let right = cx.debug_bounds("column-shell-border-2").expect("a border"); + assert_eq!(left.right(), column(0, cx).right()); + assert_eq!(right.left(), column(2, cx).left()); + drag(left.center(), px(50.0), cx); + assert_eq!(heard(&view, cx), Some(px(250.0))); + assert_eq!(column(0, cx).size.width, px(250.0)); + drag(right.center(), px(-50.0), cx); + assert_eq!(heard(&view, cx), Some(px(250.0))); + let (main, panel) = (column(1, cx), column(2, cx)); + assert_eq!((panel.size.width, panel.right()), (px(250.0), px(1000.0))); + assert_eq!((main.left(), main.right()), (px(250.0), px(750.0))); +} + +/// A drag stops at the column's least and greatest width. +#[gpui::test] +fn a_border_drag_keeps_within_the_limits(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (view, cx) = sided(Platform::Linux, [px(200.0), px(200.0)], cx); + let border = |cx: &mut VisualTestContext| { + cx.debug_bounds("column-shell-border-0") + .expect("a border") + .center() + }; + drag(border(cx), px(900.0), cx); + assert_eq!(heard(&view, cx), Some(px(400.0))); + drag(border(cx), px(-900.0), cx); + assert_eq!(heard(&view, cx), Some(px(100.0))); +} + +/// In a header the border takes the press, so no system's drag moves the window, which panics here. +#[gpui::test] +fn a_border_drag_in_a_header_moves_no_window(cx: &mut TestAppContext) { + cx.update(Theme::init); + for platform in [Platform::Windows, Platform::Mac, Platform::Linux] { + let (view, cx) = sided(platform, [px(200.0), px(200.0)], cx); + for (ix, by) in [(0, px(30.0)), (2, px(-30.0))] { + let border = cx + .debug_bounds(["column-shell-border-0", "", "column-shell-border-2"][ix]) + .expect("a border"); + let header = column(ix, cx).top() + px(4.0); + drag(point(border.center().x, header), by, cx); + assert_eq!(heard(&view, cx), Some(px(230.0)), "{platform:?}"); + } + } +} + +/// A header's own parts hold a column past the owner's width; a drag sets off from the drawn one. +#[gpui::test] +fn a_border_drag_sets_off_from_the_drawn_width(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (view, cx) = sided(Platform::Windows, [px(200.0), px(100.0)], cx); + let (panel, close) = (column(2, cx), cx.debug_bounds("caption-Close")); + let close = close.expect("a close button"); + assert!(close.right() <= panel.right(), "{close:?} in {panel:?}"); + let drawn = panel.size.width; + assert!(drawn > px(100.0), "{drawn:?} holds the caption buttons"); + let border = cx.debug_bounds("column-shell-border-2").expect("a border"); + drag(border.center(), px(-10.0), cx); + assert_eq!(heard(&view, cx), Some(drawn + px(10.0))); +} + +/// Two set columns and nothing that fills. +struct Unfilled; + +impl Render for Unfilled { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let column = |id: &'static str| ShellColumn::new(id).width(px(200.0)); + ColumnShell::new("shell") + .platform(Platform::Linux) + .column(column("first")) + .column(column("last").resizable(px(200.0), px(100.0), px(400.0), |_, _, _| {})) + } +} + +/// The last column of a shell that nothing fills has no border to drag. +#[gpui::test] +#[should_panic(expected = "no border lies toward a filling column")] +fn a_resizing_column_needs_a_border_toward_a_filling_one(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Unfilled); + cx.run_until_parked(); +}