diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..84567a9a9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 = 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::(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::(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. diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 5e54ff064..709aa4661 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -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)); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bb187811..8e0a8efab 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 | ⬜ | | |