diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index e8d486a..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "cargo" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c53393d..4522958 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,26 @@ name: CI -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + branches: + - main jobs: ci: - name: ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + name: CI + runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + lfs: true + - uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 - name: cargo build run: cargo build @@ -25,3 +33,12 @@ jobs: - name: cargo clippy run: cargo clippy -- -D warnings + + typos: + name: Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: crate-ci/typos@master \ No newline at end of file diff --git a/.gitignore b/.gitignore index e1b800a..0d1187a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ Cargo.lock **/*.rs.bk *.pdb .DS_Store +**/tests/snapshots/**/*.diff.png +**/tests/snapshots/**/*.new.png +**/tests/snapshots/**/*.old.png \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ea63d87..e6596ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "awtrix-gui" -version = "0.1.0-beta.1" -edition = "2021" +version = "0.1.0" +edition = "2024" description = "A GUI for the awtrix clock." authors = ["bircni"] readme = "README.md" @@ -24,7 +24,7 @@ icon = [ ] version = "0.1.0" resources = ["./../res/mac-icons/icon*.png"] -copyright = "© 2024 bircni" +copyright = "© 2025 bircni" short_description = "A GUI for the awtrix clock." # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -34,36 +34,64 @@ short_description = "A GUI for the awtrix clock." [dependencies] # Error handling -anyhow = "1.0.95" -# Networking -reqwest = { version = "0.12.11", features = ["blocking"] } +anyhow = "1" # Parsing -serde = { version = "1.0.217", features = ["derive"] } -serde_json = "1.0.134" -semver = "1.0.24" +serde = { version = "1", features = ["derive"] } +serde_json = "1" # GUI -eframe = "0.30.0" -egui = "0.30.0" -egui-notify = "0.18.0" -egui_extras = { version = "0.30.0", features = ["syntect", "image"] } -image = "0.25.5" -open = "5.3.1" -parking_lot = "0.12.3" +eframe = "0.33" +egui = "0.33" +egui-notify = "0.21" +egui_extras = { version = "0.33", features = ["syntect", "image"] } +image = "0.25" +open = "5.3" +parking_lot = "0.12" +ureq = "3.1.4" + +[dev-dependencies] +egui_kittest = { version = "0.33", features = ["wgpu", "snapshot"] } +eframe = { version = "0.33", features = ["wgpu"] } +tokio = { version = "1", features = ["time", "rt", "macros"] } +wgpu = "27" + [lints.rust] -unsafe_code = "forbid" -deprecated = "deny" +unsafe_code = "deny" +deprecated = "warn" +elided_lifetimes_in_paths = "warn" +rust_2021_prelude_collisions = "warn" +semicolon_in_expressions_from_macros = "warn" +trivial_numeric_casts = "warn" +unsafe_op_in_unsafe_fn = "warn" # `unsafe_op_in_unsafe_fn` may become the default in future Rust versions: https://github.com/rust-lang/rust/issues/71668 +unused_extern_crates = "warn" +unused_import_braces = "warn" +unused_lifetimes = "warn" + +[lints.rustdoc] +all = "warn" +missing_crate_level_docs = "warn" [lints.clippy] -nursery = { level = "deny", priority = 0 } -pedantic = { level = "deny", priority = 1 } -enum_glob_use = { level = "deny", priority = 2 } -perf = { level = "deny", priority = 3 } -style = { level = "deny", priority = 4 } -unwrap_used = { level = "deny", priority = 5 } -expect_used = { level = "deny", priority = 6 } -module_name_repetitions = { level = "allow", priority = 7 } -cast_precision_loss = { level = "allow", priority = 8 } -cast_possible_truncation = { level = "allow", priority = 9 } -cast_sign_loss = { level = "allow", priority = 10 } -out_of_bounds_indexing = { level = "allow", priority = 11 } +all = "warn" +correctness = "warn" +suspicious = "warn" +style = "warn" +complexity = "warn" +perf = "warn" +pedantic = "warn" +nursery = "warn" + +# Additional lints from https://rust-lang.github.io/rust-clippy/master/index.html?groups=restriction +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +assertions_on_result_states = "warn" +create_dir = "warn" +clone_on_ref_ptr = "warn" +expect_used = "warn" +missing_assert_message = "warn" +panic_in_result_fn = "warn" +str_to_string = "warn" +todo = "warn" +unwrap_used = "warn" +unimplemented = "warn" +wildcard_enum_match_arm = "warn" diff --git a/LICENSE b/LICENSE index 0b3a996..7745228 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 bircni +Copyright (c) 2025 bircni Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/config.rs b/src/config.rs index 32e9208..73026d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,9 @@ use anyhow::Context; use core::str; use serde::{Deserialize, Serialize}; -use std::{env, fs, io::Write}; +use std::{fs, io::Write}; -const ENV: &str = ".awtrix.env"; +const CONFIG: &str = "awtrix.conf"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Config { @@ -12,6 +12,7 @@ pub struct Config { pub last_state: bool, } +// TODO refactoring to use home dir impl Config { pub fn new() -> Self { Self::read().unwrap_or_else(|_| Self { @@ -21,21 +22,27 @@ impl Config { } fn read() -> anyhow::Result { - let curr = env::current_exe()?; - let filepath = curr.parent().context("Failed to gt parent path")?.join(ENV); + let home_dir = std::env::home_dir().context("Failed to get home directory")?; + let config_dir = home_dir.join(".config").join("awtrix-gui"); + if !config_dir.exists() { + fs::create_dir_all(&config_dir).context("Failed to create config directory")?; + } + let filepath = config_dir.join(CONFIG); anyhow::ensure!(filepath.exists(), "Path does not exist"); let content = fs::read_to_string(filepath)?; serde_json::from_str(&content).context("Failed to deserialize Config") } pub fn write(&self) -> anyhow::Result<()> { - if let Some(filepath) = env::current_exe()?.parent().map(|x| x.join(ENV)) { - let mut file = fs::File::create(filepath)?; - file.write_all(serde_json::to_string(self)?.as_bytes())?; - file.flush()?; - Ok(()) - } else { - anyhow::bail!("Failed to write to file") + let home_dir = std::env::home_dir().context("Failed to get home directory")?; + let config_dir = home_dir.join(".config").join("awtrix-gui"); + if !config_dir.exists() { + fs::create_dir_all(&config_dir).context("Failed to create config directory")?; } + let filepath = config_dir.join(CONFIG); + let mut file = fs::File::create(filepath)?; + file.write_all(serde_json::to_string_pretty(self)?.as_bytes())?; + file.flush()?; + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 1e9d39a..1fd6127 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use anyhow::Context; +use eframe::icon_data; use egui::ViewportBuilder; mod config; +#[cfg(test)] +mod tests; mod ui; fn main() -> anyhow::Result<()> { @@ -10,8 +13,7 @@ fn main() -> anyhow::Result<()> { .with_app_id("awtrix-gui") .with_inner_size(egui::vec2(900.0, 600.0)) .with_icon( - eframe::icon_data::from_png_bytes(include_bytes!("../res/icon.png")) - .unwrap_or_default(), + icon_data::from_png_bytes(include_bytes!("../res/icon.png")).unwrap_or_default(), ); eframe::run_native( @@ -21,7 +23,7 @@ fn main() -> anyhow::Result<()> { centered: true, ..Default::default() }, - Box::new(|cc| Ok(Box::new(ui::App::new(cc)))), + Box::new(|cc| Ok(Box::new(ui::App::new(&cc.egui_ctx)))), ) .map_err(|e| anyhow::anyhow!(e.to_string())) .context("Failed to run native") diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..2325dd5 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1 @@ +mod ui_tests; diff --git a/src/tests/ui_tests.rs b/src/tests/ui_tests.rs new file mode 100644 index 0000000..546d778 --- /dev/null +++ b/src/tests/ui_tests.rs @@ -0,0 +1,59 @@ +use egui::{ThemePreference, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable}; +use wgpu::InstanceDescriptor; + +use crate::ui::App; + +pub fn app() -> Harness<'static> { + let mut app = None; + + Harness::new(move |ctx| { + let app_instance = app.get_or_insert_with(|| App::new(ctx)); + app_instance.show(ctx); + }) +} + +async fn gpu_available() -> bool { + wgpu::Instance::new(&InstanceDescriptor::default()) + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .is_ok() +} + +#[tokio::test] +pub async fn test_main_view() { + if !gpu_available().await { + return; + } + + let themes = vec![ThemePreference::Dark, ThemePreference::Light]; + + for theme in themes { + let mut harness = app(); + harness.ctx.set_theme(theme); + + harness.run(); + harness.snapshot(format!("{theme:?}/main_view")); + + let input = harness.get_by_role(Role::TextInput); + input.focus(); + input.type_text("192.168.1.1"); + harness.run(); + harness.snapshot(format!("{theme:?}/ip_entered")); + + harness + .get_by_role_and_label(Role::Button, "Settings") + .click(); + harness.run(); + harness.snapshot(format!("{theme:?}/settings_view")); + + harness.get_by_role_and_label(Role::Button, " ? ").click(); + + harness.run(); + harness.snapshot(format!("{theme:?}/about_dialog")); + } +} diff --git a/src/ui/device.rs b/src/ui/device.rs index f57a7a4..d6d91fa 100644 --- a/src/ui/device.rs +++ b/src/ui/device.rs @@ -1,55 +1,149 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, time, +}; + use anyhow::Context; -use egui::{Button, DragValue, ScrollArea, SidePanel, Ui}; -use reqwest::blocking::Client; -use semver::Version; -use serde_json::{from_str, Value}; +use egui::{ + Color32, ColorImage, DragValue, ImageData, ScrollArea, SidePanel, TextureHandle, + TextureOptions, Ui, +}; +use image::imageops; +use parking_lot::{RawRwLock, RwLock, lock_api}; -use super::status::{self, Stat}; +const SCREEN_SIZE: [usize; 2] = [320, 80]; pub struct Device { time: i32, - update_available: bool, + screen_texture: Arc>, + update_screen: bool, + auto_refresh_handle: Option<(thread::JoinHandle<()>, Arc)>, } impl Device { - pub const fn new() -> Self { + pub fn new(ctx: &egui::Context) -> Self { + let screen_texture = ctx.load_texture( + "screen", + ImageData::Color(Arc::new(ColorImage::filled( + SCREEN_SIZE, + Color32::TRANSPARENT, + ))), + TextureOptions::default(), + ); + let screen_texture = Arc::new(RwLock::new(screen_texture)); Self { time: 0, - update_available: false, + screen_texture, + update_screen: true, + auto_refresh_handle: None, } } - pub fn show(&mut self, ui: &mut Ui, ip: &str, stats: Option<&Stat>) -> anyhow::Result<()> { + pub fn show(&mut self, ui: &mut Ui, ip: &str) { SidePanel::right("panel") .show_separator_line(true) + .min_width(340.0) .show_inside(ui, |ui| { - ScrollArea::new([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.heading("Awtrix Options"); + ScrollArea::new([false, true]).show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Awtrix Options"); + }); + ui.separator(); + if ip.is_empty() { + ui.label("No IP set"); + } else { + ui.vertical_centered(|ui| { + ui.horizontal(|ui| { + // TODO: FIX THIS + Self::power(ui, ip); + // TODO: FIX THIS + Self::reboot(ui, ip); + ui.separator(); + self.sleep(ui, ip) + }); }); ui.separator(); - if ip.is_empty() { - ui.label("No IP set"); - Ok(()) - } else { - ui.vertical_centered(|ui| { - ui.horizontal(|ui| { - // TODO: FIX THIS - Self::power(ui, ip); - // TODO: FIX THIS - Self::reboot(ui, ip); - ui.separator(); - self.sleep(ui, ip) - }); - }); - self.update_device(ui, ip, stats, self.update_available) + self.show_screen(ui, ip); + } + }) + }); + } + + pub fn show_screen(&mut self, ui: &mut Ui, ip: &str) { + ui.toggle_value(&mut self.update_screen, "Auto refresh"); + + if self.update_screen { + self.start_auto_refresh(ip.to_owned()); + } + + ui.image(&self.screen_texture.read().clone()); + } + + fn start_auto_refresh(&mut self, ip: String) { + if self.auto_refresh_handle.is_none() { + println!("Starting auto refresh thread"); + let texture = + Arc::>::clone(&self.screen_texture); + let running = Arc::new(AtomicBool::new(true)); + let running_clone = Arc::::clone(&running); + // let cycle = Arc::>::clone(&self.auto_refresh_cycle); + + let handle = thread::spawn(move || { + while running_clone.load(Ordering::Relaxed) { + match Self::get_screen(&ip) { + Ok(image) => { + texture.write().set(image, TextureOptions::default()); } - }) - .inner + Err(e) => { + eprintln!("Error fetching screen: {e}"); + } + } + thread::sleep(time::Duration::from_secs(2)); + } + }); + + self.auto_refresh_handle = Some((handle, running)); + } + } + + #[expect( + clippy::cast_possible_truncation, + reason = "The image size is fixed and known to be safe" + )] + fn get_screen(ip: &str) -> anyhow::Result { + let mut response: ureq::http::Response = + match ureq::get(format!("http://{ip}/api/screen")).call() { + Ok(response) if response.status().is_success() => response, + _ => anyhow::bail!("Failed to get screen"), + }; + let pixels = response + .body_mut() + .read_to_string()? + .trim_matches(|c| c == '[' || c == ']') + .split(',') + .filter_map(|s| s.parse().ok()) + .collect::>() + .into_iter() + .flat_map(|x: u32| { + [ + ((x >> 16) & 0xFF) as u8, + ((x >> 8) & 0xFF) as u8, + (x & 0xFF) as u8, + ] }) - .inner - //Ok(()) + .collect::>(); + Ok(ColorImage::from_rgb( + SCREEN_SIZE, + &imageops::resize( + &image::RgbImage::from_vec(32, 8, pixels).context("Failed to create image")?, + SCREEN_SIZE[0] as u32, + SCREEN_SIZE[1] as u32, + imageops::FilterType::Nearest, + ), + )) } fn power(ui: &mut Ui, ip: &str) { @@ -63,10 +157,8 @@ impl Device { fn set_power(ip: &str, curr_power: bool) -> anyhow::Result<()> { let payload = format!("{{\"power\": {curr_power}}}"); - Client::new() - .post(format!("http://{ip}/api/power")) - .body(payload) - .send() + ureq::post(format!("http://{ip}/api/power")) + .send(&payload) .context("Failed to send")? .status() .is_success() @@ -90,10 +182,9 @@ impl Device { fn set_sleep(&self, ip: &str) -> anyhow::Result<()> { let payload = format!("{{\"sleep\": {}}}", self.time); - Client::new() - .post(format!("http://{ip}/api/sleep")) - .body(payload) - .send()? + ureq::post(format!("http://{ip}/api/sleep")) + .send(&payload) + .context("Failed to send")? .status() .is_success() .then_some(()) @@ -105,101 +196,12 @@ impl Device { } fn set_reboot(ip: &str) -> anyhow::Result<()> { - Client::new() - .post(format!("http://{ip}/api/reboot")) - .body("-") - .send()? - .status() - .is_success() - .then_some(()) - .context("Failed to reboot") - } - - fn update_device( - &mut self, - ui: &mut Ui, - ip: &str, - stats: Option<&Stat>, - enabled: bool, - ) -> anyhow::Result<()> { - if self.update_available { - ui.add(Button::new("Update now")) - .on_hover_text("Update device") - .clicked() - .then(|| Self::set_update(ip)) - .unwrap_or(Ok(())) - } else { - ui.horizontal(|ui| { - let ret = ui - .add_enabled(enabled, Button::new("Update")) - .clicked() - .then(|| self.check_update(ip)); - if let Some(stats) = stats { - ui.label(format!("Version: {}", stats.version)); - } - ret - }) - .inner - .unwrap_or(Ok(())) - } - } - - fn check_update(&mut self, ip: &str) -> anyhow::Result<()> { - let stats = status::get_stats(ip).context("Failed to get stats")?; - let current = Self::parse_to_version(&stats.version); - let latest = Self::parse_to_version(&Self::get_latest_tag().unwrap_or_default()); - if current < latest { - self.update_available = true; - Ok(()) - } else { - self.update_available = false; - anyhow::bail!("No update available") - } - } - - pub fn get_latest_tag() -> anyhow::Result { - let url = "https://api.github.com/repos/Blueforcer/awtrix3/releases/latest"; - - let response = match Client::new() - .get(url) - .header("User-Agent", "reqwest") - .send() - { - Ok(response) if response.status().is_success() => response, - _ => anyhow::bail!("Could not get latest tag"), - }; - let text = response.text().context("Could not get response")?; - let json = from_str::(&text).context("Could not read latest tag")?; - let text = json - .get("tag_name") - .context("")? - .as_str() - .context("")? - .to_owned() - .replace('"', ""); - - Ok(text) - } - - fn parse_to_version(version: &str) -> Version { - let parts: Vec = version.split('.').map(|x| x.parse().unwrap_or(0)).collect(); - match parts[..] { - [a, b, c] => Version::new(a, b, c), - [a, b] => Version::new(a, b, 0), - [a] => Version::new(a, 0, 0), - _ => Version::new(0, 0, 0), - } - } - - fn set_update(ip: &str) -> anyhow::Result<()> { - Client::new() - .post(format!("http://{ip}/api/doupdate")) - .body(String::new()) - .send() + ureq::post(format!("http://{ip}/api/reboot")) + .send("-") .context("Failed to send")? .status() .is_success() .then_some(()) - .context("Failed to update") + .context("Failed to reboot") } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9f53875..6784953 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,11 +1,9 @@ use anyhow::Ok; -use eframe::CreationContext; -use egui::{ - vec2, CentralPanel, Color32, ColorImage, Context, ImageData, TextStyle, TextureOptions, -}; +use egui::{CentralPanel, Context, TextStyle, vec2}; use egui_notify::Toasts; use parking_lot::RwLock; use status::Stat; +use std::f32; use std::sync::Arc; use crate::config::Config; @@ -15,7 +13,6 @@ use self::settings::Settings; use self::statusbar::StatusBar; mod device; -mod screen; mod settings; mod status; mod statusbar; @@ -28,12 +25,10 @@ pub struct App { settings: Settings, statusbar: StatusBar, pub stat: Option, - screen_texture: Arc>, } #[derive(PartialEq)] enum Tab { - Screen, Status, Settings, } @@ -41,7 +36,6 @@ enum Tab { impl Tab { const fn as_str(&self) -> &str { match self { - Self::Screen => "Screen", Self::Status => "Status", Self::Settings => "Settings", } @@ -49,31 +43,24 @@ impl Tab { } impl App { - pub fn new(cc: &CreationContext) -> Self { - egui_extras::install_image_loaders(&cc.egui_ctx); - cc.egui_ctx.style_mut(|s| { + pub fn new(ctx: &Context) -> Self { + egui_extras::install_image_loaders(ctx); + ctx.style_mut(|s| { s.text_styles.insert( TextStyle::Name("subheading".into()), TextStyle::Monospace.resolve(s), ); s.text_styles .insert(TextStyle::Body, TextStyle::Monospace.resolve(s)); - s.spacing.item_spacing = vec2(10.0, std::f32::consts::PI * 1.76643); + s.spacing.item_spacing = vec2(10.0, f32::consts::PI * 1.76643); }); - let screen_texture = cc.egui_ctx.load_texture( - "screen", - ImageData::Color(Arc::new(ColorImage::new([320, 80], Color32::TRANSPARENT))), - TextureOptions::default(), - ); - let screen_texture = Arc::new(RwLock::new(screen_texture)); let current_tab = Arc::new(RwLock::new(Tab::Status)); Self { current_tab, config: Config::new(), toasts: Toasts::new().with_anchor(egui_notify::Anchor::BottomLeft), - device: Device::new(), - screen_texture, + device: Device::new(ctx), settings: Settings::new(), statusbar: StatusBar::new(), stat: None, @@ -81,10 +68,8 @@ impl App { } } -/// Main application loop (called every frame) -impl eframe::App for App { - #[allow(clippy::significant_drop_in_scrutinee)] - fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { +impl App { + pub fn show(&mut self, ctx: &Context) { let mut current_tab = self.current_tab.write(); CentralPanel::default().show(ctx, |ui| { self.statusbar @@ -97,17 +82,15 @@ impl eframe::App for App { ui.separator(); }); - self.device - .show(ui, &self.config.ip, self.stat.as_ref()) - .unwrap_or_else(|e| { - self.toasts.error(e.to_string()); - }); + self.device.show(ui, &self.config.ip); if !self.config.ip.is_empty() { let ip = &self.config.ip; match current_tab.as_str() { "Status" => status::show(ui, ip, &mut self.stat), - "Screen" => screen::show(ui, ip, self.screen_texture.clone()), - "Settings" => self.settings.show(ui, ip), + "Settings" => { + self.settings.show(ui, ip); + Ok(()) + } _ => Ok(()), } .unwrap_or_else(|e| { @@ -118,3 +101,10 @@ impl eframe::App for App { self.toasts.show(ctx); } } + +/// Main application loop (called every frame) +impl eframe::App for App { + fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { + self.show(ctx); + } +} diff --git a/src/ui/screen.rs b/src/ui/screen.rs deleted file mode 100644 index 38e7fa0..0000000 --- a/src/ui/screen.rs +++ /dev/null @@ -1,75 +0,0 @@ -use std::sync::{mpsc, Arc}; - -use anyhow::Context; -use egui::{ColorImage, TextureHandle, TextureOptions, Ui}; -use parking_lot::RwLock; - -const SIZE: [usize; 2] = [320, 80]; - -pub fn show(ui: &mut Ui, ip: &str, texture: Arc>) -> anyhow::Result<()> { - if ui.button("Refresh").clicked() { - return threaded_screen(ip.to_owned(), texture); - } - - ui.image(&texture.read().clone()); - Ok(()) -} - -#[allow(clippy::expect_used)] -fn threaded_screen(ip: String, texture: Arc>) -> anyhow::Result<()> { - let (tx, rx) = mpsc::channel(); - - std::thread::spawn(move || { - let result = match get_screen(&ip) { - Ok(image) => { - texture.write().set(image, TextureOptions::default()); - Ok(()) - } - Err(e) => Err(e), - }; - - tx.send(result).expect("Failed to send result"); - std::thread::sleep(std::time::Duration::from_secs(1)); - }); - - // Wait for the result from the thread - rx.recv() - .unwrap_or_else(|_| Err(anyhow::anyhow!("Failed to receive result from thread"))) -} - -fn get_screen(ip: &str) -> anyhow::Result { - let client = reqwest::blocking::Client::new(); - let response = match client - .get(format!("http://{ip}/api/screen")) - .timeout(std::time::Duration::from_secs(5)) - .send() - { - Ok(response) if response.status().is_success() => response, - _ => anyhow::bail!("Failed to get screen"), - }; - let pixels = response - .text()? - .trim_matches(|c| c == '[' || c == ']') - .split(',') - .filter_map(|s| s.parse().ok()) - .collect::>() - .into_iter() - .flat_map(|x: u32| { - [ - ((x >> 16) & 0xFF) as u8, - ((x >> 8) & 0xFF) as u8, - (x & 0xFF) as u8, - ] - }) - .collect::>(); - - Ok(ColorImage::from_rgb( - SIZE, - &image::imageops::resize( - &image::RgbImage::from_vec(32, 8, pixels).context("Failed to create image")?, - SIZE[0] as u32, - SIZE[1] as u32, - image::imageops::FilterType::Nearest, - ), - )) -} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 8f80bdf..9b71a92 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1,24 +1,19 @@ use anyhow::Context; use egui::{Align, Button, Layout, ScrollArea, TextEdit, TextStyle, Ui}; -use egui_extras::syntax_highlighting; -use reqwest::blocking::{get, Client}; pub struct Settings { - language: String, code: String, } impl Settings { - pub fn new() -> Self { + pub const fn new() -> Self { Self { - language: "json".to_owned(), code: String::new(), } } - // TODO: Remove this lint suppression - #[allow(clippy::unnecessary_wraps)] - pub fn show(&mut self, ui: &mut Ui, ip: &str) -> anyhow::Result<()> { + // #[expect(clippy::unnecessary_wraps, reason = "TODO")] + pub fn show(&mut self, ui: &mut Ui, ip: &str) { ui.horizontal(|ui| { if ui.add(Button::new("Get Settings")).clicked() { match Self::get_settings(ip) { @@ -40,7 +35,7 @@ impl Settings { } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.spacing(); - if ui.add(Button::new(" i ").rounding(40.0)).clicked() + if ui.add(Button::new(" i ").corner_radius(40.0)).clicked() && open::that("https://blueforcer.github.io/awtrix3/#/api?id=change-settings") .is_err() { @@ -50,18 +45,7 @@ impl Settings { }); Ok(()) }); - let theme = syntax_highlighting::CodeTheme::from_style(ui.style()); - let mut layouter = |ui: &Ui, string: &str, wrap_width: f32| { - let mut layout_job = syntax_highlighting::highlight( - ui.ctx(), - ui.style(), - &theme, - string, - &self.language, - ); - layout_job.wrap.max_width = wrap_width; - ui.fonts(|f| f.layout_job(layout_job)) - }; + ScrollArea::vertical().show(ui, |ui| { ui.add( TextEdit::multiline(&mut self.code) @@ -69,29 +53,28 @@ impl Settings { .code_editor() .desired_rows(20) .lock_focus(true) - .desired_width(f32::MAX) - .layouter(&mut layouter), + .desired_width(f32::MAX), // .layouter(&mut layouter), ); }); - Ok(()) } fn get_settings(ip: &str) -> anyhow::Result { - let response = get(format!("http://{ip}/api/settings")) + let mut response = ureq::get(format!("http://{ip}/api/settings")) + .call() .map_err(|_e| anyhow::anyhow!("Failed to get settings"))?; - - Ok(response - .text()? + let settings = response + .body_mut() + .read_to_string()? .replace(',', ",\n") .replace('{', "{\n") - .replace('}', "\n}")) + .replace('}', "\n}"); + + Ok(settings) } pub fn set_settings(&self, ip: &str) -> anyhow::Result<()> { - Client::new() - .post(format!("http://{ip}/api/settings")) - .body(self.code.clone()) - .send()? + ureq::post(format!("http://{ip}/api/settings")) + .send(&self.code)? .status() .is_success() .then_some(()) diff --git a/src/ui/status.rs b/src/ui/status.rs index 94c5875..763cdb5 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -1,7 +1,6 @@ -use std::fmt::Display; +use std::fmt::{self, Display}; use egui::{Button, Ui}; -use reqwest::blocking::get; use serde::{Deserialize, Serialize}; use serde_json::from_str; @@ -20,11 +19,14 @@ pub fn show(ui: &mut Ui, ip: &str, stat: &mut Option) -> anyhow::Result<() } pub fn get_stats(ip: &str) -> anyhow::Result { - let response = match get(format!("http://{ip}/api/stats")) { + let mut response = match ureq::get(format!("http://{ip}/api/stats")).call() { Ok(response) if response.status().is_success() => response, _ => anyhow::bail!("Failed to get stats"), }; - Ok(from_str(&response.text()?)?) + let stats_str = response.body_mut().read_to_string()?; + let stats = from_str::(&stats_str)?; + + Ok(stats) } fn get_string(stat: Option<&Stat>) -> String { @@ -38,7 +40,10 @@ fn get_string(stat: Option<&Stat>) -> String { )) } -#[allow(clippy::struct_excessive_bools)] +#[expect( + clippy::struct_excessive_bools, + reason = "This struct is used to deserialize the JSON response from the API." +)] #[derive(Debug, Deserialize, Serialize)] pub struct Stat { bat: u32, @@ -65,7 +70,7 @@ pub struct Stat { } impl Display for Stat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "Battery: {}%", self.bat)?; writeln!(f, "Battery Raw: {}", self.bat_raw)?; writeln!(f, "Data Type: {}", self.data_type)?; diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 0c2a85c..cd893a6 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -1,7 +1,7 @@ use crate::config::Config; use egui::{ - include_image, special_emojis::GITHUB, vec2, Align, Align2, Button, Frame, Image, Layout, - TextEdit, Ui, Window, + Align, Align2, Button, Frame, Image, Layout, TextEdit, Ui, Window, include_image, + special_emojis::GITHUB, vec2, }; use super::Tab; @@ -19,23 +19,23 @@ impl StatusBar { self.about_window(ui); ui.horizontal(|ui| { ui.add_enabled_ui(!config.ip.is_empty(), |ui| { - ui.selectable_value(tab, Tab::Screen, "Screen"); + // ui.selectable_value(tab, Tab::Screen, "Screen"); ui.selectable_value(tab, Tab::Status, "Status"); ui.selectable_value(tab, Tab::Settings, "Settings"); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.add(Button::new(" ? ").rounding(40.0)) + ui.add(Button::new(" ? ").corner_radius(40.0)) .clicked() .then(|| self.show_about = true); - let ret = ui - .add(Button::new("Save")) - .clicked() - .then(|| match config.write() { + let ret = if ui.add(Button::new("Save")).clicked() { + match config.write() { Ok(()) => anyhow::Ok(()), Err(e) => anyhow::bail!(e), - }) - .unwrap_or(Ok(())); + } + } else { + Ok(()) + }; ui.add( TextEdit::singleline(&mut config.ip) .hint_text("IP") @@ -50,6 +50,11 @@ impl StatusBar { } fn about_window(&mut self, ui: &Ui) { + let version = if cfg!(test) { + "test version" + } else { + env!("CARGO_PKG_VERSION") + }; Window::new("About") .resizable(false) .collapsible(false) @@ -62,10 +67,10 @@ impl StatusBar { ui.add( Image::new(include_image!("../../res/icon.png")) .shrink_to_fit() - .rounding(10.0), + .corner_radius(10.0), ); - ui.label(format!("{}: {}", "Version", env!("CARGO_PKG_VERSION"))); + ui.label(format!("Version: {version}")); ui.hyperlink_to( format!("{GITHUB} {}", "Github"), "https://github.com/bircni/awtrix-GUI", diff --git a/tests/snapshots/Dark/about_dialog.png b/tests/snapshots/Dark/about_dialog.png new file mode 100644 index 0000000..9e6eedd Binary files /dev/null and b/tests/snapshots/Dark/about_dialog.png differ diff --git a/tests/snapshots/Dark/ip_entered.png b/tests/snapshots/Dark/ip_entered.png new file mode 100644 index 0000000..2deb7fa Binary files /dev/null and b/tests/snapshots/Dark/ip_entered.png differ diff --git a/tests/snapshots/Dark/main_view.png b/tests/snapshots/Dark/main_view.png new file mode 100644 index 0000000..b6a10a9 Binary files /dev/null and b/tests/snapshots/Dark/main_view.png differ diff --git a/tests/snapshots/Dark/settings_view.png b/tests/snapshots/Dark/settings_view.png new file mode 100644 index 0000000..8a8f6da Binary files /dev/null and b/tests/snapshots/Dark/settings_view.png differ diff --git a/tests/snapshots/Light/about_dialog.png b/tests/snapshots/Light/about_dialog.png new file mode 100644 index 0000000..5f6ac51 Binary files /dev/null and b/tests/snapshots/Light/about_dialog.png differ diff --git a/tests/snapshots/Light/ip_entered.png b/tests/snapshots/Light/ip_entered.png new file mode 100644 index 0000000..6b51f91 Binary files /dev/null and b/tests/snapshots/Light/ip_entered.png differ diff --git a/tests/snapshots/Light/main_view.png b/tests/snapshots/Light/main_view.png new file mode 100644 index 0000000..10082b6 Binary files /dev/null and b/tests/snapshots/Light/main_view.png differ diff --git a/tests/snapshots/Light/settings_view.png b/tests/snapshots/Light/settings_view.png new file mode 100644 index 0000000..d5ea4c4 Binary files /dev/null and b/tests/snapshots/Light/settings_view.png differ