mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #1921: Context menus: a right-click elsewhere on the same widget moves its o…
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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}`.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user