42 KiB
valw Phase 4: zoom mode Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: valw zoom freezes the focused output and shows it zoomable (wheel), pannable (drag), with a flashlight (f), and c captures the visible area.
Architecture: Pure view math in zoom/view.rs; an EGL + OpenGL ES 3 renderer in zoom/gl.rs (one fullscreen triangle, texelFetch from the frozen frame, flashlight in the fragment shader); zoom/mod.rs owns the layer surface, input and the frame-callback-driven draw loop. The whole program moves to wayland-backend's client_system so EGL can use the C wl_display*.
Tech Stack: Rust 2024, SCTK 0.21, wayland-backend (client_system), khronos-egl 6 (dynamic), libloading 0.8, wayland-egl 0.32, glow 0.18.
Spec: docs/superpowers/specs/2026-10-02-valw-phase-4-zoom-design.md
Global Constraints
- jj only (
jj commit -m), never git.cargo clippy -- -D warnings,cargo fmt --check,cargo nextest run,nix flake check. - No Vulkan, no wgpu. OpenGL ES 3.0,
#version 300 es. - Scale range 1–32; flashlight radius 20–2000 logical px, default 180;
scroll_stepdefault 1.15; touchpad: one step per 15 logical px; flashlight outside = 25 % brightness; animation ≈ 90 % in 120 ms. - Namespace
valw-zoom; Esc/qexit 0 with nothing saved. - Tell the user before running zoom against the live session (it takes the keyboard).
Review Focus
- Zoom keeps the point under the pointer, at every scale and near the edges where clamping kicks in. Tests:
view::tests::zoom_keeps_the_point_under_the_pointer,zoom_out_near_an_edge_stays_inside(Task 1). cafter zooming must crop exactly the visible pixels, never outside the frame, never empty. Test:view::tests::visible_rect_*(Task 1).- Touchpad scrolling (no
value120) must zoom smoothly, not in jumps or not at all. Test:view::tests::scroll_steps_from_wheel_and_touchpad(Task 1). - The switch to libwayland-client must not change region/screen/preview behaviour. Covered by the existing suite and the headless sway check (Task 4).
- No EGL / no GLES 3 (other machines, the headless check):
valw zoomfails clearly,valw doctorwarns, other modes unaffected. Test:doctor::tests::egl_check_levels(Task 2).
File Structure
| File | Change | Task |
|---|---|---|
src/zoom/view.rs (new) |
View, Animated, scroll_steps, radius |
1 |
src/zoom/mod.rs (new) |
Task 1: pub mod view; only. Task 3: Zoom, Outcome, Command, command(), run() |
1, 3 |
src/config.rs |
[zoom] |
1 |
src/zoom/gl.rs (new) |
probe, Renderer |
2 |
src/doctor.rs |
check_egl |
2 |
Cargo.toml, nix/package.ulu.nix |
deps, client_system, LD_LIBRARY_PATH |
2 |
src/wayland.rs |
State::zoom and event routing |
3 |
src/main.rs |
Command::Zoom, Mode::Zoom |
3 |
docs/test-checklist.md |
Phase 4 section | 4 |
Task 1: View math and config
Files: Create src/zoom/mod.rs, src/zoom/view.rs; modify src/main.rs (mod zoom; + allowance), src/config.rs.
Interfaces — Produces:
-
zoom::view::{View, Animated, scroll_steps, radius, MIN_SCALE, MAX_SCALE, MIN_RADIUS, MAX_RADIUS} -
View { scale: f64, origin: (f64, f64) },View::IDENTITY,to_frame(p),zoom_at(p, factor, size) -> View,pan(delta, size) -> View,clamped(size) -> View,visible_rect((u32, u32)) -> PixelRect -
Animated { shown: View, target: View },Animated::new(),set(View),step(dt) -> bool(true while moving) -
scroll_steps(value120: i32, absolute: f64) -> f64(positive = zoom in),radius(r, steps, step) -> f64 -
config::Zoom { scroll_step: f64, flashlight_radius: f64 },Config::zoom -
Step 1: Failing tests
src/zoom/mod.rs:
//! Zoom mode: the frozen focused output, zoomed and panned on the GPU.
pub mod view;
At the top of src/main.rs: // Zoom lands in pieces; Task 3 removes this. + #![allow(dead_code)] + empty line; add mod zoom; after mod window;.
src/zoom/view.rs with only:
#[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);
}
}
In src/config.rs tests, change unknown_keys_are_reported_not_rejected to use an [editor] table (zoom becomes known):
let text = "[editor]\nbackend = \"satty\"\n\n[save]\nfolder = \"x\"\n";
let (config, unknown) = parse(text).unwrap();
assert_eq!(config, Config::default());
assert_eq!(unknown, vec!["editor".to_string(), "save.folder".to_string()]);
and add:
#[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"
);
}
- Step 2: Run, watch them fail
Run: cargo nextest run zoom:: config::
Expected: compilation fails (View, Animated, scroll_steps, radius, Zoom missing).
- Step 3: Implement
view.rs(above the tests)
//! 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)
}
(Check visible_rect_rounds_outwards_inside_the_frame: origin 100.5, width 1920/4 = 480 → start 100, end ceil(580.5) = 581 → 481; y: 200.25 + 270 = 470.25 → end 471, start 200 → 271. Edge: 1440 + 480 = 1920 → 480; 810 + 270 = 1080 → 270.)
- Step 4: Implement
config::Zoom
In src/config.rs: add pub zoom: Zoom, to Config; add
#[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,
}
}
}
and in parse, after the timeout check:
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");
}
- Step 5: Run
Run: cargo nextest run → all pass (previous 121 + 10 view + 1 config). cargo clippy -- -D warnings, cargo fmt --check clean. (Animated::new without Default may trip clippy::new_without_default; if so add impl Default for Animated delegating to new and ledger it.)
- Step 6: Commit —
jj commit -m "feat: zoom view math and config"
Task 2: EGL/GLES renderer, backend switch, doctor, packaging
Files: Modify Cargo.toml, nix/package.ulu.nix, src/doctor.rs, src/zoom/mod.rs; create src/zoom/gl.rs.
Interfaces — Produces:
-
zoom::gl::probe(conn: &Connection) -> Result<String>(EGL vendor when a GLES 3 window config exists) -
zoom::gl::Renderer::new(conn: &Connection, surface: &WlSurface, size: (u32, u32), frame: &Bgrx) -> Result<Renderer>;Renderer::draw(&self, view: &View, flashlight: Option<(f64, f64, f64)>) -> Result<()>(centre and radius in buffer px);Dropreleases EGL. -
doctor::check_egl(result: Result<String>) -> Check -
Step 1: Failing test — in
src/doctor.rstests:
#[test]
fn egl_check_levels() {
let ok = check_egl(Ok("Mesa Project".into()));
assert_eq!(ok.level, Level::Ok);
assert!(ok.text.contains("Mesa Project"), "{}", ok.text);
let missing = check_egl(Err(anyhow::anyhow!("could not load libEGL.so.1")));
assert_eq!(missing.level, Level::Warn, "only zoom needs it");
assert!(missing.text.contains("zoom"), "{}", missing.text);
}
(Check Check's field names in src/doctor.rs and use them; the test above assumes level and text.)
Run: cargo nextest run doctor:: → fails, check_egl missing.
- Step 2: Dependencies — in
Cargo.toml[dependencies](alphabetical):
glow = "0.18.0"
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
libloading = "0.8"
wayland-backend = { version = "0.3.17", features = ["client_system"] }
wayland-egl = "0.32.11"
- Step 3:
check_egland the doctor line
/// EGL with an OpenGL ES 3 config; only zoom needs it.
pub fn check_egl(result: Result<String>) -> Check {
match result {
Ok(vendor) => check(Level::Ok, format!("EGL ({vendor}) with OpenGL ES 3 (zoom)")),
Err(e) => check(
Level::Warn,
format!("no OpenGL ES 3 through EGL: {} (zoom won't work)", one_line(&e)),
),
}
}
In run, inside Ok(wl) => after the outputs loop: checks.push(check_egl(crate::zoom::gl::probe(&wl.conn)));
- Step 4:
src/zoom/gl.rs— addpub mod gl;tosrc/zoom/mod.rs.
//! OpenGL ES 3 through EGL on a Wayland surface: just enough to show the
//! frozen frame zoomed in.
use anyhow::{Context, Result, anyhow};
use glow::HasContext;
use khronos_egl as egl;
use wayland_client::{Connection, Proxy, protocol::wl_surface::WlSurface};
use super::view::View;
use crate::frame::Bgrx;
type Egl = egl::DynamicInstance<egl::EGL1_5>;
/// `EGL_PLATFORM_WAYLAND_KHR`.
const PLATFORM_WAYLAND: egl::Enum = 0x31D8;
const VERTEX: &str = "#version 300 es
// One triangle that covers the screen.
void main() {
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
";
const FRAGMENT: &str = "#version 300 es
precision highp float;
uniform sampler2D frame;
uniform float scale;
// The frame pixel at the screen's top-left.
uniform vec2 origin;
// Centre and radius in screen px; radius 0 means off.
uniform vec3 flashlight;
out vec4 color;
void main() {
ivec2 size = textureSize(frame, 0);
// gl_FragCoord starts at the bottom-left; the frame at the top-left.
vec2 screen = vec2(gl_FragCoord.x, float(size.y) - gl_FragCoord.y);
ivec2 texel = clamp(ivec2(floor(origin + screen / scale)), ivec2(0), size - 1);
// The texture holds the frame's B, G, R, X bytes.
vec3 rgb = texelFetch(frame, texel, 0).bgr;
if (flashlight.z > 0.0) {
float d = distance(screen, flashlight.xy);
rgb *= mix(1.0, 0.25, smoothstep(flashlight.z - 1.5, flashlight.z + 1.5, d));
}
color = vec4(rgb, 1.0);
}
";
/// Loads libEGL and opens the compositor's EGL display.
fn open(conn: &Connection) -> Result<(Egl, egl::Display)> {
let lib = unsafe { libloading::Library::new("libEGL.so.1") }
.context("could not load libEGL.so.1")?;
let egl = unsafe { Egl::load_required_from(lib) }
.map_err(|e| anyhow!("libEGL.so.1 has no EGL 1.5: {e}"))?;
let display = unsafe {
egl.get_platform_display(
PLATFORM_WAYLAND,
conn.backend().display_ptr().cast(),
&[egl::ATTRIB_NONE],
)
}
.context("no EGL display for the Wayland connection")?;
egl.initialize(display).context("could not initialise EGL")?;
Ok((egl, display))
}
fn config(egl: &Egl, display: egl::Display) -> Result<egl::Config> {
#[rustfmt::skip]
let attributes = [
egl::SURFACE_TYPE, egl::WINDOW_BIT,
egl::RENDERABLE_TYPE, egl::OPENGL_ES3_BIT,
egl::RED_SIZE, 8, egl::GREEN_SIZE, 8, egl::BLUE_SIZE, 8,
egl::NONE,
];
egl.choose_first_config(display, &attributes)
.context("eglChooseConfig failed")?
.context("no EGL config for OpenGL ES 3 windows")
}
/// The EGL vendor, if a window config for OpenGL ES 3 exists.
pub fn probe(conn: &Connection) -> Result<String> {
let (egl, display) = open(conn)?;
let result = config(&egl, display).map(|_| {
egl.query_string(Some(display), egl::VENDOR)
.map(|v| v.to_string_lossy().into_owned())
.unwrap_or_else(|_| "unknown vendor".into())
});
let _ = egl.terminate(display);
result
}
/// Draws the frozen frame on one surface.
pub struct Renderer {
gl: glow::Context,
scale: glow::UniformLocation,
origin: glow::UniformLocation,
flashlight: glow::UniformLocation,
egl: Egl,
display: egl::Display,
context: egl::Context,
surface: egl::Surface,
// Destroyed after the EGL surface that draws into it.
_window: wayland_egl::WlEglSurface,
}
impl Renderer {
/// A GL surface of `size` buffer px on `surface`, showing `frame`.
pub fn new(
conn: &Connection,
surface: &WlSurface,
size: (u32, u32),
frame: &Bgrx,
) -> Result<Renderer> {
let (egl, display) = open(conn)?;
egl.bind_api(egl::OPENGL_ES_API)
.context("no OpenGL ES in EGL")?;
let config = config(&egl, display)?;
let context = egl
.create_context(display, config, None, &[egl::CONTEXT_MAJOR_VERSION, 3, egl::NONE])
.context("could not create an OpenGL ES 3 context")?;
let window = wayland_egl::WlEglSurface::new(surface.id(), size.0 as i32, size.1 as i32)
.context("could not create a wl_egl_window")?;
let egl_surface = unsafe {
egl.create_window_surface(display, config, window.ptr() as egl::NativeWindowType, None)
}
.context("could not create the EGL window surface")?;
egl.make_current(display, Some(egl_surface), Some(egl_surface), Some(context))
.context("eglMakeCurrent failed")?;
// valw paces drawing with frame callbacks; swaps must not block.
egl.swap_interval(display, 0)
.context("eglSwapInterval failed")?;
let gl = unsafe {
glow::Context::from_loader_function(|name| {
egl.get_proc_address(name)
.map_or(std::ptr::null(), |f| f as *const _)
})
};
let (scale, origin, flashlight) = unsafe { setup(&gl, size, frame)? };
Ok(Renderer {
gl,
scale,
origin,
flashlight,
egl,
display,
context,
surface: egl_surface,
_window: window,
})
}
/// Draws `view` and swaps (which commits the surface).
pub fn draw(&self, view: &View, flashlight: Option<(f64, f64, f64)>) -> Result<()> {
let (x, y, r) = flashlight.unwrap_or_default();
unsafe {
self.gl.uniform_1_f32(Some(&self.scale), view.scale as f32);
self.gl
.uniform_2_f32(Some(&self.origin), view.origin.0 as f32, view.origin.1 as f32);
self.gl
.uniform_3_f32(Some(&self.flashlight), x as f32, y as f32, r as f32);
self.gl.draw_arrays(glow::TRIANGLES, 0, 3);
}
self.egl
.swap_buffers(self.display, self.surface)
.context("eglSwapBuffers failed")
}
}
impl Drop for Renderer {
fn drop(&mut self) {
let _ = self.egl.make_current(self.display, None, None, None);
let _ = self.egl.destroy_surface(self.display, self.surface);
let _ = self.egl.destroy_context(self.display, self.context);
let _ = self.egl.terminate(self.display);
}
}
/// Compiles the program, uploads the frame and returns the uniforms.
unsafe fn setup(
gl: &glow::Context,
size: (u32, u32),
frame: &Bgrx,
) -> Result<(glow::UniformLocation, glow::UniformLocation, glow::UniformLocation)> {
unsafe {
tracing::info!("zoom renderer: {}", gl.get_parameter_string(glow::RENDERER));
let program = gl.create_program().map_err(|e| anyhow!(e))?;
for (kind, source) in [(glow::VERTEX_SHADER, VERTEX), (glow::FRAGMENT_SHADER, FRAGMENT)] {
let shader = gl.create_shader(kind).map_err(|e| anyhow!(e))?;
gl.shader_source(shader, source);
gl.compile_shader(shader);
if !gl.get_shader_compile_status(shader) {
anyhow::bail!("shader: {}", gl.get_shader_info_log(shader));
}
gl.attach_shader(program, shader);
}
gl.link_program(program);
if !gl.get_program_link_status(program) {
anyhow::bail!("program: {}", gl.get_program_info_log(program));
}
gl.use_program(Some(program));
// GLES 3 needs a bound vertex array even without attributes.
let vao = gl.create_vertex_array().map_err(|e| anyhow!(e))?;
gl.bind_vertex_array(Some(vao));
let texture = gl.create_texture().map_err(|e| anyhow!(e))?;
gl.bind_texture(glow::TEXTURE_2D, Some(texture));
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA8 as i32,
frame.width() as i32,
frame.height() as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(frame.as_raw())),
);
gl.viewport(0, 0, size.0 as i32, size.1 as i32);
let uniform = |name: &str| {
gl.get_uniform_location(program, name)
.with_context(|| format!("no uniform {name}"))
};
gl.uniform_1_i32(uniform("frame").ok().as_ref(), 0);
Ok((uniform("scale")?, uniform("origin")?, uniform("flashlight")?))
}
}
(Exact glow 0.18 / khronos-egl 6 signatures may differ slightly, e.g. tex_image_2d's pixel argument or swap_interval's; adapt to what the compiler says and keep the behaviour.)
- Step 5: Packaging — in
nix/package.ulu.nix,postInstallbecomes:
# Clicking a preview opens Satty. --suffix keeps a user's own Satty first.
# Zoom loads libEGL.so.1 (libglvnd); Mesa's drivers come from the system.
postInstall = ''
wrapProgram $out/bin/valw \
--suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} \
--suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ pkgs.libglvnd ]}
'';
-
Step 6: Run —
cargo nextest run(all pass, +1), clippy and fmt clean. Thencargo run -q -- doctor 2>&1 | grep -i eglwithLD_LIBRARY_PATHset to libglvnd's lib dir (nix build --no-link --print-out-paths nixpkgs#libglvnd): expectok EGL (Mesa Project) with OpenGL ES 3 (zoom). doctor only reads; it's safe on the live session. -
Step 7: Commit —
jj commit -m "feat: EGL/GLES 3 renderer for zoom"
Task 3: The zoom mode
Files: Modify src/zoom/mod.rs, src/wayland.rs, src/main.rs.
Interfaces:
-
Consumes: Task 1
view::*,config::Zoom; Task 2gl::Renderer. -
Produces:
zoom::run(wl: &mut Wayland, output: &Output, frame: &Frame, config: &config::Zoom) -> Result<Outcome>;enum Outcome { Capture(PixelRect), Leave };enum Command { Leave, Capture, Flashlight, Reset },fn command(Keysym) -> Option<Command>;Zoom::{configure, frame_done, closed, pointer, key, modifiers};State::zoom: Option<Zoom>. -
Step 1: Failing tests — append to
src/zoom/mod.rs:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys() {
assert_eq!(command(Keysym::Escape), Some(Command::Leave));
assert_eq!(command(Keysym::q), Some(Command::Leave));
assert_eq!(command(Keysym::c), Some(Command::Capture));
assert_eq!(command(Keysym::f), Some(Command::Flashlight));
assert_eq!(command(Keysym::_0), Some(Command::Reset));
assert_eq!(command(Keysym::a), None);
}
}
and to flag_conflicts in src/main.rs:
assert!(parses(&["zoom", "--cursor", "--no-preview"]));
assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"]));
Run: cargo nextest run zoom::tests flag_conflicts → fails to compile (command missing).
- Step 2: Implement
src/zoom/mod.rs
//! Zoom mode: the frozen focused output, zoomed and panned on the GPU.
pub mod gl;
pub mod view;
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result};
use smithay_client_toolkit::{
compositor::FrameCallbackData,
seat::{
keyboard::{Keysym, Modifiers},
pointer::{BTN_LEFT, PointerEvent, PointerEventKind},
},
shell::{
WaylandSurface,
wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface},
},
};
use wayland_client::{QueueHandle, protocol::wl_surface::WlSurface};
use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{
Shape, WpCursorShapeDeviceV1,
};
use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport;
use self::view::{Animated, View};
use crate::config;
use crate::error::HintExt;
use crate::frame::{Frame, PixelRect};
use crate::wayland::{Output, State, Wayland};
pub const NAMESPACE: &str = "valw-zoom";
/// How a zoom session ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
/// `c`: this part of the frame.
Capture(PixelRect),
/// Esc or `q`: nothing to save.
Leave,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Leave,
Capture,
Flashlight,
Reset,
}
pub fn command(key: Keysym) -> Option<Command> {
match key {
Keysym::Escape | Keysym::q => Some(Command::Leave),
Keysym::c => Some(Command::Capture),
Keysym::f => Some(Command::Flashlight),
Keysym::_0 => Some(Command::Reset),
_ => None,
}
}
pub struct Zoom {
// Dropped first: the EGL surface goes before the wl_surface under it.
renderer: gl::Renderer,
layer: LayerSurface,
viewport: WpViewport,
/// Frame size in physical px.
size: (u32, u32),
/// Buffer px per surface-local (logical) px.
ratio: f64,
view: Animated,
step: f64,
/// Pointer position in buffer px.
pointer: (f64, f64),
/// Last pointer position while the left button pans.
panning: Option<(f64, f64)>,
flashlight: bool,
/// Flashlight radius in logical px.
radius: f64,
ctrl: bool,
configured: bool,
frame_pending: bool,
dirty: bool,
last_draw: Option<Instant>,
outcome: Option<Outcome>,
error: Option<anyhow::Error>,
}
/// Shows `frame` (the frozen `output`) until the user leaves or captures.
pub fn run(
wl: &mut Wayland,
output: &Output,
frame: &Frame,
config: &config::Zoom,
) -> Result<Outcome> {
let qh = wl.queue.handle();
let s = &wl.state;
let layer_shell = s
.layer_shell
.as_ref()
.context("compositor does not support wlr-layer-shell")
.hint("run `valw doctor` to see what the compositor supports")?;
let viewporter = s
.viewporter
.as_ref()
.context("compositor does not support wp-viewporter")
.hint("run `valw doctor` to see what the compositor supports")?;
let surface = s.compositor.create_surface(&qh);
let layer = layer_shell.create_layer_surface(
&qh,
surface,
Layer::Overlay,
Some(NAMESPACE),
Some(&output.wl),
);
layer.set_anchor(Anchor::all());
layer.set_exclusive_zone(-1);
layer.set_keyboard_interactivity(KeyboardInteractivity::Exclusive);
layer.commit();
let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ());
let pixels: &Arc<_> = &frame.pixels;
let size = pixels.dimensions();
let renderer = gl::Renderer::new(&wl.conn, layer.wl_surface(), size, pixels)
.context("could not start OpenGL ES for zoom")
.hint("run `valw doctor` to check EGL")?;
wl.state.zoom = Some(Zoom {
renderer,
layer,
viewport,
size,
ratio: 1.0,
view: Animated::new(),
step: config.scroll_step,
pointer: (0.0, 0.0),
panning: None,
flashlight: false,
radius: config.flashlight_radius,
ctrl: false,
configured: false,
frame_pending: false,
dirty: true,
last_draw: None,
outcome: None,
error: None,
});
let result = wl.dispatch_blocking(|s| {
s.zoom
.as_ref()
.is_some_and(|z| z.outcome.is_some() || z.error.is_some())
});
let ended = wl.state.zoom.take().map(|z| (z.outcome, z.error));
// Dropping the zoom destroys its surface; make that visible right away.
let _ = wl.conn.flush();
result?;
match ended {
Some((_, Some(e))) => Err(e),
Some((Some(outcome), None)) => Ok(outcome),
_ => Ok(Outcome::Leave),
}
}
impl Zoom {
fn is(&self, surface: &WlSurface) -> bool {
self.layer.wl_surface() == surface
}
fn sizef(&self) -> (f64, f64) {
(self.size.0 as f64, self.size.1 as f64)
}
pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32), qh: &QueueHandle<State>) {
if !self.is(layer.wl_surface()) {
return;
}
if w > 0 {
self.ratio = self.size.0 as f64 / w as f64;
}
// Show the physical-size buffer 1:1 on the logical-size surface.
self.viewport.set_destination(w as i32, h as i32);
self.configured = true;
self.request_draw(qh);
}
pub fn closed(&mut self, layer: &LayerSurface) {
if self.is(layer.wl_surface()) {
self.outcome.get_or_insert(Outcome::Leave);
}
}
pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle<State>) {
if !self.is(surface) {
return;
}
self.frame_pending = false;
if self.dirty {
self.draw(qh);
}
}
fn request_draw(&mut self, qh: &QueueHandle<State>) {
self.dirty = true;
if self.configured && !self.frame_pending {
self.draw(qh);
}
}
fn draw(&mut self, qh: &QueueHandle<State>) {
let now = Instant::now();
let dt = self
.last_draw
.map_or(0.0, |t| now.duration_since(t).as_secs_f64().min(0.1));
let moving = self.view.step(dt);
self.last_draw = moving.then_some(now);
let surface = self.layer.wl_surface();
surface.frame(qh, FrameCallbackData(surface.clone()));
let flashlight = self
.flashlight
.then(|| (self.pointer.0, self.pointer.1, self.radius * self.ratio));
if let Err(e) = self.renderer.draw(&self.view.shown, flashlight) {
self.error = Some(e);
return;
}
self.frame_pending = true;
self.dirty = moving;
}
pub fn pointer(
&mut self,
events: &[PointerEvent],
cursor: Option<&WpCursorShapeDeviceV1>,
qh: &QueueHandle<State>,
) {
for event in events {
if !self.is(&event.surface) {
continue;
}
let p = (event.position.0 * self.ratio, event.position.1 * self.ratio);
match event.kind {
PointerEventKind::Enter { serial } => {
if let Some(cursor) = cursor {
cursor.set_shape(serial, Shape::Default);
}
self.pointer = p;
}
PointerEventKind::Press {
button: BTN_LEFT,
serial,
..
} => {
self.panning = Some(p);
if let Some(cursor) = cursor {
cursor.set_shape(serial, Shape::Grabbing);
}
}
PointerEventKind::Release {
button: BTN_LEFT,
serial,
..
} => {
self.panning = None;
if let Some(cursor) = cursor {
cursor.set_shape(serial, Shape::Default);
}
}
PointerEventKind::Motion { .. } => {
self.pointer = p;
if let Some(last) = self.panning {
let size = self.sizef();
let moved = self.view.target.pan((p.0 - last.0, p.1 - last.1), size);
self.view.set(moved);
self.panning = Some(p);
self.request_draw(qh);
} else if self.flashlight {
self.request_draw(qh);
}
}
PointerEventKind::Axis { vertical, .. } => {
let steps = view::scroll_steps(vertical.value120, vertical.absolute);
if steps == 0.0 {
continue;
}
if self.ctrl {
self.radius = view::radius(self.radius, steps, self.step);
} else {
let size = self.sizef();
self.view.target =
self.view.target.zoom_at(p, self.step.powf(steps), size);
}
self.request_draw(qh);
}
_ => {}
}
}
}
pub fn key(&mut self, key: Keysym, qh: &QueueHandle<State>) {
match command(key) {
Some(Command::Leave) => {
self.outcome.get_or_insert(Outcome::Leave);
}
Some(Command::Capture) => {
let rect = self.view.target.visible_rect(self.size);
tracing::info!("zoom capture {rect:?} at {:.2}x", self.view.target.scale);
self.outcome.get_or_insert(Outcome::Capture(rect));
}
Some(Command::Flashlight) => {
self.flashlight = !self.flashlight;
self.request_draw(qh);
}
Some(Command::Reset) => {
self.view.target = View::IDENTITY;
self.request_draw(qh);
}
None => {}
}
}
pub fn modifiers(&mut self, modifiers: Modifiers) {
self.ctrl = modifiers.ctrl;
}
}
-
Step 3: Route events in
src/wayland.rs -
use crate::zoom::Zoom;; field/// Zoom mode, while it is open.pub zoom: Option<Zoom>,afteroverlay;zoom: None,inconnect. -
frame: addif let Some(zoom) = &mut self.zoom { zoom.frame_done(surface, qh); }. -
closed: addif let Some(zoom) = &mut self.zoom { zoom.closed(layer); }. -
configure: addif let Some(zoom) = &mut self.zoom { zoom.configure(layer, configure.new_size, qh); }. -
press_key(takeqhinstead of_):
if let Some(overlay) = &mut self.overlay {
match event.keysym {
Keysym::Escape => overlay.cancel(),
Keysym::space => overlay.switch_to_window(),
_ => {}
}
}
if let Some(zoom) = &mut self.zoom {
zoom.key(event.keysym, qh);
}
-
update_modifiers(name theModifiersargumentmodifiers):if let Some(zoom) = &mut self.zoom { zoom.modifiers(modifiers); }. -
pointer_frame: addif let Some(zoom) = &mut self.zoom { zoom.pointer(events, self.cursor_device.as_ref(), qh); }. -
Step 4:
src/main.rs
Remove the Task 1 allowance. enum Command after Window:
/// Freeze the screen and zoom in: wheel, drag, f, c, 0, Esc.
Zoom {
#[command(flatten)]
common: Common,
},
Mode::Zoom; in run: Command::Zoom { common } => capture(Mode::Zoom, common),; in capture's match, after Mode::Window:
Mode::Zoom => {
let focused = focused_output(&outputs);
let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0);
match zoom::run(&mut wl, &outputs[focused], &frame, &config.zoom)? {
zoom::Outcome::Capture(r) => {
let source = frame.output.name.clone();
(vec![(frame.to_rgba(r), None)], 0, source)
}
zoom::Outcome::Leave => {
tracing::info!("left zoom without capturing");
return Ok(());
}
}
}
-
Step 5: Run —
cargo nextest run(all pass, +1), clippy and fmt clean. -
Step 6: Commit —
jj commit -m "feat: valw zoom"
Task 4: Smoke run, checks and checklist
- Step 1: Smoke run (tell the user first: it covers the focused output and takes the keyboard for 3 s)
Run: LD_LIBRARY_PATH=<libglvnd>/lib timeout -s INT 3 cargo run -q -- zoom; echo exit=$? then read the newest log in ~/.local/state/valw/ (or wherever log::default_dir() points).
Expected: zoom renderer: Mesa Intel(R) HD Graphics 4000 …, no errors; exit from the signal.
-
Step 2: Full checks —
nix flake check -L --keep-going: all pass (headless sway now on libwayland-client). -
Step 3: Checklist — append to
docs/test-checklist.md:
## Zoom (Phase 4)
- [ ] `valw zoom` → the focused output freezes at 1×; Esc and `q` leave, nothing saved, exit 0, thumbnails come back.
- [ ] Wheel zooms towards the pointer (the point under it stays put), smoothly, up to 32×; pixels stay crisp.
- [ ] Touchpad scrolling zooms smoothly too.
- [ ] Left drag pans 1:1 and never shows anything outside the screenshot; the cursor turns into a grabbing hand.
- [ ] `f` toggles the flashlight (outside dimmed to 25 %), it follows the pointer; Ctrl+wheel resizes it.
- [ ] `0` animates back to 1×.
- [ ] `c` at some zoom → the saved image is exactly the visible area at native resolution; preview and clipboard as usual.
- [ ] On the 1366×768 laptop panel and the 1920×1080 monitor; stays smooth (no stutter) on the HD 4000.
- [ ] `[zoom] scroll_step` / `flashlight_radius` in the config take effect; `valw doctor` shows the EGL line.
- Step 4: Commit —
jj commit -m "docs: zoom checklist"