mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-15 14:43:28 +00:00
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:
1 parent
6a676ee1be
commit
95d79dcd83
9 files changed
+338
-22
No files matched your search
+2
-2
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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")),
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user