From d4b4fe06853e3d46a8e37d12cfa80d12742712ad Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 01:32:42 +0300 Subject: [PATCH] feat: valw toolbar --- src/main.rs | 60 ++++++- src/toolbar/mod.rs | 388 ++++++++++++++++++++++++++++++++++++++++++++- src/wayland.rs | 27 ++++ 3 files changed, 470 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index b29a4aa..e2efec0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,3 @@ -// The toolbar lands in pieces; Task 3 removes this. -#![allow(dead_code)] - mod capture; mod config; mod detach; @@ -78,6 +75,8 @@ enum Command { /// The image to edit. file: PathBuf, }, + /// Pick a mode from a floating bar (Cmd+Shift+5). + Toolbar, /// Report what the compositor and system support. Doctor, /// Internal: the process that shows preview thumbnails. @@ -157,6 +156,11 @@ fn run(cli: Cli) -> Result<()> { Command::Window { common } => capture(Mode::Window, common), Command::Zoom { common } => capture(Mode::Zoom, common), Command::Edit { file } => editor::run(&file), + Command::Toolbar => { + let config = config::load(&config::default_path())?; + let (mode, common) = from_toolbar(toolbar::run(&config)?); + capture(mode, common) + } Command::Clipboard => { let mut png = Vec::new(); std::io::Read::read_to_end(&mut std::io::stdin(), &mut png) @@ -260,8 +264,26 @@ fn window_shot(outputs: &[wayland::Output], cursor: bool) -> Result { Ok((vec![(image, None)], 0, source)) } +/// What the toolbar picked, as the command line would have said it. +fn from_toolbar(pick: toolbar::Picked) -> (Mode, Common) { + let mode = match pick.mode { + toolbar::state::Mode::Screen => Mode::Screen { all: false }, + toolbar::state::Mode::Window => Mode::Window, + toolbar::state::Mode::Region => Mode::Region, + toolbar::state::Mode::Zoom => Mode::Zoom, + }; + let common = Common { + clipboard_only: false, + output: None, + delay: None, + cursor: pick.cursor, + no_preview: !pick.preview, + }; + (mode, common) +} + /// Index of niri's focused output, or 0 if niri can't tell us. -fn focused_output(outputs: &[wayland::Output]) -> usize { +pub(crate) fn focused_output(outputs: &[wayland::Output]) -> usize { let name = match niri::focused_output() { Ok(Some(name)) => name, Ok(None) => return 0, @@ -356,6 +378,36 @@ mod tests { assert!(parses(&["edit", "a.png"])); assert!(!parses(&["edit"])); assert!(parses(&["__clipboard"])); + assert!(parses(&["toolbar"])); + } + + #[test] + fn a_toolbar_pick_becomes_a_capture() { + use toolbar::state::Mode as T; + let pick = |mode, cursor, preview| toolbar::Picked { + mode, + cursor, + preview, + }; + let (mode, common) = from_toolbar(pick(T::Screen, true, false)); + assert!(matches!(mode, Mode::Screen { all: false })); + assert!(common.cursor && common.no_preview); + assert!(matches!( + from_toolbar(pick(T::Window, false, true)).0, + Mode::Window + )); + assert!(matches!( + from_toolbar(pick(T::Region, false, true)).0, + Mode::Region + )); + let (mode, common) = from_toolbar(pick(T::Zoom, false, true)); + assert!(matches!(mode, Mode::Zoom)); + assert!( + !common.cursor + && !common.no_preview + && common.delay.is_none() + && common.output.is_none() + ); } #[test] diff --git a/src/toolbar/mod.rs b/src/toolbar/mod.rs index 15e8c1d..749f7a6 100644 --- a/src/toolbar/mod.rs +++ b/src/toolbar/mod.rs @@ -1,5 +1,391 @@ -//! `valw toolbar`: a floating bar to pick a mode. +//! `valw toolbar`: a floating bar to pick a mode, then the optional +//! countdown. The choice runs through the normal capture. pub mod draw; pub mod layout; pub mod state; + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use smithay_client_toolkit::{ + seat::{ + keyboard::Keysym, + pointer::{BTN_LEFT, PointerEvent, PointerEventKind}, + }, + shell::{ + WaylandSurface, + wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerSurface}, + }, + shm::slot::{Buffer, SlotPool}, +}; +use wayland_client::protocol::wl_shm; +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::layout::{Layout, MenuItem, Target}; +use self::state::{Mode, ToolbarState}; +use crate::config::Config; +use crate::error::{Cancelled, HintExt}; +use crate::wayland::{Output, Wayland}; + +pub const NAMESPACE: &str = "valw-toolbar"; +pub const COUNTDOWN_NAMESPACE: &str = "valw-countdown"; +const PILL: (f32, f32) = (72.0, 40.0); + +/// The mode and options the user picked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Picked { + pub mode: Mode, + pub cursor: bool, + pub preview: bool, +} + +/// Shows the bar, waits for a pick, saves it, runs the countdown. +pub fn run(config: &Config) -> Result { + let path = state::default_path(); + let mut wl = Wayland::connect()?; + let outputs = wl.outputs(); + anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); + let output = outputs[crate::focused_output(&outputs)].clone(); + let initial = state::load(&path, config); + + let picked = pick(&mut wl, &output, initial)?; + if let Err(e) = state::save(&path, &picked.0) { + tracing::warn!("could not remember the toolbar choice: {e:#}"); + } + countdown(&mut wl, &output, picked.0.timer)?; + let s = picked.0; + Ok(Picked { + mode: picked.1, + cursor: s.cursor, + preview: s.preview, + }) +} + +pub struct Toolbar { + layer: LayerSurface, + viewport: WpViewport, + pool: SlotPool, + buffer: Option, + scale: f64, + /// Logical size once configured. + size: Option<(u32, u32)>, + layout: Option, + state: ToolbarState, + hover: Option, + menu_open: bool, + outcome: Option>, +} + +fn pick(wl: &mut Wayland, output: &Output, initial: ToolbarState) -> Result<(ToolbarState, Mode)> { + 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 pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?; + wl.state.toolbar = Some(Toolbar { + layer, + viewport, + pool, + buffer: None, + scale: output.scale, + size: None, + layout: None, + state: initial, + hover: None, + menu_open: false, + outcome: None, + }); + let result = wl.dispatch_blocking(|s| s.toolbar.as_ref().is_some_and(|t| t.outcome.is_some())); + let toolbar = wl.state.toolbar.take(); + // The bar must be gone before anything is captured. + let _ = wl.conn.flush(); + let _ = wl.queue.roundtrip(&mut wl.state); + result?; + let toolbar = toolbar.context("the toolbar vanished")?; + match toolbar.outcome.flatten() { + Some(mode) => Ok(( + ToolbarState { + mode, + ..toolbar.state + }, + mode, + )), + None => Err(Cancelled.into()), + } +} + +impl Toolbar { + fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { + self.layer.wl_surface() == surface + } + + pub fn configure(&mut self, layer: &LayerSurface, (w, h): (u32, u32)) { + if !self.is(layer.wl_surface()) || w == 0 || h == 0 { + return; + } + self.size = Some((w, h)); + self.layout = Some(layout::layout((w as f32, h as f32), draw::measure)); + self.viewport.set_destination(w as i32, h as i32); + self.draw(); + } + + pub fn closed(&mut self, layer: &LayerSurface) { + if self.is(layer.wl_surface()) { + self.outcome.get_or_insert(None); + } + } + + fn draw(&mut self) { + let (Some((w, h)), Some(layout)) = (self.size, &self.layout) else { + return; + }; + let (pw, ph) = ( + (w as f64 * self.scale).round() as u32, + (h as f64 * self.scale).round() as u32, + ); + let pixmap = draw::bar( + layout, + (pw, ph), + self.scale as f32, + &self.state, + self.hover, + self.menu_open, + ); + let Ok((buffer, canvas)) = self.pool.create_buffer( + pw as i32, + ph as i32, + pw as i32 * 4, + wl_shm::Format::Argb8888, + ) else { + tracing::warn!("could not allocate a toolbar buffer"); + return; + }; + draw::to_argb(&pixmap, canvas); + let surface = self.layer.wl_surface(); + surface.damage_buffer(0, 0, pw as i32, ph as i32); + if buffer.attach_to(surface).is_err() { + tracing::warn!("could not attach the toolbar buffer"); + return; + } + self.layer.commit(); + self.buffer = Some(buffer); + } + + pub fn pointer(&mut self, events: &[PointerEvent], cursor: Option<&WpCursorShapeDeviceV1>) { + let Some(layout) = self.layout.clone() else { + return; + }; + for event in events { + if !self.is(&event.surface) { + continue; + } + let p = (event.position.0 as f32, event.position.1 as f32); + match event.kind { + PointerEventKind::Enter { serial } => { + if let Some(cursor) = cursor { + cursor.set_shape(serial, Shape::Default); + } + } + PointerEventKind::Motion { .. } => { + let hover = layout.hit(p, self.menu_open); + if hover != self.hover { + self.hover = hover; + self.draw(); + } + } + PointerEventKind::Press { + button: BTN_LEFT, .. + } => match layout.hit(p, self.menu_open) { + Some(Target::Mode(mode)) => self.outcome = Some(Some(mode)), + Some(Target::Options) => { + self.menu_open = !self.menu_open; + self.draw(); + } + Some(Target::Menu(item)) => { + match item { + MenuItem::Timer(t) => self.state.timer = t, + MenuItem::Cursor => self.state.cursor = !self.state.cursor, + MenuItem::Preview => self.state.preview = !self.state.preview, + } + self.draw(); + } + None if !layout.inside(p, self.menu_open) => self.outcome = Some(None), + None => {} + }, + _ => {} + } + } + } + + pub fn key(&mut self, key: Keysym) { + if key == Keysym::Escape { + self.outcome.get_or_insert(None); + } + } +} + +pub struct Pill { + layer: LayerSurface, + viewport: WpViewport, + pool: SlotPool, + buffer: Option, + scale: f64, + configured: bool, + text: String, + cancelled: bool, +} + +/// Shows `timer`, `timer - 1`, … 1, one second each; a click on the pill +/// cancels. The pill takes no keyboard focus, so the desktop stays usable. +fn countdown(wl: &mut Wayland, output: &Output, timer: u32) -> Result<()> { + if timer == 0 { + return Ok(()); + } + 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")?; + let viewporter = s + .viewporter + .as_ref() + .context("compositor does not support wp-viewporter")?; + let surface = s.compositor.create_surface(&qh); + let layer = layer_shell.create_layer_surface( + &qh, + surface, + Layer::Overlay, + Some(COUNTDOWN_NAMESPACE), + Some(&output.wl), + ); + layer.set_anchor(Anchor::BOTTOM); + layer.set_margin(0, 0, layout::MARGIN_BOTTOM as i32, 0); + layer.set_size(PILL.0 as u32, PILL.1 as u32); + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + layer.commit(); + let viewport = viewporter.get_viewport(layer.wl_surface(), &qh, ()); + let pool = SlotPool::new(4096, &s.shm).context("could not create shm pool")?; + wl.state.pill = Some(Pill { + layer, + viewport, + pool, + buffer: None, + scale: output.scale, + configured: false, + text: timer.to_string(), + cancelled: false, + }); + + let start = Instant::now(); + let mut result = Ok(()); + for left in (1..=timer).rev() { + if let Some(pill) = &mut wl.state.pill { + pill.set_text(&left.to_string()); + } + let until = start + Duration::from_secs((timer - left + 1) as u64); + // dispatch_until fails when the deadline passes: that is the tick. + let _ = wl.dispatch_until(until, |s| s.pill.as_ref().is_some_and(|p| p.cancelled)); + if wl.state.pill.as_ref().is_some_and(|p| p.cancelled) { + result = Err(Cancelled.into()); + break; + } + } + wl.state.pill = None; + // The pill must be gone before anything is captured. + let _ = wl.conn.flush(); + let _ = wl.queue.roundtrip(&mut wl.state); + result +} + +impl Pill { + fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { + self.layer.wl_surface() == surface + } + + pub fn configure(&mut self, layer: &LayerSurface) { + if self.is(layer.wl_surface()) { + self.configured = true; + self.viewport.set_destination(PILL.0 as i32, PILL.1 as i32); + self.draw(); + } + } + + fn set_text(&mut self, text: &str) { + self.text = text.to_string(); + self.draw(); + } + + fn draw(&mut self) { + if !self.configured { + return; + } + let (pw, ph) = ( + (PILL.0 as f64 * self.scale).round() as u32, + (PILL.1 as f64 * self.scale).round() as u32, + ); + let pixmap = draw::pill((pw, ph), self.scale as f32, &self.text); + let Ok((buffer, canvas)) = self.pool.create_buffer( + pw as i32, + ph as i32, + pw as i32 * 4, + wl_shm::Format::Argb8888, + ) else { + return; + }; + draw::to_argb(&pixmap, canvas); + let surface = self.layer.wl_surface(); + surface.damage_buffer(0, 0, pw as i32, ph as i32); + if buffer.attach_to(surface).is_ok() { + self.layer.commit(); + self.buffer = Some(buffer); + } + } + + pub fn pointer(&mut self, events: &[PointerEvent]) { + for event in events { + if self.is(&event.surface) + && matches!( + event.kind, + PointerEventKind::Press { + button: BTN_LEFT, + .. + } + ) + { + self.cancelled = true; + } + } + } + + pub fn closed(&mut self, layer: &LayerSurface) { + if self.is(layer.wl_surface()) { + self.cancelled = true; + } + } +} diff --git a/src/wayland.rs b/src/wayland.rs index 812b874..19b1fd8 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::toolbar::{Pill, Toolbar}; use crate::zoom::Zoom; /// An output as valw sees it. @@ -83,6 +84,9 @@ pub struct State { pub overlay: Option, /// Zoom mode, while it is open. pub zoom: Option, + /// The toolbar and its countdown, while they are open. + pub toolbar: Option, + pub pill: Option, /// The preview thumbnails, in the preview host process. pub preview: Option, } @@ -115,6 +119,8 @@ impl Wayland { captures: Vec::new(), overlay: None, zoom: None, + toolbar: None, + pill: None, preview: None, }; // Two round trips: one for wl_output, one for the xdg-output details. @@ -348,6 +354,12 @@ impl LayerShellHandler for State { if let Some(zoom) = &mut self.zoom { zoom.closed(layer); } + if let Some(toolbar) = &mut self.toolbar { + toolbar.closed(layer); + } + if let Some(pill) = &mut self.pill { + pill.closed(layer); + } if let Some(preview) = &mut self.preview { preview.closed(layer); } @@ -367,6 +379,12 @@ impl LayerShellHandler for State { if let Some(zoom) = &mut self.zoom { zoom.configure(layer, configure.new_size, qh); } + if let Some(toolbar) = &mut self.toolbar { + toolbar.configure(layer, configure.new_size); + } + if let Some(pill) = &mut self.pill { + pill.configure(layer); + } if let Some(preview) = &mut self.preview { preview.configure(layer, qh); } @@ -472,6 +490,9 @@ impl KeyboardHandler for State { if let Some(zoom) = &mut self.zoom { zoom.key(event.keysym, qh); } + if let Some(toolbar) = &mut self.toolbar { + toolbar.key(event.keysym); + } } fn repeat_key( @@ -524,6 +545,12 @@ impl PointerHandler for State { if let Some(zoom) = &mut self.zoom { zoom.pointer(events, self.cursor_device.as_ref(), qh); } + if let Some(toolbar) = &mut self.toolbar { + toolbar.pointer(events, self.cursor_device.as_ref()); + } + if let Some(pill) = &mut self.pill { + pill.pointer(events); + } if self.preview.is_some() { crate::host::pointer(self, events, qh); }