mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 13:15:10 +00:00
Integration: Zoom Resizes Windows shows the whole image, clear of the scrollbars (#7662)
A Zoom tool click or wheel zoom keeps the clicked spot under the pointer, so the view centre moves off the image's middle. The window then grew to the image's size around that off-centre view: a 400x300 image zoomed to 200% about (50, 50) showed grey on the left and top and lost its right and bottom edges. The size also left out the 12 pt scrollbars, which are drawn over the canvas and hid the image's right and bottom edges. The window now makes room for the scrollbars, and a window that grows around the whole image centres it in the area the scrollbars leave free (any flip, rotation, pixel aspect and display scale). A window the screen caps keeps the zoomed spot where it is. Scrubby zoom and the Animated Zoom hold re-anchor the view only when the zoom changes, so their release frame and the frames at the zoom limit keep that centred view. A maximized or full-screen window keeps the size the user gave it. Co-authored-by: Nguyễn Minh Long <181250616+KLBMinhLong@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
893b54ea8c
commit
a2a6f1e13f
4 files changed
+240
-12
No files matched your search
@@ -6553,7 +6553,7 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
view = canvas_view(app, ui, w.document, rect, view.clone(), false);
|
||||
// Preferences › Zoom Resizes Windows: a zoom wraps the window around the image.
|
||||
crate::zoom_window_fit::follow(app, ui.ctx(), class, vid, &w, &view, rect);
|
||||
crate::zoom_window_fit::follow(app, ui.ctx(), class, vid, &w, &mut view, rect);
|
||||
});
|
||||
});
|
||||
if let Some(win) = app.ui.windows.iter_mut().find(|x| x.id == w.id) {
|
||||
|
||||
@@ -110,7 +110,10 @@ pub fn update(ctx: &egui::Context, view: &mut View, xf: &ViewXform, enabled: boo
|
||||
}
|
||||
let dt = now - h.last.unwrap_or(now);
|
||||
let zoom = hold_zoom(view.zoom, dt, out, view.doc_size);
|
||||
crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp);
|
||||
// At the zoom limit the view stays as it is (a window wrapped around the image centred it).
|
||||
if zoom.to_bits() != view.zoom.to_bits() {
|
||||
crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp);
|
||||
}
|
||||
h.last = Some(now);
|
||||
ctx.data_mut(|d| d.insert_temp(id, h));
|
||||
ctx.request_repaint();
|
||||
@@ -246,6 +249,29 @@ mod tests {
|
||||
assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_at_the_zoom_limit_leaves_the_view_alone() {
|
||||
// Zoom Resizes Windows centres the image in a window wrapped around it
|
||||
// (zoom_window_fit.rs); a hold that can't zoom further must not move it back.
|
||||
let mut h = harness(true);
|
||||
let p = h.state().last_canvas_rect.center() + vec2(50.0, -30.0);
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
button(&mut h, p, true, true);
|
||||
let min = crate::zoom_levels::min(DOC);
|
||||
for _ in 0..200 {
|
||||
if h.state().current_zoom() == min {
|
||||
break;
|
||||
}
|
||||
h.step();
|
||||
}
|
||||
assert_eq!(h.state().current_zoom(), min);
|
||||
h.state_mut().ui.views[0].center = [200.0, 150.0];
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.views[0].center, [200.0, 150.0]);
|
||||
button(&mut h, p, false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animated_zoom_off_a_hold_is_one_click_step() {
|
||||
let mut h = harness(false);
|
||||
|
||||
@@ -65,7 +65,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
|
||||
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
|
||||
match z.rect_to.as_mut() {
|
||||
Some(to) => *to = p,
|
||||
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp),
|
||||
None => {
|
||||
// Only a new zoom re-anchors: a window that Zoom Resizes Windows grew around the
|
||||
// image has centred it since (zoom_window_fit.rs), and the release keeps that.
|
||||
let zoom = scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size);
|
||||
if zoom.to_bits() != view.zoom.to_bits() {
|
||||
anchor_view(view, xf, z.anchor, z.doc, zoom, app.ppp);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(id(), z));
|
||||
}
|
||||
@@ -421,6 +428,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrub_release_keeps_a_view_centred_since_its_last_zoom() {
|
||||
// Zoom Resizes Windows centres the image once its window wraps it (zoom_window_fit.rs):
|
||||
// the release frame, at the same zoom, must not pull it back to the press point.
|
||||
let mut h = harness(true, 1.0);
|
||||
let p0 = h.state().last_canvas_rect.center() + vec2(-150.0, 60.0);
|
||||
h.event(Event::PointerMoved(p0));
|
||||
h.step();
|
||||
press(&mut h, p0, true);
|
||||
let end = p0 + vec2(90.0, 0.0);
|
||||
h.event(Event::PointerMoved(end));
|
||||
h.step();
|
||||
let z = h.state().current_zoom();
|
||||
h.state_mut().ui.views[0].center = [200.0, 150.0];
|
||||
h.step();
|
||||
assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "a held, unmoved scrub keeps the view");
|
||||
press(&mut h, end, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().current_zoom(), z);
|
||||
assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "the release keeps the view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_drags_keep_the_press_point_in_rotated_and_flipped_views() {
|
||||
for (scrubby, out, rotation, flip, ppp) in [
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
//!
|
||||
//! Only a change of zoom resizes. Fit on Screen derives the zoom from the window, so a fit leaves
|
||||
//! the window alone and the two can never chase each other.
|
||||
//!
|
||||
//! A zoom about the pointer leaves the image off centre, so a window grown to wrap it also
|
||||
//! re-centres the image in what the scrollbars leave free; a window the screen caps keeps the
|
||||
//! zoomed spot where it is.
|
||||
|
||||
use egui::{Context, Id, Rect, Vec2, ViewportClass, ViewportCommand, ViewportId, vec2};
|
||||
use egui::{Context, Id, Pos2, Rect, Vec2, ViewportClass, ViewportCommand, ViewportId, vec2};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::{DocWindow, View};
|
||||
@@ -29,6 +33,11 @@ pub struct Layout {
|
||||
pub aspect: f32,
|
||||
/// The window's own chrome around the image, the rulers, in points.
|
||||
pub chrome: Vec2,
|
||||
/// The scrollbars along the right and bottom (points; 0 when hidden). They are drawn over the
|
||||
/// canvas, so the window makes room for them too.
|
||||
pub bars: f32,
|
||||
/// View › Flip Horizontal.
|
||||
pub flip: bool,
|
||||
/// The size of the screen the window is on, in points, when the platform reports it.
|
||||
pub monitor: Option<Vec2>,
|
||||
}
|
||||
@@ -37,6 +46,11 @@ pub struct Layout {
|
||||
/// at least [`MIN_SIZE`] but never bigger than the screen, even a screen smaller than that
|
||||
/// minimum. `None` until the view has a document size.
|
||||
pub fn wrapped_size(view: &View, layout: &Layout) -> Option<Vec2> {
|
||||
wrap(view, layout).map(|(_, size)| size)
|
||||
}
|
||||
|
||||
/// The window size the image and chrome need, and the size [`wrapped_size`] asks for.
|
||||
fn wrap(view: &View, layout: &Layout) -> Option<(Vec2, Vec2)> {
|
||||
let usable = |x: f32| x.is_finite() && x > 0.0;
|
||||
if !usable(view.zoom) || !usable(layout.ppp) {
|
||||
return None;
|
||||
@@ -51,11 +65,36 @@ pub fn wrapped_size(view: &View, layout: &Layout) -> Option<Vec2> {
|
||||
let (sin, cos) = view.rotation.to_radians().sin_cos();
|
||||
let (sin, cos) = (sin.abs(), cos.abs());
|
||||
let image = vec2(w * cos + h * sin, w * sin + h * cos);
|
||||
let chrome = if layout.chrome.is_finite() { layout.chrome.max(Vec2::ZERO) } else { Vec2::ZERO };
|
||||
let bars = if usable(layout.bars) { layout.bars } else { 0.0 };
|
||||
let chrome = if layout.chrome.is_finite() { layout.chrome.max(Vec2::ZERO) } else { Vec2::ZERO } + Vec2::splat(bars);
|
||||
// The screen is the hard cap: applied last, it wins over the minimum on a tiny screen.
|
||||
let screen =
|
||||
layout.monitor.filter(|m| usable(m.x) && usable(m.y)).map_or(Vec2::splat(f32::INFINITY), |m| (m - Vec2::splat(SCREEN_MARGIN)).max(Vec2::splat(1.0)));
|
||||
Some((image + chrome).max(MIN_SIZE).min(screen))
|
||||
Some((image + chrome, (image + chrome).max(MIN_SIZE).min(screen)))
|
||||
}
|
||||
|
||||
/// The view centre that shows the whole image in the middle of a window of [`wrapped_size`]:
|
||||
/// the image centred in the canvas less its scrollbars. `None` when the screen capped the window
|
||||
/// and the image doesn't fit, so the zoomed spot stays put.
|
||||
pub fn wrapped_center(view: &View, layout: &Layout) -> Option<[f32; 2]> {
|
||||
let (needed, size) = wrap(view, layout)?;
|
||||
if needed.x > size.x + 0.5 || needed.y > size.y + 0.5 {
|
||||
return None;
|
||||
}
|
||||
let bars = if layout.bars.is_finite() && layout.bars > 0.0 { layout.bars } else { 0.0 };
|
||||
let middle = [view.doc_size[0] as f32 / 2.0, view.doc_size[1] as f32 / 2.0];
|
||||
// The canvas centre sits half a bar right of and below the middle of the free area.
|
||||
let xf = crate::canvas::ViewXform {
|
||||
rect: Rect::from_center_size(Pos2::ZERO, Vec2::ZERO),
|
||||
zoom: view.zoom / layout.ppp,
|
||||
center: middle,
|
||||
flip: layout.flip,
|
||||
rotation: view.rotation,
|
||||
aspect: layout.aspect,
|
||||
};
|
||||
let [x, y] = xf.to_doc(Pos2::new(bars / 2.0, bars / 2.0));
|
||||
let center = [x as f32, y as f32];
|
||||
center.iter().all(|c| c.is_finite()).then_some(center)
|
||||
}
|
||||
|
||||
/// Where the zoom a window was last wrapped for (or first seen at) is kept between frames.
|
||||
@@ -85,19 +124,39 @@ pub fn resize_for(ctx: &Context, window: u64, on: bool, before: &View, after: &V
|
||||
/// Called by a floating document window (`canvas::extra_windows`) after its canvas drew in `rect`:
|
||||
/// with Zoom Resizes Windows on, a zoom resizes the window around the image. A window drawn
|
||||
/// embedded in the main one (a backend without multiple windows, the web) is never resized, as the
|
||||
/// command would resize the main window instead. Returns the size asked for.
|
||||
pub fn follow(app: &PhotocraftApp, ctx: &Context, class: ViewportClass, viewport: ViewportId, window: &DocWindow, after: &View, rect: Rect) -> Option<Vec2> {
|
||||
/// command would resize the main window instead, and a maximized or full-screen one keeps the
|
||||
/// size the user gave it. A window that grows around the whole image also centres it in `after`.
|
||||
/// Returns the size asked for.
|
||||
pub fn follow(
|
||||
app: &PhotocraftApp,
|
||||
ctx: &Context,
|
||||
class: ViewportClass,
|
||||
viewport: ViewportId,
|
||||
window: &DocWindow,
|
||||
after: &mut View,
|
||||
rect: Rect,
|
||||
) -> Option<Vec2> {
|
||||
if class != ViewportClass::Immediate {
|
||||
return None;
|
||||
}
|
||||
// Still recorded while maximized, so leaving it doesn't resize for a zoom made meanwhile.
|
||||
let pinned = ctx.input(|i| i.viewport().maximized == Some(true) || i.viewport().fullscreen == Some(true));
|
||||
let layout = Layout {
|
||||
ppp: ctx.pixels_per_point(),
|
||||
aspect: app.ui.view.display_aspect(),
|
||||
chrome: rect.size() - crate::rulers::content_rect(app, rect).size(),
|
||||
bars: if app.ui.view.shows_scrollbars() { crate::scrollbars::THICKNESS } else { 0.0 },
|
||||
flip: app.ui.view.flip_horizontal,
|
||||
monitor: ctx.input(|i| i.viewport().monitor_size),
|
||||
};
|
||||
let size = resize_for(ctx, window.id, app.session.prefs().general.zoom_resizes_windows, &window.view, after, &layout)?;
|
||||
let on = app.session.prefs().general.zoom_resizes_windows && !pinned;
|
||||
let size = resize_for(ctx, window.id, on, &window.view, after, &layout)?;
|
||||
ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(size));
|
||||
if let Some(center) = wrapped_center(after, &layout) {
|
||||
after.center = center;
|
||||
// The canvas drew before this: show the centred view on the next frame.
|
||||
ctx.request_repaint_of(viewport);
|
||||
}
|
||||
Some(size)
|
||||
}
|
||||
|
||||
@@ -112,7 +171,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn layout() -> Layout {
|
||||
Layout { ppp: 1.0, aspect: 1.0, chrome: Vec2::ZERO, monitor: Some(vec2(1920.0, 1080.0)) }
|
||||
Layout { ppp: 1.0, aspect: 1.0, chrome: Vec2::ZERO, bars: 0.0, flip: false, monitor: Some(vec2(1920.0, 1080.0)) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -215,13 +274,16 @@ mod tests {
|
||||
let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let at = |zoom| view([400, 300], zoom);
|
||||
let window = DocWindow { id: 3, document: 0, view: at(1.0), open: true, position: None };
|
||||
let frame = |app: &PhotocraftApp, class, after: &View| follow(app, &ctx, class, viewport, &window, after, rect);
|
||||
let frame = |app: &PhotocraftApp, class, after: &View| follow(app, &ctx, class, viewport, &window, &mut after.clone(), rect);
|
||||
// Off by default, as in Photoshop.
|
||||
assert!(!app.session.prefs().general.zoom_resizes_windows);
|
||||
assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), None);
|
||||
assert_eq!(frame(&app, ViewportClass::Immediate, &at(2.0)), None);
|
||||
app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap();
|
||||
assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), Some(vec2(400.0, 300.0)));
|
||||
// The scrollbars are drawn over the canvas: the window makes room for them.
|
||||
assert!(app.ui.view.shows_scrollbars());
|
||||
let bars = Vec2::splat(crate::scrollbars::THICKNESS);
|
||||
assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), Some(vec2(400.0, 300.0) + bars));
|
||||
// Drawn inside the main window, a resize command would resize the main window: skipped.
|
||||
assert_eq!(frame(&app, ViewportClass::EmbeddedWindow, &at(2.0)), None);
|
||||
// With the rulers on, the window makes room for them.
|
||||
@@ -230,6 +292,117 @@ mod tests {
|
||||
assert!(ruled.x > 800.0 && ruled.y > 600.0, "{ruled:?}");
|
||||
}
|
||||
|
||||
/// Where the image lands in a window of the wrapped size, and the canvas area the scrollbars
|
||||
/// leave free, both in points from the window's top-left.
|
||||
fn landing(view: &View, layout: &Layout) -> (Rect, Rect) {
|
||||
let size = wrapped_size(view, layout).unwrap();
|
||||
let canvas = Rect::from_min_max(Pos2::ZERO + layout.chrome, Pos2::ZERO + size);
|
||||
let free = Rect::from_min_max(canvas.min, canvas.max - Vec2::splat(layout.bars));
|
||||
let xf = crate::canvas::ViewXform {
|
||||
rect: canvas,
|
||||
zoom: view.zoom / layout.ppp,
|
||||
center: view.center,
|
||||
flip: layout.flip,
|
||||
rotation: view.rotation,
|
||||
aspect: layout.aspect,
|
||||
};
|
||||
(xf.image_bounds(view.doc_size), free)
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
(a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_makes_room_for_the_scrollbars() {
|
||||
let barred = Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() };
|
||||
assert_eq!(wrapped_size(&view([400, 300], 1.0), &barred), Some(vec2(428.0, 328.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_window_shows_the_whole_image_centred() {
|
||||
// Zoomed in about the top-left corner: the view centre is far from the image's middle.
|
||||
let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) };
|
||||
for layout in [
|
||||
layout(),
|
||||
Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() },
|
||||
Layout { bars: 12.0, flip: true, ..layout() },
|
||||
Layout { bars: 12.0, ppp: 2.0, aspect: 1.5, ..layout() },
|
||||
] {
|
||||
for rotation in [0.0, 90.0, -30.0] {
|
||||
let mut v = View { rotation, ..zoomed.clone() };
|
||||
let (before, free) = landing(&v, &layout);
|
||||
assert!(!near(before, free), "{layout:?} {rotation}: off centre before");
|
||||
v.center = wrapped_center(&v, &layout).unwrap();
|
||||
let (after, free) = landing(&v, &layout);
|
||||
assert!(near(after, free), "{layout:?} {rotation}: {after:?} fills {free:?}");
|
||||
}
|
||||
}
|
||||
// Zoomed far out the window keeps its minimum size, and the image sits in its middle.
|
||||
let small = View { center: [0.0, 0.0], ..view([400, 300], 0.1) };
|
||||
let c = wrapped_center(&small, &layout()).unwrap();
|
||||
assert!((c[0] - 200.0).abs() < 0.01 && (c[1] - 150.0).abs() < 0.01, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_the_screen_caps_keeps_the_zoomed_spot() {
|
||||
let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 16.0) };
|
||||
assert_eq!(wrapped_center(&zoomed, &layout()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follow_centres_the_image_in_the_grown_window() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.sync_views();
|
||||
app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap();
|
||||
let ctx = Context::default();
|
||||
let viewport = ViewportId::from_hash_of(("docwin", 4_u64));
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let window = DocWindow { id: 4, document: 0, view: view([400, 300], 1.0), open: true, position: None };
|
||||
let mut first = view([400, 300], 1.0);
|
||||
assert_eq!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut first, rect), None);
|
||||
// A Zoom tool click near the top-left corner doubles the zoom about that spot.
|
||||
let mut zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) };
|
||||
assert!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut zoomed, rect).is_some());
|
||||
let half_bar = crate::scrollbars::THICKNESS / 2.0 / 2.0;
|
||||
assert_eq!(zoomed.center, [200.0 + half_bar, 150.0 + half_bar], "the whole image, centred");
|
||||
assert_eq!(zoomed.zoom, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximized_or_full_screen_window_keeps_its_size() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.sync_views();
|
||||
app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let window = DocWindow { id: 5, document: 0, view: view([400, 300], 1.0), open: true, position: None };
|
||||
for (maximized, fullscreen) in [(true, false), (false, true)] {
|
||||
let ctx = Context::default();
|
||||
let mut raw = egui::RawInput::default();
|
||||
let info = egui::ViewportInfo { maximized: Some(maximized), fullscreen: Some(fullscreen), ..Default::default() };
|
||||
raw.viewports.insert(ViewportId::ROOT, info);
|
||||
let viewport = ViewportId::ROOT;
|
||||
let mut sizes = Vec::new();
|
||||
let frame = |raw: egui::RawInput, zoom: f32, sizes: &mut Vec<Option<Vec2>>| {
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let mut v = View { center: [60.0, 40.0], ..view([400, 300], zoom) };
|
||||
sizes.push(follow(&app, ui.ctx(), ViewportClass::Immediate, viewport, &window, &mut v, rect));
|
||||
assert_eq!(v.center, [60.0, 40.0]);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let mut normal = egui::RawInput::default();
|
||||
normal.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { maximized: Some(false), fullscreen: Some(false), ..Default::default() });
|
||||
frame(normal.clone(), 1.0, &mut sizes);
|
||||
frame(raw, 2.0, &mut sizes);
|
||||
// Back to a normal window: the zoom made meanwhile is not caught up on.
|
||||
frame(normal, 2.0, &mut sizes);
|
||||
assert_eq!(sizes, [None, None, None], "maximized {maximized}, full screen {fullscreen}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_zoom_tool_options_bar_shows_the_same_setting() {
|
||||
// Photoshop's Zoom tool option Resize Windows to Fit is Preferences › Zoom Resizes Windows.
|
||||
|
||||
Reference in new issue
Block a user