From c70e8da1f2c5822db2c83675c57753368fd4f64d Mon Sep 17 00:00:00 2001 From: Adrian G L Date: Tue, 17 Mar 2026 11:20:32 +0100 Subject: [PATCH] ui cleanup --- Cargo.lock | 49 +++++++++++++++ Cargo.toml | 2 + src/eventloop.rs | 110 +++++++++++++++++---------------- src/main.rs | 151 ++++++++++------------------------------------ src/settings.rs | 91 ++++++++++++++++++++++++---- src/ui.rs | 154 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 183 deletions(-) create mode 100644 src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 87f3652..2364f21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,7 @@ dependencies = [ "anyhow", "config", "ctrlc", + "directories", "embed-resource", "futures", "grim-rs", @@ -44,6 +45,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml 0.8.23", "tray-item", ] @@ -583,6 +585,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -1456,6 +1479,15 @@ dependencies = [ "cc", ] +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1771,6 +1803,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2136,6 +2174,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "reqwest" version = "0.13.2" diff --git a/Cargo.toml b/Cargo.toml index c88f269..f1ed876 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ build = "build.rs" anyhow = "1.0" config = "0.15.21" ctrlc = "3.5.2" +directories = "6.0" futures = "0.3" oklab = "1.1.2" png = "0.17" @@ -16,6 +17,7 @@ rgb = "0.8" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" tokio = { version = "1.50.0", features = ["full"] } +toml = "0.8" [build-dependencies] embed-resource = "2.3" diff --git a/src/eventloop.rs b/src/eventloop.rs index 01074fc..fefa1f0 100644 --- a/src/eventloop.rs +++ b/src/eventloop.rs @@ -4,7 +4,7 @@ use crate::settings::Settings; use crate::state::AppState; use rgb::RGB; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use tokio::sync::mpsc; @@ -12,7 +12,7 @@ use tokio::sync::mpsc; pub fn run_loop( settings: Arc, ha_client: Arc, - target_lights: Arc>, + target_lights: Arc>>>, state: Arc, is_running: Arc, mut exit_rx: mpsc::Receiver<()>, @@ -109,65 +109,69 @@ pub fn run_loop( } if is_running_smoothing.load(Ordering::Relaxed) { - if start.duration_since(last_calc_time) >= calc_interval { - let active_screenshot = { - let guard = state_smoothing.active_screenshot.read().unwrap(); - guard.clone() - }; + let lights_opt = target_lights_smoothing.lock().unwrap().clone(); + + if let Some(lights) = lights_opt { + if start.duration_since(last_calc_time) >= calc_interval { + let active_screenshot = { + let guard = state_smoothing.active_screenshot.read().unwrap(); + guard.clone() + }; - if let Some(screenshot) = active_screenshot { - let color = screenshot.average_color( - settings.brightness_boost, - settings.saturation_boost, - ); - let new_target = RGB::new(color.r as f32, color.g as f32, color.b as f32); + if let Some(screenshot) = active_screenshot { + let color = screenshot.average_color( + settings.brightness_boost, + settings.saturation_boost, + ); + let new_target = RGB::new(color.r as f32, color.g as f32, color.b as f32); - { - let mut target_guard = state_smoothing.target_color.write().unwrap(); - *target_guard = Some(new_target); + { + let mut target_guard = state_smoothing.target_color.write().unwrap(); + *target_guard = Some(new_target); + } + } + last_calc_time = start; + } + + { + let target_color = { + let guard = state_smoothing.target_color.read().unwrap(); + *guard + }; + + if let Some(target) = target_color { + let mut active_guard = state_smoothing.active_color.write().unwrap(); + active_guard.r = active_guard.r * smoothing + target.r * (1.0 - smoothing); + active_guard.g = active_guard.g * smoothing + target.g * (1.0 - smoothing); + active_guard.b = active_guard.b * smoothing + target.b * (1.0 - smoothing); } } - last_calc_time = start; - } - { - let target_color = { - let guard = state_smoothing.target_color.read().unwrap(); - *guard + let (r, g, b) = { + let guard = state_smoothing.active_color.read().unwrap(); + (guard.r.round() as u8, guard.g.round() as u8, guard.b.round() as u8) }; - if let Some(target) = target_color { - let mut active_guard = state_smoothing.active_color.write().unwrap(); - active_guard.r = active_guard.r * smoothing + target.r * (1.0 - smoothing); - active_guard.g = active_guard.g * smoothing + target.g * (1.0 - smoothing); - active_guard.b = active_guard.b * smoothing + target.b * (1.0 - smoothing); - } - } + let should_update = match last_sent_color { + None => true, + Some(last) => { + let dr = (r as f32 - last.r as f32).abs(); + let dg = (g as f32 - last.g as f32).abs(); + let db = (b as f32 - last.b as f32).abs(); + dr > color_threshold || dg > color_threshold || db > color_threshold + } + }; - let (r, g, b) = { - let guard = state_smoothing.active_color.read().unwrap(); - (guard.r.round() as u8, guard.g.round() as u8, guard.b.round() as u8) - }; - - let should_update = match last_sent_color { - None => true, - Some(last) => { - let dr = (r as f32 - last.r as f32).abs(); - let dg = (g as f32 - last.g as f32).abs(); - let db = (b as f32 - last.b as f32).abs(); - dr > color_threshold || dg > color_threshold || db > color_threshold - } - }; - - if should_update && start.duration_since(last_update_time) >= min_update_interval_clone { - if let Err(e) = ha_client_smoothing - .set_lights_color_parallel(&target_lights_smoothing, r, g, b) - .await - { - eprintln!("Failed to update lights: {}", e); - } else { - last_sent_color = Some(RGB::new(r, g, b)); - last_update_time = start; + if should_update && start.duration_since(last_update_time) >= min_update_interval_clone { + if let Err(e) = ha_client_smoothing + .set_lights_color_parallel(&lights, r, g, b) + .await + { + eprintln!("Failed to update lights: {}", e); + } else { + last_sent_color = Some(RGB::new(r, g, b)); + last_update_time = start; + } } } } diff --git a/src/main.rs b/src/main.rs index d6a81e2..a1b09ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,13 @@ mod eventloop; mod homeassistant; -mod screenshot; mod settings; +mod screenshot; mod state; +mod ui; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use tray_item::{IconSource, TrayItem}; - -#[cfg(target_os = "windows")] -fn get_tray_icon() -> IconSource { - IconSource::Resource("app-icon") -} - -#[cfg(not(target_os = "windows"))] -fn get_tray_icon() -> IconSource { - let icon_data = include_bytes!("../icon.png"); - let cursor = Cursor::new(&icon_data[..]); - let mut decoder = png::Decoder::new(cursor); - decoder.set_transformations(png::Transformations::normalize_to_color8()); - let mut reader = decoder.read_info().expect("Failed to read icon PNG"); - let mut buf: Vec = vec![0; reader.output_buffer_size()]; - let info = reader.next_frame(&mut buf).expect("Failed to decode icon PNG"); - - IconSource::Data { - data: buf, - width: info.width as i32, - height: info.height as i32, - } -} +use std::sync::mpsc::channel; +use std::sync::{Arc, Mutex}; fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); @@ -40,110 +18,43 @@ fn main() { std::process::exit(1); }); - let ha_client = homeassistant::HaClient::new(&settings.ha_url, &settings.ha_token); - - let target_lights = if !settings.lights.is_empty() { - settings.lights.clone() - } else { - println!("Fetching color-supported lights from Home Assistant..."); - match rt.block_on(ha_client.get_color_lights()) { - Ok(lights) => lights.into_iter().map(|l| l.entity_id).collect(), - Err(e) => { - eprintln!("Failed to fetch color lights: {}", e); - std::process::exit(1); - } - } - }; - - if target_lights.is_empty() { - println!("No target lights found or configured. Exiting."); - return; - } - - if settings.restore_on_exit { - println!("Creating scene snapshot for restore on exit..."); - if let Err(e) = - rt.block_on(ha_client.create_scene_snapshot("ambilight_restore", &target_lights)) - { - eprintln!("Failed to create scene snapshot: {}", e); - } - } - - let is_running = Arc::new(AtomicBool::new(true)); - let (exit_tx, exit_rx) = tokio::sync::mpsc::channel(1); - + let ha_client = Arc::new(homeassistant::HaClient::new(&settings.ha_url, &settings.ha_token)); let settings_arc = Arc::new(settings); - let ha_client_arc = Arc::new(ha_client); - let target_lights_arc = Arc::new(target_lights); + + let is_running = Arc::new(AtomicBool::new(false)); + let (async_exit_tx, async_exit_rx) = tokio::sync::mpsc::channel(1); + let (sync_exit_tx, sync_exit_rx) = channel(); + + let target_lights: Arc>>> = Arc::new(Mutex::new(None)); let state_arc = Arc::new(state::AppState::new()); let is_running_clone = is_running.clone(); let settings_arc_clone = settings_arc.clone(); - let ha_client_arc_clone = ha_client_arc.clone(); - let target_lights_arc_clone = target_lights_arc.clone(); + let ha_client_arc_clone = ha_client.clone(); + let target_lights_clone = target_lights.clone(); let app_thread = std::thread::spawn(move || { eventloop::run_loop( settings_arc_clone, ha_client_arc_clone, - target_lights_arc_clone, + target_lights_clone, state_arc, is_running_clone, - exit_rx, + async_exit_rx, ); }); - let icon_source = get_tray_icon(); + let tray_ctx = ui::TrayContext { + is_running: is_running.clone(), + target_lights: target_lights.clone(), + settings: settings_arc.clone(), + ha_client: ha_client.clone(), + rt_handle: rt_handle.clone(), + exit_tx: sync_exit_tx.clone(), + }; - let mut tray = TrayItem::new("Ambiligth", icon_source) - .expect("Failed to create tray icon"); + let _tray = ui::create_tray(tray_ctx); - let is_running_toggle = is_running.clone(); - let restore_on_toggle = settings_arc.restore_on_exit; - let ha_client_toggle = ha_client_arc.clone(); - let target_lights_toggle = target_lights_arc.clone(); - let rt_handle_toggle = rt_handle.clone(); - - tray.add_menu_item("Toggle Ambilight", move || { - let current = is_running_toggle.load(Ordering::Relaxed); - is_running_toggle.store(!current, Ordering::Relaxed); - - if current { - println!("Ambilight disabled."); - if restore_on_toggle { - println!("Restoring lights to original state..."); - if let Err(e) = rt_handle_toggle - .block_on(ha_client_toggle.turn_on_scene("scene.ambilight_restore")) - { - eprintln!("Failed to restore scene: {}", e); - } else { - println!("Lights restored successfully."); - } - } - } else { - println!("Ambilight enabled."); - if restore_on_toggle { - println!("Creating scene snapshot for restore on exit..."); - if let Err(e) = rt_handle_toggle.block_on( - ha_client_toggle - .create_scene_snapshot("ambilight_restore", &target_lights_toggle), - ) { - eprintln!("Failed to create scene snapshot: {}", e); - } - } - } - }) - .unwrap(); - - let (sync_exit_tx, sync_exit_rx) = std::sync::mpsc::channel(); - let sync_exit_tx_clone = sync_exit_tx.clone(); - - tray.add_menu_item("Quit", move || { - let _ = sync_exit_tx_clone.send(()); - }) - .unwrap(); - - // Ctrl+C handling to cleanly exit ctrlc::set_handler(move || { let _ = sync_exit_tx.send(()); }) @@ -152,14 +63,16 @@ fn main() { let _ = sync_exit_rx.recv(); println!("Shutting down..."); - let _ = exit_tx.try_send(()); + let _ = async_exit_tx.try_send(()); if settings_arc.restore_on_exit { - println!("Restoring lights to original state..."); - if let Err(e) = rt.block_on(ha_client_arc.turn_on_scene("scene.ambilight_restore")) { - eprintln!("Failed to restore scene: {}", e); - } else { - println!("Lights restored successfully."); + if let Some(ref lights) = *target_lights.lock().unwrap() { + println!("Restoring lights to original state..."); + if let Err(e) = rt.block_on(ha_client.turn_on_scene("scene.ambilight_restore")) { + eprintln!("Failed to restore scene: {}", e); + } else { + println!("Lights restored successfully."); + } } } diff --git a/src/settings.rs b/src/settings.rs index cc66ceb..814ea43 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,7 +1,11 @@ use config::{Config, ConfigError, Environment, File}; -use serde::Deserialize; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::PathBuf; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Settings { #[serde(default = "default_ha_url")] pub ha_url: String, @@ -70,19 +74,84 @@ fn default_ha_token() -> String { "YOUR_TOKEN".to_string() } +fn get_project_dirs() -> Option { + ProjectDirs::from("com", "ambiligth", "ambiligth") +} + +pub fn get_config_dir() -> PathBuf { + get_project_dirs() + .map(|dirs| dirs.config_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +pub fn get_config_file_path() -> PathBuf { + get_config_dir().join("config.toml") +} + impl Settings { pub fn new() -> Result { - let builder = Config::builder() - // Load settings from a configuration file named "config" (e.g. config.toml, config.json) - // It's optional, so the program won't crash if the file doesn't exist - .add_source(File::with_name("config").required(false)) - // Merge settings from environment variables - // Variables like HA_URL and HA_TOKEN will automatically map to the struct fields - .add_source(Environment::default()); + let config_dir = get_config_dir(); + let config_file = get_config_file_path(); + + let mut builder = Config::builder() + .set_default("ha_url", default_ha_url())? + .set_default("ha_token", default_ha_token())? + .set_default("target_fps", default_target_fps() as i64)? + .set_default("screenshot_fps", default_screenshot_fps() as i64)? + .set_default("smoothing", default_smoothing() as f64)? + .set_default("restore_on_exit", default_restore_on_exit())? + .set_default("min_diff_percent", default_min_diff_percent() as f64)? + .set_default("brightness_boost", default_brightness_boost() as f64)? + .set_default("saturation_boost", default_saturation_boost() as f64)?; + + if config_file.exists() { + builder = builder.add_source(File::from(config_file)); + } else { + builder = builder.add_source(File::with_name("config").required(false)); + } + + builder = builder.add_source(Environment::default()); let config = builder.build()?; - - // Deserialize the gathered configuration into the Settings struct config.try_deserialize() } + + pub fn save(&self) -> io::Result<()> { + let config_dir = get_config_dir(); + let config_file = get_config_file_path(); + + if !config_dir.exists() { + fs::create_dir_all(&config_dir)?; + } + + let toml_string = toml::to_string_pretty(self) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + fs::write(&config_file, toml_string)?; + Ok(()) + } + + pub fn save_default_if_not_exists() -> io::Result<()> { + let config_file = get_config_file_path(); + if !config_file.exists() { + let default_settings = Settings::default_values(); + default_settings.save()?; + } + Ok(()) + } + + fn default_values() -> Self { + Settings { + ha_url: default_ha_url(), + ha_token: default_ha_token(), + target_fps: default_target_fps(), + screenshot_fps: default_screenshot_fps(), + smoothing: default_smoothing(), + lights: Vec::new(), + restore_on_exit: default_restore_on_exit(), + min_diff_percent: default_min_diff_percent(), + brightness_boost: default_brightness_boost(), + saturation_boost: default_saturation_boost(), + } + } } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..2f05bbd --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,154 @@ +use crate::homeassistant; +use crate::settings; +use crate::state; +use std::io::Cursor; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{Arc, Mutex}; +use tokio::runtime::Handle; +use tray_item::{IconSource, TrayItem}; + +#[cfg(target_os = "windows")] +fn get_tray_icon() -> IconSource { + IconSource::Resource("app-icon") +} + +#[cfg(not(target_os = "windows"))] +fn get_tray_icon() -> IconSource { + let icon_data = include_bytes!("../icon.png"); + let cursor = Cursor::new(&icon_data[..]); + let mut decoder = png::Decoder::new(cursor); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().expect("Failed to read icon PNG"); + let mut buf: Vec = vec![0; reader.output_buffer_size()]; + let info = reader + .next_frame(&mut buf) + .expect("Failed to decode icon PNG"); + + IconSource::Data { + data: buf, + width: info.width as i32, + height: info.height as i32, + } +} + +#[cfg(target_os = "windows")] +fn open_config_file() { + let config_path = settings::get_config_file_path(); + std::process::Command::new("notepad") + .arg(&config_path) + .spawn() + .ok(); +} + +#[cfg(not(target_os = "windows"))] +fn open_config_file() { + let config_path = settings::get_config_file_path(); + std::process::Command::new("xdg-open") + .arg(&config_path) + .spawn() + .ok(); +} + +pub struct TrayContext { + pub is_running: Arc, + pub target_lights: Arc>>>, + pub settings: Arc, + pub ha_client: Arc, + pub rt_handle: Handle, + pub exit_tx: Sender<()>, +} + +pub fn create_tray(ctx: TrayContext) -> TrayItem { + let mut tray = TrayItem::new("Ambiligth", get_tray_icon()).expect("Failed to create tray icon"); + + let is_running_toggle = ctx.is_running.clone(); + let restore_on_toggle = ctx.settings.restore_on_exit; + let ha_client_toggle = ctx.ha_client.clone(); + let target_lights_toggle = ctx.target_lights.clone(); + let rt_handle_toggle = ctx.rt_handle.clone(); + let settings_toggle = ctx.settings.clone(); + + tray.add_menu_item("Toggle Ambilight", move || { + let current = is_running_toggle.load(Ordering::Relaxed); + is_running_toggle.store(!current, Ordering::Relaxed); + + if current { + println!("Ambilight disabled."); + if restore_on_toggle { + println!("Restoring lights to original state..."); + if let Err(e) = rt_handle_toggle + .block_on(ha_client_toggle.turn_on_scene("scene.ambilight_restore")) + { + eprintln!("Failed to restore scene: {}", e); + } else { + println!("Lights restored successfully."); + } + } + } else { + println!("Ambilight enabled."); + + let needs_fetch_lights = { + let lights_guard = target_lights_toggle.lock().unwrap(); + lights_guard.is_none() + }; + + if needs_fetch_lights { + let lights = if !settings_toggle.lights.is_empty() { + settings_toggle.lights.clone() + } else { + println!("Fetching color-supported lights from Home Assistant..."); + match rt_handle_toggle.block_on(ha_client_toggle.get_color_lights()) { + Ok(l) => l.into_iter().map(|l| l.entity_id).collect(), + Err(e) => { + eprintln!("Failed to fetch color lights: {}", e); + is_running_toggle.store(false, Ordering::Relaxed); + return; + } + } + }; + + if lights.is_empty() { + eprintln!("No target lights found or configured."); + is_running_toggle.store(false, Ordering::Relaxed); + return; + } + + { + let mut lights_guard = target_lights_toggle.lock().unwrap(); + *lights_guard = Some(lights.clone()); + } + + if restore_on_toggle { + println!("Creating scene snapshot for restore on exit..."); + if let Err(e) = rt_handle_toggle.block_on( + ha_client_toggle.create_scene_snapshot("ambilight_restore", &lights), + ) { + eprintln!("Failed to create scene snapshot: {}", e); + } + } + } + } + }) + .unwrap(); + + tray.add_menu_item("Open Config File", move || { + let config_path = settings::get_config_file_path(); + if !config_path.exists() { + if let Err(e) = settings::Settings::save_default_if_not_exists() { + eprintln!("Failed to create default config: {}", e); + return; + } + } + open_config_file(); + }) + .unwrap(); + + let exit_tx_clone = ctx.exit_tx.clone(); + tray.add_menu_item("Quit", move || { + let _ = exit_tx_clone.send(()); + }) + .unwrap(); + + tray +}