From c1287001c50fb70ca34a6fbd420a290de84e3aab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:05:00 +0200 Subject: [PATCH 1/4] #1335: AccessKit audit test; names, roles and keyboard operation for Effects & Presets, Settings and Render Queue ui_accessibility_audit walks the AccessKit tree and counts unnamed or roleless controls per panel (zero required for Project, Effects & Presets, Settings and the Render Queue; a ratchet for the rest). Rows are named tree items with selected / open state, the page list is a list with positions, controls carry label and value, the lists take the arrow keys, Home / End, Enter and Space, popup lists take the focus when they open. ui_contrast checks the theme tokens against WCAG 2. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/a11y.rs | 210 ++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 18 +- crates/ui-egui/src/panels/dialogs.rs | 2 +- crates/ui-egui/src/panels/effects_presets.rs | 343 ++++++++++++++-- crates/ui-egui/src/panels/render_queue.rs | 139 ++++++- crates/ui-egui/src/panels/rq_templates.rs | 4 + crates/ui-egui/src/panels/settings.rs | 69 +++- crates/ui-egui/src/widgets.rs | 84 +++- .../ui-egui/tests/ui_accessibility_audit.rs | 377 ++++++++++++++++++ crates/ui-egui/tests/ui_contrast.rs | 92 +++++ .../tests/ui_effects_presets_keyboard.rs | 271 +++++++++++++ .../ui-egui/tests/ui_render_queue_keyboard.rs | 226 +++++++++++ crates/ui-egui/tests/ui_settings_keyboard.rs | 113 ++++++ docs/gaps.md | 7 +- docs/ui-parity.md | 7 +- 16 files changed, 1899 insertions(+), 64 deletions(-) create mode 100644 crates/ui-egui/src/a11y.rs create mode 100644 crates/ui-egui/tests/ui_accessibility_audit.rs create mode 100644 crates/ui-egui/tests/ui_contrast.rs create mode 100644 crates/ui-egui/tests/ui_effects_presets_keyboard.rs create mode 100644 crates/ui-egui/tests/ui_render_queue_keyboard.rs create mode 100644 crates/ui-egui/tests/ui_settings_keyboard.rs diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs new file mode 100644 index 000000000..cca126876 --- /dev/null +++ b/crates/ui-egui/src/a11y.rs @@ -0,0 +1,210 @@ +//! Accessibility helpers (docs/gaps.md gap 25): names, roles and states for the widgets the app +//! paints itself, and the small pieces of list keyboard handling the panels share. +//! +//! egui builds an AccessKit node for every widget that can take the keyboard focus, but a widget +//! made with `ui.interact` has no name and no role until the panel says what it is. These helpers +//! write that onto the widget's node *after* the widget is made each frame (egui rebuilds the +//! tree every pass), and keep whatever egui already put there (its role for a `DragValue`, the +//! numeric value of a `Slider`). +//! +//! Names are the same translated strings the panel draws (`i18n::tr`), never hard-coded English. +//! `tests/ui_accessibility_audit.rs` walks the tree and fails on an unnamed control in the panels +//! that have had their pass. + +use egui::accesskit::{HasPopup, Role, Toggled}; +use egui::{Context, Id, Response}; + +/// Set `f` on `r`'s AccessKit node (nothing when AccessKit is off). egui only fills a node's +/// bounds and actions for widgets that can take the keyboard focus; a widget that cannot (the +/// non-current rows of a list) still needs both, or assistive technology cannot place or click it. +fn node(r: &Response, f: impl FnOnce(&mut egui::accesskit::Node)) { + r.ctx.accesskit_node_builder(r.id, |n| { + n.set_bounds(egui::accesskit::Rect { x0: r.rect.min.x.into(), y0: r.rect.min.y.into(), x1: r.rect.max.x.into(), y1: r.rect.max.y.into() }); + if r.sense.is_focusable() { + n.add_action(egui::accesskit::Action::Focus); + } + if r.sense.senses_click() { + n.add_action(egui::accesskit::Action::Click); + } + f(n); + }); +} + +/// Give `r`'s node a role and leave its name to the caller. +pub(crate) fn role(r: &Response, role: Role) { + node(r, |n| n.set_role(role)); +} + +/// Give `r`'s node a role and a name. +pub(crate) fn role_name(r: &Response, role: Role, label: &str) { + node(r, |n| { + n.set_role(role); + n.set_label(label); + }); +} + +/// Name `r`'s node (`label`), keeping the role egui gave it (spin button, slider, text input). +pub(crate) fn name(r: &Response, label: &str) { + node(r, |n| n.set_label(label)); +} + +/// Name `r`'s node and give it a current value (a spin button's or a text field's text). +pub(crate) fn name_value(r: &Response, label: &str, value: &str) { + node(r, |n| { + n.set_label(label); + n.set_value(value); + }); +} + +/// Set `r`'s checked state (a check box, a switch, a toggle button). +pub(crate) fn checked(r: &Response, on: bool) { + node(r, |n| n.set_toggled(if on { Toggled::True } else { Toggled::False })); +} + +/// Set `r`'s open state (a twirl, a disclosure). +pub(crate) fn expanded(r: &Response, open: bool) { + node(r, |n| n.set_expanded(open)); +} + +/// A push button; `enabled` false marks it disabled for assistive technology (it stays visible). +pub(crate) fn button(r: &Response, label: &str, enabled: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A button that opens a menu or popup: `open` is whether it is open now. +pub(crate) fn menu_button(r: &Response, label: &str, open: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + n.set_has_popup(HasPopup::Menu); + n.set_expanded(open); + }); +} + +/// A button that shows or hides something (a twirl): `expanded` is the state it leads to from. +pub(crate) fn disclosure(r: &Response, label: &str, expanded: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + n.set_expanded(expanded); + }); +} + +/// A check box. +pub(crate) fn check_box(r: &Response, label: &str, checked: bool, enabled: bool) { + node(r, |n| { + n.set_role(Role::CheckBox); + n.set_label(label); + n.set_toggled(if checked { Toggled::True } else { Toggled::False }); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A drop-down: `label` says what it sets and `value` is what it is set to. +pub(crate) fn combo_box(r: &Response, label: &str, value: &str, enabled: bool) { + node(r, |n| { + n.set_role(Role::ComboBox); + n.set_label(label); + n.set_value(value); + n.set_has_popup(HasPopup::Menu); + n.set_expanded(false); + if !enabled { + n.set_disabled(); + } + }); +} + +/// A row of a tree: selected, its depth (`level`, 1 at the top), whether it is open (`None` for a +/// leaf) and its place among its siblings. +pub(crate) fn tree_item(r: &Response, label: &str, selected: bool, level: usize, expanded: Option) { + node(r, |n| { + n.set_role(Role::TreeItem); + n.set_label(label); + n.set_selected(selected); + n.set_level(level); + if let Some(open) = expanded { + n.set_expanded(open); + } + }); +} + +/// An entry of a flat list: `position` is its 1-based place among `size`. +pub(crate) fn list_item(r: &Response, label: &str, selected: bool, position: usize, size: usize) { + node(r, |n| { + n.set_role(Role::ListItem); + n.set_label(label); + n.set_selected(selected); + n.set_position_in_set(position); + n.set_size_of_set(size); + }); +} + +/// An entry of a menu or popup list; `checked` is `Some` for an entry that is on or off. +pub(crate) fn menu_item(r: &Response, label: &str, checked: Option) { + node(r, |n| { + n.set_label(label); + match checked { + Some(on) => { + n.set_role(Role::MenuItemCheckBox); + n.set_toggled(if on { Toggled::True } else { Toggled::False }); + } + None => n.set_role(Role::MenuItem), + } + }); +} + +/// A text field with a name (and its text as the value). +pub(crate) fn text_input(r: &Response, label: &str, text: &str) { + node(r, |n| { + n.set_role(Role::TextInput); + n.set_label(label); + n.set_value(text); + }); +} + +/// A progress bar: `fraction` 0..=1 and the sentence it shows. +pub(crate) fn progress(r: &Response, label: &str, fraction: f32) { + node(r, |n| { + n.set_role(Role::ProgressIndicator); + n.set_label(label); + n.set_min_numeric_value(0.0); + n.set_max_numeric_value(1.0); + n.set_numeric_value(f64::from(fraction.clamp(0.0, 1.0))); + }); +} + +// ------------------------------------------------------------------------------------------ +// List keyboard handling + +/// Ask for the keyboard focus on widget `id` once it is drawn (a list moves its focus to the row +/// the arrow keys reached, which may not be focusable, or drawn, until the next pass). `key` +/// names the list. +pub(crate) fn focus_next_pass(ctx: &Context, key: &str, id: Id) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(Id::new(("a11y-pending-focus", key)), (id, pass))); +} + +/// True once, for the widget `focus_next_pass` named for list `key`: the caller then requests the +/// focus. A request nobody answers within a few passes is dropped. +pub(crate) fn take_pending_focus(ctx: &Context, key: &str, id: Id) -> bool { + let slot = Id::new(("a11y-pending-focus", key)); + let now = ctx.cumulative_pass_nr(); + let Some((want, at)) = ctx.data(|d| d.get_temp::<(Id, u64)>(slot)) else { return false }; + if now > at.saturating_add(4) { + ctx.data_mut(|d| d.remove::<(Id, u64)>(slot)); + return false; + } + if want != id || now == at { + return false; + } + ctx.data_mut(|d| d.remove::<(Id, u64)>(slot)); + true +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..bced4dca5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub(crate) mod a11y; pub mod audio; pub mod automation; pub mod bench; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..6006be46e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -253,9 +253,9 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { /// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`, /// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in -/// they move by fractions of a comp pixel. From any panel except Project (which owns the -/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or -/// menu open, or with mask points selected. +/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and +/// Render Queue (which own the arrow keys for their own keyboard navigation, #961); not while +/// typing, with a dialog or menu open, or with mask points selected. fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { Some(side) => (side, 10.0), @@ -269,7 +269,11 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; - if app.ui.focused == PanelKind::Project || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() { + if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) + || st.selected_layers.is_empty() + || st.text_edit.is_some() + || !st.selected_vertices.is_empty() + { return Ok(Value::Null); } // Screen pixels per comp pixel. @@ -1915,8 +1919,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only if app.dialog.is_some() { continue; } - // Enter in the Project panel renames the selected item (handled by the panel). - if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project { + // Enter in the Project panel renames the selected item (handled by the panel); in Effects + // & Presets and the Render Queue it applies or opens the row (also handled by the panel), + // not the layer rename. + if key == egui::Key::Enter && !mods.any() && matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) { continue; } // The Project panel has its own selection; a previously selected Timeline layer diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..0c193e9bf 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -205,7 +205,7 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // The id stays keyed on the untranslated source, so switching language cannot move it. let head = ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active)); let row = Rect::from_x_y_ranges(ui.max_rect().x_range(), head.rect.y_range()); - let grip = ui.interact(row, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + let grip = ui.interact(row, id.with("grip"), Sense::DRAG).on_hover_cursor(egui::CursorIcon::Grab); if grip.dragged() { let moved = (offset + grip.drag_delta()).clamp(-room, room); ui.ctx().data_mut(|d| d.insert_temp(offset_id, moved)); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index d3921cd85..48e6ad43e 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -9,6 +9,7 @@ use crate::i18n::tr; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; +use crate::a11y; use crate::icons::{self, Icon}; use crate::panels::DragPayload; use crate::theme::Tokens; @@ -105,6 +106,7 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: & p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); } +#[derive(Clone)] enum Apply { Effect(String), TextPreset(String), @@ -113,6 +115,63 @@ enum Apply { PresetFile(String), } +/// One row of the list as drawn this pass, including the rows scrolled out of view: what the +/// arrow keys, Home / End and Enter work on (the rows are painted by hand, so this is the list). +struct NavRow { + id: egui::Id, + /// The favourite star's widget, for an effect row. + star: Option, + parent: Option, + /// Where the row starts in the scrolled content. + top: f32, + /// A folder: (its key, open, forced open by a search). + folder: Option<(String, bool, bool)>, + /// What Enter applies, for an effect or preset row. + apply: Option, +} + +/// The list's keyboard state: the rows drawn, and the row the keys work from (the "cursor": +/// selected, and the one Tab stop of the list). +struct Nav { + rows: Vec, + /// The open folders on the way down to the row being drawn, by level. + stack: Vec, + cursor: Option, + /// The scroll offset and the list's top, to place rows in the content. + offset: f32, + list_top: f32, +} + +impl Nav { + /// The row `id` is the list's Tab stop: the cursor row, or the first row while there is none. + fn tab_stop(&self, id: egui::Id) -> bool { + match self.cursor { + Some(c) => c == id, + None => self.rows.is_empty(), + } + } + + /// Record the row drawn at `y`; returns whether it is the cursor row. + fn push(&mut self, id: egui::Id, y: f32, level: usize, folder: Option<(String, bool, bool)>, apply: Option) -> bool { + let parent = level.checked_sub(2).and_then(|i| self.stack.get(i)).copied(); + if folder.is_some() { + self.stack.truncate(level.saturating_sub(1)); + self.stack.push(id); + } + self.rows.push(NavRow { id, star: None, parent, top: y - self.list_top + self.offset, folder, apply }); + self.cursor == Some(id) + } +} + +fn cursor_id() -> egui::Id { + egui::Id::new("fx-cursor") +} + +/// The accessible name of a folder: what the row shows, in the interface language. +fn folder_name(label: &str) -> &str { + tr(label) +} + /// A folder row; returns whether it is open. #[allow(clippy::too_many_arguments)] fn folder_row( @@ -121,8 +180,10 @@ fn folder_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, key: &str, label: &str, + level: usize, indent: f32, forced_open: bool, icon: Option, @@ -130,12 +191,18 @@ fn folder_row( let t = app.tokens; let open = forced_open || app.ui.effects_open.contains(key); let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new(("fxcat", key)); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, level, Some((key.to_string(), open, forced_open)), None); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return open; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click()); - if resp.hovered() { + let shown = folder_name(label); + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0)); @@ -146,18 +213,44 @@ fn folder_row( Some(i) => icons::paint(lp, ic, i, t.text_dim), None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)), } - lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text); + lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text); app.auto.add(&format!("effects.category.{key}"), r, label); - if resp.clicked() && !forced_open { - if open { - app.ui.effects_open.remove(key); - } else { - app.ui.effects_open.insert(key.to_string()); + a11y::tree_item(&resp, shown, selected, level, Some(open)); + row_focus(app, ui, lp, &resp, r, id); + if resp.clicked() { + nav_click(ui.ctx(), app, id); + if !forced_open { + if open { + app.ui.effects_open.remove(key); + } else { + app.ui.effects_open.insert(key.to_string()); + } } } open } +/// What every row does with the keyboard focus: the focus ring, the panel taking the focus, the +/// list owning its keys, and moving the focus here when the arrow keys asked for it. +fn row_focus(app: &mut EffectcraftApp, ui: &egui::Ui, lp: &egui::Painter, resp: &egui::Response, r: Rect, id: egui::Id) { + let ctx = ui.ctx(); + if a11y::take_pending_focus(ctx, "fx", id) { + resp.request_focus(); + } + if resp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + } + if resp.has_focus() { + lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, app.tokens.accent), egui::StrokeKind::Inside); + } +} + +/// A click on a row moves the cursor there and gives the panel the keyboard. +fn nav_click(ctx: &egui::Context, app: &mut EffectcraftApp, id: egui::Id) { + ctx.data_mut(|d| d.insert_temp(cursor_id(), id)); + app.ui.focused = crate::dock::PanelKind::EffectsPresets; +} + /// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge). #[allow(clippy::too_many_arguments)] fn effect_row( @@ -166,7 +259,9 @@ fn effect_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, e: &'static effectcraft_engine::effects::EffectSpec, + level: usize, indent: f32, query: &str, auto_prefix: &str, @@ -175,13 +270,22 @@ fn effect_row( let t = app.tokens; let ctx = ui.ctx().clone(); let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new((auto_prefix, e.id)); + let star_id = egui::Id::new((auto_prefix, e.id, "star")); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, level, None, Some(Apply::Effect(e.id.to_string()))); + if let Some(row) = nav.rows.last_mut() { + row.star = Some(star_id); + } *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; } let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0)); - let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag()); - if resp.hovered() { + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim); @@ -193,14 +297,23 @@ fn effect_row( lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint); } app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name); - // Favourite star (always shown when starred, else on hover). + a11y::tree_item(&resp, &shown, selected, level, None); + row_focus(app, ui, lp, &resp, r, id); + // Favourite star (always shown when starred, else on hover or with the keyboard focus). let fav = app.ui.effects_favorites.contains(e.id); - let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click()); - if fav || resp.hovered() || sresp.hovered() { + let sresp = ui.interact(star.intersect(list), star_id, if tab { Sense::click() } else { Sense::CLICK }); + if fav || resp.hovered() || sresp.hovered() || sresp.has_focus() { let gold = Color32::from_rgb(0xe8, 0xc0, 0x40); filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint }); } app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" }); + a11y::button(&sresp, &format!("{}: {shown}", tr(if fav { "Remove from favorites" } else { "Add to favorites" })), true); + if sresp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + } + if sresp.has_focus() { + widgets::focus_outline(ui, &sresp, &t); + } if sresp.clicked() { if fav { app.ui.effects_favorites.remove(e.id); @@ -209,6 +322,9 @@ fn effect_row( } return; } + if resp.clicked() { + nav_click(&ctx, app, id); + } if resp.double_clicked() { *apply = Some(Apply::Effect(e.id.to_string())); } @@ -225,6 +341,7 @@ fn preset_row( lp: &egui::Painter, list: Rect, y: &mut f32, + nav: &mut Nav, name: &str, auto_id: &str, query: &str, @@ -233,22 +350,146 @@ fn preset_row( ) { let t = app.tokens; let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); + let id = egui::Id::new(("fxpreset", auto_id)); + let tab = nav.tab_stop(id); + let selected = nav.push(id, *y, 3, None, Some(on_apply.clone())); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; } - let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click()); - if resp.hovered() { + let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK }); + if selected { + lp.rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { lp.rect_filled(r, 0.0, t.hover); } icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim); highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t); app.auto.add(auto_id, r, name); + a11y::tree_item(&resp, name, selected, 3, None); + row_focus(app, ui, lp, &resp, r, id); + if resp.clicked() { + nav_click(ui.ctx(), app, id); + } if resp.double_clicked() { *apply = Some(on_apply); } } +/// The list's keys. With a row focused (Tab, a screen reader): Up / Down move between rows, +/// Home / End go to the first / last, Page Up / Page Down a page, Right opens a folder (then steps +/// into it) and Left closes it (then steps out to it), Enter and Space apply the row or open / +/// close the folder. With the mouse's last click in the panel and no widget focused, the arrows +/// and Enter work the same, so the list is operable without a mouse either way. The focus follows +/// the cursor, so a screen reader reads each row as it is reached. Returns what to apply. +fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rect) -> Option { + use egui::Key; + // A cursor whose row is gone (a search hid it): the first row is the Tab stop again. + if let Some(c) = nav.cursor + && !nav.rows.iter().any(|r| r.id == c) + { + ctx.data_mut(|d| d.remove::(cursor_id())); + ctx.request_repaint(); + } + let focused = ctx.memory(|m| m.focused()); + let on_row = focused.is_some_and(|f| nav.rows.iter().any(|r| r.id == f || r.star == Some(f))); + let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::EffectsPresets; + if (!on_row && !by_mouse) || app.dialog.is_some() || widgets::any_menu_open(ctx) { + return None; + } + let keys: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect() + }); + let page = ((list.height() / ROW_H).floor() as usize).saturating_sub(1).max(1); + // The row with the keyboard focus is where the keys start (the first Tab lands on a row + // before anything is selected); otherwise the cursor. + let focus_row = focused.and_then(|f| nav.rows.iter().find(|r| r.id == f || r.star == Some(f))).map(|r| r.id); + let mut cursor = focus_row.or_else(|| nav.cursor.filter(|c| nav.rows.iter().any(|r| r.id == *c))); + let mut apply = None; + let mut moved = false; + for key in keys { + let at = cursor.and_then(|c| nav.rows.iter().position(|r| r.id == c)); + let last = nav.rows.len().checked_sub(1)?; + let to = match key { + Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))), + Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))), + Key::Home if on_row => Some(0), + Key::End if on_row => Some(last), + Key::PageDown if on_row => Some(at.map_or(0, |a| a.saturating_add(page).min(last))), + Key::PageUp if on_row => Some(at.map_or(last, |a| a.saturating_sub(page))), + Key::ArrowRight | Key::ArrowLeft | Key::Enter | Key::Space if at.is_some() => { + let row = at.and_then(|a| nav.rows.get(a))?; + let toggle = |app: &mut EffectcraftApp, open: bool| { + if let Some((k, _, forced)) = &row.folder + && !forced + { + if open { + app.ui.effects_open.insert(k.clone()); + } else { + app.ui.effects_open.remove(k); + } + } + }; + match (key, &row.folder) { + (Key::ArrowRight, Some((_, false, _))) => { + toggle(app, true); + None + } + (Key::ArrowRight, Some((_, true, _))) => at.map(|a| a.saturating_add(1).min(last)), + (Key::ArrowLeft, Some((_, true, false))) => { + toggle(app, false); + None + } + (Key::ArrowLeft, _) => row.parent.and_then(|p| nav.rows.iter().position(|r| r.id == p)), + (Key::Enter, Some((_, open, _))) | (Key::Space, Some((_, open, _))) if on_row || key == Key::Enter => { + toggle(app, !*open); + None + } + (Key::Enter, None) => { + apply = row.apply.clone(); + None + } + (Key::Space, None) if on_row => { + apply = row.apply.clone(); + None + } + _ => None, + } + } + _ => None, + }; + if let Some(to) = to + && let Some(row) = nav.rows.get(to) + { + cursor = Some(row.id); + moved = true; + } + } + if moved && let Some(c) = cursor { + ctx.data_mut(|d| d.insert_temp(cursor_id(), c)); + // Scroll the row into view. + if let Some(row) = nav.rows.iter().find(|r| r.id == c) { + widgets::PanelScroll::reveal(ctx, egui::Id::new("fx-scroll"), row.top, row.top + ROW_H, list.height()); + } + app.ui.focused = crate::dock::PanelKind::EffectsPresets; + if on_row { + a11y::focus_next_pass(ctx, "fx", c); + } + ctx.request_repaint(); + } + // The arrows are the list's, not egui's spatial focus moves. + if on_row { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + apply +} + /// User preset files, rescanned every few seconds (and on Refresh List). fn user_presets(ctx: &egui::Context, force: bool) -> Vec { let id = egui::Id::new("fx-user-presets"); @@ -276,13 +517,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0)); let mut q = app.ui.effects_search.clone(); - widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t); + let search_hint = tr("Search effects and presets"); + let search = widgets::search_field(ui, sr, &mut q, search_hint, &t); + a11y::text_input(&search, search_hint, &q); app.ui.effects_search = q.clone(); app.auto.add("effects.search", sr, "Search"); // Contents menu. let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0)); let mpop = egui::Id::new("fx-contents-menu"); - if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() { + let menu_resp = widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")); + a11y::menu_button(&menu_resp, tr("Effects & Presets options"), widgets::popup_is_open(ui, mpop)); + if menu_resp.clicked() { widgets::open_popup(ui, mpop); } app.auto.add("effects.menu", mr, "Effects & Presets options"); @@ -314,6 +559,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.separator(); } else { let resp = ui.selectable_label(false, label); + // The ✓ and the padding are the menu's drawing: say it as a state. + let checkable = matches!(i, 0 | 1 | 2 | 4 | 6 | 7); + a11y::menu_item(&resp, label.trim_start_matches('✓').trim(), checkable.then(|| label.starts_with('✓'))); + if i == 0 { + widgets::focus_when_opened(ui, mpop, &resp); + } if i == 12 { app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path"); } @@ -328,6 +579,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None }; if let Some(i) = choice { + if widgets::by_keyboard(ui) { + menu_resp.request_focus(); + } let v = &mut app.ui.effects_view; match i { 0 => v.depth = "all".into(), @@ -354,6 +608,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let searching = !query.is_empty(); let mut y = list.min.y - scroll.offset; let mut apply: Option = None; + let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::(cursor_id())), offset: scroll.offset, list_top: list.min.y }; let reg = effectcraft_engine::effects::all(); let keep = |e: &effectcraft_engine::effects::EffectSpec| { depth_keeps(&view.depth, e) @@ -363,17 +618,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Favorites and Recently Used. let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); - if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) { + if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "favorites", "Favorites", 1, 0.0, searching, Some(Icon::Star)) { let mut favs = favs; favs.sort_by_key(|e| e.name); for e in favs { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.favorites", &mut apply); } } let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect(); - if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) { + if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, &mut nav, "recent", "Recently Used", 1, 0.0, false, None) { for e in recent { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.recent", &mut apply); } } @@ -392,21 +647,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let files: Vec = user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect(); let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty(); - if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) { - if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) { + if any && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets", "* Animation Presets", 1, 0.0, searching, None) { + if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/Text", "Text", 2, 14.0, searching, None) { for (id, name) in text { - preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply); + preset_row( + app, + ui, + &lp, + list, + &mut y, + &mut nav, + &name, + &format!("effects.preset.text.{id}"), + &query, + Apply::TextPreset(id.clone()), + &mut apply, + ); } } for cat in &categories { - if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) { + if !folder_row(app, ui, &lp, list, &mut y, &mut nav, &format!("presets/{cat}"), cat, 2, 14.0, searching, None) { continue; } for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) { - preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, Apply::Builtin(id.clone()), &mut apply); + preset_row( + app, + ui, + &lp, + list, + &mut y, + &mut nav, + name, + &format!("effects.preset.builtin.{id}"), + &query, + Apply::Builtin(id.clone()), + &mut apply, + ); } } - if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) { + if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/User", "User Presets", 2, 14.0, searching, None) { for f in files { preset_row( app, @@ -414,6 +693,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { &lp, list, &mut y, + &mut nav, &f.name, &format!("effects.preset.file.{}", f.name), &query, @@ -430,7 +710,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect(); all.sort_by_key(|e| e.name.to_lowercase()); for e in all { - effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 1, -16.0, &query, "effects.item", &mut apply); } } else { let mut cats: Vec<&str> = effectcraft_engine::effects::categories(); @@ -442,11 +722,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // The folder draws the category in the interface language; `cat` stays the key (the // open/closed state and the automation id are the registry's English name). - if !folder_row(app, ui, &lp, list, &mut y, cat, &crate::i18n::effects::category(cat), 0.0, searching, None) { + if !folder_row(app, ui, &lp, list, &mut y, &mut nav, cat, &crate::i18n::effects::category(cat), 1, 0.0, searching, None) { continue; } for e in items { - effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply); + effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.item", &mut apply); } } } @@ -455,6 +735,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let content = y + scroll.offset - list.min.y; scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); + if let Some(a) = list_keys(app, &ctx, &nav, list) { + apply = Some(a); + } if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() { diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69e..871c9dfeb 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -20,6 +20,7 @@ use effectcraft_engine::project::render_templates::RenderTemplates; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::Tokens; use crate::widgets::check_label as mark; use crate::{EffectcraftApp, widgets}; @@ -52,6 +53,11 @@ pub fn utc_stamp(unix: u64) -> String { format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60) } +/// A queue item's row: its click area, keyboard focus and accessibility node. +fn row_id(item: u64) -> egui::Id { + egui::Id::new(("rq-row", item)) +} + fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { match s { RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c), @@ -456,14 +462,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0)); let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0)); let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty(); - if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render { + let render_resp = widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")); + a11y::button(&render_resp, "Render", can_render); + if render_resp.clicked() && can_render { actions.push(("renderQueue.render", json!({"wait": false}))); } app.auto.add("renderQueue.render", render_b, "Render"); // Notify (sound + notification when the queue finishes). let notify = app.session.project.render_prefs.notify; let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0)); - if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() { + let notify_resp = widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")); + a11y::check_box(¬ify_resp, tr("Notify"), notify, true); + if notify_resp.clicked() { actions.push(("renderQueue.setNotify", json!({"notify": !notify}))); } p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim); @@ -496,8 +506,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }; - p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text); + p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, &bar_text, Tokens::ui(11.5), t.text); app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0)); + a11y::progress(&ui.interact(bar, egui::Id::new("rq-progress"), Sense::hover()), &bar_text, frac); // ---------------------------------------------------------- column header let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H)); @@ -534,6 +545,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if queue.is_empty() { lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint); } + let ctx = ui.ctx().clone(); + // Where each item's row starts in the scrolled content, for the keys. + let mut tops: Vec<(u64, f32)> = Vec::new(); let editing_id = egui::Id::new("rq-edit-output"); let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id)); for (k, it) in queue.iter().enumerate() { @@ -553,11 +567,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { t.row_alt }, ); - let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click()); - if row_resp.clicked() { + let row_resp = ui.interact(row, row_id(it.id), Sense::click()); + tops.push((it.id, row.min.y - list.min.y + scroll.offset)); + if a11y::take_pending_focus(&ctx, "rq", row_resp.id) { + row_resp.request_focus(); + } + if row_resp.clicked() || row_resp.gained_focus() { selected = Some(it.id); app.ui.focused = crate::dock::PanelKind::RenderQueue; } + if row_resp.has_focus() { + lp.rect_stroke(row.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + let comp_item = app.session.project.item(it.comp); + let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); let item_id = it.id; crate::widgets::context_menu(&row_resp, |ui| { if ui.button(tr("Duplicate")).clicked() { @@ -580,7 +603,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&aid("row"), row, &format!("#{n}")); let is_open = open.contains(&it.id); let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0)); - if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() { + let tw_resp = widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t); + a11y::disclosure(&tw_resp, &tr_args(if is_open { "Hide details of {}" } else { "Show details of {}" }, &[&name]), is_open); + if tw_resp.clicked() { if is_open { open.retain(|i| *i != it.id); } else { @@ -589,15 +614,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&aid("twirl"), tw, "Twirl"); let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0)); - if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering { + let cb_resp = widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))); + a11y::check_box(&cb_resp, &format!("{}: {name}", tr("Render")), it.render, !rendering); + if cb_resp.clicked() && !rendering { actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render}))); } app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" }); - let comp_item = app.session.project.item(it.comp); let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint); lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col); lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim); - let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text); let status = if rendering && let Some(pr) = progress.as_ref().filter(|p| p.current == Some(it.id)) { format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }) @@ -610,6 +635,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e); } app.auto.add(&aid("status"), st_rect, &status); + a11y::tree_item(&row_resp, &format!("{n}. {name}, {status}"), is_sel, 1, Some(is_open)); if let Some(s) = it.started { lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim); } else { @@ -641,11 +667,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0)); let did = egui::Id::new(("rq-dd", it.id, sub)); let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() }; - if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering { + let dd_resp = widgets::dropdown(ui, dd, &shown, &t, did); + a11y::combo_box(&dd_resp, label.trim_end_matches(':'), &summary, !rendering); + if dd_resp.clicked() && !rendering { widgets::open_popup(ui, did); } let labels: Vec = menu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) { + if widgets::by_keyboard(ui) { + dd_resp.request_focus(); + } let mut params = menu[i].1.clone(); if let Some(f) = params.get("form").and_then(Value::as_str) { forms.push((it.clone(), f.to_string())); @@ -673,11 +704,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0)); lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim); let lid = egui::Id::new(("rq-log", it.id)); - if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering { + let log_resp = widgets::dropdown(ui, ld, it.log.label(), &t, lid); + a11y::combo_box(&log_resp, tr("Log:").trim_end_matches(':'), it.log.label(), !rendering); + if log_resp.clicked() && !rendering { widgets::open_popup(ui, lid); } let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect(); if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) { + if widgets::by_keyboard(ui) { + log_resp.request_focus(); + } actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()}))); } app.auto.add(&aid("log"), ld, it.log.label()); @@ -688,13 +724,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim); let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0)); let oid = egui::Id::new(("rq-out", it.id)); - if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering { + let out_resp = widgets::dropdown(ui, odd, "", &t, oid); + a11y::combo_box( + &out_resp, + tr("Output To:").trim_end_matches(':'), + &app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()), + !rendering, + ); + if out_resp.clicked() && !rendering { widgets::open_popup(ui, oid); } app.auto.add(&aid("outputTo"), odd, "Output To"); let omenu = output_to_menu(it); let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect(); if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) { + if widgets::by_keyboard(ui) { + out_resp.request_focus(); + } if omenu[i].1.get("choose").is_some() { match pick_output(app, it) { Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))), @@ -712,6 +758,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect)); let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5))); + a11y::text_input(&resp, tr("Output file name template"), &buf); if resp.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() { actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()}))); @@ -724,6 +771,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()); let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click()); + a11y::button(&resp, &tr_args("Output file: {}", &[&shown]), !rendering); let col = if resp.hovered() { t.accent_hover } else { t.hot_text }; lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col); // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a @@ -753,6 +801,69 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => d.remove::<(u64, String)>(editing_id), }); + // Keyboard: Up / Down move between the queued items, Home / End go to the first / last, Right + // and Left open and close the item's settings, Enter and Space do the same (Space only with a + // row focused: otherwise it plays the preview). The focus follows the selection. + let focused = ctx.memory(|m| m.focused()); + let on_row = focused.is_some_and(|f| queue.iter().any(|i| row_id(i.id) == f)); + let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::RenderQueue; + if (on_row || by_mouse) && editing.is_none() && app.dialog.is_none() && !widgets::any_menu_open(&ctx) { + let keys: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect() + }); + let mut moved = None; + for key in keys { + use egui::Key; + let at = selected.and_then(|s| queue.iter().position(|i| i.id == s)); + let Some(last) = queue.len().checked_sub(1) else { break }; + let to = match key { + Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))), + Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))), + Key::Home if on_row => Some(0), + Key::End if on_row => Some(last), + _ => None, + }; + if let Some(item) = to.and_then(|to| queue.get(to)) { + selected = Some(item.id); + moved = Some(item.id); + continue; + } + let Some(item) = selected.filter(|s| queue.iter().any(|i| i.id == *s)) else { continue }; + match key { + Key::ArrowRight if !open.contains(&item) => open.push(item), + Key::ArrowLeft => open.retain(|i| *i != item), + Key::Enter | Key::Space if on_row || key == Key::Enter => { + if open.contains(&item) { + open.retain(|i| *i != item); + } else { + open.push(item); + } + } + _ => {} + } + } + if let Some(item) = moved { + if let Some((_, top)) = tops.iter().find(|(id, _)| *id == item) { + widgets::PanelScroll::reveal(&ctx, egui::Id::new("rq-scroll"), *top, *top + ROW_H, list.height()); + } + app.ui.focused = crate::dock::PanelKind::RenderQueue; + if on_row { + a11y::focus_next_pass(&ctx, "rq", row_id(item)); + } + ctx.request_repaint(); + } + // The arrows are the queue's, not egui's spatial focus moves. + if on_row { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + } + // Delete removes the selected item when the panel has focus. if app.ui.focused == crate::dock::PanelKind::RenderQueue && editing.is_none() @@ -760,7 +871,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(id) = selected && app.dialog.is_none() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() + // (A focused queue row is not typing; any other focused widget may be.) + && (on_row || !ui.ctx().egui_wants_keyboard_input()) { actions.push(("renderQueue.remove", json!({"item": id}))); selected = None; @@ -790,7 +902,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { open.retain(|id| queue.iter().any(|i| i.id == *id)); ui.data_mut(|d| d.insert_temp(state_id, (open, selected))); - let ctx = ui.ctx().clone(); for (it, f) in forms { open_menu_form(app, &it, &f); } diff --git a/crates/ui-egui/src/panels/rq_templates.rs b/crates/ui-egui/src/panels/rq_templates.rs index f03771133..a090a89fe 100644 --- a/crates/ui-egui/src/panels/rq_templates.rs +++ b/crates/ui-egui/src/panels/rq_templates.rs @@ -11,6 +11,7 @@ use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKin use egui::{Color32, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -63,6 +64,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.selectable_value(&mut pick, n.clone(), n); } }); + a11y::combo_box(&r.response, slot.label(), &cur, true); app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur); if pick != cur { actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick}))); @@ -80,6 +82,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.selectable_value(&mut st.selected, n.clone(), n); } }); + a11y::combo_box(&r.response, tr("Settings Name:").trim_end_matches(':'), &st.selected, true); app.auto.add("templates.select", r.response.rect, &st.selected); }); let builtin = RenderTemplates::is_builtin(k, &st.selected) @@ -135,6 +138,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.horizontal(|ui| { ui.label(tr("New from Render Queue item:")); let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text(tr("template name")).desired_width(180.0)); + a11y::text_input(&r, tr("template name"), &st.new_name); app.auto.add("templates.newName", r.rect, &st.new_name); let item = queue.last().map(|i| i.id); let ok = item.is_some() && !st.new_name.trim().is_empty(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b2d2ca435..8020dfd3a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -13,6 +13,7 @@ use effectcraft_engine::segment::Task; use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; +use crate::a11y; use crate::theme::{ThemeKind, Tokens}; use crate::{Dialog, EffectcraftApp}; @@ -78,6 +79,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.horizontal(|ui| { ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:"))); let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() }; + let shown_name = shown.clone(); let r = egui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| { if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() { acts.push(Act::Set(key.into(), json!(""))); @@ -88,6 +90,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } } }); + a11y::combo_box(&r.response, tr("Default Output:").trim_end_matches(':'), &shown_name, true); reg(app, &format!("settings.{key}"), &r.response, "Default Output"); }); if devices.is_empty() { @@ -103,6 +106,8 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let id = format!("settings.{key}"); let tip = (!live).then(|| tr("Stored, but not used by EffectCraft yet")); let mut resp: Option = None; + // What the control is set to, for assistive technology. + let mut value_text = String::new(); match kind { Kind::Bool => { let mut b = v.as_bool().unwrap_or(false); @@ -157,6 +162,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value Kind::Choice(opts) => { let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string); let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string(); + value_text = tr(&shown).to_string(); egui::ComboBox::from_id_salt(("settings", key)) .selected_text(tr(&shown)) .width(220.0) @@ -172,6 +178,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value Kind::Text | Kind::Path => { let mut s = v.as_str().unwrap_or("").to_string(); let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 })); + value_text = s.clone(); if r.changed() { acts.push(Act::Set(key.into(), json!(s))); } @@ -189,6 +196,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let mut f = c.map(|v| v as f32 / 255.0); let r = crate::widgets::srgb_color_button(ui, &mut f); let rgb = if r.changed() { srgb_bytes(f) } else { c }; + value_text = hex(rgb); ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim)); if rgb != c { acts.push(Act::Set(key.into(), json!(hex(rgb)))); @@ -202,6 +210,17 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value }); } } + if let Some(r) = &resp { + // The field's label is drawn beside it, not on it: give the control its name. + let name = tr(label); + match kind { + Kind::Bool => {} + Kind::Choice(_) => a11y::combo_box(r, name, &value_text, true), + Kind::Text | Kind::Path => a11y::text_input(r, name, &value_text), + Kind::Color => a11y::name_value(r, name, &value_text), + _ => a11y::name(r, name), + } + } if let Some(r) = resp { let r = match tip { Some(tip) => r.on_hover_text(tip), @@ -389,6 +408,7 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T } }) .response; + a11y::combo_box(&r, tr("Appearance Mode"), tr(shown), true); reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode"); }); ui.add_space(6.0); @@ -599,12 +619,14 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu let mut f = c.map(|v| v as f32 / 255.0); let r = crate::widgets::srgb_color_button(ui, &mut f); let rgb = if r.changed() { srgb_bytes(f) } else { c }; + a11y::name_value(&r, &tr_args("Label {} color", &[&(i + 1)]), &hex(rgb)); app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour"); if rgb != c { acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb)))); } let mut name = l["name"].as_str().unwrap_or("").to_string(); let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name)); + a11y::text_input(&r, &tr_args("Label {} name", &[&(i + 1)]), &name); app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name"); if r.changed() { acts.push(Act::Set(format!("labels.{i}.name"), json!(name))); @@ -664,9 +686,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.vertical(|ui| { ui.set_width(180.0); egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { - for p in &all { - let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click()); + // One Tab stop for the list (the open page); Up / Down / Home / End move + // between the pages and the focus goes with them. + let ctx = ui.ctx().clone(); + let page_item = |id: &str| egui::Id::new(("settings-page", id.to_string())); + let on_list = all.iter().any(|q| ctx.memory(|m| m.has_focus(page_item(q.id)))); + for (n, p) in all.iter().enumerate() { let sel = page == p.id; + let r = ui.allocate_space(vec2(176.0, 24.0)).1; + let resp = ui.interact(r, page_item(p.id), if sel { egui::Sense::click() } else { egui::Sense::CLICK }); + a11y::list_item(&resp, tr(p.title), sel, n + 1, all.len()); + if a11y::take_pending_focus(&ctx, "settings", resp.id) { + resp.request_focus(); + } if sel { ui.painter().rect_filled(r, 3.0, t.accent); } else if resp.hovered() { @@ -679,11 +711,44 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { Tokens::ui(12.5), if sel { Color32::WHITE } else { t.text }, ); + if resp.has_focus() { + ui.painter().rect_stroke(r.shrink(1.0), 3.0, egui::Stroke::new(2.0, t.text), egui::StrokeKind::Inside); + } app.auto.add(&format!("settings.page.{}", p.id), r, p.title); if resp.clicked() { page = p.id.to_string(); } } + if on_list { + let at = all.iter().position(|q| q.id == page).unwrap_or(0); + let last = all.len().saturating_sub(1); + let mut to = at; + for key in ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect::>() + }) { + to = match key { + egui::Key::ArrowDown => to.saturating_add(1).min(last), + egui::Key::ArrowUp => to.saturating_sub(1), + egui::Key::Home => 0, + egui::Key::End => last, + _ => to, + }; + } + if to != at + && let Some(q) = all.get(to) + { + page = q.id.to_string(); + a11y::focus_next_pass(&ctx, "settings", page_item(q.id)); + } + // The arrows are the list's, not egui's spatial focus moves. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } }); }); ui.separator(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d0..d4994b7d1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -245,6 +245,10 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)] }; ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col))); + // A button that opens and closes something; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::Button); + crate::a11y::expanded(&resp, open); + focus_outline(ui, &resp, t); resp } @@ -258,6 +262,10 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id if on || resp.hovered() { icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint }); } + // A switch; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::Button); + crate::a11y::checked(&resp, on); + focus_outline(ui, &resp, t); resp } @@ -277,6 +285,8 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens t.icon }; icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), icon, col); + // An icon has no text: the caller names the button. + crate::a11y::role(&resp, egui::accesskit::Role::Button); focus_outline(ui, &resp, t); resp } @@ -318,7 +328,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea } let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); - child.add( + let resp = child.add( egui::TextEdit::singleline(text) .hint_text(hint) .frame(egui::Frame::NONE) @@ -327,7 +337,10 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), - ) + ); + // The hint is the field's visible name (the caller can give it a better one). + crate::a11y::name(&resp, hint); + resp } /// Dropdown-looking control ("Full ▾"). @@ -358,6 +371,10 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: & }; ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text); icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim); + // A drop-down showing `text`; the caller names what it sets. + crate::a11y::role(&resp, egui::accesskit::Role::ComboBox); + crate::a11y::name_value(&resp, "", text); + focus_outline(ui, &resp, t); if let Some(tip) = tip { return resp.on_hover_text(tip.to_string()); } @@ -379,6 +396,8 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token }; ui.painter().rect_filled(rect, rect.height() / 2.0, bg); ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), if primary { Color32::WHITE } else { t.text }); + crate::a11y::role_name(&resp, egui::accesskit::Role::Button, text); + focus_outline(ui, &resp, t); resp } @@ -392,6 +411,10 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> let p = |x: f32, y: f32| pos2(b.min.x + x * b.width(), b.min.y + y * b.height()); ui.painter().line(vec![p(0.2, 0.52), p(0.42, 0.74), p(0.8, 0.28)], Stroke::new(1.6, Color32::WHITE)); } + // A check box; the caller names it. + crate::a11y::role(&resp, egui::accesskit::Role::CheckBox); + crate::a11y::checked(&resp, on); + focus_outline(ui, &resp, t); resp } @@ -411,7 +434,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0); let travel = (len - thumb_len).max(1.0); let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 }; - let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag()); + // Dragged with the mouse only: a Tab stop that no key could move would be a trap. The panels + // scroll with their own keys (the arrows, Page Up / Page Down) and the wheel. + let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::DRAG); let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed()); let mut s = scroll; if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) { @@ -455,6 +480,22 @@ impl PanelScroll { Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) } } + /// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to + /// `bottom` is in view of a `view` tall list, from the next pass. + pub fn reveal(ctx: &egui::Context, id: egui::Id, top: f32, bottom: f32, view: f32) { + let (offset, max): (f32, f32) = ctx.data(|d| d.get_temp(id)).unwrap_or_default(); + let wanted = if top < offset { + top + } else if bottom > offset + view { + bottom - view + } else { + offset + } + .max(0.0); + // (`max` is last frame's overflow; the next `end` measures it again.) + ctx.data_mut(|d| d.insert_temp(id, (wanted, max.max(wanted)))); + } + /// The rows were `content` points tall: keep the offset, with the scroll bar registered for /// automation as `key`. pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) { @@ -537,6 +578,30 @@ pub fn label_popup( }) } +/// This pass's input has no mouse press or release: whatever just happened (a control activated, +/// a list opened, an entry chosen) came from the keyboard or an assistive technology. +pub fn by_keyboard(ui: &Ui) -> bool { + !ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released()) +} + +/// Put the keyboard on `entry` of the popup list `id` the pass the list opens, so Up / Down and +/// Enter work it and Escape closes it. (The pass count tells a fresh opening from a list that has +/// been open for some passes.) +pub fn focus_when_opened(ui: &Ui, id: egui::Id, entry: &Response) { + let key = id.with("focus-pass"); + let pass = ui.ctx().cumulative_pass_nr(); + let previous = ui.data_mut(|d| { + let previous = d.get_temp::(key); + d.insert_temp(key, pass); + previous + }); + // A list opened with the mouse keeps the keyboard where it was: a widget with the focus + // pauses the application's shortcuts (Space, Ctrl+Z) until the focus is lost. + if previous.is_none_or(|p| p.saturating_add(1) < pass) && by_keyboard(ui) { + entry.request_focus(); + } +} + /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { if !popup_is_open(ui, id) { @@ -546,11 +611,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| { ui.set_min_width(160.0); let mut chosen = None; + let mut first = true; for (i, o) in options.iter().enumerate() { if o == "-" { ui.separator(); - } else if ui.selectable_label(current == Some(i), o).clicked() { - chosen = Some(i); + } else { + let r = ui.selectable_label(current == Some(i), o); + // The keyboard starts on the current entry (the first one, with none current). + if current == Some(i) || (current.is_none() && first) { + focus_when_opened(ui, id, &r); + } + first = false; + if r.clicked() { + chosen = Some(i); + } } } chosen diff --git a/crates/ui-egui/tests/ui_accessibility_audit.rs b/crates/ui-egui/tests/ui_accessibility_audit.rs new file mode 100644 index 000000000..012bf914f --- /dev/null +++ b/crates/ui-egui/tests/ui_accessibility_audit.rs @@ -0,0 +1,377 @@ +//! Accessibility audit (docs/gaps.md gap 25): renders panels headless and walks the AccessKit +//! tree of the frame. Every interactive node (button, check box, text input, combo box, slider, +//! spin button, tree or list item, tab, link, ...) must have a non-empty name, a role that says +//! what it is, and the state that role needs (checked, selected, a value). +//! +//! * The panels that had their pass (`FIXED`: Effects & Presets, Settings, Render Queue with its +//! Templates dialog) must have **zero** findings. +//! * Every other panel is counted and printed (run with `--nocapture`), and checked against +//! [`BASELINE`], a ratchet that can only shrink: fixing a panel lowers its number, and a number +//! must never go up. When a panel's count falls, lower its entry (the test says so). +//! +//! What this does not measure: how a real screen reader speaks the tree (none was run), colour +//! contrast, focus order or keyboard operation (the `ui_*_keyboard.rs` tests cover those), or +//! widgets that are scrolled out of view and not drawn. + +use std::collections::HashSet; + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::{Action, Role, Toggled}; +use egui::{Rect, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::NodeT; +use serde_json::json; + +/// Groups that must be clean. A group is the first part of a scenario name (before the `:`). +const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project"]; + +/// (scenario, findings, of them unnamed controls) per not-yet-fixed scenario, as measured when +/// this test was written (Effect Controls with one Gaussian Blur on the layer, every other panel +/// as it opens). A ratchet: a number may only go down, never up; when a pass over a panel lowers +/// it, lower its entry in the same change, and remove the entry (and add the panel to `FIXED`) +/// when it reaches zero. A scenario that is not listed is held to zero. +const BASELINE: &[(&str, usize, usize)] = &[ + ("EffectControls:default", 25, 15), + ("Composition:default", 16, 14), + ("Layer:default", 4, 3), + ("Timeline:default", 83, 55), + ("Audio:default", 4, 2), + ("Preview:default", 5, 5), + ("Properties:default", 45, 28), + ("Character:default", 13, 9), + ("Paragraph:default", 24, 12), + ("Align:default", 18, 11), + ("Tracker:default", 1, 1), + ("Wiggler:default", 2, 2), + ("Smoother:default", 2, 1), + ("MotionSketch:default", 4, 2), + ("Paint:default", 5, 3), + ("Brushes:default", 16, 9), + ("Flowchart:default", 8, 4), + ("History:default", 10, 5), + ("Markers:default", 1, 1), + ("MaskInterpolation:default", 2, 2), + ("ScriptConsole:default", 5, 3), + ("EssentialGraphics:default", 2, 2), + ("Footage:default", 3, 2), + ("Progress:default", 1, 1), + ("VrCompEditor:default", 13, 7), +]; + +/// How many controls the fixed scenarios must at least show, so that "zero findings" is never +/// the result of looking at nothing. Any scenario not listed needs 2. +const MIN_CONTROLS: &[(&str, usize)] = &[ + ("EffectsPresets:default", 10), + ("EffectsPresets:open", 10), + ("EffectsPresets:search", 5), + ("EffectsPresets:menu", 8), + ("Settings:general", 12), + ("Settings:appearance", 15), + ("Settings:labels", 30), + ("RenderQueue:queued", 8), + ("RenderQueue:expanded", 12), + ("RenderQueue:menu", 20), + ("RenderQueue:templates:renderSettings", 8), + ("RenderQueue:templates:outputModule", 8), +]; + +/// The roles that are controls: anything with one of these (or with a click or focus action) is +/// held to the rules below. +const CONTROL_ROLES: &[Role] = &[ + Role::Button, + Role::CheckBox, + Role::ColorWell, + Role::ComboBox, + Role::Link, + Role::ListBoxOption, + Role::ListItem, + Role::MenuItem, + Role::MenuItemCheckBox, + Role::MenuItemRadio, + Role::MultilineTextInput, + Role::RadioButton, + Role::SearchInput, + Role::Slider, + Role::SpinButton, + Role::Switch, + Role::Tab, + Role::TextInput, + Role::TreeItem, +]; + +#[derive(Debug, Clone)] +struct Finding { + scenario: String, + problem: &'static str, + role: Role, + name: String, + rect: [i32; 4], +} + +impl std::fmt::Display for Finding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}: {:?} {:?} at {:?}", self.scenario, self.problem, self.role, self.name, self.rect) + } +} + +fn app_session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Blue", "color": "#4040c0"})).unwrap(); + s.execute("effect.apply", json!({"effect": "Gaussian Blur"})).ok(); + s +} + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(app_session())); + h.run_steps(4); + h +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(4); +} + +fn panel_rect(h: &Harness<'_, EffectcraftApp>, p: PanelKind) -> Option { + let e = h.state().auto.find(&format!("panel.{}", p.id()))?; + Some(Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) +} + +fn dialog_rect(h: &Harness<'_, EffectcraftApp>, title: &str) -> Option { + h.ctx.memory(|m| m.area_rect(egui::Id::new(("modal", title)))) +} + +/// The ids of every node now, so a later audit can look at what appeared since (a popup). +fn node_ids(h: &Harness<'_, EffectcraftApp>) -> HashSet { + h.root().children_recursive().map(|n| format!("{:?}", n.accesskit_node().id())).collect() +} + +/// Walk the tree and check every control whose centre is inside `scope` (and, when `since` is +/// given, that was not in the tree before). +fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Option<&HashSet>) -> (Vec, usize) { + let mut out = Vec::new(); + let mut controls = 0; + for n in h.root().children_recursive() { + let a = n.accesskit_node(); + if a.is_hidden() { + continue; + } + let Some(b) = a.bounding_box() else { + // No bounds: assistive technology cannot place it. Only a control is a finding (and + // it is one wherever it is: there is no position to scope it by). + if CONTROL_ROLES.contains(&a.role()) { + out.push(Finding { scenario: scenario.to_string(), problem: "no bounds", role: a.role(), name: a.label().unwrap_or_default(), rect: [0; 4] }); + } + continue; + }; + if !scope.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) { + continue; + } + if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) { + continue; + } + let role = a.role(); + let actionable = a.data().supports_action(Action::Click) || a.data().supports_action(Action::Focus); + let control = CONTROL_ROLES.contains(&role); + if !control && !(actionable && matches!(role, Role::Unknown | Role::GenericContainer)) { + continue; + } + let name = a.label().unwrap_or_default(); + controls += 1; + let finding = |problem: &'static str| Finding { + scenario: scenario.to_string(), + problem, + role, + name: name.clone(), + rect: [b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32], + }; + if !control && matches!(role, Role::Unknown | Role::GenericContainer) { + // Something you can click or focus, that no role describes. + out.push(finding("clickable or focusable without a control role")); + } + if name.trim().is_empty() { + out.push(finding("no name")); + } + let state_missing = match role { + Role::CheckBox | Role::RadioButton | Role::Switch | Role::MenuItemCheckBox | Role::MenuItemRadio => { + a.toggled().is_none() || a.toggled() == Some(Toggled::Mixed) + } + Role::Slider | Role::SpinButton => a.numeric_value().is_none(), + Role::ComboBox => a.value().is_none_or(|v| v.trim().is_empty()), + Role::TreeItem | Role::ListItem | Role::ListBoxOption | Role::Tab => a.is_selected().is_none(), + _ => false, + }; + if state_missing { + out.push(finding("no state or value")); + } + } + (out, controls) +} + +fn count_unnamed(f: &[Finding]) -> usize { + f.iter().filter(|f| f.problem == "no name").count() +} + +struct Report { + findings: Vec, + /// (scenario, findings, of them unnamed, controls looked at) + rows: Vec<(String, usize, usize, usize)>, +} + +impl Report { + fn add(&mut self, scenario: &str, (f, controls): (Vec, usize)) { + self.rows.push((scenario.to_string(), f.len(), count_unnamed(&f), controls)); + self.findings.extend(f); + } +} + +fn group(scenario: &str) -> &str { + scenario.split(':').next().unwrap_or(scenario) +} + +fn collect() -> Report { + let mut r = Report { findings: vec![], rows: vec![] }; + + // ---- Effects & Presets: closed, everything open, searching, the contents menu. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::EffectsPresets).expect("Effects & Presets is drawn"); + r.add("EffectsPresets:default", audit(&h, "EffectsPresets:default", rect, None)); + { + let s = h.state_mut(); + s.ui.effects_favorites.insert("ec.blur.gaussian".into()); + s.ui.effects_open.insert("favorites".into()); + s.ui.effects_open.insert("presets".into()); + s.ui.effects_open.insert("presets/Text".into()); + s.ui.effects_open.insert("Blur & Sharpen".into()); + } + h.run_steps(4); + r.add("EffectsPresets:open", audit(&h, "EffectsPresets:open", rect, None)); + h.state_mut().ui.effects_search = "blur".into(); + h.run_steps(4); + r.add("EffectsPresets:search", audit(&h, "EffectsPresets:search", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("fx-contents-menu").with("open"), true)); + h.run_steps(4); + let screen = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(1600.0, 1000.0)); + r.add("EffectsPresets:menu", audit(&h, "EffectsPresets:menu", screen, Some(&before))); + + // ---- Settings: every page. + let mut h = harness(); + let before = node_ids(&h); + for page in effectcraft_engine::prefs::pages() { + invoke(&mut h, "app.settings", json!({"page": page.id})); + let name = format!("Settings:{}", page.id); + let rect = dialog_rect(&h, "Settings").expect("the Settings dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + + // ---- Render Queue: empty, queued and expanded, a menu open, the Templates dialogs. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::RenderQueue).expect("the Render Queue is drawn"); + r.add("RenderQueue:empty", audit(&h, "RenderQueue:empty", rect, None)); + let comp = h.state().session.active_comp_id().expect("a comp").0; + let mut items = vec![]; + for _ in 0..2 { + let out = h.state_mut().session.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + items.push(out["item"].as_u64().or_else(|| out["items"][0].as_u64()).unwrap_or(0)); + } + h.run_steps(4); + r.add("RenderQueue:queued", audit(&h, "RenderQueue:queued", rect, None)); + let first = h.state().session.project.render_queue.first().map(|i| i.id).expect("a queue item"); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (vec![first], Some(first)))); + h.run_steps(4); + r.add("RenderQueue:expanded", audit(&h, "RenderQueue:expanded", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("rq-dd", first, 0)).with("open"), true)); + h.run_steps(4); + r.add("RenderQueue:menu", audit(&h, "RenderQueue:menu", screen, Some(&before))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("rq-dd", first, 0)).with("open"), false)); + let before = node_ids(&h); + for (kind, title) in [("renderSettings", "Render Settings Templates"), ("outputModule", "Output Module Templates")] { + invoke(&mut h, "app.templates", json!({"kind": kind})); + let name = format!("RenderQueue:templates:{kind}"); + let rect = dialog_rect(&h, title).expect("the Templates dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + let _ = items; + + // ---- Everything else, informational: each panel on its own. + let mut h = harness(); + for p in PanelKind::ALL { + if matches!(p, PanelKind::EffectsPresets | PanelKind::RenderQueue) { + continue; + } + h.state_mut().show_panel(p); + h.run_steps(3); + let name = format!("{}:default", p.id()); + let Some(rect) = panel_rect(&h, p) else { + r.rows.push((format!("{name} (not drawn)"), 0, 0, 0)); + continue; + }; + r.add(&name, audit(&h, &name, rect, None)); + } + r +} + +#[test] +fn fixed_panels_have_no_unnamed_or_roleless_controls_and_the_others_do_not_get_worse() { + let report = collect(); + println!("\nAccessibility audit: findings per scenario (unnamed in brackets)"); + let mut by_group: Vec<(String, usize, usize, usize)> = vec![]; + for (name, n, unnamed, controls) in &report.rows { + println!(" {name:<44} {n:>4} findings ({unnamed} unnamed) of {controls} controls"); + let g = group(name).to_string(); + match by_group.iter_mut().find(|(k, ..)| *k == g) { + Some(e) => { + e.1 += n; + e.2 += unnamed; + e.3 += controls; + } + None => by_group.push((g, *n, *unnamed, *controls)), + } + } + println!("\nBy panel (findings, unnamed controls, controls looked at):"); + for (g, n, unnamed, controls) in &by_group { + let state = if FIXED.contains(&g.as_str()) { "fixed: must be 0" } else { "not yet audited" }; + println!(" {g:<28} {n:>4} findings, {unnamed:>3} unnamed, {controls:>4} controls {state}"); + } + let total_unnamed: usize = by_group.iter().map(|(_, _, u, _)| u).sum(); + println!(" all panels: {total_unnamed} unnamed controls\n"); + + let mut failures = vec![]; + for f in &report.findings { + if FIXED.contains(&group(&f.scenario)) { + failures.push(f.to_string()); + } + } + assert!(failures.is_empty(), "{} accessibility findings in the fixed panels:\n{}", failures.len(), failures.join("\n")); + + // A clean panel must not be clean because nothing was looked at. + for (name, _, _, controls) in &report.rows { + if FIXED.contains(&group(name)) { + let min = MIN_CONTROLS.iter().find(|(k, _)| k == name).map_or(2, |(_, v)| *v); + assert!(*controls >= min, "{name}: only {controls} controls found in the tree (expected {min}); the scenario is not showing the panel"); + } + } + + for (name, n, unnamed, _) in &report.rows { + if FIXED.contains(&group(name)) { + continue; + } + let (allowed, allowed_unnamed) = BASELINE.iter().find(|(k, ..)| k == name).map_or((0, 0), |(_, v, u)| (*v, *u)); + assert!(*n <= allowed, "{name}: {n} findings, the baseline allows {allowed}; a panel must not get worse"); + assert!(*unnamed <= allowed_unnamed, "{name}: {unnamed} unnamed controls, the baseline allows {allowed_unnamed}; a panel must not get worse"); + if *n < allowed || *unnamed < allowed_unnamed { + println!("note: {name} improved to {n} findings ({unnamed} unnamed); lower its BASELINE entry from {allowed} ({allowed_unnamed})"); + } + } +} diff --git a/crates/ui-egui/tests/ui_contrast.rs b/crates/ui-egui/tests/ui_contrast.rs new file mode 100644 index 000000000..34352b5e5 --- /dev/null +++ b/crates/ui-egui/tests/ui_contrast.rs @@ -0,0 +1,92 @@ +//! Colour contrast of the theme tokens (docs/gaps.md gap 25), separate from the AccessKit audit: +//! the WCAG 2 contrast ratio of the text, accent and focus colours against the backgrounds they +//! are drawn on, for every theme. Text needs 4.5:1, the focus ring and control borders 3:1. +//! +//! Pairs that fall short today are listed in [`KNOWN_BELOW`] with the ratio measured when this was +//! written. The list is a ratchet: a pair must not get worse, and a pair that reaches its target +//! must leave the list. This checks the tokens, not what a panel really draws with them (text on +//! a label colour, an image, a disabled control) and not colour-blindness. + +use effectcraft_ui_egui::theme::{ThemeKind, Tokens}; +use egui::Color32; + +/// WCAG 2 relative luminance of an sRGB colour. +fn luminance(c: Color32) -> f64 { + let lin = |v: u8| { + let v = f64::from(v) / 255.0; + if v <= 0.039_28 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) +} + +/// WCAG 2 contrast ratio of two opaque colours. +fn ratio(a: Color32, b: Color32) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +/// (name, foreground, background, minimum ratio) +fn pairs(t: &Tokens) -> Vec<(&'static str, Color32, Color32, f64)> { + vec![ + ("text on panel", t.text, t.panel_bg, 4.5), + ("text on field", t.text, t.field_bg, 4.5), + ("text on selected row", t.text, t.row_selected, 4.5), + ("text on row", t.text, t.row, 4.5), + ("dim text on panel", t.text_dim, t.panel_bg, 4.5), + ("faint text on panel", t.text_faint, t.panel_bg, 4.5), + ("active tab text on panel", t.tab_text_active, t.panel_bg, 4.5), + ("tab text on header", t.tab_text, t.header_bg, 4.5), + ("hot text on panel", t.hot_text, t.panel_bg, 4.5), + ("white on accent (primary button)", Color32::WHITE, t.accent, 4.5), + ("danger text on panel", t.danger, t.panel_bg, 4.5), + ("warning text on panel", t.warning, t.panel_bg, 4.5), + ("accent (focus ring) on panel", t.accent, t.panel_bg, 3.0), + ("focus colour on panel", t.focus, t.panel_bg, 3.0), + ("field border on panel", t.field_border, t.panel_bg, 3.0), + ("icon on panel", t.icon, t.panel_bg, 3.0), + ] +} + +/// (theme, pair, ratio measured when written). Ratchet: never below the number; remove the entry +/// when the pair reaches its target. +const KNOWN_BELOW: &[(&str, &str, f64)] = &[ + ("dark", "white on accent (primary button)", 3.46), + ("dark", "danger text on panel", 3.90), + ("dark", "field border on panel", 1.38), + ("darker", "white on accent (primary button)", 3.46), + ("darker", "danger text on panel", 4.37), + ("darker", "field border on panel", 1.55), + ("light", "faint text on panel", 4.46), + ("light", "white on accent (primary button)", 3.46), + ("light", "danger text on panel", 3.23), + ("light", "warning text on panel", 1.85), + ("light", "accent (focus ring) on panel", 2.78), + ("light", "focus colour on panel", 2.78), + ("light", "field border on panel", 1.86), +]; + +#[test] +fn print_and_hold_the_theme_contrast() { + let mut failures = vec![]; + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + for (name, fg, bg, min) in pairs(&t) { + let r = ratio(fg, bg); + let known = KNOWN_BELOW.iter().find(|(k, n, _)| *k == kind.name() && *n == name); + println!("{:<7} {name:<36} {r:>5.2}:1 (needs {min})", kind.name()); + match known { + Some((_, _, floor)) => { + if r + 0.005 < *floor { + failures.push(format!("{} / {name}: {r:.2}:1 is worse than the known {floor:.2}:1", kind.name())); + } + if r >= min { + failures.push(format!("{} / {name}: {r:.2}:1 now reaches {min}:1; remove it from KNOWN_BELOW", kind.name())); + } + } + None if r < min => failures.push(format!("{} / {name}: {r:.2}:1 is below {min}:1", kind.name())), + None => {} + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/ui-egui/tests/ui_effects_presets_keyboard.rs b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs new file mode 100644 index 000000000..d6f1378d1 --- /dev/null +++ b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs @@ -0,0 +1,271 @@ +//! Effects & Presets keyboard operation and accessible names (docs/gaps.md gap 25): the list is one +//! Tab stop; the arrow keys, Home / End, Right / Left, Enter and Space work it; the focus follows +//! the cursor; rows are tree items with a name, a selected state, a depth and (folders) an open +//! state; the favourite star is a named button. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +/// The role and name of the node that has the keyboard focus. +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn selected_rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let panel = h.state().auto.find("panel.EffectsPresets").expect("the panel is drawn").rect; + let panel = egui::Rect::from_min_size(pos2(panel[0], panel[1]), egui::vec2(panel[2], panel[3])); + h.root() + .children_recursive() + .filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().is_selected() == Some(true)) + .filter(|n| n.accesskit_node().bounding_box().is_some_and(|b| panel.contains(pos2(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)))) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .collect() +} + +/// Put the keyboard on the first row the way Tab does: from the search field, the menu button, +/// then the list. +fn tab_into_list(h: &mut Harness<'_, EffectcraftApp>) { + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + key(h, Key::Tab, Modifiers::NONE); + assert_eq!(focused(h).map(|f| f.1), Some("Effects & Presets options".to_string()), "Tab leaves the search field for the options button"); + key(h, Key::Tab, Modifiers::NONE); +} + +#[test] +fn tab_reaches_the_list_once_and_leaves_it() { + let mut h = harness(); + tab_into_list(&mut h); + let (role, name) = focused(&h).expect("a row has the focus"); + assert_eq!(role, Role::TreeItem); + assert!(!name.is_empty()); + // The first row is a folder: no star. The next Tab leaves the list instead of walking the + // hundreds of effects. + key(&mut h, Key::Tab, Modifiers::NONE); + let after = focused(&h); + assert!(after.as_ref().is_none_or(|(r, _)| *r != Role::TreeItem), "the second Tab is past the list: {after:?}"); +} + +#[test] +fn arrows_home_end_move_the_cursor_and_the_focus() { + let mut h = harness(); + tab_into_list(&mut h); + let first = focused(&h).unwrap().1; + assert_eq!(selected_rows(&h), Vec::::new(), "Tab alone selects nothing"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).unwrap().1; + assert_ne!(first, second, "Down Arrow moves to the next row"); + assert_eq!(selected_rows(&h), vec![second.clone()], "the row the cursor is on is selected"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first); + key(&mut h, Key::End, Modifiers::NONE); + let last = focused(&h).unwrap().1; + assert_ne!(last, first, "End goes to the last row"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, last, "Down Arrow stays on the last row"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first, "Home goes to the first row"); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_ne!(focused(&h).unwrap().1, first, "Page Down moves a page"); + // The cursor row is scrolled into view: its node is on screen. + let name = focused(&h).unwrap().1; + let r = h.get_by_label(&name).rect(); + assert!(r.min.y >= 0.0 && r.max.y <= 1000.0, "the focused row is on screen: {r:?}"); +} + +#[test] +fn right_and_left_open_and_close_folders_and_step_through_the_tree() { + let mut h = harness(); + h.state_mut().ui.effects_search.clear(); + tab_into_list(&mut h); + // Walk to a category folder (not the presets folder). + let mut guard = 0; + loop { + let n = focused(&h).unwrap().1; + if n == "Blur & Sharpen" { + break; + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40, "no Blur & Sharpen folder reached"); + } + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(folder.accesskit_node().data().level(), Some(1)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Right Arrow opens the folder"); + assert_eq!(h.get_by_label("Blur & Sharpen").accesskit_node().data().is_expanded(), Some(true)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + let child = focused(&h).unwrap(); + assert_eq!(child.0, Role::TreeItem); + assert_ne!(child.1, "Blur & Sharpen", "Right Arrow on an open folder steps into it"); + assert_eq!(h.get_by_label(&child.1).accesskit_node().data().level(), Some(2)); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, "Blur & Sharpen", "Left Arrow on an effect steps out to its folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Left Arrow closes the folder"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Enter opens a closed folder"); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Space closes it again"); +} + +#[test] +fn enter_and_space_apply_an_effect_to_the_selected_layer() { + for apply_key in [Key::Enter, Key::Space] { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + let mut guard = 0; + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + assert!(h.state().session.state.last_effect.is_none()); + key(&mut h, apply_key, Modifiers::NONE); + let applied = h.state().session.state.last_effect.clone(); + assert!(applied.is_some(), "{apply_key:?} on `{effect}` applies it to the selected layer"); + let pb = &h.state().playback; + assert!(!pb.playing && !pb.caching && pb.plan.is_none(), "{apply_key:?} on a focused row is the row's, it does not start a preview"); + } +} + +#[test] +fn the_star_is_a_named_button_on_the_cursor_row_and_enter_toggles_it() { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, name) = focused(&h).expect("the star has the focus"); + assert_eq!(role, Role::Button); + assert_eq!(name, format!("Add to favorites: {effect}")); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().ui.effects_favorites.len(), 1, "Enter on the star adds the favourite"); + let (_, name) = focused(&h).expect("the star keeps the focus"); + assert_eq!(name, format!("Remove from favorites: {effect}")); +} + +#[test] +fn the_search_field_keeps_its_arrows_and_the_layer_does_not_nudge() { + let mut h = harness(); + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + h.get_by_label("Search effects and presets").type_text("blur"); + h.run_steps(3); + assert_eq!(h.state().ui.effects_search, "blur"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert!(selected_rows(&h).is_empty(), "arrows in the search field belong to the field"); + // A click on a row, then the arrows: the list moves, the selected layer stays where it is. + let layer_before = layer_position(&h); + h.state_mut().ui.effects_search.clear(); + h.run_steps(3); + click(&mut h, "effects.category.presets"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selected_rows(&h).len(), 1, "the arrows work after a click, without keyboard focus on a row"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(layer_position(&h), layer_before, "the arrow keys did not nudge the selected layer"); +} + +fn layer_position(h: &Harness<'_, EffectcraftApp>) -> String { + let s = &h.state().session; + let comp = s.active_comp().expect("a comp"); + let layer = comp.layers.first().expect("a layer"); + format!("{:?}", layer.transform()) +} + +#[test] +fn rows_and_controls_have_names_roles_and_states() { + let mut h = harness(); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.state_mut().ui.effects_favorites.insert("ec.blur.gaussian".into()); + h.run_steps(4); + assert_eq!(h.get_by_label("Search effects and presets").accesskit_node().role(), Role::TextInput); + let menu = h.get_by_label("Effects & Presets options"); + assert_eq!(menu.accesskit_node().role(), Role::Button); + assert_eq!(menu.accesskit_node().data().is_expanded(), Some(false), "the options button says its menu is closed"); + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().role(), Role::TreeItem); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(folder.accesskit_node().is_selected(), Some(false)); + let favorites = h.get_by_label("Favorites"); + assert_eq!(favorites.accesskit_node().data().is_expanded(), Some(false)); + // Every effect row in an open category is a leaf at depth 2. + let leaves: Vec<_> = + h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().data().level() == Some(2)).collect(); + assert!(!leaves.is_empty()); + assert!(leaves.iter().all(|n| n.accesskit_node().data().is_expanded().is_none()), "effects are leaves: they have no open state"); + assert!(h.query_by_label_contains("Remove from favorites: ").is_none(), "a favourite hidden in a closed Favorites folder draws no rows"); + assert!(h.query_all_by_label_contains("Add to favorites: ").count() > 1, "the effects' stars are named"); +} + +/// A pick from the options menu with the mouse leaves no widget with the keyboard focus, and Enter +/// after a click in the panel is the panel's: it opens or applies the row and does not start the +/// layer rename the Timeline binds to Enter. +#[test] +fn mouse_picks_leave_no_focus_and_enter_after_a_click_is_the_panels() { + let mut h = harness(); + click(&mut h, "effects.menu"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening the menu with the mouse takes no keyboard focus"); + h.get_by_label_contains("Alphabetical").click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + assert!(h.state().ui.effects_view.alphabetical); + h.state_mut().ui.effects_view.alphabetical = false; + h.run_steps(3); + click(&mut h, "effects.category.presets"); + let open = h.state().ui.effects_open.contains("presets"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().ui.effects_open.contains("presets"), open, "Enter toggles the clicked folder"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_render_queue_keyboard.rs b/crates/ui-egui/tests/ui_render_queue_keyboard.rs new file mode 100644 index 000000000..3f954b7b6 --- /dev/null +++ b/crates/ui-egui/tests/ui_render_queue_keyboard.rs @@ -0,0 +1,226 @@ +//! Render Queue keyboard operation and accessible names (docs/gaps.md gap 25): queue items are +//! tree items (named by number, comp and status; selected; open or closed); the arrow keys, Home / +//! End, Right / Left, Enter and Delete work them; the buttons, the check boxes, the twirls and the +//! drop-downs have names and states; a drop-down opens from the keyboard and closes with the focus +//! back on it; the Templates dialog is named. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + for name in ["Alpha", "Beta", "Gamma"] { + let comp = s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn queue_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.project.render_queue.len() +} + +fn row_name(h: &Harness<'_, EffectcraftApp>, n: usize) -> String { + h.query_all_by_role(Role::TreeItem).map(|r| r.accesskit_node().label().unwrap_or_default()).find(|l| l.starts_with(&format!("{n}. "))).unwrap_or_default() +} + +#[test] +fn rows_are_named_tree_items_with_selected_and_open_state() { + let mut h = harness(); + let first = row_name(&h, 1); + assert!(first.starts_with("1. Alpha, "), "number, comp and status: {first}"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().is_selected(), Some(false)); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(node.accesskit_node().data().level(), Some(1)); + click(&mut h, "renderQueue.item.1.twirl"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(true), "the open item says so"); + let twirl = h.get_by_label("Hide details of Alpha"); + assert_eq!(twirl.accesskit_node().role(), Role::Button); + assert_eq!(twirl.accesskit_node().data().is_expanded(), Some(true)); + // The settings drop-downs of the open item: what they set and what they are set to. + let settings = h.get_by_role_and_label(Role::ComboBox, "Render Settings"); + assert!(settings.accesskit_node().value().is_some_and(|v| !v.is_empty())); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output Module").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Log").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output To").is_some()); + assert!(h.query_by_role_and_label(Role::Button, "Render").is_some()); + let notify = h.get_by_role_and_label(Role::CheckBox, "Notify"); + assert!(notify.accesskit_node().toggled().is_some()); + let render = h.get_by_role_and_label(Role::CheckBox, "Render: Alpha"); + assert_eq!(render.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_role(Role::ProgressIndicator).accesskit_node().data().numeric_value(), Some(0.0)); +} + +#[test] +fn arrows_home_end_right_left_enter_and_delete_work_the_rows() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + let selected = |h: &Harness<'_, EffectcraftApp>| { + h.query_all_by_role(Role::TreeItem) + .filter(|n| n.accesskit_node().is_selected() == Some(true)) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) + .collect::>() + }; + assert_eq!(selected(&h), vec![row_name(&h, 1)], "a focused row is selected"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 2)), "Down Arrow moves to the next item and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 2)).accesskit_node().is_selected(), Some(true)); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)), "Down Arrow stays on the last item"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Right Arrow opens the item"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Left Arrow closes it"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter toggles it"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Space toggles it back"); + assert!(!h.state().playback.playing && !h.state().playback.caching && h.state().playback.plan.is_none(), "Space on a row does not start a preview"); + // Delete removes the selected item, as it does with the mouse's selection. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(queue_len(&h), 3); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(queue_len(&h), 2, "Delete removes the selected item"); +} + +#[test] +fn tab_reaches_every_control_of_a_row() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + let mut seen = vec![focused(&h).unwrap()]; + for _ in 0..40 { + key(&mut h, Key::Tab, Modifiers::NONE); + match focused(&h) { + Some(f) if !seen.contains(&f) => seen.push(f), + _ => {} + } + } + let names: Vec<&str> = seen.iter().map(|s| s.1.as_str()).collect(); + for want in ["Show details of Alpha", "Render: Alpha", "Show details of Beta", "Render: Beta"] { + assert!(names.contains(&want), "Tab reaches `{want}`; reached {names:?}"); + } +} + +#[test] +fn a_drop_down_opens_from_the_keyboard_and_gives_the_focus_back() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + h.get_by_role_and_label(Role::ComboBox, "Render Settings").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + let (role, first_entry) = focused(&h).expect("the first entry has the focus"); + assert_ne!(role, Role::ComboBox, "the keyboard is in the menu: {first_entry}"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).expect("Down Arrow moves to the next entry").1; + assert_ne!(second, first_entry); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(first_entry), "Up Arrow moves back"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.0), Some(Role::ComboBox), "choosing an entry gives the focus back to the drop-down"); +} + +#[test] +fn the_check_box_and_the_buttons_work_from_the_keyboard() { + let mut h = harness(); + let before = h.state().session.project.render_queue.first().map(|i| i.render); + h.get_by_role_and_label(Role::CheckBox, "Render: Alpha").focus(); + h.run_steps(3); + key(&mut h, Key::Space, Modifiers::NONE); + assert_ne!(h.state().session.project.render_queue.first().map(|i| i.render), before, "Space toggles the Render check box"); + let notify = h.state().session.project.render_prefs.notify; + h.get_by_role_and_label(Role::CheckBox, "Notify").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().session.project.render_prefs.notify, notify, "Enter toggles Notify"); +} + +#[test] +fn the_templates_dialog_is_named() { + let mut h = harness(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.templates", json!({"kind": "renderSettings"})).unwrap(); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::ComboBox, "Settings Name").is_some()); + assert!(h.query_by_role_and_label(Role::TextInput, "template name").is_some()); + for name in ["Duplicate", "Delete", " OK "] { + assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().role(), Role::Button, "{name}"); + } + let defaults = h.query_all_by_role(Role::ComboBox).count(); + assert!(defaults >= 4, "the default slots are drop-downs: {defaults}"); +} + +/// A pick with the mouse leaves no widget with the keyboard focus: egui pauses the application's +/// shortcuts (Space, Ctrl+Z) while one has it (#776's rule for the Project panel). +#[test] +fn a_pick_with_the_mouse_leaves_no_keyboard_focus() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + click(&mut h, "renderQueue.item.1.renderSettings"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus"); + let template = h.state().session.project.render_templates.render_settings_list()[0].name.clone(); + h.get_by_label_contains(&template).click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + // The Effects & Presets options menu the same. +} + +/// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to +/// the layer rename the Timeline binds to it. +#[test] +fn enter_after_a_click_opens_the_item_and_does_not_rename_a_layer() { + let mut h = harness(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + h.run_steps(3); + click(&mut h, "renderQueue.item.1.row"); + assert_eq!(h.state().ui.focused, PanelKind::RenderQueue); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter opens the clicked item"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_settings_keyboard.rs b/crates/ui-egui/tests/ui_settings_keyboard.rs new file mode 100644 index 000000000..e74ccfd94 --- /dev/null +++ b/crates/ui-egui/tests/ui_settings_keyboard.rs @@ -0,0 +1,113 @@ +//! Settings keyboard operation and accessible names (docs/gaps.md gap 25): the page list is one +//! Tab stop worked with the arrow keys, Home and End; every control on a page has the name its +//! label shows, with its value; Escape still cancels. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::accesskit::{Role, Toggled}; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(page: &str) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": page})).unwrap(); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn titles() -> Vec<&'static str> { + effectcraft_engine::prefs::pages().iter().map(|p| p.title).collect() +} + +#[test] +fn the_page_list_is_one_tab_stop_worked_with_the_arrows() { + let mut h = harness("general"); + let titles = titles(); + let n = titles.len(); + // Every page is a list item: its place in the list, and the open page selected. + for (i, title) in titles.iter().enumerate() { + let item = h.get_by_role_and_label(Role::ListItem, title); + assert_eq!(item.accesskit_node().role(), Role::ListItem, "{title}"); + assert_eq!(item.accesskit_node().data().position_in_set(), Some(i + 1)); + assert_eq!(item.accesskit_node().data().size_of_set(), Some(n)); + assert_eq!(item.accesskit_node().is_selected(), Some(i == 0), "{title}"); + } + h.get_by_role_and_label(Role::ListItem, titles[0]).focus(); + h.run_steps(3); + assert_eq!(focused(&h), Some((Role::ListItem, titles[0].to_string()))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h), Some((Role::ListItem, titles[1].to_string())), "Down Arrow opens the next page and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[1]).accesskit_node().is_selected(), Some(true)); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[0]).accesskit_node().is_selected(), Some(false)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string())); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Up Arrow stops at the first page"); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[n - 1].to_string()), "End goes to the last page"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Home goes to the first page"); + // Only the open page is a Tab stop: Tab leaves the list for the page's first control. + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, _) = focused(&h).expect("Tab moves the focus on"); + assert_ne!(role, Role::ListItem, "Tab leaves the page list"); +} + +#[test] +fn controls_have_the_name_their_label_shows_and_their_value() { + let h = harness("general"); + let undo = h.get_by_label("Levels of Undo"); + assert_eq!(undo.accesskit_node().role(), Role::SpinButton); + assert!(undo.accesskit_node().numeric_value().is_some(), "a spin button carries its number"); + let language = h.get_by_label("Language"); + assert_eq!(language.accesskit_node().role(), Role::ComboBox); + assert!(language.accesskit_node().value().is_some_and(|v| !v.is_empty()), "a drop-down carries what it is set to"); + let tips = h.get_by_label("Show Tool Tips"); + assert_eq!(tips.accesskit_node().role(), Role::CheckBox); + assert!(matches!(tips.accesskit_node().toggled(), Some(Toggled::True | Toggled::False))); + for name in [" OK ", "Cancel", "Previous", "Next"] { + assert_eq!(h.get_by_label(name).accesskit_node().role(), Role::Button, "{name}"); + } + // Previous is disabled on the first page. + assert!(h.get_by_label("Previous").accesskit_node().is_disabled()); +} + +#[test] +fn text_fields_colour_buttons_and_theme_cards_are_named() { + let h = harness("labels"); + let name = h.get_by_label("Label 1 name"); + assert_eq!(name.accesskit_node().role(), Role::TextInput); + assert!(name.accesskit_node().value().is_some_and(|v| !v.is_empty()), "the field carries its text"); + let color = h.get_by_label("Label 1 color"); + assert_eq!(color.accesskit_node().role(), Role::ColorWell); + assert!(color.accesskit_node().value().is_some_and(|v| v.starts_with('#')), "a colour carries its hex value"); + let h = harness("appearance"); + assert_eq!(h.get_by_label("Appearance Mode").accesskit_node().role(), Role::ComboBox); + assert!(h.query_all_by_role(Role::RadioButton).count() >= 2, "the themes are radio buttons"); +} + +#[test] +fn escape_still_cancels_with_the_keyboard_on_the_page_list() { + let mut h = harness("general"); + let first = titles()[0]; + h.get_by_role_and_label(Role::ListItem, first).focus(); + h.run_steps(3); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none(), "Escape closes the dialog"); +} diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fbd..9f4dd854c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest. | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page) and the Render Queue with its Templates dialog are at zero and held there, with named, stateful nodes and arrow-key, Home / End, Enter and Space operation of their lists (#1335). The other 30 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each). `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 12–25 h (first slice, about 3 h, done) | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -211,7 +211,7 @@ The most important missing piece: it turns every other estimate here into a meas Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed: -Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work +Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); accessibility is gap 25 ### G3. Stability and a green main @@ -322,6 +322,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gap 25: an AccessKit audit test (per-panel counts, a ratchet for the unfixed panels) and a theme contrast test; Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | | 2026-10-12 | minor | Gap 21: percentage guides and effect colour labels added (#1095) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22da668da..791950879 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier change: closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -30,20 +30,21 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304); scrubbing lag (#329, closed by #784) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | +| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages) and Render Queue with Templates are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an -accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After +accessibility pass 12–25 h (the first slice, three panels and the audit, is done). Checking feel against After Effects needs the owner's Mac with After Effects running; most of the rest parallelises. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Accessibility row: AccessKit audit and contrast tests, Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | | 2026-10-10 | major | First UI parity checklist | From f877ea9682516165e03813d0812d40184ded87b5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:33:12 +0200 Subject: [PATCH 2/4] #1335: accessibility slice 2: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable The audit gets read-outs, graphics and logs as described items (a name is required in a fixed panel), dedicated maximized scenarios for the panels the shared loop squeezes, and minimum counts for the new zero panels. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/a11y.rs | 43 ++++ crates/ui-egui/src/menus.rs | 11 +- .../ui-egui/src/panels/camera_tracker_ui.rs | 25 +-- crates/ui-egui/src/panels/flowchart.rs | 170 +++++++++++++--- crates/ui-egui/src/panels/info.rs | 49 +++-- crates/ui-egui/src/panels/script_console.rs | 13 +- .../ui-egui/tests/ui_accessibility_audit.rs | 118 ++++++++++- crates/ui-egui/tests/ui_flowchart_keyboard.rs | 192 ++++++++++++++++++ crates/ui-egui/tests/ui_info_tracker_a11y.rs | 77 +++++++ .../tests/ui_script_console_keyboard.rs | 107 ++++++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 7 +- 12 files changed, 746 insertions(+), 70 deletions(-) create mode 100644 crates/ui-egui/tests/ui_flowchart_keyboard.rs create mode 100644 crates/ui-egui/tests/ui_info_tracker_a11y.rs create mode 100644 crates/ui-egui/tests/ui_script_console_keyboard.rs diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index cca126876..480f01d6a 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -181,6 +181,49 @@ pub(crate) fn progress(r: &Response, label: &str, fraction: f32) { }); } +// ------------------------------------------------------------------------------------------ +// Things a panel paints that are not controls + +/// Make `rect` a node of the tree (egui paints text and shapes into the painter and leaves them +/// out of it) and give it a role and a name. Not focusable: it is for assistive technology to +/// read, not for the keyboard. +fn static_node(ui: &egui::Ui, id: Id, rect: egui::Rect, role: Role, label: &str, value: Option<&str>) { + let r = ui.interact(rect, id, egui::Sense::hover()); + node(&r, |n| { + n.set_role(role); + n.set_label(label); + if let Some(v) = value { + n.set_value(v); + } + }); +} + +/// A read-out painted at `rect` (a colour channel, the time, a status line): `label` is the whole +/// sentence it shows, e.g. "Red: 128". +pub(crate) fn readout(ui: &egui::Ui, id: Id, rect: egui::Rect, label: &str) { + static_node(ui, id, rect, Role::Label, label, None); +} + +/// A picture with no text of its own (a colour swatch, a scope, a waveform): `label` says what it +/// shows in a few words. +pub(crate) fn graphic(ui: &egui::Ui, id: Id, rect: egui::Rect, label: &str) { + static_node(ui, id, rect, Role::Image, label, None); +} + +/// A log or console output (newest line last): `label` says which, `text` is what it holds. +pub(crate) fn log(ui: &egui::Ui, id: Id, rect: egui::Rect, label: &str, text: &str) { + static_node(ui, id, rect, Role::Log, label, Some(text)); +} + +/// A toggle button (it stays pressed while it is on). +pub(crate) fn toggle_button(r: &Response, label: &str, on: bool) { + node(r, |n| { + n.set_role(Role::Button); + n.set_label(label); + n.set_toggled(if on { Toggled::True } else { Toggled::False }); + }); +} + // ------------------------------------------------------------------------------------------ // List keyboard handling diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6006be46e..6fbe399c6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -253,8 +253,8 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { /// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`, /// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in -/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and -/// Render Queue (which own the arrow keys for their own keyboard navigation, #961); not while +/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets, Render Queue +/// and the Flowchart (which own the arrow keys for their own keyboard navigation, #961); not while /// typing, with a dialog or menu open, or with mask points selected. fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result { let (side, n) = match dir.strip_suffix("10") { @@ -269,7 +269,7 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result return Err(format!("unknown nudge `{dir}`")), }; let st = &app.session.state; - if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) + if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue | PanelKind::Flowchart) || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() @@ -1922,7 +1922,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only // Enter in the Project panel renames the selected item (handled by the panel); in Effects // & Presets and the Render Queue it applies or opens the row (also handled by the panel), // not the layer rename. - if key == egui::Key::Enter && !mods.any() && matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue) { + if key == egui::Key::Enter + && !mods.any() + && matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue | PanelKind::Flowchart) + { continue; } // The Project panel has its own selection; a previously selected Timeline layer diff --git a/crates/ui-egui/src/panels/camera_tracker_ui.rs b/crates/ui-egui/src/panels/camera_tracker_ui.rs index 2bccfae07..6cc20ac6c 100644 --- a/crates/ui-egui/src/panels/camera_tracker_ui.rs +++ b/crates/ui-egui/src/panels/camera_tracker_ui.rs @@ -28,7 +28,7 @@ use serde_json::json; use super::viewer::ViewerMap; use crate::theme::Tokens; -use crate::{EffectcraftApp, widgets}; +use crate::{EffectcraftApp, a11y, widgets}; /// The tracker whose points the viewer shows. struct Active { @@ -413,12 +413,14 @@ pub fn editor( let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0)); let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0)); let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0)); - if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy { + let analyze = widgets::text_button(ui, b1, tr("Analyze"), false, &t, egui::Id::new(("ct-analyze", g.uid))); + a11y::button(&analyze, tr("Analyze"), !busy); + if analyze.clicked() && !busy { actions.push(("camera.analyze".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze"); if running { - if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() { + if widgets::text_button(ui, b2, tr("Cancel"), false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() { actions.push(("camera.cancel".into(), json!({}))); } app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel"); @@ -426,7 +428,7 @@ pub fn editor( let params = effectcraft_engine::camera_track::static_params(g); let solve = ct::solve(¶ms); if solve.is_some() { - if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { + if widgets::text_button(ui, b3, tr("Create Camera"), false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() { actions.push(("camera.create".into(), me.clone())); } app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera"); @@ -443,16 +445,15 @@ pub fn editor( None => "Not analyzed: click Analyze".into(), }, }; - p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim); + p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.5), t.text_dim); + // The status is painted text; a read-out gives assistive technology the same words. + let line = |y: f32| Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + y), vec2((r.width() - 32.0).max(24.0), 16.0)); + a11y::readout(ui, egui::Id::new(("ct-status", g.uid)), line(30.0), &status); if let Some(sv) = &solve { let f = sv.focal(); - p.text( - pos2(r.min.x + 24.0, r.min.y + 56.0), - Align2::LEFT_CENTER, - format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)), - Tokens::ui(11.5), - t.text_faint, - ); + let solved = format!("{} solved points · Horizontal Angle of View {:.1}°", sv.points.len(), sv.hfov(f)); + p.text(pos2(r.min.x + 24.0, r.min.y + 56.0), Align2::LEFT_CENTER, &solved, Tokens::ui(11.5), t.text_faint); + a11y::readout(ui, egui::Id::new(("ct-solved", g.uid)), line(48.0), &solved); } } diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs index f59d35982..8726370cc 100644 --- a/crates/ui-egui/src/panels/flowchart.rs +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -6,7 +6,7 @@ //! [`build`] is pure and cycle-safe (a comp nested in itself is drawn once, with an edge back), //! so agents get the same graph through `flowchart.graph`. -use crate::i18n::tr_args; +use crate::i18n::{tr, tr_args}; use std::collections::{BTreeMap, BTreeSet}; use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use crate::icons::{self, Icon}; use crate::theme::Tokens; -use crate::{EffectcraftApp, widgets}; +use crate::{EffectcraftApp, a11y, widgets}; /// Flowchart panel options (the panel's switches). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -233,6 +233,19 @@ pub fn command(app: &mut EffectcraftApp, id: &str, p: &Value) -> Result String { + match kind { + "comp" => tr("Composition"), + "footage" => tr("Footage"), + "solid" => tr("Solid"), + "layer" => tr("Layer"), + "effect" => tr("Effect"), + other => other, + } + .to_string() +} + fn kind_style(kind: &str) -> (Icon, Color32) { match kind { "comp" => (Icon::Comp, Color32::from_rgb(0x3d, 0x5a, 0x8c)), @@ -267,10 +280,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ("layers", "Layers", o.layers), ("solids", "Solids", o.solids), ] { - let w = 18.0 + label.len() as f32 * 6.6; + let label = tr(label); + let w = 18.0 + label.chars().count() as f32 * 6.6; let r = Rect::from_min_size(pos2(x - w, bar.min.y + 5.0), vec2(w, 20.0)); x -= w + 6.0; - if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("flow-opt", key))).clicked() { + let resp = widgets::text_button(ui, r, label, on, &t, egui::Id::new(("flow-opt", key))); + if key != "direction" { + // Effects, Layers and Solids stay pressed while they are on. + a11y::toggle_button(&resp, label, on); + } + if resp.clicked() { let v = if key == "direction" { json!({"direction": if o.vertical { "lr" } else { "tb" }}) } else { json!({key: !on}) }; cmds.push(("flowchart.options".into(), v)); } @@ -282,7 +301,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = build(&app.session.project, rid, &o); let pos = layout(&g, o.vertical); let origin = body.min + vec2(24.0 + o.pan[0], 24.0 + o.pan[1]); - let bg = ui.interact(body, egui::Id::new("flow-bg"), Sense::click_and_drag()); + // Panned with the mouse (or the wheel); the keyboard moves between the nodes and the view + // follows, so the background is not a Tab stop. + let bg = ui.interact(body, egui::Id::new("flow-bg"), Sense::CLICK | Sense::DRAG); + a11y::role_name(&bg, egui::accesskit::Role::Group, &tr_args("Flowchart: {}", &[&root_name])); if bg.dragged() { let d = bg.drag_delta(); app.ui.flowchart.pan[0] += d.x; @@ -315,14 +337,44 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { gp.add(egui::Shape::convex_polygon(vec![e, e + l, e + r], col, Stroke::NONE)); } let cmd_down = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); + let node_id = |id: &str| egui::Id::new(("flow-node", id)); + // What a node does: opened (as a double click), revealed (as a Cmd/Ctrl click). + let open_cmd = |n: &FlowNode| -> Option<(String, Value)> { + match (n.kind, n.item, n.comp) { + ("comp", Some(c), _) | (_, _, Some(c)) => Some(("comp.open".into(), json!({"comp": c}))), + _ => None, + } + }; + let reveal_cmds = |n: &FlowNode| -> Vec<(String, Value)> { + match (n.item, n.comp, n.layer) { + // Items in the Project panel, layers selected in their comp. + (Some(item), ..) => vec![("project.select".into(), json!({"items": [item]})), ("window.panel".into(), json!({"panel": "project"}))], + (None, Some(c), Some(l)) => vec![("comp.open".into(), json!({"comp": c})), ("layer.select".into(), json!({"layers": [l]}))], + _ => vec![], + } + }; + // One Tab stop for the chart: the selected node (the first one drawn without a selection); + // the arrow keys move between the others. + let drawn: Vec = (0..g.nodes.len()).map(|i| rect_of(i).intersects(body)).collect(); + let is_drawn = |i: usize| drawn.get(i).copied().unwrap_or(false); + let cursor = o.selected.as_deref().and_then(|s| idx.get(s).copied()).filter(|i| is_drawn(*i)).or_else(|| drawn.iter().position(|d| *d)); + let size = g.nodes.len(); for (i, n) in g.nodes.iter().enumerate() { let r = rect_of(i); - if !r.intersects(body) { + if !is_drawn(i) { continue; } let (icon, fill) = kind_style(n.kind); let selected = o.selected.as_deref() == Some(n.id.as_str()); - let resp = ui.interact(r.intersect(body), egui::Id::new(("flow-node", &n.id)), Sense::click()); + let sense = if cursor == Some(i) { Sense::click() } else { Sense::CLICK }; + let resp = ui.interact(r.intersect(body), node_id(&n.id), sense); + if a11y::take_pending_focus(&ctx, "flow", resp.id) { + resp.request_focus(); + } + if resp.gained_focus() { + app.ui.flowchart.selected = Some(n.id.clone()); + app.ui.focused = crate::dock::PanelKind::Flowchart; + } let fill = if resp.hovered() { fill.gamma_multiply(1.25) } else { fill }; gp.rect_filled(r, 5.0, fill); gp.rect_stroke( @@ -331,6 +383,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { Color32::from_black_alpha(120) }), StrokeKind::Inside, ); + if resp.has_focus() { + gp.rect_stroke(r.expand(2.0), 6.0, Stroke::new(2.0, t.text), StrokeKind::Outside); + } icons::paint(&gp, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE); gp.with_clip_rect(r.shrink(2.0).intersect(body)).text( pos2(r.min.x + 30.0, r.center().y), @@ -340,31 +395,100 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Color32::WHITE, ); app.auto.add(&format!("flowchart.node.{}", n.id), r, &n.label); + // "Mid, Composition, uses 2": the node's name, what it is and how many nodes it leads to. + let uses = g.edges.iter().filter(|(a, _)| *a == n.id).count(); + let name = + if uses > 0 { tr_args("{}, {}, uses {}", &[&n.label, &kind_name(n.kind), &uses]) } else { tr_args("{}, {}", &[&n.label, &kind_name(n.kind)]) }; + a11y::list_item(&resp, &name, selected, i.saturating_add(1), size); if resp.clicked() { app.ui.flowchart.selected = Some(n.id.clone()); + app.ui.focused = crate::dock::PanelKind::Flowchart; if cmd_down { - // Reveal: items in the Project panel, layers selected in their comp. - match (n.item, n.comp, n.layer) { - (Some(item), ..) => { - cmds.push(("project.select".into(), json!({"items": [item]}))); - cmds.push(("window.panel".into(), json!({"panel": "project"}))); - } - (None, Some(c), Some(l)) => { - cmds.push(("comp.open".into(), json!({"comp": c}))); - cmds.push(("layer.select".into(), json!({"layers": [l]}))); - } - _ => {} - } + cmds.extend(reveal_cmds(n)); } } if resp.double_clicked() { - match (n.kind, n.item, n.comp) { - ("comp", Some(c), _) => cmds.push(("comp.open".into(), json!({"comp": c}))), - (_, _, Some(c)) => cmds.push(("comp.open".into(), json!({"comp": c}))), + cmds.extend(open_cmd(n)); + } + resp.on_hover_text(tr_args("{} — double-click to open, {}-click to reveal", &[&n.label, &(if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" })])); + } + + // Keyboard, with a node focused (or after a click, when nothing has the focus): Up / Down and + // Home / End move through the nodes, Right goes to a node this one uses and Left back to one + // that uses it, Enter opens (as a double click), Space reveals (as a Cmd/Ctrl click). The + // focus follows, and the view pans to keep the node in sight. + let focused = ctx.memory(|m| m.focused()); + let on_node = focused.is_some_and(|f| g.nodes.iter().any(|n| node_id(&n.id) == f)); + let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::Flowchart; + if (on_node || by_mouse) && app.dialog.is_none() && !widgets::any_menu_open(&ctx) { + let keys: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key), + _ => None, + }) + .collect() + }); + let mut at = o.selected.as_deref().and_then(|s| idx.get(s).copied()).or(cursor); + let mut moved = None; + for key in keys { + use egui::Key; + let last = g.nodes.len().checked_sub(1); + let to = match (key, last) { + (Key::ArrowDown, Some(last)) => Some(at.map_or(0, |a| a.saturating_add(1).min(last))), + (Key::ArrowUp, Some(last)) => Some(at.map_or(last, |a| a.saturating_sub(1))), + (Key::Home, Some(_)) if on_node => Some(0), + (Key::End, Some(last)) if on_node => Some(last), + (Key::ArrowRight, _) => at + .and_then(|a| g.nodes.get(a)) + .and_then(|n| g.edges.iter().find(|(from, _)| *from == n.id)) + .and_then(|(_, to)| idx.get(to.as_str()).copied()), + (Key::ArrowLeft, _) => at + .and_then(|a| g.nodes.get(a)) + .and_then(|n| g.edges.iter().find(|(_, to)| *to == n.id)) + .and_then(|(from, _)| idx.get(from.as_str()).copied()), + _ => None, + }; + if let Some(n) = to.and_then(|to| g.nodes.get(to)) { + at = to; + app.ui.flowchart.selected = Some(n.id.clone()); + moved = Some(n.id.clone()); + continue; + } + let Some(n) = at.and_then(|a| g.nodes.get(a)) else { continue }; + match key { + Key::Enter => cmds.extend(open_cmd(n)), + Key::Space if on_node => cmds.extend(reveal_cmds(n)), _ => {} } } - resp.on_hover_text(tr_args("{} — double-click to open, {}-click to reveal", &[&n.label, &(if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" })])); + if let Some(i) = moved.as_deref().and_then(|m| idx.get(m).copied()) { + // Pan so the node is inside the view, with a margin. + let r = rect_of(i); + let (mut dx, mut dy) = (0.0, 0.0); + if r.min.x < body.min.x + 16.0 { + dx = body.min.x + 16.0 - r.min.x; + } else if r.max.x > body.max.x - 16.0 { + dx = body.max.x - 16.0 - r.max.x; + } + if r.min.y < body.min.y + 16.0 { + dy = body.min.y + 16.0 - r.min.y; + } else if r.max.y > body.max.y - 16.0 { + dy = body.max.y - 16.0 - r.max.y; + } + app.ui.flowchart.pan[0] += dx; + app.ui.flowchart.pan[1] += dy; + app.ui.focused = crate::dock::PanelKind::Flowchart; + if on_node && let Some(id) = moved.as_deref() { + a11y::focus_next_pass(&ctx, "flow", node_id(id)); + } + ctx.request_repaint(); + } + // The arrows are the chart's, not egui's spatial focus moves. + if on_node { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } } for (id, params) in cmds { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 581e43344..351ea8814 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -4,7 +4,7 @@ use crate::i18n::{tr, tr_args}; use egui::{Align2, Color32, Rect, pos2, vec2}; use crate::theme::Tokens; -use crate::{EffectcraftApp, widgets}; +use crate::{EffectcraftApp, a11y, widgets}; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; @@ -38,18 +38,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }; let px = rgba.map(unpre).unwrap_or([0, 0, 0, 0]); + // The panel paints its text, which assistive technology cannot see: each line is also a + // read-out with the same words (a sentence a reader can speak: "Red: 128"). + let full = rect.width() - 24.0; + let say = |key: &str, x: f32, y: f32, w: f32, text: &str| { + a11y::readout(ui, egui::Id::new(("info", key)), Rect::from_min_size(pos2(x, y - 8.0), vec2(w.max(24.0), 16.0)), text); + }; + let half = rect.width() * 0.5 - 16.0; + say("r", x0, y, half, &tr_args("Red: {}", &[&px[0]])); + say("g", x0, y + 18.0, half, &tr_args("Green: {}", &[&px[1]])); + say("b", x0, y + 36.0, half, &tr_args("Blue: {}", &[&px[2]])); + say("a", x0, y + 54.0, half, &tr_args("Alpha: {}", &[&px[3]])); row(&p, y, "R :", format!("{}", px[0]), Color32::from_rgb(0xe0, 0x60, 0x60)); row(&p, y + 18.0, "G :", format!("{}", px[1]), Color32::from_rgb(0x60, 0xd0, 0x60)); row(&p, y + 36.0, "B :", format!("{}", px[2]), Color32::from_rgb(0x60, 0x90, 0xf0)); row(&p, y + 54.0, "A :", format!("{}", px[3]), t.text_dim); let cx = rect.min.x + rect.width() * 0.5; if let Some((x, yy)) = xy { + say("x", cx, y, half, &tr_args("X: {}", &[&format!("{x:.0}")])); + say("y", cx, y + 18.0, half, &tr_args("Y: {}", &[&format!("{yy:.0}")])); p.text(pos2(cx, y), Align2::LEFT_CENTER, format!("X : {x:.0}"), Tokens::mono(12.0), t.text); p.text(pos2(cx, y + 18.0), Align2::LEFT_CENTER, format!("Y : {yy:.0}"), Tokens::mono(12.0), t.text); } if let Some(c) = rgba { let sw = egui::Rect::from_min_size(pos2(cx, y + 34.0), vec2(36.0, 30.0)); p.rect_filled(sw, 3.0, Color32::from_rgb(px[0], px[1], px[2])); + a11y::graphic(ui, egui::Id::new(("info", "swatch")), sw, &tr_args("Color under the pointer: {}", &[&format!("{}, {}, {}", px[0], px[1], px[2])])); let _ = c; } y += 82.0; @@ -60,14 +74,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(c) = comp { let sel: Vec = app.session.state.selected_layers.iter().filter_map(|id| c.layer(*id)).map(|l| l.name.clone()).collect(); let tc = crate::panels::timecode(&app.session, &c, app.session.time()); - p.text( - pos2(x0, y), - Align2::LEFT_CENTER, - if sel.is_empty() { tr("No layer selected").to_string() } else { sel.join(", ") }, - Tokens::semibold(12.0), - t.text, - ); - p.text(pos2(x0, y + 18.0), Align2::LEFT_CENTER, tr_args("Time: {}", &[&tc]), Tokens::ui(12.0), t.text_dim); + let selection = if sel.is_empty() { tr("No layer selected").to_string() } else { sel.join(", ") }; + p.text(pos2(x0, y), Align2::LEFT_CENTER, &selection, Tokens::semibold(12.0), t.text); + say("selection", x0, y, full, &tr_args("Selected layers: {}", &[&selection])); + let time_line = tr_args("Time: {}", &[&tc]); + p.text(pos2(x0, y + 18.0), Align2::LEFT_CENTER, &time_line, Tokens::ui(12.0), t.text_dim); + say("time", x0, y + 18.0, full, &time_line); // One layer selected: Duration, In and Out like AE's Info panel. let mut ly = y + 36.0; if let [id] = app.session.state.selected_layers.as_slice() @@ -84,8 +96,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }), format!("In: {}, Out: {}", tc(l.in_point), tc(effectcraft_engine::time::Tick(l.out_point.0 - c.frame_duration().0))), ]; - for line in lines { - p.text(pos2(x0, ly), Align2::LEFT_CENTER, line, Tokens::ui(12.0), t.text_dim); + for (k, line) in lines.into_iter().enumerate() { + p.text(pos2(x0, ly), Align2::LEFT_CENTER, &line, Tokens::ui(12.0), t.text_dim); + say(if k == 0 { "duration" } else { "inout" }, x0, ly, full, &line); ly += 18.0; } } @@ -93,6 +106,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let (Some(fps), Some(rt)) = (app.playback.achieved_fps(), app.playback.real_time()) { let note = if rt { "real-time" } else { "not real-time" }; p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("{fps:.2} fps ({note})"), Tokens::ui(11.5), if rt { t.text_dim } else { t.warning }); + say("fps", x0, ly, full, &format!("{fps:.2} fps ({note})")); app.auto.add("info.previewFps", Rect::from_min_size(pos2(x0, ly - 8.0), vec2(200.0, 16.0)), &format!("{fps:.2} fps ({note})")); ly += 18.0; } @@ -100,13 +114,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if app.session.prefs.composition.show_rendering_progress { let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); let rq = app.session.render_job.as_ref().map(|_| " • Rendering queue…").unwrap_or(""); - p.text( - pos2(x0, ly), - Align2::LEFT_CENTER, - tr_args("Render: {} ms • UI {} fps{}", &[&format!("{ms:.0}"), &format!("{:.0}", app.fps), &rq]), - Tokens::ui(11.5), - t.text_faint, - ); + let render_line = tr_args("Render: {} ms • UI {} fps{}", &[&format!("{ms:.0}"), &format!("{:.0}", app.fps), &rq]); + p.text(pos2(x0, ly), Align2::LEFT_CENTER, &render_line, Tokens::ui(11.5), t.text_faint); + say("render", x0, ly, full, &render_line); } if !app.session.state.selected_keys.is_empty() { // One key: its property, time and value (as After Effects' Info panel shows them). @@ -127,7 +137,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => None, }; let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len())); - p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim); + p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, &line, Tokens::ui(11.5), t.text_dim); + say("keys", x0, ly + 18.0, full, &line); } bottom = ly + 30.0; } diff --git a/crates/ui-egui/src/panels/script_console.rs b/crates/ui-egui/src/panels/script_console.rs index 97c30c4c4..e4245ce4a 100644 --- a/crates/ui-egui/src/panels/script_console.rs +++ b/crates/ui-egui/src/panels/script_console.rs @@ -7,8 +7,8 @@ use egui::{Align2, Rect, Sense, pos2, vec2}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, a11y, widgets}; /// One line of console output. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -105,6 +105,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ); } app.auto.add("scriptConsole.output", out_rect, "Script Console output"); + // The output is painted text; a screen reader gets the same lines (the newest 200) as a log. + let tail = app.ui.script_console.log.len().saturating_sub(200); + let text: Vec<&str> = app.ui.script_console.log.iter().skip(tail).map(|l| l.text.as_str()).collect(); + a11y::log(ui, egui::Id::new("script-console-output"), out_rect, tr("Script Console output"), &text.join("\n")); // Input. let in_rect = Rect::from_min_size(pos2(rect.min.x + 4.0, out_rect.max.y + 4.0), vec2(rect.width() - 8.0, input_h - 8.0)); let mut run_now = false; @@ -114,8 +118,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .font(Tokens::mono(12.0)) .desired_width(in_rect.width()) .desired_rows(4) + // Tab leaves the field (it would otherwise type a tab character and trap the keyboard). + .lock_focus(false) .hint_text(tr("Script… (⌘/Ctrl+Enter runs)")); let resp = child.add_sized(in_rect.size(), te); + a11y::name(&resp, tr("Script… (⌘/Ctrl+Enter runs)")); if resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command) { run_now = true; } @@ -130,6 +137,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click()); p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); p.text(clear_r.center(), Align2::CENTER_CENTER, tr("Clear"), Tokens::ui(12.0), t.text); + a11y::button(&run_resp, tr("Run"), !app.ui.script_console.input.trim().is_empty()); + a11y::button(&clear_resp, tr("Clear"), !app.ui.script_console.log.is_empty()); + widgets::focus_outline(ui, &run_resp, &t); + widgets::focus_outline(ui, &clear_resp, &t); app.auto.add("scriptConsole.run", run_r, "Run"); app.auto.add("scriptConsole.clear", clear_r, "Clear"); if run_resp.clicked() { diff --git a/crates/ui-egui/tests/ui_accessibility_audit.rs b/crates/ui-egui/tests/ui_accessibility_audit.rs index 012bf914f..41387a2a7 100644 --- a/crates/ui-egui/tests/ui_accessibility_audit.rs +++ b/crates/ui-egui/tests/ui_accessibility_audit.rs @@ -4,11 +4,17 @@ //! what it is, and the state that role needs (checked, selected, a value). //! //! * The panels that had their pass (`FIXED`: Effects & Presets, Settings, Render Queue with its -//! Templates dialog) must have **zero** findings. +//! Templates dialog, Project, Info, Flowchart, Script Console and the 3D Camera Tracker block +//! of Effect Controls) must have **zero** findings. //! * Every other panel is counted and printed (run with `--nocapture`), and checked against //! [`BASELINE`], a ratchet that can only shrink: fixing a panel lowers its number, and a number //! must never go up. When a panel's count falls, lower its entry (the test says so). //! +//! A panel that only paints (Info) has no controls; there the audit counts what it *describes*: a +//! graphic (`Role::Image`, which must have a name in a fixed panel), a read-out (`Role::Label` +//! with a name) or a log. A fixed panel must show a minimum number of controls and described +//! items, so zero findings cannot mean the panel was not looked at. +//! //! What this does not measure: how a real screen reader speaks the tree (none was run), colour //! contrast, focus order or keyboard operation (the `ui_*_keyboard.rs` tests cover those), or //! widgets that are scrolled out of view and not drawn. @@ -25,7 +31,12 @@ use egui_kittest::kittest::NodeT; use serde_json::json; /// Groups that must be clean. A group is the first part of a scenario name (before the `:`). -const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project"]; +const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project", "Info", "Flowchart", "ScriptConsole", "CameraTracker"]; + +/// Panels audited in a scenario of their own (a fresh window, the panel maximized, so it has its +/// full size; in the shared loop below each panel opened squeezes the next one). The loop still +/// opens them, so the layout the other panels are measured in does not change. +const DEDICATED: &[PanelKind] = &[PanelKind::Info, PanelKind::Flowchart, PanelKind::ScriptConsole]; /// (scenario, findings, of them unnamed controls) per not-yet-fixed scenario, as measured when /// this test was written (Effect Controls with one Gaussian Blur on the layer, every other panel @@ -49,18 +60,16 @@ const BASELINE: &[(&str, usize, usize)] = &[ ("MotionSketch:default", 4, 2), ("Paint:default", 5, 3), ("Brushes:default", 16, 9), - ("Flowchart:default", 8, 4), ("History:default", 10, 5), ("Markers:default", 1, 1), ("MaskInterpolation:default", 2, 2), - ("ScriptConsole:default", 5, 3), - ("EssentialGraphics:default", 2, 2), + ("EssentialGraphics:default", 1, 1), ("Footage:default", 3, 2), ("Progress:default", 1, 1), ("VrCompEditor:default", 13, 7), ]; -/// How many controls the fixed scenarios must at least show, so that "zero findings" is never +/// How many controls (and, in the panels that paint, described read-outs, graphics and logs) the fixed scenarios must at least show, so that "zero findings" is never /// the result of looking at nothing. Any scenario not listed needs 2. const MIN_CONTROLS: &[(&str, usize)] = &[ ("EffectsPresets:default", 10), @@ -75,6 +84,14 @@ const MIN_CONTROLS: &[(&str, usize)] = &[ ("RenderQueue:menu", 20), ("RenderQueue:templates:renderSettings", 8), ("RenderQueue:templates:outputModule", 8), + // Info, Flowchart and Script Console count read-outs, graphics and logs as well as controls. + ("Info:default", 6), + ("Info:layer", 8), + ("Flowchart:default", 6), + ("Flowchart:layers", 8), + ("ScriptConsole:default", 4), + ("ScriptConsole:log", 4), + ("CameraTracker:editor", 3), ]; /// The roles that are controls: anything with one of these (or with a click or focus action) is @@ -156,6 +173,8 @@ fn node_ids(h: &Harness<'_, EffectcraftApp>) -> HashSet { fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Option<&HashSet>) -> (Vec, usize) { let mut out = Vec::new(); let mut controls = 0; + // The rules for graphics apply where the panel has had its pass. + let strict = FIXED.contains(&group(scenario)); for n in h.root().children_recursive() { let a = n.accesskit_node(); if a.is_hidden() { @@ -176,6 +195,26 @@ fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Op continue; } let role = a.role(); + // What a panel describes without being operable: a graphic (a name is required in a fixed + // panel), a read-out with a name, a log. + let described = match role { + Role::Image | Role::Canvas | Role::Log => true, + Role::Label => a.label().is_some_and(|l| !l.trim().is_empty()), + _ => false, + }; + if described { + if strict && matches!(role, Role::Image | Role::Canvas | Role::Log) && a.label().is_none_or(|l| l.trim().is_empty()) { + out.push(Finding { + scenario: scenario.to_string(), + problem: "graphic without a name", + role, + name: String::new(), + rect: [b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32], + }); + } + controls += 1; + continue; + } let actionable = a.data().supports_action(Action::Click) || a.data().supports_action(Action::Focus); let control = CONTROL_ROLES.contains(&role); if !control && !(actionable && matches!(role, Role::Unknown | Role::GenericContainer)) { @@ -312,6 +351,9 @@ fn collect() -> Report { } h.state_mut().show_panel(p); h.run_steps(3); + if DEDICATED.contains(&p) { + continue; + } let name = format!("{}:default", p.id()); let Some(rect) = panel_rect(&h, p) else { r.rows.push((format!("{name} (not drawn)"), 0, 0, 0)); @@ -319,6 +361,70 @@ fn collect() -> Report { }; r.add(&name, audit(&h, &name, rect, None)); } + + // ---- The panels with their own scenarios: alone in a window, maximized. + let alone = |p: PanelKind| { + let mut h = harness(); + h.state_mut().show_panel(p); + h.state_mut().ui.maximized = Some(p); + h.run_steps(4); + h + }; + // Info: nothing selected, then a layer selected (its Duration, In and Out), and a key. + let mut h = alone(PanelKind::Info); + h.state_mut().session.execute("layer.select", json!({"layers": []})).ok(); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::Info).expect("Info is drawn"); + r.add("Info:default", audit(&h, "Info:default", rect, None)); + h.state_mut().session.execute("layer.select", json!({"layers": ["#1"]})).ok(); + h.run_steps(4); + r.add("Info:layer", audit(&h, "Info:layer", rect, None)); + + // Flowchart: the comp and what it uses, then with layers and effects, then top to bottom. + let mut h = alone(PanelKind::Flowchart); + let rect = panel_rect(&h, PanelKind::Flowchart).expect("the Flowchart is drawn"); + r.add("Flowchart:default", audit(&h, "Flowchart:default", rect, None)); + { + let f = &mut h.state_mut().ui.flowchart; + f.layers = true; + f.effects = true; + f.vertical = true; + } + h.run_steps(4); + r.add("Flowchart:layers", audit(&h, "Flowchart:layers", rect, None)); + + // Script Console: empty, then with a run in its log. + let mut h = alone(PanelKind::ScriptConsole); + let rect = panel_rect(&h, PanelKind::ScriptConsole).expect("the Script Console is drawn"); + r.add("ScriptConsole:default", audit(&h, "ScriptConsole:default", rect, None)); + h.state_mut().ui.script_console.input = "app.project.numItems".into(); + { + let app = h.state_mut(); + let con = &mut app.ui.script_console; + con.record("app.project.numItems", &Ok(json!({"result": 3}))); + } + h.run_steps(4); + r.add("ScriptConsole:log", audit(&h, "ScriptConsole:log", rect, None)); + + // 3D Camera Tracker block of Effect Controls (a separate scenario: the block, not the panel). + let mut h = harness(); + h.state_mut().session.execute("effect.apply", json!({"effect": effectcraft_engine::effects::camera_tracker::ID})).ok(); + h.state_mut().show_panel(PanelKind::EffectControls); + h.run_steps(4); + let block: Vec = h + .state() + .auto + .query("effectControls.cameraTracker.") + .into_iter() + .map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + .collect(); + if let Some(first) = block.first() { + let area = block.iter().fold(*first, |a, b| a.union(*b)); + let area = Rect::from_min_max(area.min - egui::vec2(8.0, 4.0), area.max + egui::vec2(8.0, 40.0)); + r.add("CameraTracker:editor", audit(&h, "CameraTracker:editor", area, None)); + } else { + r.rows.push(("CameraTracker:editor (not drawn)".to_string(), 0, 0, 0)); + } r } diff --git a/crates/ui-egui/tests/ui_flowchart_keyboard.rs b/crates/ui-egui/tests/ui_flowchart_keyboard.rs new file mode 100644 index 000000000..caedfbcee --- /dev/null +++ b/crates/ui-egui/tests/ui_flowchart_keyboard.rs @@ -0,0 +1,192 @@ +//! Flowchart keyboard operation and accessible names (docs/gaps.md gap 25): the nodes are list +//! items ("Mid, Composition, uses 1": selected, place in the chart), the chart is one Tab stop, +//! the arrow keys, Home / End, Enter and Space work the nodes, and the toolbar switches say whether +//! they are on. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::{Role, Toggled}; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +/// Main uses Mid and Inner, Mid uses Inner: three comp nodes (Main, Mid, Inner) and a solid. +fn harness() -> Harness<'static, EffectcraftApp> { + sized(1600.0, 1000.0) +} + +fn sized(w: f32, hh: f32) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + let id = |v: serde_json::Value| v["id"].as_u64().or_else(|| v["comp"].as_u64()).or_else(|| v["item"].as_u64()).unwrap(); + let inner = id(s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); + s.execute("layer.select", json!({"layers": []})).unwrap(); + let mid = id(s.execute("comp.new", json!({"name": "Mid", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + id(s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.addItem", json!({"item": mid})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(w, hh)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Flowchart); + h.state_mut().ui.maximized = Some(PanelKind::Flowchart); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn names(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.query_all_by_role(Role::ListItem).map(|n| n.accesskit_node().label().unwrap_or_default()).collect() +} + +fn selected(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.query_all_by_role(Role::ListItem) + .filter(|n| n.accesskit_node().is_selected() == Some(true)) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .collect() +} + +fn focus_node(h: &mut Harness<'_, EffectcraftApp>, name: &str) { + h.get_by_role_and_label(Role::ListItem, name).focus(); + h.run_steps(3); +} + +#[test] +fn nodes_are_named_list_items_with_what_they_are_and_what_they_use() { + let h = harness(); + let all = names(&h); + for want in ["Main, Composition, uses 2", "Mid, Composition, uses 1", "Inner, Composition, uses 1", "Red, Solid"] { + assert!(all.iter().any(|n| n == want), "`{want}` among {all:?}"); + } + let node = h.get_by_role_and_label(Role::ListItem, "Mid, Composition, uses 1"); + assert_eq!(node.accesskit_node().is_selected(), Some(false)); + assert_eq!(node.accesskit_node().data().size_of_set(), Some(all.len())); + assert!(node.accesskit_node().data().position_in_set().is_some_and(|p| (1..=all.len()).contains(&p))); + assert_eq!(h.get_by_role_and_label(Role::Group, "Flowchart: Main").accesskit_node().role(), Role::Group, "the chart has a name"); +} + +#[test] +fn the_toolbar_switches_say_whether_they_are_on() { + let mut h = harness(); + let state = |h: &Harness<'_, EffectcraftApp>, name: &str| h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(); + assert_eq!(state(&h, "Solids"), Some(Toggled::True)); + assert_eq!(state(&h, "Layers"), Some(Toggled::False)); + h.get_by_role_and_label(Role::Button, "Layers").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(state(&h, "Layers"), Some(Toggled::True), "Enter on the focused switch turns it on"); + assert!(h.state().ui.flowchart.layers); + assert!(h.query_by_role_and_label(Role::Button, "Left to Right").is_some(), "the direction button is named"); +} + +#[test] +fn the_chart_is_one_tab_stop_and_the_arrows_move_between_the_nodes() { + let mut h = harness(); + // Tab from the toolbar passes through exactly one node. + h.get_by_role_and_label(Role::Button, "Solids").focus(); + h.run_steps(3); + let mut nodes_seen = std::collections::BTreeSet::new(); + for _ in 0..12 { + key(&mut h, Key::Tab, Modifiers::NONE); + if let Some((Role::ListItem, name)) = focused(&h) { + nodes_seen.insert(name); + } + } + assert_eq!(nodes_seen.len(), 1, "one Tab stop for the chart: {nodes_seen:?}"); + + let all = names(&h); + focus_node(&mut h, &all[0]); + assert_eq!(selected(&h), vec![all[0].clone()], "a focused node is selected"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(all[1].clone()), "Down Arrow moves to the next node and the focus goes with it"); + assert_eq!(selected(&h), vec![all[1].clone()]); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), all.last().cloned()); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), all.last().cloned(), "Down Arrow stays on the last node"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(all[0].clone())); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(all[0].clone()), "Up Arrow stays on the first node"); +} + +#[test] +fn right_goes_to_what_a_node_uses_left_goes_back_and_enter_opens_the_comp() { + let mut h = harness(); + // The chart's Tab stop is its selected node, the first one with none selected. + focus_node(&mut h, "Main, Composition, uses 2"); + assert_eq!(focused(&h).map(|f| f.1), Some("Main, Composition, uses 2".to_string())); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + let child = focused(&h).expect("Right Arrow moves to a node Main uses").1; + assert!(child.starts_with("Mid, ") || child.starts_with("Inner, "), "{child}"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some("Main, Composition, uses 2".to_string()), "Left Arrow goes back to the node that uses it"); + // (Right Arrow from Main goes to the first node it uses: the top layer, Inner.) + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some("Inner, Composition, uses 1".to_string())); + key(&mut h, Key::Enter, Modifiers::NONE); + let status = h.state().ui.status.clone(); + let active = h.state().session.active_comp_id().and_then(|id| h.state().session.project.item(id)).map(|i| i.name.clone()); + assert_eq!(active.as_deref(), Some("Inner"), "Enter opens the comp, as a double click does (status: {status})"); +} + +#[test] +fn after_a_click_the_arrows_belong_to_the_chart_and_do_not_nudge_layers() { + let mut h = harness(); + // Select the Mid layer in Main, then click a node: the arrows must not move the layer. + h.state_mut().session.execute("layer.select", json!({"layers": ["#1"]})).ok(); + h.run_steps(3); + let before = serde_json::to_string(&h.state().session.project).unwrap_or_default(); + let node = h.state().auto.find("flowchart.node.item:3").or_else(|| h.state().auto.query("flowchart.node.").into_iter().next()).cloned().expect("a node"); + click_at(&mut h, pos2(node.rect[0] + node.rect[2] / 2.0, node.rect[1] + node.rect[3] / 2.0)); + assert_eq!(h.state().ui.focused, PanelKind::Flowchart); + let selected_before = selected(&h); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_ne!(selected(&h), selected_before, "the arrow moved the chart's selection"); + assert_eq!(serde_json::to_string(&h.state().session.project).unwrap_or_default(), before, "no layer was nudged"); +} + +#[test] +fn a_node_scrolled_out_of_view_is_brought_back_by_the_keys() { + // A small window: the last node starts out of view (it is not in the tree until the key + // brings it in). + let mut h = sized(300.0, 400.0); + let all = names(&h); + let ctx = h.ctx.clone(); + let graph = effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "flowchart.graph", json!({})).unwrap(); + let total = graph["nodes"].as_array().map_or(0, Vec::len); + assert!(all.len() < total, "the window is small enough to hide a node ({} of {total} drawn)", all.len()); + focus_node(&mut h, &all[0]); + key(&mut h, Key::End, Modifiers::NONE); + let last_id = graph["nodes"].as_array().and_then(|n| n.last()).and_then(|n| n["id"].as_str()).unwrap_or_default().to_string(); + assert_eq!(h.state().ui.flowchart.selected.as_deref(), Some(last_id.as_str()), "End selects the last node"); + assert_eq!( + focused(&h).map(|f| f.0), + Some(Role::ListItem), + "the last node is drawn (the view panned to it) and has the focus; pan {:?}", + h.state().ui.flowchart.pan + ); +} diff --git a/crates/ui-egui/tests/ui_info_tracker_a11y.rs b/crates/ui-egui/tests/ui_info_tracker_a11y.rs new file mode 100644 index 000000000..7c8c8af54 --- /dev/null +++ b/crates/ui-egui/tests/ui_info_tracker_a11y.rs @@ -0,0 +1,77 @@ +//! Info panel and the 3D Camera Tracker block of Effect Controls (docs/gaps.md gap 25): what these +//! paint as text is also in the accessibility tree, as read-outs a screen reader can speak, and +//! the Analyze / Create Camera buttons are named and work from the keyboard. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(panel: PanelKind) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(panel); + h.state_mut().ui.maximized = Some(panel); + h.run_steps(4); + h +} + +fn labels(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.query_all_by_role(Role::Label).filter_map(|n| n.accesskit_node().label()).filter(|l| !l.trim().is_empty()).collect() +} + +#[test] +fn info_describes_the_colour_the_selection_and_the_time() { + let mut h = harness(PanelKind::Info); + let l = labels(&h); + for want in ["Red: 0", "Green: 0", "Blue: 0", "Alpha: 0"] { + assert!(l.iter().any(|x| x == want), "`{want}` in {l:?}"); + } + assert!(l.iter().any(|x| x == "Selected layers: Red"), "the selected layer: {l:?}"); + assert!(l.iter().any(|x| x.starts_with("Time: ")), "the time: {l:?}"); + // With one layer selected: its Duration, In and Out. + assert!(l.iter().any(|x| x.starts_with("Duration: ")) && l.iter().any(|x| x.starts_with("In: ")), "{l:?}"); + // Nothing selected: the panel says so. + h.state_mut().session.execute("layer.select", json!({"layers": []})).unwrap(); + h.run_steps(4); + assert!(labels(&h).iter().any(|x| x == "Selected layers: No layer selected"), "{:?}", labels(&h)); + assert!(!labels(&h).iter().any(|x| x.starts_with("Duration: "))); +} + +#[test] +fn the_camera_tracker_block_is_named_and_works_from_the_keyboard() { + let mut h = harness(PanelKind::EffectControls); + h.state_mut().session.execute("effect.apply", json!({"effect": effectcraft_engine::effects::camera_tracker::ID})).unwrap(); + h.run_steps(4); + // Applying the effect starts its analysis; while it runs, Analyze is unavailable and Cancel is + // there. Wait for it to end. + if h.state().session.is_camera_analyzing() { + assert!(h.get_by_role_and_label(Role::Button, "Analyze").accesskit_node().is_disabled(), "Analyze is unavailable while the analysis runs"); + } + for _ in 0..200 { + if !h.state().session.is_camera_analyzing() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + h.run_steps(2); + } + assert!(!h.state().session.is_camera_analyzing(), "the analysis ended"); + h.run_steps(4); + let analyze = h.get_by_role_and_label(Role::Button, "Analyze"); + assert!(!analyze.accesskit_node().is_disabled()); + assert!(labels(&h).iter().any(|x| x.contains("Analyze") || x.contains("solve") || x.contains("Method Used")), "the status is a read-out: {:?}", labels(&h)); + h.get_by_role_and_label(Role::Button, "Analyze").focus(); + h.run_steps(3); + assert_eq!(h.root().children_recursive().find(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).as_deref(), Some("Analyze")); + // Enter on the focused button starts a new analysis (the job exists for the pass it ends in). + assert!(h.state().session.camera_job.is_none()); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.state().session.camera_job.is_some() || h.state().session.is_camera_analyzing(), "Enter on Analyze starts the analysis"); +} diff --git a/crates/ui-egui/tests/ui_script_console_keyboard.rs b/crates/ui-egui/tests/ui_script_console_keyboard.rs new file mode 100644 index 000000000..8072238ea --- /dev/null +++ b/crates/ui-egui/tests/ui_script_console_keyboard.rs @@ -0,0 +1,107 @@ +//! Script Console accessible names and keyboard operation (docs/gaps.md gap 25): the input, the +//! output (a log), Run and Clear are named and reachable with Tab, Tab leaves the input instead of +//! typing a tab character, and Enter / Space work the buttons. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +const INPUT: &str = "Script… (⌘/Ctrl+Enter runs)"; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::ScriptConsole); + h.state_mut().ui.maximized = Some(PanelKind::ScriptConsole); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +#[test] +fn input_output_and_buttons_are_named() { + let mut h = harness(); + assert_eq!(h.get_by_role_and_label(Role::MultilineTextInput, INPUT).accesskit_node().role(), Role::MultilineTextInput); + let log = h.get_by_role_and_label(Role::Log, "Script Console output"); + assert!(log.accesskit_node().value().is_none_or(|v| v.is_empty()), "an empty console has an empty log"); + // Run is unavailable with nothing typed; Clear with nothing to clear. + assert!(h.get_by_role_and_label(Role::Button, "Run").accesskit_node().is_disabled()); + assert!(h.get_by_role_and_label(Role::Button, "Clear").accesskit_node().is_disabled()); + h.state_mut().ui.script_console.input = "1 + 1".into(); + h.state_mut().ui.script_console.record("1 + 1", &Ok(json!({"result": 2}))); + h.run_steps(4); + assert!(!h.get_by_role_and_label(Role::Button, "Run").accesskit_node().is_disabled()); + assert!(!h.get_by_role_and_label(Role::Button, "Clear").accesskit_node().is_disabled()); + let text = h.get_by_role_and_label(Role::Log, "Script Console output").accesskit_node().value().unwrap_or_default(); + assert!(text.contains("> 1 + 1") && text.contains('2'), "the log carries the output lines: {text:?}"); +} + +#[test] +fn tab_leaves_the_input_and_reaches_run_and_clear() { + let mut h = harness(); + h.state_mut().ui.script_console.input = "x".into(); + h.state_mut().ui.script_console.record("x", &Ok(json!({"result": 1}))); + h.run_steps(3); + h.get_by_role_and_label(Role::MultilineTextInput, INPUT).focus(); + h.run_steps(3); + assert_eq!(focused(&h).map(|f| f.0), Some(Role::MultilineTextInput)); + let mut seen = vec![]; + for _ in 0..6 { + key(&mut h, Key::Tab, Modifiers::NONE); + if let Some(f) = focused(&h) { + seen.push(f); + } + } + assert_eq!(h.state().ui.script_console.input, "x", "Tab does not type a tab character into the script"); + for want in ["Run", "Clear"] { + assert!(seen.iter().any(|(r, n)| *r == Role::Button && n == want), "Tab reaches {want}; reached {seen:?}"); + } +} + +#[test] +fn enter_runs_the_script_and_space_clears_the_log_from_the_buttons() { + let mut h = harness(); + h.state_mut().ui.script_console.input = "app.project.numItems".into(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Run").focus(); + h.run_steps(3); + assert_eq!(focused(&h), Some((Role::Button, "Run".to_string()))); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().ui.script_console.input.is_empty(), "Run ran the script and cleared the input"); + assert!(h.state().ui.script_console.log.len() >= 2, "its input and result are in the log: {:?}", h.state().ui.script_console.log); + h.get_by_role_and_label(Role::Button, "Clear").focus(); + h.run_steps(3); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(h.state().ui.script_console.log.is_empty(), "Space on Clear empties the log"); +} + +#[test] +fn ctrl_enter_runs_from_the_input() { + let mut h = harness(); + h.get_by_role_and_label(Role::MultilineTextInput, INPUT).focus(); + h.run_steps(3); + h.state_mut().ui.script_console.input = "1 + 2".into(); + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + key(&mut h, Key::Enter, Modifiers::COMMAND); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert!(h.state().ui.script_console.log.iter().any(|l| l.text.contains("> 1 + 2")), "Ctrl+Enter ran it"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9f4dd854c..c2c2c0ae9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 25, second slice: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable, #1335; earlier change: gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest. | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page) and the Render Queue with its Templates dialog are at zero and held there, with named, stateful nodes and arrow-key, Home / End, Enter and Space operation of their lists (#1335). The other 30 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each). `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 12–25 h (first slice, about 3 h, done) | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page), the Render Queue with its Templates dialog, Info, the Flowchart, the Script Console and the 3D Camera Tracker block of Effect Controls are at zero and held there, with named, stateful nodes, arrow-key, Home / End, Enter and Space operation of their lists and graph (#1335). Panels that only paint (Info, the tracker's status line) expose what they show as read-outs, and the Script Console's output as a log; each fixed scenario must show a minimum number of controls or read-outs, so zero cannot mean "looked at nothing". The other 25 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each); the Lumetri Scopes, Tracker, Layer, Waveform (a Timeline drawing) and the panels sharing `panel_kit` were left for when the open PRs on their files land. The shared-loop panels squeezed to nothing by the panels opened before them (Scopes, Media Browser, Metadata, Content-Aware Fill) read "zero" in the audit without being looked at. `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 10–22 h (two slices, about 6 h, done) | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 791950879..06bc561db 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier change: closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (accessibility row, second slice: Info, Flowchart, Script Console and the 3D Camera Tracker block named and keyboard-operable, #1335; earlier change: accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier change: closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -30,20 +30,21 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304); scrubbing lag (#329, closed by #784) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages) and Render Queue with Templates are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus | +| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages), Render Queue with Templates, Info, Flowchart, Script Console and the 3D Camera Tracker block are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); painted-only text (Info, status lines) and the console output are read-outs and a log in the tree; `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls; Scopes, Tracker, Layer and the Waveform have open PRs on their files and are untouched; their on-screen scopes and waveform have no text description yet); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate **≈ 60–120 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted test), 3D gizmos 8–15 h, multi-monitor panels 10–20 h, the 26.x interface changes 10–20 h, an -accessibility pass 12–25 h (the first slice, three panels and the audit, is done). Checking feel against After Effects needs the owner's Mac with After +accessibility pass 12–25 h (two slices, seven panels and the audit, are done). Checking feel against After Effects needs the owner's Mac with After Effects running; most of the rest parallelises. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Accessibility row, second slice: Info, Flowchart, Script Console and the 3D Camera Tracker block named and keyboard-operable (#1335); no screen reader tested | | 2026-10-13 | minor | Accessibility row: AccessKit audit and contrast tests, Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated | From b4c1569680fd3b831c0e9e86995fb6fb22bdb3e5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 17:41:51 +0200 Subject: [PATCH 3/4] Merge main into #1339; name the new Properties label control The merge of main brought a double-click label hit area on the Properties transform rows that had no accessible name; give it one so the audit ratchet holds. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/effects_presets.rs | 4 ++-- crates/ui-egui/src/panels/properties.rs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 17bfda59c..d4e7ab909 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -346,14 +346,14 @@ fn preset_row( name: &str, auto_id: &str, query: &str, - on_apply: impl FnOnce() -> Apply, + on_apply: impl Fn() -> Apply, apply: &mut Option, ) { let t = app.tokens; let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H)); let id = egui::Id::new(("fxpreset", auto_id)); let tab = nav.tab_stop(id); - let selected = nav.push(id, *y, 3, None, Some(on_apply.clone())); + let selected = nav.push(id, *y, 3, None, Some(on_apply())); *y += ROW_H; if r.max.y < list.min.y || r.min.y > list.max.y { return; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 84d8d4085..213ef01a8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -246,6 +246,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let vx = r.min.x + (r.width() * 0.5).max(130.0); let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y)); let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click()); + crate::a11y::role_name(&resp, egui::accesskit::Role::Button, &pr.name); app.auto.add(&format!("{base}.label"), label, &pr.name); if resp.double_clicked() { app.ui.timeline.search.clear(); From ace6d1836097cb5d75d61f49a2c5549cb66edf9c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:41:58 +0200 Subject: [PATCH 4/4] Review: Enter/Space on a favourite star only toggles it; keyboard activation of a property label; settings page scrolls to focus; sharper UI test assertions Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/effects_presets.rs | 6 ++++-- crates/ui-egui/src/panels/properties.rs | 3 ++- crates/ui-egui/src/panels/settings.rs | 1 + crates/ui-egui/tests/ui_accessibility_audit.rs | 6 +++--- crates/ui-egui/tests/ui_effects_presets_keyboard.rs | 1 + crates/ui-egui/tests/ui_render_queue_keyboard.rs | 5 +++-- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 0d27b510a..85119db02 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -393,6 +393,8 @@ fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rec } let focused = ctx.memory(|m| m.focused()); let on_row = focused.is_some_and(|f| nav.rows.iter().any(|r| r.id == f || r.star == Some(f))); + // Enter / Space on the star are the star button's (it toggles the favourite), not the row's. + let on_star = focused.is_some_and(|f| nav.rows.iter().any(|r| r.star == Some(f))); let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::EffectsPresets; if (!on_row && !by_mouse) || app.dialog.is_some() || widgets::any_menu_open(ctx) { return None; @@ -451,11 +453,11 @@ fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rec toggle(app, !*open); None } - (Key::Enter, None) => { + (Key::Enter, None) if !on_star => { apply = row.apply.clone(); None } - (Key::Space, None) if on_row => { + (Key::Space, None) if on_row && !on_star => { apply = row.apply.clone(); None } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 00b55abc7..b2d3d8e1a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -248,7 +248,8 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click()); crate::a11y::role_name(&resp, egui::accesskit::Role::Button, &pr.name); app.auto.add(&format!("{base}.label"), label, &pr.name); - if resp.double_clicked() { + // A double click, or the keyboard's activation (a primary single click is not one). + if resp.double_clicked() || (resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary)) { app.ui.timeline.search.clear(); app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid)); if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": lid, "prop": uid}]})) { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 0af1b9d0f..62530d90e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -700,6 +700,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { a11y::list_item(&resp, tr(p.title), sel, n + 1, all.len()); if a11y::take_pending_focus(&ctx, "settings", resp.id) { resp.request_focus(); + resp.scroll_to_me(None); } if sel { ui.painter().rect_filled(r, 3.0, t.accent); diff --git a/crates/ui-egui/tests/ui_accessibility_audit.rs b/crates/ui-egui/tests/ui_accessibility_audit.rs index 50e23d9cf..7ba5f4910 100644 --- a/crates/ui-egui/tests/ui_accessibility_audit.rs +++ b/crates/ui-egui/tests/ui_accessibility_audit.rs @@ -180,6 +180,9 @@ fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Op if a.is_hidden() { continue; } + if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) { + continue; + } let Some(b) = a.bounding_box() else { // No bounds: assistive technology cannot place it. Only a control is a finding (and // it is one wherever it is: there is no position to scope it by). @@ -191,9 +194,6 @@ fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Op if !scope.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) { continue; } - if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) { - continue; - } let role = a.role(); // What a panel describes without being operable: a graphic (a name is required in a fixed // panel), a read-out with a name, a log. diff --git a/crates/ui-egui/tests/ui_effects_presets_keyboard.rs b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs index 7c4c52413..499918a47 100644 --- a/crates/ui-egui/tests/ui_effects_presets_keyboard.rs +++ b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs @@ -191,6 +191,7 @@ fn the_star_is_a_named_button_on_the_cursor_row_and_enter_toggles_it() { assert_eq!(name, format!("Add to favorites: {effect}")); key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(h.state().session.prefs.effects_favorites.len(), 1, "Enter on the star adds the favourite"); + assert!(h.state().session.state.last_effect.is_none(), "Enter on the star does not also apply the effect"); let (_, name) = focused(&h).expect("the star keeps the focus"); assert_eq!(name, format!("Remove from favorites: {effect}")); } diff --git a/crates/ui-egui/tests/ui_render_queue_keyboard.rs b/crates/ui-egui/tests/ui_render_queue_keyboard.rs index 3f954b7b6..096c18e42 100644 --- a/crates/ui-egui/tests/ui_render_queue_keyboard.rs +++ b/crates/ui-egui/tests/ui_render_queue_keyboard.rs @@ -204,11 +204,12 @@ fn a_pick_with_the_mouse_leaves_no_keyboard_focus() { click(&mut h, "renderQueue.item.1.twirl"); click(&mut h, "renderQueue.item.1.renderSettings"); assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus"); - let template = h.state().session.project.render_templates.render_settings_list()[0].name.clone(); + let current = h.state().session.project.render_queue[0].settings.name.clone(); + let template = h.state().session.project.render_templates.render_settings_list().into_iter().map(|t| t.name).find(|n| *n != current).expect("a second template"); h.get_by_label_contains(&template).click(); h.run_steps(4); assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); - // The Effects & Presets options menu the same. + assert_eq!(h.state().session.project.render_queue[0].settings.name, template, "the pick applied the template"); } /// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to