mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e82d8d0dcd
commit
62a2860c7e
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user