Merge pull request #780 from vangel76/gap-project-panel-a11y

#776: Project panel arrow-key navigation and accessible names
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:03:44 -04:00
commit 15387619ee
5 files changed
+430 -17

No files matched your search

+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");
+224
View File
@@ -0,0 +1,224 @@
//! 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());
}
/// A mouse click on a row gives no keyboard focus to the row, so the app's shortcuts run as
/// before the arrow keys came: Delete / Backspace delete, Cmd+D duplicates, Enter renames, also
/// after an arrow key moved the selection.
#[test]
fn shortcuts_still_run_after_a_mouse_click_and_the_arrows() {
let focus = |h: &Harness<'_, EffectcraftApp>| h.ctx.memory(|m| m.focused());
for delete_key in [Key::Delete, Key::Backspace] {
let (mut h, folder, _, _) = harness();
let empty = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty"})).unwrap()["item"].as_u64().unwrap());
h.run_steps(2);
click(&mut h, &format!("project.item.{}.name", empty.0));
assert_eq!(selection(&h), vec![empty]);
assert_eq!(focus(&h), None, "a click gives the row no keyboard focus");
key(&mut h, delete_key, Modifiers::NONE);
assert!(h.state().session.project.item(empty).is_none(), "{delete_key:?} deletes the clicked item");
// Click, an arrow, then the shortcut.
let empty2 = ItemId(h.state_mut().session.execute("project.newFolder", json!({"name": "Empty 2"})).unwrap()["item"].as_u64().unwrap());
h.run_steps(2);
let rows = rows(&h);
let at = rows.iter().position(|r| *r == empty2).unwrap();
let from = if at > 0 { rows[at - 1] } else { rows[at + 1] };
click(&mut h, &format!("project.item.{}.name", from.0));
key(&mut h, if at > 0 { Key::ArrowDown } else { Key::ArrowUp }, Modifiers::NONE);
assert_eq!(selection(&h), vec![empty2]);
assert_eq!(focus(&h), None, "the arrows don't take keyboard focus after a click");
key(&mut h, delete_key, Modifiers::NONE);
assert!(h.state().session.project.item(empty2).is_none(), "{delete_key:?} deletes the item the arrow moved to");
assert!(h.state().session.project.item(folder).is_some());
}
let (mut h, folder, _, solid) = harness();
// Folders can't be duplicated (engine rule): duplicate the solid.
click(&mut h, &format!("project.item.{}.name", solid.0));
let n = h.state().session.project.items.len();
key(&mut h, Key::D, Modifiers::COMMAND);
assert_eq!(h.state().session.project.items.len(), n + 1, "Cmd+D duplicates the clicked item");
click(&mut h, &format!("project.item.{}.name", folder.0));
key(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().auto.find(&format!("project.item.{}.nameEdit", folder.0)).is_some(), "Enter renames the clicked item");
}
+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