From 6dbd900b01bcadc9912ea1e4d7992d90b5bf1eb8 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Sat, 3 Oct 2026 01:30:13 +0300 Subject: [PATCH] feat: toolbar state and layout --- src/main.rs | 4 + src/toolbar/layout.rs | 257 ++++++++++++++++++++++++++++++++++++++++++ src/toolbar/mod.rs | 4 + src/toolbar/state.rs | 171 ++++++++++++++++++++++++++++ 4 files changed, 436 insertions(+) create mode 100644 src/toolbar/layout.rs create mode 100644 src/toolbar/mod.rs create mode 100644 src/toolbar/state.rs diff --git a/src/main.rs b/src/main.rs index 4c7cff0..b29a4aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,6 @@ +// The toolbar lands in pieces; Task 3 removes this. +#![allow(dead_code)] + mod capture; mod config; mod detach; @@ -17,6 +20,7 @@ mod render; mod selection; mod stack; mod thumbnail; +mod toolbar; mod wayland; mod window; mod zoom; diff --git a/src/toolbar/layout.rs b/src/toolbar/layout.rs new file mode 100644 index 0000000..1f8670c --- /dev/null +++ b/src/toolbar/layout.rs @@ -0,0 +1,257 @@ +//! 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)) + } +} + +#[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); + } +} diff --git a/src/toolbar/mod.rs b/src/toolbar/mod.rs new file mode 100644 index 0000000..88d9dfe --- /dev/null +++ b/src/toolbar/mod.rs @@ -0,0 +1,4 @@ +//! `valw toolbar`: a floating bar to pick a mode. + +pub mod layout; +pub mod state; diff --git a/src/toolbar/state.rs b/src/toolbar/state.rs new file mode 100644 index 0000000..ada0f60 --- /dev/null +++ b/src/toolbar/state.rs @@ -0,0 +1,171 @@ +//! 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()) +} + +#[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); + } +}