feat: zoom view math and config

This commit is contained in:
elwrcl committed 2026-10-02 17:11:00 +03:00
1 parent edc56e0dd0
commit 69399a45bb
4 files changed
+324 -2

No files matched your search

+50 -2
View File
@@ -11,6 +11,7 @@ pub struct Config {
pub save: Save, pub save: Save,
pub capture: Capture, pub capture: Capture,
pub preview: Preview, pub preview: Preview,
pub zoom: Zoom,
} }
#[derive(Debug, Clone, PartialEq, Deserialize)] #[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -34,6 +35,22 @@ pub struct Preview {
pub timeout_secs: u64, pub timeout_secs: u64,
} }
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default)]
pub struct Zoom {
pub scroll_step: f64,
pub flashlight_radius: f64,
}
impl Default for Zoom {
fn default() -> Self {
Self {
scroll_step: 1.15,
flashlight_radius: 180.0,
}
}
}
impl Default for Preview { impl Default for Preview {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -98,6 +115,12 @@ pub fn parse(text: &str) -> Result<(Config, Vec<String>)> {
if config.preview.timeout_secs == 0 { if config.preview.timeout_secs == 0 {
bail!("preview.timeout_secs must be at least 1"); bail!("preview.timeout_secs must be at least 1");
} }
if config.zoom.scroll_step <= 1.0 {
bail!("zoom.scroll_step must be greater than 1");
}
if !(20.0..=2000.0).contains(&config.zoom.flashlight_radius) {
bail!("zoom.flashlight_radius must be between 20 and 2000");
}
Ok((config, unknown)) Ok((config, unknown))
} }
@@ -134,10 +157,13 @@ mod tests {
#[test] #[test]
fn unknown_keys_are_reported_not_rejected() { fn unknown_keys_are_reported_not_rejected() {
let text = "[zoom]\nscroll_step = 1.15\n\n[save]\nfolder = \"x\"\n"; let text = "[editor]\nbackend = \"satty\"\n\n[save]\nfolder = \"x\"\n";
let (config, unknown) = parse(text).unwrap(); let (config, unknown) = parse(text).unwrap();
assert_eq!(config, Config::default()); assert_eq!(config, Config::default());
assert_eq!(unknown, vec!["save.folder".to_string(), "zoom".to_string()]); assert_eq!(
unknown,
vec!["editor".to_string(), "save.folder".to_string()]
);
} }
#[test] #[test]
@@ -172,6 +198,28 @@ mod tests {
assert!(parse("[preview]\ntimeout_secs = -3\n").is_err()); assert!(parse("[preview]\ntimeout_secs = -3\n").is_err());
} }
#[test]
fn zoom_defaults_overrides_and_bounds() {
assert_eq!(
Config::default().zoom,
Zoom {
scroll_step: 1.15,
flashlight_radius: 180.0
}
);
let (config, unknown) = parse("[zoom]\nscroll_step = 1.3\n").unwrap();
assert!(unknown.is_empty());
assert_eq!(config.zoom.scroll_step, 1.3);
assert_eq!(config.zoom.flashlight_radius, 180.0);
let err = parse("[zoom]\nscroll_step = 1.0\n").unwrap_err();
assert_eq!(err.to_string(), "zoom.scroll_step must be greater than 1");
let err = parse("[zoom]\nflashlight_radius = 5.0\n").unwrap_err();
assert_eq!(
err.to_string(),
"zoom.flashlight_radius must be between 20 and 2000"
);
}
#[test] #[test]
fn missing_file_gives_defaults() { fn missing_file_gives_defaults() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+4
View File
@@ -1,3 +1,6 @@
// Zoom lands in pieces; Task 3 removes this.
#![allow(dead_code)]
mod capture; mod capture;
mod config; mod config;
mod detach; mod detach;
@@ -18,6 +21,7 @@ mod stack;
mod thumbnail; mod thumbnail;
mod wayland; mod wayland;
mod window; mod window;
mod zoom;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
+3
View File
@@ -0,0 +1,3 @@
//! Zoom mode: the frozen focused output, zoomed and panned on the GPU.
pub mod view;
+267
View File
@@ -0,0 +1,267 @@
//! The zoom view: which part of the frozen frame fills the screen.
//!
//! Everything is in the frame's physical pixels. The surface's buffer has
//! the frame's size, so at 1× one screen pixel is one frame pixel.
use crate::frame::PixelRect;
pub const MIN_SCALE: f64 = 1.0;
pub const MAX_SCALE: f64 = 32.0;
pub const MIN_RADIUS: f64 = 20.0;
pub const MAX_RADIUS: f64 = 2000.0;
/// Logical px of touchpad scrolling per zoom step.
const TOUCHPAD_PX_PER_STEP: f64 = 15.0;
/// Per second: 1 - e^(-RATE * 0.12) ≈ 0.9, 90 % of the way in 120 ms.
const RATE: f64 = 19.2;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct View {
pub scale: f64,
/// The frame pixel at the screen's top-left corner.
pub origin: (f64, f64),
}
impl View {
pub const IDENTITY: View = View {
scale: 1.0,
origin: (0.0, 0.0),
};
/// The frame point under screen point `p`.
pub fn to_frame(self, p: (f64, f64)) -> (f64, f64) {
(
self.origin.0 + p.0 / self.scale,
self.origin.1 + p.1 / self.scale,
)
}
/// Zooms by `factor`, keeping the frame point under `p` in place.
pub fn zoom_at(&self, p: (f64, f64), factor: f64, size: (f64, f64)) -> View {
let f = self.to_frame(p);
let scale = (self.scale * factor).clamp(MIN_SCALE, MAX_SCALE);
View {
scale,
origin: (f.0 - p.0 / scale, f.1 - p.1 / scale),
}
.clamped(size)
}
/// Moves the image along with a pointer that moved `delta` screen px.
pub fn pan(&self, delta: (f64, f64), size: (f64, f64)) -> View {
View {
scale: self.scale,
origin: (
self.origin.0 - delta.0 / self.scale,
self.origin.1 - delta.1 / self.scale,
),
}
.clamped(size)
}
/// Keeps the view inside a frame of `size` px.
pub fn clamped(self, size: (f64, f64)) -> View {
let max = |len: f64| len - len / self.scale;
View {
scale: self.scale,
origin: (
self.origin.0.clamp(0.0, max(size.0)),
self.origin.1.clamp(0.0, max(size.1)),
),
}
}
/// The frame pixels on screen, rounded outwards and inside the frame.
pub fn visible_rect(&self, size: (u32, u32)) -> PixelRect {
let span = |origin: f64, len: u32| {
let len = len as f64;
let start = origin.floor().clamp(0.0, len - 1.0);
let end = (origin + len / self.scale).ceil().clamp(start + 1.0, len);
(start as u32, (end - start) as u32)
};
let (x, width) = span(self.origin.0, size.0);
let (y, height) = span(self.origin.1, size.1);
PixelRect {
x,
y,
width,
height,
}
}
}
/// The view on screen, easing towards the one asked for.
#[derive(Debug, Clone, Copy)]
pub struct Animated {
pub shown: View,
pub target: View,
}
impl Animated {
pub fn new() -> Self {
Animated {
shown: View::IDENTITY,
target: View::IDENTITY,
}
}
/// Jumps to `view` without easing (panning).
pub fn set(&mut self, view: View) {
self.shown = view;
self.target = view;
}
/// Advances by `dt` seconds. True while still moving.
pub fn step(&mut self, dt: f64) -> bool {
let k = 1.0 - (-RATE * dt).exp();
let (s, t) = (&mut self.shown, self.target);
let lerp = |a: f64, b: f64| a + (b - a) * k;
s.scale = lerp(s.scale, t.scale);
s.origin = (lerp(s.origin.0, t.origin.0), lerp(s.origin.1, t.origin.1));
let settled = (s.scale - t.scale).abs() < 1e-3
&& (s.origin.0 - t.origin.0).abs() < 0.05
&& (s.origin.1 - t.origin.1).abs() < 0.05;
if settled {
self.shown = t;
}
!settled
}
}
/// Zoom steps in one axis event: wheel notches (`value120`), or touchpad
/// pixels when there are none. Positive zooms in (scrolling up).
pub fn scroll_steps(value120: i32, absolute: f64) -> f64 {
if value120 != 0 {
-(value120 as f64) / 120.0
} else {
-absolute / TOUCHPAD_PX_PER_STEP
}
}
/// The flashlight radius after `steps` scroll steps of `step` each.
pub fn radius(r: f64, steps: f64, step: f64) -> f64 {
(r * step.powf(steps)).clamp(MIN_RADIUS, MAX_RADIUS)
}
#[cfg(test)]
mod tests {
use super::*;
const SIZE: (f64, f64) = (1920.0, 1080.0);
fn close(a: (f64, f64), b: (f64, f64)) -> bool {
(a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6
}
#[test]
fn zoom_keeps_the_point_under_the_pointer() {
for p in [(960.0, 540.0), (100.0, 900.0), (1500.0, 200.0)] {
let v = View::IDENTITY.zoom_at(p, 2.0, SIZE);
assert_eq!(v.scale, 2.0);
assert!(close(v.to_frame(p), p), "{p:?} -> {v:?}");
let w = v.zoom_at(p, 1.15, SIZE);
assert!(close(w.to_frame(p), v.to_frame(p)), "{p:?} -> {w:?}");
}
}
#[test]
fn scale_is_clamped() {
let v = View::IDENTITY.zoom_at((10.0, 10.0), 0.5, SIZE);
assert_eq!(v, View::IDENTITY);
let v = View::IDENTITY.zoom_at((10.0, 10.0), 1000.0, SIZE);
assert_eq!(v.scale, MAX_SCALE);
}
#[test]
fn zoom_out_near_an_edge_stays_inside() {
let v = View::IDENTITY.zoom_at((1900.0, 1070.0), 4.0, SIZE);
let out = v.zoom_at((0.0, 0.0), 0.5, SIZE);
assert_eq!(out.scale, 2.0);
assert!(
out.origin.0 >= 0.0 && out.origin.0 <= 1920.0 - 960.0,
"{out:?}"
);
assert!(
out.origin.1 >= 0.0 && out.origin.1 <= 1080.0 - 540.0,
"{out:?}"
);
}
#[test]
fn pan_moves_with_the_pointer_and_is_clamped() {
let v = View::IDENTITY.zoom_at((960.0, 540.0), 2.0, SIZE);
let p = v.pan((100.0, 0.0), SIZE);
assert!(close(p.origin, (v.origin.0 - 50.0, v.origin.1)), "{p:?}");
let far = v.pan((-1e6, 1e6), SIZE);
assert!(close(far.origin, (960.0, 0.0)), "{far:?}");
assert_eq!(View::IDENTITY.pan((50.0, 50.0), SIZE), View::IDENTITY);
}
#[test]
fn visible_rect_at_identity_is_the_frame() {
let r = View::IDENTITY.visible_rect((1920, 1080));
assert_eq!((r.x, r.y, r.width, r.height), (0, 0, 1920, 1080));
}
#[test]
fn visible_rect_rounds_outwards_inside_the_frame() {
let v = View {
scale: 4.0,
origin: (100.5, 200.25),
};
let r = v.visible_rect((1920, 1080));
assert_eq!((r.x, r.y, r.width, r.height), (100, 200, 481, 271));
let edge = View {
scale: 4.0,
origin: (1440.0, 810.0),
};
let r = edge.visible_rect((1920, 1080));
assert_eq!((r.x, r.y, r.width, r.height), (1440, 810, 480, 270));
}
#[test]
fn animation_reaches_the_target_and_stops() {
let mut a = Animated::new();
a.target = View::IDENTITY.zoom_at((960.0, 540.0), 4.0, SIZE);
let mut frames = 0;
while a.step(1.0 / 60.0) {
frames += 1;
assert!(frames < 120, "never settles");
}
assert_eq!(a.shown, a.target);
// About 90 % of the way after 120 ms.
let mut b = Animated::new();
b.target = View {
scale: 11.0,
origin: (0.0, 0.0),
};
b.step(0.12);
assert!((b.shown.scale - 10.0).abs() < 0.2, "{:?}", b.shown);
}
#[test]
fn set_jumps() {
let mut a = Animated::new();
let v = View {
scale: 2.0,
origin: (5.0, 5.0),
};
a.set(v);
assert_eq!((a.shown, a.target), (v, v));
assert!(!a.step(0.016));
}
#[test]
fn scroll_steps_from_wheel_and_touchpad() {
assert_eq!(scroll_steps(-120, -15.0), 1.0, "one notch up zooms in");
assert_eq!(scroll_steps(240, 30.0), -2.0, "two notches down");
assert_eq!(scroll_steps(0, -7.5), 0.5, "touchpad: 15 px per step");
assert_eq!(scroll_steps(0, 0.0), 0.0);
}
#[test]
fn radius_steps_and_bounds() {
assert!((radius(180.0, 1.0, 1.15) - 207.0).abs() < 1e-9);
assert_eq!(radius(25.0, -10.0, 1.15), MIN_RADIUS);
assert_eq!(radius(1900.0, 10.0, 1.15), MAX_RADIUS);
}
}