Zoom tool: Zoom In / Zoom Out mode buttons (#12066)

The Zoom bar starts with a Zoom In / Zoom Out pair (Zoom In by default),
kept as the `zoom_in` tool option. In Zoom Out mode a click steps out and
Alt+click steps in, the cursor is the "-" magnifier ("+" with Alt), and a
drag with Scrubby Zoom off steps out once at the press point with no zoom
rectangle, as Alt+drag does in Zoom In mode. Reset Tool leaves the mode;
temporary zoom keys still decide their own direction.

The zoom-out glyph is Lucide's, next to the zoom-in one already vendored.
This commit is contained in:
RickiNano committed 2026-11-10 21:18:57 +01:00
1 parent 6a676ee1be
commit 95d79dcd83
9 files changed
+338 -22

No files matched your search

+2 -2
View File
@@ -42,8 +42,8 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (117 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in,zoom-out}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+15
View File
@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
<line x1="8" x2="14" y1="11" y2="11" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

+7 -5
View File
@@ -872,7 +872,7 @@ fn handle_dialog_canvas_input(app: &mut PhotocraftApp, input: DialogCanvasInput<
}
let b = crate::dialogs::free_buttons(input.ctx, input.rect);
let pointer = input.ctx.input(|i| i.pointer.latest_pos());
crate::zoom_tool::drag(app, input.ctx, input.view, input.xf, &b, pointer);
crate::zoom_tool::drag(app, input.ctx, input.view, input.xf, &b, pointer, &zoom_out);
if b.clicked
&& let Some(p) = pointer
{
@@ -1197,7 +1197,7 @@ fn handle_canvas_tool_navigation(
}
// Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below.
// Like the Hand, the selected Zoom yields to an open Free Transform box (#4799).
if tool == Tool::Zoom && !transform_owns_pointer && crate::zoom_tool::drag(app, ctx, view, xf, buttons, response.interact_pointer_pos()) {
if tool == Tool::Zoom && !transform_owns_pointer && crate::zoom_tool::drag(app, ctx, view, xf, buttons, response.interact_pointer_pos(), zoom_out) {
(buttons.started, buttons.dragged, buttons.stopped) = (false, false, false);
}
}
@@ -2325,13 +2325,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move);
// `~` held: a Brush or Pencil stroke that starts now erases (`tool_event`'s `Down`).
app.held_erase = crate::hold_keys::erase_held(app, &ctx);
// Zoom direction: the temporary zoom key decides, else ⌥ (Zoom tool), held or latched in
// Window › Modifier Keys (the way around a desktop that takes Alt+click, `alt_grab`).
// Zoom direction: the temporary zoom key decides, else the Zoom tool's Zoom In / Zoom Out
// mode, turned around by ⌥ held or latched in Window › Modifier Keys (the way around a
// desktop that takes Alt+click, `alt_grab`).
let sticky_alt = app.ui.shell.sticky_alt;
let out_mode = !app.ui.tool_options.zoom_in;
let zoom_out = |alt: bool| match temporary {
Some(crate::hold_keys::Temporary::ZoomOut) => true,
Some(crate::hold_keys::Temporary::ZoomIn) => false,
_ => alt || sticky_alt,
_ => (alt || sticky_alt) != out_mode,
};
if under_dialog {
+1
View File
@@ -191,4 +191,5 @@ pub static ICONS: &[(&str, &[u8])] = &[
("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")),
("x", include_bytes!("../../../assets/icons/x.svg")),
("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")),
("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")),
];
+7
View File
@@ -767,6 +767,13 @@ fn tool_specific_options(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: Tokens,
}
Tool::Eyedropper => crate::eyedropper_ui::options(app, ui),
Tool::Zoom => {
// Zoom In / Zoom Out: a pair, exactly one pressed; Zoom Out makes a click step out.
for (zoom_in, icon, tip) in [(true, "zoom-in", tl!("Zoom In")), (false, "zoom-out", tl!("Zoom Out"))] {
if icons::button(ui, icon, 24.0, app.ui.tool_options.zoom_in == zoom_in, tip).clicked() {
app.ui.tool_options.zoom_in = zoom_in;
}
}
widgets::vline(ui, 22.0);
// Resize Windows to Fit is Preferences › Zoom Resizes Windows itself, as in
// Photoshop: one setting, shown in both places (zoom_window_fit.rs).
let mut fit = app.session.prefs().general.zoom_resizes_windows;
+5
View File
@@ -242,6 +242,10 @@ pub struct ToolOptions {
/// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle).
#[serde(default = "yes")]
pub zoom_scrubby: bool,
/// Zoom tool › Zoom In / Zoom Out: which way a click steps (⌥ turns it around). Reset Tool
/// leaves it, as in Photoshop.
#[serde(default = "yes")]
pub zoom_in: bool,
/// Hand tool › Scroll All Windows: a Hand drag pans every shown document view, not only the
/// one dragged in (`scroll_all_windows.rs`). Shift held while dragging does the same.
#[serde(default)]
@@ -545,6 +549,7 @@ impl Default for ToolOptions {
cr_tolerance: 30.0,
cr_anti_alias: true,
zoom_scrubby: true,
zoom_in: true,
hand_scroll_all: false,
pencil_auto_erase: false,
brush_clear: false,
+9 -5
View File
@@ -132,7 +132,11 @@ fn is_selection_tool(tool: Tool) -> bool {
)
}
/// Put `tool`'s fields of `o` back to their defaults.
/// Options a tool preset stores but Reset Tool leaves, as in Photoshop: the Zoom tool keeps Zoom
/// Out selected (#12066).
const KEPT_BY_RESET: &[&str] = &["zoom_in"];
/// Put `tool`'s fields of `o` back to their defaults, except those [`KEPT_BY_RESET`].
pub fn reset_options(o: &mut ToolOptions, tool: Tool) {
let keys = option_keys(tool);
if keys.is_empty() {
@@ -141,7 +145,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) {
let (Ok(Value::Object(mut cur)), Ok(Value::Object(d))) = (serde_json::to_value(&*o), serde_json::to_value(ToolOptions::default())) else {
return;
};
for k in keys {
for k in keys.iter().filter(|k| !KEPT_BY_RESET.contains(k)) {
if let Some(v) = d.get(*k) {
cur.insert((*k).to_string(), v.clone());
}
@@ -167,8 +171,8 @@ pub fn sample_all_key(tool: Tool) -> Option<String> {
m.into_iter().find(|(k, v)| b.get(k) != Some(v)).map(|(k, _)| k)
}
/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets and
/// what a tool preset made with + stores (#7356).
/// The [`ToolOptions`] fields `tool` shows, by their serialized names: what Reset Tool resets (but
/// for [`KEPT_BY_RESET`]) and what a tool preset made with + stores (#7356).
pub fn option_keys(tool: Tool) -> &'static [&'static str] {
// `reset!` names each field once; one that is misspelt or gone fails to compile.
macro_rules! reset {
@@ -226,7 +230,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => {
reset!(type_font, type_style, type_size, type_aa, type_align, type_direction, type_style_defaults)
}
Tool::Zoom => reset!(zoom_scrubby),
Tool::Zoom => reset!(zoom_scrubby, zoom_in),
Tool::Hand => reset!(hand_scroll_all),
Tool::SpotHealing => reset!(spot_type),
Tool::Healing => reset!(clone_aligned, clone_sample),
+30 -10
View File
@@ -2,8 +2,9 @@
//!
//! With Scrubby Zoom on (the default, as in Photoshop) press-and-drag zooms continuously: right
//! zooms in, left zooms out, anchored at the point where the button went down. With it off, the
//! drag draws a rectangle and the release zooms to fit it. A click (no drag) still steps the zoom
//! in, or out with ⌥ (handled by the canvas).
//! drag draws a rectangle and the release zooms to fit it, unless it zooms out (Zoom Out mode, or
//! ⌥ in Zoom In mode): then the release steps out once at the press point. A click (no drag)
//! steps the zoom in, or out in Zoom Out mode, ⌥ turning either around (handled by the canvas).
use egui::{Pos2, Rect, vec2};
@@ -25,6 +26,8 @@ struct ZoomDrag {
zoom0: f32,
/// Scrubby Zoom off: a zoom rectangle, its current corner.
rect_to: Option<Pos2>,
/// The release steps out (Zoom Out mode, or ⌥ in Zoom In mode): no rectangle is shown.
out: bool,
}
fn id() -> egui::Id {
@@ -51,18 +54,29 @@ pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2],
}
/// Route the canvas buttons for the Zoom tool. Returns true when it consumed the drag (the
/// canvas then handles only the click).
pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, pointer: Option<Pos2>) -> bool {
/// canvas then handles only the click). `zoom_out` is the canvas's direction for ⌥ held or not
/// (temporary zoom key, Zoom In / Zoom Out mode, latched ⌥).
pub fn drag(
app: &PhotocraftApp,
ctx: &egui::Context,
view: &mut View,
xf: &ViewXform,
b: &Buttons,
pointer: Option<Pos2>,
zoom_out: &dyn Fn(bool) -> bool,
) -> bool {
let scrubby = app.ui.tool_options.zoom_scrubby;
let out = zoom_out(ctx.input(|i| i.modifiers.alt));
if b.started {
let anchor = ctx.input(|i| i.pointer.press_origin()).filter(|p| xf.rect.contains(*p)).or(pointer);
if let Some(anchor) = anchor {
let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor) };
let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor), out };
ctx.data_mut(|d| d.insert_temp(id(), z));
}
}
let Some(mut z) = ctx.data(|d| d.get_temp::<ZoomDrag>(id())) else { return b.started || b.dragged || b.stopped };
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
z.out = out;
match z.rect_to.as_mut() {
Some(to) => *to = p,
None => {
@@ -79,7 +93,6 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
if b.stopped {
ctx.data_mut(|d| d.remove::<ZoomDrag>(id()));
if let Some(to) = z.rect_to {
let out = crate::workspace_ui::sticky_mods(app, ctx.input(|i| i.modifiers)).alt;
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, out, app.ppp);
}
}
@@ -87,7 +100,7 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
}
/// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom
/// at the press point; ⌥ steps out).
/// at the press point; `out` steps out there).
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool, ppp: f32) {
if out || r.width() < 4.0 || r.height() < 4.0 {
let doc = xf.to_doc(anchor);
@@ -103,12 +116,19 @@ fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: boo
/// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants.
pub fn draw(ctx: &egui::Context, painter: &egui::Painter) {
let Some(z) = ctx.data(|d| d.get_temp::<ZoomDrag>(id())) else { return };
let Some(to) = z.rect_to else { return };
let r = Rect::from_two_pos(z.anchor, to);
let Some(r) = shown_rect(ctx) else { return };
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true);
}
/// The zoom rectangle on screen, if any: none for a drag that steps out at its release.
fn shown_rect(ctx: &egui::Context) -> Option<Rect> {
let z = ctx.data(|d| d.get_temp::<ZoomDrag>(id()))?;
let to = z.rect_to.filter(|_| !z.out)?;
Some(Rect::from_two_pos(z.anchor, to))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod zoom_out_mode_tests;
@@ -0,0 +1,262 @@
//! Zoom tool › Zoom In / Zoom Out (#12066): a pair of toggles on the options bar. In Zoom Out mode
//! a click steps out and ⌥+click steps in, the cursor is the "-" magnifier ("+" with ⌥), and a
//! drag with Scrubby Zoom off steps out once at the press point, as ⌥+drag does in Zoom In mode.
use super::*;
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use crate::state::{Tool, ToolOptions};
const DOC: [u32; 2] = [400, 300];
fn harness(tool: Tool, zoom_in: bool, scrubby: bool) -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": DOC[0], "height": DOC[1]})).unwrap();
// No animation, the clicked point stays under the pointer, and the view is never clamped:
// every step lands on the first frame where the tests can compare it.
app.run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.tool = tool;
app.ui.tool_options.zoom_in = zoom_in;
app.ui.tool_options.zoom_scrubby = scrubby;
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
let v = &mut h.state_mut().ui.views[0];
v.zoom = 1.0;
v.center = [200.0, 150.0];
v.fit_pending = false;
v.fill_pending = false;
h.run_steps(2);
h
}
fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform {
ViewXform::active(h.state()).unwrap()
}
/// The platform's modifiers for `Cmd` (⌘ on the Mac, Ctrl elsewhere), as egui-winit reports them.
fn platform(m: Modifiers) -> Modifiers {
let mut out = Modifiers { alt: m.alt, shift: m.shift, ctrl: m.ctrl, ..Default::default() };
if m.command {
out.command = true;
if cfg!(target_os = "macos") {
out.mac_cmd = true;
} else {
out.ctrl = true;
}
}
out
}
fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, m: Modifiers) {
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: platform(m) });
h.step();
}
fn click(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, m: Modifiers) {
h.event(Event::ModifiersChanged(platform(m)));
h.event(Event::PointerMoved(p));
h.step();
button(h, p, true, m);
button(h, p, false, m);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
/// The view after one click at `p` with `m`, from zoom 1 centred on the document.
fn view_after_click(zoom_in: bool, m: Modifiers) -> (f32, [f32; 2]) {
let mut h = harness(Tool::Zoom, zoom_in, true);
let p = h.state().last_canvas_rect.center() + vec2(90.0, -45.0);
click(&mut h, p, m);
let v = &h.state().ui.views[0];
(v.zoom, v.center)
}
#[test]
fn zoom_out_mode_click_steps_out_and_alt_click_steps_in() {
let mut h = harness(Tool::Zoom, false, true);
let p = h.state().last_canvas_rect.center() + vec2(90.0, -45.0);
let doc = xf(&h).to_doc(p);
click(&mut h, p, Modifiers::NONE);
let out = crate::zoom_levels::step(1.0, -1, DOC);
assert!(out < 1.0);
assert_eq!(h.state().current_zoom(), out, "a click steps one stop out");
assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5, "about the clicked point");
click(&mut h, p, Modifiers::ALT);
assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(out, 1, DOC), "⌥+click steps one stop in");
assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5);
assert_eq!(h.state().session.documents()[0].history.past_len(), 0, "zooming adds no history state");
}
#[test]
fn zoom_out_mode_click_is_zoom_in_mode_alt_click_and_the_reverse() {
assert_eq!(view_after_click(false, Modifiers::NONE), view_after_click(true, Modifiers::ALT));
assert_eq!(view_after_click(false, Modifiers::ALT), view_after_click(true, Modifiers::NONE));
// Zoom In mode keeps today's click: in, and out with ⌥.
assert_eq!(view_after_click(true, Modifiers::NONE).0, crate::zoom_levels::step(1.0, 1, DOC));
assert_eq!(view_after_click(true, Modifiers::ALT).0, crate::zoom_levels::step(1.0, -1, DOC));
}
/// A cursor as the platform gets it: the icon, and the bitmap with its size where one is drawn.
type Cursor = (egui::CursorIcon, Option<([u16; 2], std::sync::Arc<[u8]>)>);
/// The canvas cursor over the image with ⌥ held or not.
fn cursor(h: &mut Harness<'static, PhotocraftApp>, alt: bool) -> Cursor {
let p = h.state().last_canvas_rect.center() + vec2(alt as u8 as f32, 0.0);
h.event(Event::ModifiersChanged(if alt { Modifiers::ALT } else { Modifiers::NONE }));
h.event(Event::PointerMoved(p));
h.run_steps(2);
let o = &h.output().platform_output;
(o.cursor_icon, o.cursor_image.clone().map(|i| (i.size, i.rgba)))
}
#[test]
fn zoom_out_mode_cursor_is_the_minus_magnifier_and_plus_with_alt() {
let mut zoom_in = harness(Tool::Zoom, true, true);
let (plus, minus) = (cursor(&mut zoom_in, false), cursor(&mut zoom_in, true));
assert_ne!(plus, minus, "the two magnifiers differ");
let mut zoom_out = harness(Tool::Zoom, false, true);
assert_eq!(cursor(&mut zoom_out, false), minus, "Zoom Out mode shows \"-\"");
assert_eq!(cursor(&mut zoom_out, true), plus, "\"+\" while ⌥ is held");
}
/// Scrubby Zoom off: a drag from a point to 120 x 80 points away, with `m` held. Returns the
/// view at the release, and whether a zoom rectangle was on screen while dragging.
fn rect_drag(zoom_in: bool, m: Modifiers) -> ((f32, [f32; 2]), bool) {
let mut h = harness(Tool::Zoom, zoom_in, false);
let a = h.state().last_canvas_rect.center() + vec2(-80.0, -50.0);
let doc = xf(&h).to_doc(a);
h.event(Event::ModifiersChanged(platform(m)));
h.event(Event::PointerMoved(a));
h.step();
button(&mut h, a, true, m);
let mut shown = false;
for t in [0.25, 0.5, 1.0] {
h.event(Event::PointerMoved(a + vec2(120.0, 80.0) * t));
h.step();
shown |= shown_rect(&h.ctx).is_some();
assert_eq!(h.state().current_zoom(), 1.0, "no zoom while dragging");
}
button(&mut h, a + vec2(120.0, 80.0), false, m);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
if !zoom_in || m.alt {
assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(a) < 0.5, "anchored at the press point");
}
let v = &h.state().ui.views[0];
((v.zoom, v.center), shown)
}
#[test]
fn zoom_out_mode_drag_without_scrubby_zoom_steps_out_once_like_alt_drag() {
let (out_mode, out_shown) = rect_drag(false, Modifiers::NONE);
let (alt_drag, alt_shown) = rect_drag(true, Modifiers::ALT);
assert_eq!(out_mode.0, crate::zoom_levels::step(1.0, -1, DOC), "one stop out");
assert_eq!(out_mode, alt_drag, "the same view as ⌥+drag in Zoom In mode");
assert!(!out_shown && !alt_shown, "no zoom rectangle for a drag that steps out");
// Zoom In mode keeps its zoom rectangle.
let (rect, rect_shown) = rect_drag(true, Modifiers::NONE);
assert!(rect_shown);
assert!(rect.0 > 1.0, "the rectangle zooms in: {}", rect.0);
}
#[test]
fn temporary_zoom_keys_ignore_the_zoom_out_mode() {
let mut h = harness(Tool::Brush, false, true);
let p = h.state().last_canvas_rect.center();
let key = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, m: Modifiers| {
let m = platform(m);
h.event(Event::ModifiersChanged(m));
h.event(Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: m });
h.step();
};
key(&mut h, true, Modifiers::COMMAND);
click(&mut h, p, Modifiers::COMMAND);
key(&mut h, false, Modifiers::COMMAND);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let z = h.state().current_zoom();
assert_eq!(z, crate::zoom_levels::step(1.0, 1, DOC), "⌘Space still zooms in");
key(&mut h, true, Modifiers::COMMAND | Modifiers::ALT);
click(&mut h, p, Modifiers::COMMAND | Modifiers::ALT);
key(&mut h, false, Modifiers::COMMAND | Modifiers::ALT);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(z, -1, DOC), "⌘⌥Space still zooms out");
assert_eq!(h.state().ui.tool, Tool::Brush);
assert!(!h.state().ui.tool_options.zoom_in);
}
fn options_bar() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.tool = Tool::Zoom;
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
move |ui, app: &mut PhotocraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::ThemeKind::ALL[0]);
ready = true;
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
h.run_steps(4);
h
}
fn left(h: &Harness<'static, PhotocraftApp>, label: &str) -> f64 {
h.get_by_label(label).accesskit_node().bounding_box().unwrap_or_else(|| panic!("{label} is laid out")).x0
}
#[test]
fn options_bar_has_the_zoom_in_and_zoom_out_pair_before_the_checkboxes() {
let mut h = options_bar();
assert!(h.state().ui.tool_options.zoom_in, "Zoom In is pressed by default");
let (zin, zout, fit) = (left(&h, "Zoom In"), left(&h, "Zoom Out"), left(&h, "Resize Windows to Fit"));
assert!(zin < zout && zout < fit, "Zoom In {zin}, Zoom Out {zout}, Resize Windows to Fit {fit}");
// Exactly one is pressed: clicking either selects it, clicking it again keeps it.
for (label, zoom_in) in [("Zoom Out", false), ("Zoom Out", false), ("Zoom In", true), ("Zoom In", true), ("Zoom Out", false)] {
h.get_by_label(label).click();
h.run_steps(2);
assert_eq!(h.state().ui.tool_options.zoom_in, zoom_in, "after clicking {label}");
}
}
#[test]
fn zoom_mode_survives_reset_tool_and_defaults_to_zoom_in() {
let mut o = ToolOptions { zoom_in: false, zoom_scrubby: false, ..ToolOptions::default() };
crate::tool_reset::reset_options(&mut o, Tool::Zoom);
assert!(o.zoom_scrubby, "Reset Tool resets Scrubby Zoom");
assert!(!o.zoom_in, "but leaves Zoom Out selected");
assert!(ToolOptions::default().zoom_in);
let mut saved = serde_json::to_value(ToolOptions { zoom_in: false, ..ToolOptions::default() }).unwrap();
assert!(!serde_json::from_value::<ToolOptions>(saved.clone()).unwrap().zoom_in, "the mode is saved");
// Options saved before the mode existed load as Zoom In; a bad value is an error, not a panic.
saved.as_object_mut().unwrap().remove("zoom_in");
assert!(serde_json::from_value::<ToolOptions>(saved.clone()).unwrap().zoom_in);
saved["zoom_in"] = json!("out");
assert!(serde_json::from_value::<ToolOptions>(saved).is_err());
}