GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(keyboard): a modal Dialog and a RadioGroup (accessibility RFC phase 3)
Dialog is a raised plate (the menu's material) around a lasso of members the
host lays out. Opening it makes it modal through the context
(UiContext::open_modal / close_modal): the Tab walk is trapped among its
members, every widget outside reads as covered so neither a press nor a hover
reaches it, focus moves in and is given back on close, and the members are
linked as its children so a reader sees a modal Dialog node holding them. It
paints its plate, an optional backdrop, and its members on the plate.
RadioGroup is one Tab stop whose arrows move the choice (wrapping, Home /
End); each option is the check box's well at a full corner, the chosen one
holding a lit bead (a disc on a flat host). A reader sees a radio group of
radio buttons, each clickable: widgets can now show parts of themselves as
nodes (Input::a11y_items, a11y::A11yItem, Input::a11y_select_item).
The demo has an Options... dialog with a radio group and OK / Cancel. Checked
in a shadow: Tab cycles its three stops only, a click behind it does nothing,
Escape cancels, Space on OK keeps the choice; over AT-SPI the dialog holds the
group's radio buttons, and a click on one chooses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 19 ++-
docs/rfc-accessibility-locale.md | 15 ++
src/a11y.rs | 106 +++++++++++-
src/backend/a11y_unix.rs | 18 ++
src/context.rs | 75 +++++++-
src/main.rs | 131 +++++++++++++-
src/widget/container/dialog.rs | 306 +++++++++++++++++++++++++++++++++
src/widget/container/mod.rs | 2 +
src/widget/input/checkbox.rs | 2 +-
src/widget/input/mod.rs | 2 +
src/widget/input/radio_group.rs | 361 +++++++++++++++++++++++++++++++++++++++
src/widget/mod.rs | 14 +-
src/widget/model.rs | 21 +++
src/widget/owned.rs | 2 +
14 files changed, 1059 insertions(+), 15 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ecfb76f..fda7876 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1189,6 +1189,19 @@ widget does not fit one of them, say so rather than stretching a word.
within `snap` of the plate's edge take the edge one padding in and corners
on the plate's corner follow it concentrically: on a narrow pane a group is
that pane's inset lining, on a wide one a lasso. A group never hits.
+- **A dialog is a raised plate around a lasso** (`widget::Dialog`, since
+ 2026-10-08). Like a group it owns nothing: the host lays its members out,
+ and its plate is their padded hull with a title band above, in the menu's
+ material. `open(ctx, members)` makes it MODAL through the context
+ (`UiContext::open_modal`): the Tab walk is trapped among the members, every
+ widget outside reads as covered (`is_coordinate_covered`, which every hit
+ test and hover asks) so nothing behind takes a press, focus moves in and is
+ given back on `close`, and the members are linked as its children, so the
+ accessibility tree nests them under a modal `Dialog` node. Paint the dialog,
+ not its members (it paints them on its plate), after everything it covers;
+ `set_backdrop` dims the window. Escape is the host's to read. Hide the
+ members while it is closed, or they are stops nobody can see. The demo's
+ Options… dialog is the pattern.
- **Segments are plates or floors sharing one silhouette, parted by seams.**
A seam is a `Groove` cut across the shared surface, dying into its rolled
edge: Breadcrumb segments, ButtonStrip segments, the ColorSelector's
@@ -1232,7 +1245,8 @@ What this buys, and where the code is heading:
A Checkbox and a Toggle light the rim of their field, as every field is
lit.
Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector, ButtonStrip
- (arrows move the selection between its segment plates) and Breadcrumb
+ (arrows move the selection between its segment plates), RadioGroup (one
+ stop; arrows move the choice, which follows them) and Breadcrumb
(arrows walk its visible segments, Enter navigates) are plates; TextBox,
Spinbox, ColorSelector, KeybindRecorder, TreeList, Slider (a band, but
entered and adjusted in place — arrows step it, Enter opens the readout)
@@ -1717,7 +1731,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
window's `TreeUpdate` — its registered widgets (role, name, value, bounds, actions, focus;
`WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself), the nodes an
app without widgets declares (`Application::accessibility`, `AppNodes`), and an open context
- menu. `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
+ menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
+ (`Input::a11y_items`). `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
`docs/rfc-accessibility-locale.md`, phase 2).
- `ime.rs` — input-method composition shared between the editing widget and the shell:
`Preedit`, the composition and its generation, the reported caret, the reset request
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index 57fbbda..75af33a 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -188,6 +188,21 @@ cce-list, cce-weather, the demo and cce-relief ring their second stop; cce-fonts
two stops were its picker buttons, registered outside picker mode and never drawn, and are
now registered only in it; cce-text-editor has no stops and is unchanged.
+**A modal dialog and a radio group (2026-10-08).** `widget::Dialog` is a raised plate (the
+menu's material) around a lasso of members the host lays out; opening it makes it modal
+through the context (`UiContext::open_modal` / `close_modal`): the Tab walk is trapped among
+its members, every widget outside reads as covered so neither a press nor a hover reaches
+it, focus moves in and is given back on close, and a reader sees a modal `Dialog` node
+holding the members. `widget::RadioGroup` is one Tab stop whose arrows move the choice;
+each option is the check box's well at a full corner, the chosen one holding a lit bead.
+To a reader it is a radio group of radio buttons, each clickable, which needed a way for a
+widget to show parts of itself as nodes (`Input::a11y_items`, `a11y::A11yItem`, clicked
+through `Input::a11y_select_item`). The demo's Options… dialog holds a radio group and
+OK / Cancel; in a shadow: Tab cycles its three stops only, a click behind it does nothing,
+Escape cancels, Space on OK keeps the choice; over AT-SPI the dialog holds the group's
+three radio buttons, and a click on one chooses it. The tooltip that doubles as the
+accessible description is not done: no tooltips yet, by decision.
+
- `plate_navigation` defaults to true; an app that routes Tab itself (a terminal, a web
view) opts OUT.
- A modal dialog widget that traps focus, a tooltip that doubles as the accessible
diff --git a/src/a11y.rs b/src/a11y.rs
index 8923ea9..60c0df0 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -41,7 +41,37 @@ pub const WINDOW: NodeId = NodeId(0);
/// The widget a node is, when it is a widget's ([`node_id`]'s inverse).
pub fn widget_of(node: NodeId) -> Option<WidgetId> {
- (node.0 > 0 && node.0 < APP_BASE).then(|| WidgetId((node.0 - 1) as usize))
+ (node.0 > 0 && node.0 < ITEM_BASE).then(|| WidgetId((node.0 - 1) as usize))
+}
+
+/// A part of a widget a screen reader sees as a node of its own, under the widget's node:
+/// a radio group's radio buttons (`Input::a11y_items`). A click on it is
+/// `Input::a11y_select_item`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct A11yItem {
+ pub role: Role,
+ pub label: String,
+ /// Where it is, in window px.
+ pub rect: crate::scene::layout::Rect,
+ /// Its checked state, for a radio button or a check item.
+ pub toggled: Option<bool>,
+ /// Whether the keyboard is on it: the widget's focus, given to this item.
+ pub focused: bool,
+}
+
+/// Where widgets' items begin: `ITEM_BASE + (widget id << 16) + index`, below the apps' own
+/// nodes and above every widget's.
+const ITEM_BASE: u64 = 1 << 60;
+
+/// The node of item `idx` of widget `id` ([`A11yItem`]).
+pub fn item_id(id: WidgetId, idx: usize) -> NodeId {
+ NodeId(ITEM_BASE + ((id.0 as u64) << 16) + (idx as u64 & 0xffff))
+}
+
+/// The widget and item a node is, when it is an item's ([`item_id`]'s inverse).
+pub fn item_of(node: NodeId) -> Option<(WidgetId, usize)> {
+ (node.0 >= ITEM_BASE && node.0 < APP_BASE)
+ .then(|| (WidgetId(((node.0 - ITEM_BASE) >> 16) as usize), ((node.0 - ITEM_BASE) & 0xffff) as usize))
}
/// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
@@ -188,6 +218,8 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
pub fn key_for(w: &dyn WidgetHost, action: Action) -> Option<NamedKey> {
let role = role_for(w.type_name(), w.focus_role(), w.a11y_role());
match (action, role) {
+ // A radio group is clicked through its radio buttons (`A11yItem`), not as a whole.
+ (Action::Click, Role::RadioGroup) => None,
(Action::Click, _) if w.focus_role() == FocusRole::Plate => Some(NamedKey::Space),
(Action::Increment, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowRight),
(Action::Decrement, Role::Slider) if w.type_name() != "Slider2D" => Some(NamedKey::ArrowLeft),
@@ -255,10 +287,34 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
let mut nodes = Vec::with_capacity(widgets.len() + 1);
let mut top: Vec<(WidgetId, f32, f32)> = Vec::new();
+ // The keyboard's node: a focused widget's, or its focused item's.
+ let mut item_focus: Option<NodeId> = None;
for (id, w) in &widgets {
- let children: Vec<NodeId> =
- ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id).collect();
- nodes.push((node_id(*id), widget_node(*w, children)));
+ // The widget's own items come first, then its linked children.
+ let mut children: Vec<NodeId> = Vec::new();
+ for (i, item) in w.a11y_items().into_iter().enumerate() {
+ let nid = item_id(*id, i);
+ let mut node = Node::new(item.role);
+ node.set_label(item.label);
+ let r = item.rect;
+ node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
+ if let Some(on) = item.toggled {
+ node.set_toggled(Toggled::from(on));
+ }
+ node.add_action(Action::Click);
+ if item.focused && ctx.focused_widget == Some(*id) {
+ item_focus = Some(nid);
+ }
+ nodes.push((nid, node));
+ children.push(nid);
+ }
+ children.extend(ctx.tree.child_ids(*id).into_iter().filter(|c| shown.contains(c)).map(node_id));
+ let mut node = widget_node(*w, children);
+ // The open modal is said to be one: a reader keeps to it.
+ if ctx.modal_owner() == Some(*id) {
+ node.set_modal();
+ }
+ nodes.push((node_id(*id), node));
let parented = ctx.tree.parent_id(*id).is_some_and(|p| shown.contains(&p));
if !parented {
let (x, y, _, _) = w.rect();
@@ -284,6 +340,7 @@ pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f
let mut focus = app
.focus
+ .or(item_focus)
.or_else(|| ctx.focused_widget.filter(|id| shown.contains(id)).map(node_id))
.unwrap_or(WINDOW);
if let Some(menu_focus) = push_context_menu(&mut nodes) {
@@ -356,6 +413,47 @@ mod tests {
use super::*;
use crate::widget::{Button, Checkbox, Owned, RangeSlider, Slider, Spinbox, TextBox};
+ /// An open dialog is a modal `Dialog` node holding its members; a radio group in it is a
+ /// `RadioGroup` of `RadioButton` items, the chosen one checked and, with the group
+ /// focused, the keyboard's node.
+ #[test]
+ fn a_dialog_is_modal_and_a_radio_group_is_its_radio_buttons() {
+ use crate::widget::{Dialog, RadioGroup};
+ let mut ctx = UiContext::new();
+ let mut size = Owned::new(RadioGroup::new(["Small", "Medium", "Large"]).with_selected(1));
+ size.set_rect(120.0, 120.0, 200.0, 100.0);
+ let mut ok = Owned::new(Button::new(120.0, 240.0, 80.0, 24.0).with_label("OK"));
+ let mut dialog = Owned::new(Dialog::new().with_label("Size"));
+ ctx.register_host(&mut dialog);
+ ctx.register_host(&mut size);
+ ctx.register_host(&mut ok);
+ dialog.open(&mut ctx, vec![size.base().id(), ok.base().id()]);
+
+ let update = tree_update(&ctx, "App", 1.0);
+ let d = node(&update, node_id(dialog.base().id()));
+ assert_eq!((d.role(), d.label(), d.is_modal()), (Role::Dialog, Some("Size"), true));
+ assert_eq!(d.children(), &[node_id(size.base().id()), node_id(ok.base().id())]);
+ assert_eq!(node(&update, WINDOW).children(), &[node_id(dialog.base().id())], "the members hang from it");
+
+ let g = node(&update, node_id(size.base().id()));
+ assert_eq!(g.role(), Role::RadioGroup);
+ assert!(!g.supports_action(Action::Click), "clicked through its buttons");
+ let buttons: Vec<(Option<&str>, Option<Toggled>)> = g
+ .children()
+ .iter()
+ .map(|c| node(&update, *c))
+ .inspect(|b| assert!(b.role() == Role::RadioButton && b.supports_action(Action::Click)))
+ .map(|b| (b.label(), b.toggled()))
+ .collect();
+ assert_eq!(
+ buttons,
+ [(Some("Small"), Some(Toggled::False)), (Some("Medium"), Some(Toggled::True)), (Some("Large"), Some(Toggled::False))]
+ );
+ assert_eq!(update.focus, item_id(size.base().id(), 1), "the dialog's first stop, on its chosen button");
+ assert_eq!(item_of(item_id(size.base().id(), 2)), Some((size.base().id(), 2)));
+ assert_eq!(widget_of(item_id(size.base().id(), 2)), None, "an item is not a widget");
+ }
+
/// A node offers the actions the Linux adapter can carry out, and each by the key a
/// keyboard user presses: Space on a plate, Right / Left on a slider or a range,
/// Up / Down on a spin button, nothing on a text box.
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
index c073327..ee66de8 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -74,6 +74,8 @@ pub enum Acted {
/// (`WidgetHost::a11y_set_value`), marked changed for the app's `take_change`. An app
/// that drains changes in `tick` sees it this turn; one that drains them only in its
/// input handlers sees it at the next input.
+/// - **Click** on a widget's item (a radio button, `a11y::A11yItem`) does what a press on it
+/// does (`WidgetHost::a11y_select_item`) and puts the keyboard on its widget.
/// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
/// the row's action exactly as a pointer would.
///
@@ -94,6 +96,22 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
cm::mouse_input(crate::widget::MouseButton::Left, crate::widget::ElementState::Pressed, x, y, app.ui_context_mut());
return Acted::Changed;
}
+ if let Some((id, idx)) = crate::a11y::item_of(request.target_node) {
+ // A click on a widget's item (a radio button): what a press on it does, and the
+ // keyboard goes to its widget, as after a press.
+ if request.action != Action::Click {
+ return Acted::Nothing;
+ }
+ let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
+ if !ctx.get_widget_mut(id).is_some_and(|w| w.a11y_select_item(idx)) {
+ return Acted::Nothing;
+ }
+ if ctx.focused_widget != Some(id) {
+ ctx.set_focused_id(id);
+ app.focus_stepped();
+ }
+ return Acted::Changed;
+ }
let Some(id) = crate::a11y::widget_of(request.target_node) else { return Acted::Nothing };
let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
if request.action == Action::SetValue {
diff --git a/src/context.rs b/src/context.rs
index aa200fe..73f78c5 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -63,6 +63,14 @@ fn warn_unowned(type_name: &'static str) {
}
}
+/// One open modal: who opened it, what is inside it, and where focus was before.
+#[derive(Debug, Clone)]
+struct ModalScope {
+ owner: WidgetId,
+ members: Vec<WidgetId>,
+ restore: Option<WidgetId>,
+}
+
pub struct UiContext {
/// The widget tree + registry, consolidated into one generational store (Phase 1b of the
/// core rebuild). Replaces the former `layout_tree` + `widget_registry` maps; see
@@ -73,6 +81,10 @@ pub struct UiContext {
pub focused_widget: Option<WidgetId>,
/// Open-popover registrations, id-keyed like focus (Phase 6bc slice 2).
pub active_popovers: Vec<WidgetId>,
+ /// Open modals, innermost last ([`UiContext::open_modal`]): while one is open the Tab
+ /// walk visits only its members, and every widget outside it reads as covered, so no
+ /// press or hover reaches what lies behind.
+ modals: Vec<ModalScope>,
/// Memo for `is_coordinate_covered` at a single cursor position: the ids of
/// every widget whose popover rect contains it. That query scans the entire
/// registry, and `hit_test` calls it — so dispatching one PointerMove to N
@@ -115,6 +127,7 @@ impl UiContext {
tree: crate::scene::WidgetTree::new(),
focused_widget: None,
active_popovers: Vec::new(),
+ modals: Vec::new(),
covered_cache: std::cell::RefCell::new((None, Vec::new())),
hover_state: HoverState::new(),
cursor_pos: (0.0, 0.0),
@@ -591,6 +604,59 @@ impl UiContext {
}
}
+ /// Open a modal owned by `owner` (a `Dialog`) around `members`: the Tab walk is trapped
+ /// among them (and their embedded children), every widget outside reads as covered
+ /// ([`UiContext::is_coordinate_covered`], which every hit test and hover asks), and focus
+ /// moves to the first stop inside, remembering where it was. Modals nest; the innermost
+ /// rules. Opening one already open replaces its members and keeps its focus memory.
+ pub fn open_modal(&mut self, owner: WidgetId, members: Vec<WidgetId>) {
+ self.invalidate_coverage_cache();
+ if let Some(scope) = self.modals.iter_mut().find(|m| m.owner == owner) {
+ scope.members = members;
+ return;
+ }
+ let restore = self.focused_widget;
+ self.modals.push(ModalScope { owner, members, restore });
+ match self.focus_stops().first() {
+ Some(&first) => self.set_focused_id(first),
+ None => self.clear_focus(),
+ }
+ }
+
+ /// Close the modal `owner` opened, giving focus back to what had it before (if that is
+ /// still registered and reachable), else to nothing.
+ pub fn close_modal(&mut self, owner: WidgetId) {
+ let Some(i) = self.modals.iter().position(|m| m.owner == owner) else { return };
+ let scope = self.modals.remove(i);
+ self.invalidate_coverage_cache();
+ let inside = |ctx: &Self, id: WidgetId| ctx.tree.is_registered(id) && ctx.in_modal_scope(id);
+ match scope.restore.filter(|&id| inside(self, id)) {
+ Some(id) => self.set_focused_id(id),
+ None => self.clear_focus(),
+ }
+ }
+
+ /// The owner of the innermost open modal.
+ pub fn modal_owner(&self) -> Option<WidgetId> {
+ self.modals.last().map(|m| m.owner)
+ }
+
+ /// Whether `id` may take input: true with no modal open; with one, true for the modal's
+ /// owner, its members and anything under them in the tree.
+ pub fn in_modal_scope(&self, id: WidgetId) -> bool {
+ let Some(scope) = self.modals.last() else { return true };
+ let mut at = Some(id);
+ // Bounded: a malformed parent chain must not hang the walk.
+ for _ in 0..64 {
+ let Some(cur) = at else { return false };
+ if cur == scope.owner || scope.members.contains(&cur) {
+ return true;
+ }
+ at = self.tree.parent_id(cur);
+ }
+ false
+ }
+
pub fn clear_if_matches(&mut self, w: &dyn WidgetHost) {
if self.focused_widget == Some(w.base().id()) {
self.focused_widget = None;
@@ -613,7 +679,7 @@ impl UiContext {
continue;
}
let w = unsafe { &*ptr };
- if w.focus_role() == crate::widget::FocusRole::None || !w.visible() {
+ if w.focus_role() == crate::widget::FocusRole::None || !w.visible() || !self.in_modal_scope(id) {
continue;
}
let (x, y, width, height) = w.rect();
@@ -957,12 +1023,17 @@ impl UiContext {
/// Whether `(px, py)` is covered by an open popover or a popover-carrying widget other
/// than `query_id` (the querying widget excludes itself). Every widget has a base id
/// now (the flip) — the old `WidgetId(0)` no-base sentinel is gone.
- /// Is `(px, py)` covered by some widget's popover rect other than `query_id`?
+ /// Is `(px, py)` covered by some widget's popover rect other than `query_id` — or does
+ /// `query_id` lie behind an open modal ([`UiContext::open_modal`]), where everything is?
///
/// The covering set depends only on the point, so it is computed once and
/// memoized; `query_id` is applied afterwards as an exclusion. See the
/// `covered_at` field for why the previous per-call registry scan mattered.
pub fn is_coordinate_covered(&self, query_id: WidgetId, px: f32, py: f32) -> bool {
+ // Behind an open modal everything is covered, wherever the point is.
+ if !self.in_modal_scope(query_id) {
+ return true;
+ }
let mut cache = self.covered_cache.borrow_mut();
if cache.0 != Some((px, py)) {
cache.1.clear();
diff --git a/src/main.rs b/src/main.rs
index 41d22f4..1f90b3d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,7 +15,11 @@
//! `UiContext::propagate_event` per widget root. The router owns press hit-gating,
//! Enter/Leave synthesis, drag-target recording, and KeyInput-to-focused delivery;
//! the app keeps only state-gated `take_*` plumbing.
-//! 4. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
+//! 4. **A modal dialog is a lasso.** The Options dialog's members are ordinary widgets the app
+//! owns and lays out; [`Dialog`] is the plate under them, and opening it traps the Tab
+//! walk among them and covers the window behind (`UiContext::open_modal`). Closed, its
+//! members are hidden, so they are neither Tab stops nor in the accessibility tree.
+//! 5. **No embedded bases.** Widgets are app-owned values (all [`Adapted`]); the window
//! plate is prims, not a root plate container; popovers draw INTO the frame (there is no popup
//! surface); app state — not any widget tree — is the source of truth.
@@ -27,8 +31,8 @@ use cce_ui::scene::layout::{
};
use cce_ui::scene::paint::{DisplayList, PaintCtx};
use cce_ui::widget::{
- Adapted, Button, Dropdown, ImageView, WidgetHost, WidgetId, ElementState, Event, KeyEvent,
- MouseButton, MouseScrollDelta, Slider, TextBox, Toggle,
+ Adapted, Button, Dialog, Dropdown, ImageView, RadioGroup, WidgetHost, WidgetId, ElementState, Event,
+ KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Slider, TextBox, Toggle,
};
#[derive(Debug, Clone)]
@@ -44,6 +48,10 @@ const TITLE_FONT_SIZE: f32 = 15.0;
const STATUS_FONT_SIZE: f32 = 12.0;
/// Vertical padding on each side of the status band's text line.
const STATUS_BAND_PAD: f32 = 5.0;
+/// The Options dialog's choices.
+const TEXT_SIZES: [&str; 3] = ["Small", "Medium", "Large"];
+/// Its buttons' width.
+const DIALOG_BUTTON_W: f32 = 88.0;
fn text_leaf_height(font_size: f32) -> f32 {
(font_size * 1.2).ceil()
}
@@ -61,12 +69,20 @@ pub(crate) struct DemoApp {
// upload/free stay app-side): Contain letterboxes, Stretch fills.
image_contain: Owned<Adapted<ImageView>>,
image_stretch: Owned<Adapted<ImageView>>,
+ // The Options dialog: a button that opens it, the plate, and what stands on it.
+ options_button: Owned<Adapted<Button>>,
+ dialog: Owned<Adapted<Dialog>>,
+ text_size: Owned<Adapted<RadioGroup>>,
+ dialog_cancel: Owned<Adapted<Button>>,
+ dialog_ok: Owned<Adapted<Button>>,
// ── App state: the source of truth. Widgets are re-asserted from it every rebuild
// (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
toggle_on: bool,
clicks: u32,
status: String,
+ /// The text size chosen in the Options dialog (an index into `TEXT_SIZES`).
+ text_size_choice: usize,
ui_context: cce_ui::context::UiContext,
width: u32,
@@ -74,6 +90,8 @@ pub(crate) struct DemoApp {
scale_factor: f64,
needs_rebuild: bool,
widgets_registered: bool,
+ /// Set while `open_dialog` lays the members out before the dialog is open.
+ dialog_pending: bool,
title_rect: Rect,
status_rect: Rect,
}
@@ -82,7 +100,9 @@ impl DemoApp {
/// The widget root ids, in paint order — what the router dispatches over.
/// `propagate_event` takes a `WidgetId` and resolves it through the registry, so the
/// event paths need no raw pointers and no unsafe self-alias.
- fn root_ids(&self) -> [WidgetId; 7] {
+ fn root_ids(&self) -> [WidgetId; 12] {
+ // While the dialog is open the rest are still dispatched to: the context answers
+ // every one of them "covered", so none takes a press or a hover.
[
self.button.id(),
self.toggle.id(),
@@ -91,9 +111,67 @@ impl DemoApp {
self.theme_dropdown.id(),
self.image_contain.id(),
self.image_stretch.id(),
+ self.options_button.id(),
+ self.dialog.id(),
+ self.text_size.id(),
+ self.dialog_cancel.id(),
+ self.dialog_ok.id(),
]
}
+ /// The dialog's members, in the order Tab walks them.
+ fn dialog_members(&self) -> Vec<WidgetId> {
+ vec![self.text_size.id(), self.dialog_cancel.id(), self.dialog_ok.id()]
+ }
+
+ /// Lay the dialog's members out in the middle of the window, shown while it is open.
+ fn layout_dialog(&mut self) {
+ let open = self.dialog.inner().is_open() || self.dialog_pending;
+ for w in [&mut *self.text_size as &mut dyn WidgetHost, &mut *self.dialog_cancel, &mut *self.dialog_ok] {
+ w.set_visible(open);
+ }
+ if !open {
+ return;
+ }
+ let gap = cce_ui::layout::control_gap();
+ let group_h = self.text_size.intrinsic_size().map_or(0.0, |s| s.height);
+ let (button_w, button_h) = (DIALOG_BUTTON_W, 28.0);
+ let content_w = 2.0 * button_w + gap;
+ let content_h = group_h + gap * 2.0 + button_h;
+ let (pad, top) = (self.dialog.inner().padding(), self.dialog.inner().headroom());
+ let x = (self.width as f32 - content_w) * 0.5;
+ let y = (self.height as f32 - (top + content_h + pad)) * 0.5 + top;
+ self.text_size.set_rect(x, y, content_w, group_h);
+ let by = y + group_h + gap * 2.0;
+ self.dialog_cancel.set_rect(x, by, button_w, button_h);
+ self.dialog_ok.set_rect(x + button_w + gap, by, button_w, button_h);
+ let window = Rect { x: 0.0, y: 0.0, width: self.width as f32, height: self.height as f32 };
+ self.dialog.set_backdrop(Some(window));
+ self.dialog.fit(&self.ui_context);
+ }
+
+ /// Open the Options dialog on the size the app holds.
+ fn open_dialog(&mut self) {
+ self.text_size.inner_mut().set_selected(self.text_size_choice);
+ self.dialog_pending = true;
+ self.layout_dialog();
+ self.dialog_pending = false;
+ let members = self.dialog_members();
+ self.dialog.open(&mut self.ui_context, members);
+ self.needs_rebuild = true;
+ }
+
+ /// Close it: `keep` takes the choice into the app, else it is dropped.
+ fn close_dialog(&mut self, keep: bool) {
+ if keep {
+ self.text_size_choice = self.text_size.inner().selected();
+ self.status = format!("Text size: {}", TEXT_SIZES[self.text_size_choice]);
+ }
+ self.dialog.close(&mut self.ui_context);
+ self.layout_dialog();
+ self.needs_rebuild = true;
+ }
+
/// The widget roots as pointers, for the one genuinely pointer-consuming path left:
/// registration (the registry stores them). The paint walk takes shared borrows.
/// Register every dispatch root by reference: the registry keeps a pointer to each
@@ -108,12 +186,30 @@ impl DemoApp {
ctx.register_host(&mut self.theme_dropdown);
ctx.register_host(&mut self.image_contain);
ctx.register_host(&mut self.image_stretch);
+ ctx.register_host(&mut self.options_button);
+ ctx.register_host(&mut self.dialog);
+ ctx.register_host(&mut self.text_size);
+ ctx.register_host(&mut self.dialog_cancel);
+ ctx.register_host(&mut self.dialog_ok);
+ self.layout_dialog();
}
/// `take_*` plumbing: translate widget changes into app state. Runs after any routed
/// dispatch; every check is STATE-gated, so it does not matter which propagate call
/// consumed the event (see the KeyInput note in `handle_key_input`).
fn drain_widget_changes(&mut self) {
+ if self.options_button.take_click() {
+ self.open_dialog();
+ }
+ if self.dialog_ok.take_click() {
+ self.close_dialog(true);
+ }
+ if self.dialog_cancel.take_click() {
+ self.close_dialog(false);
+ }
+ if self.text_size.take_change() {
+ self.needs_rebuild = true;
+ }
if self.button.take_click() {
self.clicks += 1;
self.status = format!("Button clicked {} time(s)", self.clicks);
@@ -187,6 +283,13 @@ impl Application for DemoApp {
image_stretch: Owned::new(ImageView::new()
.with_image(gradient_id, GRADIENT_W, GRADIENT_H)
.with_fit(FitMode::Stretch)),
+ options_button: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Options…")),
+ dialog: Owned::new(Dialog::new().with_label("Text size")),
+ text_size: Owned::new(RadioGroup::new(TEXT_SIZES).with_selected(1)),
+ dialog_cancel: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel")),
+ dialog_ok: Owned::new(Button::new(0.0, 0.0, 0.0, 0.0).with_label("OK")),
+ text_size_choice: 1,
+ dialog_pending: false,
toggle_on: false,
clicks: 0,
status: "Ready.".to_string(),
@@ -281,6 +384,7 @@ impl Application for DemoApp {
let button = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(120.0, CONTROL_H)));
let toggle = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(64.0, CONTROL_H)));
let dropdown = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(150.0, CONTROL_H)));
+ let options = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(DIALOG_BUTTON_W, CONTROL_H)));
let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
// ImageView row: same texture through two fit modes side by side.
@@ -300,6 +404,7 @@ impl Application for DemoApp {
arena.append_child(controls, button);
arena.append_child(controls, toggle);
arena.append_child(controls, dropdown);
+ arena.append_child(controls, options);
arena.append_child(root, slider);
arena.append_child(root, name_box);
arena.append_child(root, images);
@@ -321,6 +426,8 @@ impl Application for DemoApp {
self.toggle.set_rect(t.x, t.y, t.width, t.height);
let d = r(dropdown);
self.theme_dropdown.set_rect(d.x, d.y, d.width, d.height);
+ let o = r(options);
+ self.options_button.set_rect(o.x, o.y, o.width, o.height);
let s = r(slider);
self.slider.set_rect(s.x, s.y, s.width, s.height);
let n = r(name_box);
@@ -331,6 +438,7 @@ impl Application for DemoApp {
self.image_stretch.set_rect(is.x, is.y, is.width, is.height);
self.title_rect = r(title);
self.status_rect = r(status);
+ self.layout_dialog();
self.needs_rebuild = false;
self.ui_context.rebuild_spatial_grid();
@@ -417,6 +525,7 @@ impl Application for DemoApp {
cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_contain, &mut pc);
cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_stretch, &mut pc);
cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.options_button, &mut pc);
// The dropdown popover — geometry and labels last, on top of everything, exactly
// where it hit-tests. Labels carry bounds equal to the popover rect: that clips
@@ -428,6 +537,10 @@ impl Application for DemoApp {
self.theme_dropdown.render_popover(&mut pc);
}
+ // The dialog over everything: its backdrop, its plate, and its members on the plate
+ // (it paints them; they are never painted on their own).
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.dialog, &mut pc);
+
Some(pc.finish())
}
@@ -537,6 +650,16 @@ impl Application for DemoApp {
}
}
+ // Escape cancels the open dialog, as its Cancel does.
+ if self.dialog.inner().is_open()
+ && event.state == ElementState::Pressed
+ && event.logical_key == cce_ui::widget::Key::Named(NamedKey::Escape)
+ {
+ self.close_dialog(false);
+ *needs_rebuild = true;
+ return None;
+ }
+
// KeyInput MUST short-circuit: the router delivers keys to the ctx-focused
// widget FIRST on every propagate call, so a non-short-circuited chain would
// hand a typed character to the focused widget once per root (N-time
diff --git a/src/widget/container/dialog.rs b/src/widget/container/dialog.rs
new file mode 100644
index 0000000..d769b06
--- /dev/null
+++ b/src/widget/container/dialog.rs
@@ -0,0 +1,306 @@
+//! `Dialog` — a modal plate around registered widgets (`docs/rfc-accessibility-locale.md`,
+//! phase 3).
+//!
+//! Like a [`Group`](super::Group) it OWNS nothing and lays nothing out: the host lays its
+//! members out where it wants them, and the dialog's plate is their padded hull with a title
+//! band above. What makes it a dialog is what [`Adapted<Dialog>::open`] does to the
+//! [`UiContext`]:
+//!
+//! - **Focus is trapped.** The Tab walk visits only the members (and their embedded
+//! children), wrapping inside; focus moves to the first of them on open and goes back to
+//! whatever had it on [`close`](Adapted::<Dialog>::close).
+//! - **What is behind takes nothing.** Every widget outside reads as covered
+//! (`UiContext::is_coordinate_covered`, which every widget's hit test and hover ask), so
+//! neither a press nor a hover reaches it; [`set_backdrop`](Adapted::<Dialog>::set_backdrop)
+//! dims it.
+//! - **A screen reader is told.** The members are linked as the dialog's children, so the
+//! accessibility tree nests them under a `Dialog` node marked modal, named by the title.
+//!
+//! **Paint the dialog, not its members**: it paints its plate and then each member, so they
+//! stand on it. Paint it after everything it covers. Escape is the host's to read: close the
+//! dialog on it as on Cancel.
+//!
+//! The plate is the menu's material (`Material::menu`, the `style.surface.menu` block): a
+//! dialog is a popover that holds controls instead of rows.
+
+use crate::scene::layout::Rect;
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint, UiContext, WidgetHost, WidgetId};
+
+/// What dims the window behind an open dialog with a backdrop.
+const SCRIM: [f32; 4] = [0.0, 0.0, 0.0, 0.5];
+
+#[derive(Debug, Clone)]
+pub struct Dialog {
+ members: Vec<WidgetId>,
+ title: Option<String>,
+ /// Plate inset around the members' hull.
+ padding: f32,
+ /// The window rect to dim behind the plate, while open.
+ backdrop: Option<Rect>,
+ open: bool,
+}
+
+impl Dialog {
+ /// A closed dialog titled by its label (`with_label`), at the pane rung's padding.
+ pub fn new() -> Adapted<Dialog> {
+ Adapted::new(Dialog {
+ members: Vec::new(),
+ title: None,
+ padding: crate::layout::plate_padding(),
+ backdrop: None,
+ open: false,
+ })
+ }
+
+ pub fn is_open(&self) -> bool {
+ self.open
+ }
+
+ pub fn members(&self) -> &[WidgetId] {
+ &self.members
+ }
+
+ fn title_font(&self) -> (String, f32) {
+ let (fam, size) = crate::layout::parse_font_string(&crate::layout::section_label_font());
+ (fam, size.unwrap_or(14.0))
+ }
+
+ /// The title band's height — zero without a title.
+ fn title_height(&self) -> f32 {
+ match self.title.as_deref().filter(|t| !t.is_empty()) {
+ Some(_) => self.title_font().1 + crate::layout::control_gap(),
+ None => 0.0,
+ }
+ }
+
+ /// The room the plate takes ABOVE its members: the padding and the title band. A host
+ /// lays the first member out this far below where it wants the plate's top.
+ pub fn headroom(&self) -> f32 {
+ self.padding + self.title_height()
+ }
+
+ /// The padding between the members and the plate's edge.
+ pub fn padding(&self) -> f32 {
+ self.padding
+ }
+
+ /// The plate: the members' hull, padded, with the title band on top. `None` when no
+ /// member is on screen.
+ pub fn plate(&self, ui: &UiContext) -> Option<Rect> {
+ let mut hull: Option<(f32, f32, f32, f32)> = None;
+ for &id in &self.members {
+ let Some(w) = ui.get_widget(id) else { continue };
+ let (x, y, ww, hh) = w.rect();
+ if !w.visible() || ww <= 0.0 || hh <= 0.0 || x < -9000.0 || y < -9000.0 {
+ continue;
+ }
+ let mut grow = |x: f32, y: f32, w: f32, h: f32| {
+ hull = Some(match hull {
+ None => (x, y, x + w, y + h),
+ Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h)),
+ });
+ };
+ grow(x, y, ww, hh);
+ if let Some(l) = w.detached_label_rect() {
+ grow(l.x, l.y, l.width, l.height);
+ }
+ }
+ let (x0, y0, x1, y1) = hull?;
+ let (p, top) = (self.padding, self.headroom());
+ Some(Rect { x: x0 - p, y: y0 - top, width: x1 - x0 + 2.0 * p, height: y1 - y0 + top + p })
+ }
+}
+
+impl Adapted<Dialog> {
+ /// Open around `members` (registered widget ids, laid out by the host): link them as
+ /// this dialog's children, trap focus among them and cover everything else (see the
+ /// module doc). Opening an open dialog changes its members and keeps where focus goes
+ /// back to.
+ pub fn open(&mut self, ctx: &mut UiContext, members: Vec<WidgetId>) {
+ let id = self.base().id();
+ for &old in &self.members {
+ ctx.unlink_child(id, old);
+ }
+ for &m in &members {
+ ctx.link_ids(id, m);
+ }
+ self.members = members.clone();
+ self.open = true;
+ self.fit(ctx);
+ ctx.open_modal(id, members);
+ }
+
+ /// Close: unlink the members, release the trap and give focus back.
+ pub fn close(&mut self, ctx: &mut UiContext) {
+ if !self.open {
+ return;
+ }
+ let id = self.base().id();
+ for &m in &self.members {
+ ctx.unlink_child(id, m);
+ }
+ self.open = false;
+ self.backdrop = None;
+ ctx.close_modal(id);
+ }
+
+ /// Take the plate's rect as the widget's own (its hit area and its accessible bounds),
+ /// from where the members now are. Call after laying them out.
+ pub fn fit(&mut self, ctx: &UiContext) {
+ match self.inner().plate(ctx).filter(|_| self.open) {
+ Some(r) => self.set_rect(r.x, r.y, r.width, r.height),
+ None => self.set_rect(0.0, 0.0, 0.0, 0.0),
+ }
+ }
+
+ /// Dim `window` (the window's rect) behind the plate while open; `None` for no dimming.
+ pub fn set_backdrop(&mut self, window: Option<Rect>) {
+ self.inner_mut().backdrop = window;
+ }
+
+ pub fn with_padding(mut self, padding: f32) -> Self {
+ self.inner_mut().padding = padding;
+ self
+ }
+}
+
+impl Layout for Dialog {
+ /// The title is the plate's own band, not a detached control label.
+ fn inline_label(&self) -> bool {
+ true
+ }
+}
+
+impl Paint for Dialog {
+ fn color(&self) -> [f32; 4] {
+ [0.0; 4]
+ }
+
+ fn sync_label(&mut self, label: &str) {
+ self.title = Some(label.to_string());
+ }
+
+ /// It paints its members itself, standing on its plate; the walk must not paint them
+ /// again.
+ fn paints_own_subtree(&self) -> bool {
+ true
+ }
+
+ /// Nothing without the context: the plate is where the members are.
+ fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {}
+
+ fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
+ if !self.open {
+ return;
+ }
+ if let Some(b) = self.backdrop {
+ ctx.quad(b, SCRIM);
+ }
+ let Some(plate) = self.plate(ui) else { return };
+ let r = crate::layout::menu_corner_radius().min(plate.height * 0.5);
+ let depth = crate::layout::bevel_width().min(plate.height * 0.2);
+ if crate::color::menu_color()[3] > 0.001 {
+ ctx.plate(plate, (r, r, r, r), &crate::scene::material::Material::menu(), depth);
+ } else {
+ let (plateau, radii) = crate::layout::carve_inside(plate, (r, r, r, r), depth);
+ ctx.boss(plateau, radii, depth);
+ }
+ if let Some(title) = self.title.as_deref().filter(|t| !t.is_empty()) {
+ let (fam, size) = self.title_font();
+ let color = crate::color::control_label_color_for_state(false, false);
+ let (x, y) = (plate.x + self.padding, plate.y + self.padding);
+ ctx.text_with(title.to_string(), x, y, size, color, Some(format!("{fam} {size}")), None);
+ }
+ for &id in &self.members {
+ if let Some(w) = ui.get_widget(id) {
+ crate::scene::painter::paint_root_into(ui, w, ctx);
+ }
+ }
+ }
+}
+
+impl Input for Dialog {
+ /// The plate takes a press nothing on it took, so it never falls to what is behind.
+ fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+ self.open && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
+ }
+
+ /// A press on the plate is not a drag of the window.
+ fn blocks_root_plate_drag(&self) -> bool {
+ self.open
+ }
+
+ fn a11y_role(&self) -> Option<accesskit::Role> {
+ Some(accesskit::Role::Dialog)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::widget::{Button, Owned, TextBox};
+
+ /// A dialog traps the Tab walk among its members, covers what is behind it, and gives
+ /// focus back on close.
+ #[test]
+ fn a_dialog_traps_focus_covers_the_window_and_gives_focus_back() {
+ let mut ctx = UiContext::new();
+ let mut behind = Owned::new(Button::new(10.0, 10.0, 80.0, 24.0).with_label("Behind"));
+ let mut name = Owned::new(TextBox::new(String::new()).with_label("Name"));
+ name.set_rect(120.0, 120.0, 200.0, 24.0);
+ let mut ok = Owned::new(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
+ let mut dialog = Owned::new(Dialog::new().with_label("Rename"));
+ ctx.register_host(&mut behind);
+ ctx.register_host(&mut dialog);
+ ctx.register_host(&mut name);
+ ctx.register_host(&mut ok);
+ ctx.set_focused_id(behind.base().id());
+
+ dialog.open(&mut ctx, vec![name.base().id(), ok.base().id()]);
+ assert_eq!(ctx.modal_owner(), Some(dialog.base().id()));
+ assert_eq!(ctx.focused_widget, Some(name.base().id()), "focus moves in");
+ ctx.focus_step(false);
+ assert_eq!(ctx.focused_widget, Some(ok.base().id()));
+ ctx.focus_step(false);
+ assert_eq!(ctx.focused_widget, Some(name.base().id()), "the walk wraps inside, never to Behind");
+ assert!(!behind.hit_test(20.0, 20.0, &ctx), "behind the dialog nothing hits");
+ assert!(ok.hit_test(130.0, 170.0, &ctx), "inside it does");
+
+ let plate = dialog.inner().plate(&ctx).expect("a plate round the members");
+ let (p, top) = (dialog.inner().padding(), dialog.inner().headroom());
+ assert_eq!((plate.x, plate.y), (120.0 - p, 120.0 - top), "the title band is above the members");
+ assert_eq!(dialog.rect(), (plate.x, plate.y, plate.width, plate.height), "opening fits it");
+ assert_eq!(ctx.tree.child_ids(dialog.base().id()), vec![name.base().id(), ok.base().id()]);
+
+ dialog.close(&mut ctx);
+ assert_eq!(ctx.modal_owner(), None);
+ assert_eq!(ctx.focused_widget, Some(behind.base().id()), "focus goes back");
+ assert!(behind.hit_test(20.0, 20.0, &ctx));
+ assert!(ctx.tree.child_ids(dialog.base().id()).is_empty(), "the members are unlinked");
+ }
+
+ /// The dialog paints its plate, then its members on it.
+ #[test]
+ fn a_dialog_paints_its_plate_then_its_members() {
+ use crate::scene::paint::Prim;
+ let mut ctx = UiContext::new();
+ let mut ok = Owned::new(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
+ let mut dialog = Owned::new(Dialog::new().with_label("Rename"));
+ ctx.register_host(&mut dialog);
+ ctx.register_host(&mut ok);
+ let mut pc = PaintCtx::new();
+ crate::scene::painter::paint_root_into(&ctx, &*dialog, &mut pc);
+ assert!(pc.finish().items.is_empty(), "closed, it paints nothing");
+
+ dialog.open(&mut ctx, vec![ok.base().id()]);
+ let mut pc = PaintCtx::new();
+ crate::scene::painter::paint_root_into(&ctx, &*dialog, &mut pc);
+ let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+ let texts: Vec<&str> = prims
+ .iter()
+ .filter_map(|p| if let Prim::Text { text, .. } = p { Some(text.as_str()) } else { None })
+ .collect();
+ assert_eq!(texts, ["Rename", "OK"], "the title, then the member on the plate");
+ }
+}
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index d4314be..caaa728 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -7,6 +7,7 @@ pub mod spreadsheet;
pub mod scroll_box;
pub mod paginator;
pub mod treelist;
+pub mod dialog;
pub mod group;
pub use container_layout::{ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout, ColumnsLayout, MosaicLayout, ReverseMosaicLayout};
@@ -18,4 +19,5 @@ pub use spreadsheet::{SheetColumn, Spreadsheet};
pub use scroll_box::ScrollBox;
pub use paginator::Paginator;
pub use treelist::{TreeList, TreeElement};
+pub use dialog::Dialog;
pub use group::{Group, GroupFrame};
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index b209aa3..fad06df 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -29,7 +29,7 @@ const PLATE_SHARE: f32 = 0.6;
/// plate in it ([`Checkbox::box_plate`]): both fields with relief on; off,
/// as the toggle's — a well is its frame, the hairline every well falls back
/// to (lit by `hovered` / `focused`), and the plate a lit face in it.
-fn paint_box(ctx: &mut PaintCtx, well: &Field, plate: Option<&Field>, hovered: bool, focused: bool) {
+pub(crate) fn paint_box(ctx: &mut PaintCtx, well: &Field, plate: Option<&Field>, hovered: bool, focused: bool) {
if crate::layout::control_relief() {
ctx.field(well);
if let Some(plate) = plate {
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index b3d315a..af80a86 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -1,5 +1,6 @@
pub mod button;
pub mod checkbox;
+pub mod radio_group;
pub mod slider;
pub mod slider2d;
pub mod spinbox;
@@ -16,6 +17,7 @@ pub mod ramp_preview;
pub use button::{Button, ButtonKind, PageButton};
pub use checkbox::{Checkbox, Toggle};
+pub use radio_group::RadioGroup;
pub use slider::{Slider, RangeSlider, ActiveThumb};
pub use slider2d::Slider2D;
pub use spinbox::Spinbox;
diff --git a/src/widget/input/radio_group.rs b/src/widget/input/radio_group.rs
new file mode 100644
index 0000000..cc53912
--- /dev/null
+++ b/src/widget/input/radio_group.rs
@@ -0,0 +1,361 @@
+//! `RadioGroup` — one of several options, chosen (`docs/rfc-accessibility-locale.md`,
+//! phase 3).
+//!
+//! Each option is the check box's well at a full corner (the DE's superellipse, so a
+//! squircle rather than a disc) with a lit bead, a sphere, standing in the chosen one, and
+//! its label after it — in a column, or a row with [`Adapted<RadioGroup>::with_horizontal`].
+//! The bead is round where the check box's plate is square, which is what tells the two
+//! apart at a glance (a flat host, which carries no sphere, draws it as a disc).
+//!
+//! **The keyboard.** The group is ONE stop in the Tab walk, a plate, as every set of
+//! exclusive choices is; the arrows move the choice (Down / Right the next, Up / Left the one
+//! before, wrapping; Home / End the ends), and the choice follows them — there is no option
+//! that has focus without being chosen. A press on an option chooses it.
+//!
+//! **A screen reader** sees a radio group whose children are radio buttons, the chosen one
+//! checked and focused while the group is, each one clickable
+//! ([`Input::a11y_items`] / [`Input::a11y_select_item`]).
+
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::{Field, PaintCtx};
+use crate::widget::{ElementState, Event, Key, MouseButton, NamedKey};
+use crate::widget::model::EventCtx;
+use crate::widget::{colors, Adapted, Input, Layout, Paint};
+
+/// The gap between an option's box and its label.
+const LABEL_GAP: f32 = 8.0;
+/// The chosen option's bead, as a share of its well's side.
+const BEAD_SHARE: f32 = 0.42;
+/// The bead's body (linear): lighter than the well it stands in.
+const BEAD: [f32; 4] = [0.42, 0.45, 0.56, 1.0];
+
+#[derive(Debug, Clone)]
+pub struct RadioGroup {
+ options: Vec<String>,
+ selected: usize,
+ horizontal: bool,
+ focused: bool,
+ just_changed: bool,
+}
+
+impl RadioGroup {
+ /// A column of `options`, the first chosen.
+ pub fn new<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Adapted<RadioGroup> {
+ Adapted::new(RadioGroup {
+ options: options.into_iter().map(Into::into).collect(),
+ selected: 0,
+ horizontal: false,
+ focused: false,
+ just_changed: false,
+ })
+ }
+
+ pub fn options(&self) -> &[String] {
+ &self.options
+ }
+
+ /// The chosen option's index.
+ pub fn selected(&self) -> usize {
+ self.selected
+ }
+
+ /// Choose option `idx` (clamped). Not reported as a change: the host set it.
+ pub fn set_selected(&mut self, idx: usize) {
+ self.selected = idx.min(self.options.len().saturating_sub(1));
+ }
+
+ /// Replace the options, keeping the choice where it still exists.
+ pub fn set_options<S: Into<String>>(&mut self, options: impl IntoIterator<Item = S>) {
+ self.options = options.into_iter().map(Into::into).collect();
+ self.set_selected(self.selected);
+ }
+
+ /// One option's height: a toggle's, the check box's side.
+ fn row_h() -> f32 {
+ crate::layout::toggle_height()
+ }
+
+ fn gap() -> f32 {
+ crate::layout::control_gap()
+ }
+
+ fn label_width(label: &str) -> f32 {
+ let (fam, size) = crate::layout::control_label_font_parsed();
+ crate::widget::display::measure_text_width(label, &fam, size)
+ }
+
+ /// Each option's rect (its box and its label) in `rect`.
+ pub fn option_rects(&self, rect: Rect) -> Vec<Rect> {
+ let (h, gap) = (Self::row_h(), Self::gap());
+ let mut x = rect.x;
+ self.options
+ .iter()
+ .enumerate()
+ .map(|(i, label)| {
+ if self.horizontal {
+ let w = h + LABEL_GAP + Self::label_width(label);
+ let r = Rect { x, y: rect.y, width: w, height: h };
+ x += w + gap * 2.0;
+ r
+ } else {
+ Rect { x: rect.x, y: rect.y + i as f32 * (h + gap), width: rect.width, height: h }
+ }
+ })
+ .collect()
+ }
+
+ /// The option under (`px`, `py`).
+ fn option_at(&self, rect: Rect, px: f32, py: f32) -> Option<usize> {
+ self.option_rects(rect)
+ .iter()
+ .position(|r| px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height)
+ }
+
+ /// An option's box: the check box's well, round.
+ pub fn ring(option: Rect) -> Field {
+ let side = option.height.max(1.0);
+ let square = Rect { x: option.x, y: option.y, width: side, height: side };
+ let depth = crate::layout::bevel_width().min(side * 0.2);
+ let r = side * 0.5;
+ let (outline, _) = crate::layout::carve_inside(square, (r, r, r, r), depth);
+ // Round at the carved outline's own size, whatever the carve did to the corner.
+ let r = outline.width.min(outline.height) * 0.5;
+ Field::well(outline, (r, r, r, r), depth)
+ }
+
+ fn choose(&mut self, idx: usize) -> bool {
+ if idx >= self.options.len() || idx == self.selected {
+ return false;
+ }
+ self.selected = idx;
+ self.just_changed = true;
+ true
+ }
+}
+
+impl Adapted<RadioGroup> {
+ /// Lay the options out in a row instead of a column.
+ pub fn with_horizontal(mut self, horizontal: bool) -> Self {
+ self.inner_mut().horizontal = horizontal;
+ self
+ }
+
+ pub fn with_selected(mut self, idx: usize) -> Self {
+ self.inner_mut().set_selected(idx);
+ self
+ }
+}
+
+impl Layout for RadioGroup {
+ fn inline_label(&self) -> bool {
+ true
+ }
+
+ fn intrinsic_size(&self) -> Option<Size> {
+ let (h, gap, n) = (Self::row_h(), Self::gap(), self.options.len() as f32);
+ if self.horizontal {
+ let w: f32 = self.option_rects(Rect { x: 0.0, y: 0.0, width: 0.0, height: h }).iter().map(|r| r.width).sum::<f32>()
+ + gap * 2.0 * (n - 1.0).max(0.0);
+ Some(Size::new(w, h))
+ } else {
+ Some(Size::new(0.0, n * h + (n - 1.0).max(0.0) * gap))
+ }
+ }
+}
+
+impl Paint for RadioGroup {
+ fn color(&self) -> [f32; 4] {
+ [0.0; 4]
+ }
+
+ fn widget_font(&self) -> Option<String> {
+ Some(crate::layout::control_label_font())
+ }
+
+ fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+ let (_, font_size) = crate::layout::control_label_font_parsed();
+ for (i, (option, label)) in self.option_rects(rect).into_iter().zip(&self.options).enumerate() {
+ let chosen = i == self.selected;
+ let lit = chosen && self.focused;
+ let ring = Self::ring(option).with_tint(lit.then(crate::scene::paint::ControlPlate::focus_tint));
+ super::checkbox::paint_box(ctx, &ring, None, false, lit);
+ if chosen {
+ let o = ring.rect;
+ let (cx, cy, r) = (o.x + o.width * 0.5, o.y + o.height * 0.5, o.width.min(o.height) * BEAD_SHARE * 0.5);
+ // The disc first, for a flat host that drops the sphere; the sphere covers it.
+ ctx.circle(cx, cy, r, BEAD);
+ ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(BEAD));
+ }
+ let ty = crate::layout::align_text_y(option.y, option.height, font_size, 0.0);
+ let lx = option.x + option.height + LABEL_GAP;
+ ctx.text_with(
+ label.clone(),
+ lx,
+ ty,
+ font_size,
+ colors::control_label_color_for_state(false, lit),
+ None,
+ Some([option.x, option.y, rect.x + rect.width.max(option.width), option.y + option.height]),
+ );
+ }
+ }
+}
+
+impl Input for RadioGroup {
+ fn focus_role(&self) -> crate::widget::FocusRole {
+ crate::widget::FocusRole::Plate
+ }
+
+ fn a11y_role(&self) -> Option<accesskit::Role> {
+ Some(accesskit::Role::RadioGroup)
+ }
+
+ fn a11y_items(&self, rect: Rect) -> Vec<crate::a11y::A11yItem> {
+ self.option_rects(rect)
+ .into_iter()
+ .zip(&self.options)
+ .enumerate()
+ .map(|(i, (r, label))| crate::a11y::A11yItem {
+ role: accesskit::Role::RadioButton,
+ label: label.clone(),
+ rect: r,
+ toggled: Some(i == self.selected),
+ focused: self.focused && i == self.selected,
+ })
+ .collect()
+ }
+
+ fn a11y_select_item(&mut self, idx: usize) -> bool {
+ self.choose(idx)
+ }
+
+ fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+ match event {
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+ // Already hit-gated by the adapter; a press between options chooses nothing.
+ match self.option_at(ectx.rect, *x, *y) {
+ Some(i) => {
+ self.choose(i);
+ true
+ }
+ None => false,
+ }
+ }
+ Event::FocusIn => {
+ self.focused = true;
+ true
+ }
+ Event::FocusOut => {
+ self.focused = false;
+ true
+ }
+ Event::KeyInput(key_event) => {
+ if !self.focused || key_event.state != ElementState::Pressed || self.options.is_empty() {
+ return false;
+ }
+ let n = self.options.len();
+ let target = match key_event.logical_key {
+ Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::ArrowRight) => (self.selected + 1) % n,
+ Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowLeft) => (self.selected + n - 1) % n,
+ Key::Named(NamedKey::Home) => 0,
+ Key::Named(NamedKey::End) => n - 1,
+ // Space / Enter: the focused option is already the chosen one.
+ Key::Named(NamedKey::Space) | Key::Named(NamedKey::Enter) => return true,
+ _ => return false,
+ };
+ self.choose(target);
+ true
+ }
+ _ => false,
+ }
+ }
+
+ fn take_change(&mut self) -> bool {
+ std::mem::take(&mut self.just_changed)
+ }
+
+ /// The chosen option's label.
+ fn value_string(&self) -> Option<String> {
+ self.options.get(self.selected).cloned()
+ }
+
+ /// Choose by label, or by index.
+ fn set_value_string(&mut self, val: &str) -> bool {
+ let val = val.trim();
+ let idx = self.options.iter().position(|o| o == val).or_else(|| val.parse::<usize>().ok());
+ idx.is_some_and(|i| self.choose(i))
+ }
+
+ fn value(&self) -> i32 {
+ self.selected as i32
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::widget::{UiContext, WidgetHost};
+
+ fn key(k: NamedKey) -> Event {
+ Event::KeyInput(crate::widget::KeyEvent {
+ logical_key: Key::Named(k),
+ state: ElementState::Pressed,
+ text: None,
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ })
+ }
+
+ /// The arrows move the choice, wrapping, and only while focused; a press chooses the
+ /// option under it.
+ #[test]
+ fn the_arrows_and_a_press_choose_an_option() {
+ let mut ctx = UiContext::new();
+ let mut g = RadioGroup::new(["Small", "Medium", "Large"]);
+ WidgetHost::set_rect(&mut g, 10.0, 10.0, 200.0, 200.0);
+ assert!(!g.handle_event(&key(NamedKey::ArrowDown), &mut ctx), "unfocused: not its key");
+ g.handle_event(&Event::FocusIn, &mut ctx);
+ g.handle_event(&key(NamedKey::ArrowDown), &mut ctx);
+ assert_eq!(g.inner().selected(), 1);
+ assert!(g.take_change());
+ g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
+ g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
+ assert_eq!(g.inner().selected(), 2, "wraps to the last");
+ g.handle_event(&key(NamedKey::Home), &mut ctx);
+ assert_eq!(g.inner().selected(), 0);
+
+ let third = g.inner().option_rects(Rect { x: 10.0, y: 10.0, width: 200.0, height: 200.0 })[2];
+ let (px, py) = (third.x + 4.0, third.y + third.height * 0.5);
+ g.handle_event(
+ &Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, local_x: px, local_y: py },
+ &mut ctx,
+ );
+ assert_eq!(g.inner().selected(), 2, "a press on the third chooses it");
+ assert_eq!(g.get_value_string().as_deref(), Some("Large"));
+ }
+
+ /// A reader sees a radio group of radio buttons, the chosen one checked; a click on one
+ /// chooses it.
+ #[test]
+ fn a_reader_sees_and_chooses_radio_buttons() {
+ let mut g = RadioGroup::new(["Small", "Medium"]);
+ WidgetHost::set_rect(&mut g, 0.0, 0.0, 200.0, 80.0);
+ let items = g.a11y_items();
+ assert_eq!(items.len(), 2);
+ assert_eq!((items[0].role, items[0].label.as_str(), items[0].toggled), (accesskit::Role::RadioButton, "Small", Some(true)));
+ assert_eq!(items[1].toggled, Some(false));
+ assert!(g.a11y_select_item(1));
+ assert_eq!(g.inner().selected(), 1);
+ assert!(g.take_change());
+ assert!(!g.a11y_select_item(1), "already chosen: no change");
+ }
+
+ /// Every option's box is round: the check box's well at a full corner.
+ #[test]
+ fn an_option_is_a_round_well() {
+ let ring = RadioGroup::ring(Rect { x: 0.0, y: 0.0, width: 200.0, height: 24.0 });
+ assert!((ring.radii.0 * 2.0 - ring.rect.width).abs() < 1e-3 && ring.rect.width == ring.rect.height);
+ }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 174edf7..0f78eca 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -602,6 +602,16 @@ pub trait WidgetHost {
false
}
+ /// The parts a screen reader sees as nodes of their own (`Input::a11y_items`).
+ fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> {
+ Vec::new()
+ }
+
+ /// An assistive tool clicked one of them (`Input::a11y_select_item`).
+ fn a11y_select_item(&mut self, _idx: usize) -> bool {
+ false
+ }
+
fn corner_radii(&self) -> CornerRadii {
let (r, (tl, tr, br, bl)) = self.corner_style();
CornerRadii::new(
@@ -677,14 +687,14 @@ pub use self::owned::Owned;
pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
pub use self::core::focus::link_parent_child;
pub use self::input::{
- Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
+ Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, RadioGroup, Slider, RangeSlider,
ColorSelector, Finger, Trackpad, get_font_db, ActiveThumb, FontSelector,
BevelPreview, bevel_ease, parse_bevel_knobs, RampPreview,
ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
format_ramp_spec, parse_ramp_spec
};
pub use self::container::{
- Group, GroupFrame,
+ Dialog, Group, GroupFrame,
ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
ContentBg, ParametersBg,
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 935f1d0..1be8af0 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -498,6 +498,19 @@ pub trait Input {
None
}
+ /// The parts of this widget a screen reader should see as nodes of their own, under the
+ /// widget's node: a radio group's radio buttons. `rect` is the widget's content rect.
+ /// Default none.
+ fn a11y_items(&self, _rect: Rect) -> Vec<crate::a11y::A11yItem> {
+ Vec::new()
+ }
+
+ /// An assistive tool clicked item `idx` of [`a11y_items`](Input::a11y_items): do what a
+ /// press on it does, reported as a change. Returns whether anything changed.
+ fn a11y_select_item(&mut self, _idx: usize) -> bool {
+ false
+ }
+
/// Set the value an assistive tool asked for (AT-SPI's `SetCurrentValue`), clamped to
/// [`a11y_range`](Input::a11y_range), and mark it changed as a typed value would be, so
/// the host's `take_change` reports it. Returns whether it changed. Default: not
@@ -1329,6 +1342,14 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
fn a11y_set_value(&mut self, value: f64) -> bool {
Input::a11y_set_value(&mut self.inner, value)
}
+
+ fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> {
+ Input::a11y_items(&self.inner, self.content_rect())
+ }
+
+ fn a11y_select_item(&mut self, idx: usize) -> bool {
+ Input::a11y_select_item(&mut self.inner, idx)
+ }
fn solid_border(&self) -> Option<([f32; 4], f32)> {
Paint::solid_border(&self.inner)
}
diff --git a/src/widget/owned.rs b/src/widget/owned.rs
index 97a865b..f55f524 100644
--- a/src/widget/owned.rs
+++ b/src/widget/owned.rs
@@ -159,6 +159,8 @@ impl<W: WidgetHost + 'static> WidgetHost for Owned<W> {
fn a11y_value(&self) -> Option<String> { self.widget.a11y_value() }
fn a11y_range(&self) -> Option<(f64, f64, f64)> { self.widget.a11y_range() }
fn a11y_set_value(&mut self, value: f64) -> bool { self.widget.a11y_set_value(value) }
+ fn a11y_items(&self) -> Vec<crate::a11y::A11yItem> { self.widget.a11y_items() }
+ fn a11y_select_item(&mut self, idx: usize) -> bool { self.widget.a11y_select_item(idx) }
fn corner_radii(&self) -> CornerRadii { self.widget.corner_radii() }
}