feat: toolbar state and layout
This commit is contained in:
1 parent
124572fdf5
commit
6dbd900b01
4 files changed
+436
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! `valw toolbar`: a floating bar to pick a mode.
|
||||||
|
|
||||||
|
pub mod layout;
|
||||||
|
pub mod state;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user