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 <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:
authored and GitHub committed 2026-10-13 00:09:46 -04:00
1 parent 39f3660def
commit 715f4fd397
6 files changed
+257 -9

No files matched your search

+1 -1
View File
@@ -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;
}
+1
View File
@@ -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;
+3 -1
View File
@@ -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;
}
+96
View File
@@ -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<Pos2> {
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<f32>, 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::<Scrub>(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::<Scrub>(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);
}
}
+135
View File
@@ -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<egui::Pos2> {
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");
}
+21 -7
View File
@@ -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<f32>, 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<f32>, 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<f32>, 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<u32>,
/// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]).
units: Option<FieldUnits>,
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<u32>,
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);