LR-PROF-BROWSER: profile browser (live variant thumbnails per group, favourites star, amount, hover preview in the loupe, click to apply); Browse… in the profile menu; ui.hoverWidget

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:49:34 -04:00
co-authored by Claude Opus 5.5
parent e82d8d0dcd
commit 62a2860c7e
12 changed files with 257 additions and 24 deletions
+4 -2
View File
@@ -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 {
+40
View File
@@ -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() {
+1
View File
@@ -203,6 +203,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"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),
+9
View File
@@ -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<Re
panel(app, &ctx, RightPanel::Edit, "Edit");
Ok(Value::Null)
}
"panel.profiles" => {
// 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)
+31 -5
View File
@@ -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}));
}
}
+1
View File
@@ -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;
+151
View File
@@ -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}));
}
}
}
+1
View File
@@ -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),
+2 -1
View File
@@ -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" {
+3 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 |
+13 -14
View File
@@ -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%) |
<!-- /parity:summary -->
## 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 | ⬜ | | |