Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions crates/ui-egui/src/a11y.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! 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));
}

/// Give `r`'s node a current value and keep the name it already has.
pub(crate) fn value(r: &Response, value: &str) {
node(r, |n| n.set_value(value));
}

/// 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);
});
}

/// 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::unique(("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::unique(("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
}
4 changes: 3 additions & 1 deletion crates/ui-egui/src/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping")).clicked() {
let snap_resp = widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping"));
crate::a11y::check_box(&snap_resp, tr("Snapping"), app.ui.snapping, true);
if snap_resp.clicked() {
let _ = app.session.execute("view.snapping", serde_json::json!({}));
app.ui.snapping = app.session.state.snapping;
}
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions crates/ui-egui/src/menus/invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ 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
/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and Render Queue (which own the
/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or
/// menu open. With mask points selected the arrows move the points instead (#1288).
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
Expand All @@ -131,7 +131,10 @@ 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.selected_vertices.is_empty()) || st.text_edit.is_some() {
if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue)
|| (st.selected_layers.is_empty() && st.selected_vertices.is_empty())
|| st.text_edit.is_some()
{
return Ok(Value::Null);
}
// Screen pixels per comp pixel.
Expand Down
9 changes: 8 additions & 1 deletion crates/ui-egui/src/menus/shortcuts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
}

/// Panels that handle Enter themselves (rename, apply, open the row).
fn panel_owns_enter(kind: PanelKind) -> bool {
matches!(kind, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue)
}

fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
let start = modifiers_at_start(ctx);
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
Expand Down Expand Up @@ -364,7 +369,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
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 {
// In Effects & Presets and the Render Queue Enter applies or opens the row (handled by the
// panel), not the layer rename.
if key == egui::Key::Enter && !mods.any() && panel_owns_enter(app.ui.focused) {
continue;
}
// The Project panel has its own selection; a previously selected Timeline layer
Expand Down
2 changes: 1 addition & 1 deletion crates/ui-egui/src/panels/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,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));
Expand Down
Loading
Loading