GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(a11y): menus in the tree, rows keyed by action, and a hook for apps without widgets
Phase 1 of rfc-accessibility-locale, finished:
- Context-menu rows carry their actions: context_menu::set_row_actions /
UiContext::show_context_menu_rows give each row a ContextAction and a
press runs it, whatever the label says. Matching the English label
(legacy_action_for_label) is only the fallback for menus without; the
toolkit's own menus (a widget's standard menu, the tree list's) set
theirs, so translating their labels breaks nothing.
- An open context menu is in the tree: a Menu under the window, its rows
items (✓ checkable, ●/○ radio, toggled as marked; a slider row a slider
with its range; headers labels; separators out), the focus its
highlighted row, else the menu.
- Application::accessibility(&mut AppNodes) lets an app that draws without
a UiContext declare AccessKit nodes in an id range of its own;
a11y::app_tree(&mut app, scale) is an app's whole tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 17 ++--
docs/rfc-accessibility-locale.md | 19 +++-
src/a11y.rs | 214 ++++++++++++++++++++++++++++++++++++++-
src/backend/app.rs | 9 ++
src/context.rs | 43 +++++---
src/widget/container/treelist.rs | 32 +++---
src/widget/core.rs | 155 ++++++++++++++++++++++++----
7 files changed, 425 insertions(+), 64 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 31ae15e..000efd1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1571,9 +1571,11 @@ two rules keep the retrofit cheap:
- **A new widget declares what it is**: its `focus_role`, and a label that names it to a
person (not to a host).
-- **An action is keyed by an ID, never by its label text.** A context-menu row's label is its
- identity today and hosts match on it, so translating any label breaks its action; do not
- add more code that matches on displayed text.
+- **An action is keyed by an ID, never by its label text.** Give a context-menu row its
+ action with `context_menu::set_row_actions` (or build the menu with
+ `UiContext::show_context_menu_rows`); a press runs the row's action, and matching the
+ English label (`context_menu::legacy_action_for_label`) is only the fallback for menus that
+ set none. Do not add code that matches on displayed text.
Phase 0 is done (2026-10-08): every font system is built with `locale::locale()` (from
`cce-core`): `LC_ALL`, else `LC_CTYPE`, else `LANG`, as a BCP 47 tag; in the browser,
@@ -1705,10 +1707,11 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
`Binding`, the job rules and naga's parse (see "Compute jobs run in the browser too").
`vk::ComputeDevice` and `web::ComputeDevice` run them.
-- `a11y.rs` — the accessibility tree: `tree_update(&UiContext, title, scale)` is AccessKit's
- `TreeUpdate` for a window's registered widgets (role, name, value, bounds, actions,
- focus); `WidgetHost::a11y_role` / `a11y_value` are what a widget says about itself. No
- platform adapter yet (`docs/rfc-accessibility-locale.md`, phases 1–2).
+- `a11y.rs` — the accessibility tree, in AccessKit's schema: `app_tree(&mut app, scale)` is a
+ 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. No platform adapter yet (`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
(see "Input-method composition is one model for every shell").
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index f4ef4c0..ab76d79 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -84,8 +84,23 @@ One `cce_core::locale()`: `LC_ALL`, else `LC_CTYPE`, else `LANG`, turned from PO
registered visible widget with its role (`WidgetHost::a11y_role`, else `a11y::role_for` from
type and focus role), label, value (`a11y_value`, the widget's `value_string`: toggled for a
check box or switch, numeric for a slider, spin button or progress bar), logical bounds,
-focus and click actions, children in reading order, and the context's focus. Still to come
-in this phase: the hook for immediate-mode apps, and action IDs for context-menu rows.
+focus and click actions, children in reading order, and the context's focus.
+
+**Phase 1 is done (2026-10-08).** Also landed:
+- **An open context menu is in the tree**: a `Menu` under the window, its rows items — `✓` a
+ checkable item, `●` / `○` a radio item (toggled as marked), a slider row a slider with its
+ range, a header a label, separators left out — and while it is open the focus is its
+ highlighted row, else the menu.
+- **The hook for apps without widgets**: `Application::accessibility(&mut self, &mut
+ a11y::AppNodes)`, AccessKit nodes in an id range of the app's own (`AppNodes::id`), with
+ `push_top` / `push` / `set_focus`; `a11y::app_tree(&mut app, scale)` is an app's whole
+ tree (its widgets, its own nodes, the menu).
+- **Menu rows carry their actions**: `context_menu::set_row_actions` /
+ `UiContext::show_context_menu_rows` give each row a `ContextAction`, and a press runs it;
+ matching the English label (`legacy_action_for_label`) is only a fallback for menus built
+ without. The toolkit's own menus (a widget's standard menu, the tree list's) set theirs, so
+ translating their labels changes nothing they do. Apps that match labels themselves (the
+ designer's menus) move when they are next touched.
- A node per registered widget: **role** (from `type_name` and `FocusRole` first, then an
explicit `Input::a11y_role()` a widget can override: button, checkbox, toggle-button,
diff --git a/src/a11y.rs b/src/a11y.rs
index 4f76c4c..e1975f7 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -16,6 +16,17 @@
//! - **actions** — focus for every keyboard stop, click for every [`FocusRole::Plate`];
//! - **focus** — the context's focused widget, else the window.
//!
+//! An open context menu (`widget::context_menu`, one per thread, whatever app shows it) is a
+//! [`Role::Menu`] under the window, its rows items: a `✓` row a checkable item, a `●` / `○`
+//! row a radio item (toggled as marked), a slider row a slider with its number, a header a
+//! label; `-` separators are left out. While it is open the keyboard is in it, so the focus
+//! is its highlighted row, else the menu.
+//!
+//! An app that draws without a [`UiContext`] (the status bar, the terminal, the map…) says
+//! what it shows through [`Application::accessibility`](crate::backend::app::Application::accessibility),
+//! pushing AccessKit nodes into [`AppNodes`]; [`app_tree`] puts both halves and the menu
+//! under one window.
+//!
//! Every call answers the whole tree. AccessKit's adapters compare it with the last one and
//! raise events only for nodes that changed; sending changed nodes alone is an optimisation
//! for later, with `backend::frame`'s damage diff as its model.
@@ -28,6 +39,53 @@ use crate::widget::{FocusRole, WidgetHost, WidgetId};
/// The window's node, the root every widget hangs from.
pub const WINDOW: NodeId = NodeId(0);
+/// The open context menu's node, and the first of its rows' (`MENU + 1 + row`): far above any
+/// widget's id.
+pub const MENU: NodeId = NodeId(1 << 62);
+
+/// The node of the open context menu's row `row`.
+pub fn menu_row_id(row: usize) -> NodeId {
+ NodeId(MENU.0 + 1 + row as u64)
+}
+
+/// Where an app's own nodes ([`AppNodes::id`]) begin: above every widget's, below the menu's.
+const APP_BASE: u64 = 1 << 61;
+
+/// The nodes an app declares itself, for what it draws without a [`UiContext`] — see
+/// [`Application::accessibility`](crate::backend::app::Application::accessibility). Ids come
+/// from [`AppNodes::id`], an app's own numbering; children are set on a node with
+/// `Node::set_children`, and only the nodes pushed with [`push_top`](Self::push_top) hang
+/// from the window.
+#[derive(Default)]
+pub struct AppNodes {
+ nodes: Vec<(NodeId, Node)>,
+ top: Vec<NodeId>,
+ focus: Option<NodeId>,
+}
+
+impl AppNodes {
+ /// The node id of the app's own node `n` (any number below 2^61).
+ pub fn id(n: u64) -> NodeId {
+ NodeId(APP_BASE + (n & (APP_BASE - 1)))
+ }
+
+ /// A node that hangs from another of the app's nodes (which lists it as a child).
+ pub fn push(&mut self, id: NodeId, node: Node) {
+ self.nodes.push((id, node));
+ }
+
+ /// A node directly under the window, in the order pushed (after the widgets').
+ pub fn push_top(&mut self, id: NodeId, node: Node) {
+ self.top.push(id);
+ self.nodes.push((id, node));
+ }
+
+ /// The node the keyboard is on, when it is one of the app's own.
+ pub fn set_focus(&mut self, id: NodeId) {
+ self.focus = Some(id);
+ }
+}
+
/// A widget's node: its id, moved up one so no widget can be the window.
pub fn node_id(id: WidgetId) -> NodeId {
NodeId(id.0 as u64 + 1)
@@ -114,6 +172,30 @@ fn truth(value: &str) -> Option<bool> {
/// The whole tree of `ctx`'s registered, visible widgets under a window node named `title`,
/// at HiDPI `scale` (see the module docs).
pub fn tree_update(ctx: &UiContext, title: &str, scale: f64) -> TreeUpdate {
+ window_tree(Some(ctx), AppNodes::default(), title, scale)
+}
+
+/// An app's whole tree: its [`UiContext`]'s widgets if it has one, the nodes its
+/// [`Application::accessibility`](crate::backend::app::Application::accessibility) declares,
+/// and an open context menu, under a window named by its settings' title.
+pub fn app_tree<A: crate::backend::app::Application>(app: &mut A, scale: f64) -> TreeUpdate {
+ let mut own = AppNodes::default();
+ app.accessibility(&mut own);
+ let title = app.settings().title;
+ window_tree(app.ui_context(), own, &title, scale)
+}
+
+/// The tree of `ctx`'s widgets (if any) and `app`'s own nodes under one window. Focus, most
+/// specific first: an open menu's, else the app's own, else the context's, else the window.
+pub fn window_tree(ctx: Option<&UiContext>, app: AppNodes, title: &str, scale: f64) -> TreeUpdate {
+ let empty;
+ let ctx = match ctx {
+ Some(ctx) => ctx,
+ None => {
+ empty = UiContext::new();
+ &empty
+ }
+ };
// The widgets, read once. Registered pointers name live widgets (the registry resolves
// only those: `widget::Owned`, `widget::core::Liveness`), and nothing mutates them while
// this borrows the context.
@@ -145,16 +227,84 @@ pub fn tree_update(ctx: &UiContext, title: &str, scale: f64) -> TreeUpdate {
window.set_label(title);
}
window.set_transform(Affine::scale(scale));
- window.set_children(top.iter().map(|(id, _, _)| node_id(*id)).collect::<Vec<_>>());
+ let mut children: Vec<NodeId> = top.iter().map(|(id, _, _)| node_id(*id)).collect();
+ children.extend(app.top.iter().copied());
+ nodes.extend(app.nodes);
+ if crate::widget::context_menu::is_visible() {
+ children.push(MENU);
+ }
+ window.set_children(children);
nodes.push((WINDOW, window));
- let focus = ctx.focused_widget.filter(|id| shown.contains(id)).map(node_id).unwrap_or(WINDOW);
+ let mut focus = app
+ .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) {
+ focus = menu_focus;
+ }
let mut tree = TreeInfo::new(WINDOW);
tree.toolkit_name = Some("cce-ui".into());
tree.toolkit_version = Some(env!("CARGO_PKG_VERSION").into());
TreeUpdate { nodes, tree: Some(tree), tree_id: TreeId::ROOT, focus }
}
+/// The open context menu and its rows, pushed onto `nodes`; the node the keyboard is on
+/// while it is open, or `None` when no menu is.
+fn push_context_menu(nodes: &mut Vec<(NodeId, Node)>) -> Option<NodeId> {
+ use crate::widget::context_menu as cm;
+ if !cm::is_visible() {
+ return None;
+ }
+ let (mx, my, mw, mh) = (cm::x() as f64, cm::y() as f64, cm::w() as f64, cm::h() as f64);
+ let headers = cm::header_count();
+ let mut menu = Node::new(Role::Menu);
+ menu.set_bounds(Rect::new(mx, my, mx + mw, my + mh));
+ let mut rows = Vec::new();
+ for (i, label) in cm::options().iter().enumerate() {
+ if label.trim() == "-" {
+ continue;
+ }
+ // `split_mark` answers the glyph a mark is drawn as: "check" for `✓`, "circle" /
+ // "circle-outline" for a radio row on / off.
+ let (mark, text) = cm::split_mark(label);
+ let role = if i < headers {
+ Role::Label
+ } else if cm::slider(i).is_some() {
+ Role::Slider
+ } else {
+ match mark {
+ Some("check") => Role::MenuItemCheckBox,
+ Some("circle") | Some("circle-outline") => Role::MenuItemRadio,
+ _ => Role::MenuItem,
+ }
+ };
+ let mut row = Node::new(role);
+ row.set_label(text.trim());
+ let y = cm::row_y(i) as f64;
+ row.set_bounds(Rect::new(mx, y, mx + mw, y + cm::ROW_H as f64));
+ match (role, mark) {
+ (Role::MenuItemCheckBox, _) => row.set_toggled(Toggled::True),
+ (Role::MenuItemRadio, Some(glyph)) => row.set_toggled(Toggled::from(glyph == "circle")),
+ _ => {}
+ }
+ if let Some(slider) = cm::slider(i) {
+ row.set_numeric_value(slider.value as f64);
+ row.set_min_numeric_value(slider.min as f64);
+ row.set_max_numeric_value(slider.max as f64);
+ row.set_numeric_value_step(slider.step as f64);
+ }
+ if i >= headers {
+ row.add_action(Action::Click);
+ }
+ rows.push(menu_row_id(i));
+ nodes.push((menu_row_id(i), row));
+ }
+ menu.set_children(rows);
+ nodes.push((MENU, menu));
+ Some(cm::hovered_item().filter(|&i| i >= headers).map(menu_row_id).unwrap_or(MENU))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -224,6 +374,66 @@ mod tests {
assert_eq!(node(&update, WINDOW).label(), None, "no title, no name");
}
+ #[test]
+ fn an_open_context_menu_is_a_menu_and_holds_the_keyboard() {
+ use crate::widget::context_menu as cm;
+ let ctx = UiContext::new();
+ let rows = vec![
+ "[TextBox]: Name".to_string(),
+ "✓ Wrap lines".to_string(),
+ "● Follow editor".to_string(),
+ "-".to_string(),
+ "Opacity".to_string(),
+ "Copy".to_string(),
+ ];
+ cm::show(40.0, 50.0, rows, 1, WidgetId(7));
+ cm::set_row_slider(4, cm::MenuSlider { value: 50.0, min: 0.0, max: 100.0, step: 5.0, decimals: 0, suffix: "%" });
+ let update = tree_update(&ctx, "", 1.0);
+ assert!(node(&update, WINDOW).children().contains(&MENU), "the menu hangs from the window");
+ let menu = node(&update, MENU);
+ assert_eq!(menu.role(), Role::Menu);
+ assert_eq!(menu.children(), &[menu_row_id(0), menu_row_id(1), menu_row_id(2), menu_row_id(4), menu_row_id(5)][..], "no separator");
+ let row = |i| node(&update, menu_row_id(i));
+ assert_eq!((row(0).role(), row(0).label()), (Role::Label, Some("[TextBox]: Name")));
+ assert_eq!((row(1).role(), row(1).label(), row(1).toggled()), (Role::MenuItemCheckBox, Some("Wrap lines"), Some(Toggled::True)));
+ assert_eq!((row(2).role(), row(2).toggled()), (Role::MenuItemRadio, Some(Toggled::True)));
+ assert_eq!((row(4).role(), row(4).numeric_value(), row(4).max_numeric_value()), (Role::Slider, Some(50.0), Some(100.0)));
+ assert_eq!(row(5).role(), Role::MenuItem);
+ assert!(row(5).supports_action(Action::Click) && !row(0).supports_action(Action::Click));
+ assert_eq!(update.focus, MENU, "nothing highlighted: the menu has the keyboard");
+ cm::set_hovered_item(Some(5));
+ assert_eq!(tree_update(&ctx, "", 1.0).focus, menu_row_id(5), "the highlight is the focus");
+ cm::hide();
+ let closed = tree_update(&ctx, "", 1.0);
+ assert!(!node(&closed, WINDOW).children().contains(&MENU) && closed.focus == WINDOW);
+ }
+
+ #[test]
+ fn an_app_without_widgets_declares_its_own_nodes() {
+ // A status-bar module: no UiContext, a clock it draws itself.
+ let mut own = AppNodes::default();
+ let (bar, clock, button) = (AppNodes::id(1), AppNodes::id(2), AppNodes::id(3));
+ let mut group = Node::new(Role::Group);
+ group.set_label("Status");
+ group.set_children(vec![clock, button]);
+ own.push_top(bar, group);
+ let mut label = Node::new(Role::Label);
+ label.set_value("12:30");
+ own.push(clock, label);
+ let mut b = Node::new(Role::Button);
+ b.set_label("Volume");
+ b.add_action(Action::Click);
+ own.push(button, b);
+ own.set_focus(button);
+
+ let update = window_tree(None, own, "Status bar", 1.0);
+ assert_eq!(node(&update, WINDOW).children(), &[bar][..], "only what was pushed to the top");
+ assert_eq!(node(&update, bar).children(), &[clock, button][..]);
+ assert_eq!(node(&update, clock).value(), Some("12:30"));
+ assert_eq!(update.focus, button, "the app's focus");
+ assert!(bar != WINDOW && bar != MENU && bar.0 > node_id(WidgetId(usize::MAX >> 4)).0, "its own id range");
+ }
+
#[test]
fn a_widget_that_says_what_it_is_is_believed() {
assert_eq!(role_for("Button", FocusRole::Plate, Some(Role::Tab)), Role::Tab);
diff --git a/src/backend/app.rs b/src/backend/app.rs
index ffb8e24..cf5ff20 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -334,6 +334,15 @@ pub trait Application: Sized + 'static {
None
}
+ /// What the app shows that its [`ui_context`](Self::ui_context) does not: the nodes of
+ /// an accessibility tree, for an app that draws without widgets (a status bar module, a
+ /// terminal, a map) or draws parts of its window itself. Pushed as AccessKit nodes into
+ /// `nodes` (`crate::a11y::AppNodes`); the widgets and an open context menu are added
+ /// around them (`crate::a11y::app_tree`). Default: nothing.
+ fn accessibility(&mut self, nodes: &mut crate::a11y::AppNodes) {
+ let _ = nodes;
+ }
+
fn ui_context(&self) -> Option<&crate::context::UiContext> {
None
}
diff --git a/src/context.rs b/src/context.rs
index a40bfde..aa200fe 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -1066,6 +1066,20 @@ impl UiContext {
crate::widget::context_menu::show(x, y, options, header_count, id);
}
+ /// [`show_context_menu`](Self::show_context_menu) with each row's action beside its
+ /// label (`None` for a header, or a row the host handles itself), so the label is only
+ /// what is shown and a translated menu still does what it did.
+ ///
+ /// # Safety
+ ///
+ /// As for `show_context_menu`: `target` is null or a live widget at the call.
+ pub unsafe fn show_context_menu_rows(&mut self, x: f32, y: f32, rows: Vec<(String, Option<crate::widget::ContextAction>)>, header_count: usize, target: *mut (dyn WidgetHost + 'static)) {
+ let (options, actions): (Vec<String>, Vec<Option<crate::widget::ContextAction>>) = rows.into_iter().unzip();
+ // SAFETY: the caller's contract, passed on.
+ unsafe { self.show_context_menu(x, y, options, header_count, target) };
+ crate::widget::context_menu::set_row_actions(actions);
+ }
+
/// Open the shared config context menu for a right-click on `target`.
///
/// # Safety
@@ -1102,19 +1116,18 @@ impl UiContext {
}
}
- let mut options = Vec::new();
- options.push(header);
+ use crate::widget::ContextAction as CA;
+ let row = |label: &str, action: CA| (label.to_string(), Some(action));
+ let mut rows: Vec<(String, Option<CA>)> = vec![(header, None)];
let mut header_count = 1;
if let Some((file, key)) = config_info {
- options.push(format!("File: {}", file));
- options.push(format!("Key: {}", key));
+ rows.push((format!("File: {}", file), None));
+ rows.push((format!("Key: {}", key), None));
header_count = 3;
}
if name == "TextBox" {
- // A password box has no Cut or Copy: its text never goes to the
- // clipboard (`TextBox::clipboard_text`).
let is_password = unsafe {
(*target)
.as_any()
@@ -1122,9 +1135,9 @@ impl UiContext {
.is_some_and(|tb| tb.is_password)
};
if !is_password {
- options.extend(vec!["Cut".to_string(), "Copy".to_string()]);
+ rows.extend([row("Cut", CA::Cut), row("Copy", CA::Copy)]);
}
- options.extend(vec!["Paste".to_string(), "Select All".to_string()]);
+ rows.extend([row("Paste", CA::Paste), row("Select All", CA::SelectAll)]);
let is_search = unsafe {
if let Some(tb) = (*target).as_any().downcast_ref::<crate::widget::input::TextBox>() {
tb.placeholder.as_deref() == Some("Search...")
@@ -1133,13 +1146,11 @@ impl UiContext {
}
};
if is_search {
- options.push("Clear".to_string());
+ rows.push(row("Clear", CA::ClearText));
}
} else if name == "Breadcrumb" {
- options.push("Copy Path".to_string());
+ rows.push(row("Copy Path", CA::CopyPath));
} else if name == "Ramp" {
- // The graph's menu: the controls-collapse toggle (check state in
- // the label), then the spec-string clipboard pair.
let collapsed = unsafe {
(*target)
.as_any()
@@ -1147,16 +1158,16 @@ impl UiContext {
.map(|r| r.controls_collapsed)
.unwrap_or(false)
};
- options.push(if collapsed { "✓ Collapse controls" } else { "Collapse controls" }.to_string());
- options.extend(vec!["Copy".to_string(), "Paste".to_string()]);
+ rows.push(row(if collapsed { "✓ Collapse controls" } else { "Collapse controls" }, CA::ToggleRampControls));
+ rows.extend([row("Copy", CA::Copy), row("Paste", CA::Paste)]);
} else {
- options.extend(vec!["Copy".to_string(), "Paste".to_string()]);
+ rows.extend([row("Copy", CA::Copy), row("Paste", CA::Paste)]);
}
let scroll_y = crate::widget::hover_animation::get_scroll_offset();
let adjusted_py = py - scroll_y;
// SAFETY: the caller's contract, passed on.
- unsafe { self.show_context_menu(px, adjusted_py, options, header_count, target) };
+ unsafe { self.show_context_menu_rows(px, adjusted_py, rows, header_count, target) };
}
pub fn hide_context_menu(&mut self) {
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index ecd3253..205097e 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -540,18 +540,17 @@ impl TreeList {
TreeElement::Section { ref path, collapsed, .. } => {
self.right_clicked_section = Some(path.clone());
- let mut options = vec![path.clone()];
- if collapsed {
- options.push("Expand".to_string());
- } else {
- options.push("Collapse".to_string());
- }
- options.push("Expand All".to_string());
- options.push("Collapse All".to_string());
-
+ use crate::widget::ContextAction as CA;
+ let rows = vec![
+ (path.clone(), None),
+ if collapsed { ("Expand".to_string(), Some(CA::ExpandNode)) } else { ("Collapse".to_string(), Some(CA::CollapseNode)) },
+ ("Expand All".to_string(), Some(CA::ExpandAll)),
+ ("Collapse All".to_string(), Some(CA::CollapseAll)),
+ ];
+
let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
// SAFETY: as above — our own adapter, live while its event is routed.
- if let Some(h) = host { unsafe { ui.show_context_menu(px, py - scroll_offset, options, 1, h) }; }
+ if let Some(h) = host { unsafe { ui.show_context_menu_rows(px, py - scroll_offset, rows, 1, h) }; }
changed = true;
}
TreeElement::Leaf { original_idx, ref path, ref name, indent, ref val } => {
@@ -564,15 +563,16 @@ impl TreeList {
original_idx,
});
- let options = vec![
- path.clone(),
- "Copy Key".to_string(),
- "Copy Value".to_string(),
- "Delete".to_string(),
+ use crate::widget::ContextAction as CA;
+ let rows = vec![
+ (path.clone(), None),
+ ("Copy Key".to_string(), Some(CA::CopyKey)),
+ ("Copy Value".to_string(), Some(CA::CopyValue)),
+ ("Delete".to_string(), Some(CA::DeleteKey)),
];
let scroll_offset = crate::widget::hover_animation::get_scroll_offset();
// SAFETY: as above — our own adapter, live while its event is routed.
- if let Some(h) = host { unsafe { ui.show_context_menu(px, py - scroll_offset, options, 1, h) }; }
+ if let Some(h) = host { unsafe { ui.show_context_menu_rows(px, py - scroll_offset, rows, 1, h) }; }
changed = true;
}
}
diff --git a/src/widget/core.rs b/src/widget/core.rs
index ce0bda8..bc805e2 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -532,6 +532,34 @@ pub mod context_menu {
pub const MARK_OFF: &str = "○ ";
/// The glyph a label's leading mark names, and the label without it.
+ /// A row's action read off its English label: the fallback for a menu built without
+ /// [`set_row_actions`], as every menu was until 2026-10-08. It breaks the moment a label
+ /// is translated (`docs/rfc-accessibility-locale.md`), so the toolkit's own menus set
+ /// their actions and this serves only menus that do not.
+ pub fn legacy_action_for_label(label: &str) -> Option<crate::widget::ContextAction> {
+ use crate::widget::ContextAction as CA;
+ Some(match label {
+ "Cut" => CA::Cut,
+ "Copy" => CA::Copy,
+ "Paste" => CA::Paste,
+ "Select All" => CA::SelectAll,
+ "Undo" => CA::Undo,
+ "Redo" => CA::Redo,
+ "Clear" => CA::ClearText,
+ "Copy Key" => CA::CopyKey,
+ "Copy Value" => CA::CopyValue,
+ "Delete" => CA::DeleteKey,
+ "Expand" => CA::ExpandNode,
+ "Collapse" => CA::CollapseNode,
+ "Expand All" => CA::ExpandAll,
+ "Collapse All" => CA::CollapseAll,
+ "Copy Path" => CA::CopyPath,
+ // The Ramp toggle carries its check state in the label.
+ "✓ Collapse controls" | "Collapse controls" => CA::ToggleRampControls,
+ _ => return None,
+ })
+ }
+
pub fn split_mark(label: &str) -> (Option<&'static str>, &str) {
for (mark, glyph) in [(MARK_CHECK, "check"), (MARK_ON, "circle"), (MARK_OFF, "circle-outline")] {
if let Some(rest) = label.strip_prefix(mark) {
@@ -664,6 +692,11 @@ pub mod context_menu {
/// Rows that lead to a PAGE, parallel to `options` — see
/// [`set_row_page`](Self::set_row_page). Emptied by every `show`.
pub pages: Vec<bool>,
+ /// What each row DOES, parallel to `options` — see
+ /// [`set_row_actions`](Self::set_row_actions). A row's action is its identity; its
+ /// label is only what is shown, so a translated label still runs the action.
+ /// Emptied by every `show`.
+ pub actions: Vec<Option<crate::widget::ContextAction>>,
/// On a page: the title of the plate it was turned to from, which
/// the back band across its top reads as `‹ Title`. `None` on a menu
/// that was opened rather than turned to.
@@ -735,6 +768,7 @@ pub mod context_menu {
header_count: 0,
sliders: Vec::new(),
pages: Vec::new(),
+ actions: Vec::new(),
back: None,
back_hovered: false,
turned: false,
@@ -802,6 +836,7 @@ pub mod context_menu {
self.header_count = header_count;
self.sliders = vec![None; self.options.len()];
self.pages = vec![false; self.options.len()];
+ self.actions = vec![None; self.options.len()];
self.back = None;
self.back_hovered = false;
self.turned = false;
@@ -814,6 +849,17 @@ pub mod context_menu {
/// Make row `idx` lead to a PAGE — see [`PageTurn`]. It wears
/// [`PAGE_MARK`] at its right end; the plate widens for it.
+ /// Give the rows their actions, parallel to `options` (a header or a row the host
+ /// handles itself is `None`); called after `show`, like `set_row_page`. A press on a
+ /// row runs its action on the menu's target. Rows with none fall back to matching
+ /// the label (`legacy_action_for_label`), which only works in English: build
+ /// menus with actions.
+ pub fn set_row_actions(&mut self, actions: Vec<Option<crate::widget::ContextAction>>) {
+ let n = self.options.len();
+ self.actions = actions;
+ self.actions.resize(n, None);
+ }
+
pub fn set_row_page(&mut self, idx: usize) {
if idx >= self.options.len() {
return;
@@ -1292,27 +1338,7 @@ pub mod context_menu {
if let Some(target_ptr) = ctx.tree.get_ptr(target_id) {
unsafe {
let target = &mut *target_ptr;
- use crate::widget::ContextAction as CA;
- let action = match opt.as_str() {
- "Cut" => Some(CA::Cut),
- "Copy" => Some(CA::Copy),
- "Paste" => Some(CA::Paste),
- "Select All" => Some(CA::SelectAll),
- "Undo" => Some(CA::Undo),
- "Redo" => Some(CA::Redo),
- "Clear" => Some(CA::ClearText),
- "Copy Key" => Some(CA::CopyKey),
- "Copy Value" => Some(CA::CopyValue),
- "Delete" => Some(CA::DeleteKey),
- "Expand" => Some(CA::ExpandNode),
- "Collapse" => Some(CA::CollapseNode),
- "Expand All" => Some(CA::ExpandAll),
- "Collapse All" => Some(CA::CollapseAll),
- "Copy Path" => Some(CA::CopyPath),
- // The Ramp toggle carries its check state in the label.
- "✓ Collapse controls" | "Collapse controls" => Some(CA::ToggleRampControls),
- _ => None,
- };
+ let action = self.actions.get(idx).copied().flatten().or_else(|| legacy_action_for_label(&opt));
if let Some(action) = action {
let _ = target.context_action(action);
}
@@ -1740,6 +1766,21 @@ pub mod context_menu {
/// Make row `idx` of the shown menu lead to a page — see [`PageTurn`].
/// Call after [`show`] / [`show_page`], which clear every row back to an
/// action.
+ /// Give the open menu's rows their actions — see [`ContextMenuState::set_row_actions`].
+ pub fn set_row_actions(actions: Vec<Option<crate::widget::ContextAction>>) {
+ CONTEXT_MENU.with(|m| m.borrow_mut().set_row_actions(actions));
+ }
+
+ /// The action row `idx` of the open menu runs, if it was given one.
+ pub fn row_action(idx: usize) -> Option<crate::widget::ContextAction> {
+ CONTEXT_MENU.with(|m| m.borrow().actions.get(idx).copied().flatten())
+ }
+
+ /// How many of the open menu's first rows are headers (a title, `File:` / `Key:`).
+ pub fn header_count() -> usize {
+ CONTEXT_MENU.with(|m| m.borrow().header_count)
+ }
+
pub fn set_row_page(idx: usize) {
CONTEXT_MENU.with(|m| m.borrow_mut().set_row_page(idx));
}
@@ -2623,3 +2664,75 @@ mod context_menu_page_tests {
context_menu::hide();
}
}
+
+#[cfg(test)]
+mod context_menu_action_tests {
+ use super::context_menu::{self, ROW_H};
+ use crate::context::UiContext;
+ use crate::widget::{ContextAction, ElementState, MouseButton, Owned, Widget, WidgetHost};
+
+ /// A widget that remembers the last action a menu ran on it.
+ struct Recorder {
+ base: Widget,
+ got: Option<ContextAction>,
+ }
+ impl WidgetHost for Recorder {
+ crate::impl_widget_base!(Recorder);
+ fn color(&self) -> [f32; 4] {
+ [0.0; 4]
+ }
+ fn context_action(&mut self, action: ContextAction) -> bool {
+ self.got = Some(action);
+ true
+ }
+ }
+
+ fn press_row(ctx: &mut UiContext, idx: usize) {
+ let (x, y) = (context_menu::x() + 10.0, context_menu::row_y(idx) + ROW_H * 0.5);
+ context_menu::mouse_input(MouseButton::Left, ElementState::Pressed, x, y, Some(ctx));
+ }
+
+ #[test]
+ fn a_row_runs_its_action_whatever_its_label_says() {
+ let mut ctx = UiContext::new();
+ let mut w = Owned::new(Recorder { base: Widget::new(), got: None });
+ ctx.register_host(&mut w);
+ let id = w.base().id();
+
+ // A label the English table has never seen: only the row's action can say what it is.
+ context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Kopieren".into()], 1, id);
+ context_menu::set_row_actions(vec![None, Some(ContextAction::Copy)]);
+ press_row(&mut ctx, 1);
+ assert_eq!(w.got, Some(ContextAction::Copy));
+
+ // A row with an action and an English label that names ANOTHER: the action wins.
+ w.got = None;
+ context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Paste".into()], 1, id);
+ context_menu::set_row_actions(vec![None, Some(ContextAction::SelectAll)]);
+ press_row(&mut ctx, 1);
+ assert_eq!(w.got, Some(ContextAction::SelectAll));
+
+ // A menu built without actions still works in English, through the fallback.
+ w.got = None;
+ context_menu::show(0.0, 0.0, vec!["[Recorder]".into(), "Paste".into()], 1, id);
+ press_row(&mut ctx, 1);
+ assert_eq!(w.got, Some(ContextAction::Paste));
+ }
+
+ #[test]
+ fn the_toolkits_own_menus_carry_their_actions() {
+ let mut ctx = UiContext::new();
+ let mut tb = Owned::new(crate::widget::TextBox::new(String::new()).with_label("Name"));
+ ctx.register_host(&mut tb);
+ let ptr = &mut *tb as &mut (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static);
+ // SAFETY: `tb` is live and registered for the whole test.
+ unsafe { ctx.handle_right_click(ptr, 5.0, 5.0) };
+ let options = context_menu::options();
+ let header = context_menu::header_count();
+ assert!(options.len() > header, "a text box's menu has rows");
+ for (i, label) in options.iter().enumerate().skip(header) {
+ assert!(context_menu::row_action(i).is_some(), "row {label:?} has no action of its own");
+ }
+ context_menu::hide();
+ }
+}