From c3be1ceeada5ce14768c5aaf441d8b2196d89e0e Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Mon, 12 Oct 2026 04:13:49 +1100 Subject: [PATCH] LR-BEHAV-ACCESS: canvas tools by keyboard; name hand-drawn widgets; Tab order test canvas_keys.rs gives every handle on the photo a focusable stand-in with an AccessKit name and position: crop corners, edges and box (crop.drag), spot targets and sources plus a New spot point (Return adds), radial and linear gradient handles and mask pins. Arrows move by 0.5% (Shift 5%), one undo step per press; the stand-in keeps arrows/Return/Space from the shortcuts. The audit now counts any focusable or clickable node; ~50 hand-drawn icons, tiles and rows are announced as buttons. A test walks Tab through the Edit/Crop/Remove/Masking panels top to bottom. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- ROADMAP.md | 5 +- crates/ui-egui/src/canvas_keys.rs | 99 +++++++++++++ crates/ui-egui/src/import.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels/activity.rs | 1 + crates/ui-egui/src/panels/bottombar.rs | 12 ++ crates/ui-egui/src/panels/chips.rs | 1 + crates/ui-egui/src/panels/compare.rs | 2 + crates/ui-egui/src/panels/detail.rs | 170 ++++++++++++++++++++++ crates/ui-egui/src/panels/edit.rs | 7 + crates/ui-egui/src/panels/filterbar.rs | 2 + crates/ui-egui/src/panels/grid.rs | 1 + crates/ui-egui/src/panels/keyword_list.rs | 4 + crates/ui-egui/src/panels/left.rs | 1 + crates/ui-egui/src/panels/masking.rs | 5 + crates/ui-egui/src/panels/people.rs | 1 + crates/ui-egui/src/panels/person.rs | 1 + crates/ui-egui/src/panels/presets.rs | 2 + crates/ui-egui/src/panels/profiles.rs | 2 + crates/ui-egui/src/panels/right.rs | 3 + crates/ui-egui/src/panels/topbar.rs | 6 +- crates/ui-egui/src/panels/unnamed.rs | 1 + crates/ui-egui/src/shortcuts.rs | 6 + crates/ui-egui/src/tests_canvas_keys.rs | 144 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 17 +++ crates/ui-egui/tests/accessibility.rs | 64 +++++++- docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 36 ++++- 30 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/canvas_keys.rs create mode 100644 crates/ui-egui/src/tests_canvas_keys.rs diff --git a/ROADMAP.md b/ROADMAP.md index 51e45d11b..dd4d6ad1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 32–54 h after canvas tools by keyboard) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -46,7 +46,7 @@ Hours are calibrated on this repo's PR history; see |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | | ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | | Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | @@ -161,6 +161,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | diff --git a/crates/ui-egui/src/canvas_keys.rs b/crates/ui-egui/src/canvas_keys.rs new file mode 100644 index 000000000..dadbb1577 --- /dev/null +++ b/crates/ui-egui/src/canvas_keys.rs @@ -0,0 +1,99 @@ +//! Keyboard and screen-reader access to the handles drawn on the photo (crop corners and edges, +//! spot removals, gradient handles, mask pins). Each handle gets an invisible, focusable stand-in +//! at its place on the canvas: Tab reaches it, a screen reader announces its name and position, +//! and the arrow keys move it (Shift: ten times further). The pointer still grabs the handle as +//! before; the stand-in senses no clicks or drags. + +use egui::{Id, Key, Pos2, Rect, Ui, vec2}; + +/// One arrow press moves a handle this far, as a fraction of the photo's width or height. +pub const STEP: f64 = 0.005; +/// With Shift held. +pub const BIG_STEP: f64 = 0.05; + +fn focused_key() -> Id { + Id::new("lc-canvas-handle-focused") +} + +/// The egui id of the stand-in registered as `key` (to focus it, or find it in tests). +pub fn id(key: &str) -> Id { + Id::new("lc-canvas-handle").with(key) +} + +/// Whether a canvas handle holds the keyboard focus: it keeps the arrow keys and Return from the +/// app's shortcuts (next photo, and so on). +pub fn has_focus(ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused.is_some() && focused == ctx.data(|d| d.get_temp::(focused_key())) +} + +/// What the keys did to a focused handle this frame. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct HandleKeys { + /// The move asked for, in normalized photo units (x right, y down). + pub delta: Option<(f64, f64)>, + /// Return or Space was pressed. + pub activated: bool, +} + +/// `(x, y)` normalized, as a screen reader reads it: "x 30%, y 60%". +pub fn position_text(x: f64, y: f64) -> String { + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + format!("x {}%, y {}%", pct(x), pct(y)) +} + +/// The focusable stand-in for a handle drawn at `at`, named `name` with `value` (its position or +/// size), registered for the control channel as `canvasHandle:`. +pub fn handle(ui: &mut Ui, key: &str, name: &str, value: String, at: Pos2) -> HandleKeys { + let id = id(key); + let rect = Rect::from_center_size(at, vec2(14.0, 14.0)); + let resp = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); + let label = name.to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + ui.ctx().accesskit_node_builder(id, |node| { + node.set_value(value); + }); + crate::widgets::register(ui.ctx(), format!("canvasHandle:{key}"), rect); + let mut out = HandleKeys::default(); + if !resp.has_focus() { + return out; + } + ui.data_mut(|d| d.insert_temp(focused_key(), id)); + // a ring shows where the focus is + ui.painter().circle_stroke(at, 9.0, egui::Stroke::new(2.0, ui.visuals().selection.stroke.color)); + ui.painter().circle_stroke(at, 11.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); + ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + let step = if shift { BIG_STEP } else { STEP }; + let (mut dx, mut dy) = (0.0, 0.0); + if i.consume_key(m, Key::ArrowLeft) { + dx -= step; + } + if i.consume_key(m, Key::ArrowRight) { + dx += step; + } + if i.consume_key(m, Key::ArrowUp) { + dy -= step; + } + if i.consume_key(m, Key::ArrowDown) { + dy += step; + } + if dx != 0.0 || dy != 0.0 { + out.delta = Some((dx, dy)); + } + out.activated = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::NONE, Key::Space); + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_read_as_percentages_and_survive_nan() { + assert_eq!(position_text(0.304, 0.6), "x 30%, y 60%"); + assert_eq!(position_text(f64::NAN, f64::INFINITY), "x 0%, y 0%"); + } +} diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a18357eca..804c74af6 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1252,6 +1252,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial let ok = d.importable(i); let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); register(ui.ctx(), format!("import:{i}"), img); let p = ui.painter(); p.rect_filled(img, 3.0, Color32::from_gray(30)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77c..72d66fd35 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod canvas_keys; pub mod control; pub mod credits; pub mod date_picker; @@ -48,6 +49,8 @@ mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_canvas_keys; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 7b0d449c6..2e4137fda 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -130,6 +130,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { }); if cross { let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Cancel")); paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); clicked = resp.on_hover_text(tr("Cancel")).clicked(); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 65c25c6b5..a5b5c8f01 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -112,6 +112,15 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] { let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce_selected( + &resp, + crate::i18n::tr(match f { + Flag::None => "Unflagged", + Flag::Pick => "Pick", + Flag::Reject => "Reject", + }), + flag == f, + ); register(pill_ui.ctx(), format!("flag:{id}"), r); let on = flag == f && f != Flag::None; let c = if on { @@ -134,6 +143,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0)); let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + crate::widgets::announce(&cresp, label.to_string()); register(ui.ctx(), "button:copySettings", copy_r); ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); @@ -144,6 +154,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + crate::widgets::announce(&gresp, crate::i18n::tr("Copy Settings…")); register(ui.ctx(), "icon:copyGear", gear_r); ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); @@ -235,6 +246,7 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo } else { // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Thumbnail size")); register(child.ctx(), "slider:thumbSize", r); let p = child.painter(); let y = r.center().y; diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index d1acd15a5..89d56fa7b 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -101,6 +101,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text); let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); + crate::widgets::announce(&resp, format!("{}: {label}", crate::i18n::tr("Remove this filter"))); register(ui.ctx(), format!("chip:{i}"), xr); let col = if resp.hovered() { t.text } else { t.text_dim }; let m = 3.5; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4d5e7453b..2922be012 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -172,6 +172,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + crate::widgets::announce(&resp, app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_default()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; let caption_h = 26.0; let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); @@ -482,6 +483,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.hovered() && photos.len() > 1 { let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); + crate::widgets::announce(&xresp, crate::i18n::tr("Remove from survey")); ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); if xresp.clicked() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..cf79feb07 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -421,6 +421,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // tool's overlays need the frame's own geometry. let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Photo")); let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); let mut navigating = false; @@ -872,6 +873,7 @@ fn region_overlay( for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(*cursor); } @@ -916,6 +918,7 @@ fn region_overlay( if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); register(ui.ctx(), format!("regionRemove:{index}"), xr); let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); p.rect_filled(xr, 2.0, fill); @@ -965,6 +968,7 @@ fn region_overlay( if is_face && live.is_none() { // a click on the label opens the name box, filled with the name or the guess let resp = ui.interact(label, egui::Id::new(("region-label", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); register(ui.ctx(), format!("regionLabel:{index}"), label); if resp.on_hover_text("Click to name this face").clicked() { let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); @@ -1000,6 +1004,7 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0)); let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0)); let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Filters")); register(ui.ctx(), "loupe:filters", r); p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); @@ -1065,6 +1070,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); register(ui.ctx(), "canvas:navigator", frame); let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); if (resp.clicked() || resp.dragged()) && let Some(q) = resp.interact_pointer_pos() { @@ -1290,6 +1296,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); } + // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move + if crop_keys(app, ui, map, &handles, &pts, d, frame) { + return; + } let inside = |q: Pos2| { let n = map.norm(q); let s = to_straight(n, d.crop.geometry.angle, frame); @@ -1399,6 +1409,64 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = id; } +/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 +/// edges (top, right, bottom, left), 8 the whole box. +const CROP_HANDLE_NAMES: [&str; 9] = [ + "Crop top-left corner", + "Crop top-right corner", + "Crop bottom-right corner", + "Crop bottom-left corner", + "Crop top edge", + "Crop right edge", + "Crop bottom edge", + "Crop left edge", + "Crop box", +]; + +/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one +/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the +/// frame is over (the crop was applied). +fn crop_keys( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + map: &CanvasMap, + handles: &[Pos2], + pts: &[Pos2], + d: &DevelopSettings, + frame: &Frame, +) -> bool { + let r = d.crop.geometry.rect; + let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); + for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { + let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; + let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); + if keys.activated { + let _ = app.run("tool.done", json!({})); + return true; + } + let Some((dx, dy)) = keys.delta else { continue }; + // the drag runs in the straightened crop frame, as a pointer drag from the handle would; + // the arrows follow the screen + let angle = d.crop.geometry.angle; + let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); + let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); + let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); + if let Err(e) = started { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return false; + } + let _ = app.run( + "crop.drag", + json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), + ); + let _ = app.run("develop.endInteraction", json!({})); + } + false +} + /// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. pub(crate) fn crop_angle_label(angle: f64) -> String { let shown = @@ -1769,6 +1837,7 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } } + mask_keys(app, ui, map, d, &grips); // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude // (clicks still on their way to the model included) let pending = app.session.segmenter.pending_clicks().cloned(); @@ -1930,6 +1999,48 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } +/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial +/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. +fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { + let part = match (shape, handle) { + (_, 0) => "pin", + (MaskShape::Radial { .. }, 1) => "right edge", + (MaskShape::Radial { .. }, 2) => "bottom edge", + (MaskShape::Radial { .. }, 3) => "left edge", + (MaskShape::Radial { .. }, 4) => "top edge", + (MaskShape::Radial { .. }, _) => "rotation", + (MaskShape::Linear { .. }, 1) => "start", + (MaskShape::Linear { .. }, _) => "end", + _ => "handle", + }; + format!("{mask} {}", crate::i18n::tr(part)) +} + +/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a +/// step (one undo step), as dragging it would; Return on a pin selects its mask. +fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { + for &(mid, ci, handle, q) in grips { + let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; + let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; + let at = map.norm(q); + let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; + let name = mask_handle_name(&mask_name, shape, handle); + let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); + if keys.activated && app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let Some((dx, dy)) = keys.delta else { continue }; + if app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let to = Point::new(at.x + dx, at.y + dy); + let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); + let _ = app.run("develop.endInteraction", json!({})); + } +} + /// Long-edge units → normalized units for x and y. fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { // derive from the mapping: 1 normalized unit in x and y in screen points @@ -1978,6 +2089,9 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); grips.push((i, false, tq, r)); } + if spot_keys(app, ui, map, d, &grips) { + return; + } let r = (app.ui.remove_size as f64 * long) as f32; // a target or source under `q`: the selected spot's first, then the nearest pin let hit = |q: Pos2| { @@ -2062,6 +2176,61 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves +/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move +/// and Return turns into a new spot of the brush's size there. True when a spot was added. +fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { + for &(spot, source, q, _) in grips { + let at = map.norm(q); + let name = + format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); + let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); + let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); + let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); + if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { + let _ = app.run("spot.select", json!({"index": spot})); + } + if let Some((dx, dy)) = keys.delta { + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); + let _ = app.run("develop.endInteraction", json!({})); + } + } + // where a new spot goes: the photo's centre at first, then wherever the arrows took it + let key = egui::Id::new("lc-spot-placement"); + let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); + let q = map.screen(Point::new(place.0, place.1)); + let keys = crate::canvas_keys::handle( + ui, + "spot:new", + crate::i18n::tr("New spot (Return adds it here)"), + crate::canvas_keys::position_text(place.0, place.1), + q, + ); + if let Some((dx, dy)) = keys.delta { + let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); + ui.data_mut(|m| m.insert_temp(key, moved)); + } + if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { + let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; + ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); + } + if keys.activated { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + return true; + } + false +} + // ------------------------------------------------------------------------ red eye /// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. @@ -2160,6 +2329,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); + crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ed2ee4635..dd4e2c9d8 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -537,6 +537,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let hdr = hist.as_ref().and_then(|h| h.hdr); let legend = if hdr.is_some() { 26.0 } else { 0.0 }; let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); register(ui.ctx(), "histogram", r); let p = ui.painter(); p.rect_filled(r, 0.0, t.button); @@ -753,6 +754,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev ("blue", hex("#3377ee")), ] { let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); register(ui.ctx(), format!("curveChannel:{ch}"), r); let sel = app.ui.curve_channel == ch; if ch == "parametric" { @@ -792,6 +794,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); register(ui.ctx(), "curve", r); resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); @@ -1073,6 +1076,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { ui.spacing_mut().item_spacing.x = 5.0; for (i, b) in bands.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(b)); register(ui.ctx(), format!("mixerBand:{b}"), r); ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); if i == sel { @@ -1156,6 +1160,7 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for (i, p) in d.point_colors.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); register(ui.ctx(), format!("pointColor:{i}"), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); if i == sel { @@ -1289,6 +1294,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let zone = r.expand(10.0); let hovering = ui.rect_contains_pointer(zone); let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); register(ui.ctx(), format!("wheel:{key}"), r); paint_wheel(ui.painter(), c, rad); if hidden { @@ -1360,6 +1366,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if hovering || hidden { let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); + crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); register(ui.ctx(), format!("wheelEye:{key}"), eye); paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c0962b59f..9b5106ad1 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -32,6 +32,7 @@ fn caption(ui: &mut egui::Ui, text: &str) { fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr(tip), on); register(ui.ctx(), format!("filter:{id}"), r); let p = ui.painter(); if on { @@ -131,6 +132,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); for i in 1..=5u8 { let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); + crate::widgets::announce(&resp, format!("{i} ★")); register(ui.ctx(), format!("filter:star{i}"), r); let filled = i <= f.rating; let c = if filled { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf61..c4fd7e0f8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -415,6 +415,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(r, egui::Id::new(("group-header", run.start, pinned)), Sense::click()); + crate::widgets::announce(&resp, if run.key.is_empty() { crate::i18n::tr("Unknown date").to_string() } else { run.key.clone() }); if !pinned { register(ui.ctx(), format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..b668a2be8 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -161,6 +161,7 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], ticks: &Ticks, can_fold: bool) { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + crate::widgets::announce(&resp, r.path.clone()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); if resp.drag_started() { app.ui.dragging_keyword = Some(r.path.clone()); @@ -180,6 +181,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let c = pos2(x + 6.0, cy); let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&tr, crate::i18n::tr(if r.open { "Collapse" } else { "Expand" })); register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); let pts = if r.open { vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] @@ -205,6 +207,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // (it senses drags too, so a press there that moves doesn't pick the keyword up) let sense = if on { Sense::click_and_drag() } else { Sense::hover() }; let tb = ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), sense); + crate::widgets::announce(&tb, format!("{} {}", crate::i18n::tr("Keyword"), r.path)); register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; match state { @@ -241,6 +244,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId ui.painter().galley(count_rect.min, count, t.text_dim); let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0)); let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&ar, crate::i18n::tr("Show photos")); register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c82..47cf012fd 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -565,6 +565,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 0.0; // the title offers the sections' menu too: where hidden sections come back from let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); + crate::widgets::announce(&title, crate::i18n::tr("My Photos")); register(ui.ctx(), "sidebarTitle", hr); title.context_menu(|ui| sections_menu(app, ui, None)); // the open library's name under the title, so nobody edits in the wrong one (issue #902) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..dd5655066 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -117,6 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| { for (i, (kind, label, icon)) in tiles.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(label)); register(ui.ctx(), format!("maskNew:{kind}"), r); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset }); paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label); @@ -225,6 +226,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { continue; } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + crate::widgets::announce_selected(&resp, m.name.clone(), Some(m.id) == app.session.active_mask); register(ui.ctx(), format!("mask:{}", m.id), r); ui.painter().rect_filled( r, @@ -253,6 +255,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0)); register(ui.ctx(), format!("maskVisible:{}", m.id), er); let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click()); + crate::widgets::announce_selected(&eye, crate::i18n::tr("Show mask"), m.visible); paint( ui.painter(), er.shrink(3.0), @@ -453,6 +456,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for c in OVERLAY_COLORS { let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} #{:02x}{:02x}{:02x}", crate::i18n::tr("Overlay color"), c[0], c[1], c[2])); register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); if app.ui.mask_overlay_color == c { @@ -498,6 +502,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape let (lo, hi) = (*lo, *hi); let w = ui.available_width().min(240.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Select Luminance Range")); register(ui.ctx(), format!("lumRange:{comp}"), rect); let p = ui.painter(); // dark → light ramp, the selected span outlined diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..f3d421ed4 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -156,6 +156,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed let t = Tokens::get(ui.ctx()); let face = Rect::from_min_size(r.min, vec2(edge, edge)); let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + crate::widgets::announce(&resp, person.name.clone()); register(ui.ctx(), format!("person:{}", person.name), r); let view = app.session.face_view(person.photo, person.face); let p = ui.painter(); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..19611f461 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -230,6 +230,7 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click()); + crate::widgets::announce(&resp, name.to_string()); register(ui.ctx(), format!("{salt}:{}:{}", f.photo, f.index), r); // the × is added after the tile, so it wins where they overlap let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0)); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 0968ab67d..3a27b92b8 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -86,6 +86,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, g.to_string()); register(ui.ctx(), format!("presetGroup:{g}"), r); paint( ui.painter(), @@ -114,6 +115,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { for pr in items { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + crate::widgets::announce(&resp, name.clone()); register(ui.ctx(), format!("preset:{pid}"), r); let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); if is_last { diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..458847014 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -79,6 +79,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let open_id = egui::Id::new(("profile-group", imported, title)); let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, title.to_string()); register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), @@ -157,6 +158,7 @@ fn cell_ui( let t = Tokens::get(ui.ctx()); let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") }; let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + crate::widgets::announce(&resp, p_name.to_string()); register(ui.ctx(), key, cell); let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); let s = with_profile_id(d, p_id); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 84e3ae8ff..0d264f6c1 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -554,6 +554,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { for l in lightcraft_catalog::ColorLabel::ALL { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); let key = format!("{l:?}").to_lowercase(); register(ui.ctx(), format!("label:{key}"), r); let on = p.label == Some(l); @@ -1030,6 +1031,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // newest first for (i, v) in shown.into_iter().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); register(ui.ctx(), format!("version:{i}"), r); let current = *v.settings == *p.develop; if resp.hovered() { @@ -1118,6 +1120,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); register(ui.ctx(), format!("history:{i}"), r); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index be9704ed4..211ed882b 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -16,6 +16,7 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l register(ui.ctx(), "region:titlebar", bar); let handle = egui::Id::new("titlebar-handle"); let resp = ui.interact(bar, handle, Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); // a press that began on a button or the search field is theirs: dragging off it must not move the window // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) let ctx = ui.ctx().clone(); @@ -117,6 +118,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // filter icon right of the search field let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); register(ui.ctx(), "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( @@ -168,6 +170,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(tip); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { @@ -193,7 +196,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); register(ui.ctx(), "icon:appearance", r); paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index 0b730db78..25ba4105f 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -229,6 +229,7 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize let key = (f.photo, f.index); let selected = app.ui.unnamed_selected.contains(&key); let resp = ui.interact(r, egui::Id::new(("unnamed-face", f.photo, f.index)), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr("Unnamed face"), selected); register(ui.ctx(), format!("unnamed-face:{}:{}", f.photo, f.index), r); // a suggested name along the bottom: a click accepts it for this face (added after the tile, so it wins there) let strip = Rect::from_min_max(pos2(r.left(), r.bottom() - 20.0), r.right_bottom()); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9e..0a26f2788 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -312,6 +312,12 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { if crate::widgets::slider_has_focus(ctx) { return ctx.input(|input| [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|key| input.key_pressed(*key))); } + // a handle on the photo keeps the arrows (it moves) and Return / Space (it acts) + if crate::canvas_keys::has_focus(ctx) { + return ctx.input(|input| { + [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Space].iter().any(|key| input.key_pressed(*key)) + }); + } let chrome = app .ui .docking diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests_canvas_keys.rs new file mode 100644 index 000000000..111473643 --- /dev/null +++ b/crates/ui-egui/src/tests_canvas_keys.rs @@ -0,0 +1,144 @@ +//! The canvas tools by keyboard: crop handles, spot removals, gradient handles and mask pins are +//! reached with Tab or focus, moved with the arrow keys (Shift: further), one undo step a press. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services, canvas_keys}; + +const T: Duration = Duration::from_secs(20); + +fn detail(panel: &str) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn focus(h: &mut Headless, key: &str) { + h.step(); + h.view.ctx.memory_mut(|m| m.request_focus(canvas_keys::id(key))); + h.step(); + h.step(); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(canvas_keys::id(key)), "{key} takes the focus"); +} + +fn key(h: &mut Headless, name: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": name, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +#[test] +fn crop_corner_moves_with_the_arrows_and_shift_moves_further() { + let mut h = detail("panel.crop"); + let photo = h.app.session.active(); + let r0 = develop(&h).crop.geometry.rect; + focus(&mut h, "crop:2"); + key(&mut h, "left", false); + let r1 = develop(&h).crop.geometry.rect; + assert!(r1.x1 < r0.x1 - 0.001 && (r1.x0 - r0.x0).abs() < 1e-9, "{r0:?} -> {r1:?}"); + key(&mut h, "up", true); + let r2 = develop(&h).crop.geometry.rect; + assert!(r2.y1 < r1.y1 - 0.03, "Shift is a big step: {r1:?} -> {r2:?}"); + // the arrows moved the handle, not to another photo + assert_eq!(h.app.session.active(), photo); + // one undo step a press + exec(&mut h, "edit.undo", json!({})); + assert!((develop(&h).crop.geometry.rect.y1 - r1.y1).abs() < 1e-9); + // the whole box moves with the box's handle + focus(&mut h, "crop:8"); + let before = develop(&h).crop.geometry.rect; + key(&mut h, "right", false); + let after = develop(&h).crop.geometry.rect; + assert!((after.x1 - after.x0 - (before.x1 - before.x0)).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +#[test] +fn tab_reaches_the_crop_handles() { + let mut h = detail("panel.crop"); + let handles: Vec = (0..9).map(|i| canvas_keys::id(&format!("crop:{i}"))).collect(); + let mut reached = false; + for _ in 0..200 { + key(&mut h, "tab", false); + if h.view.ctx.memory(|m| m.focused()).is_some_and(|f| handles.contains(&f)) { + reached = true; + break; + } + } + assert!(reached, "Tab reaches a crop handle"); +} + +#[test] +fn spots_are_added_and_moved_by_keyboard() { + let mut h = detail("panel.remove"); + assert!(develop(&h).spots.is_empty()); + focus(&mut h, "spot:new"); + key(&mut h, "right", true); + key(&mut h, "enter", false); + let spots = develop(&h).spots; + assert_eq!(spots.len(), 1, "Return adds a spot"); + let p = spots[0].points[0]; + assert!((p.x - 0.55).abs() < 1e-6 && (p.y - 0.5).abs() < 1e-6, "{p:?}"); + focus(&mut h, "spot:0:target"); + key(&mut h, "down", false); + let q = develop(&h).spots[0].points[0]; + assert!((q.y - p.y - canvas_keys::STEP).abs() < 1e-6, "{p:?} -> {q:?}"); + if develop(&h).spots[0].source_offset.is_some() { + let o = develop(&h).spots[0].source_offset; + focus(&mut h, "spot:0:source"); + key(&mut h, "left", false); + assert_ne!(develop(&h).spots[0].source_offset, o, "the source moves"); + } +} + +#[test] +fn gradient_handles_and_pins_move_by_keyboard() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); + h.step(); + let mid = h.app.session.active_mask.expect("a selected mask"); + focus(&mut h, &format!("mask:{mid}:0:2")); + key(&mut h, "down", false); + let MaskShape::Linear { start, end } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((end.y - 0.7 - canvas_keys::STEP).abs() < 1e-3 && (start.y - 0.2).abs() < 1e-6, "{start:?} {end:?}"); + // the pin moves the whole gradient + let r = h.request("ui.set", json!({"maskPins": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + focus(&mut h, &format!("mask:{mid}:0:0")); + key(&mut h, "right", true); + let MaskShape::Linear { start: s2, end: e2 } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((s2.x - start.x - canvas_keys::BIG_STEP).abs() < 1e-3 && (e2.x - end.x - canvas_keys::BIG_STEP).abs() < 1e-3); + // a radial gradient: its right edge widens it + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.2, "ry": 0.1})); + h.step(); + let rid = h.app.session.active_mask.expect("the radial mask"); + let rx = |h: &Headless| match develop(h).masks.iter().find(|m| m.id == rid).map(|m| m.components[0].shape.clone()) { + Some(MaskShape::Radial { rx, .. }) => rx, + other => panic!("{other:?}"), + }; + let rx0 = rx(&h); + focus(&mut h, &format!("mask:{rid}:0:1")); + key(&mut h, "right", true); + assert!(rx(&h) > rx0, "{rx0} -> {}", rx(&h)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e17e812fc..93f1774f0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -209,10 +209,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } }); let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); + crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone())); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); // the value: click it to type an exact one (over the label, so it takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); + crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value"))); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; @@ -436,6 +438,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O { let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0)); let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); + crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on); register(ui.ctx(), format!("sectionEye:{id}"), eye); paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled }); if !on { @@ -520,6 +523,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option Option { let mut rects = [Rect::NOTHING; 5]; for (i, slot) in (0..5u8).zip(rects.iter_mut()) { let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); register(ui.ctx(), format!("star:{id}:{}", i + 1), r); if resp.hovered() { hover_n = Some(i + 1); @@ -729,6 +734,18 @@ 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())); } +/// 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(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), name.clone())); +} + +/// 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, selected: bool) { + let name = name.into(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, resp.enabled(), selected, name.clone())); +} + /// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn /// beside it, or whose hint is all it shows. pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response { diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index e28d060bb..04d64f3e5 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -55,7 +55,13 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec { for node in h.root().children_recursive() { let n = node.accesskit_node(); let role = n.role(); - if !interactive(role) { + // egui's own scroll bars are announced by their role, as native ones are + if role == Role::ScrollBar { + continue; + } + // a widget that takes the focus or a click is interactive whatever its role + let acts = |a| n.data().supports_action(a); + if !interactive(role) && !acts(egui::accesskit::Action::Focus) && !acts(egui::accesskit::Action::Click) { continue; } count += 1; @@ -187,3 +193,59 @@ fn canvas_tool_sliders_are_reachable_with_tab() { assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}"); } } + +/// The canvas handles are in the tree by name, with their position as the value. +#[test] +fn canvas_handles_are_named_with_their_position() { + let h = harness(ViewMode::Detail, RightPanel::Crop); + let found: Vec<(String, String)> = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Button) + .map(|n| (n.label().unwrap_or_default(), n.value().unwrap_or_default())) + .collect(); + for name in ["Crop top-left corner", "Crop right edge", "Crop box"] { + assert!(found.iter().any(|(l, v)| l == name && v.contains('%')), "{name}: {found:?}"); + } + let h = harness(ViewMode::Detail, RightPanel::Remove); + assert!(h.root().children_recursive().any(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("New spot")))); +} + +/// Tab walks each right panel from top to bottom (rows left to right): the order a sighted user +/// reads it in. Each step down may not jump back up by more than a row. +#[test] +fn tab_walks_the_right_panels_top_to_bottom() { + for right in [RightPanel::Edit, RightPanel::Crop, RightPanel::Remove, RightPanel::Masking] { + let mut h = harness(ViewMode::Detail, right); + let panel_left = 1500.0 - h.state().ui.right_width - 10.0; + // the tool strip beside the panel is a column of its own, walked before it + let strip = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .find(|n| n.label().as_deref() == Some("Edit (E)")) + .and_then(|n| n.bounding_box()) + .map(|b| b.x0 as f32) + .unwrap_or(f32::INFINITY); + let mut seen: Vec<(String, f32)> = Vec::new(); + for _ in 0..120 { + h.key_press(egui::Key::Tab); + h.run_steps(1); + let Some(n) = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused()) else { continue }; + let Some(b) = n.bounding_box() else { continue }; + if (b.x0 as f32) < panel_left || (b.x0 as f32) >= strip - 1.0 { + continue; + } + let name = n.label().unwrap_or_default(); + if seen.iter().any(|(s, y)| *s == name && (*y - b.y0 as f32).abs() < 1.0) && !name.is_empty() { + break; + } + seen.push((name, b.y0 as f32)); + } + assert!(seen.len() > 3, "{right:?}: Tab reaches the panel: {seen:?}"); + for w in seen.windows(2) { + assert!(w[1].1 >= w[0].1 - 30.0, "{right:?}: Tab jumps up from {:?} to {:?}\n{seen:?}", w[0], w[1]); + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 4f5f1d188..3a34b57fc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: AccessKit audit test landed, estimate 10–20 h → 4–8 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 23: canvas tools by keyboard, 4–8 h → 2–4 h) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -37,7 +37,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | -| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | +| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | | 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 1ab1572ba..e7a53c72b 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-12 · **Change:** minor (LR-BEHAV-ACCESS: AccessKit audit test) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-BEHAV-ACCESS: canvas tools by keyboard) · **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 @@ -472,7 +472,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/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/panels/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/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/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/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. Canvas handles, spot and mask placement 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/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/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/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). 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` | 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` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; 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 face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-BEHAV-ACCESS: canvas handles by keyboard with AccessKit names and values; Tab order test; status stays 🟡 | | 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ba62abb40..ad6bcf06b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 40–70 h → 34–58 h after the accessibility audit) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (UI/UX remaining 32–54 h after canvas tools by keyboard) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -48,7 +48,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | | ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | -| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | | Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | @@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2236f02a7..ff2463e63 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (accessibility audit: test over the AccessKit tree; fields, sliders and dialogs named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | -| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab; crop handles, spots, gradient handles and mask pins by keyboard | 75% | brush strokes and red-eye ellipses need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | | **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | -Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8 +Remaining: **32–54 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 2–4 h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). @@ -96,15 +96,37 @@ Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the - Auto Stack's time slider had no name. Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings -are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop -handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session -with a real screen reader on each platform, and focus order (egui follows drawing order: top bar, -left panel, canvas, right panel, which is sensible but not tuned). +are reached with Tab and adjusted with the arrow keys (tested). + +Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, tests in `tests_canvas_keys.rs`): +every handle drawn on the photo has an invisible stand-in that takes the focus. Tab reaches it, a +screen reader reads its name and position ("Crop bottom-right corner", "left 0%, top 0%, right +100%, bottom 100%"; "Spot 1", "x 30%, y 60%, size 5%"), and the arrow keys move it by 0.5% of the +photo (Shift: 5%), one undo step per press. While it has the focus it keeps the arrows, Return +and Space, and the app's other shortcuts still work. +- Crop: four corners, four edges and the whole box (`crop.drag`, so the aspect lock and image + bounds apply as they do for a drag); Return applies the crop. +- Remove: each spot's target and source; a "New spot" point that starts at the centre, moves + with the arrows, and turns into a spot of the brush's size when you press Return. +- Masking: the four edges and the rotation handle of a radial gradient, the start and end of a + linear one, and each mask pin (moving one selects its mask). + +Focus order: the audit now also counts any widget that takes the focus or a click, whatever its +role. That found about 50 hand-drawn icons, tiles and rows with no name: top-bar icons, flags and +stars, filmstrip and survey tiles, preset and profile rows, curve channels, colour wheels, mask +tiles and rows, slider labels and readouts. They are now announced as buttons. A test walks Tab +through the Edit, Crop, Remove and Masking panels and checks that it goes top to bottom (the tool +strip beside them is its own column, walked first). No reordering was needed once nothing +unnamed was in the way. + +Not done: brush strokes and red-eye ellipses still need a pointer. Nobody has tried the app with +a real screen reader on any platform yet. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |