ui cleanup

This commit is contained in:
2026-03-17 11:20:32 +01:00
parent 090741525a
commit c70e8da1f2
6 changed files with 374 additions and 183 deletions

49
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<Settings>,
ha_client: Arc<HaClient>,
target_lights: Arc<Vec<String>>,
target_lights: Arc<Mutex<Option<Vec<String>>>>,
state: Arc<AppState>,
is_running: Arc<AtomicBool>,
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;
}
}
}
}

View File

@@ -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<u8> = 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<Mutex<Option<Vec<String>>>> = 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.");
}
}
}

View File

@@ -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> {
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<Self, ConfigError> {
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(),
}
}
}

154
src/ui.rs Normal file
View File

@@ -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<u8> = 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<AtomicBool>,
pub target_lights: Arc<Mutex<Option<Vec<String>>>>,
pub settings: Arc<settings::Settings>,
pub ha_client: Arc<homeassistant::HaClient>,
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
}