Files
valw/docs/superpowers/plans/2026-10-03-valw-phase-6-toolbar.md
T

39 KiB
Raw Blame History

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<Rect>, 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<Target>, Layout::inside(&self, p, menu_open) -> bool} — all in logical px.

  • Step 1: Failing tests

state.rs:

#[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):

#[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
//! 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<Mode>,
    timer: Option<u32>,
    cursor: Option<bool>,
    preview: Option<bool>,
}

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<ToolbarState> {
    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
//! 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<Rect>,
    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<f32> = 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::<f32>() + 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<Target> {
        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<Target>, 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
#[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
//! 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<Target>,
    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<Picked> where Picked { mode: state::Mode, cursor: bool, preview: bool } (the countdown has already run); State::{toolbar: Option<Toolbar>, pill: Option<Pill>}; Command::Toolbar.

  • Step 1: Failing tests — src/main.rs flag_conflicts: assert!(parses(&["toolbar"])); and a new test
    #[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:

        Command::Toolbar => {
            let config = config::load(&config::default_path())?;
            let (mode, common) = from_toolbar(toolbar::run(&config)?);
            capture(mode, common)
        }

and

/// 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
//! `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<Picked> {
    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:

pub struct Toolbar {
    layer: LayerSurface,
    viewport: WpViewport,
    pool: SlotPool,
    buffer: Option<Buffer>,
    scale: f64,
    /// Logical size once configured.
    size: Option<(u32, u32)>,
    layout: Option<Layout>,
    state: ToolbarState,
    hover: Option<Target>,
    menu_open: bool,
    outcome: Option<Option<Mode>>,
}

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:

pub struct Pill {
    layer: LayerSurface,
    viewport: WpViewport,
    pool: SlotPool,
    buffer: Option<Buffer>,
    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<Toolbar>, pub pill: Option<Pill> (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:

## 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.