Files
Ely-GPUI-Components/src/layout/sheet.rs
T
ZacharyZhang-NYandClaude Opus 5.5 d7272e88bd Port · Parity: every page matches gpui 0.2.2's; the rules re-tested
Every page and scripted state was shot with gpui 0.2.2 and with the
port, light and dark, full width and at 280px, judged pair by pair by
agents with skeptics, and every page's log diffed. What differed is
fixed, each with a test that fails without it where the test platform
can show it:

- The gallery's harness takes posted mouse moves (gpui_macos listens
  to a tracking area), posts each drag move twice (it no longer
  replays a held drag), and waits three frames before each shot.
- Rolling digits: a block column, so taffy 0.13 lends the cell no
  baseline, and a line of whole device pixels, since gpui snaps each
  length on its own. DescriptionList's row aligns ends.
- Margins that taffy 0.13 collapses through a plain block: the
  drawer's pull and snap, the stagger and transition rise, the dialog
  and palette rises, the fullscreen dialog's slide.
- An Input keeps room to type in a squeezed row; the field rows drop
  w_full, whose reason was taffy 0.9's.

AGENTS.md's rules that named gpui 0.2.2 or taffy 0.9 were re-tested
against both sources and rewritten, and new ones say what the port
changed. TASKS.md's tags follow: what the new gpui makes possible is
prove. Captions, doc comments and README say what holds now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:39:55 -04:00

304 lines
9.7 KiB
Rust

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")
.debug_selector(|| "drawer-panel".into())
.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))
.top(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.top(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)
}
}