diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 7c58ff8429..e5e5524556 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -620,3 +620,38 @@ fn options_bar_reaches_brush_settings_and_the_preset_library() { h.run_steps(3); assert!(h.state().ui.panels.brush_settings && h.state().ui.brush_tab == 0); } + +#[test] +fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { + use egui_kittest::kittest::Queryable; + + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Hand; + app.ui.views[0].zoom = 2.0; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + + h.get_by_label("100%").click(); + h.run_steps(1); + assert_eq!(h.state().ui.views[0].zoom, 1.0); + + h.get_by_label("Fit Screen").click(); + h.run_steps(1); + assert!(h.state().ui.views[0].fit_pending); + assert!(!h.state().ui.views[0].fill_pending); + + h.get_by_label("Fill Screen").click(); + h.run_steps(1); + assert!(h.state().ui.views[0].fill_pending); + assert!(!h.state().ui.views[0].fit_pending); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2a37749281..2bf52005ae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -394,6 +394,18 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) { view.zoom = zoom; view.center = [w / 2.0, h / 2.0]; view.fit_pending = false; + view.fill_pending = false; +} + +/// Centre the document and zoom until it fills the canvas. One axis can extend beyond the +/// viewport, matching the Hand tool's Fill Screen action. +pub fn fill_view(view: &mut View, doc: &Document, area: Vec2) { + let (w, h) = (doc.size.width as f32, doc.size.height as f32); + let zoom = (area.x / w).max(area.y / h).clamp(0.01, 32.0); + view.zoom = zoom; + view.center = [w / 2.0, h / 2.0]; + view.fit_pending = false; + view.fill_pending = false; } /// Zoom steps like Photoshop's (⌘+ / ⌘−). @@ -1545,6 +1557,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if view.fit_pending && rect.width() > 50.0 { fit_view(&mut view, &doc, rect.size()); } + if view.fill_pending && rect.width() > 50.0 { + fill_view(&mut view, &doc, rect.size()); + } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size()) { ctx.request_repaint(); @@ -3534,6 +3549,20 @@ mod tests { assert_eq!(zoom_step(32.0, 1), 32.0); } + #[test] + fn fill_view_covers_the_canvas_and_centres_the_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let mut view = View { fill_pending: true, ..View::default() }; + + fill_view(&mut view, &doc, vec2(600.0, 600.0)); + + assert_eq!(view.zoom, 3.0, "the short edge fills the available height"); + assert_eq!(view.center, [200.0, 100.0]); + assert!(!view.fill_pending); + } + #[test] fn damage_grows_by_nested_effect_reach() { use photocraft_doc::{Effect, Layer}; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 64b90c14b0..408b4010b5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -295,15 +295,18 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(z) = p.get("zoom").and_then(Value::as_f64) { app.ui.views[i].zoom = (z as f32).clamp(0.01, 64.0); app.ui.views[i].fit_pending = false; + app.ui.views[i].fill_pending = false; } if let Some(c) = p.get("center").and_then(Value::as_array) && c.len() == 2 { app.ui.views[i].center = [c[0].as_f64().unwrap_or(0.0) as f32, c[1].as_f64().unwrap_or(0.0) as f32]; app.ui.views[i].fit_pending = false; + app.ui.views[i].fill_pending = false; } if p.get("fit").and_then(Value::as_bool) == Some(true) { app.ui.views[i].fit_pending = true; + app.ui.views[i].fill_pending = false; } } if let Some(name) = s("theme") { diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 6508431e7d..a7b0ff2205 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -447,7 +447,7 @@ mod tests { fn space_on_the_canvas_moves_the_frame_not_the_view() { use egui::{Event, Key, PointerButton, pos2}; let mut a = app(SampleType::U8); - let view = crate::state::View { zoom: 2.0, center: [100.0, 50.0], fit_pending: false, doc_size: [200, 100] }; + let view = crate::state::View { zoom: 2.0, center: [100.0, 50.0], fit_pending: false, fill_pending: false, doc_size: [200, 100] }; a.ui.views = vec![view.clone()]; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 95b7726ff6..36df56cc94 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -268,6 +268,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: "view.fitOnScreen" => v.fit_pending = true, _ => v.zoom = 1.0, } + if id == "view.fitOnScreen" { + v.fill_pending = false; + } else { + v.fit_pending = false; + v.fill_pending = false; + } Ok(Value::Null) } "window.newWindowForDocument" => { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 95254acd55..d3c80a88fa 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -883,7 +883,28 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.views[i].zoom = 1.0; } } - Tool::Hand => hint(ui, tl!("Drag to pan · hold Space with any tool")), + Tool::Hand => { + hint(ui, tl!("Drag to pan · hold Space with any tool")); + if widgets::secondary_button(ui, "100%", 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].zoom = 1.0; + app.ui.views[i].fit_pending = false; + app.ui.views[i].fill_pending = false; + } + if widgets::secondary_button(ui, tl!("Fit Screen"), 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fit_pending = true; + app.ui.views[i].fill_pending = false; + } + if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).clicked() + && let Some(i) = app.session.active_index() + { + app.ui.views[i].fill_pending = true; + app.ui.views[i].fit_pending = false; + } + } Tool::Lasso | Tool::PolygonLasso => hint( ui, &crate::i18n::fmt( diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 36c02087df..73e2edbdef 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -280,7 +280,7 @@ mod tests { assert_eq!(clamp_axis(600.0, 1000.0, 500.0, 1.0), 600.0); // Smaller than the view: centred. assert_eq!(clamp_axis(-90.0, 1000.0, 500.0, 0.2), 500.0); - let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, doc_size: [1000, 800] }; + let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800] }; assert!(clamp_view(&mut v, vec2(500.0, 400.0))); assert_eq!(v.center, [250.0, 200.0]); assert!(!clamp_view(&mut v, vec2(500.0, 400.0)), "stable: no repaint loop"); @@ -299,7 +299,7 @@ mod tests { let (at, len) = s.thumb((0.0, 1000.0), 0.0); assert!(at.is_finite() && len.is_finite()); assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0); - let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, doc_size: [0, 0] }; + let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0] }; assert!(!clamp_view(&mut v, vec2(500.0, 400.0))); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d49f9846f..dadaf2cc27 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -367,6 +367,9 @@ pub struct View { pub center: [f32; 2], /// Recompute fit-to-screen on next frame. pub fit_pending: bool, + /// Recompute fill-screen on next frame. Unlike fit, this may crop an edge of the document. + #[serde(default)] + pub fill_pending: bool, /// Document size this view last showed; a change (Image/Canvas Size, crop) re-centres it. #[serde(default)] pub doc_size: [u32; 2], @@ -374,7 +377,7 @@ pub struct View { impl Default for View { fn default() -> Self { - Self { zoom: 1.0, center: [0.0, 0.0], fit_pending: true, doc_size: [0, 0] } + Self { zoom: 1.0, center: [0.0, 0.0], fit_pending: true, fill_pending: false, doc_size: [0, 0] } } }