Cycle Color Lookup LUTs with keyboard and wheel (#3304)

* Preview Color Lookup LUT selection while cycling

* Test Color Lookup keyboard and wheel cycling

* rustfmt; drop a useless into_iter clippy flags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
imSlicedBread
2026-10-11 14:57:43 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent b54fdfd94f
commit 58406c38da
2 changed files with 93 additions and 2 deletions
@@ -155,6 +155,50 @@ fn every_adjustment_layer_edits_through_properties() {
}
}
/// #3264: wheel browsing previews without recording one edit per selected LUT.
#[test]
fn color_lookup_picker_cycles_luts_with_the_mouse_wheel() {
let mut h = app_harness("colorLookup", "rgb", 8);
let (_, before) = layer(&h);
let Adjustment::ColorLookup { name: before_name, .. } = before else { panic!("Color Lookup layer") };
let label = label_rect(&h, "3D LUT File").expect("Color Lookup file label");
let at = h
.query_all_by_role(egui::accesskit::Role::ComboBox)
.min_by(|a, b| a.rect().center().distance(label.center()).total_cmp(&b.rect().center().distance(label.center())))
.expect("Color Lookup picker")
.rect()
.center();
let steps = h.state().session.active().unwrap().history.past_len();
h.hover_at(at);
h.run_steps(1);
h.query_all_by_role(egui::accesskit::Role::ComboBox)
.min_by(|a, b| a.rect().center().distance(label.center()).total_cmp(&b.rect().center().distance(label.center())))
.expect("Color Lookup picker")
.click();
h.run_steps(1);
h.key_press(egui::Key::ArrowDown);
h.run_steps(1);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "arrow navigation previews");
assert!(h.state().live_adjust.is_some(), "arrow navigation selects a preview LUT");
for _ in 0..3 {
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
});
h.run_steps(1);
}
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "cycling remains a preview");
assert!(h.state().live_adjust.is_some(), "the selected LUT is previewed");
h.run_steps(4);
let (_, after) = layer(&h);
let Adjustment::ColorLookup { name: after_name, .. } = after else { panic!("Color Lookup layer") };
assert_ne!(after_name, before_name, "wheel browsing selects a different LUT");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one wheel gesture makes one adjustment edit");
assert!(h.state().live_adjust.is_none(), "the completed wheel gesture is committed");
}
fn curve_of(a: &Adjustment) -> Vec<[f32; 2]> {
match a {
Adjustment::Curves { master, .. } => master.iter().map(|p| [(p.input * 255.0).round(), (p.output * 255.0).round()]).collect(),
+49 -2
View File
@@ -98,6 +98,13 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let builtins = photocraft_engine::adjust_cmds::LOOKS;
let current =
if lut.is_none() { "none".to_string() } else { builtins.iter().find(|b| b.1 == name).map_or_else(|| "custom".to_string(), |b| b.0.to_string()) };
let current = app
.live_adjust
.as_ref()
.filter(|(layer, params)| *layer == id && params.get("lut").is_some())
.and_then(|(_, params)| params.get("lut").and_then(Value::as_str))
.map(str::to_owned)
.unwrap_or(current);
let custom_label = format!("{name} ({size}³)");
let mut opts: Vec<(String, &str)> = vec![("load".into(), tl!("Load 3D LUT…")), ("none".into(), tl!("None"))];
opts.extend(builtins.iter().map(|(id, label)| (id.to_string(), *label)));
@@ -107,16 +114,47 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
let mut sel = current.clone();
let mut params: Option<Value> = None;
let mut browse = false;
let cycle_key = egui::Id::new(("color-lookup-cycle", id.0));
let cycle_input = ui.input(|input| {
input.raw.events.iter().any(|event| {
matches!(event, egui::Event::MouseWheel { .. })
|| matches!(event, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp, pressed: true, .. })
})
});
let mut cycling_preview = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("3D LUT File")).color(t.text_dim));
if widgets::dropdown(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0) {
if sel == "load" {
let (chosen, _) = widgets::dropdown_wheel_hovered(ui, &format!("clrl-{}", id.0), &mut sel, &opts, 170.0);
if !chosen.is_empty() {
if cycle_input {
if sel != "load" && sel != "custom" {
app.live_adjust = Some((id, json!({"lut": sel})));
cycling_preview = true;
}
} else if sel == "load" {
browse = true;
} else if sel != current && sel != "custom" {
params = Some(json!({"lut": sel}));
}
}
});
if cycling_preview {
let now = ui.input(|input| input.time);
ui.ctx().data_mut(|data| data.insert_temp(cycle_key, now));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
} else if let Some(since) = ui.ctx().data(|data| data.get_temp::<f64>(cycle_key)) {
if ui.input(|input| input.time - since) >= 0.75 {
if let Some((layer, preview)) = &app.live_adjust
&& *layer == id
&& preview.get("lut").is_some()
{
params = Some(preview.clone());
}
ui.ctx().data_mut(|data| data.remove::<f64>(cycle_key));
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
}
if browse
&& let Err(e) = browse_color_lookup(app, id)
&& e != crate::file_dialog::CANCELLED
@@ -139,9 +177,18 @@ pub fn color_lookup_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: Layer
params = Some(json!({"dither": d}));
}
if let Some(mut p) = params {
if p.get("lut").is_none()
&& let Some((layer, preview)) = &app.live_adjust
&& *layer == id
&& let Some(lut) = preview.get("lut")
{
p["lut"] = lut.clone();
}
p["layer"] = json!(id.0);
// Errors (an unreadable LUT file) land in the status bar.
let _ = app.run("layer.setAdjustment", p);
app.live_adjust = None;
ui.ctx().data_mut(|data| data.remove::<f64>(cycle_key));
}
}