Merge PR #1921: Context menus: a right-click elsewhere on the same widget moves its o…

This commit is contained in:
aiongg
2026-10-22 02:36:58 +00:00
20 changed files with 151 additions and 52 deletions
+64
View File
@@ -838,6 +838,70 @@ mod tests {
assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu");
}
/// #1877: a right-click while another photo's context menu is open closes that menu and
/// opens this photo's, which stays open (it flashed and closed again).
#[test]
fn right_clicking_elsewhere_moves_the_context_menu() {
let mut h = demo([1200.0, 760.0]);
let t = Duration::from_secs(10);
h.settle(SETTLE);
let thumbs: Vec<String> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("thumb:")).map(|(w, _)| w.clone()).collect();
let (Some(a), Some(b)) = (thumbs.first().cloned(), thumbs.get(1).cloned()) else { panic!("two grid photos: {thumbs:?}") };
let popup = |w: &str| {
let id: u64 = w.trim_start_matches("thumb:").parse().expect("a photo id");
egui::Id::new(("cell", id)).with("popup")
};
assert!(!egui::Popup::is_any_open(&h.view.ctx));
let r = h.request("ui.clickWidget", json!({"id": a, "button": "right"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "the first photo's menu is open");
// A real click: the press and the release reach the UI on different frames, with the
// pointer resting on the photo in between (`ui.clickWidget` sends both on one frame).
let rect = h.app.widgets.iter().find(|(w, _)| *w == b).map(|(_, r)| *r).expect("the second photo on screen");
let at = egui::pos2(rect.left() + rect.width() * 0.9, rect.center().y);
let button = egui::PointerButton::Secondary;
let modifiers = egui::Modifiers::NONE;
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.step();
h.step();
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers });
h.step();
h.step();
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers });
// one frame at a time: the menu flashed for a frame or two before it closed
for frame in 0..6 {
h.step();
assert!(!egui::Popup::is_id_open(&h.view.ctx, popup(&a)), "frame {frame}: the first photo's menu closed");
if frame > 0 {
assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the second photo's menu is open");
}
}
h.settle(SETTLE);
assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the second photo's menu stays open");
// The same photo again, elsewhere on it (the menu that is open belongs to this very
// widget): the menu moves to the pointer and stays.
let before = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area");
let at = egui::pos2(rect.left() + rect.width() * 0.2, rect.center().y);
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.step();
h.step();
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers });
h.step();
h.step();
h.app.synthetic.push(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers });
for frame in 0..6 {
h.step();
if frame > 0 {
assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "frame {frame}: the menu is open after a right-click on the same photo");
}
}
h.settle(SETTLE);
assert!(egui::Popup::is_id_open(&h.view.ctx, popup(&b)), "the menu stays open");
let after = h.view.ctx.read_response(popup(&b)).map(|r| r.rect).expect("the menu's area");
assert!((after.left() - at.x).abs() < 4.0, "the menu moved to the pointer: {after:?} vs {at:?} (was {before:?})");
}
/// #1171: Up / Down in the grid go to the cell above / below; in the filmstrip, one photo.
#[test]
fn up_and_down_move_through_the_grid_rows() {
+3 -3
View File
@@ -494,7 +494,7 @@ pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if resp.double_clicked() {
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100.0) } else { Zoom::Fit };
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
if ui.button(crate::i18n::tr("Swap")).clicked() {
let _ = swap(app);
}
@@ -561,7 +561,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"}));
}
}
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id));
}
let n = photos.len();
let msg = if app.session.selection.ids.len() > SURVEY_MAX {
@@ -591,7 +591,7 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let (_, lresp) = photo_tile(app, ui, r, Slot::Compare(0), left, "Reference", zoom);
let (img, _) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom);
app.image_rect = Some(img);
lresp.context_menu(|ui| {
crate::widgets::context_menu(&lresp, |ui| {
if ui.button(crate::i18n::tr("Clear Reference")).clicked() {
app.ui.reference = None;
app.ui.view = ViewMode::Detail;
+2 -2
View File
@@ -947,7 +947,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) {
preview_only_pill(ui, canvas, why);
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| {
for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] {
if ui.button(crate::i18n::tr(label)).clicked() {
@@ -2732,7 +2732,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
}
}
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
crate::widgets::context_menu(&resp, |ui| super::grid::context_menu(app, ui, *id));
}
});
}
+3 -3
View File
@@ -561,7 +561,7 @@ fn section(
app.toast(ui.ctx(), e);
}
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
let mut solo = app.ui.single_panel;
let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode"));
register(ui.ctx(), "sectionMenu:solo", item.rect);
@@ -984,7 +984,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
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));
crate::widgets::context_menu(&resp, |ui| curve_reset_menu(app, ui, &ch));
let p = ui.painter();
p.rect_filled(r, 2.0, t.canvas);
if let Some(hist) = preview_histogram(app, id) {
@@ -1196,7 +1196,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.close();
}
if !p.builtin {
r.context_menu(|ui| {
crate::widgets::context_menu(&r, |ui| {
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("curve.deletePreset", json!({"name": p.name}));
ui.close();
+1 -1
View File
@@ -292,7 +292,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if r.clicked() {
let _ = app.run("filter.applyPreset", json!({"name": n}));
}
r.context_menu(|ui| {
crate::widgets::context_menu(&r, |ui| {
if ui.button(crate::i18n::tr("Delete Preset")).clicked() {
let _ = app.run("filter.deletePreset", json!({"name": n}));
}
+1 -1
View File
@@ -790,7 +790,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
}
app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect());
}
resp.context_menu(|ui| context_menu(app, ui, id));
crate::widgets::context_menu(&resp, |ui| context_menu(app, ui, id));
}
/// The header of a Local folder view: the path as a breadcrumb (each part opens that folder),
+1 -1
View File
@@ -272,7 +272,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId
if resp.double_clicked() {
app.ui.dialog = Some(edit_dialog(app, &r.path));
}
resp.context_menu(|ui| menu(app, ui, &r.path, selection));
crate::widgets::context_menu(&resp, |ui| menu(app, ui, &r.path, selection));
}
/// A row while a keyword or photos are dragged: outlined under the pointer when the drop would
+1 -1
View File
@@ -225,7 +225,7 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo
body.on_hover_text(format!("{full}\n{n}"))
};
let c = c.clone();
body.context_menu(|ui| menu(app, ui, &c));
crate::widgets::context_menu(&body, |ui| menu(app, ui, &c));
}
/// When a list is shortened, only this many chips are measured for it: more than the lines it
+7 -7
View File
@@ -413,7 +413,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned));
app.ui.dragging_section = Some(section);
}
resp.context_menu(|ui| sections_menu(app, ui, Some(section)));
crate::widgets::context_menu(&resp, |ui| sections_menu(app, ui, Some(section)));
let open = !app.ui.sidebar.is_collapsed(section);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
// what is visible of the header: its bar and count end at the panel's edge, like a row's
@@ -595,7 +595,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
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));
crate::widgets::context_menu(&title, |ui| sections_menu(app, ui, None));
// the open library's name under the title, so nobody edits in the wrong one (issue #902)
let library = crate::panels::settings::open_library_name(app);
let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y };
@@ -1091,7 +1091,7 @@ fn folder_tree(
{
app.toast(ui.ctx(), e);
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
// the library actions of the Folders tree, when this folder (or one inside it) holds library photos
let library = app.caches.folder_tree(&app.session.catalog);
let held = library_node_for(&library, path);
@@ -1570,7 +1570,7 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
}
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
resp.context_menu(|ui| {
crate::widgets::context_menu(resp, |ui| {
if a.folder {
// the same commands as File ▸ New…, aimed at this folder
crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| {
@@ -1951,7 +1951,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
if !n.volume {
folder_menu_for_library(app, resp, n);
} else if n.path != "/" {
resp.context_menu(|ui| {
crate::widgets::context_menu(resp, |ui| {
folder_label_menu(app, ui, n);
locate_missing_item(app, ui, n);
synchronize_item(app, ui, n, &n.name);
@@ -2050,7 +2050,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
let name = std::path::Path::new(path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
// what dialogs call it: the last two names, so same-named folders are told apart
let label = lightcraft_catalog::folders::folder_label(path);
resp.context_menu(|ui| {
crate::widgets::context_menu(resp, |ui| {
if ui.button(crate::i18n::tr("Rename Folder…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: crate::i18n::tr_format!("Rename “{name}”", name = label),
@@ -2146,7 +2146,7 @@ fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id:
browse_all_photos(app, !sel);
let _ = app.run("library.filter", json!({"keyword": v}));
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
let has_sel = app.session.active().is_some();
if ui.add_enabled(has_sel, egui::Button::new(crate::i18n::tr("Add to Selected Photos"))).clicked() {
let _ = app.run("photo.setMeta", json!({"addKeywords": [n.path]}));
+1 -1
View File
@@ -249,7 +249,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option
if resp.clicked() {
chosen = Some(name.clone());
}
resp.context_menu(|ui| row_menu(app, ui, mask, name, fav));
crate::widgets::context_menu(&resp, |ui| row_menu(app, ui, mask, name, fav));
}
});
// resting on an effect previews it on the photo (no history entry)
+2 -2
View File
@@ -282,7 +282,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if resp.double_clicked() {
app.ui.renaming_mask = Some((m.id, m.name.clone()));
}
resp.context_menu(|ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count));
crate::widgets::context_menu(&resp, |ui| mask_menu(app, ui, m.id, &m.name, m.visible, index, count));
if sel {
// right under the selected mask: + adds to it, − takes away from it (any mask
// type; Describe… opens its field here)
@@ -349,7 +349,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if resp.double_clicked() {
app.ui.renaming_component = Some((m.id, i, label.clone()));
}
resp.context_menu(|ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
crate::widgets::context_menu(&resp, |ui| component_row_menu(app, ui, m.id, i, &label, m.components.len()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let more = ui.small_button("…").on_hover_text(crate::i18n::tr("Component options"));
register(ui.ctx(), format!("button:componentMenu{i}"), more.rect);
+1 -1
View File
@@ -185,7 +185,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed
p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim);
let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name));
let full = person.name.clone();
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
let r = ui.button(crate::i18n::tr("Rename Person…"));
register(ui.ctx(), "personMenu:rename", r.rect);
if r.clicked() {
+2 -2
View File
@@ -149,7 +149,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
if ui.button(crate::i18n::tr("Export Group…")).clicked() {
let _ = app.run("file.exportPresets", json!({"group": g}));
}
@@ -205,7 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name));
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
+24 -20
View File
@@ -1323,25 +1323,29 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
} else if resp.clicked() && !current {
let _ = app.run("version.restore", json!({"index": i}));
}
resp.on_hover_text(crate::i18n::tr(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }))
.context_menu(|ui| {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() {
let _ = app.run("version.update", json!({"index": i}));
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
}
if ui.button(crate::i18n::tr("Set as Before")).clicked() {
let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name}));
}
ui.separator();
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
});
let resp = resp.on_hover_text(crate::i18n::tr(if current {
"The photo has these settings"
} else {
"Click to restore · double-click to rename"
}));
crate::widgets::context_menu(&resp, |ui| {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("version.restore", json!({"index": i}));
}
if ui.button(crate::i18n::tr("Update with Current Settings")).clicked() {
let _ = app.run("version.update", json!({"index": i}));
}
if ui.button(crate::i18n::tr("Rename…")).clicked() {
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
}
if ui.button(crate::i18n::tr("Set as Before")).clicked() {
let _ = app.run("beforeAfter.setBefore", json!({"source": "version", "name": v.name}));
}
ui.separator();
if ui.button(crate::i18n::tr("Delete")).clicked() {
let _ = app.run("version.delete", json!({"index": i}));
}
});
}
});
}
@@ -1413,7 +1417,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
if ui.button(crate::i18n::tr("Copy History Step Settings to Before")).clicked() {
let _ = app.run("beforeAfter.setBefore", json!({"source": "history", "index": i}));
}
+1 -1
View File
@@ -145,7 +145,7 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te
if resp.clicked() {
apply(app, &ctx, &tpl.id, &name);
}
resp.context_menu(|ui| menu(app, ui, tpl, &name));
crate::widgets::context_menu(&resp, |ui| menu(app, ui, tpl, &name));
}
/// A menu entry registered as `button:{id}`.
+1 -1
View File
@@ -226,7 +226,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if resp.clicked() {
let _ = app.run("view.appearance", json!({}));
}
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
let settings = ui.button(crate::i18n::tr("Appearance Settings…"));
register(ui.ctx(), "menu:appearanceSettings", settings.rect);
if settings.clicked() {
+1 -1
View File
@@ -697,7 +697,7 @@ fn package_cells(
pu.drag = None;
}
let id = c.id;
resp.context_menu(|ui| {
crate::widgets::context_menu(&resp, |ui| {
for (how, label) in
[("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")]
{
+17 -2
View File
@@ -272,9 +272,9 @@ impl<'a> TextField<'a> {
}
let menu_was_open = memo.menu_open;
// a click on a greyed-out item does nothing, as in a native menu: only a choice or a click
// outside closes it
// outside closes it (not the right-click that moves the menu within the field, #1877)
memo.menu_open = egui::Popup::context_menu(&response)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.close_behavior(crate::widgets::context_menu_close(&response, egui::PopupCloseBehavior::CloseOnClickOutside))
.show(|ui| menu(ui, self.widget, id, self.text, &mut memo.action))
.is_some();
@@ -555,6 +555,21 @@ mod tests {
assert_eq!(rig.text, "Kyoto");
}
/// #1877: a right-click elsewhere in the field while its menu is open moves the menu there
/// (it flashed at the new spot and closed).
#[test]
fn right_clicking_again_moves_the_menu() {
let mut rig = Rig::new("Lisbon");
rig.click(FIELD, PointerButton::Secondary);
let before = rig.rect(&format!("{FIELD}:cut")).expect("the menu");
let rect = rig.rect(FIELD).unwrap();
let at = egui::pos2(rect.left() + 10.0, rect.center().y);
rig.click_at(at, PointerButton::Secondary);
let after = rig.rect(&format!("{FIELD}:cut")).unwrap_or_else(|| panic!("the menu is still open: {:?}", rig.widgets));
assert!(after.left() < before.left(), "the menu moved to the pointer: {after:?} (was {before:?})");
assert!(rig.endings.is_empty(), "the edit goes on");
}
#[test]
fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() {
let mut rig = Rig::new("Lisbon");
+15
View File
@@ -44,6 +44,21 @@ pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, contents
egui::InnerResponse::new(inner.map(|r| r.inner), response)
}
/// A context menu on `response`, as `Response::context_menu` gives, that a right-click moves
/// (#1877). egui closes a menu on any click once it was open on the previous frame, and judges
/// that by the menu's id: a right-click elsewhere on the same widget while its menu is open
/// reopened the menu at the pointer and closed it again on the same frame, a one-frame flash.
/// The click that opens (or moves) the menu is not the one that closes it.
pub fn context_menu(response: &Response, add_contents: impl FnOnce(&mut Ui)) -> Option<egui::InnerResponse<()>> {
egui::Popup::context_menu(response).close_behavior(context_menu_close(response, egui::PopupCloseBehavior::CloseOnClick)).show(add_contents)
}
/// How a context menu on `response` closes this frame: `default`, except on the frame of the
/// right-click that opens or moves it, which must not close it (see [`context_menu`]).
pub fn context_menu_close(response: &Response, default: egui::PopupCloseBehavior) -> egui::PopupCloseBehavior {
if response.secondary_clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { default }
}
// ------------------------------------------------------------------ automation registry
#[derive(Clone, Default)]
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** patch (context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877) · **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
@@ -30,7 +30,7 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla
| Zoom and navigation | Fit / Fill / 1:1 / 2:1 … 11:1, Navigator, spacebar pan | Fit to 800%, pinch zoom, pan, Navigator-like | 60% | diagnostic overlays (Visualize Spots, Point Color range, HDR range) render the whole frame instead of a zoom window; everything else, mask overlays and soft proofing included, is sharp at 1:1–8:1 |
| Library culling and metadata | ratings, flags, labels with keys, painter, keywording, metadata panel, filter bar | ratings / flags / labels and advance, keyword list and sets, keywording box, metadata fields, smart albums, filter bar | 85% | painter tool, quick collection, some Classic keys |
| 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 |
| Context menus | everywhere | 31 sites (grid, filmstrip, albums, folders, keywords, masks, presets, text fields…), all through `widgets::context_menu` (`crates/ui-egui/src/widgets.rs`): a right-click elsewhere while a menu is open closes it and opens the menu there, also on the same widget (issue #1877: that one flashed at the new spot and closed, as egui counts the opening click as a click while open) | 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; 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 |
@@ -130,6 +130,7 @@ a real screen reader on any platform yet.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged |
| 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it |
| 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-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list |