mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
KEY-SLIDER-TYPE: a long click keeps the value field open; drag the value to scrub
A press on a slider's value held past egui's click time ended with no click on release, so the field it had opened closed again (the field "flashes and vanishes"). Any release now keeps the field open; a sideways drag of more than a few points on the value scrubs it instead (as along the track, a tenth with Shift, one undo step), as in Lightroom Classic. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a4b53b28cc
commit
a706798951
@@ -178,7 +178,7 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String {
|
||||
|
||||
/// 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. Click the value to
|
||||
/// type one: Return or clicking away applies it, Esc keeps the old one.
|
||||
/// type one: Return or clicking away applies it, Esc keeps the old one. Drag the value sideways to scrub it.
|
||||
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();
|
||||
@@ -218,7 +218,8 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
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() });
|
||||
// a click types a value, a sideways drag scrubs it (Lightroom Classic's value readout)
|
||||
let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() });
|
||||
register(ui.ctx(), 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")) } else { value_resp };
|
||||
@@ -231,18 +232,52 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed));
|
||||
}
|
||||
let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||||
// The opening press must complete the readout's original click before the edit continues.
|
||||
if value_released && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() {
|
||||
typing = None;
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
// A press on the value that moves sideways is a scrub, not a click: the field it opened closes
|
||||
// and the value follows the pointer. Any other release keeps the field open, however long the
|
||||
// button was held (issue #59, upstream #1435 / #1669: a press held past egui's click time used to
|
||||
// close the field on release).
|
||||
let scrub_id = id.with("scrub");
|
||||
let mut scrub: Option<f64> = ui.data(|m| m.get_temp(scrub_id));
|
||||
let moved_away = ui.input(|i| match (i.pointer.press_origin(), i.pointer.interact_pos()) {
|
||||
(Some(a), Some(b)) => (b.x - a.x).abs() > SCRUB_THRESHOLD,
|
||||
_ => false,
|
||||
});
|
||||
if enabled && scrub.is_none() && value_resp.dragged() && moved_away {
|
||||
scrub = Some(spec.position(value));
|
||||
if typing.take().is_some() {
|
||||
ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id));
|
||||
ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id))));
|
||||
}
|
||||
}
|
||||
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()));
|
||||
let mut v = value;
|
||||
if let Some((mut text, frames, pressed)) = typing.take() {
|
||||
if let Some(pos) = scrub {
|
||||
// the value moves as it would along the track, a tenth as far with Shift; the unrounded
|
||||
// position is kept so slow movement adds up
|
||||
let started = !ui.data(|m| m.get_temp::<f64>(scrub_id).is_some());
|
||||
let dx = f64::from(ui.input(|i| i.pointer.delta().x) / track_rect.width().max(1.0));
|
||||
let dx = if ui.input(|i| i.modifiers.shift) { dx * 0.1 } else { dx };
|
||||
let pos = (pos + if started { 0.0 } else { dx }).clamp(0.0, 1.0);
|
||||
out.drag_started = started;
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
}
|
||||
if value_resp.dragged() {
|
||||
ui.data_mut(|m| m.insert_temp(scrub_id, pos));
|
||||
} else {
|
||||
out.drag_stopped = true;
|
||||
ui.data_mut(|m| m.remove::<f64>(scrub_id));
|
||||
}
|
||||
if enabled {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
} else if let Some((mut text, frames, pressed)) = typing.take() {
|
||||
// Keep the owned opening release focused without restarting its draft or selection.
|
||||
// egui gives that release's click to the readout, so the field under it gives up the focus
|
||||
// whatever frame it lands on (issue #1362): the field takes it back after drawing too.
|
||||
@@ -410,6 +445,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_
|
||||
out
|
||||
}
|
||||
|
||||
/// How far (points) a press on a slider's value moves sideways before it scrubs instead of typing.
|
||||
const SCRUB_THRESHOLD: f32 = 4.0;
|
||||
|
||||
/// Convert vertical wheel movement to bounded slider steps, keeping sub-notch
|
||||
/// trackpad movement between frames without amplifying a single large event. One mouse-wheel
|
||||
/// notch ([`crate::theme::WHEEL_LINE_POINTS`]) is one step.
|
||||
|
||||
@@ -94,3 +94,47 @@ fn typing_after_a_released_click_still_edits() {
|
||||
h.frame(vec![Event::Text("2".into())]);
|
||||
assert_eq!(h.key(Key::Enter).value, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_value_sideways_scrubs_it() {
|
||||
// Issue #59: as in Lightroom Classic, a sideways drag on the value moves it (one undo step:
|
||||
// started once, stopped once) and opens no field.
|
||||
let mut h = Harness::new();
|
||||
let pos = h.rect.center();
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
let (mut started, mut stopped) = (0, 0);
|
||||
let mut count = |out: SliderOut| {
|
||||
started += usize::from(out.drag_started);
|
||||
stopped += usize::from(out.drag_stopped);
|
||||
};
|
||||
h.frame(vec![Event::PointerMoved(pos)]);
|
||||
count(h.frame(vec![button(pos, true)]));
|
||||
for i in 1..=8 {
|
||||
count(h.frame(vec![Event::PointerMoved(pos + vec2(5.0 * i as f32, 0.0))]));
|
||||
}
|
||||
let end = pos + vec2(40.0, 0.0);
|
||||
count(h.frame(vec![button(end, false)]));
|
||||
h.frame(vec![]);
|
||||
assert!(h.value > 0.71, "scrubbing right raised the value: {}", h.value);
|
||||
assert_eq!((started, stopped), (1, 1));
|
||||
assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_click_keeps_the_field_open() {
|
||||
// Issue #59: a press held past egui's click time (here a second and a half) used to close
|
||||
// the field on release; it stays open for typing.
|
||||
let mut h = Harness::new();
|
||||
let pos = h.rect.center();
|
||||
let button = |pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
h.frame(vec![Event::PointerMoved(pos)]);
|
||||
h.frame(vec![button(true)]);
|
||||
for _ in 0..75 {
|
||||
h.frame(vec![]);
|
||||
}
|
||||
assert_eq!(h.frame(vec![button(false)]).value, None);
|
||||
h.frame(vec![]);
|
||||
assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after a long click");
|
||||
h.frame(vec![Event::Text("2".into())]);
|
||||
assert_eq!(h.key(Key::Enter).value, Some(2.0));
|
||||
}
|
||||
|
||||
@@ -669,7 +669,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`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | 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; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts |
|
||||
| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`, `a_long_click_keeps_the_field_open`, `dragging_the_value_sideways_scrubs_it`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | 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; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts; the field stays open however long the click is held, and a sideways drag on the value scrubs it (#1435, #1669) |
|
||||
| KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | |
|
||||
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
|
||||
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
|
||||
|
||||
Reference in New Issue
Block a user