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 config;
|
||||
mod detach;
|
||||
@@ -17,6 +20,7 @@ mod render;
|
||||
mod selection;
|
||||
mod stack;
|
||||
mod thumbnail;
|
||||
mod toolbar;
|
||||
mod wayland;
|
||||
mod window;
|
||||
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