feat: toolbar state and layout

This commit is contained in:
elwrcl committed 2026-10-03 01:31:10 +03:00
1 parent 124572fdf5
commit 6dbd900b01
4 files changed
+436

No files matched your search

+4
View File
@@ -1,3 +1,6 @@
// The toolbar lands in pieces; Task 3 removes this.
#![allow(dead_code)]
mod capture; mod capture;
mod config; mod config;
mod detach; mod detach;
@@ -17,6 +20,7 @@ mod render;
mod selection; mod selection;
mod stack; mod stack;
mod thumbnail; mod thumbnail;
mod toolbar;
mod wayland; mod wayland;
mod window; mod window;
mod zoom; mod zoom;
+257
View File
@@ -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<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))
}
}
#[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);
}
}
+4
View File
@@ -0,0 +1,4 @@
//! `valw toolbar`: a floating bar to pick a mode.
pub mod layout;
pub mod state;
+171
View File
@@ -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<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())
}
#[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);
}
}