git.lucas.co / cce-data-editor
structured data editor
git clone https://git.lucas.co/cce-data-editor.git

commit410969e958679bd11f751fcf523f51fdbde87c88
parent5e37e7bcd3
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-08 14:33
fix(a11y): parked editors are hidden; every control is named

Only the editor for the selected key's type is shown; the rest were parked at
(-1000, -1000), which the accessibility tree took as on screen, so every one
was in it, unnamed (the parked Send Test button included). A parked editor is
hidden now, and the shown one is named after the key it edits ("Value of
<key>", "Unit of <key>"); the File menu, the key tree and the KDL source
box are named too. only_the_selected_keys_editor_is_in_the_accessibility_tree
fails on the old code and passes now; the window draws identically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/src/main.rs b/src/main.rs
index a755065..8b9ca7c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -870,6 +870,37 @@ impl DataEditorApp {
     /// The pre-frame widget-state refresh — everything `rebuild_text_items` did EXCEPT
     /// building TextItems (all rendered text is display-list prims now: widget text from the
     /// paint walk, the toolbar file label from `display_list` directly).
+    /// The value cell shows only the editor for the selected key's type; the rest are
+    /// parked off the window (`set_rect(-1000, …)` above). A parked editor is also HIDDEN,
+    /// so it is no Tab stop and not in the accessibility tree — parked alone, every one of
+    /// them was both — and the shown one is named after the key it edits, since its label
+    /// is the key column beside it, not one of its own.
+    fn sync_editor_presence(&mut self) {
+        let key = self.selected_key_idx.and_then(|i| self.flat_keys.get(i)).map(|(k, _)| k.clone());
+        let value = key.as_ref().map(|k| format!("Value of {k}"));
+        let unit = key.as_ref().map(|k| format!("Unit of {k}"));
+        macro_rules! present {
+            ($w:expr, $name:expr) => {{
+                let shown = WidgetHost::rect(&*$w).0 > -999.0;
+                WidgetHost::set_visible(&mut $w, shown);
+                $w.set_accessible_name($name);
+            }};
+        }
+        present!(self.selected_value_editor, value.as_deref());
+        present!(self.selected_color_editor, value.as_deref());
+        present!(self.selected_spinbox_editor, value.as_deref());
+        present!(self.selected_font_editor, value.as_deref());
+        present!(self.selected_choice_editor, value.as_deref());
+        present!(self.selected_len_editor, value.as_deref());
+        present!(self.selected_unit_editor, unit.as_deref());
+        present!(self.selected_keybind_editor, value.as_deref());
+        present!(self.selected_bool_editor, value.as_deref());
+        present!(self.selected_bevel_editor, value.as_deref());
+        present!(self.selected_ramp_editor, value.as_deref());
+        // The test button keeps its own label ("Send Test").
+        present!(self.selected_button_editor, None);
+    }
+
     fn refresh_widget_text(&mut self) {
         self.rebuild_tree();
         // Glyph-advance shaping — load-bearing for cursor↔pixel mapping in the editors.
@@ -949,6 +980,7 @@ impl Application for DataEditorApp {
             .with_max_width(None);
         raw_json_editor.font_family = "monospace".to_string();
         raw_json_editor.font_size = 13.0;
+        raw_json_editor.set_accessible_name(Some("KDL source"));
 
         // Auto-load argument path if passed. `--select <flat.path>` deep-links
         // to a key (or section prefix) once the first frame has laid out.
@@ -1013,8 +1045,10 @@ impl Application for DataEditorApp {
         dropdown_options.push("Exit".to_string());
         let mut btn_open = Dropdown::new(dropdown_options, 0).with_custom_display_text("File");
         btn_open.set_rect(10.0, 8.0, 70.0, cce_ui::layout::dropdown_height());
+        btn_open.set_accessible_name(Some("File"));
 
         let mut tree_list = TreeList::new();
+        tree_list.set_accessible_name(Some("Keys"));
         let keys_to_anno: Vec<String> = flat_keys.iter().map(|(k, _)| k.clone()).collect();
         let cached_annotations = cce_ui::config::get_kdl_type_annotations(&raw_json_editor.text, &keys_to_anno);
         tree_list.annotations = cached_annotations.clone();
@@ -2117,6 +2151,7 @@ impl Application for DataEditorApp {
             
 
             
+            self.sync_editor_presence();
             self.refresh_widget_text();
             self.ui_context.rebuild_spatial_grid();
             self.needs_rebuild = false;
@@ -2988,6 +3023,38 @@ window_manager {
 }
 "##;
 
+    /// Only the editor for the selected key's type reaches a screen reader, named after the
+    /// key it edits; the rest are parked and hidden. Until 2026-10-08 a parked editor was
+    /// only moved off the window, which the accessibility tree took as on screen, so every
+    /// one of them was in it, unnamed.
+    #[test]
+    fn only_the_selected_keys_editor_is_in_the_accessibility_tree() {
+        use cce_ui::engine::Application;
+        let (tx, _rx) = calloop::channel::channel();
+        let mut app = DataEditorApp::create(tx.into());
+        app.raw_json_editor.text = FIXTURE.to_string();
+        let val = cce_ui::config::parse_kdl_to_json(FIXTURE);
+        app.flat_keys.clear();
+        flatten_json(&val, "", &mut app.flat_keys);
+        app.tree_list.set_flat_keys(app.flat_keys.clone());
+        let named = |app: &mut DataEditorApp| -> Vec<String> {
+            app.needs_rebuild = true;
+            let _ = app.display_list(cce_ui::engine::LogicalSize { width: 1200.0, height: 800.0 }, 1.0);
+            let tree = cce_ui::a11y::app_tree(app, 1.0);
+            tree.nodes.iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect()
+        };
+
+        let names = named(&mut app);
+        assert!(["File", "Keys", "KDL source"].iter().all(|n| names.iter().any(|m| m == n)), "{names:?}");
+        assert!(!names.iter().any(|n| n.starts_with("Value of")), "no key selected, no editor: {names:?}");
+
+        app.selected_key_idx = app.flat_keys.iter().position(|(k, _)| k == "duration");
+        let names = named(&mut app);
+        let editors: Vec<&String> = names.iter().filter(|n| n.starts_with("Value of") || n.starts_with("Unit of")).collect();
+        assert_eq!(editors, ["Value of duration"], "{names:?}");
+        assert!(!names.iter().any(|n| n == "Send Test"), "the parked test button is not in it: {names:?}");
+    }
+
     #[test]
     fn test_kdl_roundtrip() {
         let content = FIXTURE.to_string();