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

+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)