T03 layout: stacks, grids, surfaces, scroll, splits, sheets, dock, panes, workspace, viewport

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 02:17:37 -04:00
1 parent b35530c80d
commit 2bdf902bf4
37 files changed
+5283 -50

No files matched your search

+2 -1
View File
@@ -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 <slug>` starts on a page.
- `cargo run --example gallery -- --capture <dir>` writes PNGs of every page, light and dark, top to bottom. macOS only.
- `cargo run --example gallery -- --capture <dir>` 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 <lucide-name>...` 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.
Generated
+2
View File
@@ -1530,6 +1530,8 @@ dependencies = [
"log",
"regex",
"rust-embed",
"serde",
"serde_json",
"smallvec",
"unicode-segmentation",
]
+2
View File
@@ -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"
+4 -1
View File
@@ -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 |
+256
View File
@@ -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<SharedString>, 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")),
),
)
}
+128
View File
@@ -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()
}
+252
View File
@@ -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.")),
)
}
+164
View File
@@ -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<Item = gpui::Div> + '_ {
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)),
))
}
+233
View File
@@ -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>, bool) {
let state = window.use_keyed_state(key, cx, |_, _| false);
let value = *state.read(cx);
(state, value)
}
fn flip(state: &Entity<bool>, 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),
)),
)
}
+271
View File
@@ -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>) -> 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<Dock>, Entity<PaneGroup>) {
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::<String>);
let report = window.use_keyed_state("workspace-report", cx, |_, _| SharedString::from(""));
let preview: Option<String> = 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::<Vec<_>>().join("\n");
block.child(code(excerpt, cx))
})
}
fn blob(
at: gpui::Point<gpui::Pixels>,
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)),
)
}
+2
View File
@@ -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,
];
+33
View File
@@ -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 {
+2 -1
View File
@@ -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
+256
View File
@@ -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<ElementId>, open: bool) -> Self {
Self {
id: id.into(),
open,
body: SmallVec::new(),
}
}
}
impl ParentElement for Collapsible {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
fn measured(state: Entity<Fold>) -> 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<SharedString>) -> Self {
Self {
title: title.into(),
body: SmallVec::new(),
}
}
}
impl ParentElement for AccordionItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.body.extend(elements);
}
}
#[derive(Default)]
struct Folds {
open: Vec<bool>,
toggles: Vec<u64>,
}
/// Stacked items that fold; one open at a time unless `multiple`.
#[derive(IntoElement)]
pub struct Accordion {
id: ElementId,
items: Vec<AccordionItem>,
multiple: bool,
first_open: bool,
}
impl Accordion {
pub fn new(id: impl Into<ElementId>) -> 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)),
)
}))
}
}
+214
View File
@@ -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<String>; 4],
pub sizes: [f32; 4],
}
type Center = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
type Content = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> AnyElement>;
/// Side docks around a center; panels move between docks and float.
pub struct Dock {
panels: Vec<DockPanel>,
spots: Vec<Spot>,
active: [Option<usize>; 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<usize> {
self.panels.iter().position(|panel| panel.id.as_ref() == id)
}
fn on_side(&self, side: DockSide) -> Vec<usize> {
(0..self.panels.len())
.filter(|ix| self.spots[*ix] == Spot::Docked(side))
.collect()
}
pub fn activate(&mut self, id: &str, cx: &mut Context<Self>) {
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<Self>) {
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<usize>| 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<Self>) -> 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(())
}
}
+347
View File
@@ -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<Self>) -> 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<Self>) -> 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<Self>,
) -> Option<AnyElement> {
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<Self>) -> 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<DockSide>, cx: &mut Context<Self>| {
div()
.id(id)
.absolute()
.rounded(radius)
.border_2()
.border_dashed()
.border_color(idle)
.drag_over::<DraggedPanel>(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<Self>) -> 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<AnyElement> = (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<DockResize>, _, 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<DraggedPanel>, _, 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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Pixels>, _, _| {
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()
}
}
+168
View File
@@ -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<Pixels>,
anchor: Rc<Cell<Option<Point<Pixels>>>>,
}
/// Keeps `origin` so that at least `keep` of the panel stays in the window.
fn clamp_to_window(
origin: Point<Pixels>,
keep: Pixels,
window: gpui::Size<Pixels>,
) -> Point<Pixels> {
point(
origin.x.clamp(keep - window.width, window.width - keep),
origin.y.clamp(Pixels::ZERO, window.height - keep),
)
}
type OnMove = Rc<dyn Fn(&Point<Pixels>, &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<Pixels>,
base: gpui::Div,
on_move: Option<OnMove>,
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<ElementId>,
title: impl Into<SharedString>,
start: Point<Pixels>,
) -> 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<Pixels>, &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<Item = AnyElement>) {
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<Carry>, 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)));
}
}
+189
View File
@@ -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<Pixels>) -> 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<ElementId>, 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<Item = AnyElement>) {
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<AnyElement>,
}
impl Masonry {
pub fn new(id: impl Into<ElementId>, 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<Item = AnyElement>) {
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::<Pixels>::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<Vec<AnyElement>> = (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);
}
}
+32
View File
@@ -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};
+198
View File
@@ -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<SharedString>,
actions: SmallVec<[AnyElement; 2]>,
body: SmallVec<[AnyElement; 4]>,
}
impl Page {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
subtitle: None,
actions: SmallVec::new(),
body: SmallVec::new(),
}
}
pub fn subtitle(mut self, text: impl Into<SharedString>) -> 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<Item = AnyElement>) {
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<AnyElement>,
sidebar: Option<AnyElement>,
status_bar: Option<AnyElement>,
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<Item = AnyElement>) {
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<ElementId>,
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)
}
}
+425
View File
@@ -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<f32>,
children: Vec<PaneLayout>,
},
}
impl PaneLayout {
/// Checks ids and splits; returns the next free pane id.
pub fn validate(&self) -> anyhow::Result<PaneId> {
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<PaneId>) {
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<dyn Fn(PaneId, &mut Window, &mut App) -> 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<PaneId> {
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<Self>) -> 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<Self>) {
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>) {
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<Self>) -> 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<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<u64> {
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<f32>| 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<f32>| 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));
}
}
+251
View File
@@ -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<Pixels>,
active_at: Option<Instant>,
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<Cell<Option<Pixels>>>,
}
/// 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<ElementId>, 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<ThumbDrag>, 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<ElementId>) -> 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<Item = AnyElement>) {
self.body.extend(elements);
}
}
fn track_activity(state: &Entity<Activity>, 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))
}
}
+253
View File
@@ -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<ElementId>, 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<dyn Fn(&mut Window, &mut App) -> 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<AnyElement>,
}
impl StickyHeader {
pub fn new(
id: impl Into<ElementId>,
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<Item = AnyElement>) {
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));
}
}
+26
View File
@@ -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<T> {
seed: T,
pub value: T,
}
pub(crate) fn use_seeded<T: Clone + PartialEq + 'static>(
id: impl Into<ElementId>,
seed: T,
window: &mut Window,
cx: &mut App,
) -> Entity<Seeded<T>> {
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
}
+302
View File
@@ -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<dyn Fn(&mut Window, &mut App)>;
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<ElementId>,
side: Place,
title: impl Into<SharedString>,
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<Item = AnyElement>) {
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<Cell<Option<Pixels>>>,
}
/// 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<ElementId>,
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<Item = AnyElement>) {
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<DrawerPull>, _, 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)
}
}
+95
View File
@@ -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<ElementId>, collapsed: bool) -> Self {
Self {
id: id.into(),
collapsed,
body: SmallVec::new(),
}
}
}
impl ParentElement for Sidebar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
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()
}
}
+232
View File
@@ -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<ElementId>, axis: Axis, cx: &App) -> Stateful<gpui::Div> {
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<f32>,
anchor: Rc<Cell<Option<Pixels>>>,
}
/// 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<f32> {
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<f32> {
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<dyn Fn(&[f32], &mut Window, &mut App)>;
/// Panes side by side or stacked, split by draggable handles.
#[derive(IntoElement)]
pub struct SplitPane {
id: ElementId,
axis: Axis,
panes: Vec<AnyElement>,
sizes: Option<Vec<f32>>,
min: Pixels,
on_resize: Option<OnResize>,
}
impl SplitPane {
/// `min` is the smallest a pane may shrink to.
pub fn new(id: impl Into<ElementId>, 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<f32> = 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<DividerDrag>, 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);
}
}
+135
View File
@@ -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<Item = AnyElement>) {
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<Item = AnyElement>) {
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<Item = AnyElement>) {
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()
}
}
+395
View File
@@ -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<Item = AnyElement>) {
self.body.extend(elements);
}
}
};
}
fn heading(
title: SharedString,
description: Option<SharedString>,
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<SharedString>,
action: Option<AnyElement>,
}
impl CardHeader {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
description: None,
action: None,
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> 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<CardHeader>,
footer: Option<AnyElement>,
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<SharedString>) -> 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<SharedString>,
action: Option<AnyElement>,
body: SmallVec<[AnyElement; 2]>,
}
impl Section {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
base: div(),
title: title.into(),
description: None,
action: None,
body: SmallVec::new(),
}
}
pub fn description(mut self, text: impl Into<SharedString>) -> 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<SharedString>) -> 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)
}
}
+189
View File
@@ -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<Pixels>,
pub scale: f32,
}
impl Transform {
pub fn apply(&self, content: Point<Pixels>) -> Point<Pixels> {
point(
content.x * self.scale + self.offset.x,
content.y * self.scale + self.offset.y,
)
}
pub fn invert(&self, local: Point<Pixels>) -> Point<Pixels> {
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<Pixels>, 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<Pixels>,
grab: Option<(Point<Pixels>, Point<Pixels>)>,
}
type Content = Rc<dyn Fn(Transform, &mut Window, &mut App) -> 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<ElementId>,
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);
}
}
+105
View File
@@ -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<Self> {
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<Dock>,
panes: &Entity<PaneGroup>,
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());
}
}
+1
View File
@@ -1,6 +1,7 @@
mod assets;
pub mod buttons;
pub mod layout;
pub mod motion;
pub mod primitives;
pub mod theme;
+27 -3
View File
@@ -11,10 +11,22 @@ use crate::{motion, theme::ActiveTheme};
type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
/// 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<Dismiss>,
children: SmallVec<[AnyElement; 1]>,
}
@@ -23,11 +35,18 @@ impl Backdrop {
pub fn new(id: impl Into<ElementId>) -> 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
+1 -1
View File
@@ -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};
+1 -1
View File
@@ -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);
+49 -1
View File
@@ -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<Rems> {
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)
+41 -41
View File
@@ -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`