diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 21f84297..64bc43b6 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -3,8 +3,9 @@ //! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context //! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name), //! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop -//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are -//! undoable. +//! items on it for a comp from them, bit depth, delete), and the keyboard (the arrow keys select, +//! extend the selection and open / close folders; rows and buttons carry AccessKit names and +//! states for screen readers). Edits are engine commands, so they are undoable. use std::sync::{Arc, Mutex}; @@ -191,6 +192,130 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) { } } +/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift). +fn anchor_id() -> egui::Id { + egui::Id::new("project-selection-anchor") +} + +/// The item the arrow keys move from: the last item clicked or moved to. +fn cursor_id() -> egui::Id { + egui::Id::new("project-selection-cursor") +} + +/// An item row's widget: its click / drag area, keyboard focus and accessibility node. +fn row_id(item: ItemId) -> egui::Id { + egui::Id::new(("pitem", item.0)) +} + +/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view). +fn focused_row_id() -> egui::Id { + egui::Id::new("project-focused-row") +} + +/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys +/// apply. Any other focused widget (a text field, a button reached with Tab) keeps them. +fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool { + match ctx.memory(|m| m.focused()) { + None => app.ui.focused == crate::dock::PanelKind::Project, + Some(f) => ctx.data(|d| d.get_temp::(focused_row_id())).is_some_and(|row| row_id(row) == f), + } +} + +/// The Project panel's arrow keys, as in After Effects: Up / Down Arrow select the previous / +/// next item (the first or last when nothing is selected), Shift+Up / Down extend the selection +/// from its anchor, Right Arrow opens the selected folder and Left Arrow closes it (on an item +/// inside a folder, Left selects the folder). They apply while the panel has focus and nothing +/// else takes them: a dialog, an open menu, an inline rename. A row focused with Tab keeps them +/// too, and keyboard focus then follows the selection, so a screen reader reads the new item. +/// Returns the item the selection moved to. +fn keyboard_nav(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option { + use egui::Key; + let keys: Vec<(Key, egui::Modifiers)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key: k @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => { + Some((*k, *modifiers)) + } + _ => None, + }) + .collect() + }); + if keys.is_empty() + || app.dialog.is_some() + || widgets::any_menu_open(ctx) + || ctx.data(|d| d.get_temp::(edit_id())).is_some() + || !keys_reach_rows(app, ctx) + { + return None; + } + let row_focused = ctx.memory(|m| m.focused()).is_some(); + let mut moved = None; + for (key, m) in keys { + let (plain, shift) = (!m.any(), m.shift_only()); + // Folders opened or closed by an earlier key in this frame change the rows. + let rows = visible_rows(app); + let pos = |id: ItemId| rows.iter().position(|(r, _)| *r == id); + let sel = &app.session.state.project_selection; + let cursor = ctx.data(|d| d.get_temp::(cursor_id())).filter(|c| sel.contains(c)).or_else(|| sel.last().copied()); + let cur = cursor.and_then(pos); + let to = match key { + Key::ArrowUp | Key::ArrowDown if plain || shift => { + let Some(last) = rows.len().checked_sub(1) else { continue }; + let next = match (cur, key == Key::ArrowDown) { + (Some(c), true) => c.saturating_add(1).min(last), + (Some(c), false) => c.saturating_sub(1), + (None, true) => 0, + (None, false) => last, + }; + let Some((to, _)) = rows.get(next).copied() else { continue }; + if shift { + let anchor = ctx.data(|d| d.get_temp::(anchor_id())).and_then(pos).or(cur).unwrap_or(next); + app.session.state.project_selection = rows.get(anchor.min(next)..=anchor.max(next)).unwrap_or_default().iter().map(|(i, _)| *i).collect(); + to + } else { + app.session.state.project_selection = vec![to]; + ctx.data_mut(|d| d.insert_temp(anchor_id(), to)); + to + } + } + Key::ArrowRight | Key::ArrowLeft if plain => { + let Some((id, folder, parent)) = cursor.and_then(|c| app.session.project.item(c)).map(|it| (it.id, it.is_folder(), it.parent)) else { + continue; + }; + let open = app.ui.project_open_folders.contains(&id.0); + if key == Key::ArrowRight { + if folder { + app.ui.project_open_folders.insert(id.0); + } + continue; + } + if folder && open { + app.ui.project_open_folders.remove(&id.0); + continue; + } + let Some(parent) = parent.filter(|p| pos(*p).is_some()) else { continue }; + app.session.state.project_selection = vec![parent]; + ctx.data_mut(|d| d.insert_temp(anchor_id(), parent)); + parent + } + _ => continue, + }; + ctx.data_mut(|d| d.insert_temp(cursor_id(), to)); + moved = Some(to); + } + // The arrows are the list's, not egui's spatial focus moves. + if row_focused { + ctx.memory_mut(|m| { + m.move_focus(egui::FocusDirection::None); + if let Some(to) = moved { + m.request_focus(row_id(to)); + } + }); + } + moved +} + pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { let mut kids = app.session.project.children(folder); @@ -307,6 +432,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { )); } app.auto.add(&format!("project.sort.{key}"), hr, if label.is_empty() { "Label" } else { label }); + let column = crate::i18n::tr(if label.is_empty() { "Label" } else { label }); + let order = (app.ui.project_sort == *key).then(|| crate::i18n::tr(if app.ui.project_sort_desc { "descending" } else { "ascending" })); + resp.widget_info(|| { + let name = match order { + Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]), + None => crate::i18n::tr_args("Sort by {}", &[&column]), + }; + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); if resp.clicked() { if app.ui.project_sort == *key { app.ui.project_sort_desc = !app.ui.project_sort_desc; @@ -317,7 +451,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } column_menu(app, &resp); } - let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::click()); + let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK); column_menu(app, &hresp); app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)"); // Rows. @@ -327,7 +461,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // The list's empty area (under the rows and the scroll bar, which take their own clicks): // a click deselects, a drag draws a selection box, a double-click imports, as in After // Effects (File ▸ Import ▸ File...). - let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag()); + // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key. + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG); app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); @@ -340,11 +475,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow); } + let moved = keyboard_nav(app, &ctx); let rows = visible_rows(app); // Enter renames the selected item (Project panel focused, not typing). if app.ui.focused == crate::dock::PanelKind::Project && app.dialog.is_none() - && !ctx.egui_wants_keyboard_input() + && keys_reach_rows(app, &ctx) && ctx.data(|d| d.get_temp::(edit_id())).is_none() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -382,6 +518,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end). + if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) { + let top = row as f32 * ROW_H; + // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins. + app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top); + } app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll); let scroll = app.ui.project_scroll; let first = ((scroll / ROW_H).floor() as usize).min(rows.len()); @@ -406,14 +548,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ); // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets // on top of it take their own clicks. - let mut resp = ui.interact(r.intersect(list), egui::Id::new(("pitem", id.0)), Sense::click_and_drag()); + let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag()); app.auto.add(&format!("project.item.{}", id.0), r, &it.name); + // For screen readers: a tree item named by the item and its type, with its selection, + // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply. + let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name())))); + ctx.accesskit_node_builder(resp.id, |n| { + n.set_role(egui::accesskit::Role::TreeItem); + n.set_selected(selected); + n.set_level(depth.saturating_add(1)); + if let Some(open) = open { + n.set_expanded(open); + } + }); + if resp.has_focus() { + ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id)); + } + if resp.gained_focus() { + app.ui.focused = crate::dock::PanelKind::Project; + } let _ = i; let x0 = r.min.x + 8.0 + 14.0 * *depth as f32; if it.is_folder() { let open = app.ui.project_open_folders.contains(&id.0); let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0)); - if widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t).clicked() { + let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t); + tresp.widget_info(|| { + let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); + if tresp.clicked() { if open { app.ui.project_open_folders.remove(&id.0); } else { @@ -437,6 +602,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if px.enabled { "in use" } else { "off" } )); app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" }); + presp.widget_info(|| { + let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); if presp.clicked() { actions.push(("file.useProxy".into(), json!({"item": id.0}))); } @@ -485,6 +654,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.rect_filled(sw, 2.0, t.label(it.label)); let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click()); app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name()); + sresp.widget_info(|| { + let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]); + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name) + }); let pop = egui::Id::new(("plabel-pop", id.0)); if sresp.clicked() || sresp.secondary_clicked() { widgets::open_popup(ui, pop); @@ -510,8 +683,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if cell_edit(app, ui, &mut actions, "comment", cr) { continue; } - let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::click()); + let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK); app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment); + cresp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment])) + }); if cresp.double_clicked() { ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone()))); } else { @@ -529,12 +705,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() { lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag()); + // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring. + if ctx.memory(|m| m.has_focus(row_id(*id))) { + lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + // Part of the row: not a second Tab stop. + let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG); app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name); let resp = resp.union(nresp); if resp.clicked() { let m = ui.input(|i| i.modifiers); - let anchor_key = egui::Id::new("project-selection-anchor"); + let anchor_key = anchor_id(); + ctx.data_mut(|d| d.insert_temp(cursor_id(), *id)); let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied()); if m.shift && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a)) @@ -692,6 +874,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0)); let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); app.auto.add(&format!("project.{id}"), r, tip); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip))); if resp.clicked() { let footage = app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_))); @@ -724,6 +907,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click()); p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim }); app.auto.add("project.bitDepth", dr, depth); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth]))); // Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects). if dresp.clicked() { if ui.input(|i| i.modifiers.alt) { @@ -734,9 +918,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings")); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); - if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")).clicked() - && !app.session.state.project_selection.is_empty() - { + let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items")); + trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items"))); + if trash.clicked() && !app.session.state.project_selection.is_empty() { actions.push(("project.delete".into(), json!({}))); } app.auto.add("project.delete", tr, "Delete"); diff --git a/crates/ui-egui/tests/ui_project_keyboard.rs b/crates/ui-egui/tests/ui_project_keyboard.rs new file mode 100644 index 00000000..dba497cc --- /dev/null +++ b/crates/ui-egui/tests/ui_project_keyboard.rs @@ -0,0 +1,184 @@ +//! Project panel keyboard navigation and accessibility (#776): the arrow keys move and extend the +//! selection and open / close folders, as in After Effects; the rows, sort headers and row buttons +//! reach screen readers with names, roles and states; a row focused with Tab keeps the arrows, and +//! keyboard focus follows the selection. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemId, ItemKind}; +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; + +/// A closed folder holding a comp, and a solid at the project root: (harness, folder, comp, solid). +fn harness() -> (Harness<'static, EffectcraftApp>, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let comp = ItemId(s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap()); + s.execute("layer.newSolid", json!({"name": "Solid", "color": "#406080"})).unwrap(); + let solid = s.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let folder = ItemId(s.execute("project.newFolder", json!({"name": "Folder"})).unwrap()["item"].as_u64().unwrap()); + s.execute("project.move", json!({"items": [comp.0], "folder": folder.0})).unwrap(); + s.execute("project.move", json!({"items": [solid.0], "folder": null})).unwrap(); + s.execute("project.select", json!({"items": []})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, folder, comp, solid) +} + +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); +} + +/// Press and release `key` with `modifiers` (one frame each), then let the UI settle. +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(2); +} + +/// The item rows on screen, top to bottom. +fn rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let mut rows: Vec<(f32, ItemId)> = h + .state() + .auto + .query("project.item.") + .into_iter() + .filter_map(|e| e.id.strip_prefix("project.item.").and_then(|n| n.parse().ok()).map(|n| (e.rect[1], ItemId(n)))) + .collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + rows.into_iter().map(|(_, id)| id).collect() +} + +fn selection(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.state.project_selection.clone() +} + +#[test] +fn arrow_keys_move_and_extend_the_project_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + assert!(rows.len() >= 2, "rows: {rows:?}"); + let (first, second) = (rows[0], rows[1]); + click(&mut h, &format!("project.item.{}.name", first.0)); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert_eq!(selection(&h), vec![first]); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![second], "Down Arrow selects the next item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "Up Arrow selects the previous item"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![first], "the first item stays selected at the top"); + for _ in 0..rows.len() + 1 { + key(&mut h, Key::ArrowDown, Modifiers::SHIFT); + } + assert_eq!(selection(&h), rows, "Shift+Down Arrow extends the selection to the last item"); + key(&mut h, Key::ArrowUp, Modifiers::SHIFT); + assert_eq!(selection(&h), rows[..rows.len() - 1], "Shift+Up Arrow shrinks it back toward the anchor"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]], "a plain arrow selects one item again"); + // With nothing selected, Down selects the first item and Up the last. + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![first]); + h.state_mut().session.state.project_selection.clear(); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[rows.len() - 1]]); +} + +#[test] +fn right_and_left_arrows_open_and_close_the_selected_folder() { + let (mut h, folder, comp, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + assert!(!rows(&h).contains(&comp), "the folder starts closed"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.project_open_folders.contains(&folder.0), "Right Arrow opens the selected folder"); + assert!(rows(&h).contains(&comp)); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![comp], "Down Arrow walks into the open folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder], "Left Arrow on an item in a folder selects the folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.project_open_folders.contains(&folder.0), "Left Arrow closes the selected folder"); + assert_eq!(selection(&h), vec![folder]); +} + +#[test] +fn arrows_leave_the_project_selection_alone_when_another_panel_has_focus() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + h.state_mut().ui.focused = PanelKind::Timeline; + key(&mut h, Key::ArrowDown, Modifiers::NONE); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(selection(&h), vec![folder]); + assert!(!h.state().ui.project_open_folders.contains(&folder.0)); +} + +#[test] +fn rows_and_headers_have_accessible_names_roles_and_states() { + let (mut h, folder, _, _) = harness(); + click(&mut h, &format!("project.item.{}.name", folder.0)); + let row = h.get_by_label("Folder, Folder"); + let node = row.accesskit_node(); + assert_eq!(node.role(), Role::TreeItem); + assert_eq!(node.is_selected(), Some(true), "the selected row says so"); + assert_eq!(node.data().is_expanded(), Some(false), "a closed folder says so"); + assert_eq!(node.data().level(), Some(1)); + let solid = h.get_by_label("Solid, Solid"); + assert_eq!(solid.accesskit_node().is_selected(), Some(false)); + assert_eq!(solid.accesskit_node().data().is_expanded(), None, "only folders open"); + assert_eq!(h.get_by_label("Sort by Name, ascending").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Open folder Folder").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Delete selected project items").accesskit_node().role(), Role::Button); + assert!(h.query_by_label_contains("Label of Folder: ").is_some(), "the label swatch is named"); + assert!(h.query_by_label("Create a new Folder").is_some()); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_label("Folder, Folder").accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(h.get_by_label("Main, Composition").accesskit_node().data().level(), Some(2), "the comp sits one level down"); +} + +#[test] +fn a_focused_row_keeps_the_arrows_and_focus_follows_the_selection() { + let (mut h, _, _, _) = harness(); + let rows = rows(&h); + let names: Vec = rows + .iter() + .map(|id| { + let it = h.state().session.project.item(*id).unwrap(); + format!("{}, {}", it.name, it.type_name()) + }) + .collect(); + // Focus the first row the way a screen reader (or Tab) does, without a click: the panel + // takes focus with it. + h.state_mut().ui.focused = PanelKind::Timeline; + h.get_by_label(&names[0]).focus(); + h.run_steps(3); + assert!(h.get_by_label(&names[0]).is_focused()); + assert_eq!(h.state().ui.focused, PanelKind::Project, "keyboard focus on a row focuses the panel"); + // A plain arrow moved egui's focus to the widget below; here it moves the selection and the + // focus with it. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]], "nothing was selected: Down selects the first item"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[1]]); + assert!(h.get_by_label(&names[1]).is_focused(), "keyboard focus follows the selection"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(selection(&h), vec![rows[0]]); + assert!(h.get_by_label(&names[0]).is_focused()); +} diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 4c255cad..7d0ebf66 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -127,16 +127,21 @@ fn footage_context_menu_interprets_the_footage() { } h.run_steps(2); }; + // The menu's Interpret Footage entry, not the panel's footer button of the same name. + let menu_entry = |h: &Harness<'_, EffectcraftApp>| { + let footer = center(h, "project.interpret"); + h.query_all_by_label_contains("Interpret Footage").find(|n| !n.rect().contains(footer)).map(|n| n.rect()) + }; let at = center(&h, &format!("project.item.{}.name", solid.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![solid]); - assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage"); + assert!(menu_entry(&h).is_none(), "a solid isn't footage"); h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); let at = center(&h, &format!("project.item.{}.name", clip.0)); right_click(&mut h, at); assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item"); - let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect(); + let sub = menu_entry(&h).expect("Interpret Footage in the menu"); h.input_mut().events.push(Event::PointerMoved(sub.center())); h.run_steps(3); let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect(); diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..0d513ef1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest. | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fd807ebc..dfcaf0ab 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -30,7 +30,7 @@ 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), Shift+Space default (#551) | | 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 | 20% | No screen-reader pass, keyboard-navigation audit or contrast check | +| 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 | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | ## Estimate