Krolyxon
Commit 99a16bb

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

346 lines · 11.3 KB · Rust

1//! `budctl-gui`: control wireless earbuds from Linux, in a window.
2//!
3//! A thin layer over budctl-core, like the CLI. For now it shows demo data: the look comes first,
4//! the live connection later (CLAUDE.md, GUI). It is styled after the owner's shell, Aurora.
5
6mod demo;
7mod glyphs;
8mod overview;
9mod theme;
10mod ui;
11
12use std::path::PathBuf;
13use std::process::ExitCode;
14
15use budctl_core::status::{AncLevel, NoiseControl, NoiseMode};
16use iced::widget::{Column, column, container, row};
17use iced::{Element, Font, Length, Size, Subscription, Theme, event, window};
18
19use theme::Look;
20
21const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
22
23Colors come from --theme, else ~/.config/budctl/theme.json, else Aurora's matugen palette
24(~/.cache/matugen/aurora.json), else they are built in. A palette file takes Aurora's keys
25(bg, fg, primary, surface, surfaceHigh, outlineVariant, surfaceVariantFg, err).";
26
27fn main() -> ExitCode {
28 let theme_file = match parse_args(std::env::args().skip(1)) {
29 Ok(Args::Run { theme }) => theme.or_else(theme::default_file),
30 Ok(Args::Help) => {
31 println!("{USAGE}");
32 return ExitCode::SUCCESS;
33 }
34 Err(message) => {
35 eprintln!("budctl-gui: {message}\n{USAGE}");
36 return ExitCode::from(1);
37 }
38 };
39 let font = theme::font();
40 let result = iced::application(move || App::new(theme_file.clone(), font), App::update, App::view)
41 .title("budctl")
42 .theme(App::theme)
43 .subscription(App::subscription)
44 .default_font(font)
45 .window(window::Settings {
46 size: Size::new(960.0, 680.0),
47 min_size: Some(Size::new(720.0, 480.0)),
48 platform_specific: window::settings::PlatformSpecific {
49 application_id: "budctl".to_owned(),
50 ..window::settings::PlatformSpecific::default()
51 },
52 ..window::Settings::default()
53 })
54 .run();
55 match result {
56 Ok(()) => ExitCode::SUCCESS,
57 Err(e) => {
58 eprintln!("budctl-gui: {e}");
59 ExitCode::FAILURE
60 }
61 }
62}
63
64enum Args {
65 Run { theme: Option<PathBuf> },
66 Help,
67}
68
69fn parse_args(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
70 let mut theme = None;
71 while let Some(arg) = args.next() {
72 match arg.as_str() {
73 "--theme" => theme = Some(PathBuf::from(args.next().ok_or("--theme needs a file")?)),
74 "-h" | "--help" => return Ok(Args::Help),
75 other => return Err(format!("unknown argument {other:?}")),
76 }
77 }
78 Ok(Args::Run { theme })
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum Page {
83 Overview,
84 Equalizer,
85 Gestures,
86 Earbuds,
87 Settings,
88}
89
90impl Page {
91 const ALL: [Page; 5] = [
92 Page::Overview,
93 Page::Equalizer,
94 Page::Gestures,
95 Page::Earbuds,
96 Page::Settings,
97 ];
98
99 fn name(self) -> &'static str {
100 match self {
101 Page::Overview => "overview",
102 Page::Equalizer => "equalizer",
103 Page::Gestures => "gestures",
104 Page::Earbuds => "earbuds",
105 Page::Settings => "settings",
106 }
107 }
108
109 fn glyph(self) -> char {
110 match self {
111 Page::Overview => glyphs::OVERVIEW,
112 Page::Equalizer => glyphs::TUNE,
113 Page::Gestures => glyphs::GESTURE,
114 Page::Earbuds => glyphs::EARBUDS,
115 Page::Settings => glyphs::COG,
116 }
117 }
118
119 /// The sidebar group it is listed under.
120 fn group(self) -> &'static str {
121 match self {
122 Page::Settings => "app",
123 _ => "buds",
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
129enum Message {
130 Open(Page),
131 Noise(NoiseMode),
132 /// A switch's key, and whether to turn it on.
133 Switch(&'static str, bool),
134 Connect(bool),
135 /// The window got focus: the palette file may have changed meanwhile.
136 Focused,
137}
138
139struct App {
140 page: Page,
141 buds: demo::Buds,
142 /// The level "noise cancelling" turns on at: the one last reported or picked.
143 anc_level: AncLevel,
144 look: Look,
145 theme: Theme,
146 palette: theme::Source,
147}
148
149impl App {
150 fn new(theme_file: Option<PathBuf>, font: Font) -> Self {
151 let buds = demo::buds();
152 let anc_level = buds
153 .status
154 .noise_control
155 .as_ref()
156 .and_then(|now| now.level.as_deref())
157 .and_then(anc_level)
158 .unwrap_or(AncLevel::Deep);
159 let mut app = App {
160 page: Page::Overview,
161 buds,
162 anc_level,
163 look: Look::new(theme::BUILT_IN, font),
164 theme: theme::iced_theme(&theme::BUILT_IN),
165 palette: theme::Source::new(theme_file),
166 };
167 app.refresh_palette();
168 app
169 }
170
171 fn update(&mut self, message: Message) {
172 match message {
173 Message::Open(page) => self.page = page,
174 Message::Noise(mode) => {
175 let level = match mode {
176 NoiseMode::Anc(level) => {
177 self.anc_level = level;
178 Some(level.name().to_owned())
179 }
180 NoiseMode::Off | NoiseMode::Transparency => None,
181 };
182 self.buds.status.noise_control = Some(NoiseControl {
183 mode: mode.name().to_owned(),
184 level,
185 });
186 }
187 Message::Switch(key, on) => {
188 if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) {
189 switch.on = on;
190 }
191 }
192 Message::Connect(on) => self.buds.connected = on,
193 Message::Focused => self.refresh_palette(),
194 }
195 }
196
197 fn refresh_palette(&mut self) {
198 let palette = match self.palette.refresh() {
199 None => return,
200 Some(Ok(palette)) => palette,
201 Some(Err(e)) => {
202 eprintln!("budctl-gui: {e}; using the built-in colors");
203 theme::BUILT_IN
204 }
205 };
206 self.look.p = palette;
207 self.theme = theme::iced_theme(&palette);
208 }
209
210 fn theme(&self) -> Theme {
211 self.theme.clone()
212 }
213
214 fn subscription(&self) -> Subscription<Message> {
215 event::listen_with(|event, _status, _window| match event {
216 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
217 _ => None,
218 })
219 }
220
221 fn view(&self) -> Element<'_, Message> {
222 let look = self.look;
223 let page = match self.page {
224 Page::Overview => overview::view(look, &self.buds, self.anc_level),
225 _ => ui::hint(look, "this page comes in a later step").into(),
226 };
227 let content = column![ui::title(look, self.page.name()), ui::scroll(look, page)]
228 .spacing(12)
229 .padding(16)
230 .width(Length::Fill);
231 row![self.sidebar(), ui::divider(look), content].into()
232 }
233
234 /// Aurora's settings sidebar: the title, the buds, then the pages by group.
235 fn sidebar(&self) -> Element<'_, Message> {
236 let look = self.look;
237 let p = look.p;
238 let connected = self.buds.connected;
239 let status = format!("{} · demo", if connected { "connected" } else { "not connected" });
240 let connect = if connected {
241 ui::button_(look, Some(glyphs::BT_OFF), "disconnect", false, Message::Connect(false))
242 } else {
243 ui::button_(look, Some(glyphs::BT_CONNECT), "connect", false, Message::Connect(true))
244 };
245 let mut pages = Column::new().spacing(2);
246 let mut group = "";
247 for page in Page::ALL {
248 if page.group() != group {
249 group = page.group();
250 pages = pages.push(container(ui::group(look, group)).padding([6, 0]));
251 }
252 pages = pages.push(ui::nav_item(
253 look,
254 page.glyph(),
255 page.name(),
256 page == self.page,
257 Message::Open(page),
258 ));
259 }
260 let top = column![
261 ui::sidebar_title(look, glyphs::EARBUDS, "budctl"),
262 ui::device(look, &self.buds.name, status, connected),
263 connect.width(Length::Fill),
264 ]
265 .spacing(8);
266 container(column![top, pages].spacing(10))
267 .padding(10)
268 .width(200)
269 .height(Length::Fill)
270 .style(move |_| container::Style {
271 background: Some(p.surface.into()),
272 ..container::Style::default()
273 })
274 .into()
275 }
276}
277
278/// The level a [`NoiseControl::level`] names.
279fn anc_level(name: &str) -> Option<AncLevel> {
280 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
281 .into_iter()
282 .find(|level| level.name() == name)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::fs;
289 use std::path::Path;
290
291 #[test]
292 fn noise_cancelling_remembers_its_level() {
293 let mut app = App::new(None, Font::DEFAULT);
294 app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
295 app.update(Message::Noise(NoiseMode::Transparency));
296 assert_eq!(app.anc_level, AncLevel::Light);
297 let now = app.buds.status.noise_control.as_ref().unwrap();
298 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
299 app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
300 let now = app.buds.status.noise_control.as_ref().unwrap();
301 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
302 }
303
304 #[test]
305 fn switches_change_by_key() {
306 let mut app = App::new(None, Font::DEFAULT);
307 app.update(Message::Switch("game", true));
308 let game = app.buds.status.switches.iter().find(|s| s.key == "game").unwrap();
309 assert!(game.on);
310 }
311
312 #[test]
313 fn arguments() {
314 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
315 assert!(matches!(args(&[]), Ok(Args::Run { theme: None })));
316 assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) })));
317 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
318 assert!(args(&["--theme"]).is_err());
319 assert!(args(&["--bogus"]).is_err());
320 }
321
322 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
323 /// display. `BUDCTL_THEME=<file>` renders with that palette.
324 #[test]
325 fn pages_render() {
326 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
327 let _ = fs::remove_dir_all(&dir);
328 let font = theme::font();
329 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
330 let shots = Page::ALL
331 .into_iter()
332 .map(|page| (page, 680.0, page.name().to_owned()))
333 .chain([(Page::Overview, 860.0, "overview-full".to_owned())]);
334 for (page, height, name) in shots {
335 let mut app = App::new(palette.clone(), font);
336 app.page = page;
337 let settings = iced::Settings {
338 default_font: font,
339 ..iced::Settings::default()
340 };
341 let mut ui = iced_test::Simulator::with_size(settings, Size::new(960.0, height), app.view());
342 let snapshot = ui.snapshot(&app.theme).unwrap();
343 assert!(snapshot.matches_image(dir.join(name)).unwrap());
344 }
345 }
346}