mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #780 from vangel76/gap-project-panel-a11y
#776: Project panel arrow-key navigation and accessible names
This commit is contained in:
commit
15387619ee
5 files changed
+430
-17
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user