Krolyxon
Commit 7b28e7d

Branches

Refs →

budctl7b28e7d

Config file, a starter theme.json, and a log for the desktop app

- ~/.config/budctl/config.json (budctl_core::config), created with the defaults
  when missing: "device" (default for --device, CLI and GUI), and for the GUI
  "idle_seconds" (how long the control channel stays open, 1-600) and
  "start_page". Unknown keys are errors, and a file that cannot be used is
  reported and the defaults used, so a typo never stops `budctl --json status`.
- budctl-gui writes the built-in palette to ~/.config/budctl/theme.json when
  there is none (never overwriting), as a start for a matugen template.
- budctl-gui logs errors and session events to ~/.local/state/budctl/gui.log,
  replaced each launch, so a failure can be explained when the window was
  started from a launcher. The CLI keeps stderr only: status bars run it every
  few seconds.

New dependency: serde_json in budctl-core, to read config.json (already in the
tree through the CLI and GUI).
Author
krolyxon
Date
2026-10-28 10:40 +0530
Parent
66d7a70
Commit
7b28e7d4566660ff06e24d9cfa15e5b093bf32bf

+465 −32

13 files changed

  1. M CLAUDE.md +4 −3
  2. M Cargo.lock +1 −0
  3. M README.md +27 −2
  4. M crates/budctl-cli/src/main.rs +13 −2
  5. M crates/budctl-cli/tests/cli.rs +6 −1
  6. M crates/budctl-core/Cargo.toml +1 −0
  7. A crates/budctl-core/src/config.rs +188 −0
  8. M crates/budctl-core/src/lib.rs +1 −0
  9. A crates/budctl-gui/src/log.rs +60 −0
  10. M crates/budctl-gui/src/main.rs +77 −6
  11. M crates/budctl-gui/src/settings.rs +21 −1
  12. M crates/budctl-gui/src/theme.rs +46 −5
  13. M crates/budctl-gui/src/worker.rs +20 −12
M CLAUDE.md View
@@ -10,7 +10,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
1010 - Two front ends, one core: the `budctl` CLI and the `budctl-gui` desktop app, both thin layers over the same library (`budctl-core`). Scripts and the owner's Quickshell (Aurora) can use `budctl --json`.
1111 - One small, readable codebase that the owner fully understands.
1212 - Device support is pluggable. Adding a new pair of buds means adding one module under `crates/budctl-core/src/devices/` and registering it in `devices/mod.rs`, without touching transport, CLI or GUI code.
13−- Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config.
13+- Coexist with the phone app. Settings live on the buds, so budctl never stores device state locally beyond user config (`~/.config/budctl/config.json`, `budctl_core::config`: default device and GUI preferences, created with defaults when missing; a broken file is reported and the defaults are used).
1414
1515 ## Non-goals
1616
@@ -43,7 +43,7 @@ The first supported device is the **realme Buds T310**. More devices are added a
4343 - CLI: `clap` (derive). It is parsed with `try_parse`, so usage errors exit 1, not clap's default 2. `serde_json` provides `--json`.
4444 - Tests: `cargo test --workspace`. Lints: `cargo clippy --workspace --all-targets -- -D warnings`.
4545 - Keep dependencies minimal. Every new dependency needs a reason in the commit message. The current direct dependencies:
46− - core: `libc`, `zbus`, `serde`
46+ - core: `libc`, `zbus`, `serde`, `serde_json` (config file)
4747 - cli: `clap`, `serde_json`
4848 - gui: `iced`, `serde`, `serde_json`; `iced_test` for tests
4949
@@ -121,7 +121,8 @@ Exit codes:
121121 - `h`/`l` pick the previous or next choice, or turn a switch off or on, and `l` opens a row's page. Enter and Space toggle, step round the choices, or open.
122122 - `J`/`K`, `Ctrl+Tab`, `gt`/`gT` and `1`–`5` change pages. `Esc` hides the cursor, `q` quits.
123123 - The cursor shows only after a key press, and the first key only shows it, so nothing changes where it cannot be seen. A click hides it.
124−- **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command. A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI.
124+- **Buds:** `worker.rs` owns the session, on a thread of its own, and answers each command with one event, in order. It opens the session when a command needs it, answers the buds' own requests while it waits (`Session::serve`), and closes it 10 s after the last command (`gui.idle_seconds` in the config). A write shows "Saving…" until the buds' read-back arrives, and takes no second write meanwhile. A failure says why, with "Try again". `--demo` shows made-up buds; `--debug` hex-dumps frames like the CLI.
125+- **Files:** writes the built-in palette to `~/.config/budctl/theme.json` when missing (never overwrites it), and logs errors and session events to `~/.local/state/budctl/gui.log` (`log.rs`), replaced each launch. Frames stay on stderr with `--debug`. The CLI logs to stderr only, since status bars run it every few seconds.
125126 - **Logic:** keys and clicks both become messages for one `update` function, which stays testable without a display. `iced_test` renders pages to PNG, so the look can be checked without the owner.
126127 - **Screenshots:** the README's come from the demo buds: `cargo test -p budctl-gui readme_screenshots -- --ignored` renders them again into `docs/screenshots/`.
127128
M Cargo.lock View
@@ -340,6 +340,7 @@ version = "0.1.0"
340340 dependencies = [
341341 "libc",
342342 "serde",
343+ "serde_json",
343344 "zbus",
344345 ]
345346
M README.md View
@@ -51,7 +51,8 @@ budctl find stop
5151
5252 - `--json` prints machine-readable output for scripts and status bars (`"schema": 1`).
5353 - `--debug` hex-dumps every frame sent and received, on stderr.
54−- `--device <MAC>` picks the buds when more than one supported pair is connected.
54+- `--device <MAC>` picks the buds when more than one supported pair is connected. Without it,
55+ `"device"` in the config file (below) is used, if set.
5556
5657 Exit codes:
5758
@@ -109,11 +110,35 @@ The cursor shows only after you press a key, and the first key only shows it.
109110 palette: `--theme <file>`, else `~/.config/budctl/theme.json`. The file is JSON with these keys, all
110111 optional: `bg`, `fg`, `primary`, `primaryFg`, `primaryContainer`, `secondary`, `surfaceLow`,
111112 `surface`, `surfaceHigh`, `surfaceHighest`, `outlineVariant`, `surfaceVariantFg`, `err`. It is read
112−again whenever the window gets focus, so a new wallpaper's colors show up there too.
113+again whenever the window gets focus, so a new wallpaper's colors show up there too. When the file
114+is missing, the app writes the built-in colors there: a starting point for a matugen template.
115+
116+**Log:** each launch writes errors and session events (connected, each change and its answer,
117+closed) to `~/.local/state/budctl/gui.log`, replacing the last launch's.
113118
114119 Other options: `--demo` shows made-up buds without Bluetooth; `--debug` and `--device` work as in
115120 the command-line tool.
116121
122+## Config
123+
124+`~/.config/budctl/config.json` is created with the defaults the first time it is needed. Every key
125+is optional; a file with a mistake is reported and the defaults are used.
126+
127+```json
128+{
129+ "device": null,
130+ "gui": {
131+ "idle_seconds": 10,
132+ "start_page": "overview"
133+ }
134+}
135+```
136+
137+- `device`: the buds to use when `--device` is not given, as `"AA:BB:CC:DD:EE:FF"`.
138+- `gui.idle_seconds`: how long the app keeps the control channel open after the last change
139+ (1–600).
140+- `gui.start_page`: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`.
141+
117142 ## How it works
118143
119144 budctl speaks the buds' own control protocol over an RFCOMM socket. What is known about it, and
M crates/budctl-cli/src/main.rs View
@@ -17,7 +17,8 @@ use clap::{Parser, Subcommand, ValueEnum};
1717 #[derive(Parser)]
1818 #[command(name = "budctl", version)]
1919 struct Cli {
20− /// The buds to use, when more than one supported pair is paired.
20+ /// The buds to use, when more than one supported pair is paired. Default: "device" in
21+ /// ~/.config/budctl/config.json.
2122 #[arg(long, global = true, value_name = "MAC")]
2223 device: Option<BdAddr>,
2324
@@ -247,6 +248,16 @@ fn main() -> ExitCode {
247248 }
248249 }
249250
251+/// The config's default device. A config that cannot be used is reported and left out, so it
252+/// never stops a command.
253+fn default_device() -> Option<BdAddr> {
254+ let (config, problem) = budctl_core::config::load();
255+ if let Some(problem) = problem {
256+ eprintln!("budctl: {problem}; ignoring it");
257+ }
258+ config.device().unwrap_or_default()
259+}
260+
250261 fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
251262 // Checked before connecting: ringing is loud. Spec: realme_t310.md#find
252263 if let Command::Find {
@@ -264,7 +275,7 @@ fn run(cli: &Cli, out: &mut impl Write) -> Result<(), output::Failure> {
264275 return output::list(out, &found, cli.json);
265276 }
266277
267− let (device, model) = budctl_core::select(found, cli.device)?;
278+ let (device, model) = budctl_core::select(found, cli.device.or_else(default_device))?;
268279 let mut session = budctl_core::connect(&device, model)?;
269280 match cli.command {
270281 Command::List => Ok(()),
M crates/budctl-cli/tests/cli.rs View
@@ -6,7 +6,12 @@
66 use std::process::Command;
77
88 fn budctl(args: &[&str]) -> std::process::Output {
9− Command::new(env!("CARGO_BIN_EXE_budctl")).args(args).output().unwrap()
9+ // Never the real ~/.config: budctl writes a default config there.
10+ Command::new(env!("CARGO_BIN_EXE_budctl"))
11+ .env("XDG_CONFIG_HOME", env!("CARGO_TARGET_TMPDIR"))
12+ .args(args)
13+ .output()
14+ .unwrap()
1015 }
1116
1217 #[test]
M crates/budctl-core/Cargo.toml View
@@ -10,6 +10,7 @@ publish.workspace = true
1010 [dependencies]
1111 libc = "0.2.191"
1212 serde = { version = "1.0.229", features = ["derive"] }
13+serde_json = "1.0.154"
1314 zbus = { version = "5.19.0", default-features = false, features = ["blocking-api", "async-io"] }
1415
1516 [lints]
A crates/budctl-core/src/config.rs View
@@ -0,0 +1,188 @@
1+//! The user's config: `~/.config/budctl/config.json`, shared by the CLI and the GUI.
2+//!
3+//! It only holds preferences. Settings live on the buds, so nothing here mirrors them (CLAUDE.md,
4+//! goals). A missing file is written with the defaults, so there is something to edit; a file
5+//! that cannot be used is reported and the defaults are used instead, so a typo never stops a
6+//! status bar's `budctl --json status`.
7+
8+use std::fs;
9+use std::path::{Path, PathBuf};
10+
11+use serde::{Deserialize, Serialize};
12+
13+use crate::bt::BdAddr;
14+
15+/// What the file holds. A key the file lacks keeps its default; an unknown key is an error, so a
16+/// misspelt one is not silently ignored.
17+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18+#[serde(default, deny_unknown_fields)]
19+pub struct Config {
20+ /// The buds to use when `--device` is not given, as `AA:BB:CC:DD:EE:FF`.
21+ pub device: Option<String>,
22+ pub gui: Gui,
23+}
24+
25+/// `budctl-gui` only.
26+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27+#[serde(default, deny_unknown_fields)]
28+pub struct Gui {
29+ /// Seconds the GUI keeps the control channel open after the last command.
30+ pub idle_seconds: u64,
31+ /// The page the window opens on: `overview`, `equalizer`, `gestures`, `earbuds` or `settings`.
32+ pub start_page: String,
33+}
34+
35+impl Default for Gui {
36+ fn default() -> Self {
37+ Gui {
38+ idle_seconds: 10,
39+ start_page: "overview".to_owned(),
40+ }
41+ }
42+}
43+
44+/// The longest `gui.idle_seconds` may be: the channel is meant to be free for other apps.
45+const MAX_IDLE_SECONDS: u64 = 600;
46+
47+impl Config {
48+ /// The default device, checked.
49+ pub fn device(&self) -> Result<Option<BdAddr>, String> {
50+ self.device
51+ .as_deref()
52+ .map(|address| address.parse().map_err(|e| format!("device: {e}")))
53+ .transpose()
54+ }
55+
56+ fn check(&self) -> Result<(), String> {
57+ self.device()?;
58+ if !(1..=MAX_IDLE_SECONDS).contains(&self.gui.idle_seconds) {
59+ return Err(format!("gui.idle_seconds: must be 1 to {MAX_IDLE_SECONDS}"));
60+ }
61+ Ok(())
62+ }
63+}
64+
65+/// A config from the text of a config file.
66+pub fn parse(json: &str) -> Result<Config, String> {
67+ let config: Config = serde_json::from_str(json).map_err(|e| e.to_string())?;
68+ config.check()?;
69+ Ok(config)
70+}
71+
72+/// budctl's directory under `$XDG_CONFIG_HOME`, else `~/.config`.
73+pub fn dir() -> Option<PathBuf> {
74+ xdg_dir("XDG_CONFIG_HOME", ".config")
75+}
76+
77+/// budctl's directory under `$XDG_STATE_HOME`, else `~/.local/state`: logs.
78+pub fn state_dir() -> Option<PathBuf> {
79+ xdg_dir("XDG_STATE_HOME", ".local/state")
80+}
81+
82+fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
83+ let base = std::env::var_os(variable)
84+ .map(PathBuf::from)
85+ .filter(|p| p.is_absolute())
86+ .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(fallback)))?;
87+ Some(base.join("budctl"))
88+}
89+
90+/// `config.json` in [`dir`].
91+pub fn file() -> Option<PathBuf> {
92+ dir().map(|dir| dir.join("config.json"))
93+}
94+
95+/// The config, and why the file could not be used if it could not (the defaults are used then).
96+/// A missing file is written with the defaults; failing to write it is not a problem.
97+pub fn load() -> (Config, Option<String>) {
98+ let Some(file) = file() else {
99+ return (Config::default(), None);
100+ };
101+ load_from(&file)
102+}
103+
104+fn load_from(file: &Path) -> (Config, Option<String>) {
105+ match fs::read_to_string(file) {
106+ Ok(json) => match parse(&json) {
107+ Ok(config) => (config, None),
108+ Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))),
109+ },
110+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
111+ let _ = create(file, &defaults_json());
112+ (Config::default(), None)
113+ }
114+ Err(e) => (Config::default(), Some(format!("{}: {e}", file.display()))),
115+ }
116+}
117+
118+/// The default config, as written to a new file.
119+pub fn defaults_json() -> String {
120+ // A plain struct of strings and numbers always serializes.
121+ let mut json = serde_json::to_string_pretty(&Config::default()).unwrap_or_default();
122+ json.push('\n');
123+ json
124+}
125+
126+/// Write `contents` to `file`, creating its directory, unless the file already exists.
127+pub fn create(file: &Path, contents: &str) -> std::io::Result<()> {
128+ if let Some(dir) = file.parent() {
129+ fs::create_dir_all(dir)?;
130+ }
131+ let mut new = fs::OpenOptions::new().write(true).create_new(true).open(file)?;
132+ std::io::Write::write_all(&mut new, contents.as_bytes())
133+}
134+
135+#[cfg(test)]
136+mod tests {
137+ use super::*;
138+
139+ #[test]
140+ fn the_defaults_read_back_as_the_defaults() {
141+ assert_eq!(parse(&defaults_json()).unwrap(), Config::default());
142+ assert_eq!(parse("{}").unwrap(), Config::default());
143+ }
144+
145+ #[test]
146+ fn keys_override_the_defaults() {
147+ let config = parse(r#"{"device": "AA:BB:CC:DD:EE:FF", "gui": {"idle_seconds": 30}}"#).unwrap();
148+ assert_eq!(config.device().unwrap(), Some("AA:BB:CC:DD:EE:FF".parse().unwrap()));
149+ assert_eq!(config.gui.idle_seconds, 30);
150+ assert_eq!(config.gui.start_page, "overview");
151+ }
152+
153+ #[test]
154+ fn bad_files_are_errors() {
155+ assert!(parse("not json").is_err());
156+ assert!(
157+ parse(r#"{"devise": "AA:BB:CC:DD:EE:FF"}"#)
158+ .unwrap_err()
159+ .contains("devise")
160+ );
161+ assert!(parse(r#"{"device": "nope"}"#).unwrap_err().contains("device"));
162+ assert!(
163+ parse(r#"{"gui": {"idle_seconds": 0}}"#)
164+ .unwrap_err()
165+ .contains("idle_seconds")
166+ );
167+ }
168+
169+ #[test]
170+ fn a_missing_file_is_created_and_an_existing_one_kept() {
171+ let dir = std::env::temp_dir().join(format!("budctl-config-test-{}", std::process::id()));
172+ let file = dir.join("nested/config.json");
173+ let _ = fs::remove_dir_all(&dir);
174+
175+ assert_eq!(load_from(&file), (Config::default(), None));
176+ assert_eq!(fs::read_to_string(&file).unwrap(), defaults_json());
177+
178+ fs::write(&file, r#"{"gui": {"start_page": "equalizer"}}"#).unwrap();
179+ assert_eq!(load_from(&file).0.gui.start_page, "equalizer");
180+ assert!(create(&file, "{}").is_err(), "an existing file is never overwritten");
181+
182+ fs::write(&file, "{").unwrap();
183+ let (config, problem) = load_from(&file);
184+ assert_eq!(config, Config::default());
185+ assert!(problem.unwrap().contains("config.json"));
186+ let _ = fs::remove_dir_all(&dir);
187+ }
188+}
M crates/budctl-core/src/lib.rs View
@@ -7,6 +7,7 @@
77
88 pub mod bluez;
99 pub mod bt;
10+pub mod config;
1011 pub mod debug;
1112 pub mod devices;
1213 mod error;
A crates/budctl-gui/src/log.rs View
@@ -0,0 +1,60 @@
1+//! `~/.local/state/budctl/gui.log`: errors and session events (connected, each write and its
2+//! answer, closed), so a "Try again" can be explained after the fact even when the window was
3+//! started from a launcher and stderr went nowhere. Each launch starts the file afresh.
4+//!
5+//! Lines start with the seconds since launch, like `--debug` output. Frames are not logged;
6+//! `--debug` still hex-dumps them on stderr.
7+
8+use std::fs::{self, File};
9+use std::io::Write;
10+use std::path::{Path, PathBuf};
11+use std::sync::{Mutex, OnceLock};
12+use std::time::{Instant, SystemTime, UNIX_EPOCH};
13+
14+struct Log {
15+ file: Mutex<File>,
16+ started: Instant,
17+}
18+
19+static LOG: OnceLock<Log> = OnceLock::new();
20+
21+/// `gui.log` in budctl's state directory.
22+pub fn file() -> Option<PathBuf> {
23+ budctl_core::config::state_dir().map(|dir| dir.join("gui.log"))
24+}
25+
26+/// Start the log at `path`, replacing the last launch's. Until this runs, and if it fails,
27+/// [`line`] does nothing.
28+pub fn start(path: &Path) -> std::io::Result<()> {
29+ if let Some(dir) = path.parent() {
30+ fs::create_dir_all(dir)?;
31+ }
32+ let mut file = File::create(path)?;
33+ let since_epoch = SystemTime::now()
34+ .duration_since(UNIX_EPOCH)
35+ .map(|d| d.as_secs())
36+ .unwrap_or_default();
37+ writeln!(
38+ file,
39+ "budctl-gui {} started (unix time {since_epoch}, pid {})",
40+ env!("CARGO_PKG_VERSION"),
41+ std::process::id()
42+ )?;
43+ let _ = LOG.set(Log {
44+ file: Mutex::new(file),
45+ started: Instant::now(),
46+ });
47+ Ok(())
48+}
49+
50+/// Add a line to the log, if it is open.
51+pub fn line(message: impl AsRef<str>) {
52+ let Some(log) = LOG.get() else {
53+ return;
54+ };
55+ let elapsed = log.started.elapsed().as_secs_f64();
56+ if let Ok(mut file) = log.file.lock() {
57+ // A log that cannot be written must not take the window down with it.
58+ let _ = writeln!(file, "{elapsed:>9.3} {}", message.as_ref());
59+ }
60+}
M crates/budctl-gui/src/main.rs View
@@ -10,6 +10,7 @@ mod equalizer;
1010 mod gestures;
1111 mod icons;
1212 mod keys;
13+mod log;
1314 mod overview;
1415 mod settings;
1516 mod theme;
@@ -40,8 +41,11 @@ const USAGE: &str = "usage: budctl-gui [--device <MAC>] [--theme <palette.json>]
4041 --device picks the buds when more than one supported pair is connected.
4142 --demo shows made-up buds, without Bluetooth.
4243 --debug hex-dumps every frame sent and received on stderr, as budctl --debug does.
43−Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
44−The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).
44+Colors come from --theme, else from ~/.config/budctl/theme.json, else built in. That file is
45+written with the built-in colors when missing. It takes the keys of a matugen palette like
46+Aurora's (bg, fg, primary, …).
47+Preferences (default device, idle time, start page): ~/.config/budctl/config.json.
48+Each launch logs errors and session events to ~/.local/state/budctl/gui.log.
4549 Press ? in the window for the keys.";
4650
4751 /// The page's scrolling area, so the cursor can be kept in view.
@@ -68,9 +72,32 @@ fn main() -> ExitCode {
6872 if options.debug {
6973 budctl_core::debug::enable();
7074 }
75+ if let Some(file) = log::file()
76+ && let Err(e) = log::start(&file)
77+ {
78+ eprintln!("budctl-gui: cannot write the log {}: {e}", file.display());
79+ }
80+ let config = load_config();
81+ let start_page = Page::named(&config.gui.start_page).unwrap_or_else(|| {
82+ warn(format!(
83+ "gui.start_page: no page {:?}; opening Overview",
84+ config.gui.start_page
85+ ));
86+ Page::Overview
87+ });
88+ let idle = Duration::from_secs(config.gui.idle_seconds);
89+ let device = options.device.or_else(|| config.device().unwrap_or_default());
90+ if options.theme.is_none() {
91+ write_built_in_theme();
92+ }
7193 let theme_file = options.theme.clone().or_else(theme::default_file);
7294 let font = theme::system_font();
73− let boot = move || App::new(theme_file.clone(), font, options.device, options.demo);
95+ let boot = move || {
96+ let mut app = App::new(theme_file.clone(), font, device, options.demo);
97+ app.page = start_page;
98+ app.idle = idle;
99+ app
100+ };
74101 let result = iced::application(boot, App::update, App::view)
75102 .title("budctl")
76103 .theme(App::theme)
@@ -95,6 +122,35 @@ fn main() -> ExitCode {
95122 }
96123 }
97124
125+/// A problem worth knowing that does not stop the window: on stderr and in the log.
126+fn warn(message: String) {
127+ eprintln!("budctl-gui: {message}");
128+ log::line(message);
129+}
130+
131+/// The config, with the defaults when it cannot be used.
132+fn load_config() -> budctl_core::config::Config {
133+ let (config, problem) = budctl_core::config::load();
134+ match problem {
135+ Some(problem) => warn(format!("{problem}; using the defaults")),
136+ None => log::line(format!("config: {config:?}")),
137+ }
138+ config
139+}
140+
141+/// Write the built-in colors to `~/.config/budctl/theme.json` when there is no such file, as a
142+/// start for a matugen template.
143+fn write_built_in_theme() {
144+ let Some(file) = theme::default_file() else {
145+ return;
146+ };
147+ match budctl_core::config::create(&file, &theme::to_json(&theme::BUILT_IN)) {
148+ Ok(()) => log::line(format!("wrote the built-in colors to {}", file.display())),
149+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
150+ Err(e) => warn(format!("cannot write {}: {e}", file.display())),
151+ }
152+}
153+
98154 #[derive(Default)]
99155 struct Options {
100156 theme: Option<PathBuf>,
@@ -164,6 +220,18 @@ impl Page {
164220 }
165221 }
166222
223+ /// A page by its name in the config: `overview`, `equalizer`, `gestures`, `earbuds`, `settings`.
224+ fn named(name: &str) -> Option<Page> {
225+ match name {
226+ "overview" => Some(Page::Overview),
227+ "equalizer" => Some(Page::Equalizer),
228+ "gestures" => Some(Page::Gestures),
229+ "earbuds" => Some(Page::Earbuds),
230+ "settings" => Some(Page::Settings),
231+ _ => None,
232+ }
233+ }
234+
167235 /// The next page in the sidebar, or the previous one, going round.
168236 fn step(self, forward: bool) -> Page {
169237 let count = Page::ALL.len();
@@ -340,8 +408,10 @@ struct App {
340408 cursor_shown: bool,
341409 /// Whether the list of keys is open.
342410 help: bool,
343− /// `--device`, for the worker.
411+ /// `--device`, else the config's, for the worker.
344412 device: Option<BdAddr>,
413+ /// How long the worker keeps an idle session open.
414+ idle: Duration,
345415 link: Link,
346416 /// Where commands go, once the worker is up.
347417 worker: Option<Sender<Command>>,
@@ -397,6 +467,7 @@ impl App {
397467 cursor_shown: false,
398468 help: false,
399469 device,
470+ idle: worker::IDLE,
400471 link: if demo { Link::Demo } else { Link::Connecting },
401472 worker: None,
402473 in_flight: VecDeque::new(),
@@ -1186,7 +1257,7 @@ impl App {
11861257 palette
11871258 }
11881259 Some(Err(e)) => {
1189− eprintln!("budctl-gui: {e}; using the built-in colors");
1260+ warn(format!("{e}; using the built-in colors"));
11901261 self.palette_problem = Some(e);
11911262 theme::BUILT_IN
11921263 }
@@ -1211,7 +1282,7 @@ impl App {
12111282 if self.link == Link::Demo {
12121283 return events;
12131284 }
1214− let worker = Subscription::run_with(self.device, worker::run).map(Message::Worker);
1285+ let worker = Subscription::run_with((self.device, self.idle), worker::run).map(Message::Worker);
12151286 Subscription::batch([events, worker])
12161287 }
12171288
M crates/budctl-gui/src/settings.rs View
@@ -27,7 +27,8 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
2727 let palette = ui::setting(look, Icon::Settings, "Colors", Some(source), none(), None, false);
2828 let how = text(
2929 "budctl follows a matugen palette with Aurora's keys (bg, fg, primary, surface…): the file given \
30− with --theme, else ~/.config/budctl/theme.json. It is read again whenever the window gets focus.",
30+ with --theme, else ~/.config/budctl/theme.json, which starts out with the built-in colors. \
31+ It is read again whenever the window gets focus.",
3132 )
3233 .size(13)
3334 .color(p.dim);
@@ -40,6 +41,24 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
4041 None,
4142 false,
4243 );
44+ let files = ui::setting(
45+ look,
46+ Icon::Settings,
47+ "Preferences",
48+ Some("~/.config/budctl/config.json: default device, idle time, start page. Read at launch.".to_owned()),
49+ none(),
50+ None,
51+ false,
52+ );
53+ let log = ui::setting(
54+ look,
55+ Icon::Info,
56+ "Log",
57+ Some("~/.local/state/budctl/gui.log: errors and session events, started afresh each launch.".to_owned()),
58+ none(),
59+ None,
60+ false,
61+ );
4362 let about = ui::setting(
4463 look,
4564 Icon::Info,
@@ -53,6 +72,7 @@ pub fn view<'a>(look: Look, colors: Colors<'a>) -> Element<'a, Message> {
5372 ui::title(look, "App settings"),
5473 column![ui::card(look, vec![palette]), how].spacing(10),
5574 ui::card(look, vec![keys]),
75+ ui::card(look, vec![files, log]),
5676 ui::card(look, vec![about]),
5777 ]
5878 .spacing(18)
M crates/budctl-gui/src/theme.rs View
@@ -127,11 +127,42 @@ fn parse_hex(hex: &str) -> Option<Color> {
127127
128128 /// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`.
129129 pub fn default_file() -> Option<PathBuf> {
130− let config = std::env::var_os("XDG_CONFIG_HOME")
131− .map(PathBuf::from)
132− .filter(|p| p.is_absolute())
133− .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
134− Some(config.join("budctl/theme.json"))
130+ budctl_core::config::dir().map(|dir| dir.join("theme.json"))
131+}
132+
133+/// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to
134+/// [`default_file`] when there is none, to start a matugen template from.
135+pub fn to_json(p: &Palette) -> String {
136+ let keys = [
137+ ("bg", p.bg),
138+ ("fg", p.fg),
139+ ("primary", p.accent),
140+ ("primaryFg", p.accent_fg),
141+ ("primaryContainer", p.accent_dim),
142+ ("secondary", p.icon),
143+ ("surfaceLow", p.surface_low),
144+ ("surface", p.surface),
145+ ("surfaceHigh", p.surface_high),
146+ ("surfaceHighest", p.surface_highest),
147+ ("outlineVariant", p.line),
148+ ("surfaceVariantFg", p.dim),
149+ ("err", p.err),
150+ ];
151+ let lines: Vec<String> = keys
152+ .iter()
153+ .map(|(key, color)| format!(" \"{key}\": \"{}\"", to_hex(*color)))
154+ .collect();
155+ format!("{{\n{}\n}}\n", lines.join(",\n"))
156+}
157+
158+/// `#rrggbb`, or `#rrggbbaa` when not opaque.
159+fn to_hex(color: Color) -> String {
160+ let [r, g, b, a] = color.into_rgba8();
161+ if a == u8::MAX {
162+ format!("#{r:02x}{g:02x}{b:02x}")
163+ } else {
164+ format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
165+ }
135166 }
136167
137168 /// The palette file and the version of it last read, so it is only read again when it changes.
@@ -269,6 +300,16 @@ mod tests {
269300 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
270301 }
271302
303+ #[test]
304+ fn the_written_palette_reads_back_the_same() {
305+ assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN);
306+ let see_through = Palette {
307+ accent: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
308+ ..BUILT_IN
309+ };
310+ assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through);
311+ }
312+
272313 #[test]
273314 fn a_missing_file_changes_nothing() {
274315 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
M crates/budctl-gui/src/worker.rs View
@@ -19,8 +19,10 @@ use budctl_core::{Error, Kind, Result};
1919 use iced::futures::channel::mpsc as channel;
2020 use iced::futures::{SinkExt, Stream, StreamExt};
2121
22−/// How long the session stays open after the last command.
23−const IDLE: Duration = Duration::from_secs(10);
22+use crate::log;
23+
24+/// How long the session stays open after the last command, unless the config says otherwise.
25+pub const IDLE: Duration = Duration::from_secs(10);
2426 /// How often an open session answers the buds while it waits for a command.
2527 const SERVE_EVERY: Duration = Duration::from_millis(100);
2628
@@ -104,16 +106,15 @@ pub struct Reading {
104106 }
105107
106108 /// The worker, as a stream of events for an iced subscription. `device` picks the buds, as
107−/// `budctl --device` does.
108−pub fn run(device: &Option<BdAddr>) -> impl Stream<Item = Event> + use<> {
109− let device = *device;
109+/// `budctl --device` does; `idle` is how long a session stays open after the last command.
110+pub fn run(&(device, idle): &(Option<BdAddr>, Duration)) -> impl Stream<Item = Event> + use<> {
110111 iced::stream::channel(16, async move |mut output: channel::Sender<Event>| {
111112 let (commands, inbox) = mpsc::channel();
112113 let (events, mut outbox) = channel::unbounded();
113114 if output.send(Event::Ready(commands)).await.is_err() {
114115 return;
115116 }
116− let worker = Worker::new(bluetooth(device), events, IDLE);
117+ let worker = Worker::new(bluetooth(device), events, idle);
117118 let started = thread::Builder::new()
118119 .name("budctl-buds".to_owned())
119120 .spawn(move || worker.run(&inbox));
@@ -193,19 +194,20 @@ impl Worker {
193194 };
194195 if self.last_command.elapsed() >= self.idle {
195196 self.session = None;
197+ let message = format!("session closed after {} s without a command", self.idle.as_secs());
196198 if budctl_core::debug::enabled() {
197− eprintln!(
198− "# session closed after {} s without a command",
199− self.idle.as_secs()
200− );
199+ eprintln!("# {message}");
201200 }
201+ log::line(message);
202202 } else if let Err(error) = session.serve() {
203203 self.session = None;
204+ log::line(format!("link lost: {error}"));
204205 self.send(Event::Lost(error.to_string()));
205206 }
206207 }
207208
208209 fn handle(&mut self, command: Command) {
210+ log::line(format!("{command:?}"));
209211 let answer = match command {
210212 Command::Read => self.read().map(|reading| Event::Read(Box::new(reading))),
211213 Command::Noise(mode) => self
@@ -262,8 +264,12 @@ impl Worker {
262264 }
263265 };
264266 match answer {
265− Ok(event) => self.send(event),
267+ Ok(event) => {
268+ log::line(" done");
269+ self.send(event);
270+ }
266271 Err(error) => {
272+ log::line(format!(" failed ({:?}): {error}", error.kind()));
267273 self.session = None;
268274 self.send(Event::Failed {
269275 kind: error.kind(),
@@ -301,10 +307,12 @@ impl Worker {
301307 fn session(&mut self) -> Result<&mut Box<dyn Session>> {
302308 if self.session.is_none() {
303309 let events = self.events.clone();
304− let found = move |name| {
310+ let found = move |name: String| {
311+ log::line(format!(" connecting to {name}"));
305312 let _ = events.unbounded_send(Event::Found(name));
306313 };
307314 self.session = Some((self.connect)(&found)?);
315+ log::line(" session open");
308316 }
309317 self.session
310318 .as_mut()