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 |