refactor(ui-egui): split the global shortcut dispatcher

This commit is contained in:
Dylan M. Taylor
2026-10-28 03:24:18 +00:00
parent 2f3002bc5c
commit 63755f8a2e
+340 -267
View File
@@ -386,10 +386,22 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() {
return;
}
let (fire, aliased) = pressed_commands(app, ctx);
for (id, params) in aliased {
fire_alias(app, ctx, id, params);
}
let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect");
for f in fire {
fire_command(app, ctx, &f, rotate_fires);
}
}
/// The keys pressed this frame: the commands to fire, and the aliases (command and params) to run.
fn pressed_commands(app: &LightcraftApp, ctx: &egui::Context) -> (Vec<String>, Vec<(&'static str, serde_json::Value)>) {
let mut fire: Vec<String> = Vec::new();
// shortcuts the native menu bar handles (it consumes those key presses itself)
let native = |sc: &str| app.native_shortcuts.contains(sc);
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
let mut aliased: Vec<(&'static str, serde_json::Value)> = Vec::new();
let keymap = &app.ui.settings.keymap;
let grid = library_grid(app);
// The grid navigation keys act on the photos in view, so they wait while the full-screen preview
@@ -433,281 +445,342 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
}
}
// rating 0-5, colour labels 6-9 (with Shift: and advance)
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) {
fire.push(format!("rate:{n}:0"));
}
if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) {
fire.push(format!("rate:{n}:1"));
}
}
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) {
fire.push(format!("label:{label}"));
}
}
if grid_navigation {
// Home / End: the first / last photo of the view
for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] {
if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) {
fire.push(id.to_string());
}
}
// Shift+← / Shift+→: the previous / next photo of the view joins the selection
for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] {
if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) {
fire.push(format!("grid:extend:{step}"));
}
}
}
fixed_photo_keys(i, &native, &taken, grid_navigation, &mut fire);
});
use crate::panels::compare;
// rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or
// Auto Advance then moves on (next candidate in Compare, next photo elsewhere)
let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| {
compare::target_active(app, &mut params);
let was = app.session.active();
let ok = app.run(id, params).is_ok();
if ok && (advance || app.ui.auto_advance) {
compare::advance(app, was);
(fire, aliased)
}
/// The fixed photo keys pressed: ratings 0-5, colour labels 6-9 (with Shift: and advance), and in a grid the
/// navigation keys. Keys the user gave to a command (`taken`) and the native menu's (`native`) are left alone.
fn fixed_photo_keys(i: &egui::InputState, native: &dyn Fn(&str) -> bool, taken: &[(Modifiers, Key)], grid_navigation: bool, fire: &mut Vec<String>) {
// rating 0-5, colour labels 6-9 (with Shift: and advance)
for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() {
if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) {
fire.push(format!("rate:{n}:0"));
}
};
for (id, params) in aliased {
// a slideshow: Space pauses / resumes
if id == "view.zoomToggle" && app.ui.slideshow.is_some() {
if let Ok(v) = app.run("slideshow.togglePause", json!({})) {
app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" }));
}
continue;
}
// flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey,
// `advance` moves to the next candidate there
if matches!(id, "photo.flag" | "photo.rate" | "photo.label") {
let mut params = params;
let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false);
if let Some(o) = params.as_object_mut() {
o.remove("advance");
}
cull(app, id, params, advance);
} else if let Err(e) = app.run(id, params)
&& matches!(id, "app.export" | "app.exportPrevious")
{
// an export that can't start (e.g. no folder) says why instead of doing nothing
app.toast(ctx, e);
if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) {
fire.push(format!("rate:{n}:1"));
}
}
let rotate_fires = fire.iter().any(|f| f == "crop.rotateAspect");
for f in fire {
// a slideshow: ←/→ step (a cut)
if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") {
let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({}));
continue;
for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] {
if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) {
fire.push(format!("label:{label}"));
}
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label = if n == "0" {
crate::i18n::tr("Rating cleared").to_string()
} else {
crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0)))
};
app.toast(ctx, label);
} else if matches!(f.as_str(), "photo.decreaseRating" | "photo.increaseRating") {
cull(app, &f, json!({}), false);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
} else if f == "panel.presets" && library_grid(app) {
// Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere.
cull(app, "photo.flag", json!({"flag": "pick"}), true);
} else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
// S in a grid: expand / collapse the stack (Lightroom's Library binding)
let _ = app.run("stack.toggle", json!({}));
} else if f == "grid:first" || f == "grid:last" {
// Home / End in a grid: make the first / last photo of the view the active, selected one
let vis = app.session.visible_cloned();
let edge = if f == "grid:first" { vis.first() } else { vis.last() };
if let Some(id) = edge {
let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0}));
}
} else if let Some(step) = f.strip_prefix("grid:extend:") {
// Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and
// becomes the active one. At either end of the view the key does nothing.
let step = step.parse::<i64>().unwrap_or(0);
let vis = app.session.visible_cloned();
let neighbour = app
.session
.active()
.and_then(|active| vis.iter().position(|p| *p == active))
.and_then(|pos| i64::try_from(pos).ok())
.and_then(|pos| pos.checked_add(step))
.and_then(|pos| usize::try_from(pos).ok())
.and_then(|pos| vis.get(pos));
if let Some(id) = neighbour {
let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"}));
}
} else if compare::culling(app) && (f == "library.next" || f == "library.previous") {
let d = if f == "library.next" { 1 } else { -1 };
let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) };
} else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") {
// `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on)
let active = app.session.active();
cull(app, &f, json!({}), false);
let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag);
match flag {
Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")),
None => {}
}
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
_ => app.toast(ctx, crate::i18n::tr("Unflagged")),
}
}
if !grid_navigation {
return;
}
// Home / End: the first / last photo of the view
for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] {
if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) {
fire.push(id.to_string());
}
}
// Shift+← / Shift+→: the previous / next photo of the view joins the selection
for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] {
if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) {
fire.push(format!("grid:extend:{step}"));
}
}
}
/// Rating/flag/label keys: in Compare/Survey they act on the active photo only; `advance` (Shift+key) or
/// Auto Advance then moves on (next candidate in Compare, next photo elsewhere).
fn cull(app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool) {
use crate::panels::compare;
compare::target_active(app, &mut params);
let was = app.session.active();
let ok = app.run(id, params).is_ok();
if ok && (advance || app.ui.auto_advance) {
compare::advance(app, was);
}
}
/// Run a fixed alias (a second key for a command).
fn fire_alias(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: serde_json::Value) {
// a slideshow: Space pauses / resumes
if id == "view.zoomToggle" && app.ui.slideshow.is_some() {
if let Ok(v) = app.run("slideshow.togglePause", json!({})) {
app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" }));
}
return;
}
// flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey,
// `advance` moves to the next candidate there
if matches!(id, "photo.flag" | "photo.rate" | "photo.label") {
let mut params = params;
let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false);
if let Some(o) = params.as_object_mut() {
o.remove("advance");
}
cull(app, id, params, advance);
} else if let Err(e) = app.run(id, params)
&& matches!(id, "app.export" | "app.exportPrevious")
{
// an export that can't start (e.g. no folder) says why instead of doing nothing
app.toast(ctx, e);
}
}
/// The toast naming a flag.
fn flag_toast(app: &mut LightcraftApp, ctx: &egui::Context, flag: lightcraft_catalog::Flag) {
use lightcraft_catalog::Flag;
app.toast(
ctx,
crate::i18n::tr(match flag {
Flag::Pick => "Flagged as Pick",
Flag::Reject => "Flagged as Reject",
Flag::None => "Unflagged",
}),
);
}
/// Run one fired command.
fn fire_command(app: &mut LightcraftApp, ctx: &egui::Context, f: &str, rotate_fires: bool) {
// a slideshow: ←/→ step (a cut)
if app.ui.slideshow.is_some() && matches!(f, "library.next" | "library.previous") {
let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({}));
return;
}
if fire_rating_key(app, ctx, f) || fire_grid_key(app, f) || fire_flag_key(app, ctx, f) {
return;
}
// in the full-screen preview (no panels) I cycles the info overlay instead
if f == "panel.info" && app.ui.fullscreen {
let _ = app.run("view.infoOverlay", json!({}));
return;
}
if (f == "photo.delete" && delete_key(app, ctx)) || (f == "tool.brush" && brush_key(app, ctx)) || context_key_skipped(app, f, rotate_fires) {
return;
}
if matches!(f, "library.up" | "library.down") {
up_down_key(app, ctx, f);
return;
}
// an export that can't start (e.g. no folder) says why instead of doing nothing
if let Err(e) = app.run(f, json!({}))
&& matches!(f, "app.export" | "app.exportPrevious")
{
app.toast(ctx, e);
}
match f {
"photo.pick" => flag_toast(app, ctx, lightcraft_catalog::Flag::Pick),
"photo.reject" => flag_toast(app, ctx, lightcraft_catalog::Flag::Reject),
"photo.unflag" => flag_toast(app, ctx, lightcraft_catalog::Flag::None),
"edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")),
"edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")),
_ => {}
}
}
/// Rating and colour label keys; whether `f` was one.
fn fire_rating_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label = if n == "0" {
crate::i18n::tr("Rating cleared").to_string()
} else {
// in the full-screen preview (no panels) I cycles the info overlay instead
if f == "panel.info" && app.ui.fullscreen {
let _ = app.run("view.infoOverlay", json!({}));
continue;
crate::i18n::tr_format!("Rated {}", "★".repeat(n.parse().unwrap_or(0)))
};
app.toast(ctx, label);
} else if matches!(f, "photo.decreaseRating" | "photo.increaseRating") {
cull(app, f, json!({}), false);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
} else {
return false;
}
true
}
/// The keys of a grid or of Compare / Survey that move the active photo or the selection; whether `f` was one.
fn fire_grid_key(app: &mut LightcraftApp, f: &str) -> bool {
use crate::panels::compare;
if f == "panel.presets" && library_grid(app) {
// Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere.
cull(app, "photo.flag", json!({"flag": "pick"}), true);
} else if f == "view.softProof" && library_grid(app) {
// S in a grid: expand / collapse the stack (Lightroom's Library binding)
let _ = app.run("stack.toggle", json!({}));
} else if f == "grid:first" || f == "grid:last" {
// Home / End in a grid: make the first / last photo of the view the active, selected one
let vis = app.session.visible_cloned();
let edge = if f == "grid:first" { vis.first() } else { vis.last() };
if let Some(id) = edge {
let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0}));
}
} else if let Some(step) = f.strip_prefix("grid:extend:") {
extend_selection(app, step.parse::<i64>().unwrap_or(0));
} else if compare::culling(app) && (f == "library.next" || f == "library.previous") {
let d = if f == "library.next" { 1 } else { -1 };
let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) };
} else {
return false;
}
true
}
/// Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and
/// becomes the active one. At either end of the view the key does nothing.
fn extend_selection(app: &mut LightcraftApp, step: i64) {
let vis = app.session.visible_cloned();
let neighbour = app
.session
.active()
.and_then(|active| vis.iter().position(|p| *p == active))
.and_then(|pos| i64::try_from(pos).ok())
.and_then(|pos| pos.checked_add(step))
.and_then(|pos| usize::try_from(pos).ok())
.and_then(|pos| vis.get(pos));
if let Some(id) = neighbour {
let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"}));
}
}
/// The flag keys; whether `f` was one.
fn fire_flag_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) -> bool {
if matches!(f, "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") {
// `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on)
let active = app.session.active();
cull(app, f, json!({}), false);
if let Some(flag) = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag) {
flag_toast(app, ctx, flag);
}
true
} else if matches!(f, "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, f, json!({}), false);
let flag = match f {
"photo.pick" => lightcraft_catalog::Flag::Pick,
"photo.reject" => lightcraft_catalog::Flag::Reject,
_ => lightcraft_catalog::Flag::None,
};
flag_toast(app, ctx, flag);
true
} else {
false
}
}
/// Delete acts on what's being edited: the active mask in the Masking panel; never the photo while retouching (spots
/// are removed from their own panel). Whether the key was used up.
fn delete_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
use crate::state::RightPanel as R;
match app.ui.right {
R::Masking => {
if app.session.active_mask.is_some() {
let _ = app.run("mask.delete", json!({}));
}
// Delete acts on what's being edited: the active mask in the Masking panel; never the
// photo while retouching (spots are removed from their own panel).
if f == "photo.delete" {
use crate::state::RightPanel as R;
match app.ui.right {
R::Masking => {
if app.session.active_mask.is_some() {
let _ = app.run("mask.delete", json!({}));
}
continue;
}
R::Remove => {
if app.session.active_spot.is_some() {
let _ = app.run("spot.delete", json!({}));
}
continue;
}
R::Crop if app.ui.tool == "guidedUpright" => {
// Delete removes the selected guide alone; with none selected it removes
// nothing (the photo itself is never deleted while the tool is open)
if let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) {
if photo == active.0
&& let Some(d) = app.session.develop_of(active)
&& i < d.geometry.guides.len()
{
let guides: Vec<[f64; 4]> =
d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect();
let _ = app.run("geometry.guides", json!({"guides": guides}));
}
app.ui.selected_guide = None;
}
continue;
}
R::RedEye => continue,
_ => {}
}
// in an album, Delete takes the photos out of the album only, as in Lightroom: they
// stay in the library and their folder (issue #1805)
if crate::menus::delete_in_album(app, ctx) {
continue;
}
if crate::menus::confirm_delete(app) {
continue;
}
return true;
}
R::Remove => {
if app.session.active_spot.is_some() {
let _ = app.run("spot.delete", json!({}));
}
// B: the brush while editing; in the grids, add to the target album (Quick Collection)
if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) {
if let Ok(r) = app.run("album.toggleTarget", json!({})) {
let n = app.session.selected().len();
let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n);
let name = r["name"].as_str().unwrap_or("Quick Collection").to_string();
app.toast(
ctx,
if r["added"] == true {
crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what)
} else {
crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what)
},
);
}
continue;
}
// X is both reject (library) and swap crop aspect (crop tool). When both bindings fire,
// swap once: twice would put the crop straight back (issue #1866).
if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop {
if !rotate_fires {
let _ = app.run("crop.rotateAspect", json!({}));
}
continue;
}
if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop {
continue;
}
// A is both Visualize Spots and the crop aspect lock: each key only in its own context
if f == "view.visualizeSpots" && app.ui.right == crate::state::RightPanel::Crop {
continue;
}
if f == "crop.lockAspect" && app.ui.right != crate::state::RightPanel::Crop {
continue;
}
// / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere)
if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() {
let _ = app.run("spot.refreshSource", json!({}));
continue;
}
// Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere)
if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking {
let _ = app.run("view.maskOverlayColor", json!({}));
continue;
}
// while cropping: O cycles the guides, Shift+O their orientation
if app.ui.right == crate::state::RightPanel::Crop {
let crop_key = match f.as_str() {
"view.maskOverlay" => Some(("view.cropOverlay", json!({}))),
"view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))),
_ => None,
};
if let Some((cmd, p)) = crop_key {
let _ = app.run(cmd, p);
continue;
}
}
// Up / Down in the grids go to the cell above / below (the filmstrip steps one photo)
if matches!(f.as_str(), "library.up" | "library.down") {
// over a slider the arrows nudge it (widgets::slider takes the key)
let over_slider =
ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p)));
if over_slider {
continue;
}
match crate::panels::grid::row_distance(app, f == "library.down") {
Some(0) => {}
Some(rows) => {
let _ = app.run(&f, json!({"rows": rows}));
}
None => {
let _ = app.run(&f, json!({}));
}
}
continue;
}
// an export that can't start (e.g. no folder) says why instead of doing nothing
if let Err(e) = app.run(&f, json!({}))
&& matches!(f.as_str(), "app.export" | "app.exportPrevious")
{
app.toast(ctx, e);
}
match f.as_str() {
"photo.pick" => app.toast(ctx, crate::i18n::tr("Flagged as Pick")),
"photo.reject" => app.toast(ctx, crate::i18n::tr("Flagged as Reject")),
"photo.unflag" => app.toast(ctx, crate::i18n::tr("Unflagged")),
"edit.undo" => app.toast(ctx, crate::i18n::tr("Undo")),
"edit.redo" => app.toast(ctx, crate::i18n::tr("Redo")),
_ => {}
return true;
}
R::Crop if app.ui.tool == "guidedUpright" => {
delete_selected_guide(app);
return true;
}
R::RedEye => return true,
_ => {}
}
// in an album, Delete takes the photos out of the album only, as in Lightroom: they
// stay in the library and their folder (issue #1805)
crate::menus::delete_in_album(app, ctx) || crate::menus::confirm_delete(app)
}
/// Delete removes the selected guide alone; with none selected it removes nothing (the photo itself is never deleted
/// while the tool is open).
fn delete_selected_guide(app: &mut LightcraftApp) {
let (Some((photo, i)), Some(active)) = (app.ui.selected_guide, app.session.active()) else { return };
if photo == active.0
&& let Some(d) = app.session.develop_of(active)
&& i < d.geometry.guides.len()
{
let guides: Vec<[f64; 4]> = d.geometry.guides.iter().enumerate().filter(|(k, _)| *k != i).map(|(_, (a, b))| [a.x, a.y, b.x, b.y]).collect();
let _ = app.run("geometry.guides", json!({"guides": guides}));
}
app.ui.selected_guide = None;
}
/// B: the brush while editing; in the grids, add to the target album (Quick Collection). Whether the key was used up.
fn brush_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool {
if !library_grid(app) {
return false;
}
if let Ok(r) = app.run("album.toggleTarget", json!({})) {
let n = app.session.selected().len();
let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n);
let name = r["name"].as_str().unwrap_or("Quick Collection").to_string();
app.toast(
ctx,
if r["added"] == true {
crate::i18n::tr_format!("Added {what} to {name}", name = name, what = what)
} else {
crate::i18n::tr_format!("Removed {what} from {name}", name = name, what = what)
},
);
}
true
}
/// Keys that two bindings share, and that mean something only in one panel: whether `f` was used up (or ignored)
/// here, so nothing else runs for it.
fn context_key_skipped(app: &mut LightcraftApp, f: &str, rotate_fires: bool) -> bool {
use crate::state::RightPanel as R;
let right = app.ui.right;
match f {
// X is both reject (library) and swap crop aspect (crop tool). When both bindings fire,
// swap once: twice would put the crop straight back (issue #1866).
"photo.reject" if right == R::Crop => {
if !rotate_fires {
let _ = app.run("crop.rotateAspect", json!({}));
}
true
}
"crop.rotateAspect" => right != R::Crop,
// A is both Visualize Spots and the crop aspect lock: each key only in its own context
"view.visualizeSpots" => right == R::Crop,
"crop.lockAspect" => right != R::Crop,
// / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere)
"view.filmstrip" if right == R::Remove && app.session.active_spot.is_some() => {
let _ = app.run("spot.refreshSource", json!({}));
true
}
// Shift+O cycles the mask overlay colour while masking (the crop overlay elsewhere)
"view.cropOverlay" if right == R::Masking => {
let _ = app.run("view.maskOverlayColor", json!({}));
true
}
// while cropping: O cycles the guides, Shift+O their orientation
"view.maskOverlay" if right == R::Crop => {
let _ = app.run("view.cropOverlay", json!({}));
true
}
"view.cropOverlay" if right == R::Crop => {
let _ = app.run("view.cropOverlayOrientation", json!({}));
true
}
_ => false,
}
}
/// Up / Down in the grids go to the cell above / below (the filmstrip steps one photo).
fn up_down_key(app: &mut LightcraftApp, ctx: &egui::Context, f: &str) {
// over a slider the arrows nudge it (widgets::slider takes the key)
let over_slider = ctx.pointer_hover_pos().is_some_and(|p| app.widgets.iter().any(|(id, r)| id.starts_with("slider:") && r.contains(p)));
if over_slider {
return;
}
match crate::panels::grid::row_distance(app, f == "library.down") {
Some(0) => {}
Some(rows) => {
let _ = app.run(f, json!({"rows": rows}));
}
None => {
let _ = app.run(f, json!({}));
}
}
}