From 341420d1e42a78b4f472956921facb570ea34536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 01:51:51 -0400 Subject: [PATCH] =?UTF-8?q?Primitives=20=C2=B7=20raise:=20what=20opens=20i?= =?UTF-8?q?nside=20a=20dialog=20lies=20over=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpui 0.2.2 asserts on a deferred draw inside another, and every dialog, sheet and popover draws inside one, so opening a date field in the snooze or schedule dialog panicked, as would any list, menu or picker opened in an overlay. primitives::raise replaces each deferred draw: at the top it defers as before; inside something raised it leaves a placeholder in the tree, and its layer lays it out on its own, then draws it last. check.sh refuses gpui's deferred anywhere else. The time dialog's rule is now times::ahead, tested for a moment behind, now itself, a moment ahead and another zone; a gpui test picks yesterday in the snooze dialog, sees that it has passed and that nothing is sent, then picks two days ahead and hears that moment. --- AGENTS.md | 1 + TASKS.md | 1 + examples/gallery/pages/mail/mod.rs | 5 + scripts/check.sh | 2 + src/feedback/toast.rs | 8 +- src/forms/options.rs | 8 +- src/layout/floating.rs | 10 +- src/mail/tests/sort.rs | 36 ++++- src/mail/times.rs | 62 +++++++-- src/menus/menu.rs | 9 +- src/menus/pie.rs | 6 +- src/overlays/dialog.rs | 6 +- src/overlays/floats.rs | 8 +- src/overlays/lightbox.rs | 6 +- src/overlays/spotlight.rs | 6 +- src/primitives/backdrop.rs | 6 +- src/primitives/layer.rs | 211 +++++++++++++++++++++++++++++ src/primitives/mod.rs | 5 + src/primitives/tests.rs | 53 ++++++++ src/primitives/tooltip.rs | 5 +- 20 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 src/primitives/layer.rs create mode 100644 src/primitives/tests.rs diff --git a/AGENTS.md b/AGENTS.md index ca94289..f1ae2bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue. - `hover()` needs an element id. - gpui applies hover and group-hover styles once an element has a hitbox, in prepaint and paint, but text takes its color at layout, so a hover cannot recolor text. What shows on hover rests `invisible()` and turns `visible()` through `group_hover` (`chat::ConversationItem`, `messaging::ChatMessage`). +- gpui 0.2.2 panics on a deferred draw inside another. Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. `scripts/check.sh` refuses gpui's `deferred` anywhere else. - `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires. - A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it. - gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. diff --git a/TASKS.md b/TASKS.md index 279598a..7c4bcb2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -348,3 +348,4 @@ Per-component lines live in `tasks/`. Tags there: | T29b | 2 | PASS | | | T29c | 1 | FAIL | a long chip ran past its tag field at 280px | | T29c | 2 | PASS | | +| T29d | 1 | FAIL | the time dialog's rule for a time ahead of now had no test; its test found that a picker opened in a dialog panicked on a deferred draw inside another | diff --git a/examples/gallery/pages/mail/mod.rs b/examples/gallery/pages/mail/mod.rs index 53d0bb2..3d2d5ae 100644 --- a/examples/gallery/pages/mail/mod.rs +++ b/examples/gallery/pages/mail/mod.rs @@ -70,6 +70,11 @@ const SCRIPT: &[Step] = &[ Step::Key("enter"), Step::Wait(300), Step::Shot("snooze-dialog"), + Step::Key("tab"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("snooze-when"), + Step::Key("escape"), Step::Key("escape"), ]; diff --git a/scripts/check.sh b/scripts/check.sh index 2e01514..c394688 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -15,4 +15,6 @@ done components=$(find src -name '*.rs' ! -path 'src/theme/*' ! -path 'src/motion/*' ! -path '*/tests/*' ! -name 'tests.rs') raw=$(awk '/#\[cfg\(test\)\]/{nextfile} /(^|[^.[:alnum:]_])px\(/{print FILENAME":"FNR": "$0}' $components) [ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; } +deferred=$(grep -rn 'deferred(' src --include='*.rs' | grep -v '^src/primitives/layer.rs' || true) +[ -z "$deferred" ] || { echo "gpui deferred outside primitives::raise:"; echo "$deferred"; fail=1; } exit $fail diff --git a/src/feedback/toast.rs b/src/feedback/toast.rs index 4f57b65..30d7abd 100644 --- a/src/feedback/toast.rs +++ b/src/feedback/toast.rs @@ -6,15 +6,15 @@ use std::{ use gpui::{ Animation, AnimationExt, AnyElement, App, ClickEvent, Context, Corner, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Task, Window, anchored, canvas, deferred, div, point, - prelude::*, relative, + StatefulInteractiveElement, Styled, Task, Window, anchored, canvas, div, point, prelude::*, + relative, }; use crate::{ buttons::{Button, ButtonVariant, IconButton}, forms::Run, motion, - primitives::{Icon, IconName, Severity}, + primitives::{Icon, IconName, Severity, raise}, theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, }; @@ -234,7 +234,7 @@ impl RenderOnce for ToastViewport { hold.update(cx, |toaster, cx| toaster.hold(*hovered, cx)) }) .children(cards); - deferred( + raise( anchored() .position(point(viewport.width - margin, viewport.height - margin)) .anchor(Corner::BottomRight) diff --git a/src/forms/options.rs b/src/forms/options.rs index 066447d..2182f93 100644 --- a/src/forms/options.rs +++ b/src/forms/options.rs @@ -3,13 +3,13 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, Bounds, Corner, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, - SharedString, Stateful, StatefulInteractiveElement, Styled, Window, anchored, canvas, deferred, - div, prelude::*, + SharedString, Stateful, StatefulInteractiveElement, Styled, Window, anchored, canvas, div, + prelude::*, }; use crate::{ motion, - primitives::{Icon, IconName}, + primitives::{Icon, IconName, raise}, theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, typography::Ellipsis, }; @@ -220,7 +220,7 @@ pub(crate) fn float_height( }), ) }; - deferred(placed.snap_to_window()) + raise(placed.snap_to_window()) .with_priority(1) .into_any_element() } diff --git a/src/layout/floating.rs b/src/layout/floating.rs index aa8043a..e2ac477 100644 --- a/src/layout/floating.rs +++ b/src/layout/floating.rs @@ -3,13 +3,15 @@ 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::*, + StatefulInteractiveElement, StyleRefinement, Styled, Window, anchored, div, point, prelude::*, }; use smallvec::SmallVec; use super::seeded::use_seeded; -use crate::theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}; +use crate::{ + primitives::raise, + theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}, +}; struct Carry { owner: EntityId, @@ -147,7 +149,7 @@ impl RenderOnce for FloatingPanel { .child(self.title), ) .child(div().p_3().children(self.body)); - deferred(anchored().position(at).child(panel)) + raise(anchored().position(at).child(panel)) } } diff --git a/src/mail/tests/sort.rs b/src/mail/tests/sort.rs index 86ab33d..4bccac4 100644 --- a/src/mail/tests/sort.rs +++ b/src/mail/tests/sort.rs @@ -2,12 +2,13 @@ use gpui::{ Context, Entity, IntoElement, Modifiers, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, px, }; -use jiff::{Timestamp, tz::TimeZone}; +use jiff::{Timestamp, ToSpan, tz::TimeZone}; use super::{Mailing, heard, mailing, note, press, settle, setup, tab_to}; use crate::{ forms, - mail::{Label, LabelPicker, SnoozePicker, snooze::snoozes}, + mail::{Label, LabelPicker, SnoozePicker, snooze::snoozes, times::at_hour}, + typography::format::system_zone, }; /// Labels the host keeps, the keys on, and what it heard. @@ -166,3 +167,34 @@ fn the_last_row_asks_for_a_time_of_ones_own(cx: &mut TestAppContext) { assert!(cx.debug_bounds("time-dialog").is_some(), "the dialog shows"); assert!(heard(&host, cx).is_empty(), "no time yet"); } + +#[gpui::test] +fn the_dialog_takes_only_a_time_ahead_of_now(cx: &mut TestAppContext) { + let (host, cx) = mailing(snoozing, cx); + tab_to(1, cx); + for key in [ + "enter", "up", "enter", "tab", "enter", "left", "enter", "escape", + ] { + press(key, cx); + } + assert!( + cx.debug_bounds("time-late").is_some(), + "yesterday has passed" + ); + for key in ["tab", "tab", "enter"] { + press(key, cx); + } + assert!(heard(&host, cx).is_empty(), "Snooze waits for a time ahead"); + for key in [ + "right", "right", "right", "enter", "escape", "tab", "tab", "enter", + ] { + press(key, cx); + } + let today = Timestamp::now().to_zoned(system_zone("a test")).date(); + let ahead = at_hour( + today.checked_add(2.days()).expect("a day"), + 0, + &TimeZone::UTC, + ); + assert_eq!(heard(&host, cx), [format!("until {ahead}")]); +} diff --git a/src/mail/times.rs b/src/mail/times.rs index 2d9787c..42e79e9 100644 --- a/src/mail/times.rs +++ b/src/mail/times.rs @@ -40,6 +40,15 @@ pub(super) fn named(name: &str, at: Timestamp, zone: &TimeZone) -> (String, Time (format!("{name} ยท {hour}"), at) } +/// The moment `chosen` names in `zone`, once it lies after `now`. +pub(super) fn ahead(chosen: DateTime, zone: &TimeZone, now: Timestamp) -> Option { + let at = chosen + .to_zoned(zone.clone()) + .expect("the zone holds the time") + .timestamp(); + (at > now).then_some(at) +} + /// `times` less each moment an earlier one names, as tomorrow names next Monday on a Sunday. pub(super) fn distinct( times: impl IntoIterator, @@ -99,16 +108,16 @@ pub(super) fn time_dialog( return None; } let chosen = now.chosen; - let at = chosen.map(|chosen| { - chosen - .to_zoned(zone.clone()) - .expect("the zone holds the time") - .timestamp() - }); - let ahead = at.filter(|at| *at > Timestamp::now()); + let ahead = chosen.and_then(|chosen| ahead(chosen, zone, Timestamp::now())); let (closing, choosing, id) = (picking.clone(), picking.clone(), id.clone()); - let late = (at.is_some() && ahead.is_none()) - .then(|| FormError::new((id.clone(), "late"), "That time has passed.")); + let late = (chosen.is_some() && ahead.is_none()).then(|| { + div() + .debug_selector(|| "time-late".into()) + .child(FormError::new( + (id.clone(), "late"), + "That time has passed.", + )) + }); let dialog = Dialog::new((id.clone(), "dialog"), ask.title, move |_, cx| { closing.update(cx, |picking, cx| { *picking = Picking::default(); @@ -155,3 +164,38 @@ pub(super) fn time_dialog( }); Some(dialog) } + +#[cfg(test)] +mod tests { + use jiff::{ + SignedDuration, + civil::date, + tz::{TimeZone, offset}, + }; + + use super::{ahead, at_hour}; + + #[test] + fn a_picked_time_counts_once_it_lies_ahead_of_now() { + let day = date(2026, 9, 27); + let now = at_hour(day, 9, &TimeZone::UTC); + let utc = &TimeZone::UTC; + assert_eq!( + ahead(day.at(8, 59, 0, 0), utc, now), + None, + "a minute behind" + ); + assert_eq!(ahead(day.at(9, 0, 0, 0), utc, now), None, "now itself"); + assert_eq!( + ahead(day.at(9, 1, 0, 0), utc, now), + Some(now + SignedDuration::from_mins(1)), + "a minute ahead" + ); + let tokyo = TimeZone::fixed(offset(9)); + assert_eq!( + ahead(day.at(10, 0, 0, 0), &tokyo, now), + None, + "10:00 in Tokyo is 01:00 here" + ); + } +} diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 2cc220c..cf47410 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -2,8 +2,7 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, - IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, deferred, div, - prelude::*, + IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, div, prelude::*, }; use super::{ @@ -13,7 +12,7 @@ use super::{ use crate::{ forms::{Down, Enter, Run, TextInput, Up, float}, motion, - primitives::{Icon, IconName, Takeover, give_back, take_focus}, + primitives::{Icon, IconName, Takeover, give_back, raise, take_focus}, theme::{ActiveTheme, IconSize}, }; @@ -384,7 +383,7 @@ pub(crate) fn hang( }); let placed = match spot { Spot::Under => float(state.read(cx).anchor, menu.entries.len(), root, window, cx), - Spot::At(point) => deferred(anchored().position(point).snap_to_window().child( + Spot::At(point) => raise(anchored().position(point).snap_to_window().child( root.with_animation((id.clone(), "in"), enter.clone(), |root, t| root.opacity(t)), )) .with_priority(1) @@ -393,7 +392,7 @@ pub(crate) fn hang( let subs = (1..depth).filter_map(|level| { let row = state.read(cx).levels[level - 1].row; (row != Bounds::default()).then(|| { - deferred( + raise( anchored().position(row.top_right()).snap_to_window().child( draw::panel(id, menu, state, level, &close, None, cx) .mt_neg_1() diff --git a/src/menus/pie.rs b/src/menus/pie.rs index fe30544..2be488b 100644 --- a/src/menus/pie.rs +++ b/src/menus/pie.rs @@ -3,14 +3,14 @@ use std::{f32::consts::TAU, rc::Rc}; use gpui::{ Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, - SharedString, Styled, Window, anchored, canvas, deferred, div, point, + SharedString, Styled, Window, anchored, canvas, div, point, }; use smallvec::SmallVec; use crate::{ forms::{Pick, Run}, motion, - primitives::{Icon, IconName, give_back, take_focus}, + primitives::{Icon, IconName, give_back, raise, take_focus}, theme::{ActiveTheme, Elevation, IconSize, TextSize}, }; @@ -302,7 +302,7 @@ impl RenderOnce for PieMenu { } }); region.child( - deferred( + raise( anchored() .position(point(Pixels::ZERO, Pixels::ZERO)) .child( diff --git a/src/overlays/dialog.rs b/src/overlays/dialog.rs index 0dd378a..60d08e4 100644 --- a/src/overlays/dialog.rs +++ b/src/overlays/dialog.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, Styled, - Window, anchored, deferred, div, point, prelude::*, + Window, anchored, div, point, prelude::*, }; use smallvec::SmallVec; @@ -11,7 +11,7 @@ use crate::{ buttons::{ButtonVariant, IconButton}, forms::{Enter, Run}, motion, - primitives::{Backdrop, FocusScope, IconName, give_back, take_focus}, + primitives::{Backdrop, FocusScope, IconName, give_back, raise, take_focus}, theme::{ActiveTheme, Elevation, Radius, TextSize}, }; @@ -270,7 +270,7 @@ impl RenderOnce for Dialog { if fullscreen { let viewport = window.viewport_size(); let top = cx.theme().titlebar_height().to_pixels(window.rem_size()); - return deferred( + return raise( anchored().position(point(Pixels::ZERO, top)).child( div() .w(viewport.width) diff --git a/src/overlays/floats.rs b/src/overlays/floats.rs index daea893..4e6a02b 100644 --- a/src/overlays/floats.rs +++ b/src/overlays/floats.rs @@ -2,8 +2,8 @@ use std::rc::Rc; use gpui::{ AnimationExt, AnyElement, App, Corner, ElementId, Entity, FontWeight, InteractiveElement, - IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, anchored, canvas, - deferred, div, point, prelude::*, + IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, anchored, canvas, div, + point, prelude::*, }; use smallvec::SmallVec; @@ -12,7 +12,7 @@ use crate::{ forms::{Run, TextInput, surface}, layout::Collapsible, motion, - primitives::IconName, + primitives::{IconName, raise}, theme::{ActiveTheme, ControlSize, TextSize}, }; @@ -68,7 +68,7 @@ impl RenderOnce for FloatingToolbar { .when_some(anchor, |host, anchor| { let lift = cx.theme().float_gap().to_pixels(window.rem_size()); host.child( - deferred( + raise( anchored() .position(anchor.origin - point(gpui::Pixels::ZERO, lift)) .anchor(Corner::BottomLeft) diff --git a/src/overlays/lightbox.rs b/src/overlays/lightbox.rs index ec56a10..32091c2 100644 --- a/src/overlays/lightbox.rs +++ b/src/overlays/lightbox.rs @@ -3,14 +3,14 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, Point, RenderOnce, SharedString, Size, Stateful, - StatefulInteractiveElement, Styled, Window, anchored, canvas, deferred, div, prelude::*, + StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*, }; use crate::{ buttons::label_size, forms::Run, motion, - primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, take_focus}, + primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, raise, take_focus}, theme::{ActiveTheme, ControlSize, TextSize}, }; @@ -291,7 +291,7 @@ impl RenderOnce for Lightbox { ), ), ); - deferred(anchored().position(Point::default()).child(stage)) + raise(anchored().position(Point::default()).child(stage)) .with_priority(1) .into_any_element() } diff --git a/src/overlays/spotlight.rs b/src/overlays/spotlight.rs index ef33f1f..e45ffc6 100644 --- a/src/overlays/spotlight.rs +++ b/src/overlays/spotlight.rs @@ -3,14 +3,14 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ Animation, AnimationExt, AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Subscription, - Window, anchored, canvas, deferred, div, point, prelude::*, size, + Window, anchored, canvas, div, point, prelude::*, size, }; use crate::{ buttons::{Button, ButtonVariant}, forms::{Run, float_height, surface}, motion, - primitives::{FocusScope, give_back, take_focus}, + primitives::{FocusScope, give_back, raise, take_focus}, theme::{ActiveTheme, Radius, TextSize}, }; @@ -192,7 +192,7 @@ fn light( ); div() .child( - deferred( + raise( anchored() .position(Point::default()) .child(div().w(viewport.width).h(viewport.height).child(frame)), diff --git a/src/primitives/backdrop.rs b/src/primitives/backdrop.rs index 54a048d..1cd59d6 100644 --- a/src/primitives/backdrop.rs +++ b/src/primitives/backdrop.rs @@ -2,11 +2,11 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, - MouseButton, ParentElement, Point, RenderOnce, Styled, Window, anchored, deferred, div, - prelude::*, + MouseButton, ParentElement, Point, RenderOnce, Styled, Window, anchored, div, prelude::*, }; use smallvec::SmallVec; +use super::raise; use crate::{motion, theme::ActiveTheme}; type Dismiss = Rc; @@ -81,7 +81,7 @@ impl RenderOnce for Backdrop { |scrim, t| scrim.opacity(t), ); - deferred( + raise( anchored().position(Point::default()).child( div() .relative() diff --git a/src/primitives/layer.rs b/src/primitives/layer.rs new file mode 100644 index 0000000..726bb13 --- /dev/null +++ b/src/primitives/layer.rs @@ -0,0 +1,211 @@ +use std::{cell::RefCell, panic::Location}; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, Pixels, Point, Position, Style, Window, size, +}; + +/// What was raised while a layer prepaints its content, each with where it sits and its priority. +type Queue = Vec<(AnyElement, Point, usize)>; + +thread_local! { + static LAYERS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn nested() -> bool { + LAYERS.with_borrow(|layers| !layers.is_empty()) +} + +/// Draws `child` over the page, as gpui's `deferred` does. gpui cannot defer a draw inside a deferred one, so what is raised inside something raised is laid out and drawn last within it: a list opened in a dialog lies over the dialog. +pub fn raise(child: impl IntoElement) -> Raised { + let layer = Layer { + child: child.into_any_element(), + }; + Raised { + child: Some(layer.into_any_element()), + priority: 0, + nested: false, + } +} + +/// An element drawn over the page; see [`raise`]. +pub struct Raised { + child: Option, + priority: usize, + nested: bool, +} + +impl Raised { + /// A higher priority draws later, over the lower. + pub fn with_priority(mut self, priority: usize) -> Self { + self.priority = priority; + self + } +} + +impl IntoElement for Raised { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Raised { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + self.nested = nested(); + if self.nested { + let style = Style { + position: Position::Absolute, + ..Style::default() + }; + return (window.request_layout(style, [], cx), ()); + } + let child = self.child.as_mut().expect("a raised element lays out once"); + (child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + _: &mut App, + ) { + let child = self.child.take().expect("a raised element prepaints once"); + if !self.nested { + window.defer_draw(child, window.element_offset(), self.priority); + return; + } + LAYERS.with_borrow_mut(|layers| { + let queue = layers + .last_mut() + .expect("a nested element prepaints in its layer"); + queue.push((child, bounds.origin, self.priority)); + }); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + _: &mut (), + _: &mut Window, + _: &mut App, + ) { + } +} + +/// What a raised element draws: its content, then what was raised inside it, each laid out on its own and the higher priority last. +struct Layer { + child: AnyElement, +} + +impl IntoElement for Layer { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Runs `f` with a fresh queue on the stack, and hands back what was raised meanwhile. +fn collect(f: impl FnOnce()) -> Queue { + LAYERS.with_borrow_mut(|layers| layers.push(Vec::new())); + f(); + LAYERS + .with_borrow_mut(|layers| layers.pop()) + .expect("a layer pops what it pushed") +} + +impl Element for Layer { + type RequestLayoutState = (); + type PrepaintState = Vec; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let mut layout = None; + collect(|| layout = Some(self.child.request_layout(window, cx))); + (layout.expect("the child laid out"), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) -> Vec { + let mut raised = collect(|| { + self.child.prepaint(window, cx); + }); + raised.sort_by_key(|(_, _, priority)| *priority); + let viewport = window.viewport_size(); + let space = size( + AvailableSpace::Definite(viewport.width), + AvailableSpace::Definite(viewport.height), + ); + raised + .into_iter() + .enumerate() + .map(|(ix, (mut element, at, _))| { + window.with_element_namespace(("raised", ix), |window| { + element.prepaint_as_root(at, space, window, cx) + }); + element + }) + .collect() + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + raised: &mut Vec, + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + for (ix, element) in raised.iter_mut().enumerate() { + window.with_element_namespace(("raised", ix), |window| element.paint(window, cx)); + } + } +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 8583838..b73505d 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -6,11 +6,15 @@ mod focus; mod ghost; mod icon; mod image; +mod layer; mod measure; mod pressable; mod severity; mod tooltip; +#[cfg(all(test, feature = "test-support"))] +mod tests; + pub use backdrop::{Backdrop, Place}; pub use disclosure::Disclosure; pub use divider::Divider; @@ -21,6 +25,7 @@ pub use ghost::DragGhost; pub use icon::{Icon, IconName}; pub use image::Image; pub(crate) use image::{checked_ratio, framed}; +pub use layer::{Raised, raise}; pub use measure::{IntersectionObserver, Measure}; pub use pressable::Pressable; pub use severity::Severity; diff --git a/src/primitives/tests.rs b/src/primitives/tests.rs new file mode 100644 index 0000000..83235fa --- /dev/null +++ b/src/primitives/tests.rs @@ -0,0 +1,53 @@ +use gpui::{ + Context, IntoElement, Modifiers, ParentElement, Render, Styled, TestAppContext, Window, + anchored, div, point, prelude::*, px, +}; + +use super::raise; +use crate::theme::Theme; + +/// A raised square holding a raised button, under a cover drawn after it in the same layer. +struct Stack { + heard: Vec<&'static str>, +} + +impl Render for Stack { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (inner, cover) = (cx.entity(), cx.entity()); + let button = div() + .id("inner") + .size(px(100.0)) + .occlude() + .on_click(move |_, _, cx| inner.update(cx, |stack, _| stack.heard.push("inner"))); + let sheet = div() + .id("cover") + .absolute() + .top_0() + .left_0() + .size(px(200.0)) + .on_click(move |_, _, cx| cover.update(cx, |stack, _| stack.heard.push("cover"))); + div().size_full().child(raise( + anchored().position(point(px(0.0), px(0.0))).child( + div() + .relative() + .size(px(200.0)) + .child(raise( + anchored().position(point(px(50.0), px(50.0))).child(button), + )) + .child(sheet), + ), + )) + } +} + +#[gpui::test] +fn what_is_raised_inside_a_raise_lies_over_it(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (view, cx) = cx.add_window_view(|_, _| Stack { heard: Vec::new() }); + cx.run_until_parked(); + cx.simulate_click(point(px(100.0), px(100.0)), Modifiers::none()); + assert_eq!( + view.read_with(cx, |stack, _| stack.heard.clone()), + ["inner"] + ); +} diff --git a/src/primitives/tooltip.rs b/src/primitives/tooltip.rs index d51c322..4c0396c 100644 --- a/src/primitives/tooltip.rs +++ b/src/primitives/tooltip.rs @@ -3,10 +3,11 @@ use std::{rc::Rc, time::Duration}; use gpui::{ Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Context, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Task, Window, anchored, deferred, div, point, + SharedString, StatefulInteractiveElement, Styled, Task, Window, anchored, div, point, prelude::*, }; +use super::raise; use crate::{ motion, theme::{ActiveTheme, Elevation, Radius, TextSize}, @@ -175,7 +176,7 @@ impl RenderOnce for TooltipTrigger { .when(visible, |trigger| { let body = content(window, cx); let below = cx.theme().cursor_offset().to_pixels(window.rem_size()); - trigger.child(deferred( + trigger.child(raise( anchored() .position(mouse + point(Pixels::ZERO, below)) .snap_to_window()