#776: Project panel arrow-key navigation and accessible names

Problem: with the Project panel focused the arrow keys did nothing (After
Effects selects the previous / next item, extends with Shift and opens /
closes folders with Right / Left). The custom-drawn rows, sort headers,
twirls, label swatches, proxy switches and footer buttons reached
AccessKit with no name or role, the empty list area and each row's name
cell were unnamed extra Tab stops, a Tab-focused row showed no focus
ring, and the arrows then moved egui's spatial focus instead.

Fix: Up / Down select the previous / next row (Shift extends from the
anchor), Right opens and Left closes the selected folder or selects the
parent; the moved-to row scrolls into view. A row with keyboard focus
keeps the arrows, focus follows the selection and the panel takes focus.
Rows are AccessKit tree items named "<name>, <type>" with selected,
level and expanded state; headers and row / footer buttons are named
buttons; one Tab stop per row and a focus ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 12:53:48 +02:00
co-authored by Claude Opus 5.5
parent 9a7d3e7699
commit 132bd21078
5 changed files with 390 additions and 17 deletions
+197 -13
View File
@@ -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::<ItemId>(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<ItemId> {
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::<Editing>(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::<ItemId>(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::<ItemId>(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<ItemId>, 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::<Editing>(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::<Editing>(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::<ItemId>(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");
+184
View File
@@ -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<ItemId> {
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<ItemId> {
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<String> = 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());
}
+7 -2
View File
@@ -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();
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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