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) <noreply@anthropic.com>
This commit is contained in:
Andrew FrahnandClaude Opus 5.5 committed 2026-10-12 04:13:49 +11:00
1 parent 530320a6bd
commit c3be1ceead
30 files changed
+595 -17

No files matched your search

+3 -2
View File
@@ -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 |
+99
View File
@@ -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::<Id>(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:<key>`.
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%");
}
}
+1
View File
@@ -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));
+3
View File
@@ -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;
+1
View File
@@ -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();
+12
View File
@@ -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;
+1
View File
@@ -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;
+2
View File
@@ -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() {
+170
View File
@@ -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;
+7
View File
@@ -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" }));
+2
View File
@@ -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 {
+1
View File
@@ -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);
}
@@ -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 });
+1
View File
@@ -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)
+5
View File
@@ -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
+1
View File
@@ -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();
+1
View File
@@ -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));
+2
View File
@@ -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 {
+2
View File
@@ -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);
+3
View File
@@ -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);
+5 -1
View File
@@ -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() {
+1
View File
@@ -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());
+6
View File
@@ -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
+144
View File
@@ -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<egui::Id> = (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));
}
+17
View File
@@ -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<u
let label = crate::i18n::tr(label);
let i = row_i * per_row + j;
let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click());
crate::widgets::announce_selected(&resp, label.to_string(), active == Some(i));
register(ui.ctx(), format!("button:{id}-{key}"), r);
let fill = if active == Some(i) {
t.pressed
@@ -576,6 +580,7 @@ pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> {
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<String>) {
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<String>, 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 {
+63 -1
View File
@@ -55,7 +55,13 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> {
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]);
}
}
}
+3 -2
View File
@@ -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 |
+3 -2
View File
@@ -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) |
+3 -2
View File
@@ -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 |
+29 -7
View File
@@ -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 |