LR-BEHAV-ACCESS: every dialog is announced as a modal dialog (an alert dialog for the prompts) instead of a group with the title

This commit is contained in:
Nathaniel Ledford
2026-10-29 13:36:43 -04:00
parent 77c0a6adfa
commit 443bc5ae03
10 changed files with 134 additions and 4 deletions
+1
View File
@@ -198,6 +198,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
}
}
if let Some(w) = shown {
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog);
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
if let Some(body_h) = body_h {
let measured = (w.response.rect.height() - body_h).max(0.0);
+4 -1
View File
@@ -121,7 +121,7 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
if app.updates_rt.manual_window_open {
let mut open = true;
egui::Window::new(crate::i18n::tr("LightCraft Updates"))
let shown = egui::Window::new(crate::i18n::tr("LightCraft Updates"))
.open(&mut open)
.collapsible(false)
.resizable(false)
@@ -136,6 +136,9 @@ pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
app.updates_rt.manual_window_open = false;
}
});
if let Some(w) = &shown {
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Window);
}
if !open {
app.updates_rt.manual_window_open = false;
}
@@ -1059,6 +1059,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {
});
});
if let Some(w) = &shown {
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Alert);
// A press on the dim raises its area; keep this window (and its own buttons) above it.
ctx.move_to_top(w.response.layer_id);
}
@@ -1186,6 +1187,7 @@ fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryP
});
});
if let Some(w) = &shown {
crate::widgets::announce_dialog(ctx, &w.response, crate::widgets::Modal::Dialog);
// A press on the dim raises its area; keep the launch list above it.
ctx.move_to_top(w.response.layer_id);
}
+1
View File
@@ -512,6 +512,7 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s
});
// A backdrop press can raise its area; keep the prompt buttons above it.
if let Some(window) = shown {
crate::widgets::announce_dialog(ctx, &window.response, crate::widgets::Modal::Alert);
ctx.move_to_top(window.response.layer_id);
}
input.button_ids = seen;
+26
View File
@@ -1160,6 +1160,32 @@ pub(crate) fn announce(resp: &egui::Response, name: impl Into<String>) {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), name.clone()));
}
/// What a window with its own title and buttons is, to a screen reader.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Modal {
/// A dialog with a task (Export, Settings, a name prompt) that stops the rest of the application.
Dialog,
/// One that interrupts to say something that needs an answer (the quit prompt, a library that
/// couldn't be opened) and stops the rest of the application: an alert dialog (AT-SPI and
/// Windows tell it from a dialog; macOS reads both alike).
Alert,
/// A dialog that leaves the rest of the application reachable (the update check's answer).
Window,
}
/// Announces the window `response` drew as a dialog (an alert dialog for [`Modal::Alert`]), and for
/// [`Modal::Dialog`] and [`Modal::Alert`] as modal: VoiceOver says "dialog" and keeps its cursor
/// inside, instead of reading a group that carries the title while the rest of the application
/// stays reachable.
pub(crate) fn announce_dialog(ctx: &egui::Context, response: &Response, kind: Modal) {
ctx.accesskit_node_builder(response.id, |node| {
node.set_role(if kind == Modal::Alert { egui::accesskit::Role::AlertDialog } else { egui::accesskit::Role::Dialog });
if kind != Modal::Window {
node.set_modal();
}
});
}
/// The same for one that is on or off (a toggle, a selected row or swatch).
pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into<String>, selected: bool) {
let name = name.into();
+3 -2
View File
@@ -123,12 +123,13 @@ fn every_interactive_node_has_a_name_in_every_view_and_tool() {
}
h.run_steps(6);
problems.extend(audit(&h, dialog));
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Window).count();
let is_window = |role| matches!(role, Role::Window | Role::Dialog | Role::AlertDialog);
let windows = h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| is_window(n.role())).count();
let untitled = h
.root()
.children_recursive()
.map(|n| n.accesskit_node())
.filter(|n| n.role() == Role::Window && n.label().unwrap_or_default().trim().is_empty())
.filter(|n| is_window(n.role()) && n.label().unwrap_or_default().trim().is_empty())
.count();
if untitled > 0 {
problems.push(format!("{dialog}: {untitled} of {windows} windows have no title"));
+87
View File
@@ -0,0 +1,87 @@
//! An open dialog is a dialog to a screen reader (VoiceOver announces "dialog" and keeps its cursor
//! inside), not a group that happens to carry the dialog's title.
use egui::accesskit::Role;
use egui::vec2;
use egui_kittest::{Harness, kittest::NodeT};
use lightcraft_ui_egui::{LightcraftApp, Services};
fn harness() -> Harness<'static, LightcraftApp> {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
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
}
/// `(role, title, modal)` of every window-like node in the tree.
fn windows(h: &Harness<'_, LightcraftApp>) -> Vec<(Role, String, bool)> {
h.root()
.children_recursive()
.map(|n| n.accesskit_node())
.filter(|n| matches!(n.role(), Role::Window | Role::Dialog | Role::AlertDialog))
.map(|n| (n.role(), n.label().unwrap_or_default(), n.data().is_modal()))
.collect()
}
#[test]
fn every_dialog_is_a_modal_dialog_with_its_title() {
for command in ["dialog.export", "dialog.rename", "dialog.newAlbum", "dialog.createPreset", "dialog.contactSheet", "dialog.allMetadata"] {
let mut h = harness();
h.state_mut().run(command, serde_json::json!({})).unwrap_or_else(|e| panic!("{command}: {e}"));
h.run_steps(6);
let found = windows(&h);
assert!(!found.is_empty(), "{command}: the dialog is in the tree");
for (role, title, modal) in found {
assert_eq!(role, Role::Dialog, "{command}: {title:?} is announced as a dialog");
assert!(!title.trim().is_empty(), "{command}: the dialog has its title");
assert!(modal, "{command}: {title:?} is modal, so the rest of the window is not read");
}
}
}
#[test]
fn settings_is_a_dialog_too() {
let mut h = harness();
h.state_mut().run("app.settings", serde_json::json!({})).expect("Settings opens");
h.run_steps(6);
let found = windows(&h);
assert!(found.iter().any(|(role, title, _)| *role == Role::Dialog && title.as_str() == "Settings"), "{found:?}");
assert!(found.iter().all(|(role, ..)| *role != Role::Window), "{found:?}");
}
#[test]
fn the_quit_prompt_and_a_settings_notice_are_modal_alert_dialogs() {
let mut h = harness();
h.state_mut().quit_prompt = Some(lightcraft_ui_egui::QuitPrompt::Tasks("1 task is still running: Import. Quitting stops it.".into()));
h.run_steps(6);
let found = windows(&h);
assert!(!found.is_empty(), "the quit prompt is in the tree");
for (role, title, modal) in found {
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} interrupts with a question: a modal alert dialog");
}
let mut h = harness();
h.state_mut().notices.push("Settings could not be saved.".into());
h.run_steps(6);
let found = windows(&h);
assert!(!found.is_empty(), "the notice is in the tree");
for (role, title, modal) in found {
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog");
}
}
#[test]
fn a_library_that_could_not_be_opened_is_a_modal_alert_dialog() {
let mut h = harness();
h.state_mut().library_problem = Some(lightcraft_ui_egui::panels::library_problem::LibraryProblem::new("/no/such/library", "No catalog here."));
h.run_steps(6);
let found = windows(&h);
assert!(!found.is_empty(), "the library problem is in the tree");
for (role, title, modal) in found {
assert_eq!((role, modal), (Role::AlertDialog, true), "{title:?} is a modal alert dialog");
}
}
+1
View File
@@ -2,4 +2,5 @@
//! `tests/shutdown.rs` stays a binary of its own: it closes the GPU for the whole process.
mod accessibility;
mod dialog_role;
mod docking_workflows;
+2 -1
View File
@@ -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: every dialog and prompt is announced as a dialog (a modal one, an alert dialog for the prompts); 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
@@ -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: every dialog, the library chooser and the quit and library-problem prompts are announced as a modal dialog (an alert dialog for the prompts) instead of a group with the title; status stays 🟡 |
+7
View File
@@ -1,5 +1,12 @@
# What's new in LightCraft
## 2026-10-28
### A dialog is announced as a dialog by screen readers
- With VoiceOver, Export, Settings and the other dialogs, the library chooser and the prompts that stop the window (the quit
question, a library that could not be opened) are now exposed to screen readers as dialogs (a modal one, so the rest of the window is out of VoiceOver's way while
it is open), as is the answer to Check for Updates. Before, each was a plain group that only carried the dialog's title.
## 2026-10-24
### Relative-date filters and smart albums follow the clock