refactor(lightcraft-ui-egui): split panels/right.rs into panels/right/

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent 8c194d155e
commit db31bf3e59
5 changed files with 1476 additions and 1455 deletions
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
use super::*;
/// The aspect presets of the Crop panel: (menu label, `crop.aspect` value).
const ASPECT_PRESETS: [(&str, &str); 10] = [
("Free", "free"),
("Original", "original"),
("1 × 1", "1x1"),
("4 × 5 / 8 × 10", "4x5"),
("8.5 × 11", "8.5x11"),
("5 × 7", "5x7"),
("2 × 3 / 4 × 6", "2x3"),
("4 × 3", "4x3"),
("16 × 9", "16x9"),
("16 × 10", "16x10"),
];
/// What the aspect button says for the stored lock (`aspect` = width × 100, height × 100):
/// a preset's name when the ratio matches one in either orientation, "Original" for the photo's own
/// shape, otherwise the ratio as `1.37 : 1`.
pub(super) fn aspect_label(aspect: Option<(u32, u32)>, original: Option<f64>) -> String {
let Some((w, h)) = aspect else { return "Free".into() };
if w == 0 || h == 0 {
return "Free".into();
}
let r = w as f64 / h as f64;
let is = |q: f64| q.is_finite() && q > 0.0 && ((r - q).abs() / q < 0.004 || (1.0 / r - q).abs() / q < 0.004);
if original.is_some_and(is) {
return "Original".into();
}
for (label, key) in ASPECT_PRESETS {
if let Some((x, y)) = key.split_once('x')
&& let (Ok(x), Ok(y)) = (x.parse::<f64>(), y.parse::<f64>())
&& is(x / y)
{
return label.to_string();
}
}
format!("{:.2} : 1", r.max(1.0 / r))
}
/// "Custom" row of the aspect menu: two number fields and an Apply button (`crop.aspect` `[w, h]`).
fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let key = egui::Id::unique("crop-custom-aspect");
let (mut w, mut h): (String, String) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_else(|| ("3".into(), "2".into()));
let mut apply = false;
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Custom"));
let a = ui.add(egui::TextEdit::singleline(&mut w).desired_width(34.0));
crate::widgets::screen_reader_name(ui.ctx(), a.id, crate::i18n::tr("Width"));
register(ui, "field:cropCustomW", a.rect);
ui.label("×");
let b = ui.add(egui::TextEdit::singleline(&mut h).desired_width(34.0));
crate::widgets::screen_reader_name(ui.ctx(), b.id, crate::i18n::tr("Height"));
register(ui, "field:cropCustomH", b.rect);
let go = text_button(ui, "cropCustomApply", "Apply", false);
apply = go.clicked() || ((a.lost_focus() || b.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)));
});
if apply {
// Accept `3`, `2.5` and `3,5`; anything else (zero, negative, text, huge) is ignored.
let num = |t: &str| t.trim().replace(',', ".").parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 1000.0);
if let (Some(x), Some(y)) = (num(&w), num(&h))
&& (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&(x / y))
{
let _ = app.run("crop.aspect", json!({"aspect": [x, y]}));
ui.close();
}
}
ui.data_mut(|d| d.insert_temp(key, (w, h)));
}
pub(super) fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Crop");
padded(ui, |ui| {
let original = app.session.catalog.photo(id).map(|p| {
let (w, h) = (p.width.max(1) as f64, p.height.max(1) as f64);
if d.orientation.swaps_axes() { h / w } else { w / h }
});
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Aspect Ratio"));
let cur = aspect_label(app.crop_tool_aspect(), original);
let t = Tokens::get(ui.ctx());
let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text);
egui::Popup::menu(&r).show(|ui| {
for (label, a) in ASPECT_PRESETS {
if ui.button(crate::i18n::tr(label)).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": a}));
}
}
ui.separator();
custom_aspect(app, ui);
});
let locked = app.crop_tool_aspect().is_some();
if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": "toggle"}));
}
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
if text_button(ui, "cropRotateLeft", crate::i18n::tr("Rotate Left"), false).clicked() {
let _ = app.run("photo.rotateLeft", json!({}));
}
if text_button(ui, "cropRotateRight", crate::i18n::tr("Rotate Right"), false).clicked() {
let _ = app.run("photo.rotateRight", json!({}));
}
});
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
if text_button(ui, "cropFlipH", crate::i18n::tr("Flip H"), d.crop.flip_h).clicked() {
let _ = app.run("photo.flipHorizontal", json!({}));
}
if text_button(ui, "cropFlipV", crate::i18n::tr("Flip V"), d.crop.flip_v).clicked() {
let _ = app.run("photo.flipVertical", json!({}));
}
if text_button(ui, "cropSwapAspect", crate::i18n::tr("Swap Aspect (X)"), false).clicked() {
let _ = app.run("crop.rotateAspect", json!({}));
}
if text_button(ui, "cropReset", crate::i18n::tr("Reset"), false).clicked() {
let _ = app.run("crop.reset", json!({}));
}
});
});
let ang = lightcraft_develop::controls::find("crop.angle").copied();
if let Some(spec) = ang {
let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten"));
crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v})));
// an exact angle, typed (issue #534): a field of its own, besides the slider's value
padded(ui, |ui| {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Angle"));
let mut angle = d.crop.geometry.angle;
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
let r = ui.add(
egui::DragValue::new(&mut angle)
.range(spec.min..=spec.max)
.clamp_existing_to_range(false)
.speed(0.05)
.fixed_decimals(2)
// as the readout writes it: "+2.50", and every zero "0.00"
.custom_formatter(|v, _| crate::panels::detail::crop_angle_label(v).trim_end_matches('°').to_string())
.suffix("°")
// read as the Straighten value reads a typed one: "-3,25" too
.custom_parser(|text| crate::widgets::typed_value(&spec, text.trim().trim_end_matches('°')))
// a typed angle applies on Return (or leaving the field), not keystroke by
// keystroke: no half-typed angles, one undo step
.update_while_editing(false),
);
crate::widgets::register(ui, "cropAngleField", r.rect);
// a drag on the field is one undo step, like the slider's
if r.drag_started() {
let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"}));
}
// egui reports a change while text is typed even when the value isn't updated yet:
// apply only a new angle
// Esc keeps the old angle, but egui applies the typed text on the frame after the
// one where Esc took the focus away: remember the Esc for that frame
let cancel = egui::Id::unique("cropAngleFieldEscaped");
let escaped = ui.data_mut(|m| m.remove_temp::<bool>(cancel)).unwrap_or(false);
if escape && r.lost_focus() {
ui.data_mut(|m| m.insert_temp(cancel, true));
}
if r.changed() && !escaped && angle.is_finite() && angle != d.crop.geometry.angle {
let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0}));
}
if r.drag_stopped() {
let _ = app.run("develop.endInteraction", json!({}));
}
});
});
}
padded(ui, |ui| {
ui.horizontal(|ui| {
let on = app.ui.tool == "straighten";
if text_button(ui, "straightenTool", crate::i18n::tr("Straighten Tool"), on)
.on_hover_text(crate::i18n::tr("Drag along the horizon; double-click for Auto"))
.clicked()
{
app.ui.tool = if on { String::new() } else { "straighten".into() };
}
if text_button(ui, "straightenAuto", crate::i18n::tr("Auto"), false).clicked() {
app.auto_straighten(ui.ctx());
}
});
});
padded(ui, |ui| {
ui.label(crate::i18n::tr("Overlay"));
{
use crate::state::CropOverlay as O;
let opts = [
(O::Thirds, "Thirds", "thirds"),
(O::Grid, "Grid", "grid"),
(O::Golden, "Golden", "golden"),
(O::Diagonal, "Diagonal", "diagonal"),
(O::Triangle, "Triangle", "triangle"),
(O::Spiral, "Spiral", "spiral"),
(O::None, "None", "none"),
];
let items: Vec<(&str, &str)> = opts.iter().map(|(_, l, k)| (*l, *k)).collect();
let active = opts.iter().position(|(o, _, _)| *o == app.ui.crop_overlay);
if let Some(i) = crate::widgets::segmented(ui, "cropOverlay", &items, active, 4) {
app.ui.crop_overlay = opts[i].0;
}
if matches!(app.ui.crop_overlay, O::Triangle | O::Spiral)
&& text_button(ui, "cropOverlayOrient", crate::i18n::tr("Flip Overlay (⇧O)"), false).clicked()
{
let _ = app.run("view.cropOverlayOrientation", json!({}));
}
}
});
divider(ui);
header(ui, "Geometry");
padded(ui, |ui| {
ui.label(crate::i18n::tr("Upright"));
{
use lightcraft_develop::Upright;
let modes = [
("Off", Upright::Off, "off"),
("Auto", Upright::Auto, "auto"),
("Guided", Upright::Guided, "guided"),
("Level", Upright::Level, "level"),
("Vertical", Upright::Vertical, "vertical"),
("Full", Upright::Full, "full"),
];
let items: Vec<(&str, &str)> = modes.iter().map(|(l, _, k)| (*l, *k)).collect();
let active = modes.iter().position(|(_, m, _)| *m == d.geometry.upright);
if let Some(i) = crate::widgets::segmented(ui, "upright", &items, active, 3) {
let (_, mode, key) = modes[i];
let _ = app.run("geometry.upright", json!({"mode": key}));
app.ui.tool = if mode == Upright::Guided { "guidedUpright".into() } else { String::new() };
}
}
let guided = d.geometry.upright == lightcraft_develop::Upright::Guided;
ui.horizontal_wrapped(|ui| {
if !guided
&& d.geometry.upright != lightcraft_develop::Upright::Off
&& text_button(ui, "uprightUpdate", crate::i18n::tr("Update"), false).clicked()
{
let mode = serde_json::to_value(d.geometry.upright).unwrap_or_default();
let _ = app.run("geometry.upright", json!({"mode": mode}));
}
if guided {
let drawing = app.ui.tool == "guidedUpright";
if text_button(ui, "uprightDraw", crate::i18n::tr("Draw Guides"), drawing).clicked() {
app.ui.tool = if drawing { String::new() } else { "guidedUpright".into() };
}
if !d.geometry.guides.is_empty() && text_button(ui, "uprightClear", crate::i18n::tr("Clear Guides"), false).clicked() {
let _ = app.run("geometry.guides", json!({"guides": []}));
}
}
});
if guided {
ui.label(
egui::RichText::new(crate::i18n::tr_format!(
"{} of 4 guides — drag along lines that should be vertical or horizontal.",
d.geometry.guides.len()
))
.size(11.0)
.color(Tokens::get(ui.ctx()).text_dim),
);
}
ui.add_space(4.0);
let mut c = d.geometry.constrain_crop;
if ui.checkbox(&mut c, crate::i18n::tr("Constrain Crop")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"geometry": {"constrain_crop": c}}, "label": "Constrain Crop"}));
}
});
for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") {
let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default);
let out = slider(ui, spec, v, true, None);
crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v})));
}
}
pub(super) fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Remove");
padded(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] {
if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() {
app.ui.tool = tool.into();
}
}
});
ui.add_space(8.0);
ui.label(crate::i18n::tr_format!("{} spot(s) on this photo", d.spots.len()));
ui.add_space(4.0);
if text_button(ui, "findDust", crate::i18n::tr("Find Dust Spots"), false)
.on_hover_text(crate::i18n::tr("Heal the small, soft dark spots sensor dust leaves on smooth areas"))
.clicked()
{
match app.run("spot.findDust", json!({})) {
Ok(r) => {
let n = r["added"].as_u64().unwrap_or(0);
app.toast(
ui.ctx(),
if n == 0 {
crate::i18n::tr("No dust spots found").to_string()
} else {
crate::i18n::tr_format!("Healed {n} dust spot{}", if n == 1 { "" } else { "s" }, n = n)
},
);
}
Err(e) => app.toast(ui.ctx(), e),
}
}
ui.add_space(4.0);
ui.label(egui::RichText::new(crate::i18n::tr("Paint over a distraction on the photo to remove it.")).color(Tokens::get(ui.ctx()).text_dim));
});
// brush settings; with a spot selected they edit that spot too
let sel = app.session.active_spot.and_then(|i| d.spots.get(i).map(|sp| (i, sp.clone())));
if let Some((i, sp)) = &sel {
(app.ui.remove_size, app.ui.remove_feather, app.ui.remove_opacity) = (sp.size as f32, sp.feather as f32, sp.opacity as f32);
divider(ui);
let mode = match sp.mode {
lightcraft_develop::SpotMode::Heal => "Heal",
lightcraft_develop::SpotMode::Clone => "Clone",
lightcraft_develop::SpotMode::Remove => "Remove",
lightcraft_develop::SpotMode::Fill => "Content-Aware",
};
crate::panels::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = crate::i18n::tr(mode)));
}
let plain = |id: &'static str, label: &'static str, min: f64, max: f64, default: f64| lightcraft_develop::ControlSpec {
id,
label,
section: lightcraft_develop::Section::Detail,
min,
max,
default,
step: 1.0,
decimals: 0,
track: lightcraft_develop::Track::Plain,
};
let sliders = [
(plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64),
(plain("ui.removeFeather", "Feather", 0.0, 100.0, 50.0), "feather", app.ui.remove_feather as f64),
(plain("ui.removeOpacity", "Opacity", 0.0, 100.0, 100.0), "opacity", app.ui.remove_opacity as f64),
];
for (spec, key, v) in sliders {
let out = slider(ui, &spec, v, true, None);
let scale = if key == "size" { 1000.0 } else { 1.0 };
if let Some(v) = out.value {
match key {
"size" => app.ui.remove_size = (v / scale) as f32,
"feather" => app.ui.remove_feather = v as f32,
_ => app.ui.remove_opacity = v as f32,
}
}
if sel.is_some() {
crate::panels::edit::apply_slider_out(app, &spec, out, |app, v| app.run("spot.update", json!({key: v / scale})));
}
}
if sel.is_some() {
padded(ui, |ui| {
ui.horizontal(|ui| {
if text_button(ui, "spotRefresh", crate::i18n::tr("Refresh Source (/)"), false).clicked() {
let _ = app.run("spot.refreshSource", json!({}));
}
if text_button(ui, "spotDelete", crate::i18n::tr("Delete (⌫)"), false).clicked() {
let _ = app.run("spot.delete", json!({}));
}
});
});
}
// Visualize Spots (A): a black/white high-pass view that makes dust and specks stand out
padded(ui, |ui| {
let mut v = app.ui.visualize_spots;
if ui.checkbox(&mut v, crate::i18n::tr("Visualize Spots (A)")).changed() {
app.ui.visualize_spots = v;
}
});
let spec = lightcraft_develop::ControlSpec {
id: "ui.spotsThreshold",
label: "Threshold",
section: lightcraft_develop::Section::Detail,
min: 0.0,
max: 100.0,
default: 50.0,
step: 1.0,
decimals: 0,
track: lightcraft_develop::Track::Plain,
};
let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None);
if let Some(v) = out.value {
app.ui.spots_threshold = v as f32;
}
padded(ui, |ui| {
tool_overlay_options(app, ui);
ui.add_space(4.0);
if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() {
for i in (0..d.spots.len()).rev() {
let _ = app.run("spot.delete", json!({"index": i}));
}
}
});
}
/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never;
/// Shift+H steps through them). Remove and Masking panels.
pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) {
use crate::state::ToolOverlay;
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim));
let current = app.ui.tool_overlay;
let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label)
.on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)"));
egui::Popup::menu(&r).show(|ui| {
for m in ToolOverlay::ALL {
let item = ui.selectable_label(m == current, crate::i18n::tr(m.label()));
register(ui, format!("toolOverlay:{}", m.name()), item.rect);
if item.clicked() {
let _ = app.run("view.toolOverlay", json!({"mode": m.name()}));
}
}
});
});
}
pub(super) fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let d = app.session.develop_of(id).unwrap_or_default();
header(ui, "Red Eye");
padded(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (label, pet) in [("Red Eye", false), ("Pet Eye", true)] {
if text_button(ui, &format!("eyeMode-{}", if pet { "pet" } else { "red" }), label, app.ui.eye_pet == pet).clicked() {
app.ui.eye_pet = pet;
}
}
});
ui.add_space(8.0);
ui.label(
egui::RichText::new(crate::i18n::tr("Drag over an eye on the photo; the pupil inside is found automatically."))
.color(Tokens::get(ui.ctx()).text_dim),
);
});
let n = d.red_eye.len();
if n == 0 {
return;
}
if app.ui.eye >= n {
app.ui.eye = n - 1;
}
let i = app.ui.eye;
let eye = d.red_eye[i];
divider(ui);
crate::panels::edit::sub_title(
ui,
&crate::i18n::tr_format!(
"{label} {index} of {n}",
label = crate::i18n::tr(if eye.pet { "Pet Eye" } else { "Red Eye" }),
index = i + 1,
n = n
),
);
for k in ["pupilSize", "darken"] {
let cid = format!("redEye.{i}.{k}");
let Some(spec) = lightcraft_develop::controls::find(&cid) else { continue };
let v = lightcraft_develop::controls::get(&d, &cid).unwrap_or(spec.default);
let out = slider(ui, spec, v, true, None);
crate::panels::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": cid, "value": v})));
}
padded(ui, |ui| {
if eye.pet {
let mut on = eye.catchlight.is_some();
if ui.checkbox(&mut on, crate::i18n::tr("Add Catchlight")).changed() {
let _ = app.run("redeye.catchlight", json!({"index": i, "on": on}));
}
ui.add_space(4.0);
}
ui.horizontal(|ui| {
if text_button(ui, "eyeDelete", crate::i18n::tr("Delete"), false).clicked() {
let _ = app.run("redeye.delete", json!({"index": i}));
}
if n > 1 && text_button(ui, "eyeDeleteAll", crate::i18n::tr("Delete all"), false).clicked() {
for k in (0..n).rev() {
let _ = app.run("redeye.delete", json!({"index": k}));
}
}
});
});
}
+179
View File
@@ -0,0 +1,179 @@
use super::*;
pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "Versions");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
if text_button(ui, "versionCreate", crate::i18n::tr("Create Version"), false).clicked() {
let _ = app.run("version.create", json!({}));
}
ui.add_space(8.0);
// Named (made by you) and Auto (made by LightCraft) versions
let tab_id = egui::Id::unique("versions-tab");
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
let labels =
[crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)];
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
auto = i == 1;
ui.data_mut(|d| d.insert_temp(tab_id, auto));
}
ui.add_space(6.0);
let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect();
if shown.is_empty() {
let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." };
ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim));
}
// newest first
for (i, v) in shown.into_iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click());
crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1));
register(ui, format!("version:{i}"), r);
let current = *v.settings == *p.develop;
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7));
}
// thumbnail of the version's look
let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, &v.settings, 160)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE);
}
let x = tr.right() + 10.0;
ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text(
pos2(x, r.top() + 18.0),
Align2::LEFT_CENTER,
&v.name,
t.semibold(13.0),
t.text,
);
let painter = ui.painter().with_clip_rect(r);
painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim);
if current {
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent);
}
// resting on a version shows it in the loupe (nothing is changed)
if resp.hovered() && !current {
app.hover_preview =
Some(crate::HoverPreview { label: crate::i18n::tr_format!("Version: {}", v.name), settings: (*v.settings).clone() });
}
if resp.double_clicked() {
crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name");
} else if resp.clicked() && !current {
let _ = app.run("version.restore", 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}));
}
});
}
});
}
/// "Sep 30, 2026, 12:00 PM" from an ISO time.
pub(super) fn short_time(iso: &str) -> String {
// Other languages use their own date patterns (`i18n::display_time`).
if crate::i18n::language() != crate::i18n::Locale::En {
return crate::i18n::display_time(iso);
}
let long = lightcraft_catalog::dates::display_time(iso);
// "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped
let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t)));
let date = match date.split_once(' ') {
Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::<String>()),
None => date.to_string(),
};
match time.and_then(|t| t.rsplit_once(' ')) {
Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)),
None => date,
}
}
/// How a History row is drawn: the step the settings are at is highlighted, the steps after it
/// (still there to return to until a new edit) are greyed out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum HistoryRow {
Done,
Current,
Undone,
}
pub(super) fn history_row(i: usize, cursor: Option<usize>) -> HistoryRow {
match cursor {
Some(c) if i == c => HistoryRow::Current,
Some(c) if i > c => HistoryRow::Undone,
_ => HistoryRow::Done,
}
}
pub(super) fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "History");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
if p.history.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr("No edits yet.")).color(t.text_dim));
}
let cursor = p.history_index();
for (i, h) in p.history.iter().enumerate().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
crate::widgets::announce(&resp, crate::i18n::tr(&h.label));
register(ui, format!("history:{i}"), r);
let row = history_row(i, cursor);
if row == HistoryRow::Current {
ui.painter().rect_filled(r, 3.0, t.pressed);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let (icon, text) = if row == HistoryRow::Undone { (t.text_disabled, t.text_disabled) } else { (t.icon, t.text_label) };
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, icon);
ui.painter().text(
pos2(r.left() + 24.0, r.center().y),
Align2::LEFT_CENTER,
crate::i18n::history_label(&h.label, &app.session.presets),
t.font(12.5),
text,
);
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
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}));
}
if ui.button(crate::i18n::tr("Create Version from Step")).clicked() {
let _ = app.run("history.restore", json!({"index": i}));
let _ = app.run("version.create", json!({"name": h.label}));
}
ui.separator();
if ui.button(crate::i18n::tr("Clear History")).clicked() {
let _ = app.run("history.clear", json!({}));
}
});
}
});
}
+467
View File
@@ -0,0 +1,467 @@
use super::*;
pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
header(ui, "Info");
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
camera_card(ui, &p);
ui.add_space(6.0);
if let Some(why) = &p.preview_only {
if crate::widgets::preview_only_notice(ui, "info", why) {
crate::panels::edit::reload_preview_only(app, ui.ctx(), id);
}
ui.add_space(6.0);
}
if let Some(name) = &p.copy_name {
let of = p
.copy_of
.and_then(|m| app.session.catalog.photo(m))
.map(|m| m.file_name.clone())
.unwrap_or_else(|| crate::i18n::tr("a removed photo").into());
ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label));
}
ui.add_space(8.0);
if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner {
let mut params = json!({"rating": r});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.rate", params);
}
ui.add_space(6.0);
// colour label: one swatch per label (hover shows its name); clicking the current one clears it
ui.horizontal(|ui| {
for l in lightcraft_catalog::ColorLabel::ALL {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}")));
let key = format!("{l:?}").to_lowercase();
register(ui, format!("label:{key}"), r);
let on = p.label == Some(l);
ui.painter().circle_filled(r.center(), if on || resp.hovered() { 8.0 } else { 6.5 }, crate::panels::grid::label_color(l));
if on {
ui.painter().circle_stroke(r.center(), 10.0, egui::Stroke::new(1.5, t.text));
}
let resp = resp.on_hover_text(crate::i18n::color_label(&app.session.catalog, l));
if resp.clicked() {
// in Compare / Survey only the active photo, as the rating keys and the bottom pill do
let mut params = json!({"label": if on { "none".to_string() } else { key }});
crate::panels::compare::target_active(app, &mut params);
let _ = app.run("photo.label", params);
}
}
if let Some(l) = p.label {
ui.label(egui::RichText::new(app.session.catalog.label_name(l)).color(t.text_label));
}
});
});
divider(ui);
// with several photos selected: Target Photo shows this photo's values, Selected Photos what
// the selection shares (a field where they differ is empty and says Mixed)
let selected = app.session.selected();
let several = selected.len() > 1;
let for_selected = several && app.ui.settings.info_selected_photos;
let m = shown_meta(&app.session.catalog, &p, &selected, for_selected);
let mixed = if for_selected { mixed_meta(&app.session.catalog, &selected, &m) } else { Vec::new() };
// the photos an edit goes to: Target Photo edits the photo it shows
let targets = if several && !for_selected { vec![id] } else { selected.clone() };
padded(ui, |ui| {
if several {
metadata_for(app, ui);
}
for (label, key, value, lines) in [
("Title", "title", &m.title, 1),
("Caption", "caption", &m.caption, 2),
("Alt Text", "altText", &m.alt_text, 2),
("Extended Description", "extendedDescription", &m.extended_description, 3),
("Copyright", "copyright", &m.copyright, 1),
] {
meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets);
}
copyright_status(app, ui, m.copyright_status, mixed.contains(&"copyrightStatus"), &targets);
for (label, key, value, lines) in [
("Rights Usage Terms", "usageTerms", &m.usage_terms, 2),
("Copyright Info URL", "copyrightUrl", &m.copyright_url, 1),
("Creator", "creator", &m.creator, 1),
] {
meta_field(app, ui, label, key, value, lines, mixed.contains(&key), &targets);
}
// file: name (rename), path (reveal), capture time (edit)
let small = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(crate::i18n::tr(text)).size(11.5).color(t.text_dim));
small(ui, "File Name");
ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "infoRename", Icon::Pencil, vec2(20.0, 20.0), false, true, "Rename…").clicked() {
let _ = app.run("dialog.rename", json!({}));
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(&p.file_name).color(t.text)).truncate());
});
});
ui.add_space(4.0);
small(ui, "File Path");
ui.horizontal(|ui| {
let path = match &p.source {
lightcraft_catalog::Source::File { path } => path.clone(),
lightcraft_catalog::Source::Demo { .. } => crate::i18n::tr("Generated demo photo").into(),
};
ui.add(egui::Label::new(egui::RichText::new(path).size(12.0).color(t.text_label)).truncate());
if crate::menus::ui_enabled(app, "app.showInFinder") {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "infoReveal", Icon::Folder, vec2(20.0, 20.0), false, true, crate::menus::reveal_label())
.clicked()
{
let _ = app.run("app.showInFinder", json!({}));
}
});
}
});
ui.add_space(4.0);
small(ui, "Captured");
ui.allocate_ui_with_layout(vec2(ui.available_width(), 22.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() {
let _ = app.run("dialog.captureTime", json!({}));
}
let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into());
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate());
});
});
ui.add_space(6.0);
for (label, key, value) in [
("Location", "location", &m.location),
("City", "city", &m.city),
("State / Province", "state", &m.state),
("Country", "country", &m.country),
] {
meta_field(app, ui, label, key, value, 1, mixed.contains(&key), &targets);
}
// GPS: typed as "lat, lon" (or degrees / minutes / seconds); empty clears it
let gps_mixed = mixed.contains(&"gps");
let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() };
let gid = egui::Id::unique("info-gps");
ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));
let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets));
let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" });
let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui);
edit.typed |= edit.text != edit.start;
if r.editing {
ui.data_mut(|d| d.insert_temp(gid, edit.clone()));
} else {
ui.data_mut(|d| d.remove::<FieldEdit>(gid));
}
// Return or leaving saves; Esc gives the edit up
let text = edit.text.trim();
if r.committed()
&& (text != edit.start || edit.clears())
&& let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text}))
{
app.toast(ui.ctx(), e);
}
// (one photo's place: not shown while the selected photos' places differ)
if let Some((la, lo)) = m.gps.filter(|_| !gps_mixed) {
let pretty = format!("{:.5}° {}, {:.5}° {}", la.abs(), if la >= 0.0 { "N" } else { "S" }, lo.abs(), if lo >= 0.0 { "E" } else { "W" });
ui.horizontal(|ui| {
ui.label(egui::RichText::new(pretty).size(11.0).color(t.text_dim));
if text_button(ui, "showOnMap", crate::i18n::tr("Show on Map"), false)
.on_hover_text(crate::i18n::tr("Open the place in OpenStreetMap"))
.clicked()
{
let url = format!("https://www.openstreetmap.org/?mlat={la:.6}&mlon={lo:.6}#map=15/{la:.6}/{lo:.6}");
if let Err(e) = crate::links::open(app, &url) {
app.toast(ui.ctx(), e);
}
}
});
if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false)
.on_hover_text(format!(
"{}\n{}",
crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"),
crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.")
))
.clicked()
&& let Err(e) = app.run("metadata.reverseGeocode", json!({}))
{
app.toast_error(ui.ctx(), e);
}
}
if let Some(a) = p.analysis {
let mut line = crate::i18n::tr_format!("Focus {:.0}", a.sharpness);
if a.clipped > 0.02 {
line.push_str(&crate::i18n::tr_format!(" · {:.0}% clipped", a.clipped * 100.0));
}
if a.group.is_some() {
line.push_str(if a.best { " · best of its burst" } else { " · in a burst" });
}
ui.add_space(4.0);
ui.label(egui::RichText::new(line).color(t.text_dim));
}
// offline originals / smart previews
// (cached answers, checked off the UI thread: unknown counts as online / no smart preview)
if let lightcraft_catalog::Source::File { path } = &p.source {
let avail = &app.session.media.availability;
let online = !avail.is_offline(path);
let smart = app.session.media.smart_dir.as_ref().is_some_and(|d| {
let shard = lightcraft_engine::smart::sharded_file_path(d, &p);
let legacy = d.join(lightcraft_engine::smart::file_name(&p));
avail.exists(&shard.to_string_lossy()) == Some(true) || avail.exists(&legacy.to_string_lossy()) == Some(true)
});
if !online || smart {
let text = match (online, smart) {
(false, true) => "Original offline · editing the smart preview",
(false, false) => "Original offline · no smart preview",
_ => "Smart preview available",
};
ui.add_space(6.0);
ui.label(egui::RichText::new(crate::i18n::tr(text)).color(if online { t.text_dim } else { t.accent }));
}
}
ui.add_space(10.0);
if text_button(ui, "allMetadata", crate::i18n::tr("All Metadata…"), false)
.on_hover_text(crate::i18n::tr("Every EXIF, GPS and XMP field in the file"))
.clicked()
{
let _ = app.run("dialog.allMetadata", json!({}));
}
});
}
/// The camera card at the top of Info: camera, lens, size · file size and format, then the
/// capture settings.
fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
let t = Tokens::get(ui.ctx());
let m = &p.meta;
egui::Frame::NONE.fill(t.canvas).corner_radius(6.0).inner_margin(egui::Margin::symmetric(12, 10)).show(ui, |ui| {
ui.set_width(ui.available_width());
let dim = |s: &str| egui::RichText::new(s.to_string()).size(12.0).color(t.text_label);
if !m.camera.is_empty() {
ui.label(dim(&m.camera));
}
if !m.lens.is_empty() {
ui.add(egui::Label::new(dim(&m.lens)).truncate());
}
ui.horizontal(|ui| {
ui.label(dim(&format!("{} × {} · {}", p.width, p.height, human_size(p.file_size))));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
egui::Frame::NONE.fill(t.chrome).corner_radius(3.0).inner_margin(egui::Margin::symmetric(5, 1)).show(ui, |ui| {
ui.label(egui::RichText::new(p.format.to_uppercase()).size(10.5).color(t.text_label));
});
});
});
let rows = [
("Focal length", m.focal_mm.map(|f| format!("{f:.1} mm").replace(".0 mm", " mm"))),
("Shutter speed", (!m.shutter.is_empty()).then(|| crate::i18n::tr_format!("{} sec", m.shutter))),
("Aperture", m.aperture.map(|f| format!("f / {f:.1}").replace(".0", ""))),
("ISO", m.iso.map(|i| i.to_string())),
];
if rows.iter().any(|r| r.1.is_some()) {
ui.add_space(6.0);
egui::Grid::new("info-capture").num_columns(2).spacing([24.0, 3.0]).show(ui, |ui| {
for (k, v) in rows {
ui.label(dim(crate::i18n::tr(k)));
ui.label(dim(&v.unwrap_or_else(|| "—".into())));
ui.end_row();
}
});
}
// where the camera focused, and how (View ▸ Focus Point draws it): only when known; the
// whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits
if let Some(text) = focus_text(m.focus.as_ref()) {
ui.add_space(3.0);
let r = ui.horizontal_wrapped(|ui| {
ui.label(dim(crate::i18n::tr("Focus")));
ui.add_space(12.0);
ui.label(egui::RichText::new(text).size(12.0).color(t.text));
});
register(ui, "info:focus", r.response.rect);
}
});
}
/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye
/// Tracking"), or `None` when the photo has no focus point.
pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option<String> {
let f = f?;
let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect();
Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") })
}
fn human_size(bytes: u64) -> String {
match bytes {
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
b if b >= 1 << 10 => format!("{} KB", b >> 10),
b => format!("{b} B"),
}
}
/// What a metadata field shows while the selected photos' values differ (Selected Photos).
const MIXED: &str = "Mixed";
/// An Info panel field's edit under way, kept in egui memory between frames. A field saves when
/// its text is not the text the edit started from, so a field left as it was saves nothing, even
/// when what the panel shows changed during the edit. It saves to the photos it edited when the
/// edit started (the selected photos, or Target Photo's photo alone), also when the selection
/// changed since.
#[derive(Clone)]
struct FieldEdit {
text: String,
start: String,
/// The photos the field edited when the edit started (`photo.setMeta` `ids`).
ids: Vec<u64>,
/// The field said Mixed when the edit started.
mixed: bool,
/// Something was typed or deleted since.
typed: bool,
}
impl FieldEdit {
fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self {
let ids = targets.iter().map(|id| id.0).collect();
FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false }
}
/// A Mixed field that was typed in and left empty: it clears the field on every selected photo.
fn clears(&self) -> bool {
self.mixed && self.typed
}
}
/// The Info panel's text fields, by their `photo.setMeta` key.
const META_TEXT_KEYS: [&str; 12] = [
"title",
"caption",
"altText",
"extendedDescription",
"copyright",
"usageTerms",
"copyrightUrl",
"creator",
"location",
"city",
"state",
"country",
];
/// The value of one of [`META_TEXT_KEYS`].
fn meta_text<'a>(m: &'a lightcraft_catalog::Meta, key: &str) -> Option<&'a str> {
Some(match key {
"title" => &m.title,
"caption" => &m.caption,
"altText" => &m.alt_text,
"extendedDescription" => &m.extended_description,
"copyright" => &m.copyright,
"usageTerms" => &m.usage_terms,
"copyrightUrl" => &m.copyright_url,
"creator" => &m.creator,
"location" => &m.location,
"city" => &m.city,
"state" => &m.state,
"country" => &m.country,
_ => return None,
})
}
/// The metadata the Info panel's fields show: the active photo `p`'s, or with `for_selected`
/// (Selected Photos) the active photo's when it is one of the `selected` photos, else the first
/// selected photo's.
pub(crate) fn shown_meta(
catalog: &lightcraft_catalog::Catalog,
p: &lightcraft_catalog::Photo,
selected: &[PhotoId],
for_selected: bool,
) -> lightcraft_catalog::Meta {
if !for_selected || selected.contains(&p.id) {
return p.meta.clone();
}
selected.iter().find_map(|&id| catalog.photo(id)).map_or_else(|| p.meta.clone(), |q| q.meta.clone())
}
/// The Info panel fields (`photo.setMeta` keys, plus `copyrightStatus` and `gps`) where one of
/// the photos `ids` has a value other than `shown`'s, the metadata the panel draws.
pub(crate) fn mixed_meta(catalog: &lightcraft_catalog::Catalog, ids: &[PhotoId], shown: &lightcraft_catalog::Meta) -> Vec<&'static str> {
let mut mixed = Vec::new();
for m in ids.iter().filter_map(|&id| catalog.photo(id)).map(|p| &p.meta) {
for key in META_TEXT_KEYS {
if !mixed.contains(&key) && meta_text(m, key) != meta_text(shown, key) {
mixed.push(key);
}
}
if !mixed.contains(&"copyrightStatus") && m.copyright_status != shown.copyright_status {
mixed.push("copyrightStatus");
}
if !mixed.contains(&"gps") && m.gps != shown.gps {
mixed.push("gps");
}
if mixed.len() == META_TEXT_KEYS.len() + 2 {
break;
}
}
mixed
}
/// Target Photo / Selected Photos: whose metadata the fields below show while several photos
/// are selected (`view.infoSelectedPhotos`).
fn metadata_for(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr("Show Metadata For")).size(11.5).color(t.text_dim));
ui.horizontal_wrapped(|ui| {
let on = app.ui.settings.info_selected_photos;
for (id, label, selected) in [("infoFor-target", "Target Photo", false), ("infoFor-selected", "Selected Photos", true)] {
if text_button(ui, id, label, on == selected).clicked() && on != selected {
let _ = app.run("view.infoSelectedPhotos", json!({"on": selected}));
}
}
});
ui.add_space(6.0);
}
/// Copyright Status: Unknown / Copyrighted / Public Domain (`xmpRights:Marked`). `mixed`: the
/// selected photos' statuses differ, and any status picked is given to all of them. A status
/// picked goes to the photos `targets`.
fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus, mixed: bool, targets: &[PhotoId]) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim));
let shown = crate::i18n::tr(if mixed { MIXED } else { current.label() });
let r = crate::widgets::combo_box("info-copyright-status").selected_text(shown).show_ui(ui, |ui| {
for st in lightcraft_catalog::CopyrightStatus::ALL {
let on = !mixed && st == current;
if ui.selectable_label(on, crate::i18n::tr(st.label())).clicked() && !on {
let ids: Vec<u64> = targets.iter().map(|id| id.0).collect();
let _ = app.run("photo.setMeta", json!({"ids": ids, "copyrightStatus": st.id()}));
}
}
});
crate::widgets::name_combo(&r.response, crate::i18n::tr("Copyright Status"));
register(ui, "field:copyrightStatus", r.response.rect);
ui.add_space(6.0);
}
/// A labeled metadata text field: the typed text lives in egui memory while focused and is
/// saved (photo.setMeta `key`) when the field loses focus. `mixed`: the selected photos' values
/// differ, so the field starts empty and says so, and is saved only when it was typed in (left
/// empty, it clears the field on every selected photo). The edit is saved to the photos `targets`.
#[allow(clippy::too_many_arguments)]
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize, mixed: bool, targets: &[PhotoId]) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::unique(("info-field", key));
let value = if mixed { "" } else { value };
let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets));
let widget = format!("field:{key}");
let field = if lines > 1 {
crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines)
} else {
crate::text_field::TextField::singleline(&widget, &mut edit.text)
};
let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field };
let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui);
edit.typed |= edit.text != edit.start;
// the typed text outlives frames while it is edited (the photo's value is drawn otherwise)
if r.editing {
ui.data_mut(|d| d.insert_temp(id, edit.clone()));
} else {
ui.data_mut(|d| d.remove::<FieldEdit>(id));
}
// Return (one line) or leaving saves; Esc gives the edit up
if r.committed() && (edit.text != edit.start || edit.clears()) {
let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text}));
}
ui.add_space(6.0);
}
+330
View File
@@ -0,0 +1,330 @@
use super::*;
pub(super) fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
// (the box is the selection's: the active photo only says that there is one)
if app.session.catalog.photo(id).is_none() {
return;
}
let top = ui.cursor().top();
if fold_header(app, ui, "keywording", "Keywords") {
keywording(app, ui);
}
hold_list_in_place(ui, top);
crate::panels::keyword_list::show(app, ui);
}
/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set,
/// the painter and the suggestions.
fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
padded(ui, |ui| {
let kid = egui::Id::unique("kw-input");
let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default());
// the keywords every selected photo has: those only some have are still offered and suggested
let selection = app.session.selected();
let have: Vec<String> =
app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect();
// while the box has the cursor it offers the library's keywords for what is being typed,
// right under itself; it stays for a click on one of them, which takes the cursor away
let field = crate::text_field::id("field:keyword");
let oid = egui::Id::unique("kw-offers");
let pass = ui.ctx().cumulative_pass_nr();
// (remembered from the last frame: the text the highlight was for and the keyword it was
// on, where the offers were drawn, and whether the box was in sight)
let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option<String>, Rect, u64, bool) =
ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false));
let last_frame = drawn_pass.checked_add(1) == Some(pass);
let clicking = last_frame
&& ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p)));
let typed = crate::panels::keywording::typing(&text).to_string();
// (none while the box is scrolled out of the panel: they would float over what is there)
let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden))
.then(|| app.caches.completions(&app.session.catalog, &have, &typed))
.filter(|o| !o.keywords.is_empty() || o.create.is_some());
// ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes
// back to none: Return then gives what was typed, as without offers); Return gives the
// highlighted one. The highlight is on a keyword, not a line: it stays with it when the
// library changes under it, and goes when the keyword does. With Shift or Alt the keys
// are the text's own.
let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k)));
let mut picked = None;
if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) {
let pressed = |ui: &mut egui::Ui, key: egui::Key| {
ui.input_mut(|i| {
let plain =
|e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none());
let found = i.events.iter().any(plain);
i.events.retain(|e| !plain(e));
found
})
};
let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k));
if pressed(ui, egui::Key::ArrowDown) {
let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1));
at = o.keywords.get(next).cloned();
} else if pressed(ui, egui::Key::ArrowUp) {
at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned());
}
if let Some(k) = &at
&& pressed(ui, egui::Key::Enter)
{
picked = Some(crate::panels::keywording::Picked::Keyword(k.clone()));
}
}
// (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back)
// (asked for on the frame before this one only: a box that wasn't drawn then, its panel
// switched away, doesn't claim the cursor whenever it is next drawn)
let bid = egui::Id::unique("kw-begin");
let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass));
// Add Keywords (⌘K) asks for the cursor too, and for the box to be scrolled into sight
let asked = app.ui.focus_keywords.take().is_some_and(|at| pass <= at.saturating_add(crate::state::FOCUS_KEYWORDS_PASSES));
let r = crate::text_field::TextField::singleline("field:keyword", &mut text)
.hint(crate::i18n::tr("Add keyword"))
.width(f32::INFINITY)
.begin_edit(begin || asked)
.show(ui);
if asked {
r.response.scroll_to_me(None);
}
let in_sight = ui.clip_rect().contains(r.response.rect.center());
let mut drawn = Rect::NOTHING;
// (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing)
if !in_sight {
picked = None;
}
if offers.is_none() {
// (not over the offers)
r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos."));
}
if let Some(o) = offers.as_ref().filter(|_| in_sight) {
let (clicked, rect) = crate::panels::keywording::offers_below(ui, r.response.rect, o, at.as_deref());
drawn = rect;
picked = picked.or(clicked);
}
ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight)));
// Return gives the keywords to the selected photos; Esc gives back what was there before
// this edit (the shared field does)
let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty();
// a new name goes inside the default parent (Put New Keywords Inside This Keyword)
let as_typed = |app: &LightcraftApp, text: &str| -> Vec<String> {
text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect()
};
let adding: Option<Vec<String>> = match picked {
// an offer: it, and the keywords typed before the last comma
Some(crate::panels::keywording::Picked::Keyword(k)) => {
let mut kws = crate::panels::keywording::typed_before(&app.session.catalog, &text);
kws.push(k);
Some(kws)
}
Some(crate::panels::keywording::Picked::Typed) => Some(as_typed(app, &text)),
None if returned => Some(as_typed(app, &text)),
None => None,
};
if let Some(kws) = adding {
// (refused, the text stays to be put right, and the refusal is said)
match app.run("photo.setMeta", json!({"addKeywords": kws})) {
Ok(_) => {
text.clear();
// an offer taken leaves the cursor in the box, for the next keyword
if !returned {
ui.data_mut(|d| d.insert_temp(bid, pass));
}
}
Err(e) => app.toast_error(ui.ctx(), e),
}
}
ui.data_mut(|d| d.insert_temp(kid, text));
ui.add_space(8.0);
crate::panels::keywording::view_switch(app, ui);
ui.add_space(4.0);
crate::panels::keywording::scope(app, ui, &selection);
if app.ui.keywording_view == crate::state::KeywordingView::Keywords {
crate::panels::keywording::chip_row(app, ui, &selection);
} else {
crate::panels::keywording::names_row(app, ui);
}
ui.add_space(10.0);
keyword_set(app, ui);
ui.add_space(8.0);
// the painter: click photos in the grid to give them (or take away) a keyword
ui.horizontal(|ui| {
let pid = egui::Id::unique("kw-painter");
let painting = app.ui.keyword_painter.clone();
let mut k: String = ui.data(|d| d.get_temp(pid)).unwrap_or_else(|| painting.clone().unwrap_or_default());
// (fixed while painting: Stop first)
let field = ui
.add_enabled_ui(painting.is_none(), |ui| {
crate::text_field::TextField::singleline("field:keywordPainter", &mut k)
.hint(crate::i18n::tr("Keyword to paint"))
.width(130.0)
.show(ui)
})
.inner;
ui.data_mut(|d| d.insert_temp(pid, k.clone()));
let label = if painting.is_some() { "Stop" } else { "Paint" };
let returned = field.ending == Some(crate::text_field::Ending::Return) && painting.is_none() && !k.trim().is_empty();
if text_button(ui, "keywordPaint", label, painting.is_some())
.on_hover_text(crate::i18n::tr("Click photos in the grid to toggle the keyword; Esc stops"))
.clicked()
|| returned
{
let _ = app.run("tool.keywordPainter", json!({"keyword": if painting.is_some() { serde_json::Value::Null } else { json!(k) }}));
}
});
ui.add_space(10.0);
// suggestions: keywords used together with the selection's keywords, else the most used
// ones (what matches the text being typed is offered under the box itself)
let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone();
if !suggestions.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim));
// grouped: the keyword's own name, its whole path in the tooltip
let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped;
let names = if grouped { crate::panels::keywording::suggestion_names(&suggestions) } else { Vec::new() };
ui.horizontal_wrapped(|ui| {
if grouped {
ui.spacing_mut().item_spacing = vec2(6.0, 4.0);
}
for (i, k) in suggestions.into_iter().enumerate() {
let whole = k.replace('|', " › ");
let name = names.get(i).unwrap_or(&whole);
let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0));
let r = if grouped { r.on_hover_text(&whole) } else { r };
register(ui, format!("kwSuggest:{k}"), r.rect);
if r.clicked() {
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
}
}
});
}
});
}
/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at
/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it
/// a long tree slides away from under the pointer by a row of chips each time. It holds only while
/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and
/// while the change does not come from the box itself (the pointer on it, or typing in its field).
fn hold_list_in_place(ui: &mut egui::Ui, top: f32) {
let height = ui.cursor().top() - top;
let key = egui::Id::unique("kw-box-height");
let pass = ui.ctx().cumulative_pass_nr();
// (the height drawn on the pass before: one from an earlier visit to the panel says nothing)
let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h);
ui.data_mut(|d| d.insert_temp(key, (height, pass)));
let Some(before) = before else { return };
let grew = height - before;
if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 {
return;
}
// (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it)
let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect());
let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p));
let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword")));
if on_box || typing {
return;
}
ui.data_mut(|d| d.insert_temp(egui::Id::unique(HOLD), (grew, pass)));
// (drawn again at once at the new offset, so the list never shows a frame out of place)
ui.ctx().request_discard("the Keyword List holds its place");
}
/// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the
/// selected photos; "Save as Set…" keeps the current nine under a name.
fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) {
// on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do
let selection = app.session.selected();
let chips = app.caches.keyword_chips(&app.session.catalog, &selection);
let t = Tokens::get(ui.ctx());
let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session);
let current = sets["current"].as_str().unwrap_or_default().to_string();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim));
let combo = crate::widgets::combo_box("kw-set")
.selected_text(crate::i18n::builtin_label(&current, current == lightcraft_engine::cmd::keywords::RECENT))
.show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
let name = set["name"].as_str().unwrap_or_default();
if ui
.selectable_label(name == current, crate::i18n::builtin_label(name, name == lightcraft_engine::cmd::keywords::RECENT))
.clicked()
{
let _ = app.run("keyword.useSet", json!({"name": name}));
}
}
ui.separator();
// Edit Set…: the current nine, slot by slot (named, Recent Keywords becomes a set)
let edit = ui.button(crate::i18n::tr("Edit Set…"));
register(ui, "keywordSetMenu:edit", edit.rect);
if edit.clicked() {
let mut slots: Vec<String> =
sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
slots.resize(9, String::new());
let named = current != lightcraft_engine::cmd::keywords::RECENT;
app.ui.dialog = Some(crate::state::Dialog::KeywordSet {
replaces: named.then(|| current.clone()),
name: if named { current.clone() } else { String::new() },
slots,
as_new: false,
});
}
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
app.ui.dialog = Some(crate::state::Dialog::TextPrompt {
title: "Save Keyword Set".into(),
hint: "Set name".into(),
value: String::new(),
command: "keyword.saveSet".into(),
params: json!({}),
key: "name".into(),
});
}
if current != lightcraft_engine::cmd::keywords::RECENT
&& ui.button(crate::i18n::tr_format!("Delete “{current}”", current = current)).clicked()
{
let _ = app.run("keyword.deleteSet", json!({"name": current}));
}
});
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Keyword Set"));
register(ui, "keywordSetCombo", combo.response.rect);
});
let kws: Vec<String> = sets["keywords"].as_array().into_iter().flatten().filter_map(|k| k.as_str().map(str::to_string)).collect();
if kws.is_empty() {
ui.label(egui::RichText::new(crate::i18n::tr("Keywords you add appear here; ⌥1–⌥9 apply them.")).color(t.text_dim));
return;
}
let bw = ((ui.available_width() - 8.0) / 3.0).floor().max(40.0);
egui::Grid::new("kw-set-grid").num_columns(3).spacing([4.0, 4.0]).show(ui, |ui| {
for (i, k) in kws.iter().enumerate() {
// an empty slot: an idle button, so the others keep their ⌥ keys
if k.is_empty() {
let r = ui.add_enabled_ui(false, |ui| ui.add_sized([bw, 22.0], egui::Button::new(""))).inner;
register(ui, format!("kwSetEmpty:{}", i + 1), r.rect);
if i % 3 == 2 {
ui.end_row();
}
continue;
}
let chip = chips.iter().find(|c| lightcraft_catalog::keywords::same(&c.path, &lightcraft_catalog::keywords::clean(k)));
let on = chip.is_some_and(|c| c.on_all());
let some = chip.is_some() && !on;
let short = format!("{}{}", k.rsplit('|').next().unwrap_or(k), if some { " *" } else { "" });
let r = ui
.add_sized(
[bw, 22.0],
egui::Button::new(egui::RichText::new(short).size(11.5).color(if on { t.text } else { t.text_label })).selected(on).truncate(),
)
.on_hover_text(format!("{} — ⌥{}", k.replace('|', " › "), i + 1));
register(ui, format!("kwSet:{}", i + 1), r.rect);
if on {
register(ui, format!("kwSetOn:{}", i + 1), r.rect);
} else if some {
register(ui, format!("kwSetSome:{}", i + 1), r.rect);
}
if r.clicked() {
let _ = app.run("keyword.toggleFromSet", json!({"index": i + 1}));
}
if i % 3 == 2 {
ui.end_row();
}
}
});
}