#1335: AccessKit audit test; names, roles and keyboard operation for Effects & Presets, Settings and Render Queue

ui_accessibility_audit walks the AccessKit tree and counts unnamed or roleless controls per panel (zero required for Project, Effects & Presets, Settings and the Render Queue; a ratchet for the rest). Rows are named tree items with selected / open state, the page list is a list with positions, controls carry label and value, the lists take the arrow keys, Home / End, Enter and Space, popup lists take the focus when they open. ui_contrast checks the theme tokens against WCAG 2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-13 21:12:22 +02:00
co-authored by Claude Sonnet 5.5
parent 549e9d8251
commit c1287001c5
16 changed files with 1899 additions and 64 deletions
+210
View File
@@ -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<bool>) {
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<bool>) {
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
}
+1
View File
@@ -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;
+12 -6
View File
@@ -253,9 +253,9 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
/// 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<Value, String> {
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<Val
_ => 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
+1 -1
View File
@@ -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));
+313 -30
View File
@@ -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<egui::Id>,
parent: Option<egui::Id>,
/// 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<Apply>,
}
/// 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<NavRow>,
/// The open folders on the way down to the row being drawn, by level.
stack: Vec<egui::Id>,
cursor: Option<egui::Id>,
/// 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<Apply>) -> 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<Icon>,
@@ -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<Apply> {
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::<egui::Id>(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<Key> = 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<PresetFile> {
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<Apply> = None;
let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::<egui::Id>(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<PresetFile> =
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::<DragPayload>(&ctx).as_deref()
&& let Some(pos) = ctx.pointer_hover_pos()
{
+125 -14
View File
@@ -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(&notify_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<String> = 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<String> = 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<String> = 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<egui::Key> = 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);
}
@@ -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();
+67 -2
View File
@@ -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<egui::Response> = 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::<Vec<_>>()
}) {
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();
+79 -5
View File
@@ -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::<u64>(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<usize>) -> Option<usize> {
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
@@ -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<Rect> {
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<Rect> {
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<String> {
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<String>>) -> (Vec<Finding>, 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<Finding>,
/// (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<Finding>, 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})");
}
}
}
+92
View File
@@ -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"));
}
@@ -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<String> {
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::<String>::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");
}
@@ -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::<Vec<_>>()
};
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");
}
@@ -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");
}
+4 -3
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -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 |