Compare commits

..
Author SHA1 Message Date
Claude 3517606ffc Merge remote-tracking branch 'origin/main' into claude/fix-sam-busy-after-reply 2026-10-08 19:19:27 +00:00
BFlat 9e51ca0da8 Merge pull request #354 from storytold/claude/fix-341-date-keyword-rows
Fix #341: By Date and Keywords rows show their photos from All Photos
2026-10-09 02:19:21 +07:00
Claude 85e38c7342 Merge remote-tracking branch 'origin/main' into claude/fix-341-date-keyword-rows
# Conflicts:
#	crates/ui-egui/src/tests_panels.rs
2026-10-08 19:19:15 +00:00
BFlat 4836143f9e Merge pull request #353 from storytold/claude/fix-316-section-eye
Fix #316: the section eye switches Light, Color and Detail off, and can be clicked
2026-10-09 02:19:02 +07:00
Claude 57dc811a6a Merge remote-tracking branch 'origin/main' into claude/fix-316-section-eye
# Conflicts:
#	crates/ui-egui/src/headless.rs
2026-10-08 19:18:55 +00:00
BFlat 9e327b4772 Merge pull request #352 from storytold/claude/fix-322-type-slider-values
Fix #322: type an exact value into a slider
2026-10-09 02:18:43 +07:00
Claude f6fcef4d5c Merge remote-tracking branch 'origin/main' into claude/fix-322-type-slider-values 2026-10-08 19:18:31 +00:00
BFlat 3895cf6411 Merge pull request #363: community PRs, batch 3 (German + Russian UI, Fujifilm compressed RAF, raw fixes)
Integrate community PRs, batch 3: German + Russian UI, Fujifilm compressed RAF, raw fixes (stacked on #350)
2026-10-09 02:18:21 +07:00
BFlat 5cb74e7313 Merge pull request #350: community PRs, batch 2 (14 PRs + follow-ups)
Integrate community PRs, batch 2 (14 PRs + follow-ups; stacked on #349)
2026-10-09 02:18:09 +07:00
BFlat 4165904bd1 Merge pull request #349: community fixes, batch 1 (18 PRs + follow-ups)
Integrate community fixes, batch 1 (18 PRs + follow-ups)
2026-10-09 02:17:58 +07:00
Claude 6ea9b979c8 AI masks: the worker stops counting a job as pending before it replies
The pending counter was decremented when the job's guard dropped at the
end of the loop iteration, after the reply was sent, so a waiting caller
(CLI / MCP mask.add) could see the segmenter still busy right after its
answer arrived. Under a loaded test run that made
tests_segment::waiting_requests_with_a_damaged_model_are_errors fail on
assert!(!busy()). The guard is now dropped just before each reply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 19:02:44 +00:00
Claude 8980c3bb0e Fix #341: clippy (no single-element loop in the test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:42:11 +00:00
Claude 1ebce934ea Fix #322: clippy (collapsible if)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:42:00 +00:00
Claude a7fcd81850 Fix #341: What's new
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:40:19 +00:00
Claude c3c30dae3f Fix #341: the test clicks the row's widget id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:39:36 +00:00
Claude 47cf1ade6f Fix #341: the test window is tall enough to show the By Date rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:38:10 +00:00
Claude 00388f3fd2 Fix #341: test from Recently Deleted (the demo has picks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:37:25 +00:00
Claude 1b4518c202 Fix #341: By Date and Keywords rows show their photos from All Photos
The rows count every photo in the library, but choosing one filtered
whatever source was open (an album, a folder, Picks), so it often showed
nothing. Choosing a row now switches to All Photos first; clearing it
leaves the source alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:36:25 +00:00
Claude aaa46003d5 Fix #316: the header keeps its hover shade while the pointer is on the eye
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:34:35 +00:00
Claude a9cd761d52 Fix #316: the section eye stays while the pointer is on it
The eye was drawn only while the header itself was hovered; with the
pointer on the eye, egui reports the eye (on top) as hovered and not the
header, so the eye vanished under the pointer and could not be clicked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:33:25 +00:00
Claude 68267810b3 Fix #316: headless test of the section eye
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:30:43 +00:00
Claude 9b4ba1aa80 Fix #316: the section eye switches off Light, Color and Detail too
The pipeline honoured a section's eye only where it applies Effects,
Optics, Geometry and Calibration, so turning the eye off on Light, Color
or Detail changed nothing. DevelopSettings::effective() resets the
switched-off sections to their defaults (Light with the tone curve; Color
with white balance, mixers, grading and point colour; Detail), and both the
CPU and GPU renderers render those. Borrowed, so free, when every section
is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:29:29 +00:00
Claude acc85c4147 Fix #322: the value field draws in a child UI and leaves the slider rows in place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:22:43 +00:00
Claude 0fbf20db51 Fix #322: select the old value once the field has the focus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:21:10 +00:00
Claude 2b66eb4800 Fix #322: the typed-value field takes the keyboard on the frames after the click
The click's own release took the focus straight back from a field created
in that frame, so typing went nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:19:31 +00:00
Claude 92c7237c50 Fix #322: type an exact value into a slider
Click the value next to a slider's name to edit it: the old value starts
selected, Return or clicking away applies the typed number on the
control's steps and range as one undo step (like a keyboard nudge), and
Esc or text that isn't a number keeps the old value. A decimal comma,
a leading + and a % or K unit are accepted. The keys typed go to the
field, not to the shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8
2026-10-08 18:05:19 +00:00
11 changed files with 261 additions and 10 deletions

No files matched your search

+26
View File
@@ -68,6 +68,32 @@ impl DevelopSettings {
!self.disabled_sections.iter().any(|s| s == section)
}
/// The settings as rendered: an Edit-panel section whose eye is off contributes nothing (issue
/// #316). Light (with its tone curve), Color (white balance, presence, the mixers, colour grading
/// and point colour) and Detail are reset to their defaults here; Effects, Optics, Geometry and
/// Calibration the pipeline switches off where it applies them. Borrowed when every section is on.
pub fn effective(&self) -> std::borrow::Cow<'_, DevelopSettings> {
let off = |section: &str| !self.section_enabled(section);
if !(off("light") || off("color") || off("detail")) {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
if off("light") {
d.reset_section(Section::Light);
d.reset_section(Section::Curve);
}
if off("color") {
for section in [Section::Color, Section::Mixer, Section::BwMix, Section::Grading, Section::PointColor] {
d.reset_section(section);
}
d.point_colors.clear();
}
if off("detail") {
d.reset_section(Section::Detail);
}
std::borrow::Cow::Owned(d)
}
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
self.disabled_sections.retain(|s| s != section);
if !on {
+5 -1
View File
@@ -210,8 +210,11 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
}
let Some(mut job) = queue.pop_front() else { continue };
let is_detail = matches!(job.kind, Kind::Detail { .. });
let _done = Done(if is_detail { &shared.detail } else { &shared.pending });
// counts the job as pending until just before its reply goes out: a caller woken by the
// reply must not still see the worker busy with it
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
if queue.iter().any(|later| supersedes(later, &job)) {
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
continue;
}
@@ -229,6 +232,7 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
state.cache = None;
}
shared.loaded.store(state.model.is_some(), Ordering::SeqCst);
drop(done);
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
}
}
+3
View File
@@ -294,6 +294,9 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
#[cfg(not(target_arch = "wasm32"))]
{
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
// the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU
if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() {
return None;
+3
View File
@@ -365,6 +365,9 @@ enum Src<'a> {
}
fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered {
// sections switched off with their eye render as if at their defaults (issue #316)
let effective = s.effective();
let s: &DevelopSettings = &effective;
// `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling.
let lap = |what: &str, t: &mut Option<std::time::Instant>| {
if let Some(t) = t {
+29
View File
@@ -237,6 +237,35 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
assert!(d <= 2, "max difference {d}");
}
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
/// and Detail, which the pipeline doesn't switch off where it applies them.
#[test]
fn sections_switched_off_render_as_defaults() {
let src = Rgb32f::from_fn(32, 24, |x, y| [0.05 + x as f32 / 40.0, 0.1 + y as f32 / 30.0, 0.3]);
let info = SourceInfo::default();
let req = RenderRequest::fit(32, 24);
let plain = render(&src, &info, &DevelopSettings::default(), &req).image;
let mut s = DevelopSettings::default();
s.light.exposure = 1.5;
s.light.contrast = 40.0;
s.color.saturation = -60.0;
s.color.vibrance = 30.0;
s.detail.sharpen_amount = 120.0;
let edited = render(&src, &info, &s, &req).image;
assert_ne!(edited.data, plain.data);
for section in ["light", "color", "detail"] {
s.set_section_enabled(section, false);
}
assert_eq!(render(&src, &info, &s, &req).image.data, plain.data, "every edited section off = the unedited photo");
// one section back on brings back only its own adjustments
s.set_section_enabled("light", true);
let mut light_only = DevelopSettings::default();
light_only.light.exposure = 1.5;
light_only.light.contrast = 40.0;
assert_eq!(render(&src, &info, &s, &req).image.data, render(&src, &info, &light_only, &req).image.data);
assert!(matches!(DevelopSettings::default().effective(), std::borrow::Cow::Borrowed(_)), "nothing is copied when every section is on");
}
#[test]
fn calibration_shifts_colours_but_keeps_greys() {
let info = SourceInfo { raw: true, ..Default::default() };
+49
View File
@@ -1053,6 +1053,55 @@ mod tests {
assert_ne!(h.app.session.active(), Some(first), "advanced");
}
/// Click a slider's value and type one (issue #322): Return sets it as one undo step, Esc
/// keeps the old value, and the keys typed don't reach the shortcuts (1 = one star).
#[test]
fn slider_values_can_be_typed() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
h.settle(SETTLE);
let active = h.app.session.active().unwrap();
let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure;
let rating = |h: &Headless| h.app.session.catalog.photo(active).unwrap().rating;
let (undo0, rating0) = (h.app.session.undo.len(), rating(&h));
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "1,5"}), t);
h.request("ui.key", json!({"key": "Enter"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step");
assert_eq!(rating(&h), rating0, "typing 1 set no rating");
// Esc keeps the value
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "-2"}), t);
h.request("ui.key", json!({"key": "Escape"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
// something that isn't a number changes nothing
assert_eq!(h.request("ui.clickWidget", json!({"id": "sliderValue:light.exposure"}), t)["ok"], true);
h.request("ui.text", json!({"text": "bright"}), t);
h.request("ui.key", json!({"key": "Enter"}), t);
assert!((exposure(&h) - 1.5).abs() < 1e-9, "{}", exposure(&h));
assert_eq!(h.app.session.undo.len(), undo0 + 1);
}
/// The eye on a section header switches the section off and on again, one undo step each
/// (issue #316).
#[test]
fn section_eye_switches_a_section_off() {
let mut h = demo([1300.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "edit"}), t);
h.settle(SETTLE);
let light_on = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().section_enabled("light");
for expected in [false, true] {
// the eye shows while the pointer is on the header
assert_eq!(h.request("ui.hoverWidget", json!({"id": "section:light"}), t)["ok"], true);
assert_eq!(h.request("ui.clickWidget", json!({"id": "sectionEye:light"}), t)["ok"], true);
assert_eq!(light_on(&h), expected);
}
assert!(h.app.ui.section_open("light"), "the click didn't fold the section");
}
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
#[test]
fn return_commits_the_crop_tool() {
+11
View File
@@ -631,11 +631,21 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str,
}
if resp.clicked() {
let v = if sel { serde_json::Value::Null } else { json!(key) };
browse_all_photos(app, !sel);
let _ = app.run("library.filter", json!({"date": v}));
}
open
}
/// By Date and Keywords count every photo in the library, so choosing a row shows those photos
/// from All Photos, not from whatever album or folder happened to be open, where they could be
/// missing (issue #341). Only when choosing (`on`), not when clearing the row again.
fn browse_all_photos(app: &mut LightcraftApp, on: bool) {
if on && app.session.source != LibrarySource::All {
let _ = app.run("library.source", json!({"kind": "all"}));
}
}
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option<AlbumId>, indent: f32) {
let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect();
kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase()));
@@ -994,6 +1004,7 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
if resp.clicked() {
let v = if sel { serde_json::Value::Null } else { json!(n.path) };
browse_all_photos(app, !sel);
let _ = app.run("library.filter", json!({"keyword": v}));
}
resp.context_menu(|ui| {
+26
View File
@@ -563,3 +563,29 @@ fn filmstrip_marks_every_selected_photo() {
}
assert!(checked >= 3, "checked {checked} cells");
}
/// By Date and Keywords count the whole library, so choosing a row shows its photos from All
/// Photos even when another source (here Recently Deleted, which is empty) was open (issue #341).
#[test]
fn date_and_keyword_rows_show_their_photos_from_any_source() {
// tall enough that the By Date rows are on screen below the other sections
let mut h = demo([1400.0, 2000.0], json!({"view": "photoGrid", "leftPanel": true}));
let first = h.app.session.visible_cloned()[0];
let year = h.app.session.catalog.photo(first).and_then(|p| p.captured.clone()).expect("demo photo date")[..4].to_string();
// (Keywords rows go through the same helper, `browse_all_photos`)
let (row, key) = (format!("source:date:{year}"), "date");
let r = h.request("engine.execute", json!({"command": "library.source", "params": {"kind": "recentlyDeleted"}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(h.app.session.visible().is_empty(), "nothing deleted in the demo");
let r = h.request("ui.clickWidget", json!({"id": row}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All, "{key}");
assert!(!h.app.session.visible().is_empty(), "{key}: its photos are shown");
// choosing the row again clears it, and stays in All Photos
let r = h.request("ui.clickWidget", json!({"id": row}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default(), "{key}");
}
+95 -8
View File
@@ -141,8 +141,26 @@ pub struct SliderOut {
pub reset: bool,
}
/// What a value typed into a slider asks for (issue #322): a number such as `0.5`, `+12`, `-1,5`
/// (comma or point) or `5600 K`, on the control's steps and clamped to its range. `None` when it
/// isn't a finite number, so the slider keeps its value.
pub fn typed_value(spec: &ControlSpec, text: &str) -> Option<f64> {
let t = text.trim().replace(',', ".");
let t = t.trim_end_matches(|c: char| c == '%' || c == 'K' || c == 'k' || c.is_whitespace());
let v: f64 = t.parse().ok().filter(|v: &f64| v.is_finite())?;
let step = spec.step.max(1e-9);
Some(((v / step).round() * step).clamp(spec.min, spec.max))
}
/// A slider's value as shown next to its label.
fn shown_value(spec: &ControlSpec, v: f64) -> String {
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
if shown == "+0" || shown == "-0" { "0".to_string() } else { shown }
}
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to
/// type one: Return or clicking away applies it, Esc keeps the old one.
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
@@ -158,12 +176,58 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
resp.widget_info(|| egui::WidgetInfo::slider(enabled, value, label_text.clone()));
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
// the value: click it to type an exact one (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);
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() });
register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect);
let typing_id = id.with("typing");
let field_id = id.with("typingField");
// the text being typed and how many frames the field has been up: it takes the keyboard on its
// first frames (not on the click's own, whose release would take the focus straight back)
let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id));
if enabled && typing.is_none() && value_resp.clicked() {
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0));
}
let mut out = SliderOut::default();
let span = (spec.max - spec.min).max(1e-9);
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
let mut v = value;
if resp.double_clicked() || label_resp.double_clicked() {
if let Some((mut text, frames)) = typing.take() {
let field = egui::TextEdit::singleline(&mut text)
.id(field_id)
.font(t.font(12.5))
.horizontal_align(egui::Align::RIGHT)
.desired_width(value_rect.width())
.margin(egui::Margin::ZERO);
// a child over the value, so the field never moves the rows around it
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center)));
let te = child.add(field);
if frames < 2 {
te.request_focus();
// the old value is selected, so typing replaces it (set again once the field has the
// focus, which places the cursor)
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), field_id).unwrap_or_default();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), field_id);
typing = Some((text.clone(), frames + 1));
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1)));
} else if !te.has_focus() || !enabled {
// Esc (or the slider turning off) keeps the old value; Return or clicking away applies
let cancelled = !enabled || ui.input(|i| i.key_pressed(egui::Key::Escape));
if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) {
out.value = Some(nv);
out.drag_started = true;
out.drag_stopped = true;
v = nv;
}
ui.data_mut(|m| m.remove::<(String, u8)>(typing_id));
} else {
typing = Some((text.clone(), frames));
ui.data_mut(|m| m.insert_temp(typing_id, (text, frames)));
}
} else if resp.double_clicked() || label_resp.double_clicked() {
out.reset = true;
out.value = Some(spec.default);
v = spec.default;
@@ -201,7 +265,7 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
out.drag_stopped = true;
}
// ↑ / ↓ while the pointer rests on the row nudge the value (⇧: five times as much)
if enabled && out.value.is_none() && ui.rect_contains_pointer(row) {
if enabled && out.value.is_none() && typing.is_none() && ui.rect_contains_pointer(row) {
let (up, down, shift) = ui.input_mut(|i| {
let shift = i.modifiers.shift;
let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE };
@@ -223,9 +287,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
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 shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
if typing.is_none() {
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c);
if enabled && value_resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
}
let ring = 7.0;
let tx = to_x(v);
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
@@ -262,15 +329,18 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
register(ui.ctx(), format!("section:{id}"), r);
let p = ui.painter();
if resp.hovered() {
if ui.rect_contains_pointer(r) {
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
}
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.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 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
// clicked (issue #316)
if let Some(on) = enabled
&& (resp.hovered() || !on)
&& (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 er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
@@ -467,6 +537,23 @@ mod tests {
assert_eq!(nudged(&spec("wb.temp", 2000.0, 50000.0, 1.0), 6500.0, 1.0), 6550.0);
assert_eq!(nudged(&spec("light.contrast", -100.0, 100.0, 1.0), 99.0, 5.0), 100.0, "clamped");
}
/// Typed values (issue #322): signs, a decimal comma, units; on the control's steps, in range.
#[test]
fn typed_values_are_stepped_and_clamped() {
let exposure = lightcraft_develop::controls::find("light.exposure").unwrap();
let contrast = lightcraft_develop::controls::find("light.contrast").unwrap();
let temp = lightcraft_develop::controls::find("wb.temp").unwrap();
assert_eq!(typed_value(exposure, "1.5"), Some(1.5));
assert_eq!(typed_value(exposure, " +0,25 "), Some(0.25));
assert_eq!(typed_value(exposure, "-12"), Some(exposure.min), "clamped");
assert_eq!(typed_value(contrast, "33.4"), Some(33.0), "on the control's steps");
assert_eq!(typed_value(contrast, "-20 %"), Some(-20.0));
assert_eq!(typed_value(temp, "5600 K"), Some(5600.0));
for bad in ["", "abc", "1.2.3", "NaN", "inf", "--1"] {
assert_eq!(typed_value(contrast, bad), None, "{bad:?}");
}
}
}
#[cfg(test)]
+2 -1
View File
@@ -275,7 +275,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged |
| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered |
| LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP |
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled` | |
| LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) |
| LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" |
| LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | |
@@ -706,6 +706,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active |
| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | |
| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each |
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322 |
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
+12
View File
@@ -50,6 +50,18 @@
- The interface is available in German and Russian (Edit ▸ Language), alongside English, Chinese (Simplified and
Traditional), Japanese and Brazilian Portuguese.
### Editing
- Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)
and press Return; Esc keeps the old value.
### Editing
- The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,
Optics, Geometry and Calibration (issue #316).
### Library
- Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts
promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).
### Reliability
- On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,
labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys