From 715f4fd3976bd54ed226948605d5a088c09adfb7 Mon Sep 17 00:00:00 2001 From: Arthur Wagner <97618574+arthurdeka@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:09:46 -0300 Subject: [PATCH] Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3983) * Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3344) * Status bar: preserve keyboard editing and handle fullscreen edge crossings * widgets: initialise the new FieldOpts field after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/chrome_ui.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 4 +- crates/ui-egui/src/scrub_wrap.rs | 96 ++++++++++++++++++ crates/ui-egui/src/status_bar_tests.rs | 135 +++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 28 +++-- 6 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/scrub_wrap.rs diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index e5aefd7b51..a0970ae5f7 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -156,7 +156,7 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ); let mut pct = app.ui.views[i].zoom * 100.0; let control_h = crate::panels::status_bar_heights(&t).1; - if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 64.0, control_h).changed() { + if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(64.0, control_h)).changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b600437c1d..c0d64e7c04 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -165,6 +165,7 @@ pub mod rotate_view; pub mod rulers; pub mod screen_picker; pub mod scrollbars; +mod scrub_wrap; pub mod select_mask_view; pub mod served_fonts; pub mod shape_dialog; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index dbf53b0f55..4fd2c1609e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1594,7 +1594,9 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (w, h, mode, bits, layers) = (st.doc.size.width, st.doc.size.height, crate::canvas::mode_label(&st.doc), st.doc.depth.bits(), st.doc.layer_count()); let mut pct = app.ui.views[i].zoom * 100.0; - if widgets::compact_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", 78.0, control_h).changed() { + if widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[i]), "%", vec2(78.0, control_h)) + .changed() + { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); app.ui.views[i].fit_pending = false; } diff --git a/crates/ui-egui/src/scrub_wrap.rs b/crates/ui-egui/src/scrub_wrap.rs new file mode 100644 index 0000000000..a188caa760 --- /dev/null +++ b/crates/ui-egui/src/scrub_wrap.rs @@ -0,0 +1,96 @@ +//! Fullscreen status-bar scrubbing, with absolute cursor-warp movement removed from the value. + +use egui::{Pos2, Rect, Response, Ui, Vec2}; + +#[derive(Clone, Copy)] +struct Scrub { + precise: f64, + pending: Option<(Pos2, Pos2)>, +} + +pub(crate) fn active(ui: &Ui) -> bool { + !cfg!(target_arch = "wasm32") && ui.input(|i| i.focused && i.viewport().fullscreen == Some(true) && !i.any_touches()) +} + +fn wrap_target(pos: Pos2, viewport: Rect) -> Option { + if !viewport.is_finite() || viewport.width() < 32.0 || !pos.is_finite() || pos.y < viewport.top() || pos.y > viewport.bottom() { + return None; + } + if pos.x <= viewport.left() + 1.0 { + Some(Pos2::new(viewport.right() - 3.0, pos.y)) + } else if pos.x >= viewport.right() - 1.0 { + Some(Pos2::new(viewport.left() + 3.0, pos.y)) + } else { + None + } +} + +fn unwarped(delta: Vec2, pos: Pos2, pending: &mut Option<(Pos2, Pos2)>) -> Vec2 { + if let Some((from, to)) = *pending { + let middle = (from.x + to.x) / 2.0; + if (to.x > from.x && pos.x > middle) || (to.x < from.x && pos.x < middle) { + *pending = None; + return delta - (to - from); + } + } + delta +} + +pub(crate) fn update(ui: &Ui, resp: &Response, value: &mut f32, before: f32, range: std::ops::RangeInclusive, speed: f64, wrapping: bool) { + let key = resp.id.with("wrapped-scrub"); + if !wrapping || !resp.dragged_by(egui::PointerButton::Primary) || !ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|d| d.remove::(key)); + return; + } + let (pos, delta, viewport, slow, aim) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.delta(), i.viewport_rect(), i.modifiers.shift, i.aim_radius())); + let Some(pos) = pos else { return }; + let mut scrub = ui.data(|d| d.get_temp::(key)).unwrap_or(Scrub { precise: f64::from(before), pending: None }); + let delta = unwarped(delta, pos, &mut scrub.pending); + let speed = if slow { speed / 10.0 } else { speed }; + let change = f64::from(delta.x - delta.y) * speed; + let (lo, hi) = (f64::from(*range.start()), f64::from(*range.end())); + if change.is_finite() && change != 0.0 && lo <= hi { + scrub.precise = (scrub.precise + change).clamp(lo, hi); + let aim = f64::from(aim) * speed; + let rounded = egui::emath::smart_aim::best_in_range_f64(scrub.precise - aim, scrub.precise + aim); + *value = egui::emath::round_to_decimals(rounded, 3).clamp(lo, hi) as f32; + } + if scrub.pending.is_none() + && let Some(to) = wrap_target(pos, viewport) + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::CursorPosition(to)); + scrub.pending = Some((pos, to)); + ui.ctx().request_repaint(); + } + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_numeric_value_step(speed)); + ui.data_mut(|d| d.insert_temp(key, scrub)); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{pos2, vec2}; + + #[test] + fn warp_preserves_real_motion_in_the_acknowledgement_frame() { + for (from, to, motion) in [(pos2(0.5, 600.0), pos2(997.0, 600.0), vec2(-8.0, 2.0)), (pos2(999.5, 600.0), pos2(3.0, 600.0), vec2(8.0, -2.0))] { + let mut pending = Some((from, to)); + assert_eq!(unwarped(Vec2::ZERO, from, &mut pending), Vec2::ZERO, "wait for an actual warp acknowledgement"); + assert!(pending.is_some()); + assert_eq!(unwarped(to - from + motion, to + motion, &mut pending), motion); + assert!(pending.is_none()); + assert_eq!(unwarped(motion, to + motion * 2.0, &mut pending), motion, "ordinary movement after the warp"); + } + } + + #[test] + fn warp_uses_logical_viewport_edges_even_when_motion_crosses_the_edge() { + let rect = Rect::from_min_size(pos2(20.0, 30.0), vec2(800.0, 600.0)); + assert_eq!(wrap_target(pos2(20.5, 500.0), rect), Some(pos2(817.0, 500.0))); + assert_eq!(wrap_target(pos2(819.5, 500.0), rect), Some(pos2(23.0, 500.0))); + assert_eq!(wrap_target(pos2(19.0, 500.0), rect), Some(pos2(817.0, 500.0))); + assert_eq!(wrap_target(pos2(20.5, 631.0), rect), None); + assert_eq!(wrap_target(rect.center(), rect), None); + assert_eq!(wrap_target(pos2(0.5, 10.0), Rect::from_min_size(Pos2::ZERO, vec2(8.0, 20.0))), None); + } +} diff --git a/crates/ui-egui/src/status_bar_tests.rs b/crates/ui-egui/src/status_bar_tests.rs index 4e4f4a0db5..5f2e576853 100644 --- a/crates/ui-egui/src/status_bar_tests.rs +++ b/crates/ui-egui/src/status_bar_tests.rs @@ -61,3 +61,138 @@ fn status_bar_controls_sit_inside_the_bar_with_even_margins() { assert!((fit.height() - control_h).abs() < 0.01, "{kind:?}: Fit {fit:?}"); } } + +fn cursor_warp(h: &Harness<'_, PhotocraftApp>) -> Option { + h.output().viewport_output.values().flat_map(|v| &v.commands).find_map(|c| match c { + egui::ViewportCommand::CursorPosition(p) => Some(*p), + _ => None, + }) +} + +fn fullscreen(h: &mut Harness<'_, PhotocraftApp>, on: bool) { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().fullscreen = Some(on); + h.state_mut().ui.views[0].zoom = 10.0; + h.run_steps(2); +} + +fn begin_zoom_drag(h: &mut Harness<'_, PhotocraftApp>) -> egui::Pos2 { + let start = h.get_by_role(egui::accesskit::Role::SpinButton).rect().center(); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + start +} + +/// #3344: both status-bar layouts can scrub across either fullscreen edge. Simulate the native +/// CursorPosition acknowledgement as a PointerMoved, then keep dragging in the same direction. +#[test] +fn fullscreen_status_zoom_wraps_without_turning_the_warp_into_a_value_change() { + for kind in ThemeKind::ALL { + for left in [true, false] { + let mut h = harness(kind); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + let edge = pos2(if left { 0.5 } else { SIZE.x - 0.5 }, start.y); + h.hover_at(edge); + h.run_steps(1); + let target = cursor_warp(&h).unwrap_or_else(|| panic!("{kind:?}: wrap at {edge:?}")); + assert_eq!(target.y, edge.y, "{kind:?}: preserve Y"); + assert_eq!(target.x, if left { SIZE.x - 3.0 } else { 3.0 }); + let at_edge = h.state().ui.views[0].zoom; + h.hover_at(target); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, at_edge, "{kind:?}: cursor teleport must not change zoom"); + assert!(cursor_warp(&h).is_none(), "{kind:?}: landing margin must not bounce"); + let next = target + vec2(if left { -40.0 } else { 40.0 }, 0.0); + h.hover_at(next); + h.run_steps(1); + let continued = h.state().ui.views[0].zoom; + assert!((continued - at_edge - if left { -0.2 } else { 0.2 }).abs() < 0.015, "{kind:?}: {at_edge} -> {continued}"); + h.drop_at(next); + h.run_steps(2); + h.hover_at(edge); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, continued, "{kind:?}: pointer movement after release is not scrubbing"); + assert!(cursor_warp(&h).is_none()); + } + } +} + +#[test] +fn status_zoom_does_not_warp_for_windowed_scrubbing_or_hovering() { + for kind in [ThemeKind::ProMedium, ThemeKind::Studio] { + let mut h = harness(kind); + fullscreen(&mut h, true); + h.hover_at(pos2(0.5, SIZE.y - 10.0)); + h.run_steps(1); + assert!(cursor_warp(&h).is_none(), "hovering the edge"); + fullscreen(&mut h, false); + let start = begin_zoom_drag(&mut h); + let edge = pos2(0.5, start.y); + h.hover_at(edge); + h.run_steps(1); + assert!(cursor_warp(&h).is_none(), "ordinary windowed DragValue"); + assert!(h.state().ui.views[0].zoom < 10.0); + h.drop_at(edge); + h.run_steps(2); + } +} + +#[test] +fn fullscreen_status_zoom_keeps_typing_and_clears_a_pending_warp_on_release() { + let mut h = harness(ThemeKind::ProMedium); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + h.hover_at(pos2(0.5, start.y)); + h.run_steps(1); + let target = cursor_warp(&h).expect("wrap"); + let before = h.state().ui.views[0].zoom; + h.drop_at(target); + h.run_steps(2); + assert_eq!(h.state().ui.views[0].zoom, before); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.event(egui::Event::Text("250%".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.views[0].zoom, 2.5); + assert!(cursor_warp(&h).is_none()); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run_steps(2); + h.key_press(egui::Key::ArrowUp); + h.run_steps(2); + assert!((h.state().ui.views[0].zoom - 2.51).abs() < 0.0001, "keep keyboard stepping in fullscreen"); +} + +#[test] +fn fullscreen_status_zoom_preserves_shift_precision_and_recovers_from_the_minimum() { + let mut h = harness(ThemeKind::ProMedium); + fullscreen(&mut h, true); + let start = begin_zoom_drag(&mut h); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + let to = start + vec2(40.0, 0.0); + h.hover_at(to); + h.run_steps(1); + assert!((h.state().ui.views[0].zoom - 10.02).abs() < 0.005, "Shift reduces sensitivity by ten"); + h.drop_at(to); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); + h.state_mut().ui.views[0].zoom = 0.01; + h.run_steps(2); + let start = begin_zoom_drag(&mut h); + h.hover_at(pos2(0.5, start.y)); + h.run_steps(1); + let target = cursor_warp(&h).expect("wrap"); + let minimum = crate::zoom_levels::min(h.state().ui.views[0].doc_size); + assert_eq!(h.state().ui.views[0].zoom, minimum); + h.hover_at(target); + h.run_steps(1); + let next = target - vec2(100.0, 0.0); + h.hover_at(next); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, minimum); + h.hover_at(next + vec2(20.0, 0.0)); + h.run_steps(1); + assert!(h.state().ui.views[0].zoom > minimum, "reversing direction responds immediately after reaching a bound"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1bb5ee601c..a9559dd636 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -295,6 +295,11 @@ pub fn compact_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeI value_field_box(ui, value, range, suffix, vec2(width, height), 0.0, FieldOpts::default()).0 } +/// A compact field whose mouse scrub wraps at the native fullscreen viewport's horizontal edges. +pub(crate) fn compact_wrapping_value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, size: Vec2) -> Response { + value_field_box(ui, value, range, suffix, size, 0.0, FieldOpts { wrap_drag: true, ..Default::default() }).0 +} + /// A size dialog's unit key (`px`, `in`, `cm`, `mm`, `pt`, `pica`, `percent`) as a unit. pub fn unit_of_key(key: &str) -> photocraft_engine::prefs::Unit { use photocraft_engine::prefs::Unit; @@ -319,7 +324,7 @@ pub fn unit_decimals(key: &str) -> u32 { /// A [`value_field`] showing `decimals` decimal places instead of the one (or two, for a small /// range) it picks itself: the ruler units need their own precision (#2434). pub fn value_field_prec(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, suffix: &str, width: f32, decimals: u32) -> Response { - value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts { decimals: Some(decimals), units: None }).0 + value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts { decimals: Some(decimals), ..Default::default() }).0 } /// A field's unit and the resolution a typed unit converts at (`1in` in a px field). @@ -335,7 +340,7 @@ pub struct FieldUnits { /// A [`value_field_prec`] without a suffix, whose unit is chosen beside it (Image Size and New /// Document's unit menus) and converts typed units at that dialog's own resolution. pub fn value_field_units(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive, width: f32, decimals: u32, units: FieldUnits) -> Response { - value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units) }).0 + value_field_in(ui, value, range, "", width, 0.0, FieldOpts { decimals: Some(decimals), units: Some(units), ..Default::default() }).0 } fn field_resolution_id() -> egui::Id { @@ -463,6 +468,7 @@ struct FieldOpts { decimals: Option, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, + wrap_drag: bool, } /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and @@ -489,7 +495,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units } = opts; + let FieldOpts { decimals, units, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect); @@ -537,7 +543,7 @@ fn value_field_box( ui.spacing_mut().button_padding = Vec2::ZERO; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let layout = egui::Layout::centered_and_justified(ui.layout().main_dir()); - ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units)).inner + ui.allocate_ui_with_layout(field.size(), layout, |ui| number_edit(ui, value, range, fine, decimals, units, wrap_drag)).inner }) .inner } @@ -745,6 +751,7 @@ fn number_edit( fine: bool, decimals: Option, units: crate::numeric_expression::Units, + wrap_drag: bool, ) -> Response { let (id, ctx) = (ui.next_auto_id(), ui.ctx().clone()); // Focused before the DragValue is drawn, so it gains focus this frame and selects its text. @@ -759,10 +766,14 @@ fn number_edit( let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); ui.data_mut(|d| d.insert_temp(held, math)); let before = *value; + let speed = if fine { 0.01 } else { 0.5 }; + let wrapping = wrap_drag && crate::scrub_wrap::active(ui); + let wrapped_drag = wrapping && ui.input(|i| i.pointer.primary_down()); let mut resp = ui.add( egui::DragValue::new(value) - .range(range) - .speed(if fine { 0.01 } else { 0.5 }) + .range(range.clone()) + // A warp is an absolute-position jump. The scoped scrub below removes that jump. + .speed(if wrapped_drag { 0.0 } else { speed }) .custom_formatter(move |v, _| match decimals { Some(d) => fmt_num_prec(v, d), None if fine => fmt_num2(v), @@ -780,6 +791,9 @@ fn number_edit( v }), ); + if wrap_drag { + crate::scrub_wrap::update(ui, &resp, value, before, range, speed, wrapping); + } resp.flags.set(egui::response::Flags::CHANGED, *value != before); resp } @@ -1016,7 +1030,7 @@ pub fn slider_row_prec( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, units: None }).0); + changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, ..Default::default() }).0); }); }); let s = slider_with(ui, value, range, gradient, g);