mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 14:12:44 +00:00
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:
6 files changed
+257
-9
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user