From 4860892332185e88c28b645a9b79d9e18d195d26 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 11:12:33 -0400 Subject: [PATCH 1/2] LR-BEHAV-ACCESS: panels and bars are named as landmarks and toolbars for screen readers --- crates/ui-egui/locales/cs.json | 5 +- crates/ui-egui/locales/de.json | 5 +- crates/ui-egui/locales/es.json | 5 +- crates/ui-egui/locales/fi.json | 5 +- crates/ui-egui/locales/fr.json | 5 +- crates/ui-egui/locales/id.json | 5 +- crates/ui-egui/locales/it.json | 5 +- crates/ui-egui/locales/ja.json | 5 +- crates/ui-egui/locales/ka.json | 5 +- crates/ui-egui/locales/pl.json | 5 +- crates/ui-egui/locales/pt-br.json | 5 +- crates/ui-egui/locales/ru.json | 5 +- crates/ui-egui/locales/sv.json | 5 +- crates/ui-egui/locales/uk.json | 5 +- crates/ui-egui/locales/vi.json | 5 +- crates/ui-egui/locales/zh-hans.json | 5 +- crates/ui-egui/locales/zh-hant.json | 5 +- crates/ui-egui/src/docking.rs | 11 +++- crates/ui-egui/src/panels/bottombar.rs | 1 + crates/ui-egui/src/panels/detail/filmstrip.rs | 1 + crates/ui-egui/src/panels/strip.rs | 1 + crates/ui-egui/src/panels/topbar.rs | 1 + crates/ui-egui/src/widgets.rs | 14 +++++ crates/ui-egui/tests/it/landmarks.rs | 62 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/parity-checklist.md | 5 +- docs/whats-new.md | 7 +++ 27 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/tests/it/landmarks.rs diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 52ca94a52..5028a9ca5 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2408,5 +2408,8 @@ "Import dropped files without review": "Importovat přetažené soubory bez kontroly", "An import is running": "Import už běží", "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí", - "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky" + "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky", + "Top bar": "Horní lišta", + "Tool strip": "Pruh nástrojů", + "Bottom bar": "Dolní lišta" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e2205db78..e10d5c62a 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -3440,5 +3440,8 @@ "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren", "An import is running": "Ein Import läuft", "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist", - "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst" + "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst", + "Top bar": "Obere Leiste", + "Tool strip": "Werkzeugleiste", + "Bottom bar": "Untere Leiste" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 888af077f..8ffa50980 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2358,5 +2358,8 @@ "Import dropped files without review": "Importar los archivos soltados sin revisión", "An import is running": "Hay una importación en curso", "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine", - "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos" + "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos", + "Top bar": "Barra superior", + "Tool strip": "Barra de herramientas", + "Bottom bar": "Barra inferior" } diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index e0a8c622a..77b7af68c 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -2327,5 +2327,8 @@ "Drop to open the face model": "Pudota avataksesi kasvomallin", "Dropped Files": "Pudotetut tiedostot", "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä", - "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta" + "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta", + "Top bar": "Yläpalkki", + "Tool strip": "Työkalupalkki", + "Bottom bar": "Alapalkki" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 3278d6ccd..384edda72 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2390,5 +2390,8 @@ "Checking recent libraries": "Vérification des bibliothèques récentes", "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte", "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages", - "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :" + "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :", + "Top bar": "Barre supérieure", + "Tool strip": "Barre d’outils", + "Bottom bar": "Barre inférieure" } diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index 0d6da9b8f..580e42e72 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -2339,5 +2339,8 @@ "Drop to open the face model": "Jatuhkan untuk membuka model wajah", "Dropped Files": "File yang dijatuhkan", "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto", - "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan" + "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan", + "Top bar": "Bilah atas", + "Tool strip": "Bilah alat", + "Bottom bar": "Bilah bawah" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index e159fd47d..c5032b439 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2361,5 +2361,8 @@ "Import dropped files without review": "Importa i file rilasciati senza revisione", "An import is running": "Un'importazione è in corso", "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita", - "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto" + "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto", + "Top bar": "Barra superiore", + "Tool strip": "Barra degli strumenti", + "Bottom bar": "Barra inferiore" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index e1834555b..76a840fb5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -2356,5 +2356,8 @@ "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む", "An import is running": "読み込み中です", "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください", - "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください" + "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください", + "Top bar": "上部バー", + "Tool strip": "ツールバー", + "Bottom bar": "下部バー" } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 32c407700..4d02b8fba 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -2734,5 +2734,8 @@ "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე", "An import is running": "იმპორტი მიმდინარეობს", "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება", - "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი" + "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი", + "Top bar": "ზედა ზოლი", + "Tool strip": "ხელსაწყოების ზოლი", + "Bottom bar": "ქვედა ზოლი" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index adac22e62..b482ce660 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2283,5 +2283,8 @@ "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu", "An import is running": "Import jest w toku", "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy", - "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia" + "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia", + "Top bar": "Górny pasek", + "Tool strip": "Pasek narzędzi", + "Bottom bar": "Dolny pasek" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e84fb7615..b16cc7d4e 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2202,5 +2202,8 @@ "Import dropped files without review": "Importar arquivos soltos sem revisão", "An import is running": "Uma importação está em andamento", "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar", - "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos" + "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos", + "Top bar": "Barra superior", + "Tool strip": "Barra de ferramentas", + "Bottom bar": "Barra inferior" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 306804df0..92b999929 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2349,5 +2349,8 @@ "Import dropped files without review": "Импортировать перетащенные файлы без проверки", "An import is running": "Импорт уже выполняется", "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится", - "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото" + "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото", + "Top bar": "Верхняя панель", + "Tool strip": "Панель инструментов", + "Bottom bar": "Нижняя панель" } diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index 66e69b7ba..3f53bbf32 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -2429,5 +2429,8 @@ "Drop to open the face model": "Släpp för att öppna ansiktsmodellen", "Dropped Files": "Släppta filer", "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton", - "Import dropped files without review": "Importera släppta filer utan granskning" + "Import dropped files without review": "Importera släppta filer utan granskning", + "Top bar": "Övre fältet", + "Tool strip": "Verktygsfält", + "Bottom bar": "Nedre fältet" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index ee694ed4f..94e78f318 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2354,5 +2354,8 @@ "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду", "An import is running": "Імпорт уже виконується", "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться", - "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото" + "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото", + "Top bar": "Верхня панель", + "Tool strip": "Панель інструментів", + "Bottom bar": "Нижня панель" } diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index e4fb8feb8..bed880879 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -2235,5 +2235,8 @@ "Drop to open the face model": "Thả để mở mô hình khuôn mặt", "Dropped Files": "Tệp đã thả", "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh", - "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại" + "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại", + "Top bar": "Thanh trên cùng", + "Tool strip": "Thanh công cụ", + "Bottom bar": "Thanh dưới cùng" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 270af8a62..a10ab97fb 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2200,5 +2200,8 @@ "Import dropped files without review": "不经检查直接导入拖入的文件", "An import is running": "正在导入", "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片", - "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入" + "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入", + "Top bar": "顶部栏", + "Tool strip": "工具栏", + "Bottom bar": "底部栏" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0408e23ec..6913fb21d 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -2157,5 +2157,8 @@ "Import dropped files without review": "不經檢查直接匯入拖放的檔案", "An import is running": "正在匯入", "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片", - "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入" + "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入", + "Top bar": "頂部列", + "Tool strip": "工具列", + "Bottom bar": "底部列" } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index f2bc190d7..bbc05c497 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -402,17 +402,24 @@ pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { panel_limits, |ui, panel| match panel { Panel::Canvas => { - canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); + let child = ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect())); + crate::widgets::announce_region(&child, egui::accesskit::Role::Main, crate::i18n::tr(Panel::Canvas.label())); + canvas = Some(child); } Panel::Library => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Navigation, crate::i18n::tr(Panel::Library.label())); crate::widgets::register(ui, "panel:left_panel", ui.max_rect()); crate::panels::left::show(app, ui); } Panel::Tools => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Tools.label())); crate::widgets::register(ui, "panel:right_panel", ui.max_rect()); crate::panels::right::show(app, ui); } - Panel::Presets => crate::panels::presets::show(app, ui), + Panel::Presets => { + crate::widgets::announce_region(ui, egui::accesskit::Role::Complementary, crate::i18n::tr(Panel::Presets.label())); + crate::panels::presets::show(app, ui); + } }, ); if let Some(mut canvas) = canvas { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 0b48d1e34..e52f202d5 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -25,6 +25,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .exact_size(t.bottom_bar_h) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Bottom bar")); let full = ui.max_rect(); let compact_zoom = full.width() < 360.0; let right_start = right_side(app, ui, full, compact_zoom); diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index 4a0de28cf..ec5364cd9 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -69,6 +69,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { return; } let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); + crate::widgets::announce_region(&child, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip")); // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index 164204b86..faf527be9 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .stroke(egui::Stroke::new(1.0, t.divider)), ) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Tool strip")); let has_photo = app.session.active().is_some(); ui.vertical_centered(|ui| { ui.spacing_mut().item_spacing.y = GAP; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 5e3fea8a1..f76de9731 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -32,6 +32,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .exact_size(t.top_bar_h) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { + crate::widgets::announce_region(ui, egui::accesskit::Role::Toolbar, crate::i18n::tr("Top bar")); let full = ui.max_rect(); // The whole bar, its margins included. The search field is centred on this and not on // `full`, which is only what the margins leave: on macOS the left margin holds the diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 89185d991..16b583433 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1154,6 +1154,20 @@ pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string())); } +/// Names the part of the window `ui` draws for screen readers: a toolbar, or a landmark (VoiceOver's +/// rotor lists the landmarks and jumps to one). Set on the node the `Ui` already has, so nothing is +/// laid out differently. The node gets the `Ui`'s area as its bounds, so a screen reader's cursor +/// lands on the part and not on an empty spot. +pub(crate) fn announce_region(ui: &Ui, role: egui::accesskit::Role, name: impl Into) { + let name = name.into(); + let r = ui.max_rect(); + ui.ctx().accesskit_node_builder(ui.unique_id(), |node| { + node.set_role(role); + node.set_label(name); + node.set_bounds(egui::accesskit::Rect { x0: f64::from(r.min.x), y0: f64::from(r.min.y), x1: f64::from(r.max.x), y1: f64::from(r.max.y) }); + }); +} + /// A hand-drawn clickable area (an icon, a tile, a row) announced as a button named `name`. pub(crate) fn announce(resp: &egui::Response, name: impl Into) { let name = name.into(); diff --git a/crates/ui-egui/tests/it/landmarks.rs b/crates/ui-egui/tests/it/landmarks.rs new file mode 100644 index 000000000..69a92cdfc --- /dev/null +++ b/crates/ui-egui/tests/it/landmarks.rs @@ -0,0 +1,62 @@ +//! The window's parts are named for screen readers: the three bars are toolbars, and the sidebar, the +//! photos, the tool panel and the filmstrip are landmarks, so VoiceOver's rotor lists them and a +//! person can jump to one instead of walking every control. +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::{Harness, kittest::NodeT}; +use lightcraft_ui_egui::{ + LightcraftApp, Services, + state::{RightPanel, ViewMode}, +}; + +fn harness(view: ViewMode) -> Harness<'static, LightcraftApp> { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = view; + app.ui.right = RightPanel::Edit; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + h +} + +/// The area each node with this role and this name covers (`None`: it reports none). +fn areas(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> Vec> { + let nodes = h.root().children_recursive().map(|n| n.accesskit_node()); + nodes.filter(|n| n.role() == role && n.label().as_deref() == Some(name)).map(|n| n.bounding_box()).collect() +} + +/// How many nodes have this role and this name. +fn count(h: &Harness<'_, LightcraftApp>, role: Role, name: &str) -> usize { + areas(h, role, name).len() +} + +#[test] +fn the_bars_are_toolbars_and_the_panels_are_landmarks_in_detail() { + let h = harness(ViewMode::Detail); + for (role, name) in [ + (Role::Toolbar, "Top bar"), + (Role::Toolbar, "Tool strip"), + (Role::Toolbar, "Bottom bar"), + (Role::Navigation, "Library"), + (Role::Main, "Photos"), + (Role::Complementary, "Tools"), + (Role::Region, "Filmstrip"), + ] { + assert_eq!(count(&h, role, name), 1, "one {role:?} named {name:?}"); + let area = areas(&h, role, name).into_iter().next().flatten(); + assert!(area.is_some_and(|b| b.width() > 0.0 && b.height() > 0.0), "{name:?} reports the area it covers: {area:?}"); + } +} + +#[test] +fn the_grid_is_the_main_landmark_in_the_photo_grid() { + let h = harness(ViewMode::PhotoGrid); + assert_eq!(count(&h, Role::Main, "Photos"), 1); + assert_eq!(count(&h, Role::Region, "Filmstrip"), 0, "the grid has no filmstrip"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4b9fa7eb7..d33acc4f9 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -3,3 +3,4 @@ mod accessibility; mod docking_workflows; +mod landmarks; diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..79fe56369 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (LR-BEHAV-ACCESS: the bars are toolbars and the panels landmarks for screen readers; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -479,7 +479,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/it/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests/canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests/dialog_tab.rs`). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/it/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests/canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests/dialog_tab.rs`). The top bar, tool strip and bottom bar are announced as toolbars and the sidebar, photos, Tools / Presets panel and filmstrip as landmarks (`widgets::announce_region`, `crates/ui-egui/tests/it/landmarks.rs`). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian`, `cmd:app.language.polish`, `cmd:app.language.czech`, `cmd:app.language.italian`, `cmd:app.language.swedish`, `cmd:app.language.finnish`, `cmd:app.language.indonesian`, `cmd:app.language.vietnamese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | | LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md`, `docs/localization-pl.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, French, Ukrainian, Polish, Czech, Italian, Georgian, Swedish, Finnish, Indonesian and Vietnamese UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-28 | patch | LR-BEHAV-ACCESS: the top bar, tool strip and bottom bar are announced as toolbars and the sidebar, photos, Tools / Presets panel and filmstrip as landmarks, each with a name; status stays 🟡 | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..4925bb900 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-28 + +### The window's parts are named for screen readers +- The top bar, the tool strip and the bottom bar are now announced as toolbars (Top bar, Tool strip, Bottom bar), and the + sidebar (Library), the photos, the Tools panel (or Presets) and the filmstrip as landmarks, so a screen reader can list + them and jump to one instead of walking every control. Nothing on screen changes. Not yet tried with a real VoiceOver session. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From a0c61edb6e02fc0f152a855f5d1cb31012756a1b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 28 Oct 2026 16:02:19 -0400 Subject: [PATCH 2/2] LR-BEHAV-ACCESS: an empty filmstrip is still a landmark and its reason is read --- crates/ui-egui/src/panels/detail/filmstrip.rs | 5 +++- crates/ui-egui/tests/it/landmarks.rs | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs index ec5364cd9..a03178556 100644 --- a/crates/ui-egui/src/panels/detail/filmstrip.rs +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -65,7 +65,10 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let ppp = ui.ctx().pixels_per_point(); if ids.is_empty() { let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); + // a label, not painted text, so a screen reader reads the reason; the region keeps the strip navigable + let mut empty = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::centered_and_justified(egui::Direction::TopDown))); + crate::widgets::announce_region(&empty, egui::accesskit::Role::Region, crate::i18n::tr("Filmstrip")); + empty.add(egui::Label::new(egui::RichText::new(why).font(t.font(12.5)).color(t.text_dim)).selectable(false)); return; } let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0)))); diff --git a/crates/ui-egui/tests/it/landmarks.rs b/crates/ui-egui/tests/it/landmarks.rs index 69a92cdfc..dd98e4c88 100644 --- a/crates/ui-egui/tests/it/landmarks.rs +++ b/crates/ui-egui/tests/it/landmarks.rs @@ -60,3 +60,31 @@ fn the_grid_is_the_main_landmark_in_the_photo_grid() { assert_eq!(count(&h, Role::Main, "Photos"), 1); assert_eq!(count(&h, Role::Region, "Filmstrip"), 0, "the grid has no filmstrip"); } + +/// A filter that hides every photo leaves the filmstrip drawn with its reason: it stays a landmark +/// and the reason is text a screen reader reads, not paint. +#[test] +fn an_empty_filmstrip_is_still_a_landmark_and_says_why() { + let mut session = lightcraft_engine::Session::with_demo(); + session.execute("library.filter", &serde_json::json!({"keyword": "no-such-keyword"})).expect("the filter is set"); + assert!(session.visible_cloned().is_empty(), "the filter hides every photo"); + let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + app.ui.view = ViewMode::Detail; + app.ui.right = RightPanel::Edit; + let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(10); + assert_eq!(count(&h, Role::Region, "Filmstrip"), 1, "the empty strip is still a landmark"); + let said = h + .root() + .children_recursive() + .filter_map(|n| n.accesskit_node().value().or_else(|| n.accesskit_node().label())) + .any(|t| t.starts_with("No photos match the filters")); + assert!(said, "the reason is read, not only painted"); +}