From 124572fdf51cb8bfffeeb62b3292a0dc1c6308bc Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 01:30:02 +0300 Subject: [PATCH] docs: phase 6 toolbar implementation plan --- .../plans/2026-10-03-valw-phase-6-toolbar.md | 1051 +++++++++++++++++ 1 file changed, 1051 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md diff --git a/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md b/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md new file mode 100644 index 0000000..c4a725e --- /dev/null +++ b/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md @@ -0,0 +1,1051 @@ +# valw Phase 6: the toolbar 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 toolbar`: a floating bar (Screen, Window, Region, Zoom, Options ▾) that starts the chosen mode at once, with a remembered timer/cursor/preview. + +**Architecture:** Pure pieces first — `toolbar/state.rs` (remembered choices), `toolbar/layout.rs` (rectangles and hit-testing from measured label widths), `toolbar/draw.rs` (tiny-skia painting of the bar, menu and countdown pill into premultiplied pixels). Then `toolbar/mod.rs` puts the bar on a transparent full-output layer surface and the countdown on a small one, and `main.rs` turns the choice into the existing `capture(mode, common)`. + +**Tech Stack:** Rust 2024, SCTK 0.21 layer-shell + shm + viewporter, tiny-skia 0.12, the editor's `text` module (DejaVu Sans Bold). + +**Spec:** `docs/superpowers/specs/2026-10-03-valw-phase-6-toolbar-design.md` + +## Global Constraints + +- jj only; `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo nextest run`, `nix flake check`. +- Namespaces `valw-toolbar` and `valw-countdown`. Bar: 24 logical px above the bottom, centred; label 13 px white; background `#1c1c1e` alpha 230 (90 %); corner radius 12; buttons 32 px tall, 12 px side padding; bar padding 6 px. Menu rows 28 px. Selected mode fill white alpha 46; hover fill white alpha 26. +- State file `$XDG_STATE_HOME/valw/toolbar.toml` (fallback `~/.local/state`); defaults mode `region`, timer 0, cursor = `capture.show_cursor`, preview = `preview.enabled`. +- Esc / a click outside the bar and menu / the compositor closing the surface → `Cancelled` (exit 3). +- Tell the user before showing the toolbar on the live session. + +## Review Focus + +1. **The countdown or the bar ends up in the shot.** Both surfaces are destroyed and the connection flushed and round-tripped before `capture` starts (Task 3); real check in the checklist. +2. **A broken or hand-edited `toolbar.toml`** must not stop the toolbar. Test: `toolbar::state::tests::a_broken_file_falls_back` (Task 1). +3. **Hit-testing at the bar's edges and between buttons** (separators, padding) must not start a mode by accident. Test: `toolbar::layout::tests::gaps_hit_nothing` (Task 1). +4. **Fractional scale** (1.25): the bar must be crisp and hits must match what is drawn. Layout works in logical px, drawing scales by the output's scale (Task 2 test `draw::tests::scales`). +5. **The timer is meant for setting up the screen**: during the countdown the rest of the desktop must stay usable. Countdown surface takes no keyboard (Ruling below; Task 3). + +**Ruling (plan):** the spec gives the countdown pill exclusive keyboard focus so Esc can cancel. That would block typing during the countdown, which is what a timer is for. The pill takes no keyboard focus; a click on the pill cancels instead (its label shows the seconds; a hover shows "Cancel"). Cost if wrong: Esc doesn't cancel a timer. + +--- + +## File Structure + +| File | Change | Task | +|---|---|---| +| `src/toolbar/state.rs` | `Mode`, `ToolbarState`, `defaults`, `load`, `save`, `default_path` | 1 | +| `src/toolbar/layout.rs` | `Target`, `MenuItem`, `Rect`, `Layout`, `layout`, `Layout::hit` | 1 | +| `src/toolbar/draw.rs` | `bar`, `pill`, `to_argb` | 2 | +| `src/toolbar/mod.rs` | `run`, `Toolbar`, `Pill` surfaces | 1 (module list), 3 | +| `src/wayland.rs` | `State::{toolbar, pill}` routing | 3 | +| `src/main.rs` | `Command::Toolbar` | 3 | +| `docs/test-checklist.md` | Phase 6 section | 4 | + +--- + +### Task 1: State and layout + +**Files:** create `src/toolbar/mod.rs` (`pub mod layout; pub mod state;`), `src/toolbar/state.rs`, `src/toolbar/layout.rs`; `src/main.rs`: `mod toolbar;` plus `// The toolbar lands in pieces; Task 3 removes this.` `#![allow(dead_code)]` at the top. + +**Produces:** +- `state::Mode::{Screen, Window, Region, Zoom}` (serde lowercase), `state::ToolbarState { mode, timer: u32, cursor: bool, preview: bool }`, `state::defaults(&Config) -> ToolbarState`, `state::load(&Path, &Config) -> ToolbarState`, `state::save(&Path, &ToolbarState) -> Result<()>`, `state::default_path() -> PathBuf`. +- `layout::{Rect { x, y, w, h }, Target::{Mode(Mode), Options, Menu(MenuItem)}, MenuItem::{Timer(u32), Cursor, Preview}, Layout { bar, buttons: Vec<(Target, Rect, &'static str)>, separators: Vec, menu, rows: Vec<(MenuItem, Rect, &'static str)> }, layout(output: (f32, f32), measure: impl Fn(&str) -> f32) -> Layout, Layout::hit(&self, p: (f32, f32), menu_open: bool) -> Option, Layout::inside(&self, p, menu_open) -> bool}` — all in logical px. + +- [ ] **Step 1: Failing tests** + +`state.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_come_from_the_config() { + let mut config = Config::default(); + config.capture.show_cursor = true; + config.preview.enabled = false; + assert_eq!( + defaults(&config), + ToolbarState { mode: Mode::Region, timer: 0, cursor: true, preview: false } + ); + } + + #[test] + fn save_and_load_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("deep/toolbar.toml"); + let state = ToolbarState { mode: Mode::Zoom, timer: 10, cursor: true, preview: true }; + save(&path, &state).unwrap(); + assert_eq!(load(&path, &Config::default()), state); + } + + #[test] + fn a_missing_file_gives_the_defaults() { + let dir = tempfile::tempdir().unwrap(); + let config = Config::default(); + assert_eq!(load(&dir.path().join("none.toml"), &config), defaults(&config)); + } + + #[test] + fn a_broken_file_falls_back() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("toolbar.toml"); + let config = Config::default(); + for text in ["mode = \"video\"\n", "timer = 7\n", "not toml at all [", "cursor = \"yes\"\n"] { + std::fs::write(&path, text).unwrap(); + assert_eq!(load(&path, &config), defaults(&config), "{text:?}"); + } + } + + #[test] + fn missing_and_unknown_keys_are_fine() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("toolbar.toml"); + std::fs::write(&path, "mode = \"window\"\ncolour = \"red\"\n").unwrap(); + let config = Config::default(); + let state = load(&path, &config); + assert_eq!(state.mode, Mode::Window); + assert_eq!(state.timer, 0); + assert_eq!(state.preview, config.preview.enabled); + } +} +``` + +`layout.rs` (labels measured as 7 px per character so numbers are easy): + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn measure(s: &str) -> f32 { + 7.0 * s.chars().count() as f32 + } + + fn l() -> Layout { + layout((1366.0, 768.0), measure) + } + + fn centre(r: Rect) -> (f32, f32) { + (r.x + r.w / 2.0, r.y + r.h / 2.0) + } + + #[test] + fn the_bar_is_centred_above_the_bottom() { + let l = l(); + assert!(((l.bar.x + l.bar.w / 2.0) - 683.0).abs() < 0.01); + assert_eq!(l.bar.y + l.bar.h, 768.0 - 24.0); + assert_eq!(l.bar.h, 32.0 + 2.0 * 6.0); + } + + #[test] + fn buttons_are_in_order_and_do_not_overlap() { + let l = l(); + let labels: Vec<_> = l.buttons.iter().map(|b| b.2).collect(); + assert_eq!(labels, ["Screen", "Window", "Region", "Zoom", "Options ▾"]); + for pair in l.buttons.windows(2) { + assert!(pair[0].1.x + pair[0].1.w <= pair[1].1.x); + } + // "Screen": 6 chars × 7 + 2 × 12 padding. + assert_eq!(l.buttons[0].1.w, 42.0 + 24.0); + assert_eq!(l.separators.len(), 2); + } + + #[test] + fn each_button_hits_its_target() { + let l = l(); + for (target, rect, _) in &l.buttons { + assert_eq!(l.hit(centre(*rect), false), Some(*target)); + } + } + + #[test] + fn gaps_hit_nothing() { + let l = l(); + for sep in &l.separators { + assert_eq!(l.hit(centre(*sep), false), None); + } + assert_eq!(l.hit((l.bar.x + 2.0, l.bar.y + 2.0), false), None, "padding"); + assert_eq!(l.hit((10.0, 10.0), false), None, "outside"); + assert!(l.inside((l.bar.x + 2.0, l.bar.y + 2.0), false)); + assert!(!l.inside((10.0, 10.0), false)); + } + + #[test] + fn the_menu_sits_above_options_and_only_counts_when_open() { + let l = l(); + let options = l.buttons[4].1; + assert_eq!(l.menu.y + l.menu.h, l.bar.y - 8.0); + assert!(l.menu.x <= options.x + 0.01 || l.menu.x + l.menu.w <= 1366.0); + let rows: Vec<_> = l.rows.iter().map(|r| r.0).collect(); + assert_eq!(rows, [MenuItem::Timer(0), MenuItem::Timer(5), MenuItem::Timer(10), MenuItem::Cursor, MenuItem::Preview]); + let cursor = centre(l.rows[3].1); + assert_eq!(l.hit(cursor, true), Some(Target::Menu(MenuItem::Cursor))); + assert_eq!(l.hit(cursor, false), None); + assert!(l.inside(cursor, true) && !l.inside(cursor, false)); + } + + #[test] + fn the_menu_stays_on_a_narrow_output() { + let l = layout((400.0, 300.0), measure); + assert!(l.menu.x >= 0.0 && l.menu.x + l.menu.w <= 400.0); + } +} +``` + +Run `cargo nextest run toolbar::` → compile errors. + +- [ ] **Step 2: Implement `state.rs`** + +```rust +//! What the toolbar remembers between runs, kept out of the config so a +//! read-only (home-manager) config still works. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::config::Config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + Screen, + Window, + Region, + Zoom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ToolbarState { + pub mode: Mode, + /// Seconds before the capture: 0, 5 or 10. + pub timer: u32, + pub cursor: bool, + pub preview: bool, +} + +/// The file, every key optional. +#[derive(Deserialize)] +struct File { + mode: Option, + timer: Option, + cursor: Option, + preview: Option, +} + +pub const TIMERS: [u32; 3] = [0, 5, 10]; + +/// `$XDG_STATE_HOME/valw/toolbar.toml`, falling back to `~/.local/state`. +pub fn default_path() -> PathBuf { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| crate::config::home().join(".local/state")) + .join("valw/toolbar.toml") +} + +pub fn defaults(config: &Config) -> ToolbarState { + ToolbarState { + mode: Mode::Region, + timer: 0, + cursor: config.capture.show_cursor, + preview: config.preview.enabled, + } +} + +/// The remembered state, or the defaults if there is none or it is broken. +pub fn load(path: &Path, config: &Config) -> ToolbarState { + let fallback = defaults(config); + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return fallback, + Err(e) => { + tracing::warn!("could not read {}: {e}", path.display()); + return fallback; + } + }; + match parse(&text, fallback) { + Ok(state) => state, + Err(e) => { + tracing::warn!("ignoring {}: {e:#}", path.display()); + fallback + } + } +} + +fn parse(text: &str, fallback: ToolbarState) -> Result { + let file: File = toml::from_str(text)?; + let timer = file.timer.unwrap_or(fallback.timer); + if !TIMERS.contains(&timer) { + bail!("timer must be 0, 5 or 10, not {timer}"); + } + Ok(ToolbarState { + mode: file.mode.unwrap_or(fallback.mode), + timer, + cursor: file.cursor.unwrap_or(fallback.cursor), + preview: file.preview.unwrap_or(fallback.preview), + }) +} + +pub fn save(path: &Path, state: &ToolbarState) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?; + } + let text = toml::to_string(state).context("could not encode the toolbar state")?; + crate::output::write_atomic(path, text.as_bytes()) +} +``` + +(Unknown keys are ignored because `File` doesn't deny them.) + +- [ ] **Step 3: Implement `layout.rs`** + +```rust +//! Where the toolbar's buttons and menu are, in logical px of the output, +//! and what a point hits. + +use crate::toolbar::state::Mode; + +pub const MARGIN_BOTTOM: f32 = 24.0; +pub const PADDING: f32 = 6.0; +pub const BUTTON_H: f32 = 32.0; +pub const BUTTON_PAD: f32 = 12.0; +pub const SEPARATOR_GAP: f32 = 8.0; +pub const MENU_GAP: f32 = 8.0; +pub const ROW_H: f32 = 28.0; +/// Room for the check or radio mark left of a menu label. +pub const MARK_W: f32 = 24.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub fn contains(&self, p: (f32, f32)) -> bool { + p.0 >= self.x && p.0 < self.x + self.w && p.1 >= self.y && p.1 < self.y + self.h + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MenuItem { + Timer(u32), + Cursor, + Preview, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Target { + Mode(Mode), + Options, + Menu(MenuItem), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Layout { + pub bar: Rect, + pub buttons: Vec<(Target, Rect, &'static str)>, + pub separators: Vec, + pub menu: Rect, + pub rows: Vec<(MenuItem, Rect, &'static str)>, +} + +/// `None` stands for a separator. +const BAR: [Option<(Target, &str)>; 7] = [ + Some((Target::Mode(Mode::Screen), "Screen")), + Some((Target::Mode(Mode::Window), "Window")), + Some((Target::Mode(Mode::Region), "Region")), + None, + Some((Target::Mode(Mode::Zoom), "Zoom")), + None, + Some((Target::Options, "Options ▾")), +]; + +const MENU: [(MenuItem, &str); 5] = [ + (MenuItem::Timer(0), "No timer"), + (MenuItem::Timer(5), "5 second timer"), + (MenuItem::Timer(10), "10 second timer"), + (MenuItem::Cursor, "Show cursor"), + (MenuItem::Preview, "Show preview"), +]; + +/// Lays the toolbar out on an output of `output` logical px; `measure` +/// gives a label's width in logical px. +pub fn layout(output: (f32, f32), measure: impl Fn(&str) -> f32) -> Layout { + let widths: Vec = BAR + .iter() + .map(|item| match item { + Some((_, label)) => measure(label) + 2.0 * BUTTON_PAD, + None => 2.0 * SEPARATOR_GAP + 1.0, + }) + .collect(); + let w = widths.iter().sum::() + 2.0 * PADDING; + let h = BUTTON_H + 2.0 * PADDING; + let bar = Rect { x: (output.0 - w) / 2.0, y: output.1 - MARGIN_BOTTOM - h, w, h }; + + let mut buttons = Vec::new(); + let mut separators = Vec::new(); + let mut x = bar.x + PADDING; + for (item, width) in BAR.iter().zip(widths) { + match item { + Some((target, label)) => { + buttons.push((*target, Rect { x, y: bar.y + PADDING, w: width, h: BUTTON_H }, *label)); + } + None => separators.push(Rect { x: x + SEPARATOR_GAP, y: bar.y + PADDING + 6.0, w: 1.0, h: BUTTON_H - 12.0 }), + } + x += width; + } + + let menu_w = MENU.iter().map(|(_, l)| measure(l)).fold(0.0, f32::max) + MARK_W + 2.0 * BUTTON_PAD; + let menu_h = MENU.len() as f32 * ROW_H + 2.0 * PADDING; + let options = buttons.last().expect("the bar has buttons").1; + let menu_x = options.x.min(output.0 - menu_w).max(0.0); + let menu = Rect { x: menu_x, y: bar.y - MENU_GAP - menu_h, w: menu_w, h: menu_h }; + let rows = MENU + .iter() + .enumerate() + .map(|(i, (item, label))| { + (*item, Rect { x: menu.x, y: menu.y + PADDING + i as f32 * ROW_H, w: menu.w, h: ROW_H }, *label) + }) + .collect(); + Layout { bar, buttons, separators, menu, rows } +} + +impl Layout { + pub fn hit(&self, p: (f32, f32), menu_open: bool) -> Option { + if menu_open + && let Some(row) = self.rows.iter().find(|r| r.1.contains(p)) + { + return Some(Target::Menu(row.0)); + } + self.buttons.iter().find(|b| b.1.contains(p)).map(|b| b.0) + } + + /// Whether `p` is on the bar or the open menu (a click elsewhere closes). + pub fn inside(&self, p: (f32, f32), menu_open: bool) -> bool { + self.bar.contains(p) || (menu_open && self.menu.contains(p)) + } +} +``` + +- [ ] **Step 4: Run** (+11), clippy, fmt. **Step 5: Commit** `feat: toolbar state and layout`. + +--- + +### Task 2: Drawing + +**Files:** `src/toolbar/draw.rs`; `src/toolbar/mod.rs`: `pub mod draw;`. + +**Produces:** `draw::LABEL: f32 = 13.0`; `draw::measure(label) -> f32` (logical px, via `editor::text::layout`); `draw::bar(layout, size_px: (u32, u32), scale: f32, state: &ToolbarState, hover: Option, menu_open: bool) -> Pixmap` (whole output, transparent outside); `draw::pill(size_px, scale, text: &str) -> Pixmap`; `draw::to_argb(&Pixmap, &mut [u8])` (premultiplied RGBA → wl_shm ARGB8888 bytes B, G, R, A). + +- [ ] **Step 1: Failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::toolbar::layout::layout; + use crate::toolbar::state::Mode; + + const STATE: ToolbarState = ToolbarState { mode: Mode::Region, timer: 0, cursor: false, preview: true }; + + fn alpha(p: &Pixmap, x: f32, y: f32, scale: f32) -> u8 { + p.pixel((x * scale) as u32, (y * scale) as u32).unwrap().alpha() + } + + #[test] + fn the_bar_is_opaque_and_the_rest_transparent() { + let l = layout((800.0, 600.0), measure); + let p = bar(&l, (800, 600), 1.0, &STATE, None, false); + assert_eq!(alpha(&p, 5.0, 5.0, 1.0), 0); + assert!(alpha(&p, l.bar.x + l.bar.w / 2.0, l.bar.y + 3.0, 1.0) >= 230); + assert_eq!(alpha(&p, l.menu.x + 5.0, l.menu.y + 5.0, 1.0), 0, "menu closed"); + let open = bar(&l, (800, 600), 1.0, &STATE, None, true); + assert!(alpha(&open, l.menu.x + l.menu.w / 2.0, l.menu.y + 3.0, 1.0) >= 230); + } + + #[test] + fn the_selected_mode_is_lighter() { + let l = layout((800.0, 600.0), measure); + let p = bar(&l, (800, 600), 1.0, &STATE, None, false); + let lum = |r: crate::toolbar::layout::Rect| p.pixel((r.x + 3.0) as u32, (r.y + 3.0) as u32).unwrap().demultiply().red(); + let region = l.buttons[2].1; + let screen = l.buttons[0].1; + assert!(lum(region) > lum(screen)); + } + + #[test] + fn scales() { + let l = layout((800.0, 600.0), measure); + let p = bar(&l, (1000, 750), 1.25, &STATE, None, false); + assert_eq!((p.width(), p.height()), (1000, 750)); + assert!(alpha(&p, l.bar.x + l.bar.w / 2.0, l.bar.y + 3.0, 1.25) >= 230); + assert_eq!(alpha(&p, l.bar.x - 3.0, l.bar.y + 3.0, 1.25), 0); + } + + #[test] + fn argb_swaps_red_and_blue() { + let mut p = Pixmap::new(1, 1).unwrap(); + p.fill(tiny_skia::Color::from_rgba8(10, 20, 30, 255)); + let mut out = [0u8; 4]; + to_argb(&p, &mut out); + assert_eq!(out, [30, 20, 10, 255]); + } + + #[test] + fn the_pill_shows_its_text() { + let p = pill((72, 40), 1.0, "5"); + let lit = p.pixels().iter().filter(|c| c.demultiply().red() > 200 && c.alpha() > 200).count(); + assert!(lit > 10, "white digit pixels: {lit}"); + } +} +``` + +Run → compile errors. + +- [ ] **Step 2: Implement** + +```rust +//! Painting the toolbar, its menu and the countdown pill with tiny-skia. + +use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect as SkRect, Transform}; + +use crate::editor::text; +use crate::toolbar::layout::{Layout, MenuItem, Rect, Target}; +use crate::toolbar::state::ToolbarState; + +pub const LABEL: f32 = 13.0; +const RADIUS: f32 = 12.0; +const BACKGROUND: [u8; 4] = [0x1c, 0x1c, 0x1e, 230]; +const SELECTED: [u8; 4] = [255, 255, 255, 46]; +const HOVER: [u8; 4] = [255, 255, 255, 26]; +const SEPARATOR: [u8; 4] = [255, 255, 255, 60]; +const WHITE: [u8; 4] = [255, 255, 255, 255]; + +/// A label's width in logical px. +pub fn measure(label: &str) -> f32 { + text::layout(label, LABEL).width +} + +fn paint(c: [u8; 4]) -> Paint<'static> { + let mut p = Paint::default(); + p.set_color_rgba8(c[0], c[1], c[2], c[3]); + p.anti_alias = true; + p +} + +fn rounded(pixmap: &mut Pixmap, r: Rect, radius: f32, color: [u8; 4], scale: f32) { + let (x, y, w, h, k) = (r.x * scale, r.y * scale, r.w * scale, r.h * scale, radius * scale); + let mut pb = PathBuilder::new(); + pb.move_to(x + k, y); + pb.line_to(x + w - k, y); + pb.quad_to(x + w, y, x + w, y + k); + pb.line_to(x + w, y + h - k); + pb.quad_to(x + w, y + h, x + w - k, y + h); + pb.line_to(x + k, y + h); + pb.quad_to(x, y + h, x, y + h - k); + pb.line_to(x, y + k); + pb.quad_to(x, y, x + k, y); + pb.close(); + if let Some(path) = pb.finish() { + pixmap.fill_path(&path, &paint(color), FillRule::Winding, Transform::identity(), None); + } +} + +/// `label` centred vertically in `r`, starting `left` logical px in. +fn label(pixmap: &mut Pixmap, r: Rect, left: f32, s: &str, scale: f32) { + let size = LABEL * scale; + let height = text::line_height(size); + let origin = ((r.x + left) * scale, (r.y + r.h / 2.0) * scale - height / 2.0); + if let Some(path) = text::path(s, size, origin) { + pixmap.fill_path(&path, &paint(WHITE), FillRule::Winding, Transform::identity(), None); + } +} + +/// The whole output: transparent but for the bar and, if open, the menu. +pub fn bar( + layout: &Layout, + size: (u32, u32), + scale: f32, + state: &ToolbarState, + hover: Option, + menu_open: bool, +) -> Pixmap { + let mut p = Pixmap::new(size.0.max(1), size.1.max(1)).expect("the output has a size"); + rounded(&mut p, layout.bar, RADIUS, BACKGROUND, scale); + for (target, r, text) in &layout.buttons { + let fill = if *target == Target::Mode(state.mode) || (*target == Target::Options && menu_open) { + Some(SELECTED) + } else if hover == Some(*target) { + Some(HOVER) + } else { + None + }; + if let Some(fill) = fill { + rounded(&mut p, *r, 8.0, fill, scale); + } + label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, text, scale); + } + for s in &layout.separators { + rounded(&mut p, *s, 0.0, SEPARATOR, scale); + } + if menu_open { + rounded(&mut p, layout.menu, RADIUS, BACKGROUND, scale); + for (item, r, text) in &layout.rows { + if hover == Some(Target::Menu(*item)) { + rounded(&mut p, *r, 6.0, HOVER, scale); + } + let on = match item { + MenuItem::Timer(t) => state.timer == *t, + MenuItem::Cursor => state.cursor, + MenuItem::Preview => state.preview, + }; + if on { + label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD, "✓", scale); + } + label(&mut p, *r, crate::toolbar::layout::BUTTON_PAD + crate::toolbar::layout::MARK_W, text, scale); + } + } + p +} + +/// The countdown pill: a rounded background with `text` centred. +pub fn pill(size: (u32, u32), scale: f32, s: &str) -> Pixmap { + let mut p = Pixmap::new(size.0.max(1), size.1.max(1)).expect("the pill has a size"); + let r = Rect { x: 0.0, y: 0.0, w: size.0 as f32 / scale, h: size.1 as f32 / scale }; + rounded(&mut p, r, RADIUS, BACKGROUND, scale); + let left = (r.w - measure(s)) / 2.0; + label(&mut p, r, left, s, scale); + p +} + +/// Copies premultiplied RGBA into wl_shm ARGB8888 (bytes B, G, R, A). +pub fn to_argb(pixmap: &Pixmap, out: &mut [u8]) { + for (dst, src) in out.chunks_exact_mut(4).zip(pixmap.pixels()) { + dst.copy_from_slice(&[src.blue(), src.green(), src.red(), src.alpha()]); + } +} +``` + +(`SkRect` and `Color` imports only if used; drop the unused ones. If DejaVu Sans Bold has no "✓" glyph — check with `text::FONT.glyph_id('✓')` — draw the check as a short stroked polyline instead and ledger it.) + +- [ ] **Step 3: Run** (+5), clippy, fmt. **Step 4: Commit** `feat: toolbar drawing`. + +--- + +### Task 3: Surfaces, events and `valw toolbar` + +**Files:** `src/toolbar/mod.rs`, `src/wayland.rs`, `src/main.rs`. + +**Consumes:** Tasks 1–2. **Produces:** `toolbar::run(config: &Config) -> Result` where `Picked { mode: state::Mode, cursor: bool, preview: bool }` (the countdown has already run); `State::{toolbar: Option, pill: Option}`; `Command::Toolbar`. + +- [ ] **Step 1: Failing tests** — `src/main.rs` `flag_conflicts`: `assert!(parses(&["toolbar"]));` and a new test + +```rust + #[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()); + } +``` + +Run → compile errors. + +- [ ] **Step 2: `main.rs`** + +`Command::Toolbar` documented "Pick a mode from a floating bar (Cmd+Shift+5).", then in `run`: + +```rust + Command::Toolbar => { + let config = config::load(&config::default_path())?; + let (mode, common) = from_toolbar(toolbar::run(&config)?); + capture(mode, common) + } +``` + +and + +```rust +/// 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) +} +``` + +(`Common` needs no derive changes; it is constructed by field.) + +- [ ] **Step 3: `toolbar/mod.rs`** + +```rust +//! `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::{QueueHandle, 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, State, 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 }) +} +``` + +(`crate::focused_output` is the existing private fn in `main.rs`; make it `pub(crate)`.) + +The bar surface: + +```rust +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); + } + } +} +``` + +The countdown: + +```rust +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; + } + } +} +``` + +(The "Cancel on hover" label from the plan's ruling is dropped for simplicity: the pill shows the seconds; a click cancels. Ledger it.) + +- [ ] **Step 4: `wayland.rs` routing** + +`use crate::toolbar::{Pill, Toolbar};`; fields `pub toolbar: Option`, `pub pill: Option` (initialised `None`); in `closed`: `toolbar.closed(layer)` and `pill.closed(layer)`; in `configure`: `toolbar.configure(layer, configure.new_size)` and `pill.configure(layer)`; in `press_key`: `if let Some(t) = &mut self.toolbar { t.key(event.keysym); }`; in `pointer_frame`: `toolbar.pointer(events, self.cursor_device.as_ref())` and `pill.pointer(events)`. + +Remove the Task 1 allowance in `main.rs`. + +- [ ] **Step 5: Run** — all pass (+1 test, +1 assert), clippy, fmt. + +- [ ] **Step 6: Smoke run** (tell the user first) — `timeout -s INT 4 valw toolbar` in the dev shell: the bar appears bottom-centre on the focused output; a niri screenshot shows it; exit by the signal; no errors in the log. Then `valw toolbar` with a temporary state file `timer = 5` is not run (it would capture); the countdown is checked by hand later. + +- [ ] **Step 7: Commit** `feat: valw toolbar`. + +--- + +### Task 4: Checks and checklist + +- [ ] `nix flake check -L --keep-going` passes. +- [ ] Append to `docs/test-checklist.md`: + +```markdown + +## Toolbar (Phase 6) + +- [ ] `valw toolbar` shows the bar bottom-centre on the focused output (both monitors), crisp; the last mode is highlighted; hovering highlights buttons. +- [ ] Screen, Window, Region and Zoom each start their mode at once; the bar is not in the shot. +- [ ] Options ▾ opens the menu; timer None/5/10, Show cursor and Show preview toggle (✓) and are remembered next time (`~/.local/state/valw/toolbar.toml`). +- [ ] With a 5 s timer the pill counts 5 → 1 bottom-centre, the desktop stays usable meanwhile, and the pill is not in the shot; clicking the pill cancels (exit 3). +- [ ] Esc or a click beside the bar closes it without capturing (exit 3). +- [ ] A broken `toolbar.toml` doesn't stop the bar (warning in the log, defaults used). +- [ ] Bind it in niri (Mod+Shift+5 is taken in copland; pick a key). +``` + +- [ ] Commit `docs: toolbar checklist`.