From cf71315f798e84a32debf5418c8aeca774f3396f Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Sat, 26 Sep 2026 16:02:21 -0400 Subject: [PATCH] shikra: Add layers and anchoring --- crates/shikra/README.md | 1 + crates/shikra/TODO.md | 18 +- crates/shikra/examples/overlays.rs | 315 ++++++++++++++++ crates/shikra/src/anchored.rs | 414 ++++++++++++++++++++++ crates/shikra/src/deferred.rs | 44 +++ crates/shikra/src/div.rs | 29 +- crates/shikra/src/layout.rs | 11 +- crates/shikra/src/lib.rs | 4 + crates/shikra/src/window.rs | 23 +- crates/shikra/src/window/accessibility.rs | 47 ++- crates/shikra/src/window/dispatch.rs | 45 +++ crates/shikra/src/window/layers.rs | 237 +++++++++++++ 12 files changed, 1174 insertions(+), 14 deletions(-) create mode 100644 crates/shikra/examples/overlays.rs create mode 100644 crates/shikra/src/anchored.rs create mode 100644 crates/shikra/src/deferred.rs create mode 100644 crates/shikra/src/window/layers.rs diff --git a/crates/shikra/README.md b/crates/shikra/README.md index 6b28205..a01c562 100644 --- a/crates/shikra/README.md +++ b/crates/shikra/README.md @@ -12,6 +12,7 @@ cargo run -p shikra --example layout cargo run -p shikra --example clock cargo run -p shikra --example text cargo run -p shikra --example input +cargo run -p shikra --example overlays ``` On NixOS, winit and wgpu load X11/Wayland and Vulkan at runtime, so they need to be on the library path. The dev shell in the repo's flake sets that up: diff --git a/crates/shikra/TODO.md b/crates/shikra/TODO.md index 6767633..19aa91c 100644 --- a/crates/shikra/TODO.md +++ b/crates/shikra/TODO.md @@ -24,7 +24,7 @@ end. - No `overflow`, clipping, or scroll containers. - No grid layout. -- No z-index or stacking beyond paint order (see layers, below). +- No z-index. Elements stack in paint order, and deferred elements go on top. - Any `notify` redraws the whole window. There's no view-level caching. ## Text @@ -45,7 +45,6 @@ end. - No focus or blur events, focus trapping, or focus scopes. - Enter and Space don't click the focused element. Each element handles them itself for now. -- No click-outside (`on_mouse_down_out`) listeners. - No `group-hover` or other group states. - Hit testing ignores rounded corners and clipping. - Only `div` takes part in hit testing, dispatch, and focus. Custom elements @@ -55,6 +54,20 @@ end. - The multi-click interval (500 ms) and distance (4 px) are hardcoded rather than read from the system. +## Layers and anchored positioning + +- Deferred elements come after everything else in tab order, not after the + element that rendered them. +- Hovering a deferred element also hovers the elements it was rendered in, + since it keeps its place in the tree. +- Anchored content doesn't report which side it ended up on, so there's no + way to point an arrow at the anchor or animate from the right direction. +- Anchored content is limited to the window's size but isn't sized to the + space available on its side, so a tall menu can cover its anchor. +- When anchored content slides to stay in the window, it can slide past the + end of its anchor (floating-ui's `limitShift`). +- An `AnchorHandle` keeps its last bounds if its element stops being painted. + ## Accessibility - Elements don't get roles automatically. A clickable div with no role is @@ -75,5 +88,4 @@ end. ## Roadmap -- Step 6: layers and anchored positioning. - Step 7: headless primitives. diff --git a/crates/shikra/examples/overlays.rs b/crates/shikra/examples/overlays.rs new file mode 100644 index 0000000..7c9da8b --- /dev/null +++ b/crates/shikra/examples/overlays.rs @@ -0,0 +1,315 @@ +//! Popovers, menus, and tooltips, drawn on top of the rest of the window and +//! positioned next to what opened them. +//! +//! Open the menus in the toolbar, hover over the tooltip target, and +//! right-click in the box. Menus close when you click outside them or press +//! Escape. The menu at the bottom flips above its button when there isn't room +//! below it, and the one at the right edge slides to stay in the window. + +use shikra::{ + Align, AnchorHandle, Application, ClickEvent, Context, Deferred, Div, IntoElement, Key, + KeyDownEvent, MouseButton, MouseDownEvent, ParentElement, Point, Render, Rgba, Role, + SharedString, Side, Window, WindowOptions, anchored, deferred, div, rgb, size, +}; + +#[derive(Clone, Copy, PartialEq)] +enum Popup { + File, + Account, + Bottom, + Context(Point), +} + +struct Overlays { + open: Option, + file: AnchorHandle, + account: AnchorHandle, + bottom: AnchorHandle, + tooltip: AnchorHandle, + hovering_tooltip_target: bool, + status: SharedString, +} + +impl Overlays { + fn new(_: &mut Context) -> Self { + Self { + open: None, + file: AnchorHandle::new(), + account: AnchorHandle::new(), + bottom: AnchorHandle::new(), + tooltip: AnchorHandle::new(), + hovering_tooltip_target: false, + status: "Open a menu".into(), + } + } + + /// A button that opens `popup` when pressed, or closes it if it's open. + fn trigger( + &self, + label: &'static str, + popup: Popup, + anchor: &AnchorHandle, + cx: &mut Context, + ) -> Div { + let open = self.open == Some(popup); + div() + .style(|s| { + s.px_3() + .py_1p5() + .rounded_md() + .text_sm() + .font_medium() + .cursor_pointer() + }) + .style(|s| if open { s.bg(rgb(0xe2e8f0)) } else { s }) + .hover(|s| s.bg(rgb(0xe2e8f0))) + .role(Role::Button) + .accessibility(|node| node.set_expanded(open)) + .anchor(anchor) + // Like native menus, open on press rather than click. + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _, cx| { + this.open = if this.open == Some(popup) { + None + } else { + Some(popup) + }; + cx.notify(); + }), + ) + .child(label) + } + + /// The menu for `popup` next to `anchor`, if it's open. + fn popup( + &self, + popup: Popup, + anchor: &AnchorHandle, + align: Align, + items: &[&'static str], + cx: &mut Context, + ) -> Option { + (self.open == Some(popup)).then(|| { + deferred( + anchored() + .to(anchor) + .align(align) + .side_offset(4.) + .collision_padding(8.) + .child(self.menu(popup, items, cx)), + ) + }) + } + + /// A menu of `items` for `popup`, which closes when the mouse is pressed + /// outside it. + fn menu(&self, popup: Popup, items: &[&'static str], cx: &mut Context) -> Div { + div() + .style(|s| { + s.flex() + .flex_col() + .min_w_48() + .p_1() + .rounded_lg() + .border_1() + .border_color(rgb(0xcbd5e1)) + .bg(Rgba::WHITE) + .text_sm() + }) + .role(Role::Menu) + .on_mouse_down_out(cx.listener(move |this, _: &MouseDownEvent, _, cx| { + // Unless the press opened another popup. + if this.open == Some(popup) { + this.open = None; + cx.notify(); + } + })) + .children(items.iter().map(|&item| { + div() + .style(|s| s.px_3().py_1p5().rounded_md().cursor_pointer()) + .hover(|s| s.bg(rgb(0x6366f1)).text_color(Rgba::WHITE)) + .role(Role::MenuItem) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.status = format!("Chose \"{item}\"").into(); + this.open = None; + cx.notify(); + })) + .child(item) + })) + } +} + +impl Render for Overlays { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let file_menu = self.popup( + Popup::File, + &self.file, + Align::Start, + &["New", "Open…", "Save", "Save As…", "Close Window"], + cx, + ); + // Aligned to the button's start, this would stick out of the window, + // so it slides left. + let account_menu = self.popup( + Popup::Account, + &self.account, + Align::Start, + &["Profile", "Settings", "Keyboard Shortcuts", "Sign Out"], + cx, + ); + // There's no room below, so this flips above its button. + let bottom_menu = self.popup( + Popup::Bottom, + &self.bottom, + Align::Center, + &["Copy", "Cut", "Paste", "Delete"], + cx, + ); + let context_menu = match self.open { + Some(Popup::Context(position)) => Some(deferred( + anchored() + .at(position) + .collision_padding(8.) + .child(self.menu( + Popup::Context(position), + &["Back", "Forward", "Reload", "Inspect"], + cx, + )), + )), + _ => None, + }; + let tooltip = self.hovering_tooltip_target.then(|| { + deferred( + anchored() + .to(&self.tooltip) + .side(Side::Top) + .align(Align::Center) + .side_offset(6.) + .child( + div() + .style(|s| { + s.px_2() + .py_1() + .rounded_md() + .bg(rgb(0x0f172a)) + .text_color(Rgba::WHITE) + .text_xs() + }) + .role(Role::Tooltip) + .child("Tooltips are drawn on top, too"), + ), + ) + // Above the menus. + .priority(1) + }); + + div() + .style(|s| { + s.size_full() + .flex() + .flex_col() + .bg(rgb(0xf8fafc)) + .text_color(rgb(0x0f172a)) + }) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { + if event.keystroke.key == Key::Escape && this.open.is_some() { + this.open = None; + cx.notify(); + } + })) + .child( + div() + .style(|s| { + s.flex() + .items_center() + .justify_between() + .px_2() + .h_12() + .border_b() + .border_color(rgb(0xe2e8f0)) + .bg(Rgba::WHITE) + }) + .child(self.trigger("File", Popup::File, &self.file, cx)) + .child(self.trigger("Account", Popup::Account, &self.account, cx)), + ) + .children(file_menu) + .children(account_menu) + .child( + div() + .style(|s| s.flex_grow().flex().flex_col().gap_6().p_8()) + .child(div().style(|s| s.text_color(rgb(0x475569))).child( + "Menus and tooltips are deferred: they're painted after \ + everything else, so they cover this text and take the mouse \ + before anything under them. They're anchored to the button \ + that opened them, and flip or slide to stay in the window.", + )) + .child( + div() + .style(|s| { + s.px_3() + .py_2() + .rounded_md() + .border_1() + .border_color(rgb(0xcbd5e1)) + .bg(Rgba::WHITE) + .text_sm() + .cursor_help() + }) + .anchor(&self.tooltip) + .on_hover(cx.listener(|this, hovering: &bool, _, cx| { + this.hovering_tooltip_target = *hovering; + cx.notify(); + })) + .child("Hover for a tooltip"), + ) + .children(tooltip) + .child( + div() + .style(|s| { + s.flex_grow() + .flex() + .items_center() + .justify_center() + .rounded_xl() + .border_2() + .border_color(rgb(0xcbd5e1)) + .text_color(rgb(0x64748b)) + }) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, _, cx| { + this.open = Some(Popup::Context(event.position)); + cx.notify(); + }), + ) + .child("Right-click anywhere in this box"), + ) + .children(context_menu) + .child( + div() + .style(|s| s.flex().items_center().justify_between()) + .child(self.trigger("Near the bottom", Popup::Bottom, &self.bottom, cx)) + .children(bottom_menu) + .child( + div() + .style(|s| s.text_sm().text_color(rgb(0x64748b))) + .role(Role::Status) + .child(self.status.clone()), + ), + ), + ) + } +} + +fn main() { + Application::new().run(|cx| { + cx.open_window( + WindowOptions { + title: "Overlays".into(), + size: size(720., 560.), + }, + |_window, cx| cx.new(Overlays::new), + ); + }); +} diff --git a/crates/shikra/src/anchored.rs b/crates/shikra/src/anchored.rs new file mode 100644 index 0000000..6a74e10 --- /dev/null +++ b/crates/shikra/src/anchored.rs @@ -0,0 +1,414 @@ +use std::cell::Cell; +use std::rc::Rc; + +use crate::{ + AnyElement, App, Bounds, Element, LayoutId, ParentElement, Point, Size, Style, Window, point, + px, size, +}; + +/// Positions its children next to an anchor, like a popover next to the +/// button that opened it, keeping them inside the window. +/// +/// The anchor is the element that contains this one, unless you give it +/// another with [`to`](Anchored::to) or [`at`](Anchored::at). The children are +/// laid out on their own, sized to fit their content, and don't take up space +/// in their parent. Wrap this in [`deferred`](crate::deferred) to draw it on top +/// of the rest of the window. +/// +/// ```ignore +/// deferred( +/// anchored() +/// .to(&self.trigger) +/// .side(Side::Bottom) +/// .side_offset(4.) +/// .child(menu), +/// ) +/// ``` +pub fn anchored() -> Anchored { + Anchored { + anchor: Anchor::Parent, + side: Side::Bottom, + align: Align::Start, + side_offset: 0., + align_offset: 0., + avoid_collisions: true, + collision_padding: 0., + children: Vec::new(), + } +} + +pub struct Anchored { + anchor: Anchor, + side: Side, + align: Align, + side_offset: f32, + align_offset: f32, + avoid_collisions: bool, + collision_padding: f32, + children: Vec, +} + +enum Anchor { + Parent, + Handle(AnchorHandle), + Point(Point), +} + +/// Which side of the anchor to put the content on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + Top, + Right, + Bottom, + Left, +} + +impl Side { + fn opposite(self) -> Self { + match self { + Side::Top => Side::Bottom, + Side::Right => Side::Left, + Side::Bottom => Side::Top, + Side::Left => Side::Right, + } + } +} + +/// How to align the content with the anchor, along the side it's on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + Start, + Center, + End, +} + +impl Anchored { + /// Anchors the content to the element tracking `handle`. + pub fn to(mut self, handle: &AnchorHandle) -> Self { + self.anchor = Anchor::Handle(handle.clone()); + self + } + + /// Anchors the content to a point in the window, like the mouse position + /// for a context menu. + pub fn at(mut self, position: Point) -> Self { + self.anchor = Anchor::Point(position); + self + } + + /// Which side of the anchor to put the content on. Defaults to + /// [`Side::Bottom`]. + pub fn side(mut self, side: Side) -> Self { + self.side = side; + self + } + + /// How to align the content with the anchor. Defaults to + /// [`Align::Start`]. + pub fn align(mut self, align: Align) -> Self { + self.align = align; + self + } + + /// The gap between the anchor and the content, in logical pixels. + pub fn side_offset(mut self, offset: f32) -> Self { + self.side_offset = offset; + self + } + + /// How far to move the content from its alignment, towards the anchor's + /// center when aligned to its start or end. + pub fn align_offset(mut self, offset: f32) -> Self { + self.align_offset = offset; + self + } + + /// Whether to flip the content to the opposite side when it doesn't fit, + /// and slide it along the side to stay in the window. Defaults to `true`. + pub fn avoid_collisions(mut self, avoid_collisions: bool) -> Self { + self.avoid_collisions = avoid_collisions; + self + } + + /// How close to the window's edges the content can be, in logical pixels, + /// when avoiding collisions. + pub fn collision_padding(mut self, padding: f32) -> Self { + self.collision_padding = padding; + self + } +} + +impl ParentElement for Anchored { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Element for Anchored { + /// The node the children are laid out in. + type LayoutState = LayoutId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, LayoutId) { + let children = self + .children + .iter_mut() + .map(|child| child.request_layout(window, cx)) + .collect::>(); + let viewport = window.viewport_size(); + let padding = self.collision_padding * 2.; + let content = window.request_detached_layout( + &Style::new() + .max_w(px((viewport.width - padding).max(0.))) + .max_h(px((viewport.height - padding).max(0.))), + &children, + ); + // Take up no space where the element is. + let placeholder = window.request_layout(&Style::new().absolute(), &[]); + (placeholder, content) + } + + fn paint(&mut self, bounds: Bounds, content: &mut LayoutId, window: &mut Window, cx: &mut App) { + let anchor = match &self.anchor { + Anchor::Parent => window.parent_element_bounds().unwrap_or(bounds), + Anchor::Handle(handle) => { + // The anchor hasn't been painted. + let Some(bounds) = handle.bounds() else { + return; + }; + bounds + } + Anchor::Point(position) => Bounds::new(*position, size(0., 0.)), + }; + let content_bounds = window.layout_bounds(*content); + let padding = self.collision_padding; + let viewport = window.viewport_size(); + let viewport = Bounds::new( + point(padding, padding), + size( + viewport.width - padding * 2., + viewport.height - padding * 2., + ), + ); + let placement = place( + anchor, + content_bounds.size, + viewport, + &Placement { + side: self.side, + align: self.align, + side_offset: self.side_offset, + align_offset: self.align_offset, + avoid_collisions: self.avoid_collisions, + }, + ); + + let offset = point( + placement.x - content_bounds.origin.x, + placement.y - content_bounds.origin.y, + ); + window.with_element_offset(offset, |window| { + for child in &mut self.children { + child.paint(window, cx); + } + }); + } +} + +/// Records the bounds of the element tracking it each time it's painted, so +/// [`anchored`] content can be positioned next to it. +/// +/// Attach it to an element with [`Div::anchor`](crate::Div::anchor). +#[derive(Debug, Clone, Default)] +pub struct AnchorHandle(Rc>>); + +impl AnchorHandle { + pub fn new() -> Self { + Self::default() + } + + /// Where the element tracking this was last painted, if it has been. + pub fn bounds(&self) -> Option { + self.0.get() + } + + pub(crate) fn set_bounds(&self, bounds: Bounds) { + self.0.set(Some(bounds)); + } +} + +struct Placement { + side: Side, + align: Align, + side_offset: f32, + align_offset: f32, + avoid_collisions: bool, +} + +/// Where to put content of `size` next to `anchor`, keeping it inside +/// `viewport` if avoiding collisions. +fn place(anchor: Bounds, size: Size, viewport: Bounds, placement: &Placement) -> Point { + let (width, height) = (size.width, size.height); + // The position along the axis away from the anchor, and how far past the + // viewport it would be. + let main = |side: Side| { + let offset = placement.side_offset; + match side { + Side::Top => { + let y = anchor.origin.y - offset - height; + (y, viewport.origin.y - y) + } + Side::Bottom => { + let y = anchor.bottom() + offset; + (y, y + height - viewport.bottom()) + } + Side::Left => { + let x = anchor.origin.x - offset - width; + (x, viewport.origin.x - x) + } + Side::Right => { + let x = anchor.right() + offset; + (x, x + width - viewport.right()) + } + } + }; + + let mut side = placement.side; + let (mut main_position, overflow) = main(side); + if placement.avoid_collisions && overflow > 0. { + let (flipped_position, flipped_overflow) = main(side.opposite()); + if flipped_overflow < overflow { + side = side.opposite(); + main_position = flipped_position; + } + } + + // The position along the side, and the range the viewport allows. + let vertical = matches!(side, Side::Top | Side::Bottom); + let (start, length, extent, min, max) = if vertical { + ( + anchor.origin.x, + anchor.size.width, + width, + viewport.origin.x, + viewport.right(), + ) + } else { + ( + anchor.origin.y, + anchor.size.height, + height, + viewport.origin.y, + viewport.bottom(), + ) + }; + let mut cross_position = match placement.align { + Align::Start => start + placement.align_offset, + Align::Center => start + (length - extent) / 2. + placement.align_offset, + Align::End => start + length - extent - placement.align_offset, + }; + if placement.avoid_collisions { + // Prefer the start edge when the content is bigger than the viewport. + cross_position = cross_position.min(max - extent).max(min); + } + + if vertical { + point(cross_position, main_position) + } else { + point(main_position, cross_position) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds { + Bounds::new(point(x, y), size(width, height)) + } + + fn placement(side: Side, align: Align) -> Placement { + Placement { + side, + align, + side_offset: 4., + align_offset: 0., + avoid_collisions: true, + } + } + + const VIEWPORT: Bounds = Bounds { + origin: Point { x: 0., y: 0. }, + size: Size { + width: 800., + height: 600., + }, + }; + + #[test] + fn places_content_on_each_side() { + let anchor = bounds(300., 200., 100., 40.); + let content = size(200., 100.); + let place = |side, align| place(anchor, content, VIEWPORT, &placement(side, align)); + + assert_eq!(place(Side::Bottom, Align::Start), point(300., 244.)); + assert_eq!(place(Side::Bottom, Align::Center), point(250., 244.)); + assert_eq!(place(Side::Bottom, Align::End), point(200., 244.)); + assert_eq!(place(Side::Top, Align::Start), point(300., 96.)); + assert_eq!(place(Side::Right, Align::Start), point(404., 200.)); + assert_eq!(place(Side::Left, Align::End), point(96., 140.)); + } + + #[test] + fn flips_when_the_content_doesnt_fit() { + let content = size(200., 100.); + // Near the bottom, so it goes above. + let anchor = bounds(300., 520., 100., 40.); + let position = place( + anchor, + content, + VIEWPORT, + &placement(Side::Bottom, Align::Start), + ); + assert_eq!(position, point(300., 416.)); + + // Doesn't fit either way, so it goes on the side it overflows less. + let anchor = bounds(300., 250., 100., 40.); + let tall = size(200., 400.); + let position = place(anchor, tall, VIEWPORT, &placement(Side::Top, Align::Start)); + assert_eq!(position, point(300., 294.)); + } + + #[test] + fn shifts_to_stay_inside_the_viewport() { + let content = size(200., 100.); + let anchor = bounds(700., 200., 80., 40.); + let position = place( + anchor, + content, + VIEWPORT, + &placement(Side::Bottom, Align::Start), + ); + assert_eq!(position, point(600., 244.)); + + let anchor = bounds(-50., 200., 80., 40.); + let position = place( + anchor, + content, + VIEWPORT, + &placement(Side::Bottom, Align::End), + ); + assert_eq!(position, point(0., 244.)); + + // Unless collisions are allowed. + let position = place( + anchor, + content, + VIEWPORT, + &Placement { + avoid_collisions: false, + ..placement(Side::Bottom, Align::End) + }, + ); + assert_eq!(position, point(-170., 244.)); + } +} diff --git a/crates/shikra/src/deferred.rs b/crates/shikra/src/deferred.rs new file mode 100644 index 0000000..3094f46 --- /dev/null +++ b/crates/shikra/src/deferred.rs @@ -0,0 +1,44 @@ +use crate::{AnyElement, App, Bounds, Element, IntoElement, LayoutId, Window}; + +/// Paints `child` after the rest of the window, so it's drawn on top and gets +/// the mouse first. For popovers, menus, tooltips, and dialogs. +/// +/// The child keeps its place in the element tree otherwise: it's laid out +/// where it is, it inherits its ancestors' text style, and events bubble from +/// it to its ancestors. Combine it with [`anchored`](crate::anchored) to +/// position it outside its parent. +pub fn deferred(child: impl IntoElement) -> Deferred { + Deferred { + child: Some(child.into_any_element()), + priority: 0, + } +} + +pub struct Deferred { + child: Option, + priority: i32, +} + +impl Deferred { + /// Deferred elements with a higher priority are painted later, so they're + /// on top of those with a lower one. Defaults to 0. + pub fn priority(mut self, priority: i32) -> Self { + self.priority = priority; + self + } +} + +impl Element for Deferred { + type LayoutState = (); + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, ()) { + let child = self.child.as_mut().expect("deferred element has no child"); + (child.request_layout(window, cx), ()) + } + + fn paint(&mut self, _: Bounds, _: &mut (), window: &mut Window, _: &mut App) { + if let Some(child) = self.child.take() { + window.defer_draw(child, self.priority); + } + } +} diff --git a/crates/shikra/src/div.rs b/crates/shikra/src/div.rs index d47aed9..9f44071 100644 --- a/crates/shikra/src/div.rs +++ b/crates/shikra/src/div.rs @@ -4,9 +4,9 @@ use std::rc::Rc; use crate::element::GlobalElementId; use crate::window::{Listeners, Node}; use crate::{ - AnyElement, App, Bounds, ClickEvent, Element, ElementId, FocusHandle, KeyDownEvent, KeyUpEvent, - LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, - Rgba, Role, ScrollWheelEvent, SharedString, Style, Window, px, + AnchorHandle, AnyElement, App, Bounds, ClickEvent, Element, ElementId, FocusHandle, + KeyDownEvent, KeyUpEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + PaintQuad, ParentElement, Rgba, Role, ScrollWheelEvent, SharedString, Style, Window, px, }; /// A box with a style and children. The basic building block of a UI. @@ -21,6 +21,7 @@ pub fn div() -> Div { focus_handle: None, tab_stop: true, listeners: Listeners::default(), + anchor: None, accessibility: None, children: Vec::new(), } @@ -36,6 +37,7 @@ pub struct Div { focus_handle: Option, tab_stop: bool, listeners: Listeners, + anchor: Option, accessibility: Option>, children: Vec, } @@ -103,6 +105,13 @@ impl Div { self } + /// Records where the element is painted in `handle`, so [`anchored`](crate::anchored) + /// content can be positioned next to it. + pub fn anchor(mut self, handle: &AnchorHandle) -> Self { + self.anchor = Some(handle.clone()); + self + } + /// What the element is to assistive technologies, like screen readers. /// Like HTML's `role` attribute. /// @@ -164,6 +173,16 @@ impl Div { self } + /// Listens for any mouse button being pressed outside the element, like + /// to close a popover. + pub fn on_mouse_down_out( + mut self, + listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listeners.mouse_down_out.push(Rc::new(listener)); + self + } + pub fn on_mouse_up( mut self, button: MouseButton, @@ -314,6 +333,10 @@ impl Element for Div { }); } + if let Some(anchor) = &self.anchor { + anchor.set_bounds(bounds); + } + let mut node = Node::new(*id, bounds); node.cursor = self.style.cursor; node.focus = self.focus_handle.as_ref().map(FocusHandle::downgrade); diff --git a/crates/shikra/src/layout.rs b/crates/shikra/src/layout.rs index 53fa983..b431063 100644 --- a/crates/shikra/src/layout.rs +++ b/crates/shikra/src/layout.rs @@ -76,13 +76,18 @@ impl LayoutEngine { LayoutId(node.expect("failed to create layout node")) } - pub(crate) fn compute_layout(&mut self, root: LayoutId, available_space: Size) { + pub(crate) fn compute_layout(&mut self, root: LayoutId, available_space: Size) { + let convert = |space| match space { + AvailableSpace::Definite(value) => taffy::AvailableSpace::Definite(value), + AvailableSpace::MinContent => taffy::AvailableSpace::MinContent, + AvailableSpace::MaxContent => taffy::AvailableSpace::MaxContent, + }; self.taffy .compute_layout_with_measure( root.0, taffy::Size { - width: taffy::AvailableSpace::Definite(available_space.width), - height: taffy::AvailableSpace::Definite(available_space.height), + width: convert(available_space.width), + height: convert(available_space.height), }, |inputs, _, measure, style| { taffy::compute_leaf_layout( diff --git a/crates/shikra/src/lib.rs b/crates/shikra/src/lib.rs index 7e7e503..b21ca63 100644 --- a/crates/shikra/src/lib.rs +++ b/crates/shikra/src/lib.rs @@ -1,7 +1,9 @@ +mod anchored; mod app; mod atlas; mod color; mod context; +mod deferred; mod div; mod element; mod entity; @@ -22,9 +24,11 @@ mod view; mod window; pub use accesskit::{self, Role}; +pub use anchored::*; pub use app::*; pub use color::*; pub use context::*; +pub use deferred::*; pub use div::*; pub use element::*; pub use entity::*; diff --git a/crates/shikra/src/window.rs b/crates/shikra/src/window.rs index 3d18fb7..c067b47 100644 --- a/crates/shikra/src/window.rs +++ b/crates/shikra/src/window.rs @@ -1,5 +1,6 @@ mod accessibility; mod dispatch; +mod layers; use std::cell::RefCell; use std::collections::HashSet; @@ -19,6 +20,7 @@ use crate::{ }; pub(crate) use accessibility::AccessibilityState; pub(crate) use dispatch::{InputState, Listeners, Node}; +use layers::LayerState; /// Identifies a window within an [`App`](crate::App). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -107,6 +109,7 @@ pub struct Window { pub(crate) scene: Scene, pub(crate) input: InputState, pub(crate) accessibility: AccessibilityState, + layers: LayerState, } impl Window { @@ -126,6 +129,7 @@ impl Window { scene: Scene::default(), input: InputState::default(), accessibility: AccessibilityState::default(), + layers: LayerState::default(), } } @@ -233,9 +237,13 @@ impl Window { }); } - /// Returns the computed bounds of a layout node, relative to the window. + /// Returns the computed bounds of a layout node, relative to the window, + /// moved by the current [element offset](Self::element_offset). pub fn layout_bounds(&mut self, id: LayoutId) -> Bounds { - self.layout.layout_bounds(id) + let mut bounds = self.layout.layout_bounds(id); + bounds.origin.x += self.layers.offset.x; + bounds.origin.y += self.layers.offset.y; + bounds } /// Paints a quad, snapping its edges to device pixels. @@ -313,9 +321,16 @@ impl Window { .h(px(viewport_size.height)), &[root_layout_id], ); - self.layout - .compute_layout(viewport_layout_id, viewport_size); + self.layout.compute_layout( + viewport_layout_id, + Size { + width: AvailableSpace::Definite(viewport_size.width), + height: AvailableSpace::Definite(viewport_size.height), + }, + ); + self.compute_detached_layouts(); root.paint(self, cx); + self.paint_deferred(cx); self.accessed_entities = cx.stop_tracking_accesses(); self.finish_input_frame(cx); diff --git a/crates/shikra/src/window/accessibility.rs b/crates/shikra/src/window/accessibility.rs index 92d77f7..2d53e7b 100644 --- a/crates/shikra/src/window/accessibility.rs +++ b/crates/shikra/src/window/accessibility.rs @@ -45,6 +45,9 @@ struct OpenNode { node: Node, /// The text of the node's descendants, for naming it from its contents. text: String, + /// Whether the node was closed and reopened to add deferred children, so + /// it's already been named and added to its parent. + reopened: bool, } impl AccessibilityState { @@ -104,13 +107,23 @@ impl AccessibilityState { id, node, text: String::new(), + reopened: false, }); id } /// Closes the innermost open node. fn pop(&mut self) { - let OpenNode { id, mut node, text } = self.stack.pop().expect("no open node"); + let OpenNode { + id, + mut node, + text, + reopened, + } = self.stack.pop().expect("no open node"); + if reopened { + self.next_nodes.insert(id, node); + return; + } // Like the web, name controls like buttons after their text. if node.label().is_none() && !text.is_empty() && is_named_from_contents(node.role()) { @@ -191,6 +204,37 @@ impl Window { result } + /// The node whose children are being painted. + pub(super) fn accessibility_parent(&self) -> Option { + self.accessibility.stack.last().map(|open| open.id) + } + + /// Runs `f` to paint elements whose nodes are children of `parent`, which + /// may have been closed already. + pub(super) fn with_accessibility_parent( + &mut self, + parent: Option, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let state = &mut self.accessibility; + let is_open = state.stack.last().map(|open| open.id) == parent; + let Some(node) = parent + .filter(|_| !is_open) + .and_then(|parent| state.next_nodes.remove(&parent)) + else { + return f(self); + }; + state.stack.push(OpenNode { + id: parent.unwrap(), + node, + text: String::new(), + reopened: true, + }); + let result = f(self); + self.accessibility.pop(); + result + } + pub(super) fn start_accessibility_frame(&mut self) { let state = &mut self.accessibility; state.next_nodes.clear(); @@ -202,6 +246,7 @@ impl Window { id: ROOT_ID, node: Node::new(Role::Window), text: String::new(), + reopened: false, }); } } diff --git a/crates/shikra/src/window/dispatch.rs b/crates/shikra/src/window/dispatch.rs index 7cbc181..ad40c1f 100644 --- a/crates/shikra/src/window/dispatch.rs +++ b/crates/shikra/src/window/dispatch.rs @@ -34,6 +34,7 @@ pub(crate) type Listener = Rc; #[derive(Default)] pub(crate) struct Listeners { pub(crate) mouse_down: Vec>, + pub(crate) mouse_down_out: Vec>, pub(crate) mouse_up: Vec>, pub(crate) mouse_move: Vec>, pub(crate) scroll_wheel: Vec>, @@ -340,6 +341,33 @@ impl Window { }) } + /// The node whose children are being painted. + pub(super) fn dispatch_parent(&self) -> Option { + self.input.next_frame.stack.last().copied() + } + + /// Runs `f` to paint elements as children of `parent`, rather than of the + /// node whose children are being painted. + pub(super) fn with_dispatch_parent( + &mut self, + parent: Option, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + let stack = mem::replace( + &mut self.input.next_frame.stack, + parent.into_iter().collect(), + ); + let result = f(self); + self.input.next_frame.stack = stack; + result + } + + /// The bounds of the element whose children are being painted. + pub(crate) fn parent_element_bounds(&self) -> Option { + let frame = &self.input.next_frame; + frame.stack.last().map(|&ix| frame.nodes[ix].bounds) + } + fn push_node(&mut self, mut node: Node, f: impl FnOnce(&mut Self) -> R) -> R { let frame = &mut self.input.next_frame; node.parent = frame.stack.last().copied(); @@ -506,6 +534,23 @@ impl Window { self.input.default_prevented = false; self.dispatch_on_path(&path, &event, |l| &l.mouse_down, cx); + // Then tell the elements the mouse was pressed outside of. + let frame = &self.input.rendered_frame; + let outside: Vec = (0..frame.nodes.len()) + .filter(|ix| { + !path.contains(ix) && !frame.nodes[*ix].listeners.mouse_down_out.is_empty() + }) + .collect(); + for ix in outside { + for listener in self.input.rendered_frame.nodes[ix] + .listeners + .mouse_down_out + .clone() + { + listener(&event, self, cx); + } + } + if button == MouseButton::Left { let pressed = self.input.rendered_frame.ids(&path); self.set_pressed(pressed); diff --git a/crates/shikra/src/window/layers.rs b/crates/shikra/src/window/layers.rs new file mode 100644 index 0000000..28c58ea --- /dev/null +++ b/crates/shikra/src/window/layers.rs @@ -0,0 +1,237 @@ +//! Painting elements somewhere other than where the element tree puts them. +//! +//! Deferred elements are painted after the rest of the window, so they're +//! drawn on top of it and get the mouse first. They keep their place in the +//! element tree otherwise: they inherit the text style of their ancestors, +//! events bubble from them to their ancestors, and their accessibility nodes +//! are their ancestors' children. Like a portal on the web. +//! +//! Detached layouts are laid out on their own, rather than as part of their +//! parent, and element offsets move elements from where layout put them. +//! Together, these let an element be positioned anywhere in the window. + +use std::mem; + +use accesskit::NodeId; + +use crate::{ + AnyElement, App, AvailableSpace, LayoutId, Point, Size, Style, TextStyleRefinement, Window, + point, +}; + +#[derive(Default)] +pub(super) struct LayerState { + /// How far elements being painted are moved from where layout put them. + pub(super) offset: Point, + /// Elements to paint once the rest of the window has been painted. + deferred: Vec, + /// Layout nodes to lay out on their own, in the space of the window. + detached_roots: Vec, +} + +/// A deferred element, and what it inherits from where it would have been +/// painted. +struct DeferredDraw { + element: AnyElement, + priority: i32, + offset: Point, + text_style_stack: Vec, + dispatch_parent: Option, + accessibility_parent: Option, +} + +impl Window { + /// How far elements being painted are moved from where layout put them. + pub fn element_offset(&self) -> Point { + self.layers.offset + } + + /// Runs `f` with the elements it paints moved by `offset`, on top of any + /// offset already applied. + pub fn with_element_offset(&mut self, offset: Point, f: impl FnOnce(&mut Self) -> R) -> R { + let previous = self.layers.offset; + self.layers.offset = point(previous.x + offset.x, previous.y + offset.y); + let result = f(self); + self.layers.offset = previous; + result + } + + /// Adds a node to this frame's layout tree that's laid out on its own + /// rather than as part of a parent, in the space of the window, at the + /// top-left corner. Its size fits its content, up to the window's size. + pub fn request_detached_layout(&mut self, style: &Style, children: &[LayoutId]) -> LayoutId { + let id = self.request_layout(style, children); + self.layers.detached_roots.push(id); + id + } + + pub(super) fn compute_detached_layouts(&mut self) { + let available_space = Size { + width: AvailableSpace::MaxContent, + height: AvailableSpace::MaxContent, + }; + for root in mem::take(&mut self.layers.detached_roots) { + self.layout.compute_layout(root, available_space); + } + } + + /// Paints `element`, which has requested its layout, once the rest of the + /// window has been painted. Elements with a higher `priority` are painted + /// later, so they're on top. + pub(crate) fn defer_draw(&mut self, element: AnyElement, priority: i32) { + let draw = DeferredDraw { + element, + priority, + offset: self.layers.offset, + text_style_stack: self.text_style_stack.clone(), + dispatch_parent: self.dispatch_parent(), + accessibility_parent: self.accessibility_parent(), + }; + self.layers.deferred.push(draw); + } + + pub(super) fn paint_deferred(&mut self, cx: &mut App) { + // Deferred elements can defer elements of their own, which are painted + // after them. + loop { + let mut draws = mem::take(&mut self.layers.deferred); + if draws.is_empty() { + break; + } + draws.sort_by_key(|draw| draw.priority); + for mut draw in draws { + let text_style_stack = + mem::replace(&mut self.text_style_stack, draw.text_style_stack); + let offset = mem::replace(&mut self.layers.offset, draw.offset); + self.with_dispatch_parent(draw.dispatch_parent, |window| { + window.with_accessibility_parent(draw.accessibility_parent, |window| { + draw.element.paint(window, cx); + }); + }); + self.layers.offset = offset; + self.text_style_stack = text_style_stack; + } + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use accesskit::Role; + + use crate::input::PlatformInput; + use crate::test::test_app; + use crate::{ + AnchorHandle, App, Context, IntoElement, MouseButton, ParentElement, Render, Window, + WindowId, WindowOptions, anchored, deferred, div, point, px, size, + }; + + type Log = Rc>>; + + /// A button with a menu below it, which overlaps the square next to it. + struct Menu { + log: Log, + trigger: AnchorHandle, + menu: AnchorHandle, + } + + impl Render for Menu { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let log = |name| logger(&self.log, name); + let log_mouse_down = |name| logger(&self.log, name); + div() + .style(|s| s.size_full().flex().items_start()) + .on_click(log("root")) + .child( + div() + .style(|s| s.w(px(100.)).h(px(40.))) + .anchor(&self.trigger), + ) + .child( + div() + .style(|s| s.w(px(200.)).h(px(200.))) + .on_click(log("square")), + ) + .child(deferred( + anchored().to(&self.trigger).child( + div() + .style(|s| s.w(px(150.)).h(px(50.))) + .role(Role::Menu) + .anchor(&self.menu) + .on_click(log("menu")) + .on_mouse_down_out(log_mouse_down("outside menu")), + ), + )) + } + } + + fn logger(log: &Log, name: &'static str) -> impl Fn(&E, &mut Window, &mut App) + use { + let log = log.clone(); + move |_, _, _| log.borrow_mut().push(name) + } + + fn click(cx: &mut App, window: WindowId, x: f32, y: f32) { + for input in [ + PlatformInput::MouseMove(point(x, y)), + PlatformInput::MouseDown(MouseButton::Left), + PlatformInput::MouseUp(MouseButton::Left), + ] { + cx.update_window(window, |window, cx| window.dispatch_input(input, cx)); + } + } + + #[test] + fn deferred_elements_are_on_top_but_keep_their_place_in_the_tree() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let log = Log::default(); + let menu = AnchorHandle::new(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Menu { + log: log.clone(), + trigger: AnchorHandle::new(), + menu: menu.clone(), + }) + }) + .id(); + cx.update_window(window, |window, cx| { + window.accessibility.activate(); + window.draw(cx); + }); + + // Below the button, over the square, without moving the square. + let bounds = menu.bounds().unwrap(); + assert_eq!( + (bounds.origin, bounds.size), + (point(0., 40.), size(150., 50.)) + ); + + // Clicks go to the menu rather than the square under it, and bubble + // to the element the menu was rendered in. + click(cx, window, 120., 60.); + assert_eq!(log.take(), ["menu", "root"]); + + click(cx, window, 180., 60.); + assert_eq!(log.take(), ["outside menu", "square", "root"]); + + // Its accessibility node is a child of the element's. + let update = cx + .update_window(window, |window, _| window.accessibility.tree_update()) + .unwrap(); + let (menu_id, _) = update + .nodes + .iter() + .find(|(_, node)| node.role() == Role::Menu) + .unwrap(); + let (_, parent) = update + .nodes + .iter() + .find(|(_, node)| node.children().contains(menu_id)) + .unwrap(); + assert!(parent.supports_action(accesskit::Action::Click)); + } +} -- knotmirror 0.0.1