diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 194a5ea..c87df07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,14 @@ jobs: run: | cargo check -p gpui-native-core --no-default-features cargo check -p gpui-native-core --all-features - for f in button checkbox switch radio toggle link slider progress avatar input popover tooltip menu dialog sheet toast; do + for f in button checkbox switch radio toggle link slider progress avatar input popover tooltip menu dialog toast; do cargo check -p gpui-native-core --no-default-features --features "$f" done + - name: Feature checks (layout) + run: | + cargo check -p gpui-native-layout --no-default-features + cargo check -p gpui-native-layout --all-features + cargo check -p gpui-native-layout --no-default-features --features resize_handle - name: Feature checks (facade) run: | cargo check -p gpui-native-kit --no-default-features diff --git a/Cargo.lock b/Cargo.lock index 0bd279a..66a15ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1515,6 +1515,7 @@ dependencies = [ "gpui-native-core", "gpui-native-data", "gpui-native-icons", + "gpui-native-layout", "gpui-native-theme", "gpui_platform", ] @@ -1733,9 +1734,19 @@ dependencies = [ "gpui-native-core", "gpui-native-data", "gpui-native-icons", + "gpui-native-layout", "gpui-native-theme", ] +[[package]] +name = "gpui-native-layout" +version = "0.1.0" +dependencies = [ + "gpui", + "gpui-native-theme", + "gpui-native-utils", +] + [[package]] name = "gpui-native-theme" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9cb3ff5..15c0925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "examples/gallery"] +members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "crates/layout", "examples/gallery"] [package] name = "gpui-native-kit" @@ -11,11 +11,13 @@ description = "GPUI components that feel native to the operating system" default = ["core", "data"] core = ["dep:gpui-native-core"] data = ["dep:gpui-native-data"] +layout = ["dep:gpui-native-layout"] [dependencies] gpui = { workspace = true } gpui-native-core = { path = "crates/core", optional = true } gpui-native-data = { path = "crates/data", optional = true } +gpui-native-layout = { path = "crates/layout", optional = true } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } @@ -26,6 +28,7 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d ] } gpui-native-core = { path = "crates/core" } gpui-native-data = { path = "crates/data" } +gpui-native-layout = { path = "crates/layout" } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } gpui-native-utils = { path = "crates/utils" } diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml new file mode 100644 index 0000000..a64e400 --- /dev/null +++ b/crates/layout/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gpui-native-layout" +version = "0.1.0" +edition = "2024" +description = "Application layout bootstrap: sidebars and a center dock, one cargo feature each" + +[dependencies] +gpui = { workspace = true } +gpui-native-theme = { workspace = true } +gpui-native-utils = { workspace = true } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + +[features] +default = ["full"] +full = ["resize_handle"] +resize_handle = [] diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs new file mode 100644 index 0000000..6227dbe --- /dev/null +++ b/crates/layout/src/lib.rs @@ -0,0 +1,4 @@ +//! Application layout: sidebars and a center dock bootstrapped for the app (plan §6.4). + +#[cfg(feature = "resize_handle")] +pub mod resize_handle; diff --git a/crates/layout/src/resize_handle.rs b/crates/layout/src/resize_handle.rs new file mode 100644 index 0000000..12bf007 --- /dev/null +++ b/crates/layout/src/resize_handle.rs @@ -0,0 +1,278 @@ +//! The drag divider: a thin handle that resizes the adjacent region. + +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, Axis, Bounds, CursorStyle, DispatchPhase, ElementId, HitboxBehavior, + InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, RenderOnce, Role, + SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, fill, point, + px, +}; +use gpui_native_theme::{ActiveTheme as _, Size}; +use gpui_native_utils::{focus_handle, focus_ring}; + +/// How far one keyboard arrow step moves the seam. +const KEYBOARD_STEP: f32 = 32.0; + +type ResizeHandler = Rc; +type ResetHandler = Rc; + +/// The pointer drag state, keyed to the handle's element id. +#[derive(Clone, Copy)] +struct DragState { + dragging: bool, + last: Point, +} + +/// A thin drag divider that resizes the adjacent region. +#[derive(IntoElement)] +pub struct ResizeHandle { + id: ElementId, + /// The axis the drag moves along: a vertical seam drags horizontally. + axis: Axis, + disabled: bool, + a11y_label: Option, + on_resize: Option, + on_reset: Option, +} + +impl ResizeHandle { + /// Creates a horizontal resize handle (a vertical seam). + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + axis: Axis::Horizontal, + disabled: false, + a11y_label: None, + on_resize: None, + on_reset: None, + } + } + + /// Sets the axis the drag moves along. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Renders the handle inert and dimmed. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Overrides the accessibility label, which defaults to "Resize". + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// Runs for every pointer movement while the seam is dragged. + pub fn on_resize(mut self, handler: F) -> Self + where + F: Fn(Pixels, &mut Window, &mut App) + 'static, + { + self.on_resize = Some(Rc::new(handler)); + self + } + + /// Runs when the seam is double-clicked, to restore the default size. + pub fn on_reset(mut self, handler: F) -> Self + where + F: Fn(&mut Window, &mut App) + 'static, + { + self.on_reset = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ResizeHandle { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let border = theme.colors.border; + let ring = theme.colors.ring; + let thickness = theme.sizes.resolve(Size::default()).dense_padding_x; + + let label = self + .a11y_label + .clone() + .unwrap_or_else(|| SharedString::from("Resize")); + + let drag = window.with_element_namespace(self.id.clone(), |window| { + window + .use_keyed_state("resize-drag", cx, |_, _| DragState { + dragging: false, + last: point(px(0.), px(0.)), + }) + .clone() + }); + let focus = focus_handle(&self.id, window, cx); + let dragging = drag.read(cx).dragging; + let axis = self.axis; + let disabled = self.disabled; + + let cursor = match axis { + Axis::Horizontal => CursorStyle::ResizeLeftRight, + Axis::Vertical => CursorStyle::ResizeUpDown, + }; + + let on_reset = self.on_reset; + + div() + .id(self.id.clone()) + .debug_selector(|| "resize-handle".to_string()) + .role(Role::Splitter) + .aria_label(label) + .flex_none() + .when(axis == Axis::Horizontal, |handle| { + handle.w(thickness).h_full() + }) + .when(axis == Axis::Vertical, |handle| { + handle.w_full().h(thickness) + }) + .when(self.disabled, |handle| handle.opacity(0.5)) + .when(!self.disabled, |handle| { + handle + .track_focus(&focus) + .focusable() + .tab_stop(true) + .focus_visible(|style| style.shadow(focus_ring(ring))) + .on_mouse_down(MouseButton::Left, { + let drag = drag.clone(); + let focus = focus.clone(); + move |event: &MouseDownEvent, window, cx| { + window.focus(&focus, cx); + cx.stop_propagation(); + if event.click_count >= 2 { + drag.update(cx, |drag, cx| { + drag.dragging = false; + cx.notify(); + }); + if let Some(on_reset) = on_reset.clone() { + on_reset(window, cx); + } + return; + } + drag.update(cx, |drag, cx| { + drag.dragging = true; + drag.last = event.position; + cx.notify(); + }); + } + }) + .on_key_down({ + let on_resize = self.on_resize.clone(); + move |event: &KeyDownEvent, window, cx| { + let delta = match (axis, event.keystroke.key.as_str()) { + (Axis::Horizontal, "left") => Some(px(-KEYBOARD_STEP)), + (Axis::Horizontal, "right") => Some(px(KEYBOARD_STEP)), + (Axis::Vertical, "up") => Some(px(-KEYBOARD_STEP)), + (Axis::Vertical, "down") => Some(px(KEYBOARD_STEP)), + _ => None, + }; + if let Some(delta) = delta { + cx.stop_propagation(); + if let Some(on_resize) = on_resize.clone() { + on_resize(delta, window, cx); + } + } + } + }) + }) + .child( + canvas( + { + move |bounds, window, _| { + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + (hitbox, bounds) + } + }, + { + let drag = drag.clone(); + let on_resize = self.on_resize.clone(); + move |_, (hitbox, bounds), window, _cx| { + let hovered = hitbox.is_hovered(window); + if !disabled { + window.set_cursor_style(cursor, &hitbox); + } + + // The hairline highlights while hovered or dragged. + let line_color = if !disabled && (dragging || hovered) { + ring + } else { + border + }; + let line = match axis { + Axis::Horizontal => { + let x = bounds.center().x - px(0.5); + Bounds::from_corners( + point(x, bounds.top()), + point(x + px(1.), bounds.bottom()), + ) + } + Axis::Vertical => { + let y = bounds.center().y - px(0.5); + Bounds::from_corners( + point(bounds.left(), y), + point(bounds.right(), y + px(1.)), + ) + } + }; + window.paint_quad(fill(line, line_color)); + + window.on_mouse_event({ + let drag = drag.clone(); + let on_resize = on_resize.clone(); + move |event: &MouseMoveEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + let (dragging, last) = { + let drag = drag.read(cx); + (drag.dragging, drag.last) + }; + if !dragging { + return; + } + let delta = match axis { + Axis::Horizontal => event.position.x - last.x, + Axis::Vertical => event.position.y - last.y, + }; + drag.update(cx, |drag, _| drag.last = event.position); + if let Some(on_resize) = on_resize.clone() { + on_resize(delta, window, cx); + } + } + }); + window.on_mouse_event({ + let drag = drag.clone(); + move |event: &MouseUpEvent, phase, _, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + if drag.read(cx).dragging { + drag.update(cx, |drag, cx| { + drag.dragging = false; + cx.notify(); + }); + } + } + }); + } + }, + ) + .absolute() + .inset_0(), + ) + } +} diff --git a/crates/layout/tests/resize_handle.rs b/crates/layout/tests/resize_handle.rs new file mode 100644 index 0000000..88e9dd2 --- /dev/null +++ b/crates/layout/tests/resize_handle.rs @@ -0,0 +1,201 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + Axis, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, + Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, +}; +use gpui_native_layout::resize_handle::ResizeHandle; +use gpui_native_theme::Theme; + +type DeltaSink = Rc>>; +type ResetSink = Rc>; + +struct Harness { + deltas: Rc>>, + resets: Rc>, + axis: Axis, + disabled: bool, +} + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + let deltas = self.deltas.clone(); + let resets = self.resets.clone(); + div() + .size_full() + .flex() + .flex_row() + .child(div().w(px(120.)).h_full()) + .child( + ResizeHandle::new("test-handle") + .axis(self.axis) + .disabled(self.disabled) + .on_resize(move |delta, _, _| deltas.borrow_mut().push(delta)) + .on_reset(move |_, _| *resets.borrow_mut() += 1), + ) + .child(div().flex_1().h_full()) + } +} + +fn handle_window( + axis: Axis, + disabled: bool, +) -> (TestAppContext, DeltaSink, ResetSink, VisualTestContext) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let deltas = Rc::new(RefCell::new(Vec::new())); + let resets = Rc::new(RefCell::new(0usize)); + let deltas_for_window = deltas.clone(); + let resets_for_window = resets.clone(); + let window = cx.add_window(move |_, _| Harness { + deltas: deltas_for_window, + resets: resets_for_window, + axis, + disabled, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, deltas, resets, visual) +} + +fn handle_center(visual: &mut VisualTestContext) -> gpui::Point { + let bounds = visual + .debug_bounds("resize-handle") + .expect("resize handle missing"); + point(bounds.center().x, bounds.center().y) +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn dragging_reports_signed_deltas() { + let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false); + let start = handle_center(&mut visual); + + visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + visual.simulate_event(MouseMoveEvent { + position: point(start.x + px(30.), start.y), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_event(MouseMoveEvent { + position: point(start.x + px(10.), start.y), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + + assert_eq!(*deltas.borrow(), vec![px(30.), px(-20.)]); +} + +#[test] +fn vertical_seams_report_y_deltas() { + let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Vertical, false); + let start = handle_center(&mut visual); + + visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + visual.simulate_event(MouseMoveEvent { + position: point(start.x, start.y + px(40.)), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + + assert_eq!(*deltas.borrow(), vec![px(40.)]); +} + +#[test] +fn moves_without_a_drag_report_nothing() { + let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false); + let start = handle_center(&mut visual); + + // Hovering and moving across the seam without pressing does nothing. + visual.simulate_event(MouseMoveEvent { + position: point(start.x + px(30.), start.y), + pressed_button: None, + modifiers: Modifiers::default(), + }); + // A press-and-move that started elsewhere must not reach the handle either. + visual.simulate_mouse_down( + point(px(20.), px(20.)), + MouseButton::Left, + Modifiers::default(), + ); + visual.simulate_event(MouseMoveEvent { + position: point(px(50.), px(20.)), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + + assert!(deltas.borrow().is_empty()); +} + +#[test] +fn arrow_keys_move_the_seam_when_focused() { + let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false); + let center = handle_center(&mut visual); + click(&mut visual, center); + + visual.simulate_keystrokes("left"); + visual.simulate_keystrokes("right"); + assert_eq!(*deltas.borrow(), vec![px(-32.), px(32.)]); +} + +#[test] +fn double_click_resets_and_starts_no_drag() { + let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, false); + let center = handle_center(&mut visual); + + for click_count in [1usize, 2] { + visual.simulate_event(MouseDownEvent { + position: center, + click_count, + modifiers: Modifiers::default(), + button: MouseButton::Left, + first_mouse: false, + }); + visual.simulate_event(MouseUpEvent { + position: center, + click_count, + modifiers: Modifiers::default(), + button: MouseButton::Left, + }); + } + assert_eq!(*resets.borrow(), 1); + + // The double click must not leave a drag behind. + visual.simulate_event(MouseMoveEvent { + position: point(center.x + px(20.), center.y), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + assert!(deltas.borrow().is_empty()); +} + +#[test] +fn disabled_handle_ignores_drag_and_keys() { + let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, true); + let center = handle_center(&mut visual); + + visual.simulate_mouse_down(center, MouseButton::Left, Modifiers::default()); + visual.simulate_event(MouseMoveEvent { + position: point(center.x + px(30.), center.y), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(center, MouseButton::Left, Modifiers::default()); + visual.simulate_keystrokes("left"); + + assert!(deltas.borrow().is_empty()); + assert_eq!(*resets.borrow(), 0); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index a206588..39c4097 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -284,7 +284,7 @@ Units, each through the review gate: | Unit | Crate | Component | Notes | | --- | --- | --- | --- | -| L1 | layout | ResizeHandle | The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in `layout` — promotes to `core` if a second consumer appears (D16). | +| L1 | layout | ResizeHandle ✅ Landed (2026-11-01) | The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in `layout` — promotes to `core` if a second consumer appears (D16). Shipped as a delta reporter: `ResizeHandle::new(id).axis(Axis).on_resize(delta)` hands the owner the signed seam movement per event — the owner stores a pixel size and clamps, the `InputState::scroll_by` pattern — with `.on_reset()` for double-click; no geometry parameters, so it works at any edge or seam. The hairline is `border`, highlights to `ring` on hover/drag; hit slop = `dense_padding_x` (component-own decoration, D25), hairline 1 px like the input's paint hairlines; `Role::Splitter` + focus ring, arrow keys move the seam (`KEYBOARD_STEP` 32 px, input-style const). One gpui dispatch lesson locked in tests: bubble runs user mouse-down listeners before the div's own focus transfer, so a listener that stops propagation must focus explicitly first — the keyboard test caught it. Feature `resize_handle`; 6 headless tests in `crates/layout/tests/resize_handle.rs`; gallery section shows a draggable split with live width | | L2 | layout | AppLayout | The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through `ResizeHandle`; visibility and widths live on an application-owned `LayoutState` (the `InputState` pattern: `set_left_sidebar_visible` / `set_right_sidebar_visible` / `is_*` readers). The center region hosts the `Dock`. | | L3 | layout | Dock | The center pane tree, controlled: `DockState` = `Pane { tabs, active }` or `Split { axis, ratio, a, b }`; panels are registered descriptors (`id` + view builder); add / close / activate are plain state ops; a pane emptied by closing its last tab collapses its split. Pane headers show titles, the active tab's close button, and a context menu (via core `ContextMenu`). | | L4 | layout | Dock drag-to-split | Dragging a tab: drop on a pane's edge splits a new pane there; drop on a pane's center moves the tab into that pane; a pane emptied by the move collapses. Edge drop zones paint from theme tokens while a drag is live. Split/merge go through the same `DockState` ops L3 exposed — the drag is interaction, not a second API. | @@ -374,6 +374,6 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). 7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01). 8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when file viewing (M15) is reached. -9. Then — the layout crate (§6.4): ResizeHandle, the AppLayout bootstrap, the center Dock with drag-to-split, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts. +9. Then — the layout crate (§6.4): ResizeHandle ✅, the AppLayout bootstrap, the center Dock with drag-to-split, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 7645085..4f41f88 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -9,6 +9,7 @@ gpui = { workspace = true } gpui_platform = { workspace = true } gpui-native-core = { workspace = true } gpui-native-data = { workspace = true } +gpui-native-layout = { workspace = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index d3dcbf1..2fbd9ff 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -3,7 +3,7 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, - IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, + IntoElement, ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, WindowBounds, WindowOptions, div, px, size, }; use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView}; @@ -34,6 +34,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; +use gpui_native_layout::resize_handle::ResizeHandle; use gpui_native_theme::{ActiveTheme as _, Size, Theme}; use gpui_platform::application; @@ -47,6 +48,7 @@ struct Gallery { input_state: Entity, password_state: Entity, notes_state: Entity, + split_width: Pixels, popover_open: bool, menu_open: bool, strategy: Option, @@ -378,6 +380,74 @@ impl Render for Gallery { )), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "layout · resize handle \u{2014} NSSplitView divider, WinUI GridSplitter, GtkPaned handle", + colors, + )) + .child( + h_flex() + .h(px(112.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .w(self.split_width) + .h_full() + .flex() + .items_center() + .justify_center() + .bg(colors.muted) + .child("Sidebar"), + ) + .child( + ResizeHandle::new("gallery-resize") + .on_resize({ + let gallery = gallery.clone(); + move |delta, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.split_width = (gallery.split_width + + delta) + .clamp(px(96.), px(320.)); + cx.notify(); + }); + } + }) + .on_reset({ + let gallery = gallery.clone(); + move |_, cx| { + gallery.update(cx, |gallery, cx| { + gallery.split_width = px(200.); + cx.notify(); + }); + } + }), + ) + .child( + div() + .flex_1() + .h_full() + .flex() + .items_center() + .justify_center() + .bg(colors.input) + .child("Editor"), + ), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!( + "width: {} px \u{2014} drag the seam, double-click resets", + f32::from(self.split_width) + )), + ), + ) .child( v_flex() .gap_2() @@ -1506,6 +1576,7 @@ fn main() { input_state, password_state, notes_state, + split_width: px(200.), popover_open: false, menu_open: false, strategy: Some(Strategy::Curated), diff --git a/src/lib.rs b/src/lib.rs index b809b25..6eb6451 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,9 @@ pub use gpui_native_core::*; #[cfg(feature = "data")] pub use gpui_native_data as data; +#[cfg(feature = "layout")] +pub use gpui_native_layout as layout; + use gpui::App; /// Initializes the framework.