diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 3b589ba1b..7bb0cba03 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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 = 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() { diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 75e1e91f7..9de91a4e4 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -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; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b3f0ff386..9eae0cd77 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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)); } }); } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index d53a77a3b..08f70bd02 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 24ec07c1e..8365a1ff8 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8ec5fd6e4..7710bc331 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -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), diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index d42aa1ff5..a94327653 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -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 diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index bad2a2f43..a6f53562d 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -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 diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 107151b1e..defeed065 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -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]})); diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 505dde129..6062d241c 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -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) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index e0e9d1a42..b0371c422 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -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); diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 0ec008c2c..c5cd27b40 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index ca0243cc5..9f2f78806 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6f4c8da3f..c2f45886a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/panels/slide_templates.rs index 591e3c82a..51c27d224 100644 --- a/crates/ui-egui/src/panels/slide_templates.rs +++ b/crates/ui-egui/src/panels/slide_templates.rs @@ -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}`. diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7e40af7d5..cdf1d7a3f 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -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() { diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs index 0c3745270..a18e1f691 100644 --- a/crates/ui-egui/src/print_view.rs +++ b/crates/ui-egui/src/print_view.rs @@ -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")] { diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index 268242ef1..aed99f4d8 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -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"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a00bca4b2..cfec17d77 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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::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)] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e5e0c6e5f..8576149bb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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 |