diff --git a/src/main.rs b/src/main.rs index 6547cb8..bb9b865 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,3 @@ -// Zoom lands in pieces; Task 3 removes this. -#![allow(dead_code)] - mod capture; mod config; mod detach; @@ -66,6 +63,11 @@ enum Command { #[command(flatten)] common: Common, }, + /// Freeze the screen and zoom in: wheel, drag, f, c, 0, Esc. + Zoom { + #[command(flatten)] + common: Common, + }, /// Report what the compositor and system support. Doctor, /// Internal: the process that shows preview thumbnails. @@ -106,6 +108,7 @@ enum Mode { Screen { all: bool }, Region, Window, + Zoom, } fn main() -> ExitCode { @@ -139,6 +142,7 @@ fn run(cli: Cli) -> Result<()> { Command::Screen { all, common } => capture(Mode::Screen { all }, common), Command::Region { common } => capture(Mode::Region, common), Command::Window { common } => capture(Mode::Window, common), + Command::Zoom { common } => capture(Mode::Zoom, common), } } @@ -188,6 +192,20 @@ fn capture(mode: Mode, common: Common) -> Result<()> { } } Mode::Window => window_shot(&outputs, cursor)?, + 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(()); + } + } + } }; drop(wl); @@ -313,6 +331,8 @@ mod tests { assert!(parses(&["screen", "--all", "--no-preview"])); assert!(parses(&["window", "--cursor", "--delay", "2"])); assert!(!parses(&["window", "--clipboard-only", "-o", "a.png"])); + assert!(parses(&["zoom", "--cursor", "--no-preview"])); + assert!(!parses(&["zoom", "--clipboard-only", "-o", "a.png"])); } #[test] diff --git a/src/wayland.rs b/src/wayland.rs index a5b986b..812b874 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -42,6 +42,7 @@ use crate::error::HintExt; use crate::frame::OutputGeom; use crate::host::Host; use crate::region::Overlay; +use crate::zoom::Zoom; /// An output as valw sees it. #[derive(Debug, Clone)] @@ -80,6 +81,8 @@ pub struct State { pub captures: Vec, /// The region selection overlay, while it is open. pub overlay: Option, + /// Zoom mode, while it is open. + pub zoom: Option, /// The preview thumbnails, in the preview host process. pub preview: Option, } @@ -111,6 +114,7 @@ impl Wayland { pointer: None, captures: Vec::new(), overlay: None, + zoom: None, preview: None, }; // Two round trips: one for wl_output, one for the xdg-output details. @@ -297,6 +301,9 @@ impl CompositorHandler for State { if let Some(overlay) = &mut self.overlay { overlay.frame_done(surface, qh); } + if let Some(zoom) = &mut self.zoom { + zoom.frame_done(surface, qh); + } if let Some(preview) = &mut self.preview { preview.frame_done(surface, qh); } @@ -338,6 +345,9 @@ impl LayerShellHandler for State { if let Some(overlay) = &mut self.overlay { overlay.cancel(); } + if let Some(zoom) = &mut self.zoom { + zoom.closed(layer); + } if let Some(preview) = &mut self.preview { preview.closed(layer); } @@ -354,6 +364,9 @@ impl LayerShellHandler for State { if let Some(overlay) = &mut self.overlay { overlay.configure(layer, configure.new_size, qh); } + if let Some(zoom) = &mut self.zoom { + zoom.configure(layer, configure.new_size, qh); + } if let Some(preview) = &mut self.preview { preview.configure(layer, qh); } @@ -444,18 +457,20 @@ impl KeyboardHandler for State { fn press_key( &mut self, _: &Connection, - _: &QueueHandle, + qh: &QueueHandle, _: &wl_keyboard::WlKeyboard, _: u32, event: KeyEvent, ) { - let Some(overlay) = &mut self.overlay else { - return; - }; - match event.keysym { - Keysym::Escape => overlay.cancel(), - Keysym::space => overlay.switch_to_window(), - _ => {} + 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); } } @@ -485,10 +500,13 @@ impl KeyboardHandler for State { _: &QueueHandle, _: &wl_keyboard::WlKeyboard, _: u32, - _: Modifiers, + modifiers: Modifiers, _: RawModifiers, _: u32, ) { + if let Some(zoom) = &mut self.zoom { + zoom.modifiers(modifiers); + } } } @@ -503,6 +521,9 @@ impl PointerHandler for State { if let Some(overlay) = &mut self.overlay { overlay.pointer(events, self.cursor_device.as_ref(), qh); } + if let Some(zoom) = &mut self.zoom { + zoom.pointer(events, self.cursor_device.as_ref(), qh); + } if self.preview.is_some() { crate::host::pointer(self, events, qh); } diff --git a/src/zoom/mod.rs b/src/zoom/mod.rs index 429c905..07f683e 100644 --- a/src/zoom/mod.rs +++ b/src/zoom/mod.rs @@ -2,3 +2,335 @@ 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 { + 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, + outcome: Option, + error: Option, +} + +/// 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 { + 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) { + 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) { + if !self.is(surface) { + return; + } + self.frame_pending = false; + if self.dirty { + self.draw(qh); + } + } + + fn request_draw(&mut self, qh: &QueueHandle) { + self.dirty = true; + if self.configured && !self.frame_pending { + self.draw(qh); + } + } + + fn draw(&mut self, qh: &QueueHandle) { + 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_some((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, + ) { + 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) { + 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; + } +} + +#[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); + } +}