From 26cef16f76ded19782322af663c7d7b74e072ca7 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Sat, 26 Sep 2026 16:29:44 -0400 Subject: [PATCH] shikra_headless: Add initial set of primitives --- Cargo.lock | 7 + crates/shikra/Cargo.toml | 2 + crates/shikra/README.md | 4 + crates/shikra/TODO.md | 44 +- crates/shikra/examples/clock.rs | 2 +- crates/shikra/examples/hello.rs | 4 +- crates/shikra/examples/input.rs | 6 +- crates/shikra/examples/layout.rs | 2 +- crates/shikra/examples/overlays.rs | 7 +- crates/shikra/examples/text.rs | 4 +- crates/shikra/src/anchored.rs | 2 +- crates/shikra/src/app.rs | 2 +- crates/shikra/src/context.rs | 2 +- crates/shikra/src/div.rs | 357 ++++++++++----- crates/shikra/src/focus.rs | 6 +- crates/shikra/src/lib.rs | 4 +- crates/shikra/src/test.rs | 181 +++++++- crates/shikra/src/window.rs | 9 +- crates/shikra/src/window/accessibility.rs | 125 +++++- crates/shikra/src/window/dispatch.rs | 211 +++++++-- crates/shikra/src/window/layers.rs | 17 +- crates/shikra/src/window/state.rs | 220 ++++++++++ crates/shikra_headless/Cargo.toml | 10 + crates/shikra_headless/examples/primitives.rs | 402 +++++++++++++++++ crates/shikra_headless/src/button.rs | 60 +++ crates/shikra_headless/src/checkbox.rs | 187 ++++++++ crates/shikra_headless/src/dialog.rs | 408 ++++++++++++++++++ crates/shikra_headless/src/lib.rs | 50 +++ crates/shikra_headless/src/part.rs | 282 ++++++++++++ crates/shikra_headless/src/popover.rs | 371 ++++++++++++++++ crates/shikra_headless/src/toggle.rs | 106 +++++ crates/shikra_headless/tests/primitives.rs | 396 +++++++++++++++++ 32 files changed, 3293 insertions(+), 197 deletions(-) create mode 100644 crates/shikra/src/window/state.rs create mode 100644 crates/shikra_headless/Cargo.toml create mode 100644 crates/shikra_headless/examples/primitives.rs create mode 100644 crates/shikra_headless/src/button.rs create mode 100644 crates/shikra_headless/src/checkbox.rs create mode 100644 crates/shikra_headless/src/dialog.rs create mode 100644 crates/shikra_headless/src/lib.rs create mode 100644 crates/shikra_headless/src/part.rs create mode 100644 crates/shikra_headless/src/popover.rs create mode 100644 crates/shikra_headless/src/toggle.rs create mode 100644 crates/shikra_headless/tests/primitives.rs diff --git a/Cargo.lock b/Cargo.lock index 25a049f..f4bcf27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2646,6 +2646,13 @@ dependencies = [ "winit", ] +[[package]] +name = "shikra_headless" +version = "0.1.0" +dependencies = [ + "shikra", +] + [[package]] name = "shlex" version = "2.0.1" diff --git a/crates/shikra/Cargo.toml b/crates/shikra/Cargo.toml index 55c2bc7..b0366de 100644 --- a/crates/shikra/Cargo.toml +++ b/crates/shikra/Cargo.toml @@ -8,6 +8,8 @@ default = ["complex-scripts"] # Dictionary-based line breaking for scripts without spaces between words, like # Thai and Khmer, and better word boundaries for CJK. Adds about 4 MB. complex-scripts = ["parley/complex-scripts"] +# Running apps without a platform, for tests. See `shikra::test`. +test-support = [] [dependencies] accesskit = "0.25" diff --git a/crates/shikra/README.md b/crates/shikra/README.md index a01c562..d9e66e2 100644 --- a/crates/shikra/README.md +++ b/crates/shikra/README.md @@ -2,6 +2,9 @@ A native UI library for Rust, built on winit, wgpu, and tokio. +Headless components, with behavior and accessibility but no visuals, are in +[`shikra_headless`](../shikra_headless). + See [TODO.md](TODO.md) for what isn't implemented yet. ## Running the examples @@ -13,6 +16,7 @@ cargo run -p shikra --example clock cargo run -p shikra --example text cargo run -p shikra --example input cargo run -p shikra --example overlays +cargo run -p shikra_headless --example primitives ``` 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 19aa91c..8c2515d 100644 --- a/crates/shikra/TODO.md +++ b/crates/shikra/TODO.md @@ -42,14 +42,18 @@ end. - No pointer capture. Mouse moves go to whatever is under the pointer, not the element that was pressed, so drags and sliders can't be built yet. - No actions or keymaps. Keys go straight to `on_key_down` listeners. -- 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 focus or blur events. Focus can be trapped, but there are no other focus + scopes, like roving focus for arrow-key navigation. +- Enter and Space don't click a focused div by themselves. The headless + primitives make them click with `Window::click`; other elements have to + handle them. +- Clicking inside a focus trap on something that isn't focusable leaves focus + where it was, rather than moving it to the trap. - 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 - have no public API to add nodes, and there's no shared interactivity trait - for other element types. +- Only `div` takes part in hit testing, dispatch, and focus. Other elements can + wrap a div and implement `InteractiveElement`, like the headless parts do, + but there's no API for adding nodes directly. - No touch or pen input, and no gestures. - The multi-click interval (500 ms) and distance (4 px) are hardcoded rather than read from the system. @@ -72,14 +76,36 @@ end. - Elements don't get roles automatically. A clickable div with no role is ignored by screen readers. -- No methods for disabled or hidden elements. For now, use - `accessibility(|node| ...)`. +- No method for hiding elements from assistive technologies. For now, use + `accessibility(|node| node.set_hidden())`. +- AccessKit's AT-SPI adapter only follows the controls relation, so + `labelled_by` and `described_by` also set the node's label and description + from the referenced element's text. - Only the Click and Focus actions are handled. Scrolling and value-setting actions are ignored. - No support for text fields (selection, caret, character positions). - Only tested against AT-SPI queried by hand, not with Orca, VoiceOver, or another real screen reader. +## Headless primitives + +- Only button, toggle, checkbox, popover, and dialog so far. Menus, select, + radio group, tabs, tooltip, and slider need roving focus, typeahead, and + pointer capture first. +- Popovers don't close when focus moves out of them (there are no blur + events), and Tab from the end of one goes to the start of the window + rather than to the element after the trigger. +- Dialogs are always modal, and always close on Escape and on a click + outside. There's no way to prevent that, like Radix's `onEscapeKeyDown` and + `onPointerDownOutside`. +- No open and close animations, since there are no animations. +- Parts are always divs, with no equivalent of Radix's `asChild`. +- Roots are divs that take part in layout, where Radix's render nothing. + Taffy has no `display: contents`. +- No label part. A checkbox's label has to be inside it to toggle it. +- A popup keeps a strong handle to what it returns focus to while it's open, + which keeps that focus alive. + ## Known limitations - Closures passed to `cx.new` sometimes need a type annotation when they call @@ -88,4 +114,4 @@ end. ## Roadmap -- Step 7: headless primitives. +The planned steps are done. Next steps haven't been planned yet. diff --git a/crates/shikra/examples/clock.rs b/crates/shikra/examples/clock.rs index 6776944..07d0717 100644 --- a/crates/shikra/examples/clock.rs +++ b/crates/shikra/examples/clock.rs @@ -10,7 +10,7 @@ use std::time::Duration; use shikra::{ - Application, Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Rgba, + Application, Context, Entity, EventEmitter, IntoElement, ParentElement, Render, Rgba, Styled, Subscription, Task, Window, WindowOptions, div, relative, rgb, size, }; diff --git a/crates/shikra/examples/hello.rs b/crates/shikra/examples/hello.rs index e7f48fb..eb9729e 100644 --- a/crates/shikra/examples/hello.rs +++ b/crates/shikra/examples/hello.rs @@ -7,8 +7,8 @@ use std::time::Duration; use shikra::{ - Application, Context, IntoElement, ParentElement, Render, Rgba, Task, Window, WindowOptions, - div, rgb, + Application, Context, IntoElement, ParentElement, Render, Rgba, Styled, Task, Window, + WindowOptions, div, rgb, }; const COLORS: [u32; 4] = [0x1e1e2e, 0x89b4fa, 0xa6e3a1, 0xf38ba8]; diff --git a/crates/shikra/examples/input.rs b/crates/shikra/examples/input.rs index 77c9cf6..a99bc82 100644 --- a/crates/shikra/examples/input.rs +++ b/crates/shikra/examples/input.rs @@ -7,9 +7,9 @@ use shikra::accesskit::Live; use shikra::{ - Application, ClickEvent, Context, FocusHandle, IntoElement, Key, KeyDownEvent, Keystroke, - ParentElement, Render, Rgba, Role, ScrollDelta, ScrollWheelEvent, SharedString, Window, - WindowOptions, div, rgb, size, + Application, ClickEvent, Context, FocusHandle, InteractiveElement, IntoElement, Key, + KeyDownEvent, Keystroke, ParentElement, Render, Rgba, Role, ScrollDelta, ScrollWheelEvent, + SharedString, Styled, Window, WindowOptions, div, rgb, size, }; struct Counter { diff --git a/crates/shikra/examples/layout.rs b/crates/shikra/examples/layout.rs index bfe1c6e..5ba8769 100644 --- a/crates/shikra/examples/layout.rs +++ b/crates/shikra/examples/layout.rs @@ -4,7 +4,7 @@ //! There's no text yet, so gray bars stand in for it. use shikra::{ - AnyElement, Application, Context, IntoElement, ParentElement, Render, Style, Window, + AnyElement, Application, Context, IntoElement, ParentElement, Render, Style, Styled, Window, WindowOptions, div, rgb, size, }; diff --git a/crates/shikra/examples/overlays.rs b/crates/shikra/examples/overlays.rs index 7c9da8b..972bb77 100644 --- a/crates/shikra/examples/overlays.rs +++ b/crates/shikra/examples/overlays.rs @@ -7,9 +7,10 @@ //! 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, + Align, AnchorHandle, Application, ClickEvent, Context, Deferred, Div, InteractiveElement, + IntoElement, Key, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement, Point, Render, + Rgba, Role, SharedString, Side, Styled, Window, WindowOptions, anchored, deferred, div, rgb, + size, }; #[derive(Clone, Copy, PartialEq)] diff --git a/crates/shikra/examples/text.rs b/crates/shikra/examples/text.rs index ab3b66f..377e447 100644 --- a/crates/shikra/examples/text.rs +++ b/crates/shikra/examples/text.rs @@ -4,8 +4,8 @@ //! Resize the window to see the paragraphs rewrap. use shikra::{ - Application, Context, IntoElement, ParentElement, Render, Rgba, Window, WindowOptions, div, - rgb, size, + Application, Context, IntoElement, ParentElement, Render, Rgba, Styled, Window, WindowOptions, + div, rgb, size, }; const PARAGRAPH: &str = "Shikra lays out text with Parley, which handles shaping, bidirectional \ diff --git a/crates/shikra/src/anchored.rs b/crates/shikra/src/anchored.rs index 6a74e10..218989b 100644 --- a/crates/shikra/src/anchored.rs +++ b/crates/shikra/src/anchored.rs @@ -217,7 +217,7 @@ impl Element for Anchored { /// 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). +/// Attach it to an element with [`anchor`](crate::InteractiveElement::anchor). #[derive(Debug, Clone, Default)] pub struct AnchorHandle(Rc>>); diff --git a/crates/shikra/src/app.rs b/crates/shikra/src/app.rs index f4a3177..14f7975 100644 --- a/crates/shikra/src/app.rs +++ b/crates/shikra/src/app.rs @@ -435,7 +435,7 @@ mod tests { use super::*; use crate::test::test_app; - use crate::{IntoElement, div}; + use crate::{IntoElement, Styled, div}; struct Counter { count: usize, diff --git a/crates/shikra/src/context.rs b/crates/shikra/src/context.rs index 333cfeb..712c8e1 100644 --- a/crates/shikra/src/context.rs +++ b/crates/shikra/src/context.rs @@ -87,7 +87,7 @@ impl<'a, T: 'static> Context<'a, T> { } /// Wraps `f` as an event listener that updates this entity, for use with - /// methods like [`Div::on_click`](crate::Div::on_click). + /// methods like [`on_click`](crate::InteractiveElement::on_click). /// /// ```ignore /// div().on_click(cx.listener(|this, _, _, cx| { diff --git a/crates/shikra/src/div.rs b/crates/shikra/src/div.rs index 9f44071..9ab2b09 100644 --- a/crates/shikra/src/div.rs +++ b/crates/shikra/src/div.rs @@ -2,32 +2,33 @@ use std::mem; use std::rc::Rc; use crate::element::GlobalElementId; -use crate::window::{Listeners, Node}; +use crate::window::{Listeners, Node, Relation}; use crate::{ - AnchorHandle, AnyElement, App, Bounds, ClickEvent, Element, ElementId, FocusHandle, - KeyDownEvent, KeyUpEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - PaintQuad, ParentElement, Rgba, Role, ScrollWheelEvent, SharedString, Style, Window, px, + AccessibilityHandle, 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. +/// +/// Its methods come from [`Styled`] and [`InteractiveElement`]. pub fn div() -> Div { Div { - id: None, - style: Style::default(), - hover_style: None, - active_style: None, - focus_style: None, - focus_visible_style: None, - focus_handle: None, - tab_stop: true, - listeners: Listeners::default(), - anchor: None, - accessibility: None, + interactivity: Interactivity::default(), children: Vec::new(), } } pub struct Div { + interactivity: Interactivity, + children: Vec, +} + +/// An element's style, and how it responds to input and presents itself to +/// assistive technologies. Set with the methods of [`Styled`] and +/// [`InteractiveElement`]. +pub struct Interactivity { id: Option, style: Style, hover_style: Option>, @@ -36,55 +37,136 @@ pub struct Div { focus_visible_style: Option>, focus_handle: Option, tab_stop: bool, + trap_focus: bool, + disabled: bool, listeners: Listeners, anchor: Option, accessibility: Option>, - children: Vec, + accessibility_handle: Option, + relations: Vec<(Relation, AccessibilityHandle)>, } -impl Div { - /// Identifies the element among its siblings. See [`ElementId`]. - pub fn id(mut self, id: impl Into) -> Self { - self.id = Some(id.into()); - self +impl Default for Interactivity { + fn default() -> Self { + Self { + id: None, + style: Style::default(), + hover_style: None, + active_style: None, + focus_style: None, + focus_visible_style: None, + focus_handle: None, + tab_stop: true, + trap_focus: false, + disabled: false, + listeners: Listeners::default(), + anchor: None, + accessibility: None, + accessibility_handle: None, + relations: Vec::new(), + } + } +} + +impl Interactivity { + /// The ID given with [`InteractiveElement::id`], if any. + pub fn element_id(&self) -> Option<&ElementId> { + self.id.as_ref() } + /// The handle given with [`InteractiveElement::track_focus`], if any. + pub fn focus_handle(&self) -> Option<&FocusHandle> { + self.focus_handle.as_ref() + } + + /// Whether the element was [disabled](InteractiveElement::disabled). + pub fn is_disabled(&self) -> bool { + self.disabled + } + + /// Applies the styles for the element's current state. + fn resolve_style(&mut self, id: GlobalElementId, window: &Window) { + let focus_handle = self.focus_handle.as_ref(); + // In the same order as Tailwind, so later states win. + let states = [ + (&self.hover_style, window.is_element_hovered(id)), + ( + &self.focus_style, + focus_handle.is_some_and(|handle| handle.is_focused(window)), + ), + ( + &self.focus_visible_style, + focus_handle.is_some_and(|handle| handle.is_focus_visible(window)), + ), + (&self.active_style, window.is_element_active(id)), + ]; + for (style, active) in states { + if let Some(style) = style + && active + { + self.style = mem::take(&mut self.style).refine(style); + } + } + } +} + +/// An element with a [`Style`]. +pub trait Styled: Sized { + /// The element's style, before any state styles like + /// [`hover`](InteractiveElement::hover) are applied. + fn style_mut(&mut self) -> &mut Style; + /// Refines the element's style. /// /// ```ignore /// div().style(|s| s.flex().gap_2().p_4().bg(rgb(0x1e1e2e))) /// ``` - pub fn style(mut self, f: impl FnOnce(Style) -> Style) -> Self { - self.style = f(self.style); + fn style(mut self, f: impl FnOnce(Style) -> Style) -> Self { + let style = self.style_mut(); + *style = f(mem::take(style)); + self + } +} + +/// An element that responds to input, like a [`Div`]. +/// +/// Elements that wrap a div, like the parts of headless components, implement +/// this to offer all of its methods. +pub trait InteractiveElement: Styled { + fn interactivity(&mut self) -> &mut Interactivity; + + /// Identifies the element among its siblings. See [`ElementId`]. + fn id(mut self, id: impl Into) -> Self { + self.interactivity().id = Some(id.into()); self } /// Refines the element's style while the mouse is over it or one of its /// descendants. Like Tailwind's `hover:`. - pub fn hover(mut self, f: impl FnOnce(Style) -> Style) -> Self { - self.hover_style = Some(Box::new(f(Style::default()))); + fn hover(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.interactivity().hover_style = Some(Box::new(f(Style::default()))); self } /// Refines the element's style while the left mouse button is held after /// being pressed on it. Like Tailwind's `active:`. - pub fn active(mut self, f: impl FnOnce(Style) -> Style) -> Self { - self.active_style = Some(Box::new(f(Style::default()))); + fn active(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.interactivity().active_style = Some(Box::new(f(Style::default()))); self } /// Refines the element's style while it's focused. Requires /// [`track_focus`](Self::track_focus). Like Tailwind's `focus:`. - pub fn focus(mut self, f: impl FnOnce(Style) -> Style) -> Self { - self.focus_style = Some(Box::new(f(Style::default()))); + fn focus(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.interactivity().focus_style = Some(Box::new(f(Style::default()))); self } /// Refines the element's style while it's focused and the user is /// navigating with the keyboard. Requires /// [`track_focus`](Self::track_focus). Like Tailwind's `focus-visible:`. - pub fn focus_visible(mut self, f: impl FnOnce(Style) -> Style) -> Self { - self.focus_visible_style = Some(Box::new(f(Style::default()))); + fn focus_visible(mut self, f: impl FnOnce(Style) -> Style) -> Self { + self.interactivity().focus_visible_style = Some(Box::new(f(Style::default()))); self } @@ -92,23 +174,41 @@ impl Div { /// /// The element takes focus when clicked and when tabbed to, and receives /// keyboard events while it or one of its descendants is focused. - pub fn track_focus(mut self, handle: &FocusHandle) -> Self { - self.focus_handle = Some(handle.clone()); + fn track_focus(mut self, handle: &FocusHandle) -> Self { + self.interactivity().focus_handle = Some(handle.clone()); self } /// Whether Tab can move focus to the element. Defaults to `true`; set it /// to `false` for elements that should only take focus when clicked or /// focused programmatically. - pub fn tab_stop(mut self, tab_stop: bool) -> Self { - self.tab_stop = tab_stop; + fn tab_stop(mut self, tab_stop: bool) -> Self { + self.interactivity().tab_stop = tab_stop; + self + } + + /// Keeps keyboard focus inside the element while it's painted, like a + /// modal dialog does: Tab cycles through the focusable elements inside + /// it, and clicking outside it doesn't move focus. + /// + /// If more than one element traps focus, the last one painted wins. + fn trap_focus(mut self) -> Self { + self.interactivity().trap_focus = true; + self + } + + /// Disables the element, like a disabled button: its listeners aren't + /// called, it can't be focused, and assistive technologies are told it's + /// disabled. Its hover styles still apply. + fn disabled(mut self, disabled: bool) -> Self { + self.interactivity().disabled = disabled; 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()); + fn anchor(mut self, handle: &AnchorHandle) -> Self { + self.interactivity().anchor = Some(handle.clone()); self } @@ -117,7 +217,7 @@ impl Div { /// /// Elements without a role are grouped with their parent, unless they can /// be focused or clicked. - pub fn role(self, role: Role) -> Self { + fn role(self, role: Role) -> Self { self.accessibility(|node| node.set_role(role)) } @@ -127,18 +227,50 @@ impl Div { /// /// Elements with roles like [`Role::Button`] are named after their text /// by default. - pub fn label(self, label: impl Into) -> Self { + fn label(self, label: impl Into) -> Self { let label = label.into(); self.accessibility(|node| node.set_label(&*label)) } /// More about the element for assistive technologies, beyond its name. /// Like HTML's `aria-description`. - pub fn description(self, description: impl Into) -> Self { + fn description(self, description: impl Into) -> Self { let description = description.into(); self.accessibility(|node| node.set_description(&*description)) } + /// Lets other elements refer to this one with `handle`, with methods like + /// [`labelled_by`](Self::labelled_by). Like HTML's `id` attribute. + fn accessibility_handle(mut self, handle: &AccessibilityHandle) -> Self { + self.interactivity().accessibility_handle = Some(handle.clone()); + self + } + + /// Names the element after the text of the element with `handle`, like a + /// dialog after its title. Like HTML's `aria-labelledby`. + fn labelled_by(mut self, handle: &AccessibilityHandle) -> Self { + let relation = (Relation::LabelledBy, handle.clone()); + self.interactivity().relations.push(relation); + self + } + + /// Describes the element with the text of the element with `handle`. + /// Like HTML's `aria-describedby`. + fn described_by(mut self, handle: &AccessibilityHandle) -> Self { + let relation = (Relation::DescribedBy, handle.clone()); + self.interactivity().relations.push(relation); + self + } + + /// Tells assistive technologies that the element controls the element + /// with `handle`, like a button that opens a popover. Like HTML's + /// `aria-controls`. + fn controls(mut self, handle: &AccessibilityHandle) -> Self { + let relation = (Relation::Controls, handle.clone()); + self.interactivity().relations.push(relation); + self + } + /// Edits the element's node in the accessibility tree directly, for /// properties without their own method. /// @@ -148,22 +280,24 @@ impl Div { /// .accessibility(|node| node.set_toggled(Toggled::True)) /// ``` /// - /// Don't set the node's bounds, children, or actions; they come from the - /// element. - pub fn accessibility(mut self, f: impl FnOnce(&mut accesskit::Node)) -> Self { + /// Don't set the node's bounds, children, actions, or relations; they + /// come from the element. + fn accessibility(mut self, f: impl FnOnce(&mut accesskit::Node)) -> Self { let node = self + .interactivity() .accessibility .get_or_insert_with(|| Box::new(accesskit::Node::new(Role::GenericContainer))); f(node); self } - pub fn on_mouse_down( + fn on_mouse_down( mut self, button: MouseButton, listener: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners + self.interactivity() + .listeners .mouse_down .push(Rc::new(move |event, window, cx| { if event.button == button { @@ -175,20 +309,22 @@ impl Div { /// Listens for any mouse button being pressed outside the element, like /// to close a popover. - pub fn on_mouse_down_out( + 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)); + let listeners = &mut self.interactivity().listeners; + listeners.mouse_down_out.push(Rc::new(listener)); self } - pub fn on_mouse_up( + fn on_mouse_up( mut self, button: MouseButton, listener: impl Fn(&MouseUpEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners + self.interactivity() + .listeners .mouse_up .push(Rc::new(move |event, window, cx| { if event.button == button { @@ -198,80 +334,70 @@ impl Div { self } - pub fn on_mouse_move( + fn on_mouse_move( mut self, listener: impl Fn(&MouseMoveEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners.mouse_move.push(Rc::new(listener)); + let listeners = &mut self.interactivity().listeners; + listeners.mouse_move.push(Rc::new(listener)); self } - pub fn on_scroll_wheel( + fn on_scroll_wheel( mut self, listener: impl Fn(&ScrollWheelEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners.scroll_wheel.push(Rc::new(listener)); + let listeners = &mut self.interactivity().listeners; + listeners.scroll_wheel.push(Rc::new(listener)); self } /// Listens for the left mouse button being pressed and released on the /// element. - pub fn on_click( - mut self, - listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.listeners.click.push(Rc::new(listener)); + fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + let listeners = &mut self.interactivity().listeners; + listeners.click.push(Rc::new(listener)); self } /// Listens for the mouse entering (`true`) or leaving (`false`) the /// element, including its descendants. - pub fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { - self.listeners.hover.push(Rc::new(listener)); + fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + let listeners = &mut self.interactivity().listeners; + listeners.hover.push(Rc::new(listener)); self } /// Listens for keys pressed while the element or one of its descendants /// is focused. - pub fn on_key_down( + fn on_key_down( mut self, listener: impl Fn(&KeyDownEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners.key_down.push(Rc::new(listener)); + let listeners = &mut self.interactivity().listeners; + listeners.key_down.push(Rc::new(listener)); self } - pub fn on_key_up( + fn on_key_up( mut self, listener: impl Fn(&KeyUpEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.listeners.key_up.push(Rc::new(listener)); + let listeners = &mut self.interactivity().listeners; + listeners.key_up.push(Rc::new(listener)); self } +} - /// Applies the styles for the element's current state. - fn resolve_style(&mut self, id: GlobalElementId, window: &Window) { - let focus_handle = self.focus_handle.as_ref(); - // In the same order as Tailwind, so later states win. - let states = [ - (&self.hover_style, window.is_element_hovered(id)), - ( - &self.focus_style, - focus_handle.is_some_and(|handle| handle.is_focused(window)), - ), - ( - &self.focus_visible_style, - focus_handle.is_some_and(|handle| handle.is_focus_visible(window)), - ), - (&self.active_style, window.is_element_active(id)), - ]; - for (style, active) in states { - if let Some(style) = style - && active - { - self.style = mem::take(&mut self.style).refine(style); - } - } +impl Styled for Div { + fn style_mut(&mut self) -> &mut Style { + &mut self.interactivity.style + } +} + +impl InteractiveElement for Div { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity } } @@ -285,16 +411,18 @@ impl Element for Div { type LayoutState = GlobalElementId; fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { - let element_id = self.id.take(); + let interactivity = &mut self.interactivity; + let element_id = interactivity.id.take(); window.with_element_id(element_id.as_ref(), |window, id| { - self.resolve_style(id, window); - let children = window.with_text_style(&self.style.text, |window| { + interactivity.resolve_style(id, window); + let style = &interactivity.style; + let children = window.with_text_style(&style.text, |window| { self.children .iter_mut() .map(|child| child.request_layout(window, cx)) .collect::>() }); - (window.request_layout(&self.style, &children), id) + (window.request_layout(style, &children), id) }) } @@ -305,10 +433,10 @@ impl Element for Div { window: &mut Window, cx: &mut App, ) { + let interactivity = &mut self.interactivity; + let style = &interactivity.style; let rem_size = window.rem_size(); - let background = self.style.background; - let border_widths = self - .style + let border_widths = style .border_widths .map(|width| width.unwrap_or(px(0.)).to_pixels(rem_size)); let has_border = [ @@ -320,34 +448,47 @@ impl Element for Div { .iter() .any(|width| *width > 0.); - if background.is_some() || has_border { + if style.background.is_some() || has_border { window.paint_quad(PaintQuad { bounds, - background: background.unwrap_or(Rgba::TRANSPARENT), + background: style.background.unwrap_or(Rgba::TRANSPARENT), border_widths, - border_color: self.style.border_color.unwrap_or(Rgba::TRANSPARENT), - corner_radii: self - .style + border_color: style.border_color.unwrap_or(Rgba::TRANSPARENT), + corner_radii: style .corner_radii .map(|radius| radius.unwrap_or(px(0.)).to_pixels(rem_size)), }); } - if let Some(anchor) = &self.anchor { + if let Some(anchor) = &interactivity.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); - node.tab_stop = self.tab_stop; - node.hover_styled = self.hover_style.is_some(); - node.active_styled = self.active_style.is_some(); - node.listeners = mem::take(&mut self.listeners); - node.accessibility = self.accessibility.take().map(|node| *node); + node.cursor = style.cursor; + if !interactivity.disabled { + node.focus = interactivity + .focus_handle + .as_ref() + .map(FocusHandle::downgrade); + node.listeners = mem::take(&mut interactivity.listeners); + } + node.tab_stop = interactivity.tab_stop; + node.trap_focus = interactivity.trap_focus; + node.hover_styled = interactivity.hover_style.is_some(); + node.active_styled = interactivity.active_style.is_some(); + node.accessibility = interactivity.accessibility.take().map(|node| *node); + if interactivity.disabled { + node.accessibility + .get_or_insert_with(|| accesskit::Node::new(Role::GenericContainer)) + .set_disabled(); + } + node.accessibility_handle = interactivity.accessibility_handle.take(); + node.relations = mem::take(&mut interactivity.relations); + let text_style = &interactivity.style.text; window.with_node(node, |window| { - window.with_text_style(&self.style.text, |window| { + window.with_text_style(text_style, |window| { for child in &mut self.children { child.paint(window, cx); } diff --git a/crates/shikra/src/focus.rs b/crates/shikra/src/focus.rs index d17c213..d7d9288 100644 --- a/crates/shikra/src/focus.rs +++ b/crates/shikra/src/focus.rs @@ -9,7 +9,7 @@ pub struct FocusId(u64); /// A handle to something that can receive keyboard focus. /// -/// Attach it to an element with [`Div::track_focus`](crate::Div::track_focus) +/// Attach it to an element with [`track_focus`](crate::InteractiveElement::track_focus) /// to make that element focusable. Focus is released once every handle to it /// is dropped. #[derive(Debug, Clone)] @@ -73,4 +73,8 @@ impl WeakFocusHandle { pub(crate) fn is_alive(&self) -> bool { self.handle.strong_count() > 0 } + + pub(crate) fn upgrade(&self) -> Option { + self.handle.upgrade().map(FocusHandle) + } } diff --git a/crates/shikra/src/lib.rs b/crates/shikra/src/lib.rs index b21ca63..71ecfb1 100644 --- a/crates/shikra/src/lib.rs +++ b/crates/shikra/src/lib.rs @@ -17,8 +17,8 @@ mod renderer; mod scene; mod style; mod subscription; -#[cfg(test)] -mod test; +#[cfg(any(test, feature = "test-support"))] +pub mod test; mod text; mod view; mod window; diff --git a/crates/shikra/src/test.rs b/crates/shikra/src/test.rs index c5eefc5..a39457c 100644 --- a/crates/shikra/src/test.rs +++ b/crates/shikra/src/test.rs @@ -1,30 +1,181 @@ -//! Helpers for tests. +//! Running an app without an event loop or platform windows, for tests. +//! +//! Requires the `test-support` feature. +//! +//! ```ignore +//! let app = TestApp::new(); +//! let window = app.open_window(|_, cx| cx.new(|cx| Counter::new(cx))); +//! window.click(point(20., 20.)); +//! window.press(Key::Enter); +//! ``` use std::cell::RefCell; use std::rc::Rc; +use accesskit::TreeUpdate; + use crate::executor::ForegroundQueue; -use crate::{App, BackgroundExecutor}; +use crate::input::PlatformInput; +use crate::{ + App, BackgroundExecutor, Entity, FocusId, Key, Modifiers, MouseButton, Point, Render, Window, + WindowId, WindowOptions, +}; /// An app without an event loop or platform windows. -pub(crate) struct TestApp { +pub struct TestApp { pub(crate) app: Rc>, _foreground_queue: ForegroundQueue, _runtime: tokio::runtime::Runtime, } +#[cfg(test)] pub(crate) fn test_app() -> TestApp { - let runtime = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap(); - let foreground_queue = ForegroundQueue::new(|| {}); - let app = App::create( - foreground_queue.executor(), - BackgroundExecutor::new(runtime.handle().clone()), - ); - TestApp { - app, - _foreground_queue: foreground_queue, - _runtime: runtime, + TestApp::new() +} + +impl Default for TestApp { + fn default() -> Self { + Self::new() + } +} + +impl TestApp { + pub fn new() -> Self { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let foreground_queue = ForegroundQueue::new(|| {}); + let app = App::create( + foreground_queue.executor(), + BackgroundExecutor::new(runtime.handle().clone()), + ); + Self { + app, + _foreground_queue: foreground_queue, + _runtime: runtime, + } + } + + /// Runs `f` with the app, then redraws the windows that need it, like the + /// event loop does after handling an event. + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + let result = f(&mut app); + settle(&mut app); + result + } + + /// Opens an 800×600 window and draws it. + pub fn open_window( + &self, + build_root_view: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> TestWindow { + let id = self.update(|cx| { + let id = cx + .open_window(WindowOptions::default(), build_root_view) + .id(); + cx.pending_windows.clear(); + id + }); + TestWindow { + app: self.app.clone(), + id, + } + } +} + +/// A window in a [`TestApp`], which input can be simulated in. +/// +/// Like the event loop, each method redraws the window afterwards if it needs +/// it. +pub struct TestWindow { + app: Rc>, + id: WindowId, +} + +impl TestWindow { + /// Runs `f` with the window, then redraws it if it needs it. + pub fn update(&self, f: impl FnOnce(&mut Window, &mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + let result = app + .update_window(self.id, f) + .expect("window has been closed"); + settle(&mut app); + result + } + + /// The focused element's focus ID, if any. + pub fn focused(&self) -> Option { + self.update(|window, _| window.focused()) + } + + pub fn move_mouse(&self, position: Point) { + self.dispatch(PlatformInput::MouseMove(position)); + } + + pub fn mouse_down(&self, button: MouseButton) { + self.dispatch(PlatformInput::MouseDown(button)); + } + + pub fn mouse_up(&self, button: MouseButton) { + self.dispatch(PlatformInput::MouseUp(button)); + } + + /// Moves the mouse to `position` and clicks the left button there. + pub fn click(&self, position: Point) { + self.move_mouse(position); + self.mouse_down(MouseButton::Left); + self.mouse_up(MouseButton::Left); + } + + /// Presses and releases `key`, with the modifiers set with + /// [`set_modifiers`](Self::set_modifiers). + pub fn press(&self, key: Key) { + self.dispatch(PlatformInput::KeyDown { + key: key.clone(), + text: None, + is_repeat: false, + }); + self.dispatch(PlatformInput::KeyUp(key)); + } + + pub fn set_modifiers(&self, modifiers: Modifiers) { + self.dispatch(PlatformInput::ModifiersChanged(modifiers)); + } + + /// The whole accessibility tree, as an assistive technology would first + /// get it. Builds the tree from now on, as if one were listening. + pub fn accessibility_tree(&self) -> TreeUpdate { + self.update(|window, _| { + window.accessibility.activate(); + window.refresh(); + }); + self.update(|window, _| window.accessibility.tree_update()) + } + + fn dispatch(&self, input: PlatformInput) { + self.update(|window, cx| window.dispatch_input(input, cx)); + } +} + +/// Runs pending effects and redraws windows until nothing changes. +fn settle(app: &mut App) { + // Drawing can ask for another frame, like when it moves focus, but + // shouldn't keep asking. + for _ in 0..8 { + app.flush_effects(); + let windows: Vec = app + .windows + .iter() + .filter(|(_, window)| window.needs_redraw) + .map(|(id, _)| *id) + .collect(); + if windows.is_empty() { + return; + } + for id in windows { + app.update_window(id, |window, cx| window.draw(cx)); + } } + panic!("windows kept asking to be redrawn"); } diff --git a/crates/shikra/src/window.rs b/crates/shikra/src/window.rs index c067b47..83e05a2 100644 --- a/crates/shikra/src/window.rs +++ b/crates/shikra/src/window.rs @@ -1,6 +1,7 @@ mod accessibility; mod dispatch; mod layers; +mod state; use std::cell::RefCell; use std::collections::HashSet; @@ -18,9 +19,11 @@ use crate::{ IntoElement, LayoutId, Point, Rgba, SharedString, Size, Style, TextStyle, TextStyleRefinement, TextSystem, px, size, }; -pub(crate) use accessibility::AccessibilityState; +pub use accessibility::AccessibilityHandle; +pub(crate) use accessibility::{AccessibilityState, Relation}; pub(crate) use dispatch::{InputState, Listeners, Node}; use layers::LayerState; +use state::ElementStates; /// Identifies a window within an [`App`](crate::App). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -110,6 +113,7 @@ pub struct Window { pub(crate) input: InputState, pub(crate) accessibility: AccessibilityState, layers: LayerState, + element_states: ElementStates, } impl Window { @@ -130,6 +134,7 @@ impl Window { input: InputState::default(), accessibility: AccessibilityState::default(), layers: LayerState::default(), + element_states: ElementStates::default(), } } @@ -302,6 +307,7 @@ impl Window { self.scene.clear(); self.start_input_frame(); self.start_accessibility_frame(); + self.start_state_frame(); let mut root = self .root_view @@ -333,6 +339,7 @@ impl Window { self.paint_deferred(cx); self.accessed_entities = cx.stop_tracking_accesses(); + self.finish_state_frame(); self.finish_input_frame(cx); self.finish_accessibility_frame(self.focused_accessibility_id()); } diff --git a/crates/shikra/src/window/accessibility.rs b/crates/shikra/src/window/accessibility.rs index 2d53e7b..bf008c5 100644 --- a/crates/shikra/src/window/accessibility.rs +++ b/crates/shikra/src/window/accessibility.rs @@ -7,9 +7,11 @@ //! become its children. It's only built while an assistive technology is //! listening. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::mem; +use std::rc::Rc; use accesskit::{Affine, Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate}; @@ -36,10 +38,40 @@ pub(crate) struct AccessibilityState { /// The nodes the platform has, which updates are relative to, or `None` /// if it needs the whole tree. sent_nodes: Option>, + /// References from nodes added this frame to other elements, resolved once + /// the frame is painted. + next_relations: Vec<(NodeId, Relation, AccessibilityHandle)>, + /// The text of the nodes added this frame that other elements can refer + /// to, for naming and describing the elements referring to them. + next_texts: HashMap, /// Whether a frame has been drawn since the platform was last updated. pub(crate) update_pending: bool, } +/// Lets elements refer to one another for assistive technologies, like a +/// dialog to the title it's named after. Like an element's `id` in HTML. +/// +/// Give it to the element to refer to with +/// [`accessibility_handle`](crate::InteractiveElement::accessibility_handle), +/// and to elements referring to it with methods like +/// [`labelled_by`](crate::InteractiveElement::labelled_by). +#[derive(Debug, Clone, Default)] +pub struct AccessibilityHandle(Rc>>); + +impl AccessibilityHandle { + pub fn new() -> Self { + Self::default() + } +} + +/// How one element refers to another. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum Relation { + LabelledBy, + DescribedBy, + Controls, +} + struct OpenNode { id: NodeId, node: Node, @@ -112,6 +144,68 @@ impl AccessibilityState { id } + /// Records that the element with node `id` has `handle`, and refers to + /// the elements in `relations`. + pub(super) fn add_references( + &mut self, + id: NodeId, + handle: Option, + relations: Vec<(Relation, AccessibilityHandle)>, + ) { + if let Some(handle) = handle { + handle.0.set(Some(id)); + // Filled in when the node is closed. + self.next_texts.insert(id, String::new()); + } + self.next_relations.extend( + relations + .into_iter() + .map(|(relation, handle)| (id, relation, handle)), + ); + } + + /// Adds the references recorded this frame to the nodes, now that every + /// element referred to has been painted. + fn resolve_relations(&mut self) { + // Not every platform adapter follows the relations to name and + // describe nodes, so name and describe them directly too. + let mut texts: HashMap<(NodeId, Relation), String> = HashMap::new(); + for (id, relation, handle) in mem::take(&mut self.next_relations) { + // The element referred to may not have been painted this frame. + let Some(target) = handle + .0 + .get() + .filter(|target| self.next_nodes.contains_key(target)) + else { + continue; + }; + let Some(node) = self.next_nodes.get_mut(&id) else { + continue; + }; + match relation { + Relation::LabelledBy => node.push_labelled_by(target), + Relation::DescribedBy => node.push_described_by(target), + Relation::Controls => node.push_controlled(target), + } + if let Some(text) = self.next_texts.get(&target).filter(|text| !text.is_empty()) { + let joined = texts.entry((id, relation)).or_default(); + if !joined.is_empty() { + joined.push(' '); + } + joined.push_str(text); + } + } + + for ((id, relation), text) in texts { + let node = self.next_nodes.get_mut(&id).expect("node was removed"); + match relation { + Relation::LabelledBy if node.label().is_none() => node.set_label(text), + Relation::DescribedBy if node.description().is_none() => node.set_description(text), + _ => {} + } + } + } + /// Closes the innermost open node. fn pop(&mut self) { let OpenNode { @@ -130,20 +224,24 @@ impl AccessibilityState { node.set_label(text.clone()); } + // What the node says, to assistive technologies reading its parent + // or elements referring to it. + let text = match (node.label(), node.role()) { + (Some(label), _) => label, + (None, Role::Label) => node.value().unwrap_or_default(), + (None, _) => &text, + }; + if let Some(referred_text) = self.next_texts.get_mut(&id) { + referred_text.push_str(text); + } if !node.is_hidden() + && !text.is_empty() && let Some(parent) = self.stack.last_mut() { - let text = match (node.label(), node.role()) { - (Some(label), _) => label, - (None, Role::Label) => node.value().unwrap_or_default(), - (None, _) => &text, - }; - if !text.is_empty() { - if !parent.text.is_empty() { - parent.text.push(' '); - } - parent.text.push_str(text); + if !parent.text.is_empty() { + parent.text.push(' '); } + parent.text.push_str(text); } self.next_nodes.insert(id, node); @@ -239,6 +337,8 @@ impl Window { let state = &mut self.accessibility; state.next_nodes.clear(); state.next_ids.clear(); + state.next_relations.clear(); + state.next_texts.clear(); state.stack.clear(); if state.active { state.next_ids.insert(ROOT_ID); @@ -268,6 +368,7 @@ impl Window { self.accessibility.pop(); let state = &mut self.accessibility; + state.resolve_relations(); state.rendered_nodes = mem::take(&mut state.next_nodes); state.rendered_focus = focus; state.update_pending = true; @@ -283,8 +384,8 @@ mod tests { use crate::test::test_app; use crate::{ - App, Context, FocusHandle, IntoElement, ParentElement, Render, Window, WindowId, - WindowOptions, div, px, + App, Context, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, Styled, + Window, WindowId, WindowOptions, div, px, }; struct Counter { diff --git a/crates/shikra/src/window/dispatch.rs b/crates/shikra/src/window/dispatch.rs index ad40c1f..c7f7546 100644 --- a/crates/shikra/src/window/dispatch.rs +++ b/crates/shikra/src/window/dispatch.rs @@ -17,10 +17,11 @@ use accesskit::{Action, ActionRequest, NodeId, Role, TreeId}; use crate::element::{GlobalElementId, LocalElementId}; use crate::focus::WeakFocusHandle; use crate::input::PlatformInput; +use crate::window::Relation; use crate::{ - App, Bounds, ClickEvent, CursorStyle, ElementId, EntityId, FocusHandle, FocusId, Key, - KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Point, ScrollWheelEvent, Window, point, + AccessibilityHandle, App, Bounds, ClickEvent, CursorStyle, ElementId, EntityId, FocusHandle, + FocusId, Key, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, Point, ScrollWheelEvent, Window, point, }; /// How long after one click another counts towards a double click. @@ -51,6 +52,7 @@ pub(crate) struct Node { pub(crate) cursor: Option, pub(crate) focus: Option, pub(crate) tab_stop: bool, + pub(crate) trap_focus: bool, /// Whether the element looks different when hovered, so a change needs /// a redraw. pub(crate) hover_styled: bool, @@ -60,6 +62,9 @@ pub(crate) struct Node { /// What the element tells assistive technologies about itself, if more /// than that it can be focused or clicked. pub(crate) accessibility: Option, + pub(crate) accessibility_handle: Option, + /// Other elements this one refers to, like the one it's labelled by. + pub(crate) relations: Vec<(Relation, AccessibilityHandle)>, /// Set when the node is inserted. parent: Option, /// The element's node in the accessibility tree, set when the node is @@ -75,10 +80,13 @@ impl Node { cursor: None, focus: None, tab_stop: true, + trap_focus: false, hover_styled: false, active_styled: false, listeners: Listeners::default(), accessibility: None, + accessibility_handle: None, + relations: Vec::new(), parent: None, accessibility_id: None, } @@ -91,7 +99,9 @@ impl Node { let clickable = !self.listeners.click.is_empty(); let mut node = match self.accessibility.take() { Some(node) => node, - None if focusable || clickable => accesskit::Node::new(Role::GenericContainer), + None if focusable || clickable || self.accessibility_handle.is_some() => { + accesskit::Node::new(Role::GenericContainer) + } None => return None, }; if focusable { @@ -137,6 +147,17 @@ impl Frame { fn ids(&self, path: &[usize]) -> Vec { path.iter().map(|&ix| self.nodes[ix].id).collect() } + + /// Whether `node` is `ancestor` or one of its descendants. + fn is_within(&self, node: usize, ancestor: usize) -> bool { + iter::successors(Some(node), |&ix| self.nodes[ix].parent).any(|ix| ix == ancestor) + } + + /// The node focus is trapped in, if any: the last one painted that traps + /// it. + fn focus_trap(&self) -> Option { + self.nodes.iter().rposition(|node| node.trap_focus) + } } /// The state of the window's input, carried between frames. @@ -158,6 +179,8 @@ pub(crate) struct InputState { pressed_button: Option, last_mouse_down: Option<(Instant, Point, MouseButton, usize)>, focused: Option, + /// An element to move focus into once it's painted. + pending_focus_within: Option, /// Whether the user is navigating with the keyboard, so focus should be /// visible. focus_visible: bool, @@ -199,6 +222,12 @@ impl Window { .map(|focused| focused.id) } + /// A handle to the focused element, if any, for returning focus to it + /// later. + pub fn focused_handle(&self) -> Option { + self.input.focused.as_ref()?.upgrade() + } + pub(crate) fn is_focus_visible(&self) -> bool { self.input.focus_visible } @@ -232,16 +261,29 @@ impl Window { .collect() } + /// Once the element tracking `handle` is next painted, moves focus to the + /// first element inside it that Tab can reach, or to the element itself + /// if there isn't one. For moving focus into something that's just + /// opened, like a dialog. + pub fn focus_first_within(&mut self, handle: &FocusHandle) { + self.input.pending_focus_within = Some(handle.downgrade()); + } + /// Moves focus to the next (or previous) focusable element in the order - /// they were painted, wrapping around. + /// they were painted, wrapping around. If focus is + /// [trapped](crate::InteractiveElement::trap_focus), only elements + /// inside the trap are considered. pub fn focus_next(&mut self, backwards: bool) { - let stops: Vec = self - .input - .rendered_frame + let frame = &self.input.rendered_frame; + let trap = frame.focus_trap(); + let stops: Vec = frame .nodes .iter() - .filter(|node| node.tab_stop) - .filter_map(|node| node.focus.clone()) + .enumerate() + .filter(|(ix, node)| { + node.tab_stop && trap.is_none_or(|trap| frame.is_within(*ix, trap)) + }) + .filter_map(|(_, node)| node.focus.clone()) .collect(); if stops.is_empty() { return; @@ -262,8 +304,12 @@ impl Window { // Element identity /// Runs `f` as an element with the given ID, or the next position among - /// its siblings if it doesn't have one, passing it its global ID. - pub(crate) fn with_element_id( + /// its siblings if it doesn't have one, passing it its global ID. Elements + /// laid out in `f` are its children. + /// + /// Call this while requesting layout, for elements that need an identity + /// that lasts between frames, like one that [keeps state](Self::use_state). + pub fn with_element_id( &mut self, id: Option<&ElementId>, f: impl FnOnce(&mut Self, GlobalElementId) -> R, @@ -335,7 +381,10 @@ impl Window { return self.push_node(node, f); }; let (element, bounds) = (node.id, node.bounds); + let handle = node.accessibility_handle.take(); + let relations = mem::take(&mut node.relations); self.with_accessibility_node(element, bounds, accessibility_node, |window, id| { + window.accessibility.add_references(id, handle, relations); node.accessibility_id = Some(id); window.push_node(node, f) }) @@ -409,23 +458,41 @@ impl Window { self.set_focus(Some(focus)); } } - Action::Click => { - let bounds = frame.nodes[target].bounds; - let event = ClickEvent { - position: point( - bounds.origin.x + bounds.size.width / 2., - bounds.origin.y + bounds.size.height / 2., - ), - modifiers: self.input.modifiers, - click_count: 1, - }; - let path = frame.path(Some(target)); - self.dispatch_on_path(&path, &event, |l| &l.click, cx); - } + Action::Click => self.click_node(target, cx), _ => {} } } + /// Clicks the element tracking `handle`, as if with the mouse, if it was + /// painted. For activating elements from the keyboard, like a button with + /// Enter, so that their click listeners handle every way of activating + /// them. + pub fn click(&mut self, handle: &FocusHandle, cx: &mut App) { + if let Some(target) = self.input.rendered_frame.focused_node(handle.id()) { + self.click_node(target, cx); + } + } + + /// Dispatches a click at the center of `target`. + fn click_node(&mut self, target: usize, cx: &mut App) { + let frame = &self.input.rendered_frame; + let bounds = frame.nodes[target].bounds; + let event = ClickEvent { + position: point( + bounds.origin.x + bounds.size.width / 2., + bounds.origin.y + bounds.size.height / 2., + ), + modifiers: self.input.modifiers, + click_count: 1, + }; + let path = frame.path(Some(target)); + // This can be called while dispatching another event, which should + // keep bubbling unless its own listeners stop it. + let propagation_stopped = self.input.propagation_stopped; + self.dispatch_on_path(&path, &event, |l| &l.click, cx); + self.input.propagation_stopped = propagation_stopped; + } + pub(super) fn start_input_frame(&mut self) { self.input.next_frame = Frame::default(); self.input.element_id_stack.clear(); @@ -434,10 +501,27 @@ impl Window { /// Makes the frame just painted the one events are dispatched against. pub(super) fn finish_input_frame(&mut self, cx: &mut App) { self.input.rendered_frame = mem::take(&mut self.input.next_frame); + if let Some(handle) = self.input.pending_focus_within.take() { + self.focus_first_within_rendered(handle); + } // The layout may have moved things under the mouse. self.update_hover(cx); } + fn focus_first_within_rendered(&mut self, handle: WeakFocusHandle) { + let frame = &self.input.rendered_frame; + let Some(container) = frame.focused_node(handle.id) else { + return; + }; + let first_stop = (container + 1..frame.nodes.len()) + .filter(|&ix| frame.is_within(ix, container)) + .find_map(|ix| { + let node = &frame.nodes[ix]; + node.focus.clone().filter(|_| node.tab_stop) + }); + self.set_focus(Some(first_stop.unwrap_or(handle))); + } + // Dispatch pub(crate) fn dispatch_input(&mut self, input: PlatformInput, cx: &mut App) { @@ -557,11 +641,17 @@ impl Window { } // Like the web, focus the innermost focusable element under the - // mouse, or nothing if there isn't one. + // mouse, or nothing if there isn't one. If focus is trapped, only + // move it to an element inside the trap. if !self.input.default_prevented { let frame = &self.input.rendered_frame; let focus = path.iter().find_map(|&ix| frame.nodes[ix].focus.clone()); - self.set_focus(focus); + let trapped = frame + .focus_trap() + .is_some_and(|trap| focus.is_none() || !path.contains(&trap)); + if !trapped { + self.set_focus(focus); + } } } @@ -747,8 +837,8 @@ mod tests { use crate::input::PlatformInput; use crate::test::test_app; use crate::{ - App, Context, FocusHandle, IntoElement, Key, Modifiers, MouseButton, ParentElement, Render, - Window, WindowId, WindowOptions, div, point, px, rgb, + App, Context, FocusHandle, InteractiveElement, IntoElement, Key, Modifiers, MouseButton, + ParentElement, Render, Styled, Window, WindowId, WindowOptions, div, point, px, rgb, }; type Log = Rc>>; @@ -990,4 +1080,67 @@ mod tests { drop(handle); assert_eq!(cx.windows[&window].focused(), None); } + + /// Three focusable squares, the last two in a box that traps focus. + struct Trap { + handles: [FocusHandle; 4], + } + + impl Render for Trap { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let [outside, trap, first, second] = self.handles.clone(); + let square = |handle: &FocusHandle| { + div() + .style(|s| s.w(px(100.)).h(px(100.))) + .track_focus(handle) + }; + div() + .style(|s| s.size_full().flex()) + .child(square(&outside)) + .child( + div() + .style(|s| s.flex()) + .track_focus(&trap) + .tab_stop(false) + .trap_focus() + .child(square(&first)) + .child(square(&second)), + ) + } + } + + #[test] + fn trapped_focus_stays_inside_the_trap() { + let test = test_app(); + let cx = &mut *test.app.borrow_mut(); + let handles = [(); 4].map(|_| cx.focus_handle()); + let [_, trap, first, second] = handles.clone(); + let window = cx + .open_window(WindowOptions::default(), |_, cx| { + cx.new(|_| Trap { handles }) + }) + .id(); + let focused = |cx: &mut App| cx.windows[&window].focused(); + draw(cx, window); + + // Focus moves into the trap once it's drawn. + cx.update_window(window, |window, _| window.focus_first_within(&trap)); + assert_eq!(focused(cx), None); + draw(cx, window); + assert_eq!(focused(cx), Some(first.id())); + + press(cx, window, Key::Tab); + assert_eq!(focused(cx), Some(second.id())); + press(cx, window, Key::Tab); + assert_eq!(focused(cx), Some(first.id())); + + // Clicking outside the trap, even on something focusable, leaves + // focus where it is. + click(cx, window, 50., 50.); + assert_eq!(focused(cx), Some(first.id())); + click(cx, window, 450., 50.); + assert_eq!(focused(cx), Some(first.id())); + click(cx, window, 250., 50.); + assert_eq!(focused(cx), Some(second.id())); + } } diff --git a/crates/shikra/src/window/layers.rs b/crates/shikra/src/window/layers.rs index 28c58ea..d0dba86 100644 --- a/crates/shikra/src/window/layers.rs +++ b/crates/shikra/src/window/layers.rs @@ -2,15 +2,18 @@ //! //! 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. +//! element tree otherwise: they inherit the text style and context 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::any::Any; use std::mem; +use std::rc::Rc; use accesskit::NodeId; @@ -36,6 +39,7 @@ struct DeferredDraw { priority: i32, offset: Point, text_style_stack: Vec, + contexts: Vec>, dispatch_parent: Option, accessibility_parent: Option, } @@ -84,6 +88,7 @@ impl Window { priority, offset: self.layers.offset, text_style_stack: self.text_style_stack.clone(), + contexts: self.element_states.contexts.clone(), dispatch_parent: self.dispatch_parent(), accessibility_parent: self.accessibility_parent(), }; @@ -103,12 +108,14 @@ impl Window { 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); + let contexts = mem::replace(&mut self.element_states.contexts, draw.contexts); 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.element_states.contexts = contexts; self.text_style_stack = text_style_stack; } } @@ -125,8 +132,8 @@ mod tests { 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, + AnchorHandle, App, Context, InteractiveElement, IntoElement, MouseButton, ParentElement, + Render, Styled, Window, WindowId, WindowOptions, anchored, deferred, div, point, px, size, }; type Log = Rc>>; diff --git a/crates/shikra/src/window/state.rs b/crates/shikra/src/window/state.rs new file mode 100644 index 0000000..d7e2214 --- /dev/null +++ b/crates/shikra/src/window/state.rs @@ -0,0 +1,220 @@ +//! State that elements keep between frames, and values they provide to their +//! descendants. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::mem; +use std::rc::Rc; + +use crate::element::GlobalElementId; +use crate::{AnyEntity, App, Entity, Window}; + +#[derive(Default)] +pub(super) struct ElementStates { + /// The state kept by elements in the frame that was last drawn. + rendered: HashMap<(GlobalElementId, TypeId), AnyEntity>, + /// The state kept by elements in the frame being drawn. + next: HashMap<(GlobalElementId, TypeId), AnyEntity>, + /// Values provided to the elements being laid out or painted, innermost + /// last. + pub(super) contexts: Vec>, +} + +impl Window { + /// State of type `T` kept by the element with `id` for as long as it's + /// drawn, created with `init` the first time. Like React's `useState`. + /// + /// The state is an entity, so updating and notifying it redraws the + /// window. It's released once a frame is drawn without the element + /// asking for it. + pub fn use_state( + &mut self, + id: GlobalElementId, + cx: &mut App, + init: impl FnOnce(&mut Window, &mut App) -> T, + ) -> Entity { + let key = (id, TypeId::of::()); + let states = &mut self.element_states; + let state = match states.next.get(&key) { + Some(state) => state.clone(), + None => match states.rendered.remove(&key) { + Some(state) => state, + None => { + let value = init(self, cx); + cx.new(|_| value).into_any() + } + }, + }; + self.element_states.next.insert(key, state.clone()); + state + .downcast() + .unwrap_or_else(|_| unreachable!("element state has the wrong type")) + } + + /// Runs `f` with `value` provided to the elements it lays out or paints, + /// which can get it with [`context`](Self::context). Like React's context. + pub fn with_context( + &mut self, + value: Rc, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + self.element_states.contexts.push(value); + let result = f(self); + self.element_states.contexts.pop(); + result + } + + /// The innermost value of type `T` provided by an ancestor with + /// [`with_context`](Self::with_context). + pub fn context(&self) -> Option> { + self.element_states + .contexts + .iter() + .rev() + .find_map(|value| value.clone().downcast().ok()) + } + + pub(super) fn start_state_frame(&mut self) { + self.element_states.contexts.clear(); + } + + /// Releases the state of elements that weren't drawn this frame. + pub(super) fn finish_state_frame(&mut self) { + let states = &mut self.element_states; + states.rendered = mem::take(&mut states.next); + } +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + use crate::test::TestApp; + use crate::{ + AnyElement, App, Bounds, Context, Element, Entity, IntoElement, LayoutId, ParentElement, + Render, Window, deferred, div, + }; + + /// Keeps a count, and records the entity it's kept in. + struct Counter { + kept: Rc>>>, + } + + impl Element for Counter { + type LayoutState = (); + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, ()) { + window.with_element_id(None, |window, id| { + let count = window.use_state(id, cx, |_, _| 0_usize); + count.update(cx, |count, _| *count += 1); + *self.kept.borrow_mut() = Some(count); + }); + (window.request_layout(&Default::default(), &[]), ()) + } + + fn paint(&mut self, _: Bounds, _: &mut (), _: &mut Window, _: &mut App) {} + } + + struct Toggle { + shown: Rc>, + kept: Rc>>>, + } + + impl Render for Toggle { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let kept = self.kept.clone(); + div().children(self.shown.get().then(|| Counter { kept })) + } + } + + #[test] + fn element_state_lasts_while_the_element_is_drawn() { + let app = TestApp::new(); + let shown = Rc::new(Cell::new(true)); + let kept = Rc::new(RefCell::new(None)); + let window = app.open_window(|_, cx| { + cx.new(|_| Toggle { + shown: shown.clone(), + kept: kept.clone(), + }) + }); + let count = |cx: &App| *kept.borrow().as_ref().unwrap().read(cx); + + window.update(|window, _| window.refresh()); + assert_eq!(app.update(|cx| count(cx)), 2); + + shown.set(false); + window.update(|window, _| window.refresh()); + let released = kept.borrow_mut().take().unwrap().downgrade(); + app.update(|_| {}); + assert!(released.upgrade().is_none()); + + // It starts over when the element is drawn again. + shown.set(true); + window.update(|window, _| window.refresh()); + assert_eq!(app.update(|cx| count(cx)), 1); + } + + /// Records the context it finds when laid out and painted. + struct Reader { + log: Rc>>>, + } + + impl Element for Reader { + type LayoutState = (); + + fn request_layout(&mut self, window: &mut Window, _: &mut App) -> (LayoutId, ()) { + let value = window.context::<&'static str>().map(|value| *value); + self.log.borrow_mut().push(value); + (window.request_layout(&Default::default(), &[]), ()) + } + + fn paint(&mut self, _: Bounds, _: &mut (), window: &mut Window, _: &mut App) { + let value = window.context::<&'static str>().map(|value| *value); + self.log.borrow_mut().push(value); + } + } + + /// Provides a context to its child. + struct Provider(AnyElement); + + impl Element for Provider { + type LayoutState = (); + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, ()) { + let child = &mut self.0; + let layout_id = + window.with_context(Rc::new("outer"), |window| child.request_layout(window, cx)); + (layout_id, ()) + } + + fn paint(&mut self, _: Bounds, _: &mut (), window: &mut Window, cx: &mut App) { + window.with_context(Rc::new("outer"), |window| self.0.paint(window, cx)); + } + } + + struct Readers { + log: Rc>>>, + } + + impl Render for Readers { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let log = self.log.clone(); + div() + .child(Provider( + deferred(Reader { log: log.clone() }).into_any_element(), + )) + .child(Reader { log }) + } + } + + #[test] + fn context_reaches_descendants_even_when_deferred() { + let app = TestApp::new(); + let log = Rc::new(RefCell::new(Vec::new())); + app.open_window(|_, cx| cx.new(|_| Readers { log: log.clone() })); + // Laid out in order, then the deferred one is painted last. + assert_eq!(log.take(), [Some("outer"), None, None, Some("outer")],); + } +} diff --git a/crates/shikra_headless/Cargo.toml b/crates/shikra_headless/Cargo.toml new file mode 100644 index 0000000..077a0ba --- /dev/null +++ b/crates/shikra_headless/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shikra_headless" +version = "0.1.0" +edition = "2024" + +[dependencies] +shikra = { path = "../shikra" } + +[dev-dependencies] +shikra = { path = "../shikra", features = ["test-support"] } diff --git a/crates/shikra_headless/examples/primitives.rs b/crates/shikra_headless/examples/primitives.rs new file mode 100644 index 0000000..149d485 --- /dev/null +++ b/crates/shikra_headless/examples/primitives.rs @@ -0,0 +1,402 @@ +//! The headless primitives, styled with Tailwind-like utilities. +//! +//! Everything works with the mouse and the keyboard: Tab between controls, +//! press Enter or Space to activate them, and Escape to close popups. The +//! dialog keeps focus inside it until it's closed, and has a popover of its +//! own. + +use shikra::{ + Application, Context, Div, InteractiveElement, IntoElement, ParentElement, Render, Rgba, Role, + SharedString, Side, Styled, Window, WindowOptions, div, rgb, rgba, size, +}; +use shikra_headless::checkbox::CheckedState; +use shikra_headless::{button, checkbox, dialog, popover, toggle}; + +const ITEMS: [&str; 3] = ["Photos", "Documents", "Music"]; + +struct Primitives { + selected: [bool; 3], + dialog_open: bool, + status: SharedString, +} + +impl Primitives { + fn new(_: &mut Context) -> Self { + Self { + selected: [true, false, false], + dialog_open: false, + status: "Tab through the controls, or click them".into(), + } + } + + fn set_status(&mut self, status: impl Into, cx: &mut Context) { + self.status = status.into(); + cx.notify(); + } + + /// A toolbar of toggles, which keep their own state. + fn toolbar(&self, cx: &mut Context) -> Div { + let toggle = |label: &'static str, name: &'static str, cx: &mut Context| { + toggle::root() + .label(name) + .on_pressed_change(cx.listener(move |this, pressed: &bool, _, cx| { + let state = if *pressed { "on" } else { "off" }; + this.set_status(format!("{name} {state}"), cx); + })) + .with_state(|div, state| { + let pressed = state.pressed; + div.style(|s| { + s.size_8() + .flex() + .items_center() + .justify_center() + .rounded_md() + .font_semibold() + }) + .style(move |s| { + if pressed { + s.bg(rgb(0xe0e7ff)).text_color(rgb(0x3730a3)) + } else { + s + } + }) + .hover(move |s| if pressed { s } else { s.bg(rgb(0xf1f5f9)) }) + .focus_visible(|s| s.border_2().border_color(rgb(0x6366f1))) + }) + .child(label) + }; + section("Toggles").child( + div() + .style(|s| { + s.flex() + .gap_1() + .p_1() + .rounded_lg() + .border_1() + .border_color(rgb(0xe2e8f0)) + .bg(Rgba::WHITE) + }) + .child(toggle("B", "Bold", cx)) + .child(toggle("I", "Italic", cx)) + .child(toggle("U", "Underline", cx)), + ) + } + + /// A "select all" checkbox and the checkboxes it controls, whose state is + /// kept here. + fn checkboxes(&self, cx: &mut Context) -> Div { + let all = match self.selected.iter().filter(|selected| **selected).count() { + 0 => CheckedState::Unchecked, + 3 => CheckedState::Checked, + _ => CheckedState::Indeterminate, + }; + section("Checkboxes") + .child(checkbox_row( + "Select all", + checkbox::root().checked(all).on_checked_change(cx.listener( + |this, checked: &CheckedState, _, cx| { + this.selected = [*checked == CheckedState::Checked; 3]; + cx.notify(); + }, + )), + )) + .child( + div() + .style(|s| s.flex().flex_col().gap_2().pl_6()) + .children(ITEMS.iter().enumerate().map(|(ix, item)| { + checkbox_row( + item, + checkbox::root() + .checked(self.selected[ix]) + .on_checked_change(cx.listener( + move |this, checked: &CheckedState, _, cx| { + this.selected[ix] = *checked == CheckedState::Checked; + cx.notify(); + }, + )), + ) + })), + ) + } + + /// A popover, which keeps its own state. + fn popover(&self, cx: &mut Context) -> Div { + section("Popover").child( + popover::root() + .child(trigger_button(popover::trigger()).child("Share…")) + .child( + popover::content() + .side_offset(6.) + .collision_padding(8.) + .style(|s| { + s.w_72() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded_lg() + .border_1() + .border_color(rgb(0xcbd5e1)) + .bg(Rgba::WHITE) + .text_sm() + }) + .child( + div() + .style(|s| s.flex().items_center().justify_between()) + .child(div().style(|s| s.font_semibold()).child("Share this project")) + .child( + icon_button(popover::close()) + .label("Close") + .child("×"), + ), + ) + .child(div().style(|s| s.text_color(rgb(0x475569))).child( + "Anyone with the link can view it. Press Escape or click outside to close.", + )) + .child(checkbox_row( + "Allow editing", + checkbox::root().on_checked_change(cx.listener( + |this, checked: &CheckedState, _, cx| { + let allowed = *checked == CheckedState::Checked; + this.set_status(format!("Editing allowed: {allowed}"), cx); + }, + )), + )) + .child( + primary_button(button::root()) + .on_click(cx.listener(|this, _, _, cx| { + this.set_status("Link copied", cx); + })) + .child("Copy link"), + ), + ), + ) + } + + /// A dialog whose open state is kept here, so its buttons can close it. + fn dialog(&self, cx: &mut Context) -> Div { + section("Dialog").child( + dialog::root() + .open(self.dialog_open) + .on_open_change(cx.listener(|this, open: &bool, _, cx| { + this.dialog_open = *open; + cx.notify(); + })) + .child(trigger_button(dialog::trigger()).child("Delete project…")) + .child( + dialog::portal() + .child(dialog::overlay().style(|s| s.bg(rgba(0x0f172a66)))) + .child( + dialog::content() + .style(|s| { + s.w_96() + .flex() + .flex_col() + .gap_4() + .p_6() + .rounded_xl() + .bg(Rgba::WHITE) + .text_sm() + }) + .child( + div() + .style(|s| s.flex().items_center().justify_between()) + .child( + dialog::title() + .style(|s| s.text_lg().font_semibold()) + .child("Delete project?"), + ) + .child(help_popover()), + ) + .child( + dialog::description() + .style(|s| s.text_color(rgb(0x475569))) + .child("This deletes the project and everything in it. It can't be undone."), + ) + .child(checkbox_row("Also delete backups", checkbox::root())) + .child( + div() + .style(|s| s.flex().justify_end().gap_2()) + .child(trigger_button(dialog::close()).child("Cancel")) + .child( + primary_button(button::root()) + .style(|s| s.bg(rgb(0xdc2626))) + .hover(|s| s.bg(rgb(0xb91c1c))) + .on_click(cx.listener(|this, _, _, cx| { + this.dialog_open = false; + this.set_status("Project deleted", cx); + })) + .child("Delete"), + ), + ), + ), + ), + ) + } +} + +impl Render for Primitives { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .style(|s| { + s.size_full() + .flex() + .flex_col() + .gap_8() + .p_8() + .bg(rgb(0xf8fafc)) + .text_color(rgb(0x0f172a)) + }) + .child( + div() + .style(|s| s.flex().gap_12()) + .child(self.toolbar(cx)) + .child(self.checkboxes(cx)), + ) + .child( + div() + .style(|s| s.flex().gap_12()) + .child(self.popover(cx)) + .child(self.dialog(cx)), + ) + .child( + div() + .style(|s| s.text_sm().text_color(rgb(0x64748b))) + .role(Role::Status) + .child(self.status.clone()), + ) + } +} + +/// A heading and what's under it. +fn section(title: &'static str) -> Div { + div().style(|s| s.flex().flex_col().gap_3()).child( + div() + .style(|s| s.text_xs().font_semibold().text_color(rgb(0x64748b))) + .child(title), + ) +} + +/// Lays out `root` as a checkbox with a label, which toggles it too. +fn checkbox_row(label: &'static str, root: checkbox::Root) -> impl IntoElement { + root.label(label) + .style(|s| s.flex().items_center().gap_2().text_sm().cursor_pointer()) + .focus_visible(|s| s.text_color(rgb(0x4f46e5))) + .with_state(move |div, state| { + let checked = state.checked != CheckedState::Unchecked; + let check_box = check_box().style(move |s| { + if checked { + s.bg(rgb(0x4f46e5)).border_color(rgb(0x4f46e5)) + } else { + s + } + }); + div.child( + check_box.child(checkbox::indicator().with_state(|div, state| { + div.child(match state.checked { + CheckedState::Indeterminate => "–", + _ => "✓", + }) + })), + ) + .child(label) + }) +} + +/// The box a check mark goes in. +fn check_box() -> Div { + div().style(|s| { + s.size_4() + .flex() + .items_center() + .justify_center() + .rounded_sm() + .border_1() + .border_color(rgb(0x94a3b8)) + .bg(Rgba::WHITE) + .text_xs() + .text_color(Rgba::WHITE) + }) +} + +/// Styles a trigger or close part as a secondary button. +fn trigger_button(element: E) -> E { + element + .style(|s| { + s.px_3() + .py_1p5() + .rounded_md() + .border_1() + .border_color(rgb(0xcbd5e1)) + .bg(Rgba::WHITE) + .text_sm() + .font_medium() + .cursor_pointer() + }) + .hover(|s| s.bg(rgb(0xf1f5f9))) + .focus_visible(|s| s.border_2().border_color(rgb(0x6366f1))) +} + +fn primary_button(element: E) -> E { + element + .style(|s| { + s.px_3() + .py_1p5() + .rounded_md() + .bg(rgb(0x4f46e5)) + .text_color(Rgba::WHITE) + .text_sm() + .font_medium() + .cursor_pointer() + }) + .hover(|s| s.bg(rgb(0x4338ca))) + .focus_visible(|s| s.border_2().border_color(rgb(0xa5b4fc))) +} + +fn icon_button(element: E) -> E { + element + .style(|s| { + s.size_6() + .flex() + .items_center() + .justify_center() + .rounded_md() + .text_color(rgb(0x64748b)) + .cursor_pointer() + }) + .hover(|s| s.bg(rgb(0xf1f5f9))) + .focus_visible(|s| s.border_2().border_color(rgb(0x6366f1))) +} + +/// A popover inside the dialog, which Escape closes without closing the +/// dialog. +fn help_popover() -> impl IntoElement { + popover::root() + .child(icon_button(popover::trigger()).label("Help").child("?")) + .child( + popover::content() + .side(Side::Left) + .side_offset(6.) + .style(|s| { + s.w_56() + .p_3() + .rounded_md() + .bg(rgb(0x0f172a)) + .text_color(Rgba::WHITE) + .text_xs() + }) + .child("Deleted projects can't be recovered, even by support."), + ) +} + +fn main() { + Application::new().run(|cx| { + cx.open_window( + WindowOptions { + title: "Primitives".into(), + size: size(760., 560.), + }, + |_window, cx| cx.new(Primitives::new), + ); + }); +} diff --git a/crates/shikra_headless/src/button.rs b/crates/shikra_headless/src/button.rs new file mode 100644 index 0000000..324aabf --- /dev/null +++ b/crates/shikra_headless/src/button.rs @@ -0,0 +1,60 @@ +//! A button, activated by clicking it, or by pressing Enter or Space while +//! it's focused. +//! +//! Listen for it being activated with +//! [`on_click`](shikra::InteractiveElement::on_click), which handles every +//! way of activating it, including by assistive technologies. +//! +//! ```ignore +//! button::root() +//! .on_click(cx.listener(|this, _, _, cx| this.save(cx))) +//! .child("Save") +//! ``` + +use shikra::{ + App, Bounds, Element, GlobalElementId, InteractiveElement, LayoutId, Role, Window, div, +}; + +use crate::part::{Keys, Part, pressable, use_focus_handle}; + +pub fn root() -> Root { + Root { + part: Part::new(div().role(Role::Button)), + } +} + +pub struct Root { + part: Part, +} + +/// The state of a button, for [`Root::with_state`]. +#[derive(Debug, Clone)] +pub struct ButtonState { + pub disabled: bool, +} + +part!(Root, ButtonState); + +impl Element for Root { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + self.part.with_id(window, |part, window, id| { + let focus = use_focus_handle(window, id, cx); + let disabled = part.div.interactivity().is_disabled(); + part.apply_state(&ButtonState { disabled }); + part.update(|div| pressable(div, &focus, Keys::EnterOrSpace)); + part.div.request_layout(window, cx) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} diff --git a/crates/shikra_headless/src/checkbox.rs b/crates/shikra_headless/src/checkbox.rs new file mode 100644 index 0000000..125d0dd --- /dev/null +++ b/crates/shikra_headless/src/checkbox.rs @@ -0,0 +1,187 @@ +//! A checkbox, which can also be indeterminate, like a "select all" checkbox +//! when only some items are selected. Toggled by clicking it, or by pressing +//! Space while it's focused. +//! +//! ```ignore +//! checkbox::root() +//! .label("Accept the terms") +//! .child(checkbox::indicator().child("✓")) +//! ``` + +use std::rc::Rc; + +use shikra::accesskit::Toggled; +use shikra::{ + App, Bounds, Element, FocusHandle, GlobalElementId, InteractiveElement, LayoutId, Role, Window, + div, +}; + +use crate::part::{Controllable, Keys, Part, pressable, request_empty_layout}; + +/// Whether a checkbox is checked. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CheckedState { + #[default] + Unchecked, + Checked, + /// Neither checked nor unchecked, like a "select all" checkbox when only + /// some items are selected. Checking it makes it checked. + Indeterminate, +} + +impl From for CheckedState { + fn from(checked: bool) -> Self { + if checked { + Self::Checked + } else { + Self::Unchecked + } + } +} + +/// The state of a checkbox, for `with_state`. +#[derive(Debug, Clone)] +pub struct CheckboxState { + pub checked: CheckedState, + pub disabled: bool, +} + +/// The checkbox itself. +pub fn root() -> Root { + Root { + part: Part::new(div().role(Role::CheckBox)), + checked: Controllable::new(CheckedState::Unchecked), + } +} + +/// Shows its children only while the checkbox is checked or indeterminate, +/// like a check mark. Must be inside [`root`]. +pub fn indicator() -> Indicator { + Indicator { + part: Part::new(div()), + } +} + +pub struct Root { + part: Part, + checked: Controllable, +} + +part!(Root, CheckboxState); + +impl Root { + /// Whether the checkbox is checked, controlling it. Update it from + /// [`on_checked_change`](Self::on_checked_change). + pub fn checked(mut self, checked: impl Into) -> Self { + self.checked.control(checked.into()); + self + } + + /// Whether the checkbox starts checked, if it isn't controlled. Defaults + /// to unchecked. + pub fn default_checked(mut self, checked: impl Into) -> Self { + self.checked.default = checked.into(); + self + } + + /// Listens for the user checking or unchecking the checkbox. + pub fn on_checked_change( + mut self, + listener: impl Fn(&CheckedState, &mut Window, &mut App) + 'static, + ) -> Self { + self.checked.on_change(listener); + self + } +} + +/// What a checkbox keeps between frames. +struct Kept { + focus: FocusHandle, + checked: CheckedState, +} + +impl Element for Root { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let checked = &self.checked; + self.part.with_id(window, |part, window, id| { + let state = window.use_state(id, cx, |_, cx| Kept { + focus: cx.focus_handle(), + checked: checked.default, + }); + let (is_checked, set_checked) = checked.resolve(&state, |kept| &mut kept.checked, cx); + let focus = state.read(cx).focus.clone(); + let state = CheckboxState { + checked: is_checked, + disabled: part.div.interactivity().is_disabled(), + }; + part.apply_state(&state); + part.update(|div| { + let toggled = match is_checked { + CheckedState::Unchecked => Toggled::False, + CheckedState::Checked => Toggled::True, + CheckedState::Indeterminate => Toggled::Mixed, + }; + let next = match is_checked { + CheckedState::Checked => CheckedState::Unchecked, + CheckedState::Unchecked | CheckedState::Indeterminate => CheckedState::Checked, + }; + let div = div + .accessibility(|node| node.set_toggled(toggled)) + .on_click(move |_, window, cx| set_checked(next, window, cx)); + pressable(div, &focus, Keys::Space) + }); + window.with_context(Rc::new(state), |window| part.div.request_layout(window, cx)) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} + +pub struct Indicator { + part: Part, +} + +part!(Indicator, CheckboxState); + +impl Element for Indicator { + /// The div's state, if it's shown. + type LayoutState = Option; + + fn request_layout( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Option) { + let state = window + .context::() + .expect("checkbox::indicator must be inside checkbox::root"); + if state.checked == CheckedState::Unchecked { + return (request_empty_layout(window), None); + } + self.part.apply_state(&state); + let (layout_id, id) = self.part.div.request_layout(window, cx); + (layout_id, Some(id)) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut Option, + window: &mut Window, + cx: &mut App, + ) { + if let Some(id) = id { + self.part.div.paint(bounds, id, window, cx); + } + } +} diff --git a/crates/shikra_headless/src/dialog.rs b/crates/shikra_headless/src/dialog.rs new file mode 100644 index 0000000..4e5117f --- /dev/null +++ b/crates/shikra_headless/src/dialog.rs @@ -0,0 +1,408 @@ +//! A modal dialog: a window over the app that must be dealt with before +//! returning to the app, like a confirmation or a form. +//! +//! While it's open, the portal covers the window, so the app underneath can't +//! be clicked, and Tab only moves focus between the elements inside the +//! content. The content takes focus when the dialog opens, and returns it when +//! it closes. It closes when Escape is pressed, or the mouse is pressed +//! outside the content. +//! +//! ```ignore +//! dialog::root() +//! .child(dialog::trigger().child("Edit profile")) +//! .child( +//! dialog::portal() +//! .child(dialog::overlay().style(|s| s.bg(rgba(0x0f172a80)))) +//! .child( +//! dialog::content() +//! .child(dialog::title().child("Edit profile")) +//! .child(dialog::description().child("Changes are saved when you close this.")) +//! .child(/* … */) +//! .child(dialog::close().child("Done")), +//! ), +//! ) +//! ``` + +use std::mem; +use std::rc::Rc; + +use shikra::accesskit::HasPopup; +use shikra::{ + AccessibilityHandle, AnyElement, App, Bounds, Element, FocusHandle, GlobalElementId, + InteractiveElement, IntoElement, LayoutId, ParentElement, Role, Styled, Window, anchored, + deferred, div, point, px, +}; + +use crate::part::{ + Controllable, Keys, Part, PopupFocus, Setter, close_on_escape, close_on_mouse_down_out, + pressable, request_empty_layout, use_focus_handle, +}; + +/// The state of a dialog, for `with_state`. +#[derive(Debug, Clone)] +pub struct DialogState { + pub open: bool, +} + +/// Contains the other parts, and keeps whether the dialog is open. It's a +/// div, laid out like any other. +pub fn root() -> Root { + Root { + part: Part::new(div()), + open: Controllable::new(false), + } +} + +/// Opens the dialog. Must be inside [`root`]. +pub fn trigger() -> Trigger { + Trigger { + part: Part::new(div().role(Role::Button)), + } +} + +/// Covers the window while the dialog is open, drawn on top of everything +/// else with [`deferred`], and centers the content. Must be +/// inside [`root`]. +pub fn portal() -> Portal { + Portal { + part: Part::new(div().style(|s| s.flex().items_center().justify_center())), + } +} + +/// A backdrop behind the content, like a translucent color. Fills the +/// portal. Must be inside [`portal`], before the content. +pub fn overlay() -> Overlay { + Overlay { + part: Part::new(div().style(|s| s.absolute().inset_0())), + } +} + +/// The dialog itself. Must be inside [`portal`]. +pub fn content() -> Content { + Content { + part: Part::new( + div() + .role(Role::Dialog) + .accessibility(|node| node.set_modal()), + ), + } +} + +/// The dialog's title, which assistive technologies name it after. Must be +/// inside [`content`]. +pub fn title() -> Title { + Title { + part: Part::new(div().role(Role::Heading)), + } +} + +/// Describes the dialog to assistive technologies, beyond its title. Must be +/// inside [`content`]. +pub fn description() -> Description { + Description { + part: Part::new(div().role(Role::Paragraph)), + } +} + +/// Closes the dialog. Must be inside [`root`]. +pub fn close() -> Close { + Close { + part: Part::new(div().role(Role::Button)), + } +} + +/// What the parts of a dialog share. +struct DialogContext { + state: DialogState, + set_open: Setter, + trigger_focus: FocusHandle, + content_focus: FocusHandle, + content: AccessibilityHandle, + title: AccessibilityHandle, + description: AccessibilityHandle, +} + +fn context(window: &Window, part: &str) -> Rc { + window + .context::() + .unwrap_or_else(|| panic!("dialog::{part} must be inside dialog::root")) +} + +pub struct Root { + part: Part, + open: Controllable, +} + +part!(Root, DialogState); + +impl Root { + /// Whether the dialog is open, controlling it. Update it from + /// [`on_open_change`](Self::on_open_change). + pub fn open(mut self, open: bool) -> Self { + self.open.control(open); + self + } + + /// Whether the dialog starts open, if it isn't controlled. Defaults to + /// `false`. + pub fn default_open(mut self, open: bool) -> Self { + self.open.default = open; + self + } + + /// Listens for the user opening or closing the dialog. + pub fn on_open_change( + mut self, + listener: impl Fn(&bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.open.on_change(listener); + self + } +} + +/// What a dialog keeps between frames. +struct Kept { + open: bool, + focus: PopupFocus, + trigger_focus: FocusHandle, + content_focus: FocusHandle, + content: AccessibilityHandle, + title: AccessibilityHandle, + description: AccessibilityHandle, +} + +impl Element for Root { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let open = &self.open; + self.part.with_id(window, |part, window, id| { + let state = window.use_state(id, cx, |_, cx| Kept { + open: open.default, + focus: PopupFocus::default(), + trigger_focus: cx.focus_handle(), + content_focus: cx.focus_handle(), + content: AccessibilityHandle::new(), + title: AccessibilityHandle::new(), + description: AccessibilityHandle::new(), + }); + let (is_open, set_open) = open.resolve(&state, |kept| &mut kept.open, cx); + let kept = state.read(cx); + let context = Rc::new(DialogContext { + state: DialogState { open: is_open }, + set_open, + trigger_focus: kept.trigger_focus.clone(), + content_focus: kept.content_focus.clone(), + content: kept.content.clone(), + title: kept.title.clone(), + description: kept.description.clone(), + }); + let content_focus = &context.content_focus; + PopupFocus::update( + &state, + |kept| &mut kept.focus, + is_open, + content_focus, + window, + cx, + ); + + part.apply_state(&context.state); + window.with_context(context, |window| part.div.request_layout(window, cx)) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} + +pub struct Trigger { + part: Part, +} + +part!(Trigger, DialogState); + +impl Element for Trigger { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let context = context(window, "trigger"); + let part = &mut self.part; + part.apply_state(&context.state); + part.update(|div| { + let open = context.state.open; + let set_open = context.set_open.clone(); + let div = div + .controls(&context.content) + .accessibility(|node| { + node.set_expanded(open); + node.set_has_popup(HasPopup::Dialog); + }) + .on_click(move |_, window, cx| set_open(!open, window, cx)); + pressable(div, &context.trigger_focus, Keys::EnterOrSpace) + }); + part.div.request_layout(window, cx) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} + +pub struct Portal { + part: Part, +} + +part!(Portal, DialogState); + +impl Element for Portal { + /// The deferred portal, if the dialog is open. + type LayoutState = Option; + + fn request_layout( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Option) { + let context = context(window, "portal"); + if !context.state.open { + return (request_empty_layout(window), None); + } + + let part = &mut self.part; + part.apply_state(&context.state); + let viewport = window.viewport_size(); + part.update(|div| div.style(|s| s.w(px(viewport.width)).h(px(viewport.height)))); + let div = mem::replace(&mut part.div, div()); + let mut element = deferred( + anchored() + .at(point(0., 0.)) + .avoid_collisions(false) + .child(div), + ) + .into_any_element(); + (element.request_layout(window, cx), Some(element)) + } + + fn paint( + &mut self, + _: Bounds, + element: &mut Option, + window: &mut Window, + cx: &mut App, + ) { + if let Some(element) = element { + element.paint(window, cx); + } + } +} + +/// Defines a part that's shown only while the dialog is open. +macro_rules! shown_while_open { + ($part:ident, $name:literal, |$div:ident, $context:ident| $configure:expr) => { + pub struct $part { + part: Part, + } + + part!($part, DialogState); + + impl Element for $part { + /// The div's state, if the dialog is open. + type LayoutState = Option; + + fn request_layout( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Option) { + let $context = context(window, $name); + if !$context.state.open { + return (request_empty_layout(window), None); + } + self.part.apply_state(&$context.state); + self.part.update(|$div| $configure); + let (layout_id, id) = self.part.div.request_layout(window, cx); + (layout_id, Some(id)) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut Option, + window: &mut Window, + cx: &mut App, + ) { + if let Some(id) = id { + self.part.div.paint(bounds, id, window, cx); + } + } + } + }; +} + +shown_while_open!(Overlay, "overlay", |div, _context| div); + +shown_while_open!(Content, "content", |div, context| { + let set_open = &context.set_open; + div.track_focus(&context.content_focus) + .tab_stop(false) + .trap_focus() + .accessibility_handle(&context.content) + .labelled_by(&context.title) + .described_by(&context.description) + .on_key_down(close_on_escape(set_open.clone())) + .on_mouse_down_out(close_on_mouse_down_out(set_open.clone(), None)) +}); + +shown_while_open!(Title, "title", |div, context| div + .accessibility_handle(&context.title)); + +shown_while_open!(Description, "description", |div, context| div + .accessibility_handle(&context.description)); + +pub struct Close { + part: Part, +} + +part!(Close, DialogState); + +impl Element for Close { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let context = context(window, "close"); + self.part.with_id(window, |part, window, id| { + let focus = use_focus_handle(window, id, cx); + part.apply_state(&context.state); + let set_open = context.set_open.clone(); + part.update(|div| { + let div = div.on_click(move |_, window, cx| set_open(false, window, cx)); + pressable(div, &focus, Keys::EnterOrSpace) + }); + part.div.request_layout(window, cx) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} diff --git a/crates/shikra_headless/src/lib.rs b/crates/shikra_headless/src/lib.rs new file mode 100644 index 0000000..4a47122 --- /dev/null +++ b/crates/shikra_headless/src/lib.rs @@ -0,0 +1,50 @@ +//! Headless UI primitives for Shikra: components with behavior, keyboard +//! support, and accessibility, but no visuals of their own. Like Radix +//! Primitives or Bits UI. +//! +//! Each primitive is a module of parts, put together like this: +//! +//! ```ignore +//! use shikra_headless::popover; +//! +//! popover::root() +//! .child(popover::trigger().style(|s| s.px_3().py_1()).child("Options")) +//! .child( +//! popover::content() +//! .side(Side::Bottom) +//! .side_offset(4.) +//! .style(|s| s.p_4().rounded_lg().bg(Rgba::WHITE)) +//! .child("…"), +//! ) +//! ``` +//! +//! Parts are divs underneath, so they take every method of [`Styled`] and +//! [`InteractiveElement`], and children. Each part's `with_state` styles it +//! (or changes it in any other way) by the primitive's state, like Radix's +//! `data-state` attributes: +//! +//! ```ignore +//! checkbox::root().with_state(|div, state| { +//! div.style(|s| match state.checked { +//! CheckedState::Unchecked => s.bg(Rgba::WHITE), +//! _ => s.bg(rgb(0x4f46e5)), +//! }) +//! }) +//! ``` +//! +//! A primitive's state, like whether a popover is open, is kept by the +//! primitive unless you control it: pass the value in (like +//! [`popover::Root::open`]) and update it from the change listener (like +//! [`popover::Root::on_open_change`]). +//! +//! [`Styled`]: shikra::Styled +//! [`InteractiveElement`]: shikra::InteractiveElement + +#[macro_use] +mod part; + +pub mod button; +pub mod checkbox; +pub mod dialog; +pub mod popover; +pub mod toggle; diff --git a/crates/shikra_headless/src/part.rs b/crates/shikra_headless/src/part.rs new file mode 100644 index 0000000..c5400d7 --- /dev/null +++ b/crates/shikra_headless/src/part.rs @@ -0,0 +1,282 @@ +//! What the primitives have in common. + +use std::mem; +use std::rc::Rc; + +use shikra::{ + AnchorHandle, App, Div, Entity, FocusHandle, GlobalElementId, InteractiveElement, Key, + KeyDownEvent, LayoutId, MouseDownEvent, Style, Window, div, +}; + +/// Listens for a primitive's value being changed by the user. +pub(crate) type ChangeListener = Rc; + +/// Changes a primitive's value, as the user did. +pub(crate) type Setter = Rc; + +/// Changes a part according to the primitive's state. +type StateFn = Box Div>; + +/// A part's div, and the functions that change it by the primitive's state. +pub(crate) struct Part { + pub(crate) div: Div, + state_fns: Vec>, +} + +impl Part { + pub(crate) fn new(div: Div) -> Self { + Self { + div, + state_fns: Vec::new(), + } + } + + pub(crate) fn push_state_fn(&mut self, f: impl FnOnce(Div, &S) -> Div + 'static) { + self.state_fns.push(Box::new(f)); + } + + /// Changes the div with `f`. + pub(crate) fn update(&mut self, f: impl FnOnce(Div) -> Div) { + self.div = f(mem::replace(&mut self.div, div())); + } + + /// Changes the div with the functions given to `with_state`. + pub(crate) fn apply_state(&mut self, state: &S) { + for f in mem::take(&mut self.state_fns) { + self.update(|div| f(div, state)); + } + } + + /// Runs `f` as an element with the part's ID, for keeping state. + pub(crate) fn with_id( + &mut self, + window: &mut Window, + f: impl FnOnce(&mut Self, &mut Window, GlobalElementId) -> R, + ) -> R { + let id = self.div.interactivity().element_id().cloned(); + window.with_element_id(id.as_ref(), |window, global_id| f(self, window, global_id)) + } +} + +/// Implements `Styled`, `InteractiveElement`, and `ParentElement` for a part +/// with a `part: Part<$state>` field, and adds `with_state`. +macro_rules! part { + ($part:ident, $state:ty) => { + impl shikra::Styled for $part { + fn style_mut(&mut self) -> &mut shikra::Style { + self.part.div.style_mut() + } + } + + impl shikra::InteractiveElement for $part { + fn interactivity(&mut self) -> &mut shikra::Interactivity { + self.part.div.interactivity() + } + } + + impl shikra::ParentElement for $part { + fn extend(&mut self, elements: impl IntoIterator) { + self.part.div.extend(elements); + } + } + + impl $part { + /// Changes the part according to the primitive's state each time + /// it's drawn, like to style it. + pub fn with_state( + mut self, + f: impl FnOnce(shikra::Div, &$state) -> shikra::Div + 'static, + ) -> Self { + self.part.push_state_fn(f); + self + } + } + }; +} + +/// A value the user of a primitive can control, or leave to the primitive. +pub(crate) struct Controllable { + value: Option, + pub(crate) default: T, + on_change: Option>, +} + +impl Controllable { + pub(crate) fn new(default: T) -> Self { + Self { + value: None, + default, + on_change: None, + } + } + + pub(crate) fn control(&mut self, value: T) { + self.value = Some(value); + } + + pub(crate) fn on_change(&mut self, listener: impl Fn(&T, &mut Window, &mut App) + 'static) { + self.on_change = Some(Rc::new(listener)); + } + + /// The current value, and a function to change it. Unless the value is + /// controlled, it's kept in `field` of `state`. + pub(crate) fn resolve( + &self, + state: &Entity, + field: fn(&mut S) -> &mut T, + cx: &mut App, + ) -> (T, Setter) { + let controlled = self.value.clone(); + let value = match &controlled { + Some(value) => value.clone(), + None => state.update(cx, |state, _| field(state).clone()), + }; + let state = state.clone(); + let on_change = self.on_change.clone(); + let setter = Rc::new(move |new_value: T, window: &mut Window, cx: &mut App| { + let current = match &controlled { + Some(value) => value.clone(), + None => state.update(cx, |state, _| field(state).clone()), + }; + if new_value == current { + return; + } + if controlled.is_none() { + state.update(cx, |state, cx| { + *field(state) = new_value.clone(); + cx.notify(); + }); + } + if let Some(on_change) = &on_change { + on_change(&new_value, window, cx); + } + }); + (value, setter) + } +} + +/// Which keys activate a control. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Keys { + EnterOrSpace, + /// Like a checkbox, which Enter doesn't toggle. + Space, +} + +/// Makes `div` a control that's activated by clicking it, or by pressing +/// `keys` while it's focused. Pressing the keys clicks it, so click listeners +/// handle every way of activating it, including by assistive technologies. +/// +/// It's focusable with `focus`, unless it already tracks a focus handle. +pub(crate) fn pressable(mut div: Div, focus: &FocusHandle, keys: Keys) -> Div { + let focus = div + .interactivity() + .focus_handle() + .cloned() + .unwrap_or_else(|| focus.clone()); + div.track_focus(&focus) + .on_key_down(move |event, window, cx| { + let keystroke = &event.keystroke; + let activates = match keystroke.key { + Key::Enter => keys == Keys::EnterOrSpace, + Key::Space => true, + _ => false, + }; + if activates + && keystroke.modifiers.is_empty() + && !event.is_repeat + && focus.is_focused(window) + { + window.click(&focus, cx); + window.stop_propagation(); + } + }) +} + +/// A focus handle kept by the element with `id`. +pub(crate) fn use_focus_handle( + window: &mut Window, + id: GlobalElementId, + cx: &mut App, +) -> FocusHandle { + let handle = window.use_state(id, cx, |_, cx| cx.focus_handle()); + handle.read(cx).clone() +} + +/// Lays out nothing, for a part that isn't shown, like a closed popover's +/// content. +pub(crate) fn request_empty_layout(window: &mut Window) -> LayoutId { + window.request_layout(&Style::new().hidden(), &[]) +} + +/// What a popup, like a popover or dialog, keeps to move focus into it when +/// it opens and back out when it closes. +#[derive(Default)] +pub(crate) struct PopupFocus { + was_open: bool, + /// What was focused before the popup opened. + return_to: Option, +} + +impl PopupFocus { + /// Moves focus into the popup's content when it opens, and back to what + /// had it before when it closes, unless the user has moved it elsewhere, + /// like by clicking outside the popup. + pub(crate) fn update( + state: &Entity, + field: fn(&mut S) -> &mut PopupFocus, + open: bool, + content: &FocusHandle, + window: &mut Window, + cx: &mut App, + ) { + let was_open = state.update(cx, |state, _| { + mem::replace(&mut field(state).was_open, open) + }); + if open && !was_open { + let focused = window.focused_handle(); + state.update(cx, |state, _| field(state).return_to = focused); + window.focus_first_within(content); + } else if !open && was_open { + let return_to = state.update(cx, |state, _| field(state).return_to.take()); + let focus_was_inside = window.focused().is_none() || content.contains_focused(window); + if let Some(return_to) = return_to + && focus_was_inside + { + window.focus(&return_to); + } + } + } +} + +/// Closes a popup when Escape is pressed inside it. Stops the key there, so +/// only the innermost popup closes. +pub(crate) fn close_on_escape( + set_open: Setter, +) -> impl Fn(&KeyDownEvent, &mut Window, &mut App) + 'static { + move |event, window, cx| { + let keystroke = &event.keystroke; + if keystroke.key == Key::Escape && keystroke.modifiers.is_empty() { + window.stop_propagation(); + set_open(false, window, cx); + } + } +} + +/// Closes a popup when the mouse is pressed outside it, unless it's pressed +/// on the trigger anchored by `trigger`, which opens and closes the popup +/// itself. +pub(crate) fn close_on_mouse_down_out( + set_open: Setter, + trigger: Option, +) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static { + move |event, window, cx| { + let on_trigger = trigger + .as_ref() + .and_then(AnchorHandle::bounds) + .is_some_and(|bounds| bounds.contains(event.position)); + if !on_trigger { + set_open(false, window, cx); + } + } +} diff --git a/crates/shikra_headless/src/popover.rs b/crates/shikra_headless/src/popover.rs new file mode 100644 index 0000000..b9ec974 --- /dev/null +++ b/crates/shikra_headless/src/popover.rs @@ -0,0 +1,371 @@ +//! Content that pops over the rest of the window next to the button that +//! opened it, like a small form or a color picker. +//! +//! The content takes focus when it opens, and returns it when it closes. It +//! closes when Escape is pressed inside it, or the mouse is pressed outside +//! it. +//! +//! ```ignore +//! popover::root() +//! .child(popover::trigger().child("Share")) +//! .child( +//! popover::content() +//! .side_offset(4.) +//! .child(/* … */) +//! .child(popover::close().label("Close").child("×")), +//! ) +//! ``` + +use std::mem; +use std::rc::Rc; + +use shikra::accesskit::HasPopup; +use shikra::{ + AccessibilityHandle, Align, AnchorHandle, AnyElement, App, Bounds, Element, FocusHandle, + GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement, Role, Side, Window, + anchored, deferred, div, +}; + +use crate::part::{ + Controllable, Keys, Part, PopupFocus, Setter, close_on_escape, close_on_mouse_down_out, + pressable, request_empty_layout, use_focus_handle, +}; + +/// The state of a popover, for `with_state`. +#[derive(Debug, Clone)] +pub struct PopoverState { + pub open: bool, +} + +/// Contains the other parts, and keeps whether the popover is open. It's a +/// div, laid out like any other. +pub fn root() -> Root { + Root { + part: Part::new(div()), + open: Controllable::new(false), + } +} + +/// Opens and closes the popover, which is positioned next to it. Must be +/// inside [`root`]. +pub fn trigger() -> Trigger { + Trigger { + part: Part::new(div().role(Role::Button)), + } +} + +/// What pops over, shown while the popover is open. Must be inside [`root`]. +/// +/// It's drawn on top of the rest of the window with +/// [`deferred`], and positioned next to the trigger with +/// [`anchored`], whose options it takes. +pub fn content() -> Content { + Content { + part: Part::new(div().role(Role::Dialog)), + side: Side::Bottom, + align: Align::Center, + side_offset: 0., + align_offset: 0., + avoid_collisions: true, + collision_padding: 0., + } +} + +/// Closes the popover. Must be inside [`root`]. +pub fn close() -> Close { + Close { + part: Part::new(div().role(Role::Button)), + } +} + +/// What the parts of a popover share. +struct PopoverContext { + state: PopoverState, + set_open: Setter, + trigger_focus: FocusHandle, + content_focus: FocusHandle, + anchor: AnchorHandle, + content: AccessibilityHandle, +} + +fn context(window: &Window, part: &str) -> Rc { + window + .context::() + .unwrap_or_else(|| panic!("popover::{part} must be inside popover::root")) +} + +pub struct Root { + part: Part, + open: Controllable, +} + +part!(Root, PopoverState); + +impl Root { + /// Whether the popover is open, controlling it. Update it from + /// [`on_open_change`](Self::on_open_change). + pub fn open(mut self, open: bool) -> Self { + self.open.control(open); + self + } + + /// Whether the popover starts open, if it isn't controlled. Defaults to + /// `false`. + pub fn default_open(mut self, open: bool) -> Self { + self.open.default = open; + self + } + + /// Listens for the user opening or closing the popover. + pub fn on_open_change( + mut self, + listener: impl Fn(&bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.open.on_change(listener); + self + } +} + +/// What a popover keeps between frames. +struct Kept { + open: bool, + focus: PopupFocus, + trigger_focus: FocusHandle, + content_focus: FocusHandle, + anchor: AnchorHandle, + content: AccessibilityHandle, +} + +impl Element for Root { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let open = &self.open; + self.part.with_id(window, |part, window, id| { + let state = window.use_state(id, cx, |_, cx| Kept { + open: open.default, + focus: PopupFocus::default(), + trigger_focus: cx.focus_handle(), + content_focus: cx.focus_handle(), + anchor: AnchorHandle::new(), + content: AccessibilityHandle::new(), + }); + let (is_open, set_open) = open.resolve(&state, |kept| &mut kept.open, cx); + let kept = state.read(cx); + let context = Rc::new(PopoverContext { + state: PopoverState { open: is_open }, + set_open, + trigger_focus: kept.trigger_focus.clone(), + content_focus: kept.content_focus.clone(), + anchor: kept.anchor.clone(), + content: kept.content.clone(), + }); + let content_focus = &context.content_focus; + PopupFocus::update( + &state, + |kept| &mut kept.focus, + is_open, + content_focus, + window, + cx, + ); + + part.apply_state(&context.state); + window.with_context(context, |window| part.div.request_layout(window, cx)) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} + +pub struct Trigger { + part: Part, +} + +part!(Trigger, PopoverState); + +impl Element for Trigger { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let context = context(window, "trigger"); + let part = &mut self.part; + part.apply_state(&context.state); + part.update(|div| { + let open = context.state.open; + let set_open = context.set_open.clone(); + let div = div + .anchor(&context.anchor) + .controls(&context.content) + .accessibility(|node| { + node.set_expanded(open); + node.set_has_popup(HasPopup::Dialog); + }) + .on_click(move |_, window, cx| set_open(!open, window, cx)); + pressable(div, &context.trigger_focus, Keys::EnterOrSpace) + }); + part.div.request_layout(window, cx) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} + +pub struct Content { + part: Part, + side: Side, + align: Align, + side_offset: f32, + align_offset: f32, + avoid_collisions: bool, + collision_padding: f32, +} + +part!(Content, PopoverState); + +impl Content { + /// Which side of the trigger 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 trigger. Defaults to + /// [`Align::Center`]. + pub fn align(mut self, align: Align) -> Self { + self.align = align; + self + } + + /// The gap between the trigger 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. See + /// [`Anchored::align_offset`](shikra::Anchored::align_offset). + pub fn align_offset(mut self, offset: f32) -> Self { + self.align_offset = offset; + self + } + + /// Whether to flip and slide the content to keep it 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. + pub fn collision_padding(mut self, padding: f32) -> Self { + self.collision_padding = padding; + self + } +} + +impl Element for Content { + /// The deferred content, if the popover is open. + type LayoutState = Option; + + fn request_layout( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Option) { + let context = context(window, "content"); + if !context.state.open { + return (request_empty_layout(window), None); + } + + let part = &mut self.part; + part.apply_state(&context.state); + part.update(|div| { + div.track_focus(&context.content_focus) + .tab_stop(false) + .accessibility_handle(&context.content) + .on_key_down(close_on_escape(context.set_open.clone())) + .on_mouse_down_out(close_on_mouse_down_out( + context.set_open.clone(), + Some(context.anchor.clone()), + )) + }); + + let div = mem::replace(&mut part.div, div()); + let mut element = deferred( + anchored() + .to(&context.anchor) + .side(self.side) + .align(self.align) + .side_offset(self.side_offset) + .align_offset(self.align_offset) + .avoid_collisions(self.avoid_collisions) + .collision_padding(self.collision_padding) + .child(div), + ) + .into_any_element(); + (element.request_layout(window, cx), Some(element)) + } + + fn paint( + &mut self, + _: Bounds, + element: &mut Option, + window: &mut Window, + cx: &mut App, + ) { + if let Some(element) = element { + element.paint(window, cx); + } + } +} + +pub struct Close { + part: Part, +} + +part!(Close, PopoverState); + +impl Element for Close { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let context = context(window, "close"); + self.part.with_id(window, |part, window, id| { + let focus = use_focus_handle(window, id, cx); + part.apply_state(&context.state); + let set_open = context.set_open.clone(); + part.update(|div| { + let div = div.on_click(move |_, window, cx| set_open(false, window, cx)); + pressable(div, &focus, Keys::EnterOrSpace) + }); + part.div.request_layout(window, cx) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} diff --git a/crates/shikra_headless/src/toggle.rs b/crates/shikra_headless/src/toggle.rs new file mode 100644 index 0000000..aecb0f2 --- /dev/null +++ b/crates/shikra_headless/src/toggle.rs @@ -0,0 +1,106 @@ +//! A button that can be on or off, like a bold button in a toolbar. +//! +//! ```ignore +//! toggle::root() +//! .label("Bold") +//! .with_state(|div, state| div.style(|s| if state.pressed { s.bg(active) } else { s })) +//! .child("B") +//! ``` + +use shikra::accesskit::Toggled; +use shikra::{ + App, Bounds, Element, FocusHandle, GlobalElementId, InteractiveElement, LayoutId, Role, Window, + div, +}; + +use crate::part::{Controllable, Keys, Part, pressable}; + +pub fn root() -> Root { + Root { + part: Part::new(div().role(Role::Button)), + pressed: Controllable::new(false), + } +} + +pub struct Root { + part: Part, + pressed: Controllable, +} + +/// The state of a toggle, for [`Root::with_state`]. +#[derive(Debug, Clone)] +pub struct ToggleState { + pub pressed: bool, + pub disabled: bool, +} + +part!(Root, ToggleState); + +impl Root { + /// Whether the toggle is on, controlling it. Update it from + /// [`on_pressed_change`](Self::on_pressed_change). + pub fn pressed(mut self, pressed: bool) -> Self { + self.pressed.control(pressed); + self + } + + /// Whether the toggle starts on, if it isn't controlled. Defaults to + /// `false`. + pub fn default_pressed(mut self, pressed: bool) -> Self { + self.pressed.default = pressed; + self + } + + /// Listens for the user turning the toggle on or off. + pub fn on_pressed_change( + mut self, + listener: impl Fn(&bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.pressed.on_change(listener); + self + } +} + +/// What a toggle keeps between frames. +struct Kept { + focus: FocusHandle, + pressed: bool, +} + +impl Element for Root { + type LayoutState = GlobalElementId; + + fn request_layout(&mut self, window: &mut Window, cx: &mut App) -> (LayoutId, GlobalElementId) { + let pressed = &self.pressed; + self.part.with_id(window, |part, window, id| { + let state = window.use_state(id, cx, |_, cx| Kept { + focus: cx.focus_handle(), + pressed: pressed.default, + }); + let (is_pressed, set_pressed) = pressed.resolve(&state, |kept| &mut kept.pressed, cx); + let focus = state.read(cx).focus.clone(); + let disabled = part.div.interactivity().is_disabled(); + part.apply_state(&ToggleState { + pressed: is_pressed, + disabled, + }); + part.update(|div| { + let div = div + .accessibility(|node| node.set_toggled(Toggled::from(is_pressed))) + .on_click(move |_, window, cx| set_pressed(!is_pressed, window, cx)); + pressable(div, &focus, Keys::EnterOrSpace) + }); + part.div.request_layout(window, cx) + }) + } + + fn paint( + &mut self, + bounds: Bounds, + id: &mut GlobalElementId, + window: &mut Window, + cx: &mut App, + ) { + self.part.div.paint(bounds, id, window, cx); + } +} diff --git a/crates/shikra_headless/tests/primitives.rs b/crates/shikra_headless/tests/primitives.rs new file mode 100644 index 0000000..8e1112b --- /dev/null +++ b/crates/shikra_headless/tests/primitives.rs @@ -0,0 +1,396 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use shikra::accesskit::{Node, NodeId, Toggled, TreeUpdate}; +use shikra::test::{TestApp, TestWindow}; +use shikra::{ + AnyElement, App, Context, FocusHandle, InteractiveElement, IntoElement, Key, Modifiers, + ParentElement, Render, Role, Styled, Window, div, point, px, +}; +use shikra_headless::checkbox::CheckedState; +use shikra_headless::{button, checkbox, dialog, popover, toggle}; + +type Log = Rc>>; + +type RenderFn = Box AnyElement>; + +/// A view that renders whatever its function returns. +struct View(RenderFn); + +impl Render for View { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + (self.0)(window, cx) + } +} + +fn open( + app: &TestApp, + render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, +) -> TestWindow { + app.open_window(|_, cx| cx.new(|_| View(Box::new(render)))) +} + +fn logger( + log: &Log, + name: &'static str, +) -> impl Fn(&T, &mut Window, &mut App) + 'static { + let log = log.clone(); + move |value, _, _| log.borrow_mut().push(format!("{name}: {value:?}")) +} + +/// A 100×40 box at the top left of the window, followed by the next one to +/// its right, and so on. +fn sized(element: E) -> E { + element.style(|s| s.w(px(100.)).h(px(40.))) +} + +fn node_with_role(tree: &TreeUpdate, role: Role) -> Option<(NodeId, &Node)> { + tree.nodes + .iter() + .find(|(_, node)| node.role() == role) + .map(|(id, node)| (*id, node)) +} + +fn named<'a>(tree: &'a TreeUpdate, label: &str) -> &'a Node { + tree.nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.label() == Some(label)) + .unwrap() +} + +fn focus_handles(app: &TestApp, count: usize) -> Vec { + app.update(|cx| (0..count).map(|_| cx.focus_handle()).collect()) +} + +#[test] +fn buttons_are_clicked_with_the_mouse_enter_and_space() { + let app = TestApp::new(); + let log = Log::default(); + let disabled = Rc::new(RefCell::new(false)); + let window = open(&app, { + let log = log.clone(); + let disabled = disabled.clone(); + move |_, _| { + let log = log.clone(); + sized(button::root()) + .disabled(*disabled.borrow()) + .on_click(move |_, _, _| log.borrow_mut().push("click".into())) + .child("Save") + .into_any_element() + } + }); + + window.click(point(50., 20.)); + window.press(Key::Enter); + window.press(Key::Space); + // Enter with a modifier is a shortcut, not a press. + window.set_modifiers(Modifiers { + control: true, + ..Default::default() + }); + window.press(Key::Enter); + window.set_modifiers(Modifiers::default()); + assert_eq!(log.take(), ["click", "click", "click"]); + + let tree = window.accessibility_tree(); + let (_, button) = node_with_role(&tree, Role::Button).unwrap(); + assert_eq!(button.label(), Some("Save")); + + *disabled.borrow_mut() = true; + window.update(|window, _| window.refresh()); + window.click(point(50., 20.)); + window.press(Key::Enter); + assert_eq!(log.take(), Vec::::new()); + let tree = window.accessibility_tree(); + assert!(node_with_role(&tree, Role::Button).unwrap().1.is_disabled()); +} + +#[test] +fn uncontrolled_toggles_keep_their_state() { + let app = TestApp::new(); + let log = Log::default(); + let window = open(&app, { + let log = log.clone(); + move |_, _| { + sized(toggle::root()) + .on_pressed_change(logger(&log, "pressed")) + .child("B") + .into_any_element() + } + }); + let toggled = |window: &TestWindow| { + let tree = window.accessibility_tree(); + node_with_role(&tree, Role::Button).unwrap().1.toggled() + }; + + assert_eq!(toggled(&window), Some(Toggled::False)); + window.click(point(50., 20.)); + assert_eq!(toggled(&window), Some(Toggled::True)); + window.press(Key::Space); + assert_eq!(toggled(&window), Some(Toggled::False)); + assert_eq!(log.take(), ["pressed: true", "pressed: false"]); +} + +#[test] +fn controlled_toggles_only_change_when_told_to() { + let app = TestApp::new(); + let log = Log::default(); + let window = open(&app, { + let log = log.clone(); + move |_, _| { + sized(toggle::root()) + .pressed(false) + .on_pressed_change(logger(&log, "pressed")) + .with_state(|div, state| div.label(format!("pressed: {}", state.pressed))) + .into_any_element() + } + }); + + window.click(point(50., 20.)); + window.click(point(50., 20.)); + // It asks to be pressed each time, but stays unpressed. + assert_eq!(log.take(), ["pressed: true", "pressed: true"]); + let tree = window.accessibility_tree(); + let (_, node) = node_with_role(&tree, Role::Button).unwrap(); + assert_eq!(node.label(), Some("pressed: false")); +} + +#[test] +fn checkboxes_toggle_with_space_and_show_their_indicator() { + let app = TestApp::new(); + let log = Log::default(); + let window = open(&app, { + let log = log.clone(); + move |_, _| { + sized(checkbox::root()) + .default_checked(CheckedState::Indeterminate) + .on_checked_change(logger(&log, "checked")) + .label("Select all") + .child( + checkbox::indicator() + .role(Role::Image) + .with_state(|div, state| { + div.label(match state.checked { + CheckedState::Indeterminate => "dash", + _ => "check", + }) + }), + ) + .into_any_element() + } + }); + let state = |window: &TestWindow| { + let tree = window.accessibility_tree(); + let toggled = node_with_role(&tree, Role::CheckBox).unwrap().1.toggled(); + let indicator = + node_with_role(&tree, Role::Image).map(|(_, node)| node.label().unwrap().to_string()); + (toggled, indicator) + }; + + assert_eq!(state(&window), (Some(Toggled::Mixed), Some("dash".into()))); + window.click(point(50., 20.)); + assert_eq!(state(&window), (Some(Toggled::True), Some("check".into()))); + // Enter doesn't toggle checkboxes. + window.press(Key::Enter); + assert_eq!(state(&window), (Some(Toggled::True), Some("check".into()))); + window.press(Key::Space); + assert_eq!(state(&window), (Some(Toggled::False), None)); + assert_eq!(log.take(), ["checked: Checked", "checked: Unchecked"]); +} + +/// A popover whose trigger is at (0, 0), next to a focusable box at +/// (100, 0), with a close button in its content. +fn open_popover(app: &TestApp) -> (TestWindow, FocusHandle, FocusHandle, FocusHandle) { + let handles = focus_handles(app, 3); + let [trigger, close, other] = [0, 1, 2].map(|ix| handles[ix].clone()); + let window = open(app, { + let (trigger, close, other) = (trigger.clone(), close.clone(), other.clone()); + move |_, _| { + div() + .style(|s| s.size_full().flex().items_start()) + .child( + popover::root() + .child( + sized(popover::trigger()) + .track_focus(&trigger) + .child("Open"), + ) + .child( + popover::content() + .style(|s| s.p_2()) + .child(sized(popover::close()).track_focus(&close).child("Close")), + ), + ) + .child(sized(div()).track_focus(&other)) + .into_any_element() + } + }); + (window, trigger, close, other) +} + +#[test] +fn popovers_open_and_close_with_focus_moving_in_and_back() { + let app = TestApp::new(); + let (window, trigger, close, _) = open_popover(&app); + let expanded = |window: &TestWindow| { + let tree = window.accessibility_tree(); + let trigger = named(&tree, "Open"); + ( + trigger.is_expanded(), + node_with_role(&tree, Role::Dialog).is_some(), + ) + }; + assert_eq!(expanded(&window), (Some(false), false)); + + window.click(point(50., 20.)); + assert_eq!(expanded(&window), (Some(true), true)); + // Focus moved to the first thing in the content. + assert_eq!(window.focused(), Some(close.id())); + + window.press(Key::Escape); + assert_eq!(expanded(&window), (Some(false), false)); + assert_eq!(window.focused(), Some(trigger.id())); + + // With the keyboard, and closing with the close button. + window.press(Key::Enter); + assert_eq!(window.focused(), Some(close.id())); + window.press(Key::Enter); + assert_eq!(expanded(&window), (Some(false), false)); + assert_eq!(window.focused(), Some(trigger.id())); + + // Clicking the trigger closes it again, rather than closing it as a + // click outside and then reopening it. + window.click(point(50., 20.)); + window.click(point(50., 20.)); + assert_eq!(expanded(&window), (Some(false), false)); +} + +#[test] +fn popovers_close_when_the_mouse_is_pressed_outside_them() { + let app = TestApp::new(); + let (window, _, close, other) = open_popover(&app); + + window.click(point(50., 20.)); + assert_eq!(window.focused(), Some(close.id())); + // The content is below the trigger, so this is inside it, next to the + // close button. + window.click(point(3., 43.)); + let tree = window.accessibility_tree(); + assert!(node_with_role(&tree, Role::Dialog).is_some()); + + // Focus goes where the mouse was pressed, rather than back to the + // trigger. + window.click(point(150., 20.)); + let tree = window.accessibility_tree(); + assert!(node_with_role(&tree, Role::Dialog).is_none()); + assert_eq!(window.focused(), Some(other.id())); +} + +#[test] +fn popovers_are_controlled_by_their_trigger_for_assistive_technologies() { + let app = TestApp::new(); + let (window, _, _, _) = open_popover(&app); + window.click(point(50., 20.)); + let tree = window.accessibility_tree(); + let (content, _) = node_with_role(&tree, Role::Dialog).unwrap(); + let trigger = named(&tree, "Open"); + assert_eq!(trigger.controls(), [content]); +} + +/// A dialog whose trigger is at (0, 0), next to a button at (100, 0), with +/// two buttons in its content. +fn open_dialog(app: &TestApp, log: &Log) -> (TestWindow, [FocusHandle; 4]) { + let handles = focus_handles(app, 4); + let handles = [0, 1, 2, 3].map(|ix| handles[ix].clone()); + let window = open(app, { + let [trigger, first, second, other] = handles.clone(); + let log = log.clone(); + move |_, _| { + let log = log.clone(); + div() + .style(|s| s.size_full().flex().items_start()) + .child( + dialog::root() + .child(sized(dialog::trigger()).track_focus(&trigger).child("Edit")) + .child( + dialog::portal().child(dialog::overlay()).child( + dialog::content() + .style(|s| s.w(px(300.)).h(px(200.))) + .child(dialog::title().child("Edit profile")) + .child(dialog::description().child("Make changes here.")) + .child(sized(button::root()).track_focus(&first).child("Save")) + .child( + sized(dialog::close()).track_focus(&second).child("Cancel"), + ), + ), + ), + ) + .child( + sized(button::root()) + .track_focus(&other) + .on_click(move |_, _, _| log.borrow_mut().push("other".into())) + .child("Other"), + ) + .into_any_element() + } + }); + (window, handles) +} + +#[test] +fn dialogs_trap_focus_and_block_the_window_while_open() { + let app = TestApp::new(); + let log = Log::default(); + let (window, [trigger, first, second, _]) = open_dialog(&app, &log); + let is_open = |window: &TestWindow| { + let tree = window.accessibility_tree(); + node_with_role(&tree, Role::Dialog).is_some() + }; + + window.press(Key::Tab); + assert_eq!(window.focused(), Some(trigger.id())); + window.press(Key::Enter); + assert!(is_open(&window)); + assert_eq!(window.focused(), Some(first.id())); + + // Tab cycles inside the dialog. + window.press(Key::Tab); + assert_eq!(window.focused(), Some(second.id())); + window.press(Key::Tab); + assert_eq!(window.focused(), Some(first.id())); + + // The portal covers the button next to the trigger. + window.click(point(150., 20.)); + assert_eq!(log.take(), Vec::::new()); + assert!(!is_open(&window)); + assert_eq!(window.focused(), Some(trigger.id())); + + // Now the button can be clicked. + window.click(point(150., 20.)); + assert_eq!(log.take(), ["other"]); +} + +#[test] +fn dialogs_are_named_after_their_titles() { + let app = TestApp::new(); + let (window, _) = open_dialog(&app, &Log::default()); + window.click(point(50., 20.)); + + let tree = window.accessibility_tree(); + let (_, dialog) = node_with_role(&tree, Role::Dialog).unwrap(); + assert!(dialog.is_modal()); + let (title, _) = node_with_role(&tree, Role::Heading).unwrap(); + let (description, _) = node_with_role(&tree, Role::Paragraph).unwrap(); + assert_eq!(dialog.labelled_by(), [title]); + assert_eq!(dialog.described_by(), [description]); + // Named and described directly too, for platforms that don't follow + // the relations. + assert_eq!(dialog.label(), Some("Edit profile")); + assert_eq!(dialog.description(), Some("Make changes here.")); + + // Escape closes it, returning focus to the trigger. + window.press(Key::Escape); + let tree = window.accessibility_tree(); + assert!(node_with_role(&tree, Role::Dialog).is_none()); +} -- knotmirror 0.0.1