Browse / github.com/storytold/lightcraft / patch
LR-UI-TRACK-SHADE: grey slider tracks show which way they take the photo
LR-UI-TRACK-SHADE: grey slider tracks show which way they take the photo **Stacked on #3155. Please review that first.** Until it merges, this diff includes its commits. This PR's own commits are the LR-UI-TRACK-SHADE ones. It does not depend on #3158. ## Why On a one-line slider row the track is the main thing your eye lands on. A plain grey line doesn't say which way a slider takes the photo: - Contrast and Clarity darken toward the right of their effect; - Exposure lightens to the right; - Vignette darkens at both ends. Lightroom Classic shades its grey tracks to show this. After years in it, a photographer reads the direction from the track without
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- OneSlips (imported from pull request #3164, open)
- Base
3a3e49af53aeff55248419234a05d1a262094092- Size
- +1183 −286 in 44 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2a50432d00c84c0efae1dcd9
How it went
No other operator has reported using it yet.
Diff
raw · crates/develop/src/controls.rs crates/develop/src/lib.rs crates/ui-egui/src/dialogs/export.rs crates/ui-egui/src/dialogs/import/tags.rs crates/ui-egui/src/dialogs/merge.rs crates/ui-egui/src/dialogs/settings/appearance.rs crates/ui-egui/src/dialogs/settings/slide_templates.rs crates/ui-egui/src/headless/tests.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/panels/activity.rs crates/ui-egui/src/panels/denoise.rs crates/ui-egui/src/panels/edit.rs crates/ui-egui/src/panels/edit/curve.rs crates/ui-egui/src/panels/edit/grading.rs crates/ui-egui/src/panels/edit/histogram.rs crates/ui-egui/src/panels/edit/mixer.rs crates/ui-egui/src/panels/edit/sliders.rs crates/ui-egui/src/panels/keyword_list.rs crates/ui-egui/src/panels/keywording.rs crates/ui-egui/src/panels/left/rows.rs crates/ui-egui/src/panels/left/sections.rs crates/ui-egui/src/panels/mask_effects.rs crates/ui-egui/src/panels/masking.rs crates/ui-egui/src/panels/presets.rs crates/ui-egui/src/panels/right.rs crates/ui-egui/src/panels/right/crop.rs crates/ui-egui/src/panels/right/history.rs crates/ui-egui/src/panels/right/info.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/dialog_fit.rs crates/ui-egui/src/tests/dialogs.rs crates/ui-egui/src/tests/docking_acceptance.rs crates/ui-egui/src/tests/masking.rs crates/ui-egui/src/tests/masking_layout.rs crates/ui-egui/src/tests/slider_columns.rs crates/ui-egui/src/tests/temp_slider.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/widgets.rs crates/ui-egui/src/widgets/album_picker.rs crates/ui-egui/src/widgets/rating.rs crates/ui-egui/src/widgets/tests_dense_row.rs crates/ui-egui/tests/it/accessibility.rs docs/control-protocol.md docs/parity-checklist.md
@@ -77,6 +77,24 @@ pub enum Track { Rainbow, } +/// The brightness ramp drawn inside a grey track, telling which way the slider darkens or+/// lightens the photo (as Lightroom shades its tracks). Colour tracks ([`Track`] gradients, hue,+/// temperature…) keep their colours and ignore it.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]+pub enum Shade {+ /// A flat track.+ #[default]+ Plain,+ /// Dark at the left, light at the right (Exposure, Shadows, Noise Reduction…).+ Ramp,+ /// Light at the left, dark at the right (Contrast, Clarity, Sharpening Masking).+ ReverseRamp,+ /// Lightest at the centre, darker toward both ends (Vignette Amount, Roundness).+ CentreLight,+ /// Dark grey to red (Sharpening Amount).+ Tinted,+}+ #[derive(Clone, Copy, Debug, Serialize)] pub struct ControlSpec { pub id: &'static str,@@ -89,6 +107,8 @@ pub struct ControlSpec { /// Decimal places shown. pub decimals: u8, pub track: Track,+ /// The grey ramp inside a grey track.+ pub shade: Shade, } impl ControlSpec {@@ -138,11 +158,21 @@ const KELVIN_CENTRE: f64 = 7000.0; /// just past the centre (76 K against 100 K per 1 % of the track), so the thumb moves evenly across it. const KELVIN_KNEE: f64 = 1000.0; +/// A row's [`Shade`]: the one named after `=>`, else plain.+macro_rules! shade {+ () => {+ Shade::Plain+ };+ ($s:ident) => {+ Shade::$s+ };+}+ macro_rules! controls {- ($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {+ ($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr $(=> $shade:ident)?; )*) => { /// All numeric develop controls, in panel order. pub static CONTROLS: &[ControlSpec] = &[- $( ControlSpec { id: $id, label: $label, section: Section::$section, min: $min as f64, max: $max as f64, default: $def as f64, step: $step as f64, decimals: $dec, track: $track }, )*+ $( ControlSpec { id: $id, label: $label, section: Section::$section, min: $min as f64, max: $max as f64, default: $def as f64, step: $step as f64, decimals: $dec, track: $track, shade: shade!($($shade)?) }, )* ]; /// Read a control value by id.@@ -171,12 +201,12 @@ controls! { "profile.amount" => profile.amount, "Amount", Profile, 0, 200, 100, 1, 0, Plain; "wb.temp" => wb.temp, "Temp", Color, 2000, 50000, 6500, 50, 0, Temp; "wb.tint" => wb.tint, "Tint", Color, -150, 150, 0, 1, 0, Tint;- "light.exposure" => light.exposure, "Exposure", Light, -5, 5, 0, 0.01, 2, Centered;- "light.contrast" => light.contrast, "Contrast", Light, -100, 100, 0, 1, 0, Centered;- "light.highlights" => light.highlights, "Highlights", Light, -100, 100, 0, 1, 0, Centered;- "light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;- "light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;- "light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;+ "light.exposure" => light.exposure, "Exposure", Light, -5, 5, 0, 0.01, 2, Centered => Ramp;+ "light.contrast" => light.contrast, "Contrast", Light, -100, 100, 0, 1, 0, Centered => ReverseRamp;+ "light.highlights" => light.highlights, "Highlights", Light, -100, 100, 0, 1, 0, Centered => Ramp;+ "light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered => Ramp;+ "light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered => Ramp;+ "light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered => Ramp; "hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain; "hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered; "hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered;@@ -241,27 +271,27 @@ controls! { "grading.blending" => grading.blending, "Blending", Grading, 0, 100, 50, 1, 0, Plain; "grading.balance" => grading.balance, "Balance", Grading, -100, 100, 0, 1, 0, Centered; "effects.texture" => effects.texture, "Texture", Effects, -100, 100, 0, 1, 0, Centered;- "effects.clarity" => effects.clarity, "Clarity", Effects, -100, 100, 0, 1, 0, Centered;+ "effects.clarity" => effects.clarity, "Clarity", Effects, -100, 100, 0, 1, 0, Centered => ReverseRamp; "effects.dehaze" => effects.dehaze, "Dehaze", Effects, -100, 100, 0, 1, 0, Centered;- "vignette.amount" => vignette.amount, "Vignette", Vignette, -100, 100, 0, 1, 0, Gradient { from: "#101010", to: "#f0f0f0" };- "vignette.midpoint" => vignette.midpoint, "Midpoint", Vignette, 0, 100, 50, 1, 0, Plain;- "vignette.roundness" => vignette.roundness, "Roundness", Vignette, -100, 100, 0, 1, 0, Centered;- "vignette.feather" => vignette.feather, "Feather", Vignette, 0, 100, 50, 1, 0, Plain;- "vignette.highlights" => vignette.highlights, "Highlights", Vignette, 0, 100, 0, 1, 0, Plain;- "grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;+ "vignette.amount" => vignette.amount, "Vignette", Vignette, -100, 100, 0, 1, 0, Centered => CentreLight;+ "vignette.midpoint" => vignette.midpoint, "Midpoint", Vignette, 0, 100, 50, 1, 0, Plain => Ramp;+ "vignette.roundness" => vignette.roundness, "Roundness", Vignette, -100, 100, 0, 1, 0, Centered => CentreLight;+ "vignette.feather" => vignette.feather, "Feather", Vignette, 0, 100, 50, 1, 0, Plain => Ramp;+ "vignette.highlights" => vignette.highlights, "Highlights", Vignette, 0, 100, 0, 1, 0, Plain => Ramp;+ "grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain => Ramp; "grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain; "grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain; "enhance.denoise" => enhance.denoise, "Amount", Detail, 0, 100, 0, 1, 0, Plain;- "detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;- "detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;- "detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;- "detail.sharpenMasking" => detail.sharpen_masking, "Masking", Detail, 0, 100, 0, 1, 0, Plain;- "detail.nrLuminance" => detail.nr_luminance, "Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;- "detail.nrDetail" => detail.nr_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;- "detail.nrContrast" => detail.nr_contrast, "Contrast", Detail, 0, 100, 0, 1, 0, Plain;- "detail.nrColor" => detail.nr_color, "Color Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;- "detail.nrColorDetail" => detail.nr_color_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;- "detail.nrColorSmoothness" => detail.nr_color_smoothness, "Smoothness", Detail, 0, 100, 50, 1, 0, Plain;+ "detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain => Tinted;+ "detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain => Ramp;+ "detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain => Ramp;+ "detail.sharpenMasking" => detail.sharpen_masking, "Masking", Detail, 0, 100, 0, 1, 0, Plain => ReverseRamp;+ "detail.nrLuminance" => detail.nr_luminance, "Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain => Ramp;+ "detail.nrDetail" => detail.nr_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain => Ramp;+ "detail.nrContrast" => detail.nr_contrast, "Contrast", Detail, 0, 100, 0, 1, 0, Plain => Ramp;+ "detail.nrColor" => detail.nr_color, "Color Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain => Ramp;+ "detail.nrColorDetail" => detail.nr_color_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain => Ramp;+ "detail.nrColorSmoothness" => detail.nr_color_smoothness, "Smoothness", Detail, 0, 100, 50, 1, 0, Plain => Ramp; "optics.distortion" => optics.distortion, "Distortion", Optics, -100, 100, 0, 1, 0, Centered; "optics.vignetting" => optics.vignetting, "Vignetting", Optics, -100, 100, 0, 1, 0, Centered; "optics.vignettingMidpoint" => optics.vignetting_midpoint, "Midpoint", Optics, 0, 100, 50, 1, 0, Plain;@@ -304,6 +334,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Rainbow,+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.satShift",@@ -315,6 +346,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Gradient { from: "#7a7a7a", to: "#e04a3a" },+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.lumShift",@@ -326,6 +358,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Gradient { from: "#202020", to: "#f0f0f0" },+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.variance",@@ -337,6 +370,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Centered,+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.range",@@ -348,6 +382,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Plain,+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.hueRange",@@ -359,6 +394,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Rainbow,+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.satRange",@@ -370,6 +406,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Gradient { from: "#7a7a7a", to: "#e04a3a" },+ shade: Shade::Plain, }, ControlSpec { id: "pointColor.lumRange",@@ -381,6 +418,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Gradient { from: "#202020", to: "#f0f0f0" },+ shade: Shade::Plain, }, ControlSpec { id: "redEye.pupilSize",@@ -392,6 +430,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Plain,+ shade: Shade::Plain, }, ControlSpec { id: "redEye.darken",@@ -403,6 +442,7 @@ pub static INDEXED: &[ControlSpec] = &[ step: 1.0, decimals: 0, track: Gradient { from: "#d0d0d0", to: "#101010" },+ shade: Shade::Plain, }, ]; @@ -11,7 +11,7 @@ pub mod presets; pub mod segmask; pub mod settings; -pub use controls::{CONTROLS, ControlSpec, Section, Track};+pub use controls::{CONTROLS, ControlSpec, Section, Shade, Track}; pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups}; pub use segmask::SegMask; pub use settings::*;@@ -172,7 +172,18 @@ pub(super) fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<Str pub(super) const LABEL_W: f32 = 78.0; const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, default: f64, step: f64) -> ControlSpec {- ControlSpec { id, label, section: Section::Light, min, max, default, step, decimals: 0, track: Track::Plain }+ ControlSpec {+ id,+ label,+ section: Section::Light,+ min,+ max,+ default,+ step,+ decimals: 0,+ track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain,+ } } pub(super) const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 100.0, 90.0, 1.0);@@ -60,7 +60,7 @@ pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mu pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; let t = Tokens::get(ui.ctx());- egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {+ egui::Frame::new().fill(t.inset).corner_radius(crate::theme::RADIUS).inner_margin(6.0).show(ui, |ui| { ui.label( egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) .color(t.text_dim)@@ -526,6 +526,7 @@ const BOUNDARY_WARP: lightcraft_develop::ControlSpec = lightcraft_develop::Contr step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; const BRACKET: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec { id: "merge.bracket",@@ -537,6 +538,7 @@ const BRACKET: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) -> bool {@@ -44,7 +44,7 @@ fn theme_card<V: PartialEq + Copy>( egui::Frame::new() .fill(t.inset) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))- .corner_radius(6)+ .corner_radius(crate::theme::RADIUS_PANEL) .inner_margin(8) .show(ui, |ui| { ui.vertical(|ui| {@@ -135,7 +135,7 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te } let p = ui.painter(); if resp.hovered() {- p.rect_filled(rect, 3.0, t.hover);+ p.rect_filled(rect, crate::theme::RADIUS, t.hover); } if current { p.rect_stroke(rect.shrink(0.5), 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);@@ -3451,13 +3451,15 @@ fn capture_time_dialog_shifts_time_zone() { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap();- // The dock header leaves less vertical space; bring the metadata action into view.+ // The dock header leaves less vertical space; bring the metadata action into view when it+ // starts below the visible panel (the denser panel may already show it). let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1;- let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;- assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}");- h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t);- h.request("ui.scroll", json!({"dy": -160.0}), t);- h.settle(SETTLE);+ let first = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1;+ if !panel.contains_rect(first) {+ h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t);+ h.request("ui.scroll", json!({"dy": -160.0}), t);+ h.settle(SETTLE);+ } let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t);@@ -102,7 +102,7 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { // a slideshow's playback controls sit at the bottom: the toast goes to the top there, clear of them let y = if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 }; let r = Rect::from_center_size(pos2(canvas.center().x, y), size);- painter.rect_filled(r, 6.0, background.gamma_multiply(fade));+ painter.rect_filled(r, crate::theme::RADIUS_PANEL, background.gamma_multiply(fade)); painter.galley(r.center() - galley.size() / 2.0, galley, foreground); ctx.request_repaint_after(std::time::Duration::from_millis(30)); }@@ -72,7 +72,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let max_height = (ctx.content_rect().height() - t.top_bar_h - 60.0).max(80.0); let mut cancel = None; let mut toggle = false;- let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN));+ let frame = egui::Frame::NONE+ .fill(t.chrome)+ .stroke(Stroke::new(1.0, t.field_border))+ .corner_radius(crate::theme::RADIUS_PANEL)+ .inner_margin(Margin::same(MARGIN)); egui::Area::new(egui::Id::unique("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { // An area lays out in the size it had last frame, and the scroll area would take that as all the room there // is: the stack would never grow when rows are added. Give it the whole height below the top bar instead@@ -144,7 +144,7 @@ fn watch_downloads(app: &mut LightcraftApp, ctx: &egui::Context) { /// Per-photo AI Denoise switch, including a request waiting for model installation. pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on: bool, applicable: bool) { let mut enabled = on || crate::model_setup::denoise_requested(app, id);- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 2)).show(ui, |ui| { let r = ui.add_enabled(applicable, egui::Checkbox::new(&mut enabled, crate::i18n::tr("AI Denoise"))); register(ui, "denoise:enabled", r.rect); if r.changed() {@@ -174,7 +174,7 @@ pub(crate) fn settings_bar(ui: &mut egui::Ui, done: u64, total: u64, text: Strin pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) { let t = Tokens::get(ui.ctx()); let state = app.session.denoise_photo_state(id);- let pad = egui::Margin { left: 24, right: 22, top: 0, bottom: 6 };+ let pad = crate::widgets::row_margin(0, 4); let note = |ui: &mut egui::Ui, text: &str, color: egui::Color32| { ui.add(egui::Label::new(RichText::new(crate::i18n::tr(text)).size(11.0).color(color)).wrap()); };@@ -62,13 +62,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.ui.soft_proof { soft_proofing(app, ui, id); }- // header: Edit + Auto / B&W / HDR+ // header: Auto / B&W / HDR … Reset, one row (no "Edit" title: the tool strip says which panel+ // this is) let mut reset_clicked = false;- let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());- ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text);- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(5, 5)).show(ui, |ui| { ui.horizontal(|ui| {- ui.spacing_mut().item_spacing.x = 8.0;+ ui.spacing_mut().item_spacing.x = 4.0; if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() { let _ = app.run("develop.auto", json!({})); app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied"));@@ -96,7 +95,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); if let Some(why) = &preview_only { let reload = egui::Frame::NONE- .inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 })+ .inner_margin(crate::widgets::row_margin(0, 4)) .show(ui, |ui| crate::widgets::preview_only_notice(ui, "edit", why)) .inner; if reload {@@ -108,7 +107,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { quick_develop(app, ui, n); } if app.session.auto_sync && n > 1 {- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(0, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("Auto Sync: edits apply to {n} photos", n = n)).color(t.accent).size(12.0)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {@@ -121,14 +120,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } divider(ui); // profile row- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| {- ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));+ ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(12.0)).color(t.text_dim)); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); // a long name is cut short before Browse Profiles: a wider row would widen the whole panel's // content, and the sliders below would run past its edge- let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x;- let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max);+ let max = ui.available_width() - 22.0 - ui.spacing().item_spacing.x;+ let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(13.0), t.text_label, max); let below = r.rect.bottom(); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {@@ -136,7 +135,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui, "profileBrowser", Icon::ProfileGrid,- vec2(28.0, 28.0),+ vec2(22.0, 22.0), false, true, crate::i18n::tr("Browse Profiles"),@@ -150,12 +149,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); if d.profile.id != "lc.color" { control(app, ui, &d, "profile.amount", true);- ui.add_space(6.0);+ ui.add_space(3.0); } // a raw carrying its own local tone mapping (Apple ProRAW's gain table map): off renders it as // Lightroom Classic does, on as the camera does if app.session.source_info(id).local_tone.is_some() {- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(0, 4)).show(ui, |ui| { let mut on = d.profile.camera_local_tone; let r = ui.checkbox(&mut on, crate::i18n::tr("Camera local tone mapping")); crate::widgets::register(ui, "check:profile.cameraLocalTone", r.rect);@@ -171,17 +170,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] { control(app, ui, d, c, true); }- ui.add_space(6.0);+ ui.add_space(3.0); if d.hdr.enabled { let open = app.ui.flyout_open("sdrRendition"); if flyout_row(ui, "sdrRendition", "SDR Rendition", Icon::Sliders, open).clicked() { app.ui.toggle_flyout("sdrRendition"); } if open {- for c in ["hdr.sdrBrightness", "hdr.sdrContrast", "hdr.sdrHighlights", "hdr.sdrShadows", "hdr.sdrWhites", "hdr.sdrClarity"] {- control(app, ui, d, c, true);- }- ui.add_space(6.0);+ crate::widgets::slider_group(ui, "sdrRendition", |ui| {+ for c in ["hdr.sdrBrightness", "hdr.sdrContrast", "hdr.sdrHighlights", "hdr.sdrShadows", "hdr.sdrWhites", "hdr.sdrClarity"] {+ control(app, ui, d, c, true);+ }+ ui.add_space(3.0);+ }); } } let open = app.ui.flyout_open("curve");@@ -189,16 +190,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.toggle_flyout("curve"); } if open {- curve_editor(app, ui, id, d);+ crate::widgets::slider_group(ui, "curve", |ui| {+ curve_editor(app, ui, id, d);+ }); }- ui.add_space(8.0);+ ui.add_space(4.0); }); section(app, ui, &d, "color", "Color", |app, ui, d| { // White balance row- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 2)).show(ui, |ui| { ui.horizontal(|ui| {- ui.label(egui::RichText::new(crate::i18n::tr("White Balance")).font(t.font(13.0)).color(t.text_dim));- let r = crate::widgets::dropdown(ui, "wbMode", crate::i18n::tr(d.wb.mode.label()), t.font(14.0), t.text_label);+ ui.label(egui::RichText::new(crate::i18n::tr("White Balance")).font(t.font(12.0)).color(t.text_dim));+ let r = crate::widgets::dropdown(ui, "wbMode", crate::i18n::tr(d.wb.mode.label()), t.font(12.5), t.text_label); egui::Popup::menu(&r).show(|ui| { for m in WbMode::ALL { if m == WbMode::Custom {@@ -216,7 +219,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui, "wbPicker", Icon::Picker,- vec2(28.0, 28.0),+ vec2(22.0, 22.0), active, true, crate::i18n::tr("White Balance Selector (W)"),@@ -244,13 +247,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } control(app, ui, d, "color.vibrance", true); control(app, ui, d, "color.saturation", true);- ui.add_space(6.0);+ ui.add_space(3.0); let open = app.ui.flyout_open("mixer"); if flyout_row(ui, "mixer", if crate::is_bw(d) { "B&W Mixer" } else { "Color Mixer" }, Icon::Radial, open).clicked() { app.ui.toggle_flyout("mixer"); } if open {- mixer(app, ui, d);+ crate::widgets::slider_group(ui, "mixer", |ui| {+ mixer(app, ui, d);+ }); } if !crate::is_bw(d) { let open = app.ui.flyout_open("pointColor");@@ -258,7 +263,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.toggle_flyout("pointColor"); } if open {- point_color(app, ui, d);+ crate::widgets::slider_group(ui, "pointColor", |ui| {+ point_color(app, ui, d);+ }); } } let open = app.ui.flyout_open("grading");@@ -266,9 +273,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.toggle_flyout("grading"); } if open {- grading(app, ui, d, id);+ crate::widgets::slider_group(ui, "grading", |ui| {+ grading(app, ui, d, id);+ }); }- ui.add_space(8.0);+ ui.add_space(4.0); }); section(app, ui, &d, "effects", "Effects", |app, ui, d| { for c in ["effects.texture", "effects.clarity", "effects.dehaze"] {@@ -285,7 +294,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let items: Vec<(&str, &str)> = styles.iter().map(|(_, l, k)| (*l, *k)).collect(); let active = styles.iter().position(|(v, _, _)| *v == d.vignette.style); let mut chosen = None;- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 4)).show(ui, |ui| { chosen = crate::widgets::segmented(ui, "vignetteStyle", &items, active, 3); }); if let Some(i) = chosen {@@ -303,9 +312,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { for c in ["grain.amount", "grain.size", "grain.roughness"] { control(app, ui, d, c, c == "grain.amount" || d.grain.amount != 0.0); }- ui.add_space(8.0);+ ui.add_space(4.0); }); section(app, ui, &d, "detail", "Detail", |app, ui, d| {+ sub_title(ui, crate::i18n::tr("Sharpening")); for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] { control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0); }@@ -322,11 +332,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { { control(app, ui, d, c, true); }- ui.add_space(8.0);+ ui.add_space(4.0); }); section(app, ui, &d, "optics", "Optics", |app, ui, d| { let has_lens = app.session.catalog.photo(id).is_some_and(|p| p.embedded_lens.is_some());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { let mut ca = d.optics.remove_ca; if ui.checkbox(&mut ca, crate::i18n::tr("Remove Chromatic Aberration")).changed() { let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));@@ -342,6 +352,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); if d.optics.lens_profile {+ sub_title(ui, crate::i18n::tr("Profile")); for c in ["optics.profileDistortion", "optics.profileVignetting"] { control(app, ui, d, c, has_lens); }@@ -351,25 +362,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, c, true); } control(app, ui, d, "optics.vignettingMidpoint", d.optics.vignetting != 0.0);- ui.add_space(6.0);+ ui.add_space(3.0); let open = app.ui.flyout_open("defringe"); if flyout_row(ui, "defringe", crate::i18n::tr("Defringe"), Icon::Picker, open).clicked() { app.ui.toggle_flyout("defringe"); } if open {- control(app, ui, d, "optics.defringePurple", true);- let on = d.optics.defringe_purple_amount > 0.0;- control(app, ui, d, "optics.defringePurpleHueLo", on);- control(app, ui, d, "optics.defringePurpleHueHi", on);- control(app, ui, d, "optics.defringeGreen", true);- let on = d.optics.defringe_green_amount > 0.0;- control(app, ui, d, "optics.defringeGreenHueLo", on);- control(app, ui, d, "optics.defringeGreenHueHi", on);- sub_title(ui, crate::i18n::tr("Lateral Chromatic Aberration"));- control(app, ui, d, "optics.caRed", true);- control(app, ui, d, "optics.caBlue", true);+ crate::widgets::slider_group(ui, "defringe", |ui| {+ sub_title(ui, crate::i18n::tr("Purple"));+ control(app, ui, d, "optics.defringePurple", true);+ let on = d.optics.defringe_purple_amount > 0.0;+ control(app, ui, d, "optics.defringePurpleHueLo", on);+ control(app, ui, d, "optics.defringePurpleHueHi", on);+ sub_title(ui, crate::i18n::tr("Green"));+ control(app, ui, d, "optics.defringeGreen", true);+ let on = d.optics.defringe_green_amount > 0.0;+ control(app, ui, d, "optics.defringeGreenHueLo", on);+ control(app, ui, d, "optics.defringeGreenHueHi", on);+ sub_title(ui, crate::i18n::tr("Lateral Chromatic Aberration"));+ control(app, ui, d, "optics.caRed", true);+ control(app, ui, d, "optics.caBlue", true);+ }); }- ui.add_space(8.0);+ ui.add_space(4.0); }); // Lightroom Classic's Calibration panel (the cloud app hides it): last, like there. section(app, ui, &d, "calibration", "Calibration", |app, ui, d| {@@ -380,7 +395,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, &format!("calibration.{k}Hue"), true); control(app, ui, d, &format!("calibration.{k}Sat"), true); }- ui.add_space(8.0);+ ui.add_space(4.0); }); ui.add_space(40.0); // A pending numeric edit commits on blur before the explicit Reset takes precedence.@@ -162,7 +162,7 @@ fn param_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, resp: &egui:: pub(super) fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) { let t = Tokens::get(ui.ctx()); // channel selector- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| { for (ch, c) in [ ("parametric", Color32::from_gray(200)),@@ -393,7 +393,7 @@ pub(super) fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: Photo fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { use lightcraft_engine::cmd::curves::{all_presets, matching_preset}; let t = Tokens::get(ui.ctx());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); let current = matching_preset(&app.session, &d.curve);@@ -16,7 +16,7 @@ const GRADING_WHEELS: [(&str, &str); 3] = [("shadows", "Shadows"), ("midtones", pub(super) fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, photo: PhotoId) { let mode = GRADING_TABS.iter().find(|(k, ..)| *k == app.ui.grading_mode).map_or("3way", |(k, ..)| *k);- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 2 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 2)).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (key, tip, icon) in GRADING_TABS {@@ -236,7 +236,7 @@ pub(super) fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) pub(super) fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { use lightcraft_engine::pipeline::OutputSpace; let t = Tokens::get(ui.ctx());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 6 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Soft Proofing")).color(t.text).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {@@ -8,7 +8,7 @@ pub(super) fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSetti let t = Tokens::get(ui.ctx()); let bands = lightcraft_develop::MIXER_BANDS; let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0);- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; for (i, b) in bands.iter().enumerate() {@@ -27,7 +27,7 @@ pub(super) fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSetti }); }); // targeted adjustment: pick the attribute, then drag on the photo- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 2)).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; let (r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::hover());@@ -85,7 +85,7 @@ pub(super) fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &Develo app.ui.point_color = n - 1; } let sel = app.ui.point_color;- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; let active = app.ui.tool == "pointColor";@@ -118,7 +118,7 @@ pub(super) fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &Develo }); }); if n == 0 {- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 8 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 4)).show(ui, |ui| { ui.label(egui::RichText::new(crate::i18n::tr("Pick a colour on the photo with the eyedropper.")).size(12.0).color(t.text_dim)); }); return;@@ -130,7 +130,7 @@ pub(super) fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &Develo for k in ["range", "hueRange", "satRange", "lumRange"] { control(app, ui, d, &format!("pointColor.{sel}.{k}"), true); }- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 8 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { ui.horizontal(|ui| { let mut v = app.ui.point_color_visualize; if ui.checkbox(&mut v, crate::i18n::tr("Visualize range")).changed() {@@ -23,10 +23,39 @@ pub fn apply_slider_out( 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);+ // (the short name is for the eye; screen readers announce the full one)+ let out = crate::widgets::slider_named(ui, spec, v, enabled, short_name(id), None); apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": id, "value": v}))); } +/// The short name a slider shows under its group's caption (Grain ▸ Amount, Red Primary ▸ Hue,+/// as Lightroom Classic labels them), so a section's name column stays narrow and its tracks+/// long. The control keeps its full name everywhere else (commands, the console, menus, and what+/// screen readers announce).+pub(crate) fn short_name(id: &str) -> Option<&'static str> {+ Some(match id {+ "vignette.amount" | "grain.amount" | "detail.sharpenAmount" | "optics.defringePurple" | "optics.defringeGreen" => "Amount",+ "detail.nrLuminance" => "Luminance",+ "detail.nrColor" => "Color",+ "optics.profileDistortion" => "Distortion",+ "optics.profileVignetting" => "Vignetting",+ "optics.defringePurpleHueLo" | "optics.defringeGreenHueLo" => "Hue Low",+ "optics.defringePurpleHueHi" | "optics.defringeGreenHueHi" => "Hue High",+ "optics.caRed" => "Red/Cyan",+ "optics.caBlue" => "Blue/Yellow",+ "calibration.shadowsTint" => "Tint",+ "calibration.redHue" | "calibration.greenHue" | "calibration.blueHue" => "Hue",+ "calibration.redSat" | "calibration.greenSat" | "calibration.blueSat" => "Saturation",+ "hdr.sdrBrightness" => "Brightness",+ "hdr.sdrContrast" => "Contrast",+ "hdr.sdrHighlights" => "Highlights",+ "hdr.sdrShadows" => "Shadows",+ "hdr.sdrWhites" => "Whites",+ "hdr.sdrClarity" => "Clarity",+ _ => return None,+ })+}+ /// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift. pub(super) const REL_TEMP: ControlSpec = ControlSpec { id: "wb.tempRel",@@ -38,6 +67,7 @@ pub(super) const REL_TEMP: ControlSpec = ControlSpec { step: 1.0, decimals: 0, track: Track::Temp,+ shade: lightcraft_develop::Shade::Plain, }; pub(super) const REL_TINT: ControlSpec = ControlSpec {@@ -50,6 +80,7 @@ pub(super) const REL_TINT: ControlSpec = ControlSpec { step: 1.0, decimals: 0, track: Track::Tint,+ shade: lightcraft_develop::Shade::Plain, }; pub(super) fn k_to_rel(k: f64) -> f64 {@@ -169,8 +200,16 @@ fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSett pub fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx());- let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());- ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);+ let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover());+ let left = r.left() + crate::widgets::PAD_L;+ let right = r.right() - crate::widgets::PAD_R;+ let g = crate::widgets::one_line(ui.painter(), crate::i18n::tr(title), t.semibold(11.0), t.text_dim, (right - left).max(1.0));+ let y = r.center().y + 2.0;+ let text_right = left + g.size().x;+ ui.painter().galley(pos2(left, y - g.size().y / 2.0), g, t.text_dim);+ if right > text_right + 12.0 {+ ui.painter().hline((text_right + 6.0)..=right, y.round() + 0.5, egui::Stroke::new(1.0, t.divider.gamma_multiply(0.75)));+ } } pub(super) fn section(@@ -218,7 +257,7 @@ pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSet if !d.hdr.enabled { return; }- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { let mut v = app.ui.hdr_visualize; let r = ui.checkbox(&mut v, "Visualize HDR"); register(ui, "check:hdrVisualize", r.rect);@@ -233,7 +272,7 @@ pub(super) fn hdr_row(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSet /// photo from its own value. pub(super) fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { let t = Tokens::get(ui.ctx());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(0, 4)).show(ui, |ui| { ui.label(egui::RichText::new(crate::i18n::tr_format!("Quick Develop · {n} photos", n = n)).color(t.text_label).size(12.5)); ui.add_space(4.0); let rows: [(&str, &str, f64, f64); 9] = [@@ -178,9 +178,9 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let picked = app.ui.keyword_list_selected.as_deref().is_some_and(|k| same(k, &r.path)); let inner = Rect::from_min_max(rect.min + vec2(16.0, 0.0), rect.max - vec2(16.0, 0.0)); if picked {- ui.painter().rect_filled(inner, 4.0, t.canvas);+ ui.painter().rect_filled(inner, crate::theme::RADIUS, t.canvas); } else if resp.hovered() {- ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));+ ui.painter().rect_filled(inner, crate::theme::RADIUS, t.hover.gamma_multiply(0.6)); } let x = inner.left() + 4.0 + r.depth as f32 * INDENT; let cy = rect.center().y;@@ -450,7 +450,7 @@ pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at let resp = ui.interact(r, id, Sense::click()).union(resp); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, lit, said)); if lit || resp.hovered() {- ui.painter().rect_filled(r, 4.0, if lit { t.pressed } else { t.hover });+ ui.painter().rect_filled(r, crate::theme::RADIUS, if lit { t.pressed } else { t.hover }); } let font = t.font(13.0); let measure = |s: &str, f: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), f.clone(), t.text).size().x;@@ -142,10 +142,10 @@ pub(super) fn row_sensed( let edge = r.right().min(visible_right); let inner = Rect::from_min_max(r.min + vec2(8.0, 0.0), pos2((r.right() - 8.0).min(edge - 8.0).max(r.left() + 8.0), r.bottom())); if selected {- ui.painter().rect_filled(inner, 4.0, t.canvas);+ ui.painter().rect_filled(inner, crate::theme::RADIUS, t.canvas); register(ui, format!("highlight:{id}"), inner); } else if resp.hovered() {- ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));+ ui.painter().rect_filled(inner, crate::theme::RADIUS, t.hover.gamma_multiply(0.6)); } paint( ui.painter(),@@ -213,7 +213,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe let hovered = resp.hovered() && app.ui.dragging_section.is_none(); if hovered { let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0));- ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6));+ ui.painter().rect_filled(bar, crate::theme::RADIUS, t.hover.gamma_multiply(0.6)); } let c = pos2(r.left() + 10.0, r.center().y); register(ui, format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0)));@@ -147,7 +147,7 @@ fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String /// The Effect menu above the mask's sliders. pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) { let t = Tokens::get(ui.ctx());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(2, 4)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); let current = fx::matching_effect(&app.session, &mask.adjust);@@ -227,7 +227,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option } let lit = st.highlight == Some(i) || resp.hovered(); if lit {- ui.painter().rect_filled(rect, 3.0, t.hover);+ ui.painter().rect_filled(rect, crate::theme::RADIUS, t.hover); } if current == Some(name.as_str()) { ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent);@@ -548,9 +548,9 @@ pub fn manage_body( register(ui, format!("effectRow:{}", e.name), rect); } if sel {- ui.painter().rect_filled(rect, 3.0, t.tool_active);+ ui.painter().rect_filled(rect, crate::theme::RADIUS, t.tool_active); } else if r.hovered() {- ui.painter().rect_filled(rect, 3.0, t.hover);+ ui.painter().rect_filled(rect, crate::theme::RADIUS, t.hover); } let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0); let elided = g.elided;@@ -2,7 +2,7 @@ use egui::{Rect, Sense, Stroke, pos2, vec2}; use lightcraft_catalog::PhotoId;-use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};+use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Shade, Track}; use serde_json::json; use super::edit::apply_slider_out;@@ -13,21 +13,27 @@ use crate::theme::Tokens; use crate::widgets::{divider, icon_button, one_line, register, slider, text_button}; const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {- ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }+ ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track, shade: Shade::Plain }+}++/// `s` with the grey ramp of its Edit-panel twin (a test keeps each in step with `light.*` /+/// `effects.*`).+const fn shaded(s: ControlSpec, shade: Shade) -> ControlSpec {+ ControlSpec { shade, ..s } } /// Local adjustment sliders (key = `LocalAdjustments` field name). pub const LOCAL: &[ControlSpec] = &[ spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp), spec("tint", "Tint", -100.0, 100.0, 1.0, 0, Track::Tint),- spec("exposure", "Exposure", -4.0, 4.0, 0.01, 2, Track::Centered),- spec("contrast", "Contrast", -100.0, 100.0, 1.0, 0, Track::Centered),- spec("highlights", "Highlights", -100.0, 100.0, 1.0, 0, Track::Centered),- spec("shadows", "Shadows", -100.0, 100.0, 1.0, 0, Track::Centered),- spec("whites", "Whites", -100.0, 100.0, 1.0, 0, Track::Centered),- spec("blacks", "Blacks", -100.0, 100.0, 1.0, 0, Track::Centered),+ shaded(spec("exposure", "Exposure", -4.0, 4.0, 0.01, 2, Track::Centered), Shade::Ramp),+ shaded(spec("contrast", "Contrast", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::ReverseRamp),+ shaded(spec("highlights", "Highlights", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::Ramp),+ shaded(spec("shadows", "Shadows", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::Ramp),+ shaded(spec("whites", "Whites", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::Ramp),+ shaded(spec("blacks", "Blacks", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::Ramp), spec("texture", "Texture", -100.0, 100.0, 1.0, 0, Track::Centered),- spec("clarity", "Clarity", -100.0, 100.0, 1.0, 0, Track::Centered),+ shaded(spec("clarity", "Clarity", -100.0, 100.0, 1.0, 0, Track::Centered), Shade::ReverseRamp), spec("dehaze", "Dehaze", -100.0, 100.0, 1.0, 0, Track::Centered), spec("hue", "Hue", -100.0, 100.0, 1.0, 0, Track::Rainbow), spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),@@ -61,6 +67,8 @@ fn kind_label(s: &MaskShape) -> (&'static str, Icon) { const TILE: f32 = 52.0; const TILE_GAP: f32 = 6.0;+/// Height of a Create New Mask tile.+const TILE_H: f32 = 36.0; /// Room left and right of a tile's label. const TILE_PAD: f32 = 3.0; @@ -90,9 +98,9 @@ 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(); header(ui, "Masking");- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(0, 4)).show(ui, |ui| { ui.label(egui::RichText::new(crate::i18n::tr("Create New Mask")).color(t.text_dim));- ui.add_space(6.0);+ ui.add_space(3.0); let tiles: [(&str, &str, Icon); 10] = [ ("object", "Object", Icon::Subject), ("prompt", "Describe", Icon::Subject),@@ -116,16 +124,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (cols, tile, size) = tile_layout(ui.available_width(), widest); egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| { for (i, (kind, label, icon)) in tiles.iter().enumerate() {- let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click());+ let (r, resp) = ui.allocate_exact_size(vec2(tile, TILE_H), Sense::click()); crate::widgets::announce(&resp, crate::i18n::tr(label)); register(ui, format!("maskNew:{kind}"), r);- ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });- paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);+ ui.painter().rect_filled(r, crate::theme::RADIUS, if resp.hovered() { t.hover } else { t.inset });+ paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 5.0), vec2(14.0, 14.0)), *icon, t.text_label); // (cut short only in a panel too narrow for the label: the full name on hover) let label = crate::i18n::tr(label); let g = one_line(ui.painter(), label, t.font(size), t.text_dim, tile - 2.0 * TILE_PAD); let elided = g.elided;- let at = pos2(r.center().x, r.bottom() - 9.0) - g.size() / 2.0;+ let at = pos2(r.center().x, r.bottom() - 7.0) - g.size() / 2.0; register(ui, format!("maskNewLabel:{kind}"), Rect::from_min_size(at, g.size())); ui.painter().galley(at, g, t.text_dim); let resp = if elided { resp.on_hover_text(label) } else { resp };@@ -305,7 +313,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(mid) = app.session.active_mask else { return }; let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return }; divider(ui);- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 8 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, |ui| { for (i, c) in m.components.iter().enumerate() { let (kind, icon) = kind_label(&c.shape); let label = c.name.clone().unwrap_or_else(|| match &c.shape {@@ -382,12 +390,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui);+ // the mask's edges: a setting of its shape, not one of its effects, so above them+ let refine = ControlSpec {+ id: "refine",+ label: "Refine Edges",+ section: Section::Light,+ min: 0.0,+ max: 100.0,+ default: 0.0,+ step: 1.0,+ decimals: 0,+ track: Track::Plain,+ shade: Shade::Plain,+ };+ let out = slider(ui, &refine, m.refine, true, None);+ apply_slider_out(app, &refine, out, |app, v| app.run("mask.refine", json!({"value": v})));+ divider(ui); super::mask_effects::effect_row(app, ui, &m);- for s in LOCAL {- let v = local_get(&m.adjust, s.id);- let out = slider(ui, s, v, true, None);- apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}})));- }+ // how strongly the mask's adjustments apply, above them let amt = ControlSpec { id: "amount", label: "Amount",@@ -398,26 +418,50 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { step: 1.0, decimals: 0, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &amt, m.adjust.amount, true, None); apply_slider_out(app, &amt, out, |app, v| app.run("mask.adjust", json!({"values": {"amount": v}})));- let refine = ControlSpec {- id: "refine",- label: "Refine Edges",- section: Section::Light,- min: 0.0,- max: 100.0,- default: 0.0,- step: 1.0,- decimals: 0,- track: Track::Plain,- };- let out = slider(ui, &refine, m.refine, true, None);- apply_slider_out(app, &refine, out, |app, v| app.run("mask.refine", json!({"value": v})));+ ui.add_space(2.0);+ // the effect sliders in sections, as the Edit panel's: Tone, Color, Effects, Details+ for (section, title, keys) in MASK_GROUPS {+ divider(ui);+ if !mask_section(app, ui, section, title) {+ continue;+ }+ for key in *keys {+ let Some(s) = LOCAL.iter().find(|s| s.id == *key) else { continue };+ let v = local_get(&m.adjust, s.id);+ let out = slider(ui, s, v, true, None);+ apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}})));+ }+ ui.add_space(3.0);+ }+ ui.add_space(30.0); let _ = Stroke::NONE; } +/// The mask's effect sliders in sections, in Lightroom Classic's order: section id (folded state+/// in [`crate::state::UiState::folded_sections`]), title, and the `LOCAL` keys in it. Every+/// `LOCAL` key is in one.+pub(crate) const MASK_GROUPS: &[(&str, &str, &[&str])] = &[+ ("maskTone", "Tone", &["exposure", "contrast", "highlights", "shadows", "whites", "blacks"]),+ ("maskColor", "Color", &["temp", "tint", "hue", "saturation"]),+ ("maskEffects", "Effects", &["texture", "clarity", "dehaze"]),+ ("maskDetails", "Details", &["sharpness", "noise", "moire", "defringe"]),+];++/// A mask section's header (the Edit panel's), folding it on a click. True while it is open.+fn mask_section(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> bool {+ let open = !app.ui.section_folded(id);+ let (resp, _) = crate::widgets::section_header(ui, id, title, open, None);+ if resp.clicked() {+ app.ui.toggle_folded(id);+ }+ open+}+ /// Translate fixed action labels, but never mutate or translate user-entered mask names. fn menu_label(source: &str) -> &str { crate::i18n::tr(source)@@ -495,6 +539,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { step: 1.0, decimals: 0, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None); if let Some(v) = out.value {@@ -581,6 +626,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape step: 1.0, decimals: 0, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let cur = ((lo_feather + hi_feather) / 2.0 / 0.5 * 100.0).clamp(0.0, 100.0); let out = slider(ui, &smooth, cur, true, None);@@ -616,6 +662,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape step: 1.0, decimals: 0, track: Track::Centered,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &spec, *edge, true, None); let base = shape.clone();@@ -653,6 +700,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape step: 1.0, decimals: 0, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &spec, *refine, true, None); let samples = samples.clone();@@ -875,7 +923,7 @@ fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx());- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| {+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 0)).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Brush")).font(t.semibold(13.0))); if text_button(ui, "brushAdd", crate::i18n::tr("Add"), !app.ui.brush_erase).clicked() {@@ -907,7 +955,18 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("ui.brushFeather", "Feather", 0.0, 100.0, app.ui.brush_feather as f64), ("ui.brushFlow", "Flow", 1.0, 100.0, app.ui.brush_flow as f64), ] {- let s = ControlSpec { id, label, section: Section::Light, min, max, default: min, step: 1.0, decimals: 0, track: Track::Plain };+ let s = ControlSpec {+ id,+ label,+ section: Section::Light,+ min,+ max,+ default: min,+ step: 1.0,+ decimals: 0,+ track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain,+ }; let out = slider(ui, &s, get, true, None); if let Some(v) = out.value { match id {@@ -136,6 +136,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { step: 1.0, decimals: 0, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &spec, last.amount, true, None); if let Some(v) = out.value@@ -73,7 +73,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { None => d.remove::<f32>(egui::Id::unique(OFFSET)), }); let scrolled = area.show(ui, |ui| {- ui.spacing_mut().item_spacing.y = 0.0;+ compact_style(ui); match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), RightPanel::Profiles => super::profiles::show(app, ui, id),@@ -92,6 +92,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// The panel's text, checkboxes and radio buttons at the dense rows' size: 12 pt text, 16 pt+/// rows, small boxes, little space between rows.+pub(crate) fn compact_style(ui: &mut egui::Ui) {+ let style = ui.style_mut();+ style.spacing.item_spacing.y = 0.0;+ style.spacing.interact_size.y = 16.0;+ style.spacing.icon_width = 12.0;+ style.spacing.icon_width_inner = 6.0;+ style.spacing.icon_spacing = 5.0;+ style.spacing.button_padding = egui::vec2(5.0, 1.0);+ for (text, size) in [(egui::TextStyle::Body, 12.0), (egui::TextStyle::Button, 12.0)] {+ if let Some(font) = style.text_styles.get_mut(&text) {+ font.size = size;+ }+ }+}+ /// The panel's scroll area, its offset as the last pass started, and how far /// [`hold_list_in_place`] asks it to move. const AREA: &str = "right-scroll-id";@@ -112,14 +129,14 @@ fn held_offset(ctx: &egui::Context) -> Option<f32> { pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx());- let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());- ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text);+ let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), t.section_h), Sense::hover());+ ui.painter().text(pos2(r.left() + crate::widgets::PAD_L, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(12.5), t.text); } /// A panel title that folds its section shut and open again (`section:{id}`; kept in /// [`crate::state::UiState::folded_sections`]). Says whether the section is open. pub fn fold_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> bool {- let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());+ let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), Tokens::get(ui.ctx()).section_h), Sense::click()); let title = crate::i18n::tr(title); fold_title(app, ui, id, r, &resp, title) }@@ -131,14 +148,12 @@ pub fn fold_title(app: &mut LightcraftApp, ui: &egui::Ui, id: &str, r: Rect, res crate::widgets::announce_disclosure(resp, true, open, title); register(ui, format!("section:{id}"), r); let p = ui.painter();- if resp.hovered() {- p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));- }+ crate::widgets::paint_section_band(p, r, &t, resp.hovered()); if resp.has_focus() {- p.rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);+ p.rect_stroke(r.shrink(2.0), crate::theme::RADIUS, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); }- p.text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);- let chevron = Rect::from_center_size(pos2(r.right() - 30.0, r.center().y + 2.0), vec2(14.0, 14.0));+ p.text(pos2(r.left() + crate::widgets::PAD_L, r.center().y), Align2::LEFT_CENTER, title, t.semibold(crate::widgets::SECTION_TITLE), t.text);+ let chevron = Rect::from_center_size(pos2(r.right() - crate::widgets::PAD_R - 8.0, r.center().y), vec2(12.0, 12.0)); paint(p, chevron, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); if resp.clicked() { app.ui.toggle_folded(id);@@ -154,7 +169,7 @@ pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { } fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {- egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add);+ egui::Frame::NONE.inner_margin(crate::widgets::row_margin(4, 4)).show(ui, add); } #[cfg(test)]@@ -331,6 +331,7 @@ pub(super) fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let sliders = [ (plain("ui.removeSize", "Size", 1.0, 250.0, 20.0), "size", (app.ui.remove_size * 1000.0) as f64),@@ -380,6 +381,7 @@ pub(super) fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { step: 1.0, decimals: 0, track: lightcraft_develop::Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; let out = slider(ui, &spec, app.ui.spots_threshold as f64, app.ui.visualize_spots, None); if let Some(v) = out.value {@@ -34,7 +34,7 @@ pub(super) fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) 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));+ ui.painter().rect_filled(r, crate::theme::RADIUS, 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));@@ -145,9 +145,9 @@ pub(super) fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) 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);+ ui.painter().rect_filled(r, crate::theme::RADIUS, t.pressed); } else if resp.hovered() {- ui.painter().rect_filled(r, 3.0, t.hover);+ ui.painter().rect_filled(r, crate::theme::RADIUS, 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);@@ -228,7 +228,7 @@ pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { 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| {+ egui::Frame::NONE.fill(t.canvas).corner_radius(crate::theme::RADIUS_PANEL).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() {@@ -240,7 +240,7 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { 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| {+ egui::Frame::NONE.fill(t.chrome).corner_radius(crate::theme::RADIUS).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)); }); });@@ -68,6 +68,7 @@ mod rename_person; mod rules_editor; mod sam_setup; mod scroll;+mod slider_columns; mod slider_reset; mod slideshow; mod solo_sections;@@ -94,12 +94,14 @@ fn export_dialog_is_unchanged_in_a_tall_window() { assert_fits(&h, "Export at 1600×1000"); let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away, plus the Export to row- // since #956; 615 wide since the format row gained JPEG XL, #780. On macOS the dialog has one- // more row, "Add to Apple Photos" (#236), so it is 30 taller there. 8 more since its footer's- // buttons are the button component's medium size, 30 points, as the welcome's and every dialog's)- let height = if cfg!(target_os = "macos") { 916.0 } else { 886.0 };+ // since #956; 615 wide since the format row gained JPEG XL, #780, 566 since the format buttons+ // are the panels' smaller buttons. On macOS the dialog has one more row, "Add to Apple Photos"+ // (#236), so it is 30 taller there. 8 more since its footer's buttons are the button+ // component's medium size, 30 points, as the welcome's and every dialog's; 100 less since its+ // four sliders are one-line rows, 25 points shorter each)+ let height = if cfg!(target_os = "macos") { 816.0 } else { 786.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}");- assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}");+ assert!((dialog.width() - 566.0).abs() <= 1.0, "{dialog:?}"); } // Scenario: other long dialogs fit a short window too, with their buttons on screen@@ -135,8 +135,16 @@ fn a_click_outside_an_open_dropdown_keeps_the_dialog() { let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); h.app.ui.settings.close_dialogs_outside = true; h.app.run("dialog.export", json!({})).unwrap();- h.step();- h.step();+ // (the dialog sizes itself to its rows over its first frames: click once it stands still)+ let mut last = None;+ for _ in 0..10 {+ h.step();+ let now = h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r);+ if now.is_some() && now == last {+ break;+ }+ last = now;+ } let r = h.request("ui.clickWidget", json!({"id": "combo:exportPreset"}), T); assert_eq!(r["ok"], true, "{r}"); h.step();@@ -27,14 +27,13 @@ fn the_gaps_between_dock_panes_show_chrome_not_the_dark_clear_colour() { let mut h = app(); let image = h.snapshot(std::time::Duration::from_secs(5)); let pixel = |p: egui::Pos2| image.pixels[p.y as usize * image.size[0] + p.x as usize];- // each panel's own chrome just inside its edge stroke, and the dock's gap just outside it+ // the dock's gap just outside each panel's edge stroke let right = rect(&h, "panel:right_panel"); let left = rect(&h, "panel:left_panel");- for (panel, inside, gap) in [- ("Tools", egui::pos2(right.left() + 3.0, right.center().y), egui::pos2(right.left() - 2.0, right.center().y)),- ("Library", egui::pos2(left.right() - 3.0, left.center().y), egui::pos2(left.right() + 2.0, left.center().y)),- ] {- assert_eq!(pixel(gap), pixel(inside), "the gap beside {panel} at {gap:?} is not chrome");+ // compared with the chrome token, not the pixel inside: a section header's band may sit there+ let chrome = crate::theme::Tokens::for_kind(crate::theme::current(&h.view.ctx).unwrap_or_default()).chrome;+ for (panel, gap) in [("Tools", egui::pos2(right.left() - 2.0, right.center().y)), ("Library", egui::pos2(left.right() + 2.0, left.center().y))] {+ assert_eq!(pixel(gap), chrome, "the gap beside {panel} at {gap:?} is not chrome"); } } fn release(h: &mut Headless, pos: egui::Pos2) {@@ -143,14 +143,16 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { // Changing the shape needs the overlay; changing the image needs an unobscured preview. exec(&mut h, "mask.refine", json!({"value": 10.0})); assert!(h.app.ui.mask_overlay);- // The dock header pushes this adjustment below the initial viewport. Exercise the- // actual visible slider rather than sending a synthetic click outside its clipped body.+ // The dock header can push this adjustment below the initial viewport (the dense rows may+ // already show it). Exercise the actual visible slider rather than sending a synthetic click+ // outside its clipped body. let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1;- assert!(!panel.contains_rect(slider), "exposure initially needs scrolling: {slider:?} in {panel:?}");- h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), T);- h.request("ui.scroll", json!({"dy": -160.0}), T);- h.settle(SETTLE);+ if !panel.contains_rect(slider) {+ h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), T);+ h.request("ui.scroll", json!({"dy": -160.0}), T);+ h.settle(SETTLE);+ } let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1; assert!(panel.contains_rect(slider), "exposure is visible before clicking: {slider:?} in {panel:?}"); let r = h.request("ui.clickWidget", json!({"id": "slider:exposure", "fx": 0.7}), T);@@ -1909,3 +1911,40 @@ fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0)); assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift"); }++/// The mask's sliders sit in sections like the Edit panel's, in Lightroom Classic's order: Tone+/// (Exposure … Blacks), Color (Temp, Tint, Hue, Saturation), Effects (Texture, Clarity, Dehaze),+/// Details (Sharpness … Defringe); Refine Edges above the effect (a setting of the mask's shape)+/// and Amount above the sections. Every mask slider is in one section, and a folded section hides+/// its rows.+#[test]+fn the_mask_sliders_sit_in_sections() {+ let mut h = detail("panel.masking");+ exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5]}));+ h.request("ui.resize", json!({"width": 1400, "height": 2400}), T);+ h.settle(SETTLE);+ let at = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} drawn"));+ assert!(at(&h, "slider:refine").bottom() <= at(&h, "slider:amount").top(), "Refine Edges above the effect's sliders");+ let mut last = at(&h, "slider:amount").bottom() - 1.0;+ let mut seen = Vec::new();+ for (section, _, keys) in crate::panels::masking::MASK_GROUPS {+ let head = at(&h, &format!("section:{section}"));+ assert!(head.top() >= last, "{section} in order");+ last = head.bottom() - 1.0;+ for key in *keys {+ let row = at(&h, &format!("slider:{key}"));+ assert!(row.top() >= last, "{key} under {section}");+ last = row.bottom() - 1.0;+ seen.push(*key);+ }+ }+ let mut all: Vec<_> = crate::panels::masking::LOCAL.iter().map(|s| s.id).collect();+ all.sort_unstable();+ seen.sort_unstable();+ assert_eq!(seen, all, "every mask slider in exactly one section");+ // folding Tone hides its sliders, and the others stay+ click(&mut h, "section:maskTone");+ h.settle(SETTLE);+ assert!(!h.app.widgets.iter().any(|(w, _)| w == "slider:exposure"));+ assert!(h.app.widgets.iter().any(|(w, _)| w == "slider:temp"));+}@@ -86,7 +86,10 @@ fn masking_long_names_and_describe_controls_stay_inside_the_panel() { let field = widgets.iter().find(|(id, _)| id == "maskDescribe").unwrap().1; let button = widgets.iter().find(|(id, _)| id == "button:maskDescribeGo").unwrap().1; assert!(field.right() <= button.left(), "the prompt must not overlap Select");- assert!(field.left() >= 24.0 && button.right() <= width - 22.0, "{locale:?} {width}: {field:?} {button:?}");+ assert!(+ field.left() >= crate::widgets::PAD_L && button.right() <= width - crate::widgets::PAD_R,+ "{locale:?} {width}: {field:?} {button:?}"+ ); } } }new file mode 100644@@ -0,0 +1,79 @@+//! Headless tests of the Edit panel's slider name columns: opening or closing a part of the panel+//! never moves or resizes the tracks of the sliders around it, and a few long names (Color+//! Grading's "Highlights Luminance") don't shorten every slider.++use std::time::Duration;++use serde_json::json;++use crate::headless::Headless;+use crate::{LightcraftApp, Services};++const T: Duration = Duration::from_secs(20);+const SETTLE: Duration = Duration::from_secs(120);++/// The demo library in Detail with the Edit panel at `width` and Light, Color and Effects open.+fn edit_panel(width: f32) -> Headless {+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });+ let mut h = Headless::new(app, [1600.0, 1000.0], 1.0);+ let id = h.app.session.visible_cloned()[0].0;+ assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true);+ assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true);+ h.app.ui.right_width = width;+ h.app.ui.open_sections = vec!["light".to_string(), "color".to_string(), "effects".to_string()];+ h.settle(SETTLE);+ h+}++fn track(h: &Headless, control: &str) -> egui::Rect {+ let id = format!("slider:{control}");+ h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))+}++fn label(h: &Headless, control: &str) -> egui::Rect {+ let id = format!("sliderLabel:{control}");+ h.app.widgets.iter().find(|(w, _)| *w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))+}++/// Draws a few passes so every name column has seen all of its rows.+fn redraw(h: &mut Headless) {+ for _ in 0..3 {+ h.settle(SETTLE);+ }+}++#[test]+fn opening_color_grading_leaves_the_other_tracks_where_they_are() {+ for width in [270.0, 318.0, 416.0] {+ let mut h = edit_panel(width);+ redraw(&mut h);+ let closed: Vec<egui::Rect> = ["wb.tempRel", "color.vibrance", "effects.texture", "light.exposure"].iter().map(|c| track(&h, c)).collect();+ h.app.ui.toggle_flyout("grading");+ redraw(&mut h);+ assert!(h.app.widgets.iter().any(|(w, _)| w == "slider:grading.blending"), "Color Grading is open");+ // Color Grading's own sliders don't sit in the column of its longest names either+ let lum = track(&h, "grading.highlights.lum");+ for (c, before) in ["wb.tempRel", "color.vibrance", "effects.texture", "light.exposure"].iter().zip(&closed) {+ let open = track(&h, c);+ // (x within the row: the name column's left edge is PAD_L in)+ let row_left = label(&h, c).left() - crate::widgets::PAD_L;+ eprintln!(+ "{c} at {width} pt: track starts at x {:.1} closed, {:.1} with Color Grading open",+ before.left() - row_left,+ open.left() - row_left+ );+ assert!(+ (open.left() - before.left()).abs() < 0.01 && (open.width() - before.width()).abs() < 0.01,+ "{c} at {width} pt: closed {before:?}, Color Grading open {open:?}"+ );+ // (the rows below Color Grading move down, as they should)+ }+ assert!(lum.width() > 0.0, "{lum:?}");+ h.app.ui.toggle_flyout("grading");+ redraw(&mut h);+ for (c, before) in ["wb.tempRel", "color.vibrance", "effects.texture", "light.exposure"].iter().zip(&closed) {+ let again = track(&h, c);+ assert!((again.left() - before.left()).abs() < 0.01 && (again.width() - before.width()).abs() < 0.01, "{c} after closing: {again:?}");+ }+ }+}@@ -41,7 +41,7 @@ fn widget(h: &Headless, id: &str) -> egui::Rect { h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) } -/// Where the slider's thumb (the hollow ring on the track's centre line) is drawn, from 0 to 1 along the track.+/// Where the slider's handle (the small dot on the track's centre line) is drawn, from 0 to 1 along the track. fn thumb(h: &Headless, control: &str) -> f32 { let track = widget(h, &format!("slider:{control}")); let rings: Vec<f32> = h@@ -49,7 +49,12 @@ fn thumb(h: &Headless, control: &str) -> f32 { .painted_shapes() .iter() .filter_map(|s| match s {- egui::epaint::Shape::Circle(c) if c.radius == 7.0 && (c.center.y - track.center().y).abs() < 0.5 => Some(c.center.x),+ egui::epaint::Shape::Circle(c)+ if (c.radius == crate::widgets::HANDLE_R || c.radius == crate::widgets::HANDLE_R_HOT)+ && (c.center.y - track.center().y).abs() < 0.5 =>+ {+ Some(c.center.x)+ } _ => None, }) .filter(|x| (track.left() - 0.5..=track.right() + 0.5).contains(x))@@ -34,6 +34,12 @@ pub fn label_toast_colors(label: lightcraft_catalog::ColorLabel) -> (Color32, Co (Color32::WHITE.lerp_to_gamma(label_color(label), 0.3), Color32::from_rgb(36, 24, 24)) } +/// Corner radius (points) of buttons, segmented controls, fields, hover and selection fills: kept+/// small so the panels read calm (half of the earlier 4).+pub const RADIUS: u8 = 2;+/// Corner radius of panels, cards, windows and menus (half of the earlier 6).+pub const RADIUS_PANEL: u8 = 3;+ #[derive(Clone, Copy, Debug)] pub struct Tokens { /// Top bar, side panels, bottom bar, tool strip.@@ -48,6 +54,9 @@ pub struct Tokens { /// cells and the canvas in every theme. pub skeleton: Color32, pub divider: Color32,+ /// The band behind a major section's header (Light, Color, Effects…): the strongest row in a+ /// panel, set apart from the chrome so a section's start reads at a glance.+ pub section_band: Color32, pub inset: Color32, pub field: Color32, pub field_border: Color32,@@ -130,6 +139,7 @@ impl Default for Tokens { cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d), skeleton: Color32::from_rgb(0x25, 0x25, 0x25), divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),+ section_band: Color32::from_rgb(0x3b, 0x3b, 0x3b), inset: Color32::from_rgb(0x23, 0x23, 0x23), field: Color32::from_rgb(0x22, 0x22, 0x22), field_border: Color32::from_rgb(0x3c, 0x3c, 0x3c),@@ -156,8 +166,8 @@ impl Default for Tokens { bottom_bar_h: 48.0, panel_w: 270.0, strip_w: 48.0,- slider_row_h: 45.0,- section_h: 57.0,+ slider_row_h: 20.0,+ section_h: 26.0, film_h: 142.0, } }@@ -178,6 +188,7 @@ impl Tokens { cell_selected: rgb(0x262626), skeleton: rgb(0x1d1d1d), divider: rgb(0x0e0e0e),+ section_band: rgb(0x2c2c2c), inset: rgb(0x181818), field: rgb(0x161616), field_border: rgb(0x333333),@@ -196,6 +207,7 @@ impl Tokens { cell_selected: rgb(0xb4b4b4), skeleton: rgb(0xa6a6a6), divider: rgb(0xc4c4c4),+ section_band: rgb(0xd0d0d0), inset: rgb(0xdcdcdc), field: rgb(0xf6f6f6), field_border: rgb(0xb8b8b8),@@ -227,6 +239,7 @@ impl Tokens { cell_selected: rgb(0xd2d2d2), skeleton: rgb(0xbcbcbc), divider: rgb(0xe0e0e0),+ section_band: rgb(0xe4e4e4), inset: rgb(0xf0f0f0), field: rgb(0xffffff), field_border: rgb(0xcccccc),@@ -424,8 +437,8 @@ pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { v.extreme_bg_color = t.field; v.faint_bg_color = t.inset; v.window_stroke = Stroke::new(1.0, t.button_border);- v.window_corner_radius = CornerRadius::same(6);- v.menu_corner_radius = CornerRadius::same(6);+ v.window_corner_radius = CornerRadius::same(RADIUS_PANEL);+ v.menu_corner_radius = CornerRadius::same(RADIUS_PANEL); v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label);@@ -435,7 +448,7 @@ pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { let w = &mut v.widgets; craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to( w,- CornerRadius::same(4),+ CornerRadius::same(RADIUS), Stroke::new(1.0, t.text_label), ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);@@ -3,7 +3,7 @@ //! ([`register`]) so the control channel can find and click it by name. use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};-use lightcraft_develop::{ControlSpec, Track};+use lightcraft_develop::{ControlSpec, Shade, Track}; use crate::icons::{Icon, paint}; use crate::theme::Tokens;@@ -218,7 +218,7 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool { let r = egui::Frame::NONE .fill(t.canvas) .stroke(Stroke::new(1.0, t.caution.gamma_multiply(0.45)))- .corner_radius(6.0)+ .corner_radius(crate::theme::RADIUS_PANEL) .inner_margin(egui::Margin { left: 10, right: 10, top: 8, bottom: 9 }) .show(ui, |ui| { ui.set_width(ui.available_width());@@ -283,28 +283,78 @@ fn track_stops(track: &Track) -> Option<Vec<Color32>> { }) } -fn paint_track(ui: &Ui, rect: Rect, track: &Track, t: &Tokens, thumb_x: f32, ring: f32) {+/// A slider's track: a thin line, a grey ramp where the spec has a [`Shade`], a colour gradient where colour is the meaning (Temp, Tint, Hue,+/// Saturation…), with a small tick at the middle of a centred one. Dimmed when disabled.+fn paint_track(ui: &Ui, rect: Rect, track: &Track, shade: Shade, t: &Tokens, enabled: bool) { let p = ui.painter(); let y = rect.center().y;- let h = 1.0;+ let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.45) };+ // one mesh with a colour per column of vertices: smooth, and no overlapping strips that would+ // stripe a dimmed (translucent) track+ let paint_ramp = |colour: &dyn Fn(f32) -> Color32| {+ let h = TRACK_COLOR_H / 2.0;+ let steps: u32 = 48;+ let mut mesh = egui::Mesh::default();+ for i in 0..=steps {+ let a = i as f32 / steps as f32;+ let x = rect.left() + rect.width() * a;+ let c = dim(colour(a));+ mesh.colored_vertex(pos2(x, y - h), c);+ mesh.colored_vertex(pos2(x, y + h), c);+ if i > 0 {+ let k = 2 * i;+ mesh.add_triangle(k - 2, k - 1, k);+ mesh.add_triangle(k - 1, k, k + 1);+ }+ }+ p.add(egui::Shape::mesh(mesh));+ }; if let Some(stops) = track_stops(track) { // gradient: segments between stops let n = stops.len().max(2) - 1;- let steps = 48;- for i in 0..steps {- let (a, b) = (i as f32 / steps as f32, (i + 1) as f32 / steps as f32);+ paint_ramp(&|a: f32| { let seg = (a * n as f32).floor() as usize; let local = a * n as f32 - seg as f32;- let c = lerp(stops[seg.min(n)], stops[(seg + 1).min(n)], local);- let x0 = rect.left() + rect.width() * a;- let x1 = rect.left() + rect.width() * b;- p.rect_filled(Rect::from_min_max(pos2(x0, y - h - 0.5), pos2(x1 + 0.5, y + h + 0.5)), 0.0, c);- }+ lerp(stops[seg.min(n)], stops[(seg + 1).min(n)], local)+ });+ } else if shade != Shade::Plain {+ paint_ramp(&|a: f32| shade_color(shade, t, a).unwrap_or(t.track)); } else {- p.rect_filled(Rect::from_min_max(pos2(rect.left(), y - h), pos2(rect.right(), y + h)), 1.0, t.track);+ let h = TRACK_H / 2.0;+ p.rect_filled(Rect::from_min_max(pos2(rect.left(), y - h), pos2(rect.right(), y + h)), 1.0, dim(t.track));+ }+ if matches!(track, Track::Centered) {+ // where the slider has no effect: lighter than a shaded track around it, so it shows on it+ let tick = if shade == Shade::Plain { t.track } else { t.text_label };+ p.rect_filled(Rect::from_center_size(pos2(rect.center().x, y), vec2(1.0, 6.0)), 0.0, dim(tick)); }- // gap around the hollow thumb- p.rect_filled(Rect::from_center_size(pos2(thumb_x, y), vec2(ring * 2.0 + 4.0, 6.0)), 0.0, t.chrome);+}++/// The colour of a grey track shaded `shade` at `x` (0 = left end, 1 = right end), or `None` for+/// a plain track. The ramp runs between the theme's track colour pulled toward its darkest and its+/// lightest colour, so it stays subtle in every theme (as Lightroom's ~92 → 133 grey).+pub(crate) fn shade_color(shade: Shade, t: &Tokens, x: f32) -> Option<Color32> {+ let x = if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.5 };+ let lum = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b());+ let (darkest, lightest) = if lum(t.chrome) < lum(t.text) { (t.chrome, t.text) } else { (t.text, t.chrome) };+ let dark = t.track.lerp_to_gamma(darkest, 0.4);+ let light = t.track.lerp_to_gamma(lightest, 0.3);+ Some(match shade {+ Shade::Plain => return None,+ Shade::Ramp => dark.lerp_to_gamma(light, x),+ Shade::ReverseRamp => light.lerp_to_gamma(dark, x),+ Shade::CentreLight => dark.lerp_to_gamma(light, 1.0 - (2.0 * x - 1.0).abs()),+ Shade::Tinted => dark.lerp_to_gamma(light.lerp_to_gamma(Color32::from_rgb(0xc8, 0x40, 0x40), 0.45), x),+ })+}++/// A slider's handle: a small dot on the track, a little larger while hovered or dragged, ringed+/// in the panel colour so it stands off a colour track.+fn paint_handle(ui: &Ui, at: egui::Pos2, hot: bool, color: Color32, t: &Tokens) {+ let r = if hot { HANDLE_R_HOT } else { HANDLE_R };+ let p = ui.painter();+ p.circle_filled(at, r + 1.0, t.chrome);+ p.circle_filled(at, r, color); } /// Result of a slider interaction this frame.@@ -337,24 +387,164 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { /// scrub rather than a click that types a value. const SCRUB_START: f32 = 3.0; -/// The radius of a slider's hollow ring thumb.-const THUMB_RING: f32 = 7.0;+/// The radius of a slider's handle, and while it is hovered or dragged.+pub(crate) const HANDLE_R: f32 = 4.5;+pub(crate) const HANDLE_R_HOT: f32 = 5.5;+/// A plain track's thickness, and a colour track's (a little thicker, so its colours read).+const TRACK_H: f32 = 2.0;+const TRACK_COLOR_H: f32 = 3.0;+/// Space between a slider row's name, track and value.+const ROW_GAP: f32 = 6.0;+/// Gap between a right-aligned name and its track: room for the handle sitting at the track's+/// left end, so a name never runs into it.+const NAME_GAP: f32 = ROW_GAP + HANDLE_R_HOT;+/// Left and right margins of the panels' rows (sliders, captions, buttons line up on them).+pub const PAD_L: f32 = 14.0;+pub const PAD_R: f32 = 12.0;+/// The panels' row margins, `top` and `bottom` apart, as an egui frame margin.+pub fn row_margin(top: i8, bottom: i8) -> egui::Margin {+ egui::Margin { left: PAD_L as i8, right: PAD_R as i8, top, bottom }+}+/// The size of a slider's name and value.+const ROW_FONT: f32 = 11.0;+/// The name column's narrowest, and the most of a row (inside its margins) it may take: a longer+/// name is cut short with … (shown whole on hover).+const NAME_MIN: f32 = 24.0;+const NAME_SHARE_MAX: f32 = 0.4;+/// Width of the value column at a row's right ("50000", "+0.17", "-100" at [`ROW_FONT`]).+const VALUE_W: f32 = 32.0;++/// Where the parts of a one-line slider row sit: the name in a column at the left (`name_w`+/// wide: the longest name of its group, see [`name_column`]), the value right-aligned at the+/// right, the track between them — as long as the names leave it. Each part is the row's full+/// height, so all of the row takes the pointer. A row too narrow for its parts still gives the+/// track a point of width (it is divided by).+#[derive(Debug, Clone, Copy, PartialEq)]+pub(crate) struct RowParts {+ pub label: Rect,+ pub track: Rect,+ pub value: Rect,+}++pub(crate) fn row_parts(row: Rect, name_w: f32) -> RowParts {+ // a row too narrow for everything gives up its margins first, then shrinks the name and value+ // columns, then the gaps, so every part stays inside it with a point of track to divide by+ let row_w = if row.width().is_finite() { row.width().max(0.0) } else { 0.0 };+ let pads = PAD_L + PAD_R;+ let kp = (row_w - 1.0).max(0.0).min(pads) / pads;+ let left = row.left() + PAD_L * kp;+ let right = (row.right() - PAD_R * kp).max(left);+ let w = right - left;+ let track_min = w.min(1.0);+ let name_w = if name_w.is_finite() { name_w } else { 0.0 };+ let mut label_w = name_w.max(NAME_MIN).min(w * NAME_SHARE_MAX);+ let mut value_w = VALUE_W.min(w * 0.25);+ let columns = (w - track_min).max(0.0);+ if label_w + value_w > columns {+ let k = columns / (label_w + value_w);+ label_w *= k;+ value_w *= k;+ }+ // room for the handle at the track's left end, as far as a very narrow row allows+ let name_gap = NAME_GAP.min(ROW_GAP.max(w * 0.05));+ let spare = (w - label_w - value_w - track_min).max(0.0);+ let kg = if name_gap + ROW_GAP > spare { spare / (name_gap + ROW_GAP) } else { 1.0 };+ let label = Rect::from_min_max(pos2(left, row.top()), pos2(left + label_w, row.bottom()));+ let value = Rect::from_min_max(pos2(right - value_w, row.top()), pos2(right, row.bottom()));+ let track_l = (label.right() + name_gap * kg).min(value.left());+ let track_r = (value.left() - ROW_GAP * kg).clamp(track_l, value.left());+ let track = Rect::from_min_max(pos2(track_l, row.top()), pos2(track_r, row.bottom()));+ RowParts { label, track, value }+}++/// Where a slider's name starts: right-aligned in its name column, so it ends [`NAME_GAP`] before+/// its own track however short it is (a name too wide for the column starts at its left edge).+pub(crate) fn name_left(label: Rect, text_w: f32) -> f32 {+ let w = if text_w.is_finite() { text_w.max(0.0) } else { 0.0 };+ (label.right() - w).max(label.left())+}++/// Where the current slider group is kept, per layer (a dialog's sliders aren't the panel's).+fn group_slot(layer: egui::LayerId) -> egui::Id {+ egui::Id::unique(("slider-group", layer))+}++/// Starts a group of sliders whose names share one column (a section: [`section_header`] calls+/// it). Sliders drawn outside any group this pass share a column with the others in their `Ui`.+pub(crate) fn begin_slider_group(ui: &Ui, id: &str) {+ let pass = ui.ctx().cumulative_pass_nr();+ ui.data_mut(|d| d.insert_temp(group_slot(ui.layer_id()), (egui::Id::unique(("slider-names", id)), pass)));+}++/// Draws `body` as a slider group of its own (a flyout such as Color Grading or the Color Mixer):+/// its names get their own column, so a few long ones ("Highlights Luminance") neither shorten the+/// tracks of the section around it nor move them as it opens and closes. The section's group+/// carries on after it.+pub(crate) fn slider_group<R>(ui: &mut Ui, id: &str, body: impl FnOnce(&mut Ui) -> R) -> R {+ let slot = group_slot(ui.layer_id());+ let outer = ui.data(|d| d.get_temp::<(egui::Id, u64)>(slot));+ begin_slider_group(ui, id);+ let out = body(ui);+ ui.data_mut(|d| {+ if let Some(g) = outer {+ d.insert_temp(slot, g);+ } else {+ d.remove::<(egui::Id, u64)>(slot);+ }+ });+ out+}++/// The width of the name column of the slider group being drawn: the longest name in it, as+/// measured over this pass and the one before (a group's rows below this one count once they have+/// been drawn), so a section's tracks start together just after its longest name.+fn name_column(ui: &Ui, name_w: f32) -> f32 {+ let pass = ui.ctx().cumulative_pass_nr();+ let group = match ui.data(|d| d.get_temp::<(egui::Id, u64)>(group_slot(ui.layer_id()))) {+ Some((g, p)) if p == pass => g,+ _ => ui.scope_id().with("slider-names"),+ };+ let name_w = if name_w.is_finite() { name_w.max(0.0) } else { 0.0 };+ let (seen, cur, prev) = ui.data(|d| d.get_temp::<(u64, f32, f32)>(group)).unwrap_or((pass, 0.0, 0.0));+ // a new pass: the last one's widest becomes the remembered one+ let (cur, prev) = if seen == pass { (cur, prev) } else { (0.0, cur) };+ let cur = cur.max(name_w);+ ui.data_mut(|d| d.insert_temp(group, (pass, cur, prev)));+ cur.max(prev)+} -/// A Lightroom slider row (label + value above a track with a hollow ring thumb).-/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment.-/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it,-/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth-/// as far).+/// The one slider of the app (Edit panel, mask panel, dialogs): ONE row of the panel's+/// `slider_row_h` — the name at the left, a thin track with a small handle, the value+/// right-aligned at the right. The whole row takes the pointer: click or drag the track to set the+/// value there, drag the name or the value sideways to move it from where it is (Shift: a tenth as+/// far), double-click the name or the handle to reset (hovering either says so), click the value+/// to type one (Return or clicking away applies it, Esc keeps the old one). Scroll sideways over+/// it, or the wheel after clicking it, to nudge it; ↑ / ↓ nudge a hovered or focused slider. A+/// value at its default is dim, a changed one bright. pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {+ slider_named(ui, spec, value, enabled, label_override, label_override)+}++/// [`slider`] showing the name `shown` (or the spec's) while screen readers announce `spoken` (or+/// the spec's): a short name under a caption ("Amount" under Grain) only makes sense to the eye,+/// so assistive tools get the control's full name ("Grain").+pub fn slider_named(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, shown: Option<&str>, spoken: Option<&str>) -> SliderOut {+ let label_override = shown; let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover());- let pad_l = 24.0;- let pad_r = 22.0;- let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));- let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));+ // the focused row's highlight goes under everything drawn in it+ let backdrop = ui.painter().add(egui::Shape::Noop);+ // (a hostile value is drawn at the nearest end, never at NaN)+ let value = if value.is_finite() { value } else { spec.default };+ let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string();+ let access_name = crate::i18n::tr(spoken.unwrap_or(spec.label)).to_string();+ let font = t.font(ROW_FONT);+ let name_w = ui.painter().layout_no_wrap(label_text.clone(), font.clone(), t.text).size().x.ceil();+ let RowParts { label: label_rect, track: track_rect, value: value_rect } = row_parts(row, name_column(ui, name_w));+ let track_w = track_rect.width().max(1.0); let id = ui.scope_id().with(spec.id);- let resp = ui.interact(track_rect.expand2(vec2(8.0, 2.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() });+ let resp = ui.interact(track_rect.expand2(vec2(HANDLE_R_HOT, 0.0)), id, if enabled { Sense::click_and_drag() } else { Sense::hover() }); // Keyboard focus survives leaving the row; vertical wheel edits need a fresh click or drag // in it (at most one slider is armed: `wheel_slider_key`). let wheel_id = id.with("wheel-rem");@@ -395,8 +585,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ wheel_armed = false; } // screen readers: a slider named after its control, with its value- let label_text = crate::i18n::tr(label_override.unwrap_or(spec.label)).to_string();- resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));+ resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, access_name.clone())); // and its range, step and the value as shown ("+0.50", "5600") let shown = shown_value(spec, value); ui.ctx().accesskit_node_builder(resp.id, |node| {@@ -409,19 +598,18 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ node.add_action(egui::accesskit::Action::Decrement); } });- // the name: double-click it to reset, as the thumb (a disabled slider's name is only text)- let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() });+ // the name: double-click it to reset, as the handle; drag it sideways to move the value from+ // where it is (a disabled slider's name is only text)+ let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text); let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp };- crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone()));+ crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = access_name.clone())); register(ui, format!("sliderLabel:{}", spec.id), label_rect); register(ui, format!("slider:{}", spec.id), track_rect);- // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it- // takes the click)- let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max);+ // the value: click it to type an exact one, drag it sideways to scrub let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });- crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it")));+ crate::widgets::announce(&value_resp, format!("{access_name}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui, format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp };@@ -431,11 +619,13 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead // of rounding back to the same step every frame. let scrub_id = id.with("scrub-pos");- let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None };+ // (the name scrubs as the value does)+ let scrubber = if label_resp.dragged() || label_resp.drag_stopped() { &label_resp } else { &value_resp };+ let mut scrub: Option<f64> = if scrubber.dragged() || scrubber.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; let mut scrub_started = false; if enabled && scrub.is_none()- && value_resp.dragged()+ && scrubber.dragged() && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) && (p.x - origin.x).abs() >= SCRUB_START {@@ -445,7 +635,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); // the travel so far counts, so the value doesn't lag the pointer by the start distance let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 };- scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0));+ scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_w) * fine).clamp(0.0, 1.0)); scrub_started = true; } if scrub.is_none() {@@ -468,11 +658,11 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } let mut out = SliderOut::default(); // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`)- let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width();- let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width()));- // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest- // of the track, which is clicked and dragged, gets no tooltip)- let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0;+ let to_x = |v: f64| track_rect.left() + (spec.position(v) as f32).clamp(0.0, 1.0) * track_rect.width();+ let from_x = |x: f32| spec.value_at(f64::from(((x - track_rect.left()) / track_w).clamp(0.0, 1.0)));+ // the handle: a click there doesn't move the value, and resting on it says how to reset (the+ // rest of the track, which is clicked and dragged, gets no tooltip)+ let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= HANDLE_R_HOT + 1.0; if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { resp.clone().on_hover_text(reset_hint.as_str()); }@@ -480,10 +670,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ if let Some(mut pos) = scrub { // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel // over the track's width (a tenth with Shift), on the control's steps- if !scrub_started && value_resp.dragged() {+ if !scrub_started && scrubber.dragged() { let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; let dx = ui.input(|i| i.pointer.delta().x);- pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0);+ pos = (pos + f64::from(dx / track_w) * fine).clamp(0.0, 1.0); } let step = spec.step.max(1e-9); let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max);@@ -492,7 +682,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ v = nv; } out.drag_started = scrub_started;- if value_resp.drag_stopped() {+ if scrubber.drag_stopped() { out.drag_stopped = true; ui.data_mut(|m| m.remove::<f64>(scrub_id)); } else {@@ -511,7 +701,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); // the old value is selected when the field takes the focus, so typing replaces it let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text)- .font(t.font(12.5))+ .font(t.font(ROW_FONT)) .horizontal_align(egui::Align::RIGHT) .width(value_rect.width()) .margin(egui::Margin::ZERO)@@ -556,7 +746,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let fine_id = id.with("fine-pos"); let dx = ui.input(|i| i.pointer.delta().x); let kept = ui.data(|m| m.get_temp::<f64>(fine_id)).filter(|&p| ((spec.value_at(p) / step).round() * step - value).abs() < step / 2.0);- let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_rect.width()) * 0.1).clamp(0.0, 1.0);+ let pos = (kept.unwrap_or_else(|| spec.position(value)) + f64::from(dx / track_w) * 0.1).clamp(0.0, 1.0); ui.data_mut(|m| m.insert_temp(fine_id, pos)); spec.value_at(pos) } else {@@ -661,29 +851,51 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } // paint let hovered = resp.hovered() || resp.dragged();- let text_c = if enabled { t.text_label } else { t.text_disabled };- let p = ui.painter();- p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c);+ let hot = enabled && (hovered || scrub.is_some() || label_resp.hovered() || value_resp.hovered());+ let focused = enabled && resp.has_focus();+ if focused {+ // the slider the arrow keys (and the console) move+ ui.painter().set(backdrop, egui::Shape::rect_filled(row, 2.0, t.hover.gamma_multiply(0.45)));+ }+ let name_c = if !enabled {+ t.text_disabled+ } else if hot || focused {+ t.text+ } else {+ t.text_label+ };+ // a name too long for its column is cut short (hovering it shows it whole, with the reset hint)+ let name = one_line(ui.painter(), &label_text, font.clone(), name_c, label_rect.width());+ let name_x = name_left(label_rect, name.size().x);+ ui.painter().galley(pos2(name_x, label_rect.center().y - name.size().y / 2.0), name, name_c); if typing.is_none() {- p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);+ // a value at its default is dim, a changed one bright: what was touched shows at a glance+ let changed = (v - spec.default).abs() > spec.step.max(1e-9) / 2.0;+ let value_c = if !enabled {+ t.text_disabled+ } else if changed || hot {+ t.text+ } else {+ t.text_dim+ };+ ui.painter().text(value_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), font, value_c); // the scrub pointer, as over Lightroom's values: a drag here changes the value if enabled && (value_resp.hovered() || scrub.is_some()) { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } }- let ring = THUMB_RING;- let tx = to_x(v);- paint_track(ui, track_rect, &spec.track, &t, tx, ring);- let p = ui.painter();- let ring_c = if !enabled {+ if enabled && label_resp.hovered() {+ ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);+ }+ paint_track(ui, track_rect, &spec.track, spec.shade, &t, enabled);+ let handle_c = if !enabled { t.text_disabled- } else if hovered {+ } else if hovered || scrub.is_some() { t.thumb_hover } else { t.thumb };- p.circle_filled(pos2(tx, track_rect.center().y), ring, t.chrome);- p.circle_stroke(pos2(tx, track_rect.center().y), ring, Stroke::new(2.0, ring_c));+ paint_handle(ui, pos2(to_x(v), track_rect.center().y), hovered || scrub.is_some(), handle_c, &t); if hovered { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); }@@ -719,21 +931,51 @@ pub(crate) fn announce_disclosure(resp: &egui::Response, enabled: bool, expanded resp.ctx.accesskit_node_builder(resp.id, |node| node.set_expanded(expanded)); } -/// Collapsible section header ("› Light"). Returns the response (click toggles).+/// Title size of a major section header (Light, Color…); sub-section rows use [`SUB_TITLE`].+pub(crate) const SECTION_TITLE: f32 = 13.0;+/// Title size of a sub-section row (Curve, Color Mixer…): smaller and regular weight, so it reads+/// as part of its section.+pub(crate) const SUB_TITLE: f32 = 12.0;++/// The fill of a major section header's band: the section-band token, a shade lighter (darker in+/// light themes) under the pointer.+pub(crate) fn section_fill(t: &Tokens, hovered: bool) -> Color32 {+ if hovered { t.section_band.lerp_to_gamma(t.text, 0.06) } else { t.section_band }+}++/// The fill of a sub-section row (Curve, Color Mixer…): none at rest, a light hover, so the rows+/// stay quieter than the section header above them.+pub(crate) fn flyout_fill(t: &Tokens, hovered: bool) -> Option<Color32> {+ hovered.then(|| t.chrome.lerp_to_gamma(t.hover, 0.6))+}++/// Paints a major section header's band over `r`: the band fill and a separator along its top.+pub(crate) fn paint_section_band(p: &egui::Painter, r: Rect, t: &Tokens, hovered: bool) {+ p.rect_filled(r, 0.0, section_fill(t, hovered));+ p.rect_filled(Rect::from_min_max(r.left_top(), pos2(r.right(), r.top() + 1.0)), 0.0, t.divider);+}++/// Collapsible section header ("› Light"): one thin bar of the theme's `section_h`, the chevron+/// and name at the left, the section's eye (show the photo without it) at the right. The same bar+/// heads the Edit panel's and the mask panel's sections, and would head a tab's (Tabs layout).+/// Returns the response (click toggles) and the eye's new state when it was clicked. pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: Option<bool>) -> (Response, Option<bool>) { let title = crate::i18n::tr(title); let t = Tokens::get(ui.ctx()); let w = ui.available_width();- let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());+ let (r, resp) = ui.allocate_exact_size(vec2(w, t.section_h), Sense::click()); announce_disclosure(&resp, true, open, title); register(ui, format!("section:{id}"), r);+ // the section's sliders line their names up in one column of their own+ begin_slider_group(ui, id); let p = ui.painter();- if ui.rect_contains_pointer(r) {- p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));+ paint_section_band(p, r, &t, ui.rect_contains_pointer(r));+ if resp.has_focus() {+ p.rect_stroke(r.shrink(1.0), crate::theme::RADIUS, Stroke::new(1.5, t.accent), StrokeKind::Inside); }- let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(16.0, 16.0));- paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);- p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);+ let chev = Rect::from_center_size(pos2(r.left() + PAD_L + 5.0, r.center().y), vec2(12.0, 12.0));+ paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text);+ p.text(pos2(r.left() + PAD_L + 16.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(SECTION_TITLE), t.text); let mut toggled = None; // shown while the pointer is anywhere on the header: `resp.hovered()` turns false as soon as the // pointer is over the eye itself (the eye is on top), which hid the eye before it could be@@ -741,7 +983,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O if let Some(on) = enabled && (ui.rect_contains_pointer(r) || !on) {- let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0));+ let eye = Rect::from_center_size(pos2(r.right() - PAD_R - 8.0, r.center().y), vec2(15.0, 15.0)); let er = ui.interact(eye, ui.scope_id().with(("eye", id)), Sense::click()); crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on); register(ui, format!("sectionEye:{id}"), eye);@@ -764,38 +1006,44 @@ pub fn divider(ui: &mut Ui) { ui.painter().rect_filled(r, 0.0, t.divider); } -/// A flyout row inside a section (Curve, Color Mixer, Color Grading…): inset dark box with icon tile.+/// A sub-section row inside a section (Curve, Color Mixer, Color Grading…): indented icon and a+/// regular-weight title, no box of its own, so it reads as part of the section above it. pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> Response { let title = crate::i18n::tr(title); let t = Tokens::get(ui.ctx()); let w = ui.available_width();- let (outer, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());- let r = Rect::from_min_max(pos2(outer.left() + 16.0, outer.top() + 4.0), pos2(outer.right() - 16.0, outer.bottom() - 4.0));+ let (outer, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());+ let r = Rect::from_min_max(pos2(outer.left() + PAD_L - 4.0, outer.top() + 2.0), pos2(outer.right() - PAD_R + 4.0, outer.bottom() - 2.0)); let resp = ui.interact(r, ui.scope_id().with(("flyout", id)), Sense::click()); announce_disclosure(&resp, true, open, title); register(ui, format!("flyout:{id}"), r); let p = ui.painter();- p.rect_filled(r, 4.0, if resp.hovered() { t.hover.gamma_multiply(0.8) } else { t.inset });- let tile = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0));- p.rect_filled(tile, 3.0, t.chrome.gamma_multiply(1.15));- paint(p, tile.shrink(3.0), icon, t.text_label);- p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);+ if let Some(fill) = flyout_fill(&t, resp.hovered()) {+ p.rect_filled(r, crate::theme::RADIUS, fill);+ }+ // indented under the section's title: a nested part of it, not a section of its own+ let tile = Rect::from_min_size(pos2(r.left() + 10.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0));+ paint(p, tile.shrink(1.0), icon, t.text_dim);+ p.text(pos2(tile.right() + 6.0, r.center().y), Align2::LEFT_CENTER, title, t.font(SUB_TITLE), t.text_label); paint( p,- Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(16.0, 16.0)),+ Rect::from_center_size(pos2(r.right() - 10.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::ChevronLeft }, t.text_label, ); resp } +/// Height of the panels' small buttons and segmented controls.+pub const BUTTON_H: f32 = 20.0;+ /// A small bordered text button (Auto, B&W, HDR…). pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response { let label = if id.starts_with("labelSet-") { label } else { crate::i18n::tr(label) }; let t = Tokens::get(ui.ctx());- let font = t.semibold(11.5);+ let font = t.semibold(11.0); let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text);- let size = vec2((galley.size().x + 18.0).max(37.0), 24.0);+ let size = vec2((galley.size().x + 14.0).max(34.0), BUTTON_H); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); register(ui, format!("button:{id}"), r);@@ -807,7 +1055,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response t.button }; let p = ui.painter();- p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);+ p.rect(r, CornerRadius::same(crate::theme::RADIUS), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); p.galley(r.center() - galley.size() / 2.0, galley, t.text); resp }@@ -827,7 +1075,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u for (j, (label, key)) in row.iter().enumerate() { let label = crate::i18n::tr(label); let i = row_i * per_row + j;- let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click());+ let (r, resp) = ui.allocate_exact_size(vec2(seg_w, BUTTON_H), Sense::click()); crate::widgets::announce_selected(&resp, label.to_string(), active == Some(i)); register(ui, format!("button:{id}-{key}"), r); let fill = if active == Some(i) {@@ -838,8 +1086,8 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u t.button }; let p = ui.painter();- p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);- p.text(r.center(), Align2::CENTER_CENTER, label, t.semibold(11.5), t.text);+ p.rect(r, CornerRadius::same(crate::theme::RADIUS), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);+ p.text(r.center(), Align2::CENTER_CENTER, label, t.semibold(11.0), t.text); if resp.clicked() { clicked = Some(i); }@@ -859,9 +1107,9 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: let p = ui.painter(); let sq = Rect::from_center_size(r.center(), vec2(32.0f32.min(size.x), 32.0f32.min(size.y))); if active {- p.rect_filled(sq, 4.0, t.tool_active);+ p.rect_filled(sq, crate::theme::RADIUS, t.tool_active); } else if resp.hovered() && enabled {- p.rect_filled(sq, 4.0, t.hover.gamma_multiply(0.7));+ p.rect_filled(sq, crate::theme::RADIUS, t.hover.gamma_multiply(0.7)); } let c = if !enabled { t.text_disabled@@ -927,6 +1175,7 @@ mod tests { step, decimals: 2, track: Track::Plain,+ shade: lightcraft_develop::Shade::Plain, }; assert!((nudged(&spec("light.exposure", -5.0, 5.0, 0.01), 0.0, 1.0) - 0.05).abs() < 1e-9); assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 10.0, -5.0), 5.0);@@ -1125,6 +1374,10 @@ pub mod text_field; #[path = "widgets/tests_slider_reset.rs"] mod slider_reset_tests; +#[cfg(test)]+#[path = "widgets/tests_dense_row.rs"]+mod dense_row_tests;+ fn focused_slider_key() -> egui::Id { egui::Id::unique("lightcraft-focused-slider") }@@ -297,7 +297,7 @@ impl<'a> AlbumPicker<'a> { let height = ui.spacing().interact_size.y; let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), height), egui::Sense::click()); if r.hovered() {- ui.painter().rect_filled(rect, 3.0, t.hover);+ ui.painter().rect_filled(rect, crate::theme::RADIUS, t.hover); } let x = rect.left() + indent; disclosure(@@ -183,7 +183,7 @@ impl Rating { let side = RatingSize::Large.icon(); for (i, r) in (0..STARS).zip(cells) { if hover_n == Some(i + 1) {- ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7));+ ui.painter().rect_filled(r, crate::theme::RADIUS, t.hover.gamma_multiply(0.7)); } let filled = star_lit(i, self.rating, hover_n); paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(side, side)), filled, if filled { t.star } else { t.icon });@@ -204,7 +204,7 @@ pub fn favorite_button(ui: &mut Ui, widget: String, favorite: bool, name: &str) register(ui, widget, r); } if resp.hovered() {- ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7));+ ui.painter().rect_filled(r, crate::theme::RADIUS, t.hover.gamma_multiply(0.7)); } if resp.has_focus() { ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);new file mode 100644@@ -0,0 +1,285 @@+//! The one-line slider row: its parts, its height, and real egui frames+//! for reset, typing, nudges and hostile values.+use super::{SliderOut, row_parts, slider, take_registry};+use crate::headless::HeadlessView;+use egui::{Event, Key, Modifiers, PointerButton, Rect, pos2, vec2};+use lightcraft_develop::{ControlSpec, controls};++struct Row {+ view: HeadlessView,+ spec: ControlSpec,+ value: f64,+ time: f64,+ width: f32,+}++impl Row {+ fn new(id: &str, value: f64) -> Self {+ let mut r = Row { view: HeadlessView::new(), spec: *controls::find(id).unwrap(), value, time: 0.0, width: 300.0 };+ for _ in 0..2 {+ r.frame(vec![]);+ }+ r+ }+ fn frame(&mut self, events: Vec<Event>) -> SliderOut {+ self.time += 0.02;+ let mut out = SliderOut::default();+ let (spec, value) = (self.spec, self.value);+ self.view.run(HeadlessView::raw_input(vec2(self.width, 100.0), 1.0, self.time, events), |ui| {+ out = slider(ui, &spec, value, true, None);+ });+ if let Some(v) = out.value {+ self.value = v;+ }+ out+ }+ fn rect(&self, part: &str) -> Rect {+ let name = format!("{part}:{}", self.spec.id);+ take_registry(&self.view.ctx).into_iter().find(|(id, _)| *id == name).map(|(_, r)| r).unwrap_or_else(|| panic!("no {name}"))+ }+ fn parts(&mut self) -> (Rect, Rect, Rect) {+ self.frame(vec![]);+ let registry = take_registry(&self.view.ctx);+ let find = |p: &str| registry.iter().find(|(id, _)| *id == format!("{p}:{}", self.spec.id)).unwrap().1;+ (find("sliderLabel"), find("slider"), find("sliderValue"))+ }+ fn click(&mut self, at: egui::Pos2, count: usize) -> Vec<SliderOut> {+ let mut outs = vec![self.frame(vec![Event::PointerMoved(at)])];+ for _ in 0..count {+ for pressed in [true, false] {+ outs.push(self.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]));+ }+ }+ outs.push(self.frame(vec![]));+ outs+ }+ fn key(&mut self, key: Key) -> SliderOut {+ self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }])+ }+}++#[test]+fn a_slider_is_one_row_of_the_dense_height() {+ let mut r = Row::new("light.exposure", 0.0);+ let (label, track, value) = r.parts();+ let h = crate::theme::Tokens::default().slider_row_h;+ assert!((18.0..=22.0).contains(&h), "row height {h}");+ for (name, part) in [("name", label), ("track", track), ("value", value)] {+ assert!((part.height() - h).abs() < 0.5, "{name} spans the row: {part:?}");+ assert!((part.top() - label.top()).abs() < 0.5, "{name} on the same line: {part:?}");+ }+ assert!(label.right() < track.left() && track.right() < value.left(), "name | track | value: {label:?} {track:?} {value:?}");+}++#[test]+fn row_parts_never_overlap_or_vanish_at_any_width() {+ for w in [0.0, 1.0, 10.0, 40.0, 120.0, 250.0, 270.0, 520.0, 4000.0] {+ for name in [0.0, 40.0, 1e9, f32::NAN, f32::INFINITY, -5.0] {+ let p = row_parts(Rect::from_min_size(pos2(0.0, 0.0), vec2(w, 20.0)), name);+ if w >= 1.0 {+ assert!(p.track.width() >= 1.0 - 1e-3, "{w}: a track to divide by: {p:?}");+ }+ assert!(p.label.right() <= p.track.left(), "{w}: {p:?}");+ // every part stays inside the row, and the track never runs into the value+ assert!(p.track.right() <= p.value.left(), "{w}: the track runs into the value: {p:?}");+ for r in [p.label, p.track, p.value] {+ assert!(r.left() >= -1e-3 && r.right() <= w + 1e-3, "{w}: outside the row: {p:?}");+ }+ for r in [p.label, p.track, p.value] {+ assert!(r.min.x.is_finite() && r.max.x.is_finite() && r.width() >= 0.0, "{w}: {p:?}");+ }+ }+ }+}++#[test]+fn double_clicking_the_name_resets() {+ let mut r = Row::new("light.contrast", 40.0);+ let (label, _, _) = r.parts();+ let outs = r.click(label.left_center() + vec2(6.0, 0.0), 2);+ assert!(outs.iter().any(|o| o.reset), "a reset");+ assert_eq!(r.value, 0.0);+}++#[test]+fn dragging_the_name_moves_the_value_from_where_it_is() {+ let mut r = Row::new("light.contrast", 10.0);+ let (label, track, _) = r.parts();+ let from = label.center();+ r.frame(vec![Event::PointerMoved(from)]);+ r.frame(vec![Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]);+ // a quarter of the track to the right: a quarter of the range (200) up, from 10, not a jump+ let to = from + vec2(track.width() / 4.0, 0.0);+ for i in 1..=10 {+ r.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))]);+ }+ r.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]);+ assert!((r.value - 60.0).abs() <= 3.0, "10 + 50: {}", r.value);+}++#[test]+fn typing_a_value_into_the_row_sets_it() {+ let mut r = Row::new("light.exposure", 0.0);+ let (_, _, value) = r.parts();+ r.click(value.center(), 1);+ r.frame(vec![Event::Text("1.5".into())]);+ let out = r.key(Key::Enter);+ assert_eq!(out.value, Some(1.5));+ assert_eq!(r.value, 1.5);+}++#[test]+fn arrow_keys_and_the_wheel_nudge_a_hovered_or_clicked_row() {+ let mut r = Row::new("light.contrast", 0.0);+ let (_, track, _) = r.parts();+ r.frame(vec![Event::PointerMoved(track.center())]);+ r.key(Key::ArrowUp);+ assert_eq!(r.value, 1.0, "↑ over the row");+ // a click arms the wheel; a notch down is a step down (the click itself set the value there)+ r.click(track.center(), 1);+ let before = r.value;+ r.frame(vec![Event::MouseWheel {+ unit: egui::MouseWheelUnit::Point,+ delta: vec2(0.0, -120.0),+ phase: egui::TouchPhase::Move,+ modifiers: Modifiers::NONE,+ source: egui::MouseWheelSource::Unknown,+ }]);+ for _ in 0..40 {+ r.frame(vec![]);+ }+ assert!(r.value < before, "the wheel nudged it down: {before} → {}", r.value);+}++#[test]+fn hostile_values_and_widths_never_panic() {+ for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300] {+ for w in [0.0f32, 3.0, 300.0] {+ let mut r = Row::new("wb.temp", 6500.0);+ r.width = w.max(1.0);+ r.value = v;+ let out = r.frame(vec![]);+ assert!(out.value.is_none_or(|x| x.is_finite()), "{v} at {w}: {out:?}");+ // a press and drag over whatever is there+ let at = pos2(w / 2.0, 10.0);+ r.frame(vec![Event::PointerMoved(at)]);+ r.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]);+ let out = r.frame(vec![Event::PointerMoved(at + vec2(30.0, 0.0))]);+ assert!(out.value.is_none_or(|x| x.is_finite()), "{v} at {w}: {out:?}");+ r.frame(vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]);+ }+ }+ // the registry is still there after all that+ let r = Row::new("light.exposure", 0.0);+ let _ = r.rect("slider");+}++/// A short name ends next to its own track, not at the left edge.+#[test]+fn names_are_right_aligned_next_to_their_track() {+ let label = Rect::from_min_max(pos2(14.0, 0.0), pos2(80.0, 20.0));+ assert_eq!(super::name_left(label, 30.0), 50.0);+ assert_eq!(super::name_left(label, 200.0), 14.0, "too wide: starts at the column's left edge");+ for w in [f32::NAN, f32::INFINITY, -5.0] {+ let x = super::name_left(label, w);+ assert!(x.is_finite() && x >= label.left() && x <= label.right(), "{w}: {x}");+ }+ // two rows sharing one name column: the short and the long name end at the same x+ let mut view = HeadlessView::new();+ let specs = [*controls::find("light.exposure").unwrap(), *controls::find("light.whites").unwrap()];+ for i in 0..4 {+ view.run(HeadlessView::raw_input(vec2(320.0, 100.0), 1.0, 0.02 * f64::from(i), vec![]), |ui| {+ for s in &specs {+ slider(ui, s, s.default, true, None);+ }+ });+ }+ let right_of = |text: &str| {+ view.painted_shapes()+ .into_iter()+ .find_map(|s| match s {+ egui::epaint::Shape::Text(t) if t.galley.job.text == text => Some(t.pos.x + t.galley.size().x),+ _ => None,+ })+ .unwrap_or_else(|| panic!("{text} not painted"))+ };+ let (long, short) = (right_of(specs[0].label), right_of(specs[1].label));+ assert!((long - short).abs() < 1.0, "names end together: {} at {long}, {} at {short}", specs[0].label, specs[1].label);+ let track = take_registry(&view.ctx).into_iter().find(|(id, _)| id == "slider:light.whites").unwrap().1;+ assert!(+ track.left() - short <= super::NAME_GAP + 1.0 && track.left() - short >= super::HANDLE_R_HOT,+ "the name ends a small gap before the track, clear of the handle: {short} vs {}",+ track.left()+ );+}++/// A major section's header band stands out from the panel more than a+/// sub-section row (Curve, Color Mixer…), which has no fill of its own at rest, in every theme.+#[test]+fn section_headers_stand_out_more_than_sub_sections() {+ use crate::theme::{ThemeKind, Tokens};+ let dist = |a: egui::Color32, b: egui::Color32| (0..3).map(|i| (i32::from(a[i]) - i32::from(b[i])).abs()).max().unwrap_or(0);+ for kind in ThemeKind::ALL {+ let t = Tokens::for_kind(kind);+ assert!(super::flyout_fill(&t, false).is_none(), "{kind:?}: a sub-section row has no box at rest");+ let band = dist(super::section_fill(&t, false), t.chrome);+ assert!(band >= 10, "{kind:?}: the section band is distinct from the chrome ({band})");+ let hover = super::flyout_fill(&t, true).map_or(0, |c| dist(c, t.chrome));+ assert!(band >= hover, "{kind:?}: even a hovered sub-section stays quieter ({hover} vs {band})");+ }+ const { assert!(super::SECTION_TITLE > super::SUB_TITLE) };+}++/// Corners are at most half of the earlier 4 (controls) and 6 (panels),+/// and egui's own widgets, windows and menus take them from the theme.+#[test]+fn corners_are_at_most_half_the_earlier_radius() {+ use crate::theme::{RADIUS, RADIUS_PANEL, ThemeKind, apply_kind};+ const { assert!(RADIUS <= 2 && RADIUS_PANEL <= 3) };+ for kind in ThemeKind::ALL {+ let ctx = egui::Context::default();+ apply_kind(&ctx, kind);+ let v = ctx.global_style().visuals.clone();+ for w in [v.widgets.inactive, v.widgets.hovered, v.widgets.active, v.widgets.noninteractive] {+ assert!(w.corner_radius.nw <= RADIUS, "{kind:?}: widget corner {:?}", w.corner_radius);+ }+ assert!(v.window_corner_radius.nw <= RADIUS_PANEL && v.menu_corner_radius.nw <= RADIUS_PANEL, "{kind:?}");+ }+}++/// Grey ramps inside the tracks, in every theme.+#[test]+fn shaded_tracks_run_the_way_their_spec_says() {+ use super::shade_color;+ use crate::theme::{ThemeKind, Tokens};+ use lightcraft_develop::Shade;+ let lum = |c: egui::Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b());+ for kind in ThemeKind::ALL {+ let t = Tokens::for_kind(kind);+ let at = |s: Shade, x: f32| lum(shade_color(s, &t, x).unwrap());+ // "lighter" follows the theme: in the light themes the lighter end is still the lighter grey+ assert!(at(Shade::Ramp, 1.0) > at(Shade::Ramp, 0.0) + 20, "{kind:?}: a ramp is lighter at the right");+ assert!(at(Shade::ReverseRamp, 0.0) > at(Shade::ReverseRamp, 1.0) + 20, "{kind:?}: a reversed ramp is lighter at the left");+ let c = at(Shade::CentreLight, 0.5);+ assert!(c > at(Shade::CentreLight, 0.0) + 20 && c > at(Shade::CentreLight, 1.0) + 20, "{kind:?}: a centre ramp is lightest at the centre");+ let red = shade_color(Shade::Tinted, &t, 1.0).unwrap();+ assert!(red.r() > red.g() + 20, "{kind:?}: the tinted end is red: {red:?}");+ assert!(shade_color(Shade::Plain, &t, 0.3).is_none(), "{kind:?}: a plain track is flat");+ for x in [f32::NAN, f32::INFINITY, -1.0, 2.0] {+ assert!(shade_color(Shade::Ramp, &t, x).is_some());+ }+ }+ // the kind comes from the develop spec, so mask sliders and new controls follow it+ let shade = |id: &str| lightcraft_develop::controls::find(id).unwrap().shade;+ assert_eq!(shade("light.exposure"), Shade::Ramp);+ assert_eq!(shade("light.contrast"), Shade::ReverseRamp);+ assert_eq!(shade("vignette.amount"), Shade::CentreLight);+ assert_eq!(shade("detail.sharpenAmount"), Shade::Tinted);+ assert_eq!(shade("effects.texture"), Shade::Plain);+ for s in crate::panels::masking::LOCAL {+ let twin = ["light", "effects"].iter().find_map(|g| lightcraft_develop::controls::find(&format!("{g}.{}", s.id)));+ if let Some(twin) = twin {+ assert_eq!(s.shade, twin.shade, "mask {} follows its Edit twin {}", s.id, twin.id);+ }+ }+}@@ -148,6 +148,22 @@ fn slider_value(h: &Harness<'_, LightcraftApp>, name: &str) -> Option<f64> { } /// A screen reader's Increment on a develop slider moves it one step, as an arrow key does.+/// A slider that shows a short name under its caption ("Amount" under Grain) is announced by its+/// full name, which still says what it changes without the caption.+#[test]+fn short_named_sliders_are_announced_by_their_full_name() {+ let mut h = harness(ViewMode::Detail, RightPanel::Edit);+ h.state_mut().ui.open_sections = vec!["effects".to_string(), "detail".to_string()];+ h.run_steps(10);+ let sliders: Vec<String> =+ h.root().children_recursive().map(|n| n.accesskit_node()).filter(|n| n.role() == Role::Slider).filter_map(|n| n.label()).collect();+ for full in ["Vignette", "Grain", "Sharpening"] {+ assert!(sliders.iter().any(|s| s == full), "a slider announced as {full}: {sliders:?}");+ }+ // (AI Denoise's own label is "Amount"; the short-named Vignette, Grain and Sharpening aren't)+ assert_eq!(sliders.iter().filter(|s| *s == "Amount").count(), 1, "only AI Denoise is announced as Amount: {sliders:?}");+}+ #[test] fn screen_reader_increment_moves_a_slider() { let mut h = harness(ViewMode::Detail, RightPanel::Edit);@@ -252,7 +252,7 @@ use control ids and ranges from `develop.controls` rather than translated labels | Read selection and active photo | `library.state {}` | `selection: {ids, active}`; `active` is `null` when no photo is active | | Read photo identity and settings together | `photo.inspect {}` | Active photo's `id`, `develop`, rating, flag and history in one reply | | Read a specific photo without selecting it | `photo.inspect {id}` / `develop.get {id}` | Photo details / full develop JSON; omitting `id` reads the active photo |-| Read global slider metadata and values | `develop.controls {section?}` | Array of `{id, label, section, min, max, default, step, decimals, track, value}` for the active photo |+| Read global slider metadata and values | `develop.controls {section?}` | Array of `{id, label, section, min, max, default, step, decimals, track, shade, value}` (`shade`: the grey ramp in the track: `Plain`, `Ramp`, `ReverseRamp`, `CentreLight` or `Tinted`) for the active photo | | Set absolute slider values | `develop.set {values: {"light.exposure": 0.7}}` | Undoable edit of the active photo; also accepts `{control, value}` | | Move a relative control | `develop.adjust {control: "light.exposure", delta: 0.1}` | Adds to the current value, clamped to the control's range | | Change photo | `library.select {ids: [photoId]}` / `library.next {}` / `library.previous {}` | Changes the active photo; next/previous follow the current view's order |@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-UI-TRACK-SHADE: grey slider tracks show which way they darken or lighten the photo; LR-UI-DENSE-ROWS: one-line 20 pt slider rows with a fitted, right-aligned name column, banded section headers, smaller corners; LR-MASK-PANEL: mask sliders in Tone / Color / Effects / Details sections, Refine Edges above the effect; earlier: LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |-| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |+| X. Cross-cutting behaviours (BEHAV) | 25 | 6 | 1 | 1 | 8/8 (100%) | 8/11 (73%) | | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) |-| **Total** | 421 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) |+| **Total** | 423 | 65 | 57 | 35 | 196/203 (97%) | 147/162 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 178.+Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.6%** of 545 in-scope rows — P0 98.3% of 203 · P1 94.4% of 162 · P2 57.0% of 179. <!-- /parity:summary --> ## Top gaps and shortcut notes@@ -289,7 +289,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---|-| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder |+| LR-MASK-PANEL | Masks panel | P0 | ✅ | `cmd:panel.masking`, `cmd:mask.add`, `cmd:mask.select`, `cmd:mask.rename`, `cmd:mask.duplicate` (`invert`), `cmd:mask.move`, `cmd:mask.visible`, `cmd:mask.delete`, `crates/ui-egui/src/panels/masking.rs` | list with per-mask show/hide eye, double-click rename, right-click menu (duplicate, duplicate and invert, invert, hide, move up/down, rename, delete); fits any panel width (create tiles go to three columns, mask actions wrap, long component names are cut short); no drag-to-reorder; the mask's sliders sit in folding Tone / Color / Effects / Details sections (`crates/ui-egui/src/panels/masking.rs`, `MASK_GROUPS`; test `the_mask_sliders_sit_in_sections`), with Refine Edges above the effect picker and Amount | | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) |@@ -475,6 +475,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests/panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests/panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with View ▸ Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests/panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |+| LR-UI-DENSE-ROWS | Dense one-line slider rows | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `row_parts`, `name_column`, `section_header`, `paint_section_band`), `crates/ui-egui/src/theme.rs` (`slider_row_h`, `section_h`, `RADIUS`, `RADIUS_PANEL`), `crates/ui-egui/src/panels/edit/sliders.rs` (`short_name`, `sub_title`), `crates/ui-egui/src/panels/right.rs` (`compact_style`), `crates/ui-egui/src/widgets/tests_dense_row.rs` | as in Lightroom Classic, each slider is one 20 pt row: the name right-aligned in a column fitted to the longest name of its section (at most 40 % of the row), a long track, then the value; a flyout (Color Grading, Color Mixer, Point Color, Curve, Defringe) fits its own column, so opening or closing it never moves the tracks around it (`slider_group`; `crates/ui-egui/src/tests/slider_columns.rs`); double-click the name to reset, drag the name to scrub, click the value to type, arrow keys and the wheel nudge a hovered or clicked row; major section headers sit on a band, sub-sections (Sharpening, Profile, Purple / Green fringe) are quieter captions with a hairline; corner radii are 2 / 3 pt; the Light..Detail sections stand half as tall as with two-line sliders (2068 → 1048 pt) and twice as many sliders are in view; the track shares its line with the name, so at the 270 pt default it is 52 % of the panel (Lightroom Classic ≈ 58 %). There is no Compact / Standard setting: the dense layout is the only one |+| LR-UI-TRACK-SHADE | Shaded slider tracks | P2 | ✅ | `crates/develop/src/controls.rs` (`Shade`, `ControlSpec.shade`), `crates/ui-egui/src/widgets.rs` (`paint_track`, `shade_color`), `crates/ui-egui/src/panels/masking.rs` (`LOCAL`), `cmd:develop.controls`, `docs/control-protocol.md`, `crates/ui-egui/src/widgets/tests_dense_row.rs` (`shaded_tracks_run_the_way_their_spec_says`) | as in Lightroom Classic, a grey track carries a subtle ramp that says which way the slider takes the photo: dark to light (Exposure, Highlights, Shadows, Whites, Blacks, Noise Reduction…), light to dark (Contrast, Clarity, Sharpening Masking), lightest at the centre (Vignette Amount, Roundness) or dark grey to red (Sharpening Amount); colour tracks keep their colours; the kind lives in the develop control table, so the mask panel's twins and `develop.controls` (`shade`) follow it; the ramp is drawn from the theme's own colours, so it stays subtle in every theme | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests/empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. Files dragged over the window dim it and a card says what dropping will do; a drop of photos or folders opens the same import review as File ▸ Import | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/widgets/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |@@ -799,6 +801,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | LR-UI-TRACK-SHADE (new, ✅): grey slider tracks are shaded by `ControlSpec.shade` (Plain, Ramp, ReverseRamp, CentreLight, Tinted); `develop.controls` reports it |+| 2026-10-27 | minor | LR-UI-DENSE-ROWS (new, ✅): one-line 20 pt slider rows with a fitted, right-aligned name column, banded section headers, quieter sub-sections, 2 / 3 pt corners; LR-MASK-PANEL: mask sliders in Tone / Color / Effects / Details sections, Refine Edges above the effect | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |