T03 layout: stacks, grids, surfaces, scroll, splits, sheets, dock, panes, workspace, viewport
This commit is contained in:
37 files changed
+5283
-50
No files matched your search
@@ -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)),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
mod assets;
|
||||
|
||||
pub mod buttons;
|
||||
pub mod layout;
|
||||
pub mod motion;
|
||||
pub mod primitives;
|
||||
pub mod theme;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user