diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..33c24e4 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,389 @@ +use std::{ + collections::{HashMap, HashSet}, + ops::RangeInclusive, + sync::{mpsc, MutexGuard}, + thread::JoinHandle, + time::{Duration, Instant}, +}; + +use crate::{ + capitalize_first_letter, + color::{default_hsbk, kelvin_to_rgb, DeltaColor, HSBK32}, + color_slider, + device_info::DeviceInfo, + display_color_circle, + products::TemperatureRange, + screencap::FollowType, + toggle_button, + ui::{handle_eyedropper, handle_screencap}, + BulbInfo, Manager, ScreencapManager, +}; + +use eframe::egui::{self, Color32, Modifiers, RichText, Ui, Vec2}; +use lifx_core::HSBK; +use serde::{Deserialize, Serialize}; + +// UI and window size constants +pub const MAIN_WINDOW_SIZE: [f32; 2] = [320.0, 800.0]; +pub const ABOUT_WINDOW_SIZE: [f32; 2] = [320.0, 480.0]; +pub const MIN_WINDOW_SIZE: [f32; 2] = [300.0, 220.0]; + +// Color and refresh constants +pub const LIFX_RANGE: RangeInclusive = 0..=u16::MAX; +pub const KELVIN_RANGE: TemperatureRange = TemperatureRange { + min: 2500, + max: 9000, +}; +pub const REFRESH_RATE: Duration = Duration::from_secs(10); +pub const FOLLOW_RATE: Duration = Duration::from_millis(500); + +// Icon data +pub const ICON: &[u8; 1751] = include_bytes!("../res/logo32.png"); +pub const EYEDROPPER_ICON: &[u8; 238] = include_bytes!("../res/icons/color-picker.png"); +pub const MONITOR_ICON: &[u8; 204] = include_bytes!("../res/icons/device-desktop.png"); + +#[derive(Debug, Clone)] +pub struct RunningWaveform { + pub active: bool, + pub last_update: Instant, + pub follow_type: FollowType, + pub stop_tx: Option>, +} +pub type ColorChannel = HashMap< + u64, + ( + mpsc::Sender, + mpsc::Receiver, + Option>, + ), +>; + +#[derive(Deserialize, Serialize)] +#[serde(default)] +pub struct MantleApp { + #[serde(skip)] + pub mgr: Manager, + #[serde(skip)] + pub screen_manager: ScreencapManager, + pub show_about: bool, + pub show_eyedropper: bool, + #[serde(skip)] + pub waveform_map: HashMap, + #[serde(skip)] + pub waveform_trx: ColorChannel, +} + +impl Default for MantleApp { + fn default() -> Self { + let mgr = Manager::new().expect("Failed to create manager"); + let screen_manager = ScreencapManager::new().expect("Failed to create screen manager"); + Self { + mgr, + screen_manager, + show_about: false, + show_eyedropper: false, + waveform_map: HashMap::new(), + waveform_trx: HashMap::new(), + } + } +} + +impl MantleApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + if let Some(storage) = cc.storage { + return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); + } + Default::default() + } + + fn sort_bulbs<'a>(&self, mut bulbs: Vec<&'a BulbInfo>) -> Vec<&'a BulbInfo> { + bulbs.sort_by(|a, b| { + let group_a = a.group_label(); + let group_b = b.group_label(); + let name_a = a.name_label(); + let name_b = b.name_label(); + group_a.cmp(&group_b).then(name_a.cmp(&name_b)) + }); + bulbs + } + + fn display_device( + &mut self, + ui: &mut Ui, + device: &DeviceInfo, + bulbs: &MutexGuard>, + ) { + let color = match device { + DeviceInfo::Bulb(bulb) => { + if let Some(s) = bulb.name.data.as_ref().and_then(|s| s.to_str().ok()) { + ui.label(RichText::new(s).size(14.0)); + } + bulb.get_color().cloned() + } + DeviceInfo::Group(group) => { + if let Ok(s) = group.label.cstr().to_str() { + if *group == self.mgr.all { + ui.label(RichText::new(s).size(16.0).strong().underline()); + } else { + ui.label(RichText::new(s).size(16.0).strong()); + } + } + Some(self.mgr.avg_group_color(group, bulbs)) + } + }; + + ui.horizontal(|ui| { + display_color_circle( + ui, + device, + color.unwrap_or(default_hsbk()), + Vec2::new(1.0, 1.0), + 8.0, + bulbs, + ); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Power"); + toggle_button(ui, &self.mgr, device, Vec2::new(1.0, 1.0), bulbs); + }); + if let Some(before_color) = color { + let mut after_color = + self.display_color_controls(ui, device, color.unwrap_or(default_hsbk())); + ui.horizontal(|ui| { + after_color = handle_eyedropper(self, ui).unwrap_or(after_color); + after_color = handle_screencap(self, ui, device).unwrap_or(after_color); + }); + if before_color != after_color.next { + match device { + DeviceInfo::Bulb(bulb) => { + if let Err(e) = + self.mgr + .set_color(bulb, after_color.next, after_color.duration) + { + log::error!("Error setting color: {}", e); + } + } + DeviceInfo::Group(group) => { + if let Err(e) = self.mgr.set_group_color( + group, + after_color.next, + bulbs, + after_color.duration, + ) { + log::error!("Error setting group color: {}", e); + } + } + } + } + } else { + ui.label(format!("No color data: {:?}", color)); + } + }); + }); + ui.separator(); + } + + fn display_color_controls(&self, ui: &mut Ui, device: &DeviceInfo, color: HSBK) -> DeltaColor { + let HSBK { + mut hue, + mut saturation, + mut brightness, + mut kelvin, + } = color; + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Hue"); + color_slider(ui, &mut hue, LIFX_RANGE, "Hue", |v| { + HSBK32 { + hue: v as u32, + saturation: u32::MAX, + brightness: u32::MAX, + kelvin: 0, + } + .into() + }); + }); + ui.horizontal(|ui| { + ui.label("Saturation"); + color_slider(ui, &mut saturation, LIFX_RANGE, "Saturation", |v| { + let color_value = (u16::MAX - v).max(0) / u8::MAX as u16; + Color32::from_gray(color_value as u8) + }); + }); + ui.horizontal(|ui| { + ui.label("Brightness"); + color_slider(ui, &mut brightness, LIFX_RANGE, "Brightness", |v| { + let color_value = v.min(u16::MAX) / u8::MAX as u16; + Color32::from_gray(color_value as u8) + }); + }); + ui.horizontal(|ui| { + ui.label("Kelvin"); + match device { + DeviceInfo::Bulb(bulb) => { + if let Some(range) = bulb.features.temperature_range.as_ref() { + if range.min != range.max { + color_slider( + ui, + &mut kelvin, + RangeInclusive::new(range.min as u16, range.max as u16), + "Kelvin", + |v| { + let temp = (((v as f32 / u16::MAX as f32) + * (range.max - range.min) as f32) + + range.min as f32) + as u16; + kelvin_to_rgb(temp).into() + }, + ); + } else { + ui.label(format!("{}K", range.min)); + } + } + } + DeviceInfo::Group(_) => { + color_slider( + ui, + &mut kelvin, + RangeInclusive::new(KELVIN_RANGE.min as u16, KELVIN_RANGE.max as u16), + "Kelvin", + |v| { + let temp = (((v as f32 / u16::MAX as f32) + * (KELVIN_RANGE.max - KELVIN_RANGE.min) as f32) + + KELVIN_RANGE.min as f32) + as u16; + kelvin_to_rgb(temp).into() + }, + ); + } + } + }); + }); + DeltaColor { + next: HSBK { + hue, + saturation, + brightness, + kelvin, + }, + duration: None, + } + } + + fn file_menu_button(&self, ui: &mut Ui) { + let close_shortcut = egui::KeyboardShortcut::new(Modifiers::CTRL, egui::Key::Q); + let refresh_shortcut = egui::KeyboardShortcut::new(Modifiers::NONE, egui::Key::F5); + if ui.input_mut(|i| i.consume_shortcut(&close_shortcut)) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + if ui.input_mut(|i| i.consume_shortcut(&refresh_shortcut)) { + self.mgr.refresh(); + } + + ui.menu_button("File", |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + if ui + .add( + egui::Button::new("Refresh") + .shortcut_text(ui.ctx().format_shortcut(&refresh_shortcut)), + ) + .clicked() + { + self.mgr.refresh(); + ui.close_menu(); + } + if ui + .add( + egui::Button::new("Quit") + .shortcut_text(ui.ctx().format_shortcut(&close_shortcut)), + ) + .clicked() + { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + ui.close_menu(); + } + }); + } + + fn help_menu_button(&mut self, ui: &mut Ui) { + ui.menu_button("Help", |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + if ui.add(egui::Button::new("About")).clicked() { + self.show_about = true; + ui.close_menu(); + } + }); + } + + fn update_ui(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + self.file_menu_button(ui); + self.help_menu_button(ui); + }); + }); + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + let bulbs = self.mgr.bulbs.clone(); + let bulbs = bulbs.lock(); + let mut seen_groups = HashSet::::new(); + ui.vertical(|ui| { + if let Ok(bulbs) = bulbs { + self.display_device(ui, &DeviceInfo::Group(self.mgr.all.clone()), &bulbs); + let sorted_bulbs = self.sort_bulbs(bulbs.values().collect()); + for bulb in sorted_bulbs { + if let Some(group) = bulb.group.data.as_ref() { + let group_name = group.label.cstr().to_str().unwrap_or_default(); + if !seen_groups.contains(group_name) { + seen_groups.insert(group_name.to_owned()); + self.display_device( + ui, + &DeviceInfo::Group(group.clone()), + &bulbs, + ); + } + } + self.display_device(ui, &DeviceInfo::Bulb(bulb), &bulbs); + } + } + }); + }); + }); + } + + fn show_about_window(&mut self, ctx: &egui::Context) { + if self.show_about { + egui::Window::new("About") + .default_width(ABOUT_WINDOW_SIZE[0]) + .default_height(ABOUT_WINDOW_SIZE[1]) + .open(&mut self.show_about) + .resizable([true, false]) + .show(ctx, |ui| { + ui.heading(capitalize_first_letter(env!("CARGO_PKG_NAME"))); + ui.add_space(8.0); + ui.label(env!("CARGO_PKG_DESCRIPTION")); + ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); + ui.label(format!("Author: {}", env!("CARGO_PKG_AUTHORS"))); + ui.hyperlink_to("Github", env!("CARGO_PKG_REPOSITORY")); + }); + } + } +} + +impl eframe::App for MantleApp { + fn save(&mut self, storage: &mut dyn eframe::Storage) { + eframe::set_value(storage, eframe::APP_KEY, self); + } + + fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(debug_assertions)] + puffin::GlobalProfiler::lock().new_frame(); + if Instant::now() - self.mgr.last_discovery > REFRESH_RATE { + self.mgr.discover().expect("Failed to discover bulbs"); + } + self.mgr.refresh(); + self.update_ui(_ctx); + self.show_about_window(_ctx); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7d22708..023e991 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,18 @@ +pub mod app; pub mod color; pub mod device_info; -pub mod helpers; pub mod manager; pub mod products; pub mod refreshable_data; pub mod screencap; -pub mod widgets; +pub mod ui; +pub mod utils; pub use color::{contrast_color, HSBK32, RGB8}; pub use device_info::{BulbInfo, DeviceColor}; -pub use helpers::{capitalize_first_letter, AngleIter}; pub use manager::Manager; pub use products::{get_products, Product}; pub use refreshable_data::RefreshableData; pub use screencap::ScreencapManager; -pub use widgets::{color_slider, display_color_circle, toggle_button}; +pub use ui::{color_slider, display_color_circle, toggle_button}; +pub use utils::{capitalize_first_letter, AngleIter}; diff --git a/src/main.rs b/src/main.rs index 7fbf92d..1b60c2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,59 +2,14 @@ all(target_os = "windows", not(debug_assertions),), windows_subsystem = "windows" )] -// Hide console window on Release -use device_query::{DeviceQuery, DeviceState}; -use eframe::egui::{self, Color32, Modifiers, RichText, Ui, Vec2}; -use image::GenericImageView; -use lifx_core::HSBK; -use log::LevelFilter; -use log4rs::config::{Appender, Config, Root}; -use log4rs::encode::pattern::PatternEncoder; -use log4rs::{ - append::{console::ConsoleAppender, file::FileAppender}, - filter::threshold::ThresholdFilter, -}; -use mantle::color::{kelvin_to_rgb, DeltaColor, HSBK32}; -use mantle::products::TemperatureRange; -use mantle::screencap::FollowType; -use mantle::{color_slider, ScreencapManager}; -use serde::{Deserialize, Serialize}; -use std::ops::RangeInclusive; -use std::sync::mpsc; -use std::thread::{self, JoinHandle}; -use std::{ - collections::{HashMap, HashSet}, - sync::MutexGuard, - time::{Duration, Instant}, -}; -use mantle::{ - capitalize_first_letter, color::default_hsbk, device_info::DeviceInfo, display_color_circle, - toggle_button, BulbInfo, Manager, -}; - -// UI and window size constants -const MAIN_WINDOW_SIZE: [f32; 2] = [320.0, 800.0]; -const ABOUT_WINDOW_SIZE: [f32; 2] = [320.0, 480.0]; -const MIN_WINDOW_SIZE: [f32; 2] = [300.0, 220.0]; - -// Color and refresh constants -const LIFX_RANGE: RangeInclusive = 0..=u16::MAX; -const KELVIN_RANGE: TemperatureRange = TemperatureRange { - min: 2500, - max: 9000, -}; -const REFRESH_RATE: Duration = Duration::from_secs(10); -const FOLLOW_RATE: Duration = Duration::from_millis(500); - -// Icon data -const ICON: &[u8; 1751] = include_bytes!("../res/logo32.png"); -const EYEDROPPER_ICON: &[u8; 238] = include_bytes!("../res/icons/color-picker.png"); -const MONITOR_ICON: &[u8; 204] = include_bytes!("../res/icons/device-desktop.png"); +use mantle::app::MantleApp; +use mantle::ui::setup_eframe_options; +use mantle::utils::init_logging; fn main() -> eframe::Result { #[cfg(debug_assertions)] - start_puffin_server(); + start_puffin_server(); // Optional, keep if you're using Puffin for profiling init_logging(); @@ -70,561 +25,6 @@ fn main() -> eframe::Result { ) } -fn setup_eframe_options() -> eframe::NativeOptions { - let icon = load_icon(ICON); - - eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size(MAIN_WINDOW_SIZE) - .with_min_inner_size(MIN_WINDOW_SIZE) - .with_icon(icon), - ..Default::default() - } -} - -fn load_icon(icon: &[u8]) -> egui::IconData { - let icon = image::load_from_memory(icon).expect("Failed to load icon"); - egui::IconData { - rgba: icon.to_rgba8().into_raw(), - width: icon.width(), - height: icon.height(), - } -} - -fn init_logging() { - let logfile = FileAppender::builder() - .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) - .build("log/output.log") - .expect("Failed to create log file appender"); - - let console = ConsoleAppender::builder().build(); - - let config = Config::builder() - .appender( - Appender::builder() - .filter(Box::new(ThresholdFilter::new(LevelFilter::Info))) - .build("logfile", Box::new(logfile)), - ) - .appender( - Appender::builder() - .filter(Box::new(ThresholdFilter::new(LevelFilter::Debug))) - .build("stdout", Box::new(console)), - ) - .build( - Root::builder() - .appender("logfile") - .appender("stdout") - .build(LevelFilter::Debug), - ) - .expect("Failed to create log config"); - - log4rs::init_config(config).expect("Failed to initialize log4rs"); -} - -#[derive(Debug, Clone)] -struct RunningWaveform { - active: bool, - last_update: Instant, - follow_type: FollowType, - stop_tx: Option>, -} - -type ColorChannel = HashMap< - u64, - ( - mpsc::Sender, - mpsc::Receiver, - Option>, - ), ->; - -#[derive(Deserialize, Serialize)] -#[serde(default)] -struct MantleApp { - #[serde(skip)] - mgr: Manager, - #[serde(skip)] - screen_manager: ScreencapManager, - show_about: bool, - show_eyedropper: bool, - #[serde(skip)] - waveform_map: HashMap, - #[serde(skip)] - waveform_trx: ColorChannel, -} - -impl Default for MantleApp { - fn default() -> Self { - let mgr = Manager::new().expect("Failed to create manager"); - let screen_manager = ScreencapManager::new().expect("Failed to create screen manager"); - Self { - mgr, - screen_manager, - show_about: false, - show_eyedropper: false, - waveform_map: HashMap::new(), - waveform_trx: HashMap::new(), - } - } -} - -impl MantleApp { - fn new(cc: &eframe::CreationContext<'_>) -> Self { - if let Some(storage) = cc.storage { - return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default(); - } - Default::default() - } - - fn sort_bulbs<'a>(&self, mut bulbs: Vec<&'a BulbInfo>) -> Vec<&'a BulbInfo> { - bulbs.sort_by(|a, b| { - let group_a = a.group_label(); - let group_b = b.group_label(); - let name_a = a.name_label(); - let name_b = b.name_label(); - group_a.cmp(&group_b).then(name_a.cmp(&name_b)) - }); - bulbs - } - - fn display_device( - &mut self, - ui: &mut Ui, - device: &DeviceInfo, - bulbs: &MutexGuard>, - ) { - let color = match device { - DeviceInfo::Bulb(bulb) => { - if let Some(s) = bulb.name.data.as_ref().and_then(|s| s.to_str().ok()) { - ui.label(RichText::new(s).size(14.0)); - } - bulb.get_color().cloned() - } - DeviceInfo::Group(group) => { - if let Ok(s) = group.label.cstr().to_str() { - if *group == self.mgr.all { - ui.label(RichText::new(s).size(16.0).strong().underline()); - } else { - ui.label(RichText::new(s).size(16.0).strong()); - } - } - Some(self.mgr.avg_group_color(group, bulbs)) - } - }; - - ui.horizontal(|ui| { - display_color_circle( - ui, - device, - color.unwrap_or(default_hsbk()), - Vec2::new(1.0, 1.0), - 8.0, - bulbs, - ); - - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label("Power"); - toggle_button(ui, &self.mgr, device, Vec2::new(1.0, 1.0), bulbs); - }); - if let Some(before_color) = color { - let mut after_color = - self.display_color_controls(ui, device, color.unwrap_or(default_hsbk())); - ui.horizontal(|ui| { - after_color = handle_eyedropper(self, ui).unwrap_or(after_color); - after_color = handle_screencap(self, ui, device).unwrap_or(after_color); - }); - if before_color != after_color.next { - match device { - DeviceInfo::Bulb(bulb) => { - if let Err(e) = - self.mgr - .set_color(bulb, after_color.next, after_color.duration) - { - log::error!("Error setting color: {}", e); - } - } - DeviceInfo::Group(group) => { - if let Err(e) = self.mgr.set_group_color( - group, - after_color.next, - bulbs, - after_color.duration, - ) { - log::error!("Error setting group color: {}", e); - } - } - } - } - } else { - ui.label(format!("No color data: {:?}", color)); - } - }); - }); - ui.separator(); - } - - fn display_color_controls(&self, ui: &mut Ui, device: &DeviceInfo, color: HSBK) -> DeltaColor { - let HSBK { - mut hue, - mut saturation, - mut brightness, - mut kelvin, - } = color; - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label("Hue"); - color_slider(ui, &mut hue, LIFX_RANGE, "Hue", |v| { - HSBK32 { - hue: v as u32, - saturation: u32::MAX, - brightness: u32::MAX, - kelvin: 0, - } - .into() - }); - }); - ui.horizontal(|ui| { - ui.label("Saturation"); - color_slider(ui, &mut saturation, LIFX_RANGE, "Saturation", |v| { - let color_value = (u16::MAX - v).max(0) / u8::MAX as u16; - Color32::from_gray(color_value as u8) - }); - }); - ui.horizontal(|ui| { - ui.label("Brightness"); - color_slider(ui, &mut brightness, LIFX_RANGE, "Brightness", |v| { - let color_value = v.min(u16::MAX) / u8::MAX as u16; - Color32::from_gray(color_value as u8) - }); - }); - ui.horizontal(|ui| { - ui.label("Kelvin"); - match device { - DeviceInfo::Bulb(bulb) => { - if let Some(range) = bulb.features.temperature_range.as_ref() { - if range.min != range.max { - color_slider( - ui, - &mut kelvin, - RangeInclusive::new(range.min as u16, range.max as u16), - "Kelvin", - |v| { - let temp = (((v as f32 / u16::MAX as f32) - * (range.max - range.min) as f32) - + range.min as f32) - as u16; - kelvin_to_rgb(temp).into() - }, - ); - } else { - ui.label(format!("{}K", range.min)); - } - } - } - DeviceInfo::Group(_) => { - color_slider( - ui, - &mut kelvin, - RangeInclusive::new(KELVIN_RANGE.min as u16, KELVIN_RANGE.max as u16), - "Kelvin", - |v| { - let temp = (((v as f32 / u16::MAX as f32) - * (KELVIN_RANGE.max - KELVIN_RANGE.min) as f32) - + KELVIN_RANGE.min as f32) - as u16; - kelvin_to_rgb(temp).into() - }, - ); - } - } - }); - }); - DeltaColor { - next: HSBK { - hue, - saturation, - brightness, - kelvin, - }, - duration: None, - } - } - - fn file_menu_button(&self, ui: &mut Ui) { - let close_shortcut = egui::KeyboardShortcut::new(Modifiers::CTRL, egui::Key::Q); - let refresh_shortcut = egui::KeyboardShortcut::new(Modifiers::NONE, egui::Key::F5); - if ui.input_mut(|i| i.consume_shortcut(&close_shortcut)) { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - if ui.input_mut(|i| i.consume_shortcut(&refresh_shortcut)) { - self.mgr.refresh(); - } - - ui.menu_button("File", |ui| { - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - if ui - .add( - egui::Button::new("Refresh") - .shortcut_text(ui.ctx().format_shortcut(&refresh_shortcut)), - ) - .clicked() - { - self.mgr.refresh(); - ui.close_menu(); - } - if ui - .add( - egui::Button::new("Quit") - .shortcut_text(ui.ctx().format_shortcut(&close_shortcut)), - ) - .clicked() - { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - ui.close_menu(); - } - }); - } - - fn help_menu_button(&mut self, ui: &mut Ui) { - ui.menu_button("Help", |ui| { - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - if ui.add(egui::Button::new("About")).clicked() { - self.show_about = true; - ui.close_menu(); - } - }); - } - - fn update_ui(&mut self, ctx: &egui::Context) { - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { - egui::menu::bar(ui, |ui| { - self.file_menu_button(ui); - self.help_menu_button(ui); - }); - }); - egui::CentralPanel::default().show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - let bulbs = self.mgr.bulbs.clone(); - let bulbs = bulbs.lock(); - let mut seen_groups = HashSet::::new(); - ui.vertical(|ui| { - if let Ok(bulbs) = bulbs { - self.display_device(ui, &DeviceInfo::Group(self.mgr.all.clone()), &bulbs); - let sorted_bulbs = self.sort_bulbs(bulbs.values().collect()); - for bulb in sorted_bulbs { - if let Some(group) = bulb.group.data.as_ref() { - let group_name = group.label.cstr().to_str().unwrap_or_default(); - if !seen_groups.contains(group_name) { - seen_groups.insert(group_name.to_owned()); - self.display_device( - ui, - &DeviceInfo::Group(group.clone()), - &bulbs, - ); - } - } - self.display_device(ui, &DeviceInfo::Bulb(bulb), &bulbs); - } - } - }); - }); - }); - } - - fn show_about_window(&mut self, ctx: &egui::Context) { - if self.show_about { - egui::Window::new("About") - .default_width(ABOUT_WINDOW_SIZE[0]) - .default_height(ABOUT_WINDOW_SIZE[1]) - .open(&mut self.show_about) - .resizable([true, false]) - .show(ctx, |ui| { - ui.heading(capitalize_first_letter(env!("CARGO_PKG_NAME"))); - ui.add_space(8.0); - ui.label(env!("CARGO_PKG_DESCRIPTION")); - ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); - ui.label(format!("Author: {}", env!("CARGO_PKG_AUTHORS"))); - ui.hyperlink_to("Github", env!("CARGO_PKG_REPOSITORY")); - }); - } - } -} - -fn handle_eyedropper(app: &mut MantleApp, ui: &mut Ui) -> Option { - let mut color: Option = None; - let highlight = if app.show_eyedropper { - ui.visuals().widgets.hovered.bg_stroke.color - } else { - ui.visuals().widgets.inactive.bg_fill - }; - if ui - .add( - egui::Button::image( - egui::Image::from_bytes("eyedropper", EYEDROPPER_ICON) - .fit_to_exact_size(Vec2::new(15., 15.)), - ) - .sense(egui::Sense::click()) - .fill(highlight), - ) - .clicked() - { - app.show_eyedropper = !app.show_eyedropper; - } - if app.show_eyedropper { - let screencap = ScreencapManager::new().unwrap(); - ui.ctx().output_mut(|out| { - out.cursor_icon = egui::CursorIcon::Crosshair; - }); - let device_state = DeviceState::new(); - let mouse = device_state.get_mouse(); - if mouse.button_pressed[1] { - let position = mouse.coords; - color = Some(screencap.from_click(position.0, position.1)); - app.show_eyedropper = false; - } - } - color.map(|color| DeltaColor { - next: color, - duration: None, - }) -} - -fn handle_screencap(app: &mut MantleApp, ui: &mut Ui, device: &DeviceInfo) -> Option { - #[cfg(debug_assertions)] - puffin::profile_function!(); - let mut color: Option = None; - let highlight = if app - .waveform_map - .get(&device.id()) - .map_or(false, |w| w.active) - { - ui.visuals().widgets.hovered.bg_stroke.color - } else { - ui.visuals().widgets.inactive.bg_fill - }; - if let Some((_tx, rx, _thread)) = app.waveform_trx.get(&device.id()) { - let follow_state: &mut RunningWaveform = - app.waveform_map - .entry(device.id()) - .or_insert(RunningWaveform { - active: false, - last_update: Instant::now(), - follow_type: FollowType::All, - stop_tx: None, - }); - if follow_state.active && (Instant::now() - follow_state.last_update > FOLLOW_RATE) { - if let Ok(computed_color) = rx.try_recv() { - color = Some(computed_color); - follow_state.last_update = Instant::now(); - } - } - } else { - let (tx, rx) = mpsc::channel(); - app.waveform_trx.insert(device.id(), (tx, rx, None)); - } - - if ui - .add( - egui::Button::image( - egui::Image::from_bytes("monitor", MONITOR_ICON) - .fit_to_exact_size(Vec2::new(15., 15.)), - ) - .sense(egui::Sense::click()) - .fill(highlight), - ) - .clicked() - { - if let Some(waveform) = app.waveform_map.get_mut(&device.id()) { - waveform.active = !waveform.active; - } else { - let running_waveform = RunningWaveform { - active: true, - last_update: Instant::now(), - follow_type: FollowType::All, - stop_tx: None, - }; - app.waveform_map - .insert(device.id(), running_waveform.clone()); - } - // if the waveform is active, we need to spawn a thread to get the color - if app.waveform_map[&device.id()].active { - let screen_manager = app.screen_manager.clone(); - let tx = app.waveform_trx.get(&device.id()).unwrap().0.clone(); - let follow_type = app.waveform_map[&device.id()].follow_type.clone(); - let (stop_tx, stop_rx) = mpsc::channel::<()>(); - if let Some(waveform_trx) = app.waveform_trx.get_mut(&device.id()) { - waveform_trx.2 = Some(thread::spawn(move || loop { - #[cfg(debug_assertions)] - puffin::profile_function!(); - let avg_color = screen_manager.avg_color(follow_type.clone()); - if let Err(err) = tx.send(avg_color) { - eprintln!("Failed to send color data: {}", err); - } - thread::sleep(Duration::from_millis((FOLLOW_RATE.as_millis() / 4) as u64)); - if stop_rx.try_recv().is_ok() { - break; - } - })); - } - app.waveform_map.get_mut(&device.id()).unwrap().stop_tx = Some(stop_tx); - } else { - // kill thread - if let Some(waveform_trx) = app.waveform_trx.get_mut(&device.id()) { - if let Some(thread) = waveform_trx.2.take() { - // Send a signal to stop the thread - if let Some(stop_tx) = app - .waveform_map - .get_mut(&device.id()) - .unwrap() - .stop_tx - .take() - { - stop_tx.send(()).unwrap(); - } - // Wait for the thread to finish - thread.join().unwrap(); - } - } - } - } - - ui.horizontal(|ui| { - if let Some(waveform) = app.waveform_map.get_mut(&device.id()) { - ui.radio_value(&mut waveform.follow_type, FollowType::All, "All"); - for monitor in app.screen_manager.monitors.iter() { - ui.radio_value( - &mut waveform.follow_type, - FollowType::Monitor(vec![monitor.clone()]), - monitor.name(), - ); - } - } - }); - - color.map(|color| DeltaColor { - next: color, - duration: Some((FOLLOW_RATE.as_millis() / 2) as u32), - }) -} - -impl eframe::App for MantleApp { - fn save(&mut self, storage: &mut dyn eframe::Storage) { - eframe::set_value(storage, eframe::APP_KEY, self); - } - - fn update(&mut self, _ctx: &egui::Context, _frame: &mut eframe::Frame) { - #[cfg(debug_assertions)] - puffin::GlobalProfiler::lock().new_frame(); - if Instant::now() - self.mgr.last_discovery > REFRESH_RATE { - self.mgr.discover().expect("Failed to discover bulbs"); - } - self.mgr.refresh(); - self.update_ui(_ctx); - self.show_about_window(_ctx); - } -} - #[cfg(debug_assertions)] fn start_puffin_server() { puffin::set_scopes_on(true); // tell puffin to collect data diff --git a/src/widgets.rs b/src/ui.rs similarity index 52% rename from src/widgets.rs rename to src/ui.rs index 5fa941a..9787502 100644 --- a/src/widgets.rs +++ b/src/ui.rs @@ -1,5 +1,24 @@ -use std::{collections::HashMap, ops::RangeInclusive, sync::MutexGuard}; +use std::{ + collections::HashMap, + ops::RangeInclusive, + sync::{mpsc, MutexGuard}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + app::{ + MantleApp, RunningWaveform, EYEDROPPER_ICON, FOLLOW_RATE, ICON, MAIN_WINDOW_SIZE, + MIN_WINDOW_SIZE, MONITOR_ICON, + }, + color::DeltaColor, + contrast_color, + device_info::DeviceInfo, + screencap::{FollowType, ScreencapManager}, + AngleIter, BulbInfo, Manager, RGB8, +}; +use device_query::{DeviceQuery, DeviceState}; use eframe::{ egui::{ self, lerp, pos2, remap_clamp, vec2, Color32, Mesh, Pos2, Response, Sense, Shape, Stroke, @@ -7,9 +26,190 @@ use eframe::{ }, epaint::CubicBezierShape, }; +use image::GenericImageView; use lifx_core::HSBK; -use crate::{contrast_color, device_info::DeviceInfo, AngleIter, BulbInfo, Manager, RGB8}; +pub fn setup_eframe_options() -> eframe::NativeOptions { + let icon = load_icon(ICON); + + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size(MAIN_WINDOW_SIZE) + .with_min_inner_size(MIN_WINDOW_SIZE) + .with_icon(icon), + ..Default::default() + } +} + +pub fn load_icon(icon: &[u8]) -> egui::IconData { + let icon = image::load_from_memory(icon).expect("Failed to load icon"); + egui::IconData { + rgba: icon.to_rgba8().into_raw(), + width: icon.width(), + height: icon.height(), + } +} + +pub fn handle_eyedropper(app: &mut MantleApp, ui: &mut Ui) -> Option { + let mut color: Option = None; + let highlight = if app.show_eyedropper { + ui.visuals().widgets.hovered.bg_stroke.color + } else { + ui.visuals().widgets.inactive.bg_fill + }; + if ui + .add( + egui::Button::image( + egui::Image::from_bytes("eyedropper", EYEDROPPER_ICON) + .fit_to_exact_size(Vec2::new(15., 15.)), + ) + .sense(egui::Sense::click()) + .fill(highlight), + ) + .clicked() + { + app.show_eyedropper = !app.show_eyedropper; + } + if app.show_eyedropper { + let screencap = ScreencapManager::new().unwrap(); + ui.ctx().output_mut(|out| { + out.cursor_icon = egui::CursorIcon::Crosshair; + }); + let device_state = DeviceState::new(); + let mouse = device_state.get_mouse(); + if mouse.button_pressed[1] { + let position = mouse.coords; + color = Some(screencap.from_click(position.0, position.1)); + app.show_eyedropper = false; + } + } + color.map(|color| DeltaColor { + next: color, + duration: None, + }) +} + +pub fn handle_screencap( + app: &mut MantleApp, + ui: &mut Ui, + device: &DeviceInfo, +) -> Option { + #[cfg(debug_assertions)] + puffin::profile_function!(); + let mut color: Option = None; + let highlight = if app + .waveform_map + .get(&device.id()) + .map_or(false, |w| w.active) + { + ui.visuals().widgets.hovered.bg_stroke.color + } else { + ui.visuals().widgets.inactive.bg_fill + }; + if let Some((_tx, rx, _thread)) = app.waveform_trx.get(&device.id()) { + let follow_state: &mut RunningWaveform = + app.waveform_map + .entry(device.id()) + .or_insert(RunningWaveform { + active: false, + last_update: Instant::now(), + follow_type: FollowType::All, + stop_tx: None, + }); + if follow_state.active && (Instant::now() - follow_state.last_update > FOLLOW_RATE) { + if let Ok(computed_color) = rx.try_recv() { + color = Some(computed_color); + follow_state.last_update = Instant::now(); + } + } + } else { + let (tx, rx) = mpsc::channel(); + app.waveform_trx.insert(device.id(), (tx, rx, None)); + } + + if ui + .add( + egui::Button::image( + egui::Image::from_bytes("monitor", MONITOR_ICON) + .fit_to_exact_size(Vec2::new(15., 15.)), + ) + .sense(egui::Sense::click()) + .fill(highlight), + ) + .clicked() + { + if let Some(waveform) = app.waveform_map.get_mut(&device.id()) { + waveform.active = !waveform.active; + } else { + let running_waveform = RunningWaveform { + active: true, + last_update: Instant::now(), + follow_type: FollowType::All, + stop_tx: None, + }; + app.waveform_map + .insert(device.id(), running_waveform.clone()); + } + // if the waveform is active, we need to spawn a thread to get the color + if app.waveform_map[&device.id()].active { + let screen_manager = app.screen_manager.clone(); + let tx = app.waveform_trx.get(&device.id()).unwrap().0.clone(); + let follow_type = app.waveform_map[&device.id()].follow_type.clone(); + let (stop_tx, stop_rx) = mpsc::channel::<()>(); + if let Some(waveform_trx) = app.waveform_trx.get_mut(&device.id()) { + waveform_trx.2 = Some(thread::spawn(move || loop { + #[cfg(debug_assertions)] + puffin::profile_function!(); + let avg_color = screen_manager.avg_color(follow_type.clone()); + if let Err(err) = tx.send(avg_color) { + eprintln!("Failed to send color data: {}", err); + } + thread::sleep(Duration::from_millis((FOLLOW_RATE.as_millis() / 4) as u64)); + if stop_rx.try_recv().is_ok() { + break; + } + })); + } + app.waveform_map.get_mut(&device.id()).unwrap().stop_tx = Some(stop_tx); + } else { + // kill thread + if let Some(waveform_trx) = app.waveform_trx.get_mut(&device.id()) { + if let Some(thread) = waveform_trx.2.take() { + // Send a signal to stop the thread + if let Some(stop_tx) = app + .waveform_map + .get_mut(&device.id()) + .unwrap() + .stop_tx + .take() + { + stop_tx.send(()).unwrap(); + } + // Wait for the thread to finish + thread.join().unwrap(); + } + } + } + } + + ui.horizontal(|ui| { + if let Some(waveform) = app.waveform_map.get_mut(&device.id()) { + ui.radio_value(&mut waveform.follow_type, FollowType::All, "All"); + for monitor in app.screen_manager.monitors.iter() { + ui.radio_value( + &mut waveform.follow_type, + FollowType::Monitor(vec![monitor.clone()]), + monitor.name(), + ); + } + } + }); + + color.map(|color| DeltaColor { + next: color, + duration: Some((FOLLOW_RATE.as_millis() / 2) as u32), + }) +} pub fn display_color_circle( ui: &mut Ui, diff --git a/src/helpers.rs b/src/utils.rs similarity index 52% rename from src/helpers.rs rename to src/utils.rs index ff7ef9b..76df9f1 100644 --- a/src/helpers.rs +++ b/src/utils.rs @@ -1,6 +1,43 @@ -// AngleIter from https://vcs.cozydsp.space/cozy-dsp/cozy-ui/src/commit/d4706ec9f4592137307ce8acafb56b881ea54e35/src/util.rs#L49 +use log::LevelFilter; +use log4rs::append::console::ConsoleAppender; +use log4rs::append::file::FileAppender; +use log4rs::config::{Appender, Config, Root}; +use log4rs::encode::pattern::PatternEncoder; +use log4rs::filter::threshold::ThresholdFilter; use std::f32::consts::PI; +pub fn init_logging() { + let logfile = FileAppender::builder() + .encoder(Box::new(PatternEncoder::new("{l} - {m}\n"))) + .build("log/output.log") + .expect("Failed to create log file appender"); + + let console = ConsoleAppender::builder().build(); + + let config = Config::builder() + .appender( + Appender::builder() + .filter(Box::new(ThresholdFilter::new(LevelFilter::Info))) + .build("logfile", Box::new(logfile)), + ) + .appender( + Appender::builder() + .filter(Box::new(ThresholdFilter::new(LevelFilter::Debug))) + .build("stdout", Box::new(console)), + ) + .build( + Root::builder() + .appender("logfile") + .appender("stdout") + .build(LevelFilter::Debug), + ) + .expect("Failed to create log config"); + + log4rs::init_config(config).expect("Failed to initialize log4rs"); +} + +// AngleIter from https://vcs.cozydsp.space/cozy-dsp/cozy-ui/src/commit/d4706ec9f4592137307ce8acafb56b881ea54e35/src/util.rs#L49 + const PI_OVER_2: f32 = PI / 2.0; pub struct AngleIter {