From 2bdf902bf42f9f3133370cdf8b52be34f0d09365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 02:17:37 -0400 Subject: [PATCH] T03 layout: stacks, grids, surfaces, scroll, splits, sheets, dock, panes, workspace, viewport --- AGENTS.md | 3 +- Cargo.lock | 2 + Cargo.toml | 2 + TASKS.md | 5 +- examples/gallery/pages/layout/basics.rs | 256 +++++++++++++ examples/gallery/pages/layout/mod.rs | 128 +++++++ examples/gallery/pages/layout/regions.rs | 252 ++++++++++++ examples/gallery/pages/layout/scrolling.rs | 164 ++++++++ examples/gallery/pages/layout/shells.rs | 233 +++++++++++ examples/gallery/pages/layout/systems.rs | 271 +++++++++++++ examples/gallery/pages/mod.rs | 2 + examples/gallery/script.rs | 33 ++ scripts/check.sh | 3 +- src/layout/collapse.rs | 256 +++++++++++++ src/layout/dock/mod.rs | 214 +++++++++++ src/layout/dock/view.rs | 347 +++++++++++++++++ src/layout/floating.rs | 168 ++++++++ src/layout/grid.rs | 189 +++++++++ src/layout/mod.rs | 32 ++ src/layout/page.rs | 198 ++++++++++ src/layout/panes.rs | 425 +++++++++++++++++++++ src/layout/scroll.rs | 251 ++++++++++++ src/layout/scroll_aids.rs | 253 ++++++++++++ src/layout/seeded.rs | 26 ++ src/layout/sheet.rs | 302 +++++++++++++++ src/layout/sidebar.rs | 95 +++++ src/layout/split.rs | 232 +++++++++++ src/layout/stack.rs | 135 +++++++ src/layout/surface.rs | 395 +++++++++++++++++++ src/layout/viewport.rs | 189 +++++++++ src/layout/workspace.rs | 105 +++++ src/lib.rs | 1 + src/primitives/backdrop.rs | 30 +- src/primitives/mod.rs | 2 +- src/theme/mod.rs | 2 +- src/theme/tokens.rs | 50 ++- tasks/ch01-10.md | 82 ++-- 37 files changed, 5283 insertions(+), 50 deletions(-) create mode 100644 examples/gallery/pages/layout/basics.rs create mode 100644 examples/gallery/pages/layout/mod.rs create mode 100644 examples/gallery/pages/layout/regions.rs create mode 100644 examples/gallery/pages/layout/scrolling.rs create mode 100644 examples/gallery/pages/layout/shells.rs create mode 100644 examples/gallery/pages/layout/systems.rs create mode 100644 src/layout/collapse.rs create mode 100644 src/layout/dock/mod.rs create mode 100644 src/layout/dock/view.rs create mode 100644 src/layout/floating.rs create mode 100644 src/layout/grid.rs create mode 100644 src/layout/mod.rs create mode 100644 src/layout/page.rs create mode 100644 src/layout/panes.rs create mode 100644 src/layout/scroll.rs create mode 100644 src/layout/scroll_aids.rs create mode 100644 src/layout/seeded.rs create mode 100644 src/layout/sheet.rs create mode 100644 src/layout/sidebar.rs create mode 100644 src/layout/split.rs create mode 100644 src/layout/stack.rs create mode 100644 src/layout/surface.rs create mode 100644 src/layout/viewport.rs create mode 100644 src/layout/workspace.rs diff --git a/AGENTS.md b/AGENTS.md index 041fc74..6de1f03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. ## Commands - `cargo run --example gallery` opens the gallery. `-- --page ` starts on a page. -- `cargo run --example gallery -- --capture ` writes PNGs of every page, light and dark, top to bottom. macOS only. +- `cargo run --example gallery -- --capture ` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states. macOS only. - `cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`. - `rm -rf target/debug/incremental` after each task item keeps the disk lean. - `scripts/icons.sh ...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`. @@ -49,3 +49,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 2026-09-24: gpui 0.2.2 has no accessibility tree and no tray, badge, or notification API. Those entries carry `blocked` or `prove`. - 2026-09-24: the gallery photographs its own window with `CGWindowListCreateImage`. No Screen Recording permission needed. - 2026-09-24: license MIT OR Apache-2.0. +- 2026-09-25: layouts persist as versioned JSON through serde. Unknown fields are ignored. Newer versions, unknown panels and bad pane trees are refused, and restore is all or nothing. diff --git a/Cargo.lock b/Cargo.lock index 9fda5e5..b5981b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1530,6 +1530,8 @@ dependencies = [ "log", "regex", "rust-embed", + "serde", + "serde_json", "smallvec", "unicode-segmentation", ] diff --git a/Cargo.toml b/Cargo.toml index fccd519..eab184d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ jiff = "0.2" log = "0.4" regex = "1" rust-embed = "8.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" smallvec = "1" unicode-segmentation = "1" diff --git a/TASKS.md b/TASKS.md index 0567533..470701c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -19,7 +19,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T00 Foundation: package, theme, motion, assets, Icon, Button, gallery shell, docs - [x] T01 Primitives — `tasks/ch01-10.md` - [x] T02 Typography -- [ ] T03 Layout +- [x] T03 Layout - [ ] T04 Window & Shell - [ ] T05 Buttons & Actions - [ ] T06a Forms · Text @@ -110,3 +110,6 @@ Per-component lines live in `tasks/`. Tags there: | T01 | 3 | FAIL | two-line doc comment; fixed after the cap, verified by scan, no fourth review | | T02 | 1 | FAIL | stale selection; strftime and zone fallbacks; \\text flattening; probes missed targets; ellipsis overflow; u64 precision; caret sizes; LineClamp copy; 500-line wording | | T02 | 2 | PASS | | +| T03 | 1 | FAIL | restore kept cached split and float state; drags lacked an owner; weights unchecked; pane id overflow broke atomic restore; sticky copy escaped the clip; wheel propagated past the viewport; drawer pull lost the pointer; no top dock; grip sizes raw | +| T03 | 2 | FAIL | closing a pane made NaN or infinite weights; drag ghost text black in dark | +| T03 | 3 | FAIL | even split of a denormal weight underflowed; fixed after the cap by normalizing on close and split, verified by tests and capture, no fourth review | diff --git a/examples/gallery/pages/layout/basics.rs b/examples/gallery/pages/layout/basics.rs new file mode 100644 index 0000000..08a1671 --- /dev/null +++ b/examples/gallery/pages/layout/basics.rs @@ -0,0 +1,256 @@ +use ely_gpui_component::{ + layout::{AspectRatio, Container, Masonry, SimpleGrid, ZStack, h_stack, v_stack}, + theme::{ActiveTheme, ContainerSize, Radius, TextSize}, + typography::Caption, +}; +use gpui::{App, Div, Hsla, IntoElement, ParentElement, SharedString, Styled, div, px}; + +use crate::ui::{code, section, specimen, specimens}; + +pub fn tile(label: impl Into, cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_center() + .px_3() + .py_2() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) + .border_1() + .border_color(theme.colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(label.into()) +} + +fn outline(cx: &App) -> Div { + let theme = cx.theme(); + div() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) +} + +pub fn stacks(cx: &App) -> impl IntoElement + use<> { + section( + "Stack / HStack / VStack", + "h_stack() and v_stack(): flex rows and columns.", + cx, + ) + .child( + specimens() + .child(specimen( + "h_stack", + h_stack() + .gap_2() + .child(tile("A", cx)) + .child(tile("B", cx)) + .child(tile("C", cx)), + cx, + )) + .child(specimen( + "v_stack", + v_stack().gap_2().child(tile("A", cx)).child(tile("B", cx)), + cx, + )), + ) +} + +pub fn flex(cx: &App) -> impl IntoElement + use<> { + section("Flex", "gpui's .flex(), with grow and justify.", cx).child( + outline(cx) + .p_2() + .w(px(420.0)) + .flex() + .gap_2() + .child(tile("fixed", cx)) + .child(tile("flex_1", cx).flex_1()) + .child(tile("fixed", cx)), + ) +} + +pub fn grid(cx: &App) -> impl IntoElement + use<> { + section("Grid / GridItem", "gpui's .grid() and .col_span().", cx).child( + div() + .w(px(420.0)) + .grid() + .grid_cols(3) + .gap_2() + .child(tile("span 2", cx).col_span(2)) + .child(tile("1", cx)) + .child(tile("1", cx)) + .child(tile("span 2", cx).col_span(2)), + ) +} + +pub fn simple_grid(cx: &App) -> impl IntoElement + use<> { + section( + "SimpleGrid", + "As many equal columns as fit at a minimum width.", + cx, + ) + .child( + SimpleGrid::new("simple-grid", px(140.0), px(12.0)) + .max_w(px(720.0)) + .children((1..=7).map(|ix| tile(format!("Item {ix}"), cx).h(px(56.0)))), + ) +} + +pub fn masonry(cx: &App) -> impl IntoElement + use<> { + let heights = [72.0, 120.0, 56.0, 96.0, 140.0, 64.0, 88.0, 110.0]; + section("Masonry", "Each item joins the shortest column.", cx).child( + div().w(px(560.0)).child( + Masonry::new("masonry", 3, px(10.0)).children( + heights + .iter() + .enumerate() + .map(|(ix, height)| tile(format!("{}", ix + 1), cx).h(px(*height))), + ), + ), + ) +} + +pub fn center(cx: &App) -> impl IntoElement + use<> { + section("Center", "flex, items_center, justify_center.", cx).child( + outline(cx) + .w(px(280.0)) + .h(px(96.0)) + .flex() + .items_center() + .justify_center() + .child(tile("centered", cx)), + ) +} + +pub fn container(cx: &App) -> impl IntoElement + use<> { + section( + "Container", + "A centered column capped by a width token.", + cx, + ) + .child( + outline(cx).w_full().py_3().child( + Container::new(ContainerSize::Sm).child( + div() + .h(px(40.0)) + .rounded(cx.theme().radius(Radius::Md)) + .bg(cx.theme().colors.sunken), + ), + ), + ) + .child(code("Container::new(ContainerSize::Sm)", cx)) +} + +pub fn aspect_ratio(cx: &App) -> impl IntoElement + use<> { + let fill = |ratio: &str, cx: &App| { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(cx.theme().colors.sunken) + .text_color(cx.theme().colors.fg_subtle) + .child(SharedString::from(ratio.to_string())) + }; + section("AspectRatio", "Width fills; height follows the ratio.", cx).child( + specimens() + .child( + div().w(px(240.0)).child( + AspectRatio::new(16.0 / 9.0) + .rounded(cx.theme().radius(Radius::Lg)) + .child(fill("16 : 9", cx)), + ), + ) + .child( + div().w(px(120.0)).child( + AspectRatio::new(1.0) + .rounded(cx.theme().radius(Radius::Lg)) + .child(fill("1 : 1", cx)), + ), + ), + ) +} + +pub fn inset(cx: &App) -> impl IntoElement + use<> { + section("Inset / Padding", "gpui's .p_*() on the rem scale.", cx).child( + specimens() + .child(specimen( + "p_2", + outline(cx).p_2().child(tile("content", cx)), + cx, + )) + .child(specimen( + "p_6", + outline(cx).p_6().child(tile("content", cx)), + cx, + )), + ) +} + +pub fn wrap(cx: &App) -> impl IntoElement + use<> { + let tags = [ + "design", "motion", "type", "color", "layout", "grid", "rhythm", "space", + ]; + section( + "Wrap", + "gpui's .flex_wrap() moves overflow to the next line.", + cx, + ) + .child( + outline(cx) + .p_3() + .w(px(320.0)) + .flex() + .flex_wrap() + .gap_2() + .children(tags.map(|tag| tile(tag, cx))), + ) +} + +pub fn absolute(cx: &App) -> impl IntoElement + use<> { + section( + "Absolute / Positioned", + "gpui's .absolute() with edge offsets.", + cx, + ) + .child( + outline(cx) + .relative() + .w(px(280.0)) + .h(px(112.0)) + .child(tile("top left", cx).absolute().top_2().left_2()) + .child(tile("bottom right", cx).absolute().bottom_2().right_2()), + ) +} + +pub fn z_stack(cx: &App) -> impl IntoElement + use<> { + let colors = &cx.theme().colors; + let layer = |color: Hsla, inset: f32| { + div() + .size_full() + .p(px(inset)) + .child(div().size_full().rounded_md().bg(color)) + }; + section( + "Layer / ZStack", + "The first child sets the size; the rest cover it.", + cx, + ) + .child( + ZStack::new() + .w(px(200.0)) + .h(px(120.0)) + .child(div().size_full().rounded_lg().bg(colors.sunken)) + .child(layer(colors.hover, 16.0)) + .child(layer(colors.active, 32.0)) + .child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Caption::new("three layers")), + ), + ) +} diff --git a/examples/gallery/pages/layout/mod.rs b/examples/gallery/pages/layout/mod.rs new file mode 100644 index 0000000..b20e900 --- /dev/null +++ b/examples/gallery/pages/layout/mod.rs @@ -0,0 +1,128 @@ +mod basics; +mod regions; +mod scrolling; +mod shells; +mod systems; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 3, + slug: "layout", + title: "Layout", + summary: "Stacks, grids, splits, docks. Structure that stays out of the way.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::Hover("scroll-area"), + Step::Wait(300), + Step::Shot("scroll-hover"), + Step::DownAt("scrollbar", 313.0, 12.0), + Step::DragTo("scrollbar", 313.0, 110.0), + Step::UpAt("scrollbar", 313.0, 110.0), + Step::Shot("scrollbar-drag"), + Step::DownAt("scrollbar", 313.0, 110.0), + Step::DragTo("scrollbar", 313.0, 12.0), + Step::UpAt("scrollbar", 313.0, 12.0), + Step::DownAt("sticky", 313.0, 12.0), + Step::DragTo("sticky", 313.0, 76.0), + Step::UpAt("sticky", 313.0, 76.0), + Step::Shot("sticky-pushed"), + Step::DownAt("sticky", 313.0, 76.0), + Step::DragTo("sticky", 313.0, 12.0), + Step::UpAt("sticky", 313.0, 12.0), + Step::Click("collapse-toggle"), + Step::Wait(400), + Step::Shot("collapsed"), + Step::Click("collapse-toggle"), + Step::Click("sidebar-toggle"), + Step::Wait(400), + Step::Shot("sidebar-collapsed"), + Step::Click("sidebar-toggle"), + Step::Rest, + Step::Click("sheet-open"), + Step::Wait(400), + Step::Shot("sheet"), + Step::Click("sheet-open"), + Step::Click("drawer-open"), + Step::Wait(400), + Step::Shot("drawer"), + Step::DownAt("drawer-body", 10.0, -28.0), + Step::DragTo("drawer-body", 10.0, 172.0), + Step::UpAt("drawer-body", 10.0, 172.0), + Step::Wait(400), + Step::Shot("drawer-pulled"), + Step::Click("floating-open"), + Step::Wait(300), + Step::Shot("floating"), + Step::Click("floating-open"), + Step::DownAt("dock", 300.0, 240.0), + Step::DragTo("dock", 380.0, 60.0), + Step::Wait(200), + Step::Shot("dock-zones"), + Step::UpAt("dock", 380.0, 60.0), + Step::Wait(200), + Step::Shot("dock-top"), + Step::DownAt("dock", 300.0, 20.0), + Step::DragTo("dock", 380.0, 330.0), + Step::UpAt("dock", 380.0, 330.0), + Step::Click("workspace-save"), + Step::DownAt("panes", 380.0, 150.0), + Step::DragTo("panes", 560.0, 150.0), + Step::UpAt("panes", 560.0, 150.0), + Step::Shot("panes-dragged"), + Step::Click("workspace-restore"), + Step::Wait(300), + Step::Shot("panes-restored"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(basics::stacks(cx)) + .child(basics::flex(cx)) + .child(basics::grid(cx)) + .child(basics::simple_grid(cx)) + .child(basics::masonry(cx)) + .child(basics::center(cx)) + .child(basics::container(cx)) + .child(basics::aspect_ratio(cx)) + .child(basics::inset(cx)) + .child(basics::wrap(cx)) + .child(basics::absolute(cx)) + .child(basics::z_stack(cx)) + .child(scrolling::scroll_area(cx)) + .child(scrolling::scrollbar(window, cx)) + .child(scrolling::scroll_shadow(window, cx)) + .child(scrolling::scroll_to_top(window, cx)) + .child(scrolling::sticky_header(window, cx)) + .child(regions::split_pane(cx)) + .child(regions::resize_handles(cx)) + .child(regions::collapsible(window, cx)) + .child(regions::accordion(cx)) + .child(regions::card(cx)) + .child(regions::panel(cx)) + .child(regions::section_demo(cx)) + .child(regions::fieldset(cx)) + .child(regions::frame_demo(cx)) + .child(regions::well(cx)) + .child(shells::sidebar(window, cx)) + .child(shells::drawer(window, cx)) + .child(shells::sheet(window, cx)) + .child(shells::page(cx)) + .child(shells::app_shell(cx)) + .child(shells::master_detail(cx)) + .child(systems::dock(window, cx)) + .child(systems::dock_zones(cx)) + .child(systems::floating(window, cx)) + .child(systems::panes(window, cx)) + .child(systems::workspace(window, cx)) + .child(systems::viewport(cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/layout/regions.rs b/examples/gallery/pages/layout/regions.rs new file mode 100644 index 0000000..85995fe --- /dev/null +++ b/examples/gallery/pages/layout/regions.rs @@ -0,0 +1,252 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton}, + layout::{ + Accordion, AccordionItem, Card, CardHeader, Collapsible, Fieldset, Frame, Panel, Section, + SplitPane, Well, resize_handle, + }, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Radius}, + typography::{Caption, Label, Paragraph}, +}; +use gpui::{App, Axis, IntoElement, ParentElement, Styled, Window, div, px}; + +use super::basics::tile; +use crate::{ + probe::probe, + ui::{code, row, section}, +}; + +fn pane(label: &'static str, cx: &App) -> gpui::Div { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(cx.theme().colors.sunken) + .text_color(cx.theme().colors.fg_subtle) + .child(label) +} + +fn frame(cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .w(px(560.0)) + .h(px(220.0)) + .overflow_hidden() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) +} + +pub fn split_pane(cx: &App) -> impl IntoElement + use<> { + section( + "SplitPane", + "Drag a divider. Panes nest across and down.", + cx, + ) + .child(probe( + "split", + frame(cx).child( + SplitPane::new("split-outer", Axis::Horizontal, px(96.0)) + .sizes(&[0.3, 0.7]) + .pane(pane("files", cx)) + .pane( + SplitPane::new("split-inner", Axis::Vertical, px(48.0)) + .pane(pane("editor", cx)) + .pane(pane("terminal", cx)), + ), + ), + )) + .child(code( + "ResizablePanel and ThreeColumnLayout are SplitPane.", + cx, + )) +} + +pub fn resize_handles(cx: &App) -> impl IntoElement + use<> { + section( + "ResizeHandle", + "A hairline with a wide grab. Hover lights it.", + cx, + ) + .child( + row() + .gap_8() + .child( + div() + .h(px(80.0)) + .child(resize_handle("handle-x", Axis::Horizontal, cx)), + ) + .child( + div() + .w(px(160.0)) + .child(resize_handle("handle-y", Axis::Vertical, cx)), + ), + ) +} + +pub fn collapsible(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = window.use_keyed_state("collapse-open", cx, |_, _| true); + let is_open = *open.read(cx); + section( + "Collapsible", + "Height animates to the measured content.", + cx, + ) + .child( + div() + .w(px(420.0)) + .flex() + .flex_col() + .gap_2() + .child(probe( + "collapse-toggle", + Button::new( + "collapse-toggle", + if is_open { "Collapse" } else { "Expand" }, + ) + .variant(ButtonVariant::Outline) + .trailing_icon(if is_open { + IconName::ChevronUp + } else { + IconName::ChevronDown + }) + .on_click(move |_, _, cx| { + open.update(cx, |open, cx| { + *open = !*open; + cx.notify(); + }) + }), + )) + .child( + Collapsible::new("collapse", is_open).child( + Paragraph::new( + "Content keeps its natural height. The frame grows to it, then lets go.", + ) + .pt_2(), + ), + ), + ) +} + +pub fn accordion(cx: &App) -> impl IntoElement + use<> { + section( + "Accordion / AccordionItem", + "One open at a time. The chevron turns with it.", + cx, + ) + .child(probe( + "accordion", + div().w(px(480.0)).child( + Accordion::new("faq") + .first_open() + .item( + AccordionItem::new("What is Ely?") + .child("A component library for gpui, in light and dark."), + ) + .item( + AccordionItem::new("Does it animate?") + .child("Where motion explains a change, and nowhere else."), + ) + .item( + AccordionItem::new("Can I theme it?") + .child("Every color, size and radius comes from one token set."), + ), + ), + )) +} + +pub fn card(cx: &App) -> impl IntoElement + use<> { + section( + "Card / CardHeader / CardBody / CardFooter", + "Flat: a hairline, no shadow.", + cx, + ) + .child( + Card::new() + .w(px(380.0)) + .header( + CardHeader::new("Storage") + .description("Across all workspaces") + .action(IconButton::new("card-more", IconName::Ellipsis).size(ControlSize::Sm)), + ) + .child(Label::new("42.8 GB of 100 GB")) + .child(Caption::new("Media takes the most room.")) + .footer( + div() + .flex() + .gap_2() + .child(Button::new("card-cancel", "Later").variant(ButtonVariant::Ghost)) + .child(Button::new("card-go", "Manage").primary()), + ), + ) +} + +pub fn panel(cx: &App) -> impl IntoElement + use<> { + section( + "Panel / PanelHeader", + "An app region: a quiet header bar and a body.", + cx, + ) + .child( + Panel::new("Outline") + .w(px(320.0)) + .h(px(160.0)) + .action(IconButton::new("panel-filter", IconName::Filter).size(ControlSize::Sm)) + .action(IconButton::new("panel-more", IconName::Ellipsis).size(ControlSize::Sm)) + .child( + div() + .p_3() + .flex() + .flex_col() + .gap_2() + .child(tile("fn render", cx)) + .child(tile("struct Theme", cx)), + ), + ) +} + +pub fn section_demo(cx: &App) -> impl IntoElement + use<> { + section( + "Section / SectionHeader", + "A page block: title, note, action.", + cx, + ) + .child( + Section::new("Members") + .w(px(520.0)) + .description("People who can open this workspace.") + .action(Button::new("invite", "Invite").icon(IconName::Plus)) + .child( + row() + .child(tile("Ada", cx)) + .child(tile("Grace", cx)) + .child(tile("Linus", cx)), + ), + ) +} + +pub fn fieldset(cx: &App) -> impl IntoElement + use<> { + section("Group / Fieldset", "Related fields under one name.", cx).child( + Fieldset::new("Shipping address") + .w(px(420.0)) + .child(tile("Street", cx)) + .child(row().child(tile("City", cx)).child(tile("Postcode", cx))), + ) +} + +pub fn frame_demo(cx: &App) -> impl IntoElement + use<> { + section("Frame", "A bordered region with no fill.", cx).child( + Frame::new() + .w(px(320.0)) + .child(Caption::new("Framed content sits on the page tone.")), + ) +} + +pub fn well(cx: &App) -> impl IntoElement + use<> { + section("Well", "A recessed region on the sunken tone.", cx).child( + Well::new() + .w(px(320.0)) + .child(Caption::new("Wells hold secondary material.")), + ) +} diff --git a/examples/gallery/pages/layout/scrolling.rs b/examples/gallery/pages/layout/scrolling.rs new file mode 100644 index 0000000..e8d16ff --- /dev/null +++ b/examples/gallery/pages/layout/scrolling.rs @@ -0,0 +1,164 @@ +use ely_gpui_component::{ + layout::{ScrollArea, ScrollShadow, ScrollToTop, Scrollbar, StickyHeader}, + theme::{ActiveTheme, Radius, TextSize}, +}; +use gpui::{ + App, Axis, FontWeight, InteractiveElement, IntoElement, ParentElement, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, +}; + +use crate::{probe::probe, ui::section}; + +fn lines(count: usize, cx: &App) -> impl Iterator + '_ { + let theme = cx.theme(); + (1..=count).map(move |ix| { + div() + .px_3() + .py_2() + .border_b_1() + .border_color(theme.colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .child(SharedString::from(format!( + "Line {ix:02} · quiet content that scrolls" + ))) + }) +} + +fn well(cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .relative() + .w(px(320.0)) + .h(px(200.0)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() +} + +pub fn scroll_area(cx: &App) -> impl IntoElement + use<> { + section( + "ScrollArea", + "Overlay scrollbars appear while scrolling or hovered.", + cx, + ) + .child(probe( + "scroll-area", + well(cx).child( + ScrollArea::new("scroll-area") + .size_full() + .children(lines(24, cx)), + ), + )) +} + +fn handle(window: &mut Window, cx: &mut App, key: &'static str) -> ScrollHandle { + window + .use_keyed_state(key, cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone() +} + +pub fn scrollbar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let scroll = handle(window, cx, "bar-handle"); + section( + "Scrollbar", + "Any ScrollHandle can wear one. Drag the thumb.", + cx, + ) + .child(probe( + "scrollbar", + well(cx) + .child( + div() + .id("bar-body") + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .children(lines(30, cx)), + ) + .child(Scrollbar::new("bar", &scroll, Axis::Vertical)), + )) +} + +pub fn scroll_shadow(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let scroll = handle(window, cx, "shadow-handle"); + section( + "ScrollShadow", + "A soft edge wherever more content waits.", + cx, + ) + .child( + well(cx) + .child( + div() + .id("shadow-body") + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .children(lines(30, cx)), + ) + .child(ScrollShadow::new(&scroll)), + ) +} + +pub fn scroll_to_top(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let scroll = handle(window, cx, "top-handle"); + section( + "ScrollToTop", + "Past half a screen, a button eases back up.", + cx, + ) + .child( + well(cx) + .child( + div() + .id("top-body") + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .children(lines(40, cx)), + ) + .child(ScrollToTop::new("to-top", &scroll)), + ) +} + +pub fn sticky_header(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let scroll = handle(window, cx, "sticky-handle"); + let groups = ["Today", "Yesterday", "Earlier"]; + section( + "StickyHeader", + "Each group's header pins until the next pushes it off.", + cx, + ) + .child(probe( + "sticky", + well(cx) + .child( + div() + .id("sticky-body") + .size_full() + .overflow_y_scroll() + .track_scroll(&scroll) + .children(groups.iter().enumerate().map(|(ix, group)| { + let title = SharedString::from(*group); + StickyHeader::new(("sticky", ix), &scroll, move |_, cx| { + let theme = cx.theme(); + div() + .px_3() + .py_1p5() + .bg(theme.colors.sunken) + .border_b_1() + .border_color(theme.colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg_muted) + .child(title.clone()) + .into_any_element() + }) + .children(lines(8, cx)) + })), + ) + .child(Scrollbar::new("sticky-bar", &scroll, Axis::Vertical)), + )) +} diff --git a/examples/gallery/pages/layout/shells.rs b/examples/gallery/pages/layout/shells.rs new file mode 100644 index 0000000..cd358d7 --- /dev/null +++ b/examples/gallery/pages/layout/shells.rs @@ -0,0 +1,233 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + layout::{AppShell, Drawer, MasterDetail, Page, Sheet, Sidebar, h_stack}, + primitives::{Icon, IconName, Place}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{Caption, Paragraph}, +}; +use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px}; + +use super::basics::tile; +use crate::{ + probe::probe, + ui::{row, section}, +}; + +pub fn frame(width: f32, height: f32, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .w(px(width)) + .h(px(height)) + .overflow_hidden() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) +} + +fn toggle(window: &mut Window, cx: &mut App, key: &'static str) -> (Entity, bool) { + let state = window.use_keyed_state(key, cx, |_, _| false); + let value = *state.read(cx); + (state, value) +} + +fn flip(state: &Entity, cx: &mut App) { + state.update(cx, |value, cx| { + *value = !*value; + cx.notify(); + }) +} + +fn nav(collapsed: bool, cx: &App) -> impl IntoElement + use<> { + let items = [ + (IconName::House, "Home"), + (IconName::Inbox, "Inbox"), + (IconName::Settings, "Settings"), + ]; + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_0p5() + .p_2() + .children(items.map(|(icon, label)| { + h_stack() + .gap_3() + .h(theme.control_height(ControlSize::Md)) + .px_2() + .rounded(theme.radius(Radius::Md)) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child( + Icon::new(icon) + .size(IconSize::Sm) + .color(theme.colors.fg_muted), + ) + .when(!collapsed, |item| item.child(label)) + })) +} + +pub fn sidebar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (state, collapsed) = toggle(window, cx, "sidebar-collapsed"); + section("Sidebar", "Folds to an icon rail; the width eases.", cx).child( + frame(560.0, 200.0, cx) + .flex() + .child(Sidebar::new("demo-sidebar", collapsed).child(nav(collapsed, cx))) + .child( + div().flex_1().p_4().flex().items_start().child(probe( + "sidebar-toggle", + Button::new( + "sidebar-toggle", + if collapsed { "Expand" } else { "Collapse" }, + ) + .variant(ButtonVariant::Outline) + .icon(IconName::PanelLeft) + .on_click(move |_, _, cx| flip(&state, cx)), + )), + ), + ) +} + +pub fn drawer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (state, open) = toggle(window, cx, "drawer-open"); + let close = state.clone(); + section( + "Drawer", + "Rises from the bottom. Pull the handle down to dismiss.", + cx, + ) + .child( + row() + .child(probe( + "drawer-open", + Button::new("drawer-open", "Open drawer") + .on_click(move |_, _, cx| flip(&state, cx)), + )) + .when(open, |row| { + row.child( + Drawer::new("demo-drawer", move |_, cx| flip(&close, cx)) + .child(probe( + "drawer-body", + Paragraph::new( + "Drawers hold short tasks close to where the hand already is.", + ), + )) + .child(Caption::new("Drag the handle down, or click the scrim.")), + ) + }), + ) +} + +pub fn sheet(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (state, open) = toggle(window, cx, "sheet-open"); + let close = state.clone(); + section("Sheet", "Slides in from an edge over a scrim.", cx).child( + row() + .child(probe( + "sheet-open", + Button::new("sheet-open", "Open sheet").on_click(move |_, _, cx| flip(&state, cx)), + )) + .when(open, |row| { + row.child( + Sheet::new("demo-sheet", Place::Right, "Filters", move |_, cx| { + flip(&close, cx) + }) + .child(Paragraph::new( + "Sheets keep context visible while a side task runs.", + )) + .child(tile("Status: open", cx)) + .child(tile("Owner: anyone", cx)), + ) + }), + ) +} + +pub fn page(cx: &App) -> impl IntoElement + use<> { + section( + "Page / PageHeader / PageContent", + "Title, note and actions over the body.", + cx, + ) + .child( + frame(640.0, 240.0, cx).child( + Page::new("Projects") + .subtitle("Everything your team is shipping.") + .action( + Button::new("page-new", "New project") + .primary() + .icon(IconName::Plus), + ) + .child( + row() + .child(tile("Atlas", cx)) + .child(tile("Beacon", cx)) + .child(tile("Compass", cx)), + ), + ), + ) +} + +pub fn app_shell(cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let bar = |label: &'static str, cx: &App| { + div() + .h(cx.theme().control_height(ControlSize::Md)) + .px_3() + .flex() + .items_center() + .text_size(cx.theme().text_size(TextSize::Xs)) + .text_color(cx.theme().colors.fg_subtle) + .child(label) + }; + section("AppShell", "Title bar, sidebar, content, status bar.", cx).child( + frame(640.0, 260.0, cx).child( + AppShell::new() + .title_bar( + bar("Ely — Projects", cx) + .border_b_1() + .border_color(theme.colors.border), + ) + .sidebar(Sidebar::new("shell-sidebar", false).child(nav(false, cx))) + .status_bar( + bar("main · 3 changes · UTF-8", cx) + .border_t_1() + .border_color(theme.colors.border), + ) + .child(div().p_4().child(Paragraph::new( + "The shell holds the frame. Your content fills the middle.", + ))), + ), + ) +} + +pub fn master_detail(cx: &App) -> impl IntoElement + use<> { + let items = [ + "Quarterly review", + "Design sync", + "Launch plan", + "Hiring loop", + ]; + section( + "MasterDetail", + "A list beside the chosen item. The divider drags.", + cx, + ) + .child( + frame(640.0, 220.0, cx).child(MasterDetail::new( + "master-detail", + div() + .p_2() + .flex() + .flex_col() + .gap_1() + .children(items.map(|item| tile(item, cx))), + div() + .p_5() + .flex() + .flex_col() + .gap_2() + .child(Paragraph::new("Quarterly review")) + .child(Caption::new("Numbers, notes and the next three moves.")), + px(160.0), + )), + ) +} diff --git a/examples/gallery/pages/layout/systems.rs b/examples/gallery/pages/layout/systems.rs new file mode 100644 index 0000000..8a1ac57 --- /dev/null +++ b/examples/gallery/pages/layout/systems.rs @@ -0,0 +1,271 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + layout::{Dock, DockPanel, DockSide, FloatingPanel, PaneGroup, Transform, Viewport, Workspace}, + primitives::IconName, + theme::{ActiveTheme, Radius, TextSize}, + typography::{Caption, Paragraph}, +}; +use gpui::{ + AnyElement, App, Axis, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, + point, prelude::*, px, +}; + +use super::{basics::tile, shells::frame}; +use crate::{ + probe::probe, + ui::{code, row, section}, +}; + +fn demo_dock(cx: &mut App) -> Dock { + let panel = |id: &str, title: &str, icon| DockPanel { + id: SharedString::from(id.to_string()), + title: SharedString::from(title.to_string()), + icon, + }; + let mut dock = Dock::new( + |_, cx| { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(cx.theme().colors.fg_subtle) + .child("Center. Drag a tab onto a zone.") + .into_any_element() + }, + |id, _, cx| { + div() + .p_3() + .text_size(cx.theme().text_size(TextSize::Sm)) + .text_color(cx.theme().colors.fg_muted) + .child(SharedString::from(format!("{id} panel"))) + .into_any_element() + }, + cx, + ); + dock.add(panel("files", "Files", IconName::Folder), DockSide::Left); + dock.add( + panel("outline", "Outline", IconName::Layers), + DockSide::Left, + ); + dock.add(panel("search", "Search", IconName::Search), DockSide::Right); + dock.add( + panel("terminal", "Terminal", IconName::Terminal), + DockSide::Bottom, + ); + dock +} + +fn demo_panes(cx: &mut Context) -> PaneGroup { + let mut group = PaneGroup::new(|id, _, cx| { + div() + .size_full() + .p_3() + .text_color(cx.theme().colors.fg_subtle) + .child(SharedString::from(format!("buffer {id}"))) + .into_any_element() + }); + group.split(1, Axis::Horizontal, cx); + group +} + +/// The dock and pane group shared by the demos and the workspace section. +fn systems(window: &mut Window, cx: &mut App) -> (Entity, Entity) { + let dock = window.use_keyed_state("demo-dock", cx, |_, cx| demo_dock(cx)); + let panes = window.use_keyed_state("demo-panes", cx, |_, cx| demo_panes(cx)); + (dock, panes) +} + +pub fn dock(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (dock, _) = systems(window, cx); + section( + "Dock / DockPanel", + "Panels dock on any edge, tab together, and float.", + cx, + ) + .child(probe("dock", frame(760.0, 380.0, cx).child(dock))) +} + +pub fn dock_zones(cx: &App) -> impl IntoElement + use<> { + section( + "DockZone / DropIndicator", + "While a tab drags, the drop zones appear and the one below lights.", + cx, + ) + .child(Caption::new( + "Drag a tab across the dock above to see them.", + )) +} + +pub fn floating(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = window.use_keyed_state("floating-open", cx, |_, _| false); + let shown = *open.read(cx); + section( + "FloatingPanel", + "Drag the title bar. It stays inside the window.", + cx, + ) + .child( + row() + .child(probe( + "floating-open", + Button::new( + "floating-open", + if shown { "Hide panel" } else { "Show panel" }, + ) + .on_click(move |_, _, cx| { + open.update(cx, |open, cx| { + *open = !*open; + cx.notify(); + }) + }), + )) + .when(shown, |row| { + row.child( + FloatingPanel::new("demo-float", "Inspector", point(px(720.0), px(160.0))) + .w(px(240.0)) + .child(Caption::new("Floating panels sit above the page.")) + .child(tile("Opacity 100%", cx)), + ) + }), + ) +} + +pub fn panes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (_, panes) = systems(window, cx); + section( + "Pane / PaneGroup", + "Split right or down from any pane; close all but one.", + cx, + ) + .child(probe("panes", frame(760.0, 300.0, cx).child(panes))) +} + +pub fn workspace(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let (dock, panes) = systems(window, cx); + let saved = window.use_keyed_state("workspace-json", cx, |_, _| None::); + let report = window.use_keyed_state("workspace-report", cx, |_, _| SharedString::from("")); + let preview: Option = saved.read(cx).clone(); + let note = report.read(cx).clone(); + let (save_dock, save_panes, save_to) = (dock.clone(), panes.clone(), saved.clone()); + let (load_from, load_note) = (saved, report.clone()); + section( + "Workspace", + "Layout to versioned JSON and back, all or nothing.", + cx, + ) + .child( + row() + .child(probe( + "workspace-save", + Button::new("workspace-save", "Save layout") + .icon(IconName::Save) + .on_click(move |_, _, cx| { + let json = + Workspace::capture(save_dock.read(cx), save_panes.read(cx)).to_json(); + save_to.update(cx, |saved, cx| { + *saved = Some(json); + cx.notify(); + }) + }), + )) + .child(probe( + "workspace-restore", + Button::new("workspace-restore", "Restore") + .variant(ButtonVariant::Ghost) + .icon(IconName::RotateCcw) + .disabled(preview.is_none()) + .on_click(move |_, _, cx| { + let json = load_from + .read(cx) + .clone() + .expect("restore is disabled until a layout is saved"); + let outcome = Workspace::from_json(&json) + .and_then(|layout| layout.apply(&dock, &panes, cx)); + let text = match outcome { + Ok(()) => "Restored.".to_string(), + Err(error) => format!("Refused: {error:#}"), + }; + load_note.update(cx, |note, cx| { + *note = SharedString::from(text); + cx.notify(); + }) + }), + )) + .child(Caption::new(note)), + ) + .when_some(preview, |block, json| { + let excerpt: String = json.lines().take(12).collect::>().join("\n"); + block.child(code(excerpt, cx)) + }) +} + +fn blob( + at: gpui::Point, + size: f32, + transform: Transform, + label: &'static str, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let local = transform.apply(at); + div() + .absolute() + .left(local.x) + .top(local.y) + .w(px(size) * transform.scale) + .h(px(size * 0.6) * transform.scale) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border_strong) + .bg(theme.colors.surface) + .flex() + .items_center() + .justify_center() + .text_color(theme.colors.fg_muted) + .child(label) + .into_any_element() +} + +pub fn viewport(cx: &App) -> impl IntoElement + use<> { + section( + "Viewport", + "Drag to pan. Hold ⌘ or Ctrl and scroll to zoom at the pointer.", + cx, + ) + .child(probe( + "viewport", + frame(640.0, 260.0, cx) + .bg(cx.theme().colors.sunken) + .child(Viewport::new("demo-viewport", |transform, _, cx| { + div() + .size_full() + .child(blob( + point(px(60.0), px(50.0)), + 140.0, + transform, + "Idea", + cx, + )) + .child(blob( + point(px(260.0), px(110.0)), + 140.0, + transform, + "Draft", + cx, + )) + .child(blob( + point(px(460.0), px(40.0)), + 120.0, + transform, + "Ship", + cx, + )) + .into_any_element() + })), + )) + .child( + Paragraph::new("Content places itself with the transform; zoom stays between 0.1 and 8.") + .max_w(px(560.0)), + ) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 4c1cf59..bb10b60 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,4 +1,5 @@ mod buttons; +mod layout; mod primitives; mod theme; mod typography; @@ -20,6 +21,7 @@ pub struct Page { pub const ALL: &[Page] = &[ primitives::PAGE, typography::PAGE, + layout::PAGE, buttons::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index a2186b6..c72b3b0 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -14,17 +14,24 @@ pub enum Step { Click(&'static str), /// Clicks just inside the probe's right edge. ClickEnd(&'static str), + /// Presses at an offset from the probe's top-left corner. + DownAt(&'static str, f32, f32), + /// Drags from the last point to an offset, in small steps. + DragTo(&'static str, f32, f32), + UpAt(&'static str, f32, f32), Key(&'static str), Wait(u64), Shot(&'static str), } const FRAME: Duration = Duration::from_millis(120); +const DRAG_STEPS: u32 = 10; #[derive(Clone, Copy)] enum Mouse { Move, Down, + Drag, Up, } @@ -39,6 +46,7 @@ pub async fn play( path: impl Fn(&str) -> PathBuf, cx: &mut AsyncApp, ) -> Result<()> { + let mut last = point(px(0.0), px(0.0)); for step in steps { match *step { Step::Rest => park(window, cx)?, @@ -68,6 +76,30 @@ pub async fn play( send(window, Mouse::Down, at, cx)?; send(window, Mouse::Up, at, cx)?; } + Step::DownAt(key, x, y) => { + last = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); + send(window, Mouse::Move, last, cx)?; + send(window, Mouse::Down, last, cx)?; + } + Step::DragTo(key, x, y) => { + let goal = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); + for step in 1..=DRAG_STEPS { + let t = step as f32 / DRAG_STEPS as f32; + let at = point( + last.x + (goal.x - last.x) * t, + last.y + (goal.y - last.y) * t, + ); + send(window, Mouse::Drag, at, cx)?; + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + } + last = goal; + } + Step::UpAt(key, x, y) => { + last = target_bounds(window, key, cx).await?.origin + point(px(x), px(y)); + send(window, Mouse::Up, last, cx)?; + } Step::Key(stroke) => { let stroke = Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?; @@ -113,6 +145,7 @@ fn post(kind: Mouse, x: f64, y: f64) -> Result<()> { let kind = match kind { Mouse::Move => NSEventType::NSMouseMoved, Mouse::Down => NSEventType::NSLeftMouseDown, + Mouse::Drag => NSEventType::NSLeftMouseDragged, Mouse::Up => NSEventType::NSLeftMouseUp, }; unsafe { diff --git a/scripts/check.sh b/scripts/check.sh index bf0e444..fcd220c 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -12,6 +12,7 @@ for f in $files AGENTS.md README.md TASKS.md tasks/*.md; do n=$(wc -l < "$f") [ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; } done -raw=$(grep -rnE "(^|[^.[:alnum:]_])px\(" src --include='*.rs' | grep -v "^src/theme/tokens.rs\|^src/motion/" || true) +components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*') +raw=$(awk '/#\[cfg\(test\)\]/{nextfile} /(^|[^.[:alnum:]_])px\(/{print FILENAME":"FNR": "$0}' $components) [ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; } exit $fail diff --git a/src/layout/collapse.rs b/src/layout/collapse.rs new file mode 100644 index 0000000..cdad47f --- /dev/null +++ b/src/layout/collapse.rs @@ -0,0 +1,256 @@ +use std::time::Instant; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, + IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, canvas, div, radians, +}; +use smallvec::SmallVec; + +use crate::{ + motion, + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, IconSize, TextSize}, +}; + +struct Fold { + open: bool, + height: Pixels, + generation: u64, + changed: Instant, +} + +/// Region that opens and closes by animating its measured height. +#[derive(IntoElement)] +pub struct Collapsible { + id: ElementId, + open: bool, + body: SmallVec<[AnyElement; 2]>, +} + +impl Collapsible { + pub fn new(id: impl Into, open: bool) -> Self { + Self { + id: id.into(), + open, + body: SmallVec::new(), + } + } +} + +impl ParentElement for Collapsible { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +fn measured(state: Entity) -> impl IntoElement { + canvas( + move |bounds, _, cx| { + if state.read(cx).height != bounds.size.height { + state.update(cx, |fold, cx| { + fold.height = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +impl RenderOnce for Collapsible { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = self.open; + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Fold { + open, + height: Pixels::ZERO, + generation: 0, + changed: Instant::now(), + }); + if state.read(cx).open != open { + state.update(cx, |fold, _| { + fold.open = open; + fold.generation += 1; + fold.changed = Instant::now(); + }); + } + let (height, generation, changed) = { + let fold = state.read(cx); + (fold.height, fold.generation, fold.changed) + }; + let duration = motion::duration(motion::BASE, cx); + let settled = generation == 0 || changed.elapsed() >= duration; + let inner = div().relative().children(self.body).child(measured(state)); + let frame = div().id(self.id).overflow_hidden(); + match (settled, open) { + (true, true) => frame.child(inner).into_any_element(), + (true, false) => frame.into_any_element(), + (false, _) => frame + .child(inner) + .with_animation( + ("fold", generation), + Animation::new(duration).with_easing(motion::ease_in_out_cubic), + move |frame, t| frame.h(height * if open { t } else { 1.0 - t }), + ) + .into_any_element(), + } + } +} + +/// One titled, foldable row of an `Accordion`. +pub struct AccordionItem { + title: SharedString, + body: SmallVec<[AnyElement; 2]>, +} + +impl AccordionItem { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + body: SmallVec::new(), + } + } +} + +impl ParentElement for AccordionItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +#[derive(Default)] +struct Folds { + open: Vec, + toggles: Vec, +} + +/// Stacked items that fold; one open at a time unless `multiple`. +#[derive(IntoElement)] +pub struct Accordion { + id: ElementId, + items: Vec, + multiple: bool, + first_open: bool, +} + +impl Accordion { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + items: Vec::new(), + multiple: false, + first_open: false, + } + } + + pub fn item(mut self, item: AccordionItem) -> Self { + self.items.push(item); + self + } + + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + /// Starts with the first item open. + pub fn first_open(mut self) -> Self { + self.first_open = true; + self + } +} + +impl RenderOnce for Accordion { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.items.len(); + let first_open = self.first_open; + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Folds { + open: (0..count).map(|ix| first_open && ix == 0).collect(), + toggles: vec![0; count], + }); + if state.read(cx).open.len() != count { + state.update(cx, |folds, _| { + folds.open.resize(count, false); + folds.toggles.resize(count, 0); + }); + } + let theme = cx.theme(); + let (border, muted) = (theme.colors.border, theme.colors.fg_muted); + let title_size = theme.text_size(TextSize::Base); + let multiple = self.multiple; + let duration = motion::duration(motion::FAST, cx); + let folds = state.read(cx); + let rows: Vec<_> = self + .items + .into_iter() + .enumerate() + .map(|(ix, item)| (ix, item, folds.open[ix], folds.toggles[ix])) + .collect(); + div() + .id(self.id) + .flex() + .flex_col() + .border_t_1() + .border_color(border) + .children(rows.into_iter().map(|(ix, item, open, toggles)| { + let toggle = state.clone(); + let chevron = Icon::new(IconName::ChevronRight) + .size(IconSize::Sm) + .color(muted) + .with_animation( + ("chevron", toggles), + Animation::new(duration).with_easing(motion::ease_out_cubic), + move |icon, t| { + let turn = if open { t } else { 1.0 - t }; + icon.rotate(radians(turn * std::f32::consts::FRAC_PI_2)) + }, + ); + div() + .border_b_1() + .border_color(border) + .child( + div() + .id(ix) + .flex() + .items_center() + .justify_between() + .py_3() + .px_1() + .border_1() + .border_color(gpui::transparent_black()) + .cursor_pointer() + .tab_index(0) + .focus_ring(cx) + .text_size(title_size) + .font_weight(FontWeight::MEDIUM) + .child(item.title) + .child(chevron) + .on_click(move |_, _, cx| { + toggle.update(cx, |folds, cx| { + let now = !folds.open[ix]; + for (other, open) in folds.open.iter_mut().enumerate() { + let next = if other == ix { + now + } else { + *open && (multiple || !now) + }; + if next != *open { + *open = next; + folds.toggles[other] += 1; + } + } + cx.notify(); + }) + }), + ) + .child( + Collapsible::new(("body", ix), open) + .child(div().pb_4().px_1().text_color(muted).children(item.body)), + ) + })) + } +} diff --git a/src/layout/dock/mod.rs b/src/layout/dock/mod.rs new file mode 100644 index 0000000..1d18501 --- /dev/null +++ b/src/layout/dock/mod.rs @@ -0,0 +1,214 @@ +mod view; + +use std::rc::Rc; + +use gpui::{AnyElement, App, Context, Rems, SharedString, Window}; +use serde::{Deserialize, Serialize}; + +use crate::{primitives::IconName, theme::ActiveTheme}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DockSide { + Left, + Right, + Top, + Bottom, +} + +impl DockSide { + const ALL: [DockSide; 4] = [ + DockSide::Left, + DockSide::Right, + DockSide::Top, + DockSide::Bottom, + ]; + + fn index(self) -> usize { + self as usize + } +} + +/// Where a panel lives: a dock, or floating at a window position. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum Spot { + Docked(DockSide), + Floating { x: f32, y: f32 }, +} + +#[derive(Clone, Debug)] +pub struct DockPanel { + pub id: SharedString, + pub title: SharedString, + pub icon: IconName, +} + +/// Saved dock arrangement, per side in `DockSide` order. Sizes are in rems. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DockLayout { + pub spots: Vec<(String, Spot)>, + pub active: [Option; 4], + pub sizes: [f32; 4], +} + +type Center = Rc AnyElement>; +type Content = Rc AnyElement>; + +/// Side docks around a center; panels move between docks and float. +pub struct Dock { + panels: Vec, + spots: Vec, + active: [Option; 4], + sizes: [Rems; 4], + center: Center, + content: Content, + dragging: bool, +} + +impl Dock { + pub fn new( + center: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + content: impl Fn(&SharedString, &mut Window, &mut App) -> AnyElement + 'static, + cx: &App, + ) -> Self { + let theme = cx.theme(); + let (side, strip) = (theme.sidebar_width(false), theme.pane_min()); + Self { + panels: Vec::new(), + spots: Vec::new(), + active: [None; 4], + sizes: [side, side, strip, strip], + center: Rc::new(center), + content: Rc::new(content), + dragging: false, + } + } + + /// Panics on a repeated id. + pub fn add(&mut self, panel: DockPanel, side: DockSide) { + assert!( + self.find(&panel.id).is_none(), + "dock panel {} added twice", + panel.id + ); + self.panels.push(panel); + self.spots.push(Spot::Docked(side)); + let slot = &mut self.active[side.index()]; + if slot.is_none() { + *slot = Some(self.panels.len() - 1); + } + } + + fn find(&self, id: &str) -> Option { + self.panels.iter().position(|panel| panel.id.as_ref() == id) + } + + fn on_side(&self, side: DockSide) -> Vec { + (0..self.panels.len()) + .filter(|ix| self.spots[*ix] == Spot::Docked(side)) + .collect() + } + + pub fn activate(&mut self, id: &str, cx: &mut Context) { + let ix = self + .find(id) + .unwrap_or_else(|| panic!("no dock panel {id}")); + if let Spot::Docked(side) = self.spots[ix] { + self.active[side.index()] = Some(ix); + cx.notify(); + } + } + + pub fn move_panel(&mut self, id: &str, spot: Spot, cx: &mut Context) { + let ix = self + .find(id) + .unwrap_or_else(|| panic!("no dock panel {id}")); + let from = self.spots[ix]; + self.spots[ix] = spot; + if let Spot::Docked(side) = from + && self.active[side.index()] == Some(ix) + { + self.active[side.index()] = self.on_side(side).first().copied(); + } + if let Spot::Docked(side) = spot { + self.active[side.index()] = Some(ix); + } + log::info!("dock: {id} {from:?} -> {spot:?}"); + cx.notify(); + } + + pub fn layout(&self) -> DockLayout { + let name = |ix: Option| ix.map(|ix| self.panels[ix].id.to_string()); + DockLayout { + spots: self + .panels + .iter() + .zip(&self.spots) + .map(|(panel, spot)| (panel.id.to_string(), *spot)) + .collect(), + active: DockSide::ALL.map(|side| name(self.active[side.index()])), + sizes: self.sizes.map(|size| size.0), + } + } + + /// All or nothing. A side the layout leaves unset shows its first panel. + pub fn restore(&mut self, layout: &DockLayout, cx: &mut Context) -> anyhow::Result<()> { + let mut named: Vec<&str> = layout.spots.iter().map(|(id, _)| id.as_str()).collect(); + named.sort_unstable(); + named.dedup(); + anyhow::ensure!( + named.len() == layout.spots.len(), + "layout places a panel twice" + ); + anyhow::ensure!( + layout + .sizes + .iter() + .all(|size| size.is_finite() && *size > 0.0), + "dock sizes {:?} must be finite and positive", + layout.sizes + ); + anyhow::ensure!( + layout.spots.iter().all(|(_, spot)| match spot { + Spot::Docked(_) => true, + Spot::Floating { x, y } => x.is_finite() && y.is_finite(), + }), + "floating positions must be finite" + ); + let unknown: Vec<&str> = layout + .spots + .iter() + .map(|(id, _)| id.as_str()) + .chain(layout.active.iter().flatten().map(String::as_str)) + .filter(|id| self.find(id).is_none()) + .collect(); + anyhow::ensure!( + unknown.is_empty(), + "layout names unknown panels: {unknown:?}" + ); + let mut spots = self.spots.clone(); + for (id, spot) in &layout.spots { + spots[self.find(id).expect("checked above")] = *spot; + } + let mut active = [None; 4]; + for side in DockSide::ALL { + let chosen = layout.active[side.index()] + .as_deref() + .and_then(|id| self.find(id)); + if let Some(ix) = chosen { + anyhow::ensure!( + spots[ix] == Spot::Docked(side), + "active panel {} is not docked {side:?}", + self.panels[ix].id + ); + } + active[side.index()] = + chosen.or_else(|| (0..spots.len()).find(|ix| spots[*ix] == Spot::Docked(side))); + } + self.spots = spots; + self.active = active; + self.sizes = layout.sizes.map(gpui::rems); + log::info!("dock: restored {} panel spots", layout.spots.len()); + cx.notify(); + Ok(()) + } +} diff --git a/src/layout/dock/view.rs b/src/layout/dock/view.rs new file mode 100644 index 0000000..ebb4ad9 --- /dev/null +++ b/src/layout/dock/view.rs @@ -0,0 +1,347 @@ +use gpui::{ + AnyElement, Axis, Context, DragMoveEvent, EmptyView, EntityId, FontWeight, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, Point, Render, SharedString, + StatefulInteractiveElement, Styled, Window, div, point, prelude::*, relative, +}; + +use super::{Dock, DockSide, Spot}; +use crate::{ + layout::{FloatingPanel, resize_handle}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, +}; + +struct DraggedPanel { + dock: EntityId, + id: SharedString, +} + +struct PanelGhost { + title: SharedString, + icon: IconName, +} + +impl Render for PanelGhost { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .items_center() + .gap_1p5() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.overlay) + .border_1() + .border_color(theme.colors.border) + .shadow(theme.elevation(Elevation::Floating)) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg) + .child( + Icon::new(self.icon) + .size(IconSize::Sm) + .color(theme.colors.fg_muted), + ) + .child(self.title.clone()) + } +} + +struct DockResize { + dock: EntityId, + side: DockSide, +} + +impl Dock { + fn tabs(&self, side: DockSide, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let active = self.active[side.index()]; + div() + .flex() + .items_center() + .gap_0p5() + .h(theme.control_height(ControlSize::Lg)) + .px_1() + .border_b_1() + .border_color(theme.colors.border) + .children(self.on_side(side).into_iter().map(|ix| { + let panel = &self.panels[ix]; + let selected = active == Some(ix); + let (id, title, icon) = (panel.id.clone(), panel.title.clone(), panel.icon); + let drag = DraggedPanel { + dock: cx.entity_id(), + id: id.clone(), + }; + div() + .id(SharedString::from(format!("tab-{id}"))) + .flex() + .items_center() + .gap_1p5() + .px_2() + .h(theme.control_height(ControlSize::Sm)) + .rounded(theme.radius(Radius::Md)) + .cursor_pointer() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if selected { + theme.colors.fg + } else { + theme.colors.fg_subtle + }) + .when(selected, |tab| { + tab.bg(theme.colors.hover).font_weight(FontWeight::MEDIUM) + }) + .hover(|style| style.text_color(theme.colors.fg)) + .child(Icon::new(icon).size(IconSize::Sm).color(if selected { + theme.colors.fg + } else { + theme.colors.fg_subtle + })) + .child(title.clone()) + .on_click(cx.listener(move |dock, _, _, cx| dock.activate(&id, cx))) + .on_drag(drag, move |_, _, _, cx| { + let (title, icon) = (title.clone(), icon); + cx.new(|_| PanelGhost { title, icon }) + }) + })) + .into_any_element() + } + + fn side( + &self, + side: DockSide, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let active = self.active[side.index()]?; + let size = self.sizes[side.index()]; + let panel_id = self.panels[active].id.clone(); + let body = (self.content)(&panel_id, window, cx); + let theme = cx.theme(); + let pane = div() + .flex() + .flex_col() + .overflow_hidden() + .bg(theme.colors.surface) + .child(self.tabs(side, cx)) + .child(div().flex_1().min_h_0().child(body)); + let axis = match side { + DockSide::Left | DockSide::Right => Axis::Horizontal, + DockSide::Top | DockSide::Bottom => Axis::Vertical, + }; + let resize = DockResize { + dock: cx.entity_id(), + side, + }; + let handle = resize_handle( + SharedString::from(format!("dock-handle-{side:?}")), + axis, + cx, + ) + .on_drag(resize, |_, _, _, cx| cx.new(|_| EmptyView)); + let pane = match axis { + Axis::Horizontal => pane.w(size).h_full(), + Axis::Vertical => pane.h(size).w_full(), + }; + let row = div().flex().flex_none(); + Some( + match side { + DockSide::Left => row.h_full().child(pane).child(handle), + DockSide::Right => row.h_full().child(handle).child(pane), + DockSide::Top => row.w_full().flex_col().child(pane).child(handle), + DockSide::Bottom => row.w_full().flex_col().child(handle).child(pane), + } + .into_any_element(), + ) + } + + fn zones(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (lit, edge, idle) = ( + theme.colors.focus.opacity(0.12), + theme.colors.focus.opacity(0.7), + theme.colors.border_strong, + ); + let radius = theme.radius(Radius::Lg); + let zone = |id: &'static str, spot: Option, cx: &mut Context| { + div() + .id(id) + .absolute() + .rounded(radius) + .border_2() + .border_dashed() + .border_color(idle) + .drag_over::(move |style, _, _, _| style.bg(lit).border_color(edge)) + .on_drop( + cx.listener(move |dock, dragged: &DraggedPanel, window, cx| { + if dragged.dock != cx.entity_id() { + return; + } + let target = match spot { + Some(side) => Spot::Docked(side), + None => { + let at = window.mouse_position(); + Spot::Floating { + x: f32::from(at.x), + y: f32::from(at.y), + } + } + }; + dock.dragging = false; + dock.move_panel(&dragged.id, target, cx); + }), + ) + }; + div() + .absolute() + .top_0() + .left_0() + .size_full() + .p_2() + .child( + zone("zone-left", Some(DockSide::Left), cx) + .top_2() + .bottom_2() + .left_2() + .w(relative(0.2)), + ) + .child( + zone("zone-right", Some(DockSide::Right), cx) + .top_2() + .bottom_2() + .right_2() + .w(relative(0.2)), + ) + .child( + zone("zone-top", Some(DockSide::Top), cx) + .top_2() + .left(relative(0.22)) + .right(relative(0.22)) + .h(relative(0.25)), + ) + .child( + zone("zone-bottom", Some(DockSide::Bottom), cx) + .bottom_2() + .left(relative(0.22)) + .right(relative(0.22)) + .h(relative(0.25)), + ) + .child( + zone("zone-float", None, cx) + .top(relative(0.3)) + .left(relative(0.3)) + .right(relative(0.3)) + .h(relative(0.4)), + ) + .into_any_element() + } +} + +impl Render for Dock { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let left = self.side(DockSide::Left, window, cx); + let right = self.side(DockSide::Right, window, cx); + let top = self.side(DockSide::Top, window, cx); + let bottom = self.side(DockSide::Bottom, window, cx); + let center = (self.center)(window, cx); + let floating: Vec = (0..self.panels.len()) + .filter_map(|ix| match self.spots[ix] { + Spot::Floating { x, y } => Some((ix, point(Pixels::from(x), Pixels::from(y)))), + Spot::Docked(_) => None, + }) + .map(|(ix, start)| self.floating(ix, start, window, cx)) + .collect(); + let showing_zones = self.dragging && cx.has_active_drag(); + let rem = window.rem_size(); + let min = cx.theme().pane_min(); + let own = cx.entity_id(); + div() + .id("dock") + .relative() + .size_full() + .flex() + .on_drag_move( + cx.listener(move |dock, event: &DragMoveEvent, _, cx| { + let resize = event.drag(cx); + if resize.dock != own { + return; + } + let side = resize.side; + let bounds = event.bounds; + let pointer = event.event.position; + let span = match side { + DockSide::Left => pointer.x - bounds.left(), + DockSide::Right => bounds.right() - pointer.x, + DockSide::Top => pointer.y - bounds.top(), + DockSide::Bottom => bounds.bottom() - pointer.y, + }; + let limit = match side { + DockSide::Left | DockSide::Right => bounds.size.width * 0.45, + DockSide::Top | DockSide::Bottom => bounds.size.height * 0.45, + }; + let size = span.max(min.to_pixels(rem)).min(limit); + dock.sizes[side.index()] = gpui::rems(f32::from(size) / f32::from(rem)); + cx.notify(); + }), + ) + .on_drag_move( + cx.listener(|dock, event: &DragMoveEvent, _, cx| { + if event.drag(cx).dock == cx.entity_id() && !dock.dragging { + dock.dragging = true; + cx.notify(); + } + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|dock, _, _, _| dock.dragging = false), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|dock, _, _, _| dock.dragging = false), + ) + .children(left) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .children(top) + .child(div().flex_1().min_h_0().child(center)) + .children(bottom), + ) + .children(right) + .children(floating) + .when(showing_zones, |dock| dock.child(self.zones(cx))) + } +} + +impl Dock { + fn floating( + &self, + ix: usize, + start: Point, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let panel = &self.panels[ix]; + let id = panel.id.clone(); + let body = (self.content)(&id, window, cx); + let dock_id = id.clone(); + FloatingPanel::new( + SharedString::from(format!("float-{id}")), + panel.title.clone(), + start, + ) + .w(cx.theme().sidebar_width(false)) + .h(cx.theme().sheet_size()) + .on_move(cx.listener(move |dock, at: &Point, _, _| { + let ix = dock.find(&dock_id).expect("dock panels are never removed"); + dock.spots[ix] = Spot::Floating { + x: f32::from(at.x), + y: f32::from(at.y), + }; + })) + .child(body) + .into_any_element() + } +} diff --git a/src/layout/floating.rs b/src/layout/floating.rs new file mode 100644 index 0000000..aa8043a --- /dev/null +++ b/src/layout/floating.rs @@ -0,0 +1,168 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, App, DragMoveEvent, ElementId, EmptyView, EntityId, FontWeight, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, anchored, deferred, div, point, + prelude::*, +}; +use smallvec::SmallVec; + +use super::seeded::use_seeded; +use crate::theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}; + +struct Carry { + owner: EntityId, + start: Point, + anchor: Rc>>>, +} + +/// Keeps `origin` so that at least `keep` of the panel stays in the window. +fn clamp_to_window( + origin: Point, + keep: Pixels, + window: gpui::Size, +) -> Point { + point( + origin.x.clamp(keep - window.width, window.width - keep), + origin.y.clamp(Pixels::ZERO, window.height - keep), + ) +} + +type OnMove = Rc, &mut Window, &mut App)>; + +/// Panel that floats above the page; drag its title bar to move it. +#[derive(IntoElement)] +pub struct FloatingPanel { + id: ElementId, + title: SharedString, + start: Point, + base: gpui::Div, + on_move: Option, + body: SmallVec<[AnyElement; 2]>, +} + +impl FloatingPanel { + /// Top-left corner in window coordinates. A new value replaces a dragged one. + pub fn new( + id: impl Into, + title: impl Into, + start: Point, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + start, + base: div(), + on_move: None, + body: SmallVec::new(), + } + } + + /// Reports each new top-left corner while dragging. + pub fn on_move( + mut self, + handler: impl Fn(&Point, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } +} + +impl Styled for FloatingPanel { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for FloatingPanel { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for FloatingPanel { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let origin = use_seeded(self.id.clone(), self.start, window, cx); + let owner = origin.entity_id(); + let theme = cx.theme(); + let keep = theme + .control_height(ControlSize::Lg) + .to_pixels(window.rem_size()); + let viewport = window.viewport_size(); + let at = clamp_to_window(origin.read(cx).value, keep, viewport); + let on_move = self.on_move; + let carry = Carry { + owner, + start: at, + anchor: Rc::new(Cell::new(None)), + }; + let panel = self + .base + .id(self.id) + .flex() + .flex_col() + .overflow_hidden() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.overlay) + .shadow(theme.elevation(Elevation::Floating)) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + let carry = event.drag(cx); + if carry.owner != owner { + return; + } + let pointer = event.event.position; + let anchor = match carry.anchor.get() { + Some(anchor) => anchor, + None => { + carry.anchor.set(Some(pointer)); + pointer + } + }; + let next = clamp_to_window(carry.start + (pointer - anchor), keep, viewport); + if let Some(report) = &on_move { + report(&next, window, cx); + } + origin.update(cx, |origin, cx| { + origin.value = next; + cx.notify(); + }); + }) + .child( + div() + .id("floating-title") + .flex() + .items_center() + .h(theme.control_height(ControlSize::Md)) + .px_3() + .border_b_1() + .border_color(theme.colors.border) + .cursor_grab() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg_muted) + .on_drag(carry, |_, _, _, cx| cx.new(|_| EmptyView)) + .child(self.title), + ) + .child(div().p_3().children(self.body)); + deferred(anchored().position(at).child(panel)) + } +} + +#[cfg(test)] +mod tests { + use gpui::{point, px, size}; + + use super::clamp_to_window; + + #[test] + fn panel_stays_reachable() { + let window = size(px(800.0), px(600.0)); + let inside = clamp_to_window(point(px(100.0), px(100.0)), px(32.0), window); + assert_eq!(inside, point(px(100.0), px(100.0))); + let far = clamp_to_window(point(px(2000.0), px(-50.0)), px(32.0), window); + assert_eq!(far, point(px(768.0), px(0.0))); + } +} diff --git a/src/layout/grid.rs b/src/layout/grid.rs new file mode 100644 index 0000000..eb3c500 --- /dev/null +++ b/src/layout/grid.rs @@ -0,0 +1,189 @@ +use gpui::{ + AnyElement, App, Div, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, + StyleRefinement, Styled, Window, canvas, div, +}; +use smallvec::SmallVec; + +/// Columns for `width`, each at least `min`, `gap` apart. +fn columns_for(width: Pixels, min: Pixels, gap: Pixels) -> u16 { + let fit = ((width + gap) / (min + gap)).floor(); + fit.clamp(1.0, f32::from(u16::MAX)) as u16 +} + +/// Index of the shortest column; ties go left. +fn shortest(heights: &[Pixels]) -> usize { + heights + .iter() + .enumerate() + .min_by(|a, b| a.1.partial_cmp(b.1).expect("heights are finite")) + .map(|(ix, _)| ix) + .expect("at least one column") +} + +fn measure_width(state: gpui::Entity) -> impl IntoElement { + canvas( + move |bounds, _, cx| { + if *state.read(cx) != bounds.size.width { + state.update(cx, |width, cx| { + *width = bounds.size.width; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + +/// Equal columns: as many as fit at `min` width each. +#[derive(IntoElement)] +pub struct SimpleGrid { + id: ElementId, + base: Div, + min: Pixels, + gap: Pixels, + children: SmallVec<[AnyElement; 8]>, +} + +impl SimpleGrid { + pub fn new(id: impl Into, min: Pixels, gap: Pixels) -> Self { + assert!(min > Pixels::ZERO, "simple grid needs a positive min width"); + Self { + id: id.into(), + base: div(), + min, + gap, + children: SmallVec::new(), + } + } +} + +impl Styled for SimpleGrid { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for SimpleGrid { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for SimpleGrid { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let width = window.use_keyed_state(self.id, cx, |_, _| Pixels::ZERO); + let columns = columns_for(*width.read(cx), self.min, self.gap); + self.base + .relative() + .w_full() + .grid() + .grid_cols(columns) + .gap(self.gap) + .children(self.children) + .child(measure_width(width)) + } +} + +/// Columns fill shortest first, so uneven items pack tight. +#[derive(IntoElement)] +pub struct Masonry { + id: ElementId, + columns: usize, + gap: Pixels, + children: Vec, +} + +impl Masonry { + pub fn new(id: impl Into, columns: usize, gap: Pixels) -> Self { + assert!(columns > 0, "masonry needs a column"); + Self { + id: id.into(), + columns, + gap, + children: Vec::new(), + } + } +} + +impl ParentElement for Masonry { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for Masonry { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.children.len(); + let heights = window.use_keyed_state(self.id, cx, |_, _| Vec::::new()); + if heights.read(cx).len() != count { + heights.update(cx, |heights, _| heights.resize(count, Pixels::ZERO)); + } + let known = heights.read(cx).clone(); + let mut columns: Vec> = (0..self.columns).map(|_| Vec::new()).collect(); + let mut filled = vec![Pixels::ZERO; self.columns]; + for (ix, child) in self.children.into_iter().enumerate() { + let column = shortest(&filled); + filled[column] += known[ix] + self.gap; + let state = heights.clone(); + columns[column].push( + div() + .relative() + .child(child) + .child( + canvas( + move |bounds, _, cx| { + if state.read(cx)[ix] != bounds.size.height { + state.update(cx, |heights, cx| { + heights[ix] = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .into_any_element(), + ); + } + div() + .flex() + .items_start() + .gap(self.gap) + .children(columns.into_iter().map(|items| { + div() + .flex_1() + .flex() + .flex_col() + .gap(self.gap) + .children(items) + })) + } +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::{columns_for, shortest}; + + #[test] + fn columns_fit_the_width() { + assert_eq!(columns_for(px(1000.0), px(220.0), px(16.0)), 4); + assert_eq!(columns_for(px(100.0), px(220.0), px(16.0)), 1); + assert_eq!(columns_for(px(0.0), px(220.0), px(16.0)), 1); + } + + #[test] + fn shortest_prefers_the_left_on_ties() { + assert_eq!(shortest(&[px(3.0), px(1.0), px(1.0)]), 1); + assert_eq!(shortest(&[px(0.0), px(0.0)]), 0); + } +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs new file mode 100644 index 0000000..7091bd0 --- /dev/null +++ b/src/layout/mod.rs @@ -0,0 +1,32 @@ +mod collapse; +mod dock; +mod floating; +mod grid; +mod page; +mod panes; +mod scroll; +mod scroll_aids; +mod seeded; +mod sheet; +mod sidebar; +mod split; +mod stack; +mod surface; +mod viewport; +mod workspace; + +pub use collapse::{Accordion, AccordionItem, Collapsible}; +pub use dock::{Dock, DockLayout, DockPanel, DockSide, Spot}; +pub use floating::FloatingPanel; +pub use grid::{Masonry, SimpleGrid}; +pub use page::{AppShell, MasterDetail, Page}; +pub use panes::{PaneGroup, PaneId, PaneLayout}; +pub use scroll::{ScrollArea, Scrollbar}; +pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader}; +pub use sheet::{Drawer, Sheet}; +pub use sidebar::Sidebar; +pub use split::{SplitPane, resize_handle}; +pub use stack::{AspectRatio, Container, ZStack, h_stack, v_stack}; +pub use surface::{Card, CardHeader, Fieldset, Frame, Panel, Section, Well}; +pub use viewport::{Transform, Viewport}; +pub use workspace::{WORKSPACE_VERSION, Workspace}; diff --git a/src/layout/page.rs b/src/layout/page.rs new file mode 100644 index 0000000..28e939c --- /dev/null +++ b/src/layout/page.rs @@ -0,0 +1,198 @@ +use gpui::{ + AnyElement, App, Axis, ElementId, FontWeight, IntoElement, ParentElement, Pixels, RenderOnce, + SharedString, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use super::SplitPane; +use crate::theme::{ActiveTheme, TextSize}; + +/// Page header and content: title, note, actions, then the body. +#[derive(IntoElement)] +pub struct Page { + title: SharedString, + subtitle: Option, + actions: SmallVec<[AnyElement; 2]>, + body: SmallVec<[AnyElement; 4]>, +} + +impl Page { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + subtitle: None, + actions: SmallVec::new(), + body: SmallVec::new(), + } + } + + pub fn subtitle(mut self, text: impl Into) -> Self { + self.subtitle = Some(text.into()); + self + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.actions.push(action.into_any_element()); + self + } +} + +impl ParentElement for Page { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Page { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_8() + .px_10() + .py_8() + .child( + div() + .flex() + .items_end() + .justify_between() + .gap_6() + .pb_6() + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xxl)) + .font_weight(FontWeight::SEMIBOLD) + .child(self.title), + ) + .when_some(self.subtitle, |block, text| { + block.child( + div() + .text_size(theme.text_size(TextSize::Md)) + .text_color(theme.colors.fg_muted) + .child(text), + ) + }), + ) + .child(div().flex().items_center().gap_2().children(self.actions)), + ) + .child(div().flex().flex_col().gap_6().children(self.body)) + } +} + +/// Window frame: title bar, sidebar, content, status bar. +#[derive(IntoElement)] +pub struct AppShell { + title_bar: Option, + sidebar: Option, + status_bar: Option, + body: SmallVec<[AnyElement; 2]>, +} + +impl AppShell { + pub fn new() -> Self { + Self { + title_bar: None, + sidebar: None, + status_bar: None, + body: SmallVec::new(), + } + } + + pub fn title_bar(mut self, bar: impl IntoElement) -> Self { + self.title_bar = Some(bar.into_any_element()); + self + } + + pub fn sidebar(mut self, sidebar: impl IntoElement) -> Self { + self.sidebar = Some(sidebar.into_any_element()); + self + } + + pub fn status_bar(mut self, bar: impl IntoElement) -> Self { + self.status_bar = Some(bar.into_any_element()); + self + } +} + +impl Default for AppShell { + fn default() -> Self { + Self::new() + } +} + +impl ParentElement for AppShell { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for AppShell { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + div() + .size_full() + .flex() + .flex_col() + .bg(theme.colors.bg) + .text_color(theme.colors.fg) + .children(self.title_bar) + .child( + div() + .flex_1() + .min_h_0() + .flex() + .children(self.sidebar) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .children(self.body), + ), + ) + .children(self.status_bar) + } +} + +/// List on the left, the selected item on the right. +#[derive(IntoElement)] +pub struct MasterDetail { + id: ElementId, + master: AnyElement, + detail: AnyElement, + min: Pixels, +} + +impl MasterDetail { + pub fn new( + id: impl Into, + master: impl IntoElement, + detail: impl IntoElement, + min: Pixels, + ) -> Self { + Self { + id: id.into(), + master: master.into_any_element(), + detail: detail.into_any_element(), + min, + } + } +} + +impl RenderOnce for MasterDetail { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + SplitPane::new(self.id, Axis::Horizontal, self.min) + .sizes(&[0.36, 0.64]) + .pane(self.master) + .pane(self.detail) + } +} diff --git a/src/layout/panes.rs b/src/layout/panes.rs new file mode 100644 index 0000000..e941dee --- /dev/null +++ b/src/layout/panes.rs @@ -0,0 +1,425 @@ +use std::rc::Rc; + +use anyhow::Context as _; +use gpui::{ + AnyElement, App, Axis, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, + Render, Styled, Window, div, +}; +use serde::{Deserialize, Serialize}; + +use super::{SplitPane, split::check_weights}; +use crate::{ + buttons::IconButton, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +pub type PaneId = u64; + +/// Split tree. The live model and its saved form are the same data. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum PaneLayout { + Pane(PaneId), + Split { + axis: Axis, + sizes: Vec, + children: Vec, + }, +} + +impl PaneLayout { + /// Checks ids and splits; returns the next free pane id. + pub fn validate(&self) -> anyhow::Result { + let mut ids = Vec::new(); + self.panes(&mut ids); + let mut unique = ids.clone(); + unique.sort_unstable(); + unique.dedup(); + anyhow::ensure!(unique.len() == ids.len(), "pane ids repeat: {ids:?}"); + check_sizes(self)?; + let last = *unique.last().expect("a layout holds a pane"); + last.checked_add(1) + .with_context(|| format!("no pane id follows {last}")) + } + + fn panes(&self, out: &mut Vec) { + match self { + PaneLayout::Pane(id) => out.push(*id), + PaneLayout::Split { children, .. } => { + children.iter().for_each(|child| child.panes(out)) + } + } + } + + /// Inserts `new` beside `target`; true if `target` was found. + fn split(&mut self, target: PaneId, axis: Axis, new: PaneId) -> bool { + match self { + PaneLayout::Pane(id) if *id == target => { + *self = PaneLayout::Split { + axis, + sizes: vec![0.5, 0.5], + children: vec![PaneLayout::Pane(target), PaneLayout::Pane(new)], + }; + true + } + PaneLayout::Pane(_) => false, + PaneLayout::Split { + axis: own, + sizes, + children, + } => { + let direct = children + .iter() + .position(|child| *child == PaneLayout::Pane(target)); + match direct { + Some(ix) if *own == axis => { + let total: f32 = sizes.iter().sum(); + sizes.iter_mut().for_each(|size| *size /= total); + let half = sizes[ix] / 2.0; + sizes[ix] = half; + sizes.insert(ix + 1, half); + children.insert(ix + 1, PaneLayout::Pane(new)); + true + } + _ => children + .iter_mut() + .any(|child| child.split(target, axis, new)), + } + } + } + } + + /// Removes `target`, folding splits left with one child. + fn close(&mut self, target: PaneId) -> bool { + let PaneLayout::Split { + sizes, children, .. + } = self + else { + return false; + }; + if let Some(ix) = children + .iter() + .position(|child| *child == PaneLayout::Pane(target)) + { + children.remove(ix); + sizes.remove(ix); + let rest: f32 = sizes.iter().sum(); + if rest > 0.0 { + sizes.iter_mut().for_each(|size| *size /= rest); + } else { + sizes.iter_mut().for_each(|size| *size = 1.0); + } + } else if !children.iter_mut().any(|child| child.close(target)) { + return false; + } + if children.len() == 1 { + *self = children.remove(0); + } + true + } + + fn set_sizes(&mut self, path: &[usize], shares: &[f32]) { + let PaneLayout::Split { + sizes, children, .. + } = self + else { + panic!("pane layout path points past a leaf"); + }; + match path.split_first() { + None => *sizes = shares.to_vec(), + Some((ix, rest)) => children[*ix].set_sizes(rest, shares), + } + } +} + +type Content = Rc AnyElement>; + +/// Editor-style split panes: split right or down, close, focus. +pub struct PaneGroup { + root: PaneLayout, + focused: PaneId, + next: PaneId, + content: Content, +} + +impl PaneGroup { + pub fn new(content: impl Fn(PaneId, &mut Window, &mut App) -> AnyElement + 'static) -> Self { + Self { + root: PaneLayout::Pane(1), + focused: 1, + next: 2, + content: Rc::new(content), + } + } + + pub fn focused(&self) -> PaneId { + self.focused + } + + pub fn panes(&self) -> Vec { + let mut out = Vec::new(); + self.root.panes(&mut out); + out + } + + /// Opens a new pane beside `pane` and focuses it. + pub fn split(&mut self, pane: PaneId, axis: Axis, cx: &mut Context) -> PaneId { + let new = self.next; + assert!(self.root.split(pane, axis, new), "no pane {pane} to split"); + self.next = new.checked_add(1).expect("pane ids exhausted"); + self.focused = new; + log::info!("pane group: split {pane} -> {new} ({axis:?})"); + cx.notify(); + new + } + + /// Panics on the last pane; its close button is disabled. + pub fn close(&mut self, pane: PaneId, cx: &mut Context) { + assert!(self.panes().len() > 1, "cannot close the last pane"); + assert!(self.root.close(pane), "no pane {pane} to close"); + if self.focused == pane { + self.focused = self.panes()[0]; + } + log::info!("pane group: closed {pane}"); + cx.notify(); + } + + pub fn focus(&mut self, pane: PaneId, cx: &mut Context) { + self.focused = pane; + cx.notify(); + } + + pub fn layout(&self) -> PaneLayout { + self.root.clone() + } + + /// Replaces the tree after `PaneLayout::validate` accepts it. + pub fn restore(&mut self, layout: PaneLayout, cx: &mut Context) -> anyhow::Result<()> { + self.next = layout.validate()?; + let mut ids = Vec::new(); + layout.panes(&mut ids); + self.focused = ids[0]; + self.root = layout; + log::info!("pane group: restored {} panes", ids.len()); + cx.notify(); + Ok(()) + } + + fn render_node( + &self, + node: &PaneLayout, + path: Vec, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + match node { + PaneLayout::Pane(id) => self.render_pane(*id, window, cx), + PaneLayout::Split { + axis, + sizes, + children, + } => { + let min = cx.theme().pane_min().to_pixels(window.rem_size()); + let key = format!("pane-split-{path:?}-{}", children.len()); + let at = path.clone(); + let mut split = SplitPane::new(gpui::SharedString::from(key), *axis, min) + .sizes(sizes) + .on_resize(cx.listener(move |group: &mut Self, shares: &[f32], _, _| { + group.root.set_sizes(&at, shares) + })); + for (ix, child) in children.iter().enumerate() { + let mut child_path = path.clone(); + child_path.push(ix); + split = split.pane(self.render_node(child, child_path, window, cx)); + } + split.into_any_element() + } + } + } + + fn render_pane(&self, id: PaneId, window: &mut Window, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let focused = id == self.focused; + let lone = self.panes().len() == 1; + let header = div() + .flex() + .items_center() + .justify_between() + .h(theme.control_height(ControlSize::Lg)) + .pl_3() + .pr_1() + .border_b_1() + .border_color(theme.colors.border) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(if focused { + theme.colors.fg + } else { + theme.colors.fg_subtle + }) + .child(gpui::SharedString::from(format!("Pane {id}"))) + .child( + div() + .flex() + .gap_0p5() + .child( + IconButton::new(("split-right", id), IconName::PanelRight) + .size(ControlSize::Sm) + .on_click(cx.listener(move |group, _, _, cx| { + group.split(id, Axis::Horizontal, cx); + })), + ) + .child( + IconButton::new(("split-down", id), IconName::PanelBottom) + .size(ControlSize::Sm) + .on_click(cx.listener(move |group, _, _, cx| { + group.split(id, Axis::Vertical, cx); + })), + ) + .child( + IconButton::new(("close", id), IconName::X) + .size(ControlSize::Sm) + .disabled(lone) + .on_click(cx.listener(move |group, _, _, cx| group.close(id, cx))), + ), + ); + div() + .id(("pane", id)) + .size_full() + .flex() + .flex_col() + .bg(if focused { + theme.colors.surface + } else { + theme.colors.bg + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |group, _, _, cx| { + if group.focused != id { + group.focus(id, cx); + } + }), + ) + .child(header) + .child( + div() + .flex_1() + .min_h_0() + .child((self.content)(id, window, cx)), + ) + .into_any_element() + } +} + +fn check_sizes(node: &PaneLayout) -> anyhow::Result<()> { + if let PaneLayout::Split { + sizes, children, .. + } = node + { + anyhow::ensure!(children.len() >= 2, "a split needs two children"); + anyhow::ensure!( + sizes.len() == children.len(), + "split sizes {sizes:?} miss children" + ); + check_weights(sizes)?; + children.iter().try_for_each(check_sizes)?; + } + Ok(()) +} + +impl Render for PaneGroup { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let root = self.root.clone(); + div() + .size_full() + .child(self.render_node(&root, Vec::new(), window, cx)) + } +} + +#[cfg(test)] +mod tests { + use gpui::Axis; + + use super::PaneLayout; + + fn ids(layout: &PaneLayout) -> Vec { + let mut out = Vec::new(); + layout.panes(&mut out); + out + } + + #[test] + fn split_nests_or_extends() { + let mut root = PaneLayout::Pane(1); + assert!(root.split(1, Axis::Horizontal, 2)); + assert!(root.split(2, Axis::Horizontal, 3)); + let PaneLayout::Split { sizes, .. } = &root else { + panic!("split") + }; + assert_eq!(sizes, &[0.5, 0.25, 0.25]); + assert!(root.split(3, Axis::Vertical, 4)); + assert_eq!(ids(&root), [1, 2, 3, 4]); + assert!(!root.split(9, Axis::Vertical, 5)); + let mut tiny = PaneLayout::Split { + axis: Axis::Horizontal, + sizes: vec![1e-45, 0.0], + children: vec![PaneLayout::Pane(1), PaneLayout::Pane(2)], + }; + assert!(tiny.split(1, Axis::Horizontal, 3)); + assert!(tiny.validate().is_ok()); + } + + #[test] + fn validate_refuses_bad_weights_and_exhausted_ids() { + let split = |sizes: Vec| PaneLayout::Split { + axis: Axis::Horizontal, + sizes, + children: vec![PaneLayout::Pane(1), PaneLayout::Pane(2)], + }; + assert_eq!(split(vec![1.0, 3.0]).validate().unwrap(), 3); + assert!(split(vec![0.0, 0.0]).validate().is_err()); + assert!(split(vec![-1.0, 2.0]).validate().is_err()); + assert!(split(vec![f32::MAX, f32::MAX]).validate().is_err()); + assert!(PaneLayout::Pane(u64::MAX).validate().is_err()); + } + + #[test] + fn close_keeps_weights_finite() { + let three = |sizes: Vec| PaneLayout::Split { + axis: Axis::Horizontal, + sizes, + children: vec![ + PaneLayout::Pane(1), + PaneLayout::Pane(2), + PaneLayout::Pane(3), + ], + }; + let sizes = |layout: &PaneLayout| match layout { + PaneLayout::Split { sizes, .. } => sizes.clone(), + PaneLayout::Pane(_) => panic!("split"), + }; + let mut zeros = three(vec![0.0, 0.0, 1.0]); + assert!(zeros.close(3)); + assert_eq!(sizes(&zeros), [1.0, 1.0]); + let mut tiny = three(vec![0.0, 0.0, 1e-45]); + assert!(tiny.close(3)); + assert!(tiny.validate().is_ok()); + let mut huge = three(vec![1e30, 1e30, 1e30]); + assert!(huge.close(1)); + assert_eq!(sizes(&huge), [0.5, 0.5]); + let mut kept = three(vec![1.0, 3.0, 4.0]); + assert!(kept.close(3)); + assert_eq!(sizes(&kept), [0.25, 0.75]); + } + + #[test] + fn close_folds_single_children() { + let mut root = PaneLayout::Pane(1); + root.split(1, Axis::Horizontal, 2); + root.split(2, Axis::Vertical, 3); + assert!(root.close(3)); + assert_eq!(ids(&root), [1, 2]); + assert!(root.close(2)); + assert_eq!(root, PaneLayout::Pane(1)); + } +} diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs new file mode 100644 index 0000000..f38eb14 --- /dev/null +++ b/src/layout/scroll.rs @@ -0,0 +1,251 @@ +use std::{ + cell::Cell, + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + AnyElement, App, Axis, Div, ElementId, EmptyView, Entity, EntityId, InteractiveElement, + IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, + StatefulInteractiveElement, StyleRefinement, Styled, Window, div, point, prelude::*, +}; +use smallvec::SmallVec; + +use super::ScrollShadow; +use crate::{motion, theme::ActiveTheme}; + +const IDLE: Duration = Duration::from_millis(900); + +struct Activity { + handle: ScrollHandle, + last: Point, + active_at: Option, + hovered: bool, +} + +/// Visibility 0..=1: full while active, fading after `IDLE`. +fn presence(activity: &Activity, fade: Duration) -> f32 { + if activity.hovered { + return 1.0; + } + let Some(at) = activity.active_at else { + return 0.0; + }; + let quiet = at.elapsed().saturating_sub(IDLE); + 1.0 - (quiet.as_secs_f32() / fade.as_secs_f32()).min(1.0) +} + +/// Thumb start at press, and the pointer's first position. +struct ThumbDrag { + owner: EntityId, + start: Pixels, + anchor: Rc>>, +} + +/// Overlay thumb for a `ScrollHandle`; drag it to scroll. +#[derive(IntoElement)] +pub struct Scrollbar { + id: ElementId, + handle: ScrollHandle, + axis: Axis, + presence: f32, +} + +impl Scrollbar { + pub fn new(id: impl Into, handle: &ScrollHandle, axis: Axis) -> Self { + Self { + id: id.into(), + handle: handle.clone(), + axis, + presence: 1.0, + } + } + + /// Fades the thumb; 0 hides it. + pub fn presence(mut self, presence: f32) -> Self { + self.presence = presence.clamp(0.0, 1.0); + self + } +} + +impl RenderOnce for Scrollbar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let owner = window + .use_keyed_state(self.id.clone(), cx, |_, _| ()) + .entity_id(); + let theme = cx.theme(); + let rem = window.rem_size(); + let thickness = theme.scrollbar_thickness().to_pixels(rem); + let min_thumb = theme.scrollbar_min_thumb().to_pixels(rem); + let (view, max, offset) = ( + self.handle.bounds().size, + self.handle.max_offset(), + self.handle.offset(), + ); + let (span, reach, scrolled) = match self.axis { + Axis::Vertical => (view.height, max.height, -offset.y), + Axis::Horizontal => (view.width, max.width, -offset.x), + }; + let length = (span * (span / (span + reach))).max(min_thumb).min(span); + let travel = span - length; + if reach <= Pixels::ZERO || travel <= Pixels::ZERO || self.presence <= 0.0 { + return div().into_any_element(); + } + let start = travel * (scrolled / reach).clamp(0.0, 1.0); + let axis = self.axis; + let handle = self.handle.clone(); + let (idle, busy) = (theme.colors.fg.opacity(0.22), theme.colors.fg.opacity(0.42)); + let thumb = div() + .id("thumb") + .absolute() + .rounded_full() + .bg(idle) + .hover(|style| style.bg(busy)) + .on_drag( + ThumbDrag { + owner, + start, + anchor: Rc::new(Cell::new(None)), + }, + |_, _, _, cx| cx.new(|_| EmptyView), + ) + .map(|thumb| match axis { + Axis::Vertical => thumb.top(start).left_0().w(thickness).h(length), + Axis::Horizontal => thumb.left(start).top_0().h(thickness).w(length), + }); + let track = div() + .id(self.id) + .absolute() + .opacity(self.presence) + .on_drag_move(move |event: &gpui::DragMoveEvent, window, cx| { + let drag = event.drag(cx); + if drag.owner != owner { + return; + } + let local = event.event.position - event.bounds.origin; + let along = match axis { + Axis::Vertical => local.y, + Axis::Horizontal => local.x, + }; + let anchor = match drag.anchor.get() { + Some(anchor) => anchor, + None => { + drag.anchor.set(Some(along)); + along + } + }; + let ratio = ((drag.start + along - anchor) / travel).clamp(0.0, 1.0); + let target = -(reach * ratio); + let current = handle.offset(); + handle.set_offset(match axis { + Axis::Vertical => point(current.x, target), + Axis::Horizontal => point(target, current.y), + }); + window.refresh(); + }); + match axis { + Axis::Vertical => track + .top_0() + .bottom_0() + .right_0() + .w(thickness * 2.0) + .pl(thickness * 0.5), + Axis::Horizontal => track + .left_0() + .right_0() + .bottom_0() + .h(thickness * 2.0) + .pt(thickness * 0.5), + } + .child(thumb) + .into_any_element() + } +} + +/// Scroll region with overlay scrollbars that show while in use. +#[derive(IntoElement)] +pub struct ScrollArea { + id: ElementId, + base: Div, + shadows: bool, + body: SmallVec<[AnyElement; 2]>, +} + +impl ScrollArea { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: div(), + shadows: false, + body: SmallVec::new(), + } + } + + /// Soft edges where content continues. + pub fn shadows(mut self) -> Self { + self.shadows = true; + self + } +} + +impl Styled for ScrollArea { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for ScrollArea { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +fn track_activity(state: &Entity, window: &mut Window, cx: &mut App) -> f32 { + let offset = state.read(cx).handle.offset(); + if state.read(cx).last != offset { + state.update(cx, |activity, _| { + activity.last = offset; + activity.active_at = Some(Instant::now()); + }); + } + let fade = motion::duration(motion::SLOW, cx); + let presence = presence(state.read(cx), fade); + if presence > 0.0 && !state.read(cx).hovered { + window.request_animation_frame(); + } + presence +} + +impl RenderOnce for ScrollArea { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Activity { + handle: ScrollHandle::new(), + last: Point::default(), + active_at: None, + hovered: false, + }); + let handle = state.read(cx).handle.clone(); + let presence = track_activity(&state, window, cx); + self.base + .id(self.id) + .relative() + .overflow_hidden() + .on_hover(move |hovered, _, cx| { + state.update(cx, |activity, cx| { + activity.hovered = *hovered; + cx.notify(); + }) + }) + .child( + div() + .id("scroll-body") + .size_full() + .overflow_scroll() + .track_scroll(&handle) + .children(self.body), + ) + .when(self.shadows, |area| area.child(ScrollShadow::new(&handle))) + .child(Scrollbar::new("scrollbar-y", &handle, Axis::Vertical).presence(presence)) + .child(Scrollbar::new("scrollbar-x", &handle, Axis::Horizontal).presence(presence)) + } +} diff --git a/src/layout/scroll_aids.rs b/src/layout/scroll_aids.rs new file mode 100644 index 0000000..93c940a --- /dev/null +++ b/src/layout/scroll_aids.rs @@ -0,0 +1,253 @@ +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; + +use gpui::{ + AnyElement, App, ElementId, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, + Styled, Window, canvas, div, point, prelude::*, +}; + +use crate::{ + buttons::{ButtonVariant, IconButton}, + motion, + primitives::IconName, + theme::{ActiveTheme, Elevation}, +}; + +const FRAME: Duration = Duration::from_millis(8); + +/// Soft shade at each edge where content continues. +#[derive(IntoElement)] +pub struct ScrollShadow { + handle: ScrollHandle, +} + +impl ScrollShadow { + pub fn new(handle: &ScrollHandle) -> Self { + Self { + handle: handle.clone(), + } + } +} + +impl RenderOnce for ScrollShadow { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let scrolled = -self.handle.offset().y; + let reach = self.handle.max_offset().height; + let shade = cx.theme().elevation(Elevation::Floating); + let edge = || { + div() + .absolute() + .left_0() + .right_0() + .h_0() + .shadow(shade.clone()) + }; + div() + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden() + .when(scrolled > Pixels::ZERO, |layer| layer.child(edge().top_0())) + .when(scrolled < reach, |layer| layer.child(edge().bottom_0())) + } +} + +/// Floating button that eases back to the top once the reader has gone far. +#[derive(IntoElement)] +pub struct ScrollToTop { + id: ElementId, + handle: ScrollHandle, +} + +impl ScrollToTop { + pub fn new(id: impl Into, handle: &ScrollHandle) -> Self { + Self { + id: id.into(), + handle: handle.clone(), + } + } +} + +impl RenderOnce for ScrollToTop { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let scrolled = -self.handle.offset().y; + let view = self.handle.bounds().size.height; + if view <= Pixels::ZERO || scrolled < view / 2.0 { + return div().into_any_element(); + } + let handle = self.handle; + let duration = motion::duration(motion::SLOW, cx); + div() + .absolute() + .bottom_4() + .right_4() + .rounded_full() + .bg(cx.theme().colors.surface) + .shadow(cx.theme().elevation(Elevation::Floating)) + .child( + IconButton::new(self.id, IconName::ArrowUp) + .variant(ButtonVariant::Secondary) + .on_click(move |_, window, cx| { + let handle = handle.clone(); + let from = handle.offset(); + let start = Instant::now(); + window + .spawn(cx, async move |cx| { + loop { + cx.background_executor().timer(FRAME).await; + let t = (start.elapsed().as_secs_f32() + / duration.as_secs_f32()) + .min(1.0); + let left = 1.0 - motion::ease_out_cubic(t); + handle.set_offset(point(from.x, from.y * left)); + if cx.update(|window, _| window.refresh()).is_err() || t >= 1.0 + { + return; + } + } + }) + .detach(); + }), + ) + .into_any_element() + } +} + +type Header = Rc AnyElement>; + +/// A section whose header pins to the top of `handle`'s viewport. +#[derive(IntoElement)] +pub struct StickyHeader { + id: ElementId, + handle: ScrollHandle, + header: Header, + body: Vec, +} + +impl StickyHeader { + pub fn new( + id: impl Into, + handle: &ScrollHandle, + header: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + id: id.into(), + handle: handle.clone(), + header: Rc::new(header), + body: Vec::new(), + } + } +} + +impl ParentElement for StickyHeader { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +/// Where the section sits in the scrolled content, and its header's height. +#[derive(Clone, Copy, Default, PartialEq)] +struct Placement { + top: Pixels, + height: Pixels, + header: Pixels, +} + +/// The header's offset inside its section: 0 at rest, pinned while the section crosses the top. +fn pinned_offset(scrolled: Pixels, at: Placement) -> Pixels { + let reach = (at.height - at.header).max(Pixels::ZERO); + (scrolled - at.top).clamp(Pixels::ZERO, reach) +} + +impl RenderOnce for StickyHeader { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let seen = window.use_keyed_state(self.id, cx, |_, _| Placement::default()); + let offset = pinned_offset(-self.handle.offset().y, *seen.read(cx)); + let (section_state, header_state, handle) = (seen.clone(), seen, self.handle); + let bg = cx.theme().colors.bg; + div() + .relative() + .child( + div() + .id("sticky-rest") + .invisible() + .child((self.header)(window, cx)) + .child( + canvas( + move |bounds, _, cx| { + if header_state.read(cx).header != bounds.size.height { + header_state.update(cx, |at, cx| { + at.header = bounds.size.height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ), + ) + .children(self.body) + .child( + canvas( + move |bounds, _, cx| { + let content_top = handle.bounds().top() + handle.offset().y; + let (top, height) = (bounds.top() - content_top, bounds.size.height); + let at = *section_state.read(cx); + if at.top != top || at.height != height { + section_state.update(cx, |at, cx| { + at.top = top; + at.height = height; + cx.notify(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child( + div() + .id("sticky-pinned") + .absolute() + .top(offset) + .left_0() + .right_0() + .bg(bg) + .child((self.header)(window, cx)), + ) + } +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::{Placement, pinned_offset}; + + #[test] + fn pins_while_the_section_crosses_the_top_then_yields() { + let at = Placement { + top: px(50.0), + height: px(300.0), + header: px(30.0), + }; + assert_eq!(pinned_offset(px(0.0), at), px(0.0)); + assert_eq!(pinned_offset(px(100.0), at), px(50.0)); + assert_eq!(pinned_offset(px(400.0), at), px(270.0)); + let short = Placement { + height: px(20.0), + ..at + }; + assert_eq!(pinned_offset(px(400.0), short), px(0.0)); + } +} diff --git a/src/layout/seeded.rs b/src/layout/seeded.rs new file mode 100644 index 0000000..7fef067 --- /dev/null +++ b/src/layout/seeded.rs @@ -0,0 +1,26 @@ +use gpui::{App, ElementId, Entity, Window}; + +/// Local state that restarts from its seed whenever the owner passes a new one. +pub(crate) struct Seeded { + seed: T, + pub value: T, +} + +pub(crate) fn use_seeded( + id: impl Into, + seed: T, + window: &mut Window, + cx: &mut App, +) -> Entity> { + let state = window.use_keyed_state(id, cx, |_, _| Seeded { + seed: seed.clone(), + value: seed.clone(), + }); + if state.read(cx).seed != seed { + state.update(cx, |state, _| { + state.value = seed.clone(); + state.seed = seed; + }); + } + state +} diff --git a/src/layout/sheet.rs b/src/layout/sheet.rs new file mode 100644 index 0000000..d412b9e --- /dev/null +++ b/src/layout/sheet.rs @@ -0,0 +1,302 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, DragMoveEvent, ElementId, EmptyView, EntityId, + FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::IconButton, + motion, + primitives::{Backdrop, IconName, Place}, + theme::{ActiveTheme, Elevation, Radius, TextSize}, +}; + +type Close = Rc; + +fn title_row(title: SharedString, close: Close, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ) + .child( + IconButton::new("close", IconName::X).on_click(move |_, window, cx| close(window, cx)), + ) +} + +/// Panel that slides in from an edge over a scrim. +#[derive(IntoElement)] +pub struct Sheet { + id: ElementId, + side: Place, + title: SharedString, + on_close: Close, + body: SmallVec<[AnyElement; 2]>, +} + +impl Sheet { + /// `side` must be an edge, not `Place::Center`. + pub fn new( + id: impl Into, + side: Place, + title: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + assert!(side != Place::Center, "a sheet needs an edge"); + Self { + id: id.into(), + side, + title: title.into(), + on_close: Rc::new(on_close), + body: SmallVec::new(), + } + } +} + +impl ParentElement for Sheet { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Sheet { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let size = theme.sheet_size(); + let travel = size.to_pixels(window.rem_size()); + let side = self.side; + let panel = div() + .flex() + .flex_col() + .gap_5() + .p_6() + .bg(theme.colors.overlay) + .shadow(theme.elevation(Elevation::Modal)) + .map(|panel| match side { + Place::Left | Place::Right => panel.w(size).h_full(), + Place::Top | Place::Bottom | Place::Center => panel.h(size).w_full(), + }) + .child(title_row(self.title, self.on_close.clone(), cx)) + .children(self.body) + .with_animation( + "sheet-in", + Animation::new(motion::duration(motion::BASE, cx)) + .with_easing(motion::ease_out_cubic), + move |panel, t| { + let hidden = -(travel * (1.0 - t)); + match side { + Place::Left => panel.ml(hidden), + Place::Right => panel.mr(hidden), + Place::Top => panel.mt(hidden), + Place::Bottom | Place::Center => panel.mb(hidden), + } + }, + ); + let close = self.on_close; + Backdrop::new(self.id) + .place(side) + .on_dismiss(move |window, cx| close(window, cx)) + .child(panel) + } +} + +#[derive(Default)] +struct Pull { + pulling: bool, + offset: Pixels, + released: Pixels, + snaps: u64, + height: Pixels, +} + +struct DrawerPull { + owner: EntityId, + anchor: Rc>>, +} + +/// Share of the drawer's height a pull must pass to dismiss it. +const DISMISS: f32 = 0.3; + +/// Bottom panel with a grab handle; pull it down to dismiss. +#[derive(IntoElement)] +pub struct Drawer { + id: ElementId, + on_close: Close, + body: SmallVec<[AnyElement; 2]>, +} + +impl Drawer { + pub fn new( + id: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + on_close: Rc::new(on_close), + body: SmallVec::new(), + } + } +} + +impl ParentElement for Drawer { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Drawer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Pull::default()); + let owner = state.entity_id(); + let (offset, released, snaps, threshold) = { + let pull = state.read(cx); + ( + pull.offset, + pull.released, + pull.snaps, + pull.height * DISMISS, + ) + }; + let theme = cx.theme(); + let grip = theme.grip(); + let travel = theme.sheet_size().to_pixels(window.rem_size()); + let quick = motion::duration(motion::BASE, cx); + let (move_state, release, out, measure) = + (state.clone(), state.clone(), state.clone(), state); + let close = self.on_close.clone(); + let finish = move |pull: &mut Pull| -> bool { + let dismiss = pull.pulling && pull.offset > threshold; + log::info!("drawer: pulled {:?} of {threshold:?}", pull.offset); + pull.pulling = false; + pull.released = pull.offset; + pull.offset = Pixels::ZERO; + pull.snaps += 1; + dismiss + }; + let finish_out = finish; + let (close_up, close_out) = (close.clone(), close); + let panel = div() + .id("drawer-panel") + .relative() + .w_full() + .max_h(relative(0.85)) + .flex() + .flex_col() + .gap_4() + .px_6() + .pb_8() + .rounded_t(theme.radius(Radius::Xl)) + .bg(theme.colors.overlay) + .shadow(theme.elevation(Elevation::Modal)) + .mb(-offset) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + let pull = event.drag(cx); + if pull.owner != owner { + return; + } + let y = event.event.position.y; + let anchor = pull.anchor.get().unwrap_or_else(|| { + pull.anchor.set(Some(y)); + y + }); + move_state.update(cx, |pull, cx| { + pull.pulling = true; + pull.offset = (y - anchor).max(Pixels::ZERO); + cx.notify(); + }) + }) + .on_mouse_up(MouseButton::Left, move |_, window, cx| { + let pulled = release.read(cx).pulling; + if pulled + && release.update(cx, |pull, cx| { + cx.notify(); + finish(pull) + }) + { + close_up(window, cx); + } + }) + .on_mouse_up_out(MouseButton::Left, move |_, window, cx| { + let pulled = out.read(cx).pulling; + if pulled + && out.update(cx, |pull, cx| { + cx.notify(); + finish_out(pull) + }) + { + close_out(window, cx); + } + }) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).height != bounds.size.height { + measure.update(cx, |pull, _| pull.height = bounds.size.height); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child( + div() + .id("drawer-grab") + .flex() + .justify_center() + .pt_3() + .pb_2() + .cursor_grab() + .on_drag( + DrawerPull { + owner, + anchor: Rc::new(Cell::new(None)), + }, + |_, _, _, cx| cx.new(|_| EmptyView), + ) + .child( + div() + .w(grip.width) + .h(grip.height) + .rounded_full() + .bg(theme.colors.border_strong), + ), + ) + .children(self.body); + let snapping = snaps > 0 && released > Pixels::ZERO; + let settled = div().w_full().child(panel); + let settled = if snapping { + settled + .with_animation( + ("drawer-snap", snaps), + Animation::new(quick).with_easing(motion::ease_out_cubic), + move |wrap, t| wrap.mb(-(released * (1.0 - t))), + ) + .into_any_element() + } else { + settled.into_any_element() + }; + let entering = div().w_full().child(settled).with_animation( + "drawer-in", + Animation::new(quick).with_easing(motion::ease_out_cubic), + move |wrap, t| wrap.mb(-(travel * (1.0 - t))), + ); + let close = self.on_close; + Backdrop::new(self.id) + .place(Place::Bottom) + .on_dismiss(move |window, cx| close(window, cx)) + .child(entering) + } +} diff --git a/src/layout/sidebar.rs b/src/layout/sidebar.rs new file mode 100644 index 0000000..b841cf1 --- /dev/null +++ b/src/layout/sidebar.rs @@ -0,0 +1,95 @@ +use std::time::Instant; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, + ParentElement, RenderOnce, Styled, Window, div, +}; +use smallvec::SmallVec; + +use crate::{motion, theme::ActiveTheme}; + +struct Width { + collapsed: bool, + generation: u64, + changed: Instant, +} + +/// Side column that folds to an icon rail, width animated. +#[derive(IntoElement)] +pub struct Sidebar { + id: ElementId, + collapsed: bool, + body: SmallVec<[AnyElement; 4]>, +} + +impl Sidebar { + /// Children should render their own compact form when `collapsed`. + pub fn new(id: impl Into, collapsed: bool) -> Self { + Self { + id: id.into(), + collapsed, + body: SmallVec::new(), + } + } +} + +impl ParentElement for Sidebar { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for Sidebar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let collapsed = self.collapsed; + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Width { + collapsed, + generation: 0, + changed: Instant::now(), + }); + if state.read(cx).collapsed != collapsed { + state.update(cx, |width, _| { + width.collapsed = collapsed; + width.generation += 1; + width.changed = Instant::now(); + }); + } + let (generation, changed) = { + let width = state.read(cx); + (width.generation, width.changed) + }; + let theme = cx.theme(); + let (wide, narrow) = (theme.sidebar_width(false), theme.sidebar_width(true)); + let duration = motion::duration(motion::BASE, cx); + let column = div() + .id(self.id) + .flex() + .flex_col() + .flex_none() + .h_full() + .overflow_hidden() + .border_r_1() + .border_color(theme.colors.border) + .bg(theme.colors.bg) + .children(self.body); + if generation == 0 || changed.elapsed() >= duration { + return column + .w(if collapsed { narrow } else { wide }) + .into_any_element(); + } + column + .with_animation( + ("width", generation), + Animation::new(duration).with_easing(motion::ease_in_out_cubic), + move |column, t| { + let (from, to) = if collapsed { + (wide, narrow) + } else { + (narrow, wide) + }; + column.w(from + (to - from) * t) + }, + ) + .into_any_element() + } +} diff --git a/src/layout/split.rs b/src/layout/split.rs new file mode 100644 index 0000000..5b46458 --- /dev/null +++ b/src/layout/split.rs @@ -0,0 +1,232 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, + InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, Stateful, + StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::seeded::use_seeded; +use crate::theme::ActiveTheme; + +/// Thin divider with a wide grab area. `axis` is the split's direction. +pub fn resize_handle(id: impl Into, axis: Axis, cx: &App) -> Stateful { + let theme = cx.theme(); + let (line, lit, hit) = (theme.colors.border, theme.colors.focus, theme.handle_hit()); + let base = div() + .id(id) + .group("resize") + .flex_none() + .flex() + .justify_center() + .items_center(); + match axis { + Axis::Horizontal => base + .w(hit) + .h_full() + .cursor(CursorStyle::ResizeLeftRight) + .child( + div() + .h_full() + .border_l_1() + .border_color(line) + .group_hover("resize", |s| s.border_color(lit)), + ), + Axis::Vertical => base + .h(hit) + .w_full() + .cursor(CursorStyle::ResizeUpDown) + .child( + div() + .w_full() + .border_t_1() + .border_color(line) + .group_hover("resize", |s| s.border_color(lit)), + ), + } +} + +struct DividerDrag { + owner: EntityId, + ix: usize, + start: Vec, + anchor: Rc>>, +} + +/// Moves divider `ix` by `delta` of the whole, keeping each pane at `floor` or more. +fn shift(start: &[f32], ix: usize, delta: f32, floor: f32) -> Vec { + let pair = start[ix] + start[ix + 1]; + let floor = floor.min(pair / 2.0); + let left = (start[ix] + delta).clamp(floor, pair - floor); + let mut next = start.to_vec(); + next[ix] = left; + next[ix + 1] = pair - left; + next +} + +/// Weights are finite and non-negative, with a finite, positive sum. +pub(crate) fn check_weights(weights: &[f32]) -> anyhow::Result { + anyhow::ensure!( + weights + .iter() + .all(|weight| weight.is_finite() && *weight >= 0.0), + "split weights {weights:?} must be finite and non-negative" + ); + let total: f32 = weights.iter().sum(); + anyhow::ensure!( + total.is_finite() && total > 0.0, + "split weights {weights:?} need a finite, positive sum" + ); + Ok(total) +} + +type OnResize = Rc; + +/// Panes side by side or stacked, split by draggable handles. +#[derive(IntoElement)] +pub struct SplitPane { + id: ElementId, + axis: Axis, + panes: Vec, + sizes: Option>, + min: Pixels, + on_resize: Option, +} + +impl SplitPane { + /// `min` is the smallest a pane may shrink to. + pub fn new(id: impl Into, axis: Axis, min: Pixels) -> Self { + Self { + id: id.into(), + axis, + panes: Vec::new(), + sizes: None, + min, + on_resize: None, + } + } + + pub fn pane(mut self, pane: impl IntoElement) -> Self { + self.panes.push(pane.into_any_element()); + self + } + + /// Shares, one per pane, normalized. A new value replaces dragged ones. + pub fn sizes(mut self, sizes: &[f32]) -> Self { + self.sizes = Some(sizes.to_vec()); + self + } + + /// Reports shares after each drag step. + pub fn on_resize(mut self, handler: impl Fn(&[f32], &mut Window, &mut App) + 'static) -> Self { + self.on_resize = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SplitPane { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.panes.len(); + assert!(count >= 2, "split pane {:?} needs two panes", self.id); + let weights = self.sizes.unwrap_or_else(|| vec![1.0; count]); + assert_eq!( + weights.len(), + count, + "split pane {:?}: one size per pane", + self.id + ); + let total = check_weights(&weights) + .unwrap_or_else(|error| panic!("split pane {:?}: {error:#}", self.id)); + let normalized: Vec = weights.iter().map(|weight| weight / total).collect(); + let fractions = use_seeded(self.id.clone(), normalized, window, cx); + let owner = fractions.entity_id(); + let on_resize = self.on_resize; + let shares = fractions.read(cx).value.clone(); + let (axis, min) = (self.axis, self.min); + let mut root = div().id(self.id).size_full().flex().on_drag_move( + move |event: &DragMoveEvent, window, cx| { + let drag = event.drag(cx); + if drag.owner != owner { + return; + } + let (along, extent) = match axis { + Axis::Horizontal => (event.event.position.x, event.bounds.size.width), + Axis::Vertical => (event.event.position.y, event.bounds.size.height), + }; + let anchor = match drag.anchor.get() { + Some(anchor) => anchor, + None => { + drag.anchor.set(Some(along)); + along + } + }; + let next = shift( + &drag.start, + drag.ix, + (along - anchor) / extent, + min / extent, + ); + if let Some(report) = &on_resize { + report(&next, window, cx); + } + fractions.update(cx, |shares, cx| { + shares.value = next; + cx.notify(); + }); + }, + ); + root = match axis { + Axis::Horizontal => root.flex_row(), + Axis::Vertical => root.flex_col(), + }; + let mut children = Vec::with_capacity(count * 2); + for (ix, pane) in self.panes.into_iter().enumerate() { + if ix > 0 { + let drag = DividerDrag { + owner, + ix: ix - 1, + start: shares.clone(), + anchor: Rc::new(Cell::new(None)), + }; + children.push( + resize_handle(("divider", ix), axis, cx) + .on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView)) + .into_any_element(), + ); + } + let mut cell = div() + .relative() + .overflow_hidden() + .flex_basis(gpui::relative(0.0)); + cell.style().flex_grow = Some(shares[ix]); + children.push(cell.child(pane).into_any_element()); + } + root.children(children) + } +} + +#[cfg(test)] +mod tests { + use super::{check_weights, shift}; + + #[test] + fn weights_must_be_finite_non_negative_and_sum_positive() { + assert_eq!(check_weights(&[1.0, 3.0]).unwrap(), 4.0); + assert_eq!(check_weights(&[0.0, 1.0]).unwrap(), 1.0); + assert!(check_weights(&[0.0, 0.0]).is_err()); + assert!(check_weights(&[-1.0, 2.0]).is_err()); + assert!(check_weights(&[f32::NAN, 1.0]).is_err()); + assert!(check_weights(&[f32::MAX, f32::MAX]).is_err()); + } + + #[test] + fn shift_moves_one_boundary_and_respects_the_floor() { + let moved = shift(&[0.5, 0.5], 0, 0.1, 0.1); + assert!((moved[0] - 0.6).abs() < 1e-6 && (moved[1] - 0.4).abs() < 1e-6); + let floored = shift(&[0.5, 0.5], 0, -0.9, 0.2); + assert!((floored[0] - 0.2).abs() < 1e-6); + let three = shift(&[0.2, 0.3, 0.5], 1, 0.1, 0.05); + assert!((three[1] - 0.4).abs() < 1e-6 && (three[2] - 0.4).abs() < 1e-6); + assert!((three[0] - 0.2).abs() < 1e-6); + } +} diff --git a/src/layout/stack.rs b/src/layout/stack.rs new file mode 100644 index 0000000..64d1385 --- /dev/null +++ b/src/layout/stack.rs @@ -0,0 +1,135 @@ +use gpui::{ + AnyElement, App, Div, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled, Window, + div, +}; +use smallvec::SmallVec; + +use crate::theme::{ActiveTheme, ContainerSize}; + +/// Row with children centered on the cross axis. +pub fn h_stack() -> Div { + div().flex().flex_row().items_center() +} + +/// Column. +pub fn v_stack() -> Div { + div().flex().flex_col() +} + +/// Layers: the first child sets the size, the rest cover it. +#[derive(IntoElement)] +pub struct ZStack { + base: Div, + layers: SmallVec<[AnyElement; 2]>, +} + +impl ZStack { + pub fn new() -> Self { + Self { + base: div(), + layers: SmallVec::new(), + } + } +} + +impl Default for ZStack { + fn default() -> Self { + Self::new() + } +} + +impl Styled for ZStack { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for ZStack { + fn extend(&mut self, elements: impl IntoIterator) { + self.layers.extend(elements); + } +} + +impl RenderOnce for ZStack { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let mut layers = self.layers.into_iter(); + let ground = layers.next(); + self.base + .relative() + .children(ground) + .children(layers.map(|layer| div().absolute().inset_0().child(layer))) + } +} + +/// Keeps width to height at `ratio`; width fills the parent. +#[derive(IntoElement)] +pub struct AspectRatio { + base: Div, + ratio: f32, +} + +impl AspectRatio { + /// Ratios such as `16.0 / 9.0`. Must be positive. + pub fn new(ratio: f32) -> Self { + assert!( + ratio.is_finite() && ratio > 0.0, + "aspect ratio {ratio} is not positive" + ); + Self { base: div(), ratio } + } +} + +impl Styled for AspectRatio { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for AspectRatio { + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} + +impl RenderOnce for AspectRatio { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let mut base = self.base.w_full().overflow_hidden(); + base.style().aspect_ratio = Some(self.ratio); + base + } +} + +/// Centered column with a readable maximum width. +#[derive(IntoElement)] +pub struct Container { + base: Div, + size: ContainerSize, +} + +impl Container { + pub fn new(size: ContainerSize) -> Self { + Self { base: div(), size } + } +} + +impl Styled for Container { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl ParentElement for Container { + fn extend(&mut self, elements: impl IntoIterator) { + self.base.extend(elements); + } +} + +impl RenderOnce for Container { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + self.base + .w_full() + .max_w(cx.theme().container_width(self.size)) + .mx_auto() + .px_6() + } +} diff --git a/src/layout/surface.rs b/src/layout/surface.rs new file mode 100644 index 0000000..3d9eec2 --- /dev/null +++ b/src/layout/surface.rs @@ -0,0 +1,395 @@ +use gpui::{ + AnyElement, App, Div, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + StyleRefinement, Styled, Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use crate::theme::{ActiveTheme, ControlSize, Radius, TextSize}; + +macro_rules! container_parts { + ($name:ident) => { + impl Styled for $name { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } + } + + impl ParentElement for $name { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } + } + }; +} + +fn heading( + title: SharedString, + description: Option, + size: TextSize, + cx: &App, +) -> impl IntoElement + use<> { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_0p5() + .min_w_0() + .child( + div() + .text_size(theme.text_size(size)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg) + .child(title), + ) + .when_some(description, |block, text| { + block.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(text), + ) + }) +} + +/// Title row for a `Card`: title, note, one trailing action. +#[derive(IntoElement)] +pub struct CardHeader { + title: SharedString, + description: Option, + action: Option, +} + +impl CardHeader { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + description: None, + action: None, + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.action = Some(action.into_any_element()); + self + } +} + +impl RenderOnce for CardHeader { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .flex() + .items_start() + .justify_between() + .gap_4() + .child(heading(self.title, self.description, TextSize::Md, cx)) + .children(self.action) + } +} + +/// Flat surface: header, body, footer. Hairline, no shadow. +#[derive(IntoElement)] +pub struct Card { + base: Div, + header: Option, + footer: Option, + body: SmallVec<[AnyElement; 2]>, +} + +impl Card { + pub fn new() -> Self { + Self { + base: div(), + header: None, + footer: None, + body: SmallVec::new(), + } + } + + pub fn header(mut self, header: CardHeader) -> Self { + self.header = Some(header); + self + } + + pub fn footer(mut self, footer: impl IntoElement) -> Self { + self.footer = Some(footer.into_any_element()); + self + } +} + +impl Default for Card { + fn default() -> Self { + Self::new() + } +} + +container_parts!(Card); + +impl RenderOnce for Card { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + self.base + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .when_some(self.header, |card, header| { + card.child(div().px_5().pt_5().child(header)) + }) + .child(div().p_5().flex().flex_col().gap_3().children(self.body)) + .when_some(self.footer, |card, footer| { + card.child( + div() + .flex() + .justify_end() + .gap_2() + .px_5() + .py_3() + .border_t_1() + .border_color(theme.colors.border) + .child(footer), + ) + }) + } +} + +/// App region with a header bar: title and actions. +#[derive(IntoElement)] +pub struct Panel { + base: Div, + title: SharedString, + actions: SmallVec<[AnyElement; 2]>, + body: SmallVec<[AnyElement; 2]>, +} + +impl Panel { + pub fn new(title: impl Into) -> Self { + Self { + base: div(), + title: title.into(), + actions: SmallVec::new(), + body: SmallVec::new(), + } + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.actions.push(action.into_any_element()); + self + } +} + +container_parts!(Panel); + +impl RenderOnce for Panel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + self.base + .flex() + .flex_col() + .overflow_hidden() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .child( + div() + .flex() + .items_center() + .justify_between() + .h(theme.control_height(ControlSize::Lg)) + .pl_3() + .pr_1() + .border_b_1() + .border_color(theme.colors.border) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg_muted) + .child(self.title), + ) + .child(div().flex().items_center().gap_0p5().children(self.actions)), + ) + .child(div().flex_1().children(self.body)) + } +} + +/// Titled block of a page, with an optional note and action. +#[derive(IntoElement)] +pub struct Section { + base: Div, + title: SharedString, + description: Option, + action: Option, + body: SmallVec<[AnyElement; 2]>, +} + +impl Section { + pub fn new(title: impl Into) -> Self { + Self { + base: div(), + title: title.into(), + description: None, + action: None, + body: SmallVec::new(), + } + } + + pub fn description(mut self, text: impl Into) -> Self { + self.description = Some(text.into()); + self + } + + pub fn action(mut self, action: impl IntoElement) -> Self { + self.action = Some(action.into_any_element()); + self + } +} + +container_parts!(Section); + +impl RenderOnce for Section { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + self.base + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child(heading(self.title, self.description, TextSize::Lg, cx)) + .children(self.action), + ) + .children(self.body) + } +} + +/// Named group of fields inside a hairline. +#[derive(IntoElement)] +pub struct Fieldset { + base: Div, + legend: SharedString, + body: SmallVec<[AnyElement; 2]>, +} + +impl Fieldset { + pub fn new(legend: impl Into) -> Self { + Self { + base: div(), + legend: legend.into(), + body: SmallVec::new(), + } + } +} + +container_parts!(Fieldset); + +impl RenderOnce for Fieldset { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + self.base + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg_muted) + .child(self.legend), + ) + .child( + div() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .children(self.body), + ) + } +} + +/// Bordered region, no fill. +#[derive(IntoElement)] +pub struct Frame { + base: Div, + body: SmallVec<[AnyElement; 2]>, +} + +impl Frame { + pub fn new() -> Self { + Self { + base: div(), + body: SmallVec::new(), + } + } +} + +impl Default for Frame { + fn default() -> Self { + Self::new() + } +} + +container_parts!(Frame); + +impl RenderOnce for Frame { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + self.base + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border_strong) + .p_4() + .children(self.body) + } +} + +/// Recessed region on the sunken tone. +#[derive(IntoElement)] +pub struct Well { + base: Div, + body: SmallVec<[AnyElement; 2]>, +} + +impl Well { + pub fn new() -> Self { + Self { + base: div(), + body: SmallVec::new(), + } + } +} + +impl Default for Well { + fn default() -> Self { + Self::new() + } +} + +container_parts!(Well); + +impl RenderOnce for Well { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + self.base + .rounded(theme.radius(Radius::Lg)) + .bg(theme.colors.sunken) + .border_1() + .border_color(theme.colors.border) + .p_4() + .children(self.body) + } +} diff --git a/src/layout/viewport.rs b/src/layout/viewport.rs new file mode 100644 index 0000000..daa389a --- /dev/null +++ b/src/layout/viewport.rs @@ -0,0 +1,189 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, Point, RenderOnce, Styled, Window, canvas, div, point, +}; + +/// Maps content space to the viewport: `local = content * scale + offset`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Transform { + pub offset: Point, + pub scale: f32, +} + +impl Transform { + pub fn apply(&self, content: Point) -> Point { + point( + content.x * self.scale + self.offset.x, + content.y * self.scale + self.offset.y, + ) + } + + pub fn invert(&self, local: Point) -> Point { + point( + (local.x - self.offset.x) / self.scale, + (local.y - self.offset.y) / self.scale, + ) + } + + /// Scales by `factor`, clamped, keeping `anchor` fixed on screen. + fn zoom_at(self, anchor: Point, factor: f32, range: (f32, f32)) -> Self { + let scale = (self.scale * factor).clamp(range.0, range.1); + let ratio = scale / self.scale; + Self { + offset: point( + anchor.x - (anchor.x - self.offset.x) * ratio, + anchor.y - (anchor.y - self.offset.y) * ratio, + ), + scale, + } + } +} + +struct View { + transform: Transform, + bounds: Bounds, + grab: Option<(Point, Point)>, +} + +type Content = Rc AnyElement>; + +/// Pan with a drag or the wheel; zoom with ⌘/Ctrl and the wheel. +#[derive(IntoElement)] +pub struct Viewport { + id: ElementId, + range: (f32, f32), + content: Content, +} + +impl Viewport { + /// `content` places itself with the transform it receives. + pub fn new( + id: impl Into, + content: impl Fn(Transform, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + id: id.into(), + range: (0.1, 8.0), + content: Rc::new(content), + } + } + + pub fn zoom_range(mut self, min: f32, max: f32) -> Self { + assert!(0.0 < min && min <= max, "zoom range {min}..{max} is empty"); + self.range = (min, max); + self + } +} + +impl RenderOnce for Viewport { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let state = window.use_keyed_state(self.id.clone(), cx, |_, _| View { + transform: Transform { + offset: Point::default(), + scale: 1.0, + }, + bounds: Bounds::default(), + grab: None, + }); + let transform = state.read(cx).transform; + let range = self.range; + let (wheel, down, drag, up, out, measure) = ( + state.clone(), + state.clone(), + state.clone(), + state.clone(), + state.clone(), + state, + ); + div() + .id(self.id) + .relative() + .size_full() + .overflow_hidden() + .on_scroll_wheel(move |event, window, cx| { + let delta = event.delta.pixel_delta(window.line_height()); + wheel.update(cx, |view, cx| { + view.transform = if event.modifiers.secondary() || event.modifiers.control { + let anchor = event.position - view.bounds.origin; + let factor = (-f32::from(delta.y) * 0.01).exp(); + view.transform.zoom_at(anchor, factor, range) + } else { + Transform { + offset: view.transform.offset + delta, + ..view.transform + } + }; + cx.notify(); + }); + cx.stop_propagation(); + }) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + down.update(cx, |view, _| { + view.grab = Some((event.position, view.transform.offset)) + }) + }) + .on_mouse_move(move |event, _, cx| { + drag.update(cx, |view, cx| { + if let Some((anchor, start)) = view.grab.filter(|_| event.dragging()) { + view.transform.offset = start + (event.position - anchor); + cx.notify(); + } + }) + }) + .on_mouse_up(MouseButton::Left, move |_, _, cx| { + up.update(cx, |view, _| view.grab = None) + }) + .on_mouse_up_out(MouseButton::Left, move |_, _, cx| { + out.update(cx, |view, _| view.grab = None) + }) + .child( + canvas( + move |bounds, _, cx| { + if measure.read(cx).bounds != bounds { + measure.update(cx, |view, _| view.bounds = bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .child((self.content)(transform, window, cx)) + } +} + +#[cfg(test)] +mod tests { + use gpui::{point, px}; + + use super::Transform; + + #[test] + fn apply_and_invert_are_inverse() { + let transform = Transform { + offset: point(px(30.0), px(-20.0)), + scale: 2.5, + }; + let content = point(px(12.0), px(8.0)); + let back = transform.invert(transform.apply(content)); + assert!((f32::from(back.x) - 12.0).abs() < 1e-4 && (f32::from(back.y) - 8.0).abs() < 1e-4); + } + + #[test] + fn zoom_keeps_the_anchor_still_and_clamps() { + let start = Transform { + offset: point(px(10.0), px(10.0)), + scale: 1.0, + }; + let anchor = point(px(100.0), px(50.0)); + let zoomed = start.zoom_at(anchor, 2.0, (0.1, 8.0)); + let before = start.invert(anchor); + let after = zoomed.invert(anchor); + assert!((f32::from(before.x) - f32::from(after.x)).abs() < 1e-4); + assert_eq!(start.zoom_at(anchor, 100.0, (0.1, 8.0)).scale, 8.0); + } +} diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs new file mode 100644 index 0000000..76767e3 --- /dev/null +++ b/src/layout/workspace.rs @@ -0,0 +1,105 @@ +use anyhow::Context as _; +use gpui::{App, Entity}; +use serde::{Deserialize, Serialize}; + +use super::{Dock, DockLayout, PaneGroup, PaneLayout}; + +/// Bump when a field changes meaning; never reuse a removed name. +pub const WORKSPACE_VERSION: u32 = 1; + +/// Saved window layout: docks and editor panes, as JSON. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Workspace { + pub version: u32, + pub dock: DockLayout, + pub panes: PaneLayout, +} + +impl Workspace { + pub fn capture(dock: &Dock, panes: &PaneGroup) -> Self { + Self { + version: WORKSPACE_VERSION, + dock: dock.layout(), + panes: panes.layout(), + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string_pretty(self).expect("workspace layout always serializes") + } + + /// Unknown fields are ignored; a newer version is refused. + pub fn from_json(json: &str) -> anyhow::Result { + let workspace: Self = serde_json::from_str(json).context("workspace layout json")?; + anyhow::ensure!( + workspace.version <= WORKSPACE_VERSION, + "workspace version {} is newer than this build ({WORKSPACE_VERSION})", + workspace.version + ); + Ok(workspace) + } + + /// Applies all of it or none of it. + pub fn apply( + &self, + dock: &Entity, + panes: &Entity, + cx: &mut App, + ) -> anyhow::Result<()> { + self.panes.validate()?; + dock.update(cx, |dock, cx| dock.restore(&self.dock, cx))?; + panes.update(cx, |group, cx| group.restore(self.panes.clone(), cx)) + } +} + +#[cfg(test)] +mod tests { + use gpui::Axis; + + use super::*; + use crate::layout::{DockSide, Spot}; + + fn sample() -> Workspace { + Workspace { + version: WORKSPACE_VERSION, + dock: DockLayout { + spots: vec![ + ("files".into(), Spot::Docked(DockSide::Left)), + ("outline".into(), Spot::Floating { x: 40.0, y: 60.0 }), + ], + active: [Some("files".into()), None, None, None], + sizes: [15.0, 17.5, 12.0, 13.75], + }, + panes: PaneLayout::Split { + axis: Axis::Horizontal, + sizes: vec![0.5, 0.5], + children: vec![PaneLayout::Pane(1), PaneLayout::Pane(2)], + }, + } + } + + #[test] + fn round_trips_through_json() { + let saved = sample(); + assert_eq!(Workspace::from_json(&saved.to_json()).unwrap(), saved); + } + + #[test] + fn ignores_unknown_fields_but_refuses_newer_versions() { + let mut value = serde_json::to_value(sample()).unwrap(); + value["added_later"] = serde_json::json!(true); + assert!(Workspace::from_json(&value.to_string()).is_ok()); + value["version"] = serde_json::json!(WORKSPACE_VERSION + 1); + assert!(Workspace::from_json(&value.to_string()).is_err()); + } + + #[test] + fn invalid_pane_trees_are_refused() { + let bad = PaneLayout::Split { + axis: Axis::Vertical, + sizes: vec![1.0], + children: vec![PaneLayout::Pane(1), PaneLayout::Pane(1)], + }; + assert!(bad.validate().is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 72fd868..ab5f80f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod assets; pub mod buttons; +pub mod layout; pub mod motion; pub mod primitives; pub mod theme; diff --git a/src/primitives/backdrop.rs b/src/primitives/backdrop.rs index 8b9039a..54a048d 100644 --- a/src/primitives/backdrop.rs +++ b/src/primitives/backdrop.rs @@ -11,10 +11,22 @@ use crate::{motion, theme::ActiveTheme}; type Dismiss = Rc; -/// Full-window scrim. Children sit centered above it. +/// Where a `Backdrop` holds its children. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Place { + #[default] + Center, + Left, + Right, + Top, + Bottom, +} + +/// Full-window scrim. Children sit above it, centered or on an edge. #[derive(IntoElement)] pub struct Backdrop { id: ElementId, + place: Place, on_dismiss: Option, children: SmallVec<[AnyElement; 1]>, } @@ -23,11 +35,18 @@ impl Backdrop { pub fn new(id: impl Into) -> Self { Self { id: id.into(), + place: Place::Center, on_dismiss: None, children: SmallVec::new(), } } + /// Edges stretch children along that edge. + pub fn place(mut self, place: Place) -> Self { + self.place = place; + self + } + /// Runs on a click outside the children. pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_dismiss = Some(Rc::new(handler)); @@ -69,8 +88,13 @@ impl RenderOnce for Backdrop { .w(viewport.width) .h(viewport.height) .flex() - .items_center() - .justify_center() + .map(|layer| match self.place { + Place::Center => layer.items_center().justify_center(), + Place::Left => layer.flex_row().justify_start(), + Place::Right => layer.flex_row().justify_end(), + Place::Top => layer.flex_col().justify_start(), + Place::Bottom => layer.flex_col().justify_end(), + }) .child(scrim) .children( self.children diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 893059e..9b1a930 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -7,7 +7,7 @@ mod measure; mod pressable; mod tooltip; -pub use backdrop::Backdrop; +pub use backdrop::{Backdrop, Place}; pub use divider::Divider; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; pub use icon::{Icon, IconName}; diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 9394ba2..e24403b 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -7,7 +7,7 @@ use gpui::{App, Global, SharedString, WindowAppearance}; use crate::motion; pub use palette::{Mix, Palette, Syntax}; -pub use tokens::{ControlSize, Density, Elevation, IconSize, Radius, TextSize}; +pub use tokens::{ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize}; const FRAME: Duration = Duration::from_millis(8); diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index a0db1d7..9048946 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -1,4 +1,4 @@ -use gpui::{BoxShadow, Hsla, Rems, point, px, rems}; +use gpui::{BoxShadow, Hsla, Rems, Size, point, px, rems, size}; use super::{Mode, Theme}; @@ -41,6 +41,14 @@ pub enum TextSize { Display, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ContainerSize { + Sm, + #[default] + Md, + Lg, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Radius { Sm, @@ -101,6 +109,46 @@ impl Theme { px_to_rems(base * self.font_scale) } + pub fn container_width(&self, size: ContainerSize) -> Rems { + px_to_rems(match size { + ContainerSize::Sm => 640.0, + ContainerSize::Md => 960.0, + ContainerSize::Lg => 1200.0, + }) + } + + pub fn sidebar_width(&self, collapsed: bool) -> Rems { + px_to_rems(if collapsed { 56.0 } else { 240.0 }) + } + + /// Width of a side sheet, height of a top or bottom one. + pub fn sheet_size(&self) -> Rems { + px_to_rems(380.0) + } + + pub fn scrollbar_thickness(&self) -> Rems { + px_to_rems(6.0) + } + + pub fn scrollbar_min_thumb(&self) -> Rems { + px_to_rems(24.0) + } + + /// Smallest a split pane or dock may shrink to. + pub fn pane_min(&self) -> Rems { + px_to_rems(160.0) + } + + /// Grab bar on a drawer. + pub fn grip(&self) -> Size { + size(px_to_rems(36.0), px_to_rems(4.0)) + } + + /// Hit area of a split or resize handle. + pub fn handle_hit(&self) -> Rems { + px_to_rems(6.0) + } + /// Gap between the pointer and a tooltip. pub fn cursor_offset(&self) -> Rems { px_to_rems(18.0) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 09f687f..45f2f8e 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -68,47 +68,47 @@ ## T03 Layout — `src/layout` -- [ ] Stack / HStack / VStack -- [ ] Flex — native `.flex()` -- [ ] Grid / GridItem — native `.grid()`, `.col_span()` -- [ ] SimpleGrid -- [ ] Masonry -- [ ] Center — native `.flex().items_center().justify_center()` -- [ ] Container -- [ ] AspectRatio -- [ ] Inset / Padding — native `.p_*()` -- [ ] Wrap — native `.flex_wrap()` -- [ ] Absolute / Positioned — native `.absolute()` -- [ ] Layer / ZStack -- [ ] ScrollArea -- [ ] Scrollbar -- [ ] ScrollShadow -- [ ] ScrollToTop -- [ ] StickyHeader -- [ ] SplitPane — horizontal, vertical, nested -- [ ] ResizablePanel / ResizablePanelGroup → `layout::SplitPane` -- [ ] ResizeHandle -- [ ] Collapsible -- [ ] Accordion / AccordionItem -- [ ] Card / CardHeader / CardBody / CardFooter -- [ ] Panel / PanelHeader -- [ ] Section / SectionHeader -- [ ] Group / Fieldset -- [ ] Frame -- [ ] Well -- [ ] Sidebar -- [ ] Drawer -- [ ] Sheet -- [ ] Page / PageHeader / PageContent -- [ ] AppShell -- [ ] MasterDetail -- [ ] ThreeColumnLayout → `layout::SplitPane` -- [ ] Dock / DockPanel -- [ ] DockZone / DropIndicator -- [ ] FloatingPanel -- [ ] Pane / PaneGroup -- [ ] Workspace — versioned layout snapshot -- [ ] Viewport +- [x] Stack / HStack / VStack +- [x] Flex — native `.flex()` +- [x] Grid / GridItem — native `.grid()`, `.col_span()` +- [x] SimpleGrid +- [x] Masonry +- [x] Center — native `.flex().items_center().justify_center()` +- [x] Container +- [x] AspectRatio +- [x] Inset / Padding — native `.p_*()` +- [x] Wrap — native `.flex_wrap()` +- [x] Absolute / Positioned — native `.absolute()` +- [x] Layer / ZStack +- [x] ScrollArea +- [x] Scrollbar +- [x] ScrollShadow +- [x] ScrollToTop +- [x] StickyHeader +- [x] SplitPane — horizontal, vertical, nested +- [x] ResizablePanel / ResizablePanelGroup → `layout::SplitPane` +- [x] ResizeHandle +- [x] Collapsible +- [x] Accordion / AccordionItem +- [x] Card / CardHeader / CardBody / CardFooter +- [x] Panel / PanelHeader +- [x] Section / SectionHeader +- [x] Group / Fieldset +- [x] Frame +- [x] Well +- [x] Sidebar +- [x] Drawer +- [x] Sheet +- [x] Page / PageHeader / PageContent +- [x] AppShell +- [x] MasterDetail +- [x] ThreeColumnLayout → `layout::SplitPane` +- [x] Dock / DockPanel +- [x] DockZone / DropIndicator +- [x] FloatingPanel +- [x] Pane / PaneGroup +- [x] Workspace — versioned layout snapshot +- [x] Viewport ## T04 Window & Shell — `src/shell`