From 62a2860c7e7c8ad860b0d41b2c31adca9ed7a11a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:49:34 -0400 Subject: [PATCH] =?UTF-8?q?LR-PROF-BROWSER:=20profile=20browser=20(live=20?= =?UTF-8?q?variant=20thumbnails=20per=20group,=20favourites=20star,=20amou?= =?UTF-8?q?nt,=20hover=20preview=20in=20the=20loupe,=20click=20to=20apply)?= =?UTF-8?q?;=20Browse=E2=80=A6=20in=20the=20profile=20menu;=20ui.hoverWidg?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/headless.rs | 40 +++++++ crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/menus.rs | 9 ++ crates/ui-egui/src/panels/edit.rs | 36 +++++- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/profiles.rs | 151 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 1 + crates/ui-egui/src/panels/strip.rs | 3 +- crates/ui-egui/src/state.rs | 4 +- docs/control-protocol.md | 2 +- docs/parity.md | 27 +++-- 12 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/panels/profiles.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2537e5ea0..6fc4434ed 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -144,13 +144,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest app.widgets.iter().filter(|(id, _)| id.contains(filter)).map(|(id, r)| json!({"id": id, "rect": rect_json(*r)})).collect(), )) } - "ui.clickWidget" | "ui.dragWidget" => { + "ui.clickWidget" | "ui.dragWidget" | "ui.hoverWidget" => { let Some(id) = s("id") else { return err("missing `id`") }; let Some(r) = widget_rect(app, id) else { return err(format!("no widget `{id}` on screen (see ui.widgets)")) }; let m = modifiers(p); // optional relative position inside the widget (0..1) let at = egui::pos2(r.left() + r.width() * f("fx").unwrap_or(0.5) as f32, r.top() + r.height() * f("fy").unwrap_or(0.5) as f32); - if req.method == "ui.clickWidget" { + if req.method == "ui.hoverWidget" { + app.synthetic.push(egui::Event::PointerMoved(at)); + } else if req.method == "ui.clickWidget" { let n = p.get("count").and_then(Value::as_u64).unwrap_or(1); app.synthetic.push(egui::Event::PointerMoved(at)); for _ in 0..n { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f4c70cec5..386e16f4e 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -345,6 +345,46 @@ mod tests { h.settle(SETTLE); } + /// Profile browser: live variant thumbnails, hover previews in the loupe without touching the + /// photo or its history, click applies, the star toggles the favourite. + #[test] + fn profile_browser_previews_on_hover_and_applies_on_click() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let r = h.request("engine.execute", json!({"command": "panel.profiles"}), t); + assert_eq!(r["result"]["open"], true, "{r}"); + h.settle(SETTLE); + let id = h.app.session.active().unwrap(); + let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone(); + let before = photo(&h); + assert!(h.app.renderer.variant_textures() >= 6, "variant thumbnails rendered: {}", h.app.renderer.variant_textures()); + // hover: the loupe shows the look, nothing is committed + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Profile: Vivid")); + assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover")); + let hover = h.app.renderer.textures.get(&crate::render::Slot::Hover).expect("hover render"); + assert_eq!(hover.photo, id); + let after = photo(&h); + assert_eq!((after.develop.clone(), after.history.len()), (before.develop.clone(), before.history.len()), "hover leaves the photo alone"); + assert_eq!(h.app.session.undo.len(), 0); + // moving away ends the preview + h.request("ui.move", json!({"x": 5, "y": 500}), t); + h.step(); + assert!(h.app.hover_preview.is_none()); + // click applies (one history step); the star toggles the favourite + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid"); + assert_eq!(photo(&h).history.len(), before.history.len() + 1); + h.request("ui.clickWidget", json!({"id": "profileStar:lc.portrait"}), t); + assert_eq!(h.app.session.profile_favorites, ["lc.portrait"]); + assert_eq!(photo(&h).develop.profile.id, "lc.vivid", "the star doesn't apply"); + h.settle(SETTLE); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1b56db044..e5efb5fb4 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -203,6 +203,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), "panel.edit" => panel(RightPanel::Edit), + "panel.profiles" => panel(RightPanel::Profiles), "panel.crop" => panel(RightPanel::Crop), "panel.remove" => panel(RightPanel::Remove), "panel.masking" => panel(RightPanel::Masking), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4d31a0024..76d447c7a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -39,6 +39,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.back", "Back to Grid", Some("Escape"), ""), ("view.filterBar", "Filter Bar", Some("Shift+F"), "View"), ("panel.edit", "Edit", Some("E"), "Window"), + ("panel.profiles", "Profile Browser", None, "Window"), ("panel.crop", "Crop & Rotate", Some("C"), "Window"), ("panel.remove", "Remove", Some("H"), "Window"), ("panel.masking", "Masking", Some("M"), "Window"), @@ -234,6 +235,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // toggles between the profile browser and the Edit panel it belongs to + app.ui.right = if app.ui.right == RightPanel::Profiles { RightPanel::Edit } else { RightPanel::Profiles }; + if !matches!(app.ui.view, ViewMode::Detail) { + app.ui.view = ViewMode::Detail; + } + Ok(json!({"open": app.ui.right == RightPanel::Profiles})) + } "panel.crop" => { panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry"); Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 7e98fe7d3..5fa492f3f 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -32,7 +32,7 @@ pub fn apply_slider_out( } } -fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { +pub(crate) fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) { let Some(spec) = controls::find(id) else { return }; let v = controls::get(d, id).unwrap_or(spec.default); let out = slider(ui, spec, v, enabled, None); @@ -107,6 +107,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); let r = crate::widgets::dropdown(ui, "profile", name, t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { + let _ = app.run("panel.profiles", json!({})); + } + }); }); }); if d.profile.id != "lc.color" { @@ -309,12 +314,19 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); let cur = d.profile.id.as_str(); - let mut pick: Option<&'static str> = None; - let item = |ui: &mut egui::Ui, key: &str, p: &'static lightcraft_engine::presets::ProfileInfo, pick: &mut Option<&'static str>| { + // (clicked, hovered) + let mut pick: (Option<&'static str>, Option<&'static str>) = (None, None); + let item = |ui: &mut egui::Ui, + key: &str, + p: &'static lightcraft_engine::presets::ProfileInfo, + pick: &mut (Option<&'static str>, Option<&'static str>)| { let r = ui.selectable_label(p.id == cur, p.name); register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); if r.clicked() { - *pick = Some(p.id); + pick.0 = Some(p.id); + } + if r.hovered() { + pick.1 = Some(p.id); } }; let heading = |ui: &mut egui::Ui, s: &str| { @@ -350,7 +362,21 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let _ = app.run("profile.favorite", json!({"id": p.id})); } } - if let Some(id) = pick { + let r = ui.button("Browse…"); + register(ui.ctx(), "profileMenu:browse", r.rect); + if r.clicked() { + let _ = app.run("panel.profiles", json!({})); + } + // resting on a profile previews it in the loupe + if let Some(p) = pick.1.and_then(profile) + && p.id != cur + { + let mut s = d.clone(); + s.profile.id = p.id.to_string(); + s.profile.amount = 100.0; + app.hover_preview = Some(crate::HoverPreview { label: format!("Profile: {}", p.name), settings: s }); + } + if let Some(id) = pick.0 { let _ = app.run("develop.profile", json!({"id": id})); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 76cbb0116..677870286 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -10,6 +10,7 @@ pub mod grid; pub mod left; pub mod masking; pub mod presets; +pub mod profiles; pub mod right; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs new file mode 100644 index 000000000..bdf1e851c --- /dev/null +++ b/crates/ui-egui/src/panels/profiles.rs @@ -0,0 +1,151 @@ +//! The profile browser (the profile menu's "Browse…", or the grid button next to it): every +//! profile as a live thumbnail of the current photo, grouped (Favorites first), with a favourite +//! star, the applied profile's amount, a temporary loupe preview while hovering and apply on click. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_develop::DevelopSettings; +use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups}; +use serde_json::json; + +use crate::icons::{Icon, paint}; +use crate::state::RightPanel; +use crate::theme::Tokens; +use crate::widgets::{divider, icon_button, register}; +use crate::{HoverPreview, LightcraftApp}; + +/// Long edge of the variant thumbnails (px). +const THUMB_EDGE: usize = 256; +/// Thumbnail columns. +const COLUMNS: usize = 2; + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + let t = Tokens::get(ui.ctx()); + let d = (*app.session.develop_of(id).unwrap_or_default()).clone(); + // header: back + title + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + let mut back = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(hr.min + vec2(12.0, 9.0), vec2(28.0, 28.0)))); + if icon_button(&mut back, "profilesBack", Icon::ChevronLeft, vec2(28.0, 28.0), false, true, "Back to Edit").clicked() { + app.ui.right = RightPanel::Edit; + } + ui.painter().text(pos2(hr.left() + 46.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Profiles", t.semibold(15.0), t.text); + divider(ui); + // the applied profile and its amount + let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); + ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, "Profile", t.font(13.0), t.text_dim); + ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label); + if d.profile.id != "lc.color" { + super::edit::control(app, ui, &d, "profile.amount", true); + ui.add_space(6.0); + } + divider(ui); + let mut sections: Vec<(&str, &str, Vec<&'static ProfileInfo>)> = Vec::new(); + let favs: Vec<&'static ProfileInfo> = app.session.profile_favorites.iter().filter_map(|f| profile(f)).collect(); + if !favs.is_empty() { + sections.push(("Favorites", "fav", favs)); + } + for g in profile_groups() { + sections.push((g, "", PROFILES.iter().filter(|p| p.group == g).collect())); + } + for (title, tag, list) in sections { + let open_id = egui::Id::new(("profile-group", title)); + let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + register(ui.ctx(), format!("profileGroup:{title}"), r); + paint( + ui.painter(), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_label, + ); + ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); + ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim); + if resp.clicked() { + ui.data_mut(|m| m.insert_temp(open_id, !open)); + } + if !open { + continue; + } + let gap = 8.0; + let cw = ((ui.available_width() - 44.0 - gap * (COLUMNS as f32 - 1.0)) / COLUMNS as f32).max(40.0); + let th = (cw * 0.75).round(); + for row in list.chunks(COLUMNS) { + let (rr, _) = ui.allocate_exact_size(vec2(ui.available_width(), th + 30.0), Sense::hover()); + for (i, p) in row.iter().enumerate() { + let cell = Rect::from_min_size(pos2(rr.left() + 22.0 + i as f32 * (cw + gap), rr.top()), vec2(cw, th + 24.0)); + cell_ui(app, ui, &d, id, tag, p, cell, th); + } + } + ui.add_space(6.0); + } + ui.add_space(30.0); +} + +/// `d` with profile `p` (at 100 % unless it is the applied one). +fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings { + let mut s = d.clone(); + if s.profile.id != p.id { + s.profile.id = p.id.to_string(); + s.profile.amount = 100.0; + } + s +} + +/// The part of a `size` image that covers `r` (centre crop), as texture coordinates. +fn cover_uv(r: Rect, size: [usize; 2]) -> Rect { + let a = size[0] as f32 / size[1].max(1) as f32; + let ra = r.width() / r.height().max(1.0); + if a > ra { + let f = ra / a; + Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0)) + } else { + let f = a / ra; + Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0)) + } +} + +#[allow(clippy::too_many_arguments)] +fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) { + let t = Tokens::get(ui.ctx()); + let key = if tag.is_empty() { format!("profileCell:{}", p.id) } else { format!("profileCell:{tag}:{}", p.id) }; + let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + register(ui.ctx(), key, cell); + let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); + let s = with_profile(d, p); + let painter = ui.painter(); + painter.rect_filled(thumb, 3.0, t.canvas); + if let Some(job) = app.session.variant_job(id, &s, THUMB_EDGE) + && let Some(tex) = app.renderer.variant(job) + { + painter.image(tex.tex.id(), thumb, cover_uv(thumb, tex.size), Color32::WHITE); + } + let applied = d.profile.id == p.id; + if applied { + painter.rect_stroke(thumb.expand(1.0), 4.0, Stroke::new(2.0, t.pick), egui::StrokeKind::Outside); + } else if resp.hovered() { + painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); + } + let name_color = if applied { t.text } else { t.text_label }; + painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color); + // favourite star (always shown when set, on hover otherwise) + let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); + register(ui.ctx(), format!("profileStar:{}{}", if tag.is_empty() { "" } else { "fav:" }, p.id), star); + let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); + if fav || resp.hovered() { + painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); + paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); + } + if resp.hovered() && !applied && !over_star { + app.hover_preview = Some(HoverPreview { label: format!("Profile: {}", p.name), settings: s.clone() }); + } + let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); + if resp.clicked() { + if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) { + let _ = app.run("profile.favorite", json!({"id": p.id})); + } else if !applied { + let _ = app.run("develop.profile", json!({"id": p.id, "amount": 100})); + } + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 016c9a1d0..7bb3c3e25 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -27,6 +27,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), + RightPanel::Profiles => super::profiles::show(app, ui, id), RightPanel::Crop => crop(app, ui, id), RightPanel::Remove => remove(app, ui, id), RightPanel::Masking => super::masking::show(app, ui, id), diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index 9b7a1dde5..ae1d6e557 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -35,7 +35,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("masking", Icon::Mask, RightPanel::Masking, "Masking (M)"), ("redeye", Icon::Eye, RightPanel::RedEye, "Red Eye"), ] { - if icon_button(ui, id, icon, sz, app.ui.right == panel, has_photo, tip).clicked() { + let on = app.ui.right == panel || (panel == RightPanel::Edit && app.ui.right == RightPanel::Profiles); + if icon_button(ui, id, icon, sz, on, has_photo, tip).clicked() { let _ = app.run(&format!("panel.{id}"), json!({})); } if id == "edit" { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3ba2821..3c6d557fb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -31,11 +31,13 @@ pub enum RightPanel { Activity, Keywords, Info, + /// The profile browser (part of Edit). + Profiles, } impl RightPanel { pub fn is_edit_tool(self) -> bool { - matches!(self, RightPanel::Edit | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) + matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 71d5ee05c..68a6a6621 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,7 +23,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | | `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | -| `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | +| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | | `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | | `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | diff --git a/docs/parity.md b/docs/parity.md index 1263f9e54..013077b10 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -27,7 +27,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | -| G. Profiles (PROF) | 4 | 1 | 5 | 0 | 2/3 (67%) | 1/3 (33%) | +| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 209 | 103 | 188 | 37 | 152/198 (77%) | 48/144 (33%) | +| **Total** | 211 | 102 | 187 | 37 | 153/198 (77%) | 49/144 (34%) | ## Top gaps @@ -66,20 +66,19 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. 6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. -7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -8. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -9. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), +7. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +8. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. -10. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +9. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -11. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +10. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -12. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +11. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -13. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -14. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -15. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -16. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +12. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +13. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +14. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +15. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -276,8 +275,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `cmd:profiles.menu`, `cmd:profile.favorite`, `crates/ui-egui/src/panels/edit.rs` | Favorites, Recent (last 5), group submenus, favourite toggle; favourites/recent persist with the library; Amount slider under the menu for non-default profiles; no Browse… entry yet | -| LR-PROF-BROWSER | Profile browser | P1 | ⬜ | | | +| LR-PROF-DROPDOWN | Profile menu | P0 | ✅ | `cmd:develop.profile`, `cmd:profiles.list`, `cmd:profiles.menu`, `cmd:profile.favorite`, `crates/ui-egui/src/panels/edit.rs` | Favorites, Recent (last 5), group submenus, favourite toggle; favourites/recent persist with the library; Amount slider under the menu for non-default profiles; Browse… opens the profile browser; resting on an entry previews it in the loupe | +| LR-PROF-BROWSER | Profile browser | P1 | ✅ | `cmd:panel.profiles`, `crates/ui-egui/src/panels/profiles.rs`, `crates/engine/src/media.rs` | grid of live variant thumbnails of the current photo per group (Favorites first), star toggles favourites, amount slider, hover = loupe preview (no history), click applies | | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |