Krolyxon
Commit 6d1e074

Branches

Refs →

budctlcratesbudctl-guisrcmain.rs

374 lines · 12.1 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).
5
6mod demo;
7mod icons;
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, scrollable, space, text};
17use iced::{Alignment, Border, Element, Font, Length, Size, Subscription, Theme, event, window};
18
19use icons::Icon;
20use theme::Look;
21
22const USAGE: &str = "usage: budctl-gui [--theme <palette.json>]
23
24Colors come from --theme, else from ~/.config/budctl/theme.json if it exists, else built in.
25The palette file takes the keys of a matugen palette like Aurora's (bg, fg, primary, …).";
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::system_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(1040.0, 800.0),
47 min_size: Some(Size::new(760.0, 540.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 title(self) -> &'static str {
100 match self {
101 Page::Overview => "Overview",
102 Page::Equalizer => "Equalizer",
103 Page::Gestures => "Gestures",
104 Page::Earbuds => "Earbud settings",
105 Page::Settings => "App settings",
106 }
107 }
108
109 fn icon(self) -> Icon {
110 match self {
111 Page::Overview => Icon::Overview,
112 Page::Equalizer => Icon::Equalizer,
113 Page::Gestures => Icon::Gestures,
114 Page::Earbuds => Icon::Earbud,
115 Page::Settings => Icon::Settings,
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
121enum Message {
122 Open(Page),
123 Noise(NoiseMode),
124 /// A switch's key, and whether to turn it on.
125 Switch(&'static str, bool),
126 Connect(bool),
127 /// The window got focus: the palette file may have changed meanwhile.
128 Focused,
129}
130
131struct App {
132 page: Page,
133 buds: demo::Buds,
134 /// The level "Noise cancelling" turns on at: the one last reported or picked.
135 anc_level: AncLevel,
136 look: Look,
137 theme: Theme,
138 palette: theme::Source,
139}
140
141impl App {
142 fn new(theme_file: Option<PathBuf>, font: Font) -> Self {
143 let buds = demo::buds();
144 let anc_level = buds
145 .status
146 .noise_control
147 .as_ref()
148 .and_then(|now| now.level.as_deref())
149 .and_then(anc_level)
150 .unwrap_or(AncLevel::Deep);
151 let mut app = App {
152 page: Page::Overview,
153 buds,
154 anc_level,
155 look: Look {
156 p: theme::BUILT_IN,
157 font,
158 },
159 theme: theme::iced_theme(&theme::BUILT_IN),
160 palette: theme::Source::new(theme_file),
161 };
162 app.refresh_palette();
163 app
164 }
165
166 fn update(&mut self, message: Message) {
167 match message {
168 Message::Open(page) => self.page = page,
169 Message::Noise(mode) => {
170 let level = match mode {
171 NoiseMode::Anc(level) => {
172 self.anc_level = level;
173 Some(level.name().to_owned())
174 }
175 NoiseMode::Off | NoiseMode::Transparency => None,
176 };
177 self.buds.status.noise_control = Some(NoiseControl {
178 mode: mode.name().to_owned(),
179 level,
180 });
181 }
182 Message::Switch(key, on) => {
183 if let Some(switch) = self.buds.status.switches.iter_mut().find(|s| s.key == key) {
184 switch.on = on;
185 }
186 }
187 Message::Connect(on) => self.buds.connected = on,
188 Message::Focused => self.refresh_palette(),
189 }
190 }
191
192 fn refresh_palette(&mut self) {
193 let palette = match self.palette.refresh() {
194 None => return,
195 Some(Ok(palette)) => palette,
196 Some(Err(e)) => {
197 eprintln!("budctl-gui: {e}; using the built-in colors");
198 theme::BUILT_IN
199 }
200 };
201 self.look.p = palette;
202 self.theme = theme::iced_theme(&palette);
203 }
204
205 fn theme(&self) -> Theme {
206 self.theme.clone()
207 }
208
209 fn subscription(&self) -> Subscription<Message> {
210 event::listen_with(|event, _status, _window| match event {
211 iced::Event::Window(window::Event::Focused) => Some(Message::Focused),
212 _ => None,
213 })
214 }
215
216 fn view(&self) -> Element<'_, Message> {
217 let look = self.look;
218 let page = match self.page {
219 Page::Overview => overview::view(look, &self.buds, self.anc_level),
220 page => column![
221 ui::title(look, page.title()),
222 text("This page comes in a later step.").size(14).color(look.p.dim)
223 ]
224 .spacing(12)
225 .into(),
226 };
227 let p = look.p;
228 let content = scrollable(
229 container(container(page).max_width(860))
230 .center_x(Length::Fill)
231 .padding([30, 36]),
232 )
233 .height(Length::Fill)
234 .style(move |theme, status| {
235 let mut style = scrollable::default(theme, status);
236 for rail in [&mut style.vertical_rail, &mut style.horizontal_rail] {
237 rail.background = None;
238 rail.border = Border::default();
239 rail.scroller.background = p.surface_highest.into();
240 rail.scroller.border = Border::default().rounded(4);
241 }
242 style
243 });
244 row![self.sidebar(), ui::divider(look), content].into()
245 }
246
247 fn sidebar(&self) -> Element<'_, Message> {
248 let look = self.look;
249 let p = look.p;
250 let connected = self.buds.connected;
251 let (light, state) = if connected {
252 (p.ok, "Connected")
253 } else {
254 (p.dim, "Not connected")
255 };
256 let device = container(
257 column![
258 text(self.buds.name.as_str()).size(16).font(look.bold()).color(p.fg),
259 row![
260 ui::light(light),
261 text(state).size(13).color(p.dim),
262 space::horizontal(),
263 ui::tag(look, "demo")
264 ]
265 .spacing(8)
266 .align_y(Alignment::Center),
267 ui::outline_button(
268 look,
269 if connected { "Disconnect" } else { "Connect" },
270 Message::Connect(!connected)
271 ),
272 ]
273 .spacing(12),
274 )
275 .padding(16)
276 .style(move |_| container::Style {
277 background: Some(p.surface.into()),
278 border: Border {
279 color: p.line,
280 width: 1.0,
281 radius: 18.0.into(),
282 },
283 ..container::Style::default()
284 });
285 let pages = Page::ALL.into_iter().fold(Column::new().spacing(3), |pages, page| {
286 pages.push(ui::nav_item(
287 look,
288 page.icon(),
289 page.title(),
290 page == self.page,
291 Message::Open(page),
292 ))
293 });
294 container(column![device, pages].spacing(22))
295 .padding([18, 14])
296 .width(268)
297 .height(Length::Fill)
298 .style(move |_| container::Style {
299 background: Some(p.surface_low.into()),
300 ..container::Style::default()
301 })
302 .into()
303 }
304}
305
306/// The level a [`NoiseControl::level`] names.
307fn anc_level(name: &str) -> Option<AncLevel> {
308 [AncLevel::Light, AncLevel::Medium, AncLevel::Deep]
309 .into_iter()
310 .find(|level| level.name() == name)
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use std::fs;
317 use std::path::Path;
318
319 #[test]
320 fn noise_cancelling_remembers_its_level() {
321 let mut app = App::new(None, Font::DEFAULT);
322 app.update(Message::Noise(NoiseMode::Anc(AncLevel::Light)));
323 app.update(Message::Noise(NoiseMode::Transparency));
324 assert_eq!(app.anc_level, AncLevel::Light);
325 let now = app.buds.status.noise_control.as_ref().unwrap();
326 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("transparency", None));
327 app.update(Message::Noise(NoiseMode::Anc(app.anc_level)));
328 let now = app.buds.status.noise_control.as_ref().unwrap();
329 assert_eq!((now.mode.as_str(), now.level.as_deref()), ("anc", Some("light")));
330 }
331
332 #[test]
333 fn switches_change_by_key() {
334 let mut app = App::new(None, Font::DEFAULT);
335 app.update(Message::Switch("game", true));
336 let game = app.buds.status.switches.iter().find(|s| s.key == "game").unwrap();
337 assert!(game.on);
338 }
339
340 #[test]
341 fn arguments() {
342 let args = |list: &[&str]| parse_args(list.iter().map(|s| (*s).to_owned()));
343 assert!(matches!(args(&[]), Ok(Args::Run { theme: None })));
344 assert!(matches!(args(&["--theme", "x.json"]), Ok(Args::Run { theme: Some(_) })));
345 assert!(matches!(args(&["--help"]), Ok(Args::Help)));
346 assert!(args(&["--theme"]).is_err());
347 assert!(args(&["--bogus"]).is_err());
348 }
349
350 /// Renders every page to `target/gui-snapshots/`, so the look can be checked without a
351 /// display. `BUDCTL_THEME=<file>` renders with that palette.
352 #[test]
353 fn pages_render() {
354 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/gui-snapshots");
355 let _ = fs::remove_dir_all(&dir);
356 let font = theme::system_font();
357 let palette = std::env::var_os("BUDCTL_THEME").map(PathBuf::from);
358 let shots = Page::ALL
359 .into_iter()
360 .map(|page| (page, 800.0, format!("{page:?}").to_lowercase()))
361 .chain([(Page::Overview, 1240.0, "overview-full".to_owned())]);
362 for (page, height, name) in shots {
363 let mut app = App::new(palette.clone(), font);
364 app.page = page;
365 let settings = iced::Settings {
366 default_font: font,
367 ..iced::Settings::default()
368 };
369 let mut ui = iced_test::Simulator::with_size(settings, Size::new(1040.0, height), app.view());
370 let snapshot = ui.snapshot(&app.theme).unwrap();
371 assert!(snapshot.matches_image(dir.join(name)).unwrap());
372 }
373 }
374}