git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit0633ea44fc41862821ac61b6b6372aaa17a70fff
parentafb569952d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 10:10
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();
+    }
+}