diff --git a/Cargo.lock b/Cargo.lock index cbb0b9c..5a0fcda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1219,6 +1219,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data" +version = "0.1.6" +dependencies = [ + "anyhow", + "dirs 6.0.0", + "global-hotkey", + "hardware", + "log", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tray-icon", + "windows-sys 0.61.2", + "winreg", +] + [[package]] name = "data-url" version = "0.3.2" @@ -2265,6 +2283,19 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hardware" +version = "0.1.6" +dependencies = [ + "ddc", + "ddc-winapi", + "log", + "serde", + "serde_json", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + [[package]] name = "harfrust" version = "0.5.2" @@ -5486,6 +5517,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ui" +version = "0.1.6" +dependencies = [ + "data", + "libcosmic", + "log", + "tokio", +] + [[package]] name = "uncased" version = "0.9.10" @@ -6111,23 +6152,12 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windisplaymanager_rs" -version = "0.1.5" +version = "0.1.6" dependencies = [ - "anyhow", - "ddc", - "ddc-winapi", - "dirs 6.0.0", + "data", "env_logger", - "global-hotkey", "libcosmic", - "log", - "serde", - "serde_json", - "thiserror 2.0.19", - "tokio", - "tray-icon", - "windows-sys 0.61.2", - "winreg", + "ui", "winres", ] diff --git a/Cargo.toml b/Cargo.toml index 1b18a30..d703e3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,19 @@ +[workspace] +members = ["hardware", "data", "ui"] +default-members = [".", "hardware", "data", "ui"] +resolver = "3" + +[workspace.package] +version = "0.1.6" +edition = "2024" +license = "MIT" + [package] name = "windisplaymanager_rs" -version = "0.1.5" -edition = "2024" description = "Windows Display Manager - DDC/CI monitor control with GUI" -license = "MIT" +version.workspace = true +edition.workspace = true +license.workspace = true [[bin]] name = "windisplaymanager_rs" @@ -17,6 +27,15 @@ lto = "thin" winres = "0.1" [dependencies] +data.workspace = true +ui.workspace = true +env_logger.workspace = true +libcosmic.workspace = true + +[workspace.dependencies] +hardware = { path = "hardware" } +data = { path = "data" } +ui = { path = "ui" } ddc = "0.2" ddc-winapi = "0.2" global-hotkey = "0.8" @@ -38,7 +57,7 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_WindowsAndMessaging", ] } -[dependencies.libcosmic] +[workspace.dependencies.libcosmic] git = "https://github.com/pop-os/libcosmic.git" default-features = false features = [ diff --git a/data/Cargo.toml b/data/Cargo.toml new file mode 100644 index 0000000..b680ffa --- /dev/null +++ b/data/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "data" +description = "Application state and logic for Windows Display Manager" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +hardware.workspace = true +global-hotkey.workspace = true +thiserror.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true +log.workspace = true +tray-icon.workspace = true +winreg.workspace = true +anyhow.workspace = true +windows-sys.workspace = true + +[dev-dependencies] +hardware = { workspace = true, features = ["test-util"] } diff --git a/src/app/actions.rs b/data/src/actions.rs similarity index 99% rename from src/app/actions.rs rename to data/src/actions.rs index c81cd1b..7e517ca 100644 --- a/src/app/actions.rs +++ b/data/src/actions.rs @@ -492,7 +492,7 @@ impl ActionExecutor { #[cfg(test)] mod tests { use super::*; - use crate::ddc::tests::{FakeVcp, fake_session, key, monitor_state}; + use crate::ddc::test_util::{FakeVcp, fake_session, key, monitor_state}; #[derive(Default)] struct FakeHardware { diff --git a/src/config.rs b/data/src/config.rs similarity index 99% rename from src/config.rs rename to data/src/config.rs index 6b25239..6fdb2c8 100644 --- a/src/config.rs +++ b/data/src/config.rs @@ -1131,7 +1131,7 @@ mod tests { let path = dir.0.join("config.json"); let mut config = AppConfig::default(); let mut hotkey = Hotkey::new_empty(); - let key = crate::ddc::tests::key(1); + let key = crate::ddc::test_util::key(1); let unknown: MonitorTarget = serde_json::from_str("\"future-monitor:v2:abc\"").unwrap(); hotkey.actions[0] = HotkeyActionSpec { target: ActionTarget::InputSource, @@ -1171,7 +1171,7 @@ mod tests { assert_eq!(action.monitor_inputs[0].input_source, InputSource::Hdmi2); assert!( action - .resolve_monitors(&[crate::ddc::tests::monitor_state().info]) + .resolve_monitors(&[crate::ddc::test_util::monitor_state().info]) .is_err() ); } @@ -1194,7 +1194,7 @@ mod tests { ], ..Default::default() }; - let live = crate::ddc::tests::monitor_state().info; + let live = crate::ddc::test_util::monitor_state().info; assert!( action .resolve_monitors(std::slice::from_ref(&live)) @@ -1219,14 +1219,14 @@ mod tests { #[test] fn whole_action_resolution_rejects_legacy_missing_duplicate_and_empty_targets_including_off() { - let live = crate::ddc::tests::monitor_state().info; + let live = crate::ddc::test_util::monitor_state().info; let stable = MonitorTarget::Stable(live.key.clone()); for action_type in [ActionType::Set, ActionType::Offset, ActionType::Off] { for targets in [ vec![stable.clone(), 1.into()], vec![ stable.clone(), - MonitorTarget::Stable(crate::ddc::tests::key(2)), + MonitorTarget::Stable(crate::ddc::test_util::key(2)), ], vec![stable.clone(), stable.clone()], Vec::new(), @@ -1261,7 +1261,7 @@ mod tests { #[test] fn explicit_resolution_uses_keys_after_numbers_change() { - let mut live = crate::ddc::tests::monitor_state().info; + let mut live = crate::ddc::test_util::monitor_state().info; let action = HotkeyActionSpec { all_monitors: false, monitors: vec![MonitorTarget::Stable(live.key.clone())], diff --git a/src/app/coordination.rs b/data/src/coordination.rs similarity index 99% rename from src/app/coordination.rs rename to data/src/coordination.rs index eb2177a..533be66 100644 --- a/src/app/coordination.rs +++ b/data/src/coordination.rs @@ -1,4 +1,4 @@ -use super::actions::{self, ActionExecutor, HardwareJob, HardwareOutcome, PreparedJob}; +use crate::actions::{self, ActionExecutor, HardwareJob, HardwareOutcome, PreparedJob}; use crate::config::{HotkeyActionSpec, MonitorTarget, TurnOffBehavior}; use crate::ddc::{self, MonitorInfo, MonitorKey, MonitorState}; use std::collections::HashMap; @@ -357,7 +357,7 @@ impl HardwareCoordinator { mod tests { use super::*; use crate::config::ActionTarget; - use crate::ddc::tests::{FakeVcp, fake_session, key, monitor_state}; + use crate::ddc::test_util::{FakeVcp, fake_session, key, monitor_state}; use crate::ddc::{InputSource, VCP_BRIGHTNESS, VCP_CONTRAST, VCP_INPUT_SOURCE}; use std::collections::VecDeque; diff --git a/src/app/debounce.rs b/data/src/debounce.rs similarity index 98% rename from src/app/debounce.rs rename to data/src/debounce.rs index 306b496..21ea95d 100644 --- a/src/app/debounce.rs +++ b/data/src/debounce.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub(super) enum SliderFeature { +pub enum SliderFeature { Brightness, Contrast, } @@ -20,7 +20,7 @@ struct PendingChange { /// Drafts are separate from both confirmed readings and submitted hardware jobs. #[derive(Debug, Default)] -pub(super) struct SliderDebounce { +pub struct SliderDebounce { revision: u64, pending: HashMap<(u32, SliderFeature), PendingChange>, } diff --git a/src/hotkeys.rs b/data/src/hotkeys.rs similarity index 73% rename from src/hotkeys.rs rename to data/src/hotkeys.rs index 2c82985..6cc95c8 100644 --- a/src/hotkeys.rs +++ b/data/src/hotkeys.rs @@ -1,12 +1,8 @@ use crate::config::{AppConfig, HotkeyActionSpec, build_hotkey_map}; -use cosmic::iced::futures::SinkExt; -use cosmic::iced::{Subscription, stream}; use global_hotkey::hotkey::HotKey; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; use std::collections::{HashMap, HashSet}; -use std::hash::{Hash, Hasher}; use std::sync::Arc; -use std::time::Duration; // --------------------------------------------------------------------------- // HotkeyManager – registers hotkeys and keeps the ID → action-chain mapping @@ -126,62 +122,26 @@ impl HotkeyManager { } // --------------------------------------------------------------------------- -// Subscription – polls global hotkey events and emits action chains +// Event polling – drained by the UI's hotkey subscription // --------------------------------------------------------------------------- -/// Identity/data wrapper for the hotkey subscription. Hashing the set of -/// registered hotkey ids ensures the subscription restarts when the bindings -/// change. -struct HotkeyData { - generation: u64, - registered_ids: Vec, -} - -impl Hash for HotkeyData { - fn hash(&self, state: &mut H) { - self.generation.hash(state); - self.registered_ids.hash(state); - } -} - fn should_emit(registered_ids: &[u32], event_id: u32, state: HotKeyState) -> bool { state == HotKeyState::Pressed && registered_ids.binary_search(&event_id).is_ok() } -/// Create an iced `Subscription` that polls global hotkey events. -/// -/// The subscription emits the triggered hotkey's OS id whenever a registered -/// hotkey is pressed. The caller resolves the current action chain by id. -pub fn hotkey_subscription(mut registered_ids: Vec, generation: u64) -> Subscription { - registered_ids.sort_unstable(); - registered_ids.dedup(); - - Subscription::run_with( - HotkeyData { - generation, - registered_ids, - }, - |data| { - let registered_ids = data.registered_ids.clone(); - stream::channel( - 16, - move |mut emitter: cosmic::iced::futures::channel::mpsc::Sender| async move { - let receiver = GlobalHotKeyEvent::receiver(); - for _ in receiver.try_iter() {} - loop { - while let Ok(event) = receiver.try_recv() { - if should_emit(®istered_ids, event.id(), event.state) - && emitter.send(event.id()).await.is_err() - { - return; - } - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }, - ) - }, - ) +/// Drops events queued before a listener started. +pub fn discard_pending_events() { + for _ in GlobalHotKeyEvent::receiver().try_iter() {} +} + +/// OS ids of registered hotkeys pressed since the last call. `registered_ids` +/// must be sorted. +pub fn take_pressed(registered_ids: &[u32]) -> Vec { + GlobalHotKeyEvent::receiver() + .try_iter() + .filter(|event| should_emit(registered_ids, event.id(), event.state)) + .map(|event| event.id()) + .collect() } #[cfg(test)] diff --git a/data/src/lib.rs b/data/src/lib.rs new file mode 100644 index 0000000..688dcfd --- /dev/null +++ b/data/src/lib.rs @@ -0,0 +1,13 @@ +pub use hardware::{ccd, ddc}; + +pub mod actions; +pub mod config; +pub mod coordination; +pub mod debounce; +pub mod hotkeys; +pub mod persistence; +pub mod presentation; +pub mod profiles; +pub mod startup; +pub mod state; +pub mod tray; diff --git a/src/persistence.rs b/data/src/persistence.rs similarity index 100% rename from src/persistence.rs rename to data/src/persistence.rs diff --git a/src/app/monitor_views.rs b/data/src/presentation.rs similarity index 57% rename from src/app/monitor_views.rs rename to data/src/presentation.rs index b5d9e16..e8d9959 100644 --- a/src/app/monitor_views.rs +++ b/data/src/presentation.rs @@ -1,13 +1,11 @@ -use super::debounce::{SliderDebounce, SliderFeature}; -use super::{AppModel, HardwareJob, Message}; -use crate::ddc::{InputSource, input_choices}; -use cosmic::Element; -use cosmic::iced::Length; -use cosmic::iced::alignment::Horizontal; -use cosmic::widget; +//! View models derived from state; widget code lives in the UI crate. + +use crate::actions::HardwareJob; +use crate::ddc::InputSource; +use crate::debounce::{SliderDebounce, SliderFeature}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(super) enum PendingValue { +pub enum PendingValue { #[default] None, Set(T), @@ -15,14 +13,14 @@ pub(super) enum PendingValue { } #[derive(Default)] -pub(super) struct MonitorPending { +pub struct MonitorPending { pub brightness: PendingValue, pub contrast: PendingValue, pub input_source: PendingValue, } impl MonitorPending { - pub(super) fn new<'a>( + pub fn new<'a>( monitor_id: u32, jobs: impl Iterator, drafts: &SliderDebounce, @@ -69,7 +67,7 @@ impl MonitorPending { } #[derive(Debug, PartialEq, Eq)] -pub(super) struct ControlPresentation<'a, T> { +pub struct ControlPresentation<'a, T> { pub value: Option, pub label: String, pub read_error: Option<&'a str>, @@ -103,7 +101,7 @@ fn control_presentation( } } -pub(super) fn scalar_presentation( +pub fn scalar_presentation( value: u16, maximum: u16, read_error: Option<&str>, @@ -123,7 +121,7 @@ pub(super) fn scalar_presentation( presentation } -pub(super) fn input_presentation( +pub fn input_presentation( source: InputSource, read_error: Option<&str>, pending: PendingValue, @@ -131,179 +129,12 @@ pub(super) fn input_presentation( control_presentation(source, read_error, pending, |source| source.to_string()) } -impl AppModel { - /// View for a single monitor page. - pub(super) fn view_monitor(&self, monitor_id: u32) -> Element<'_, Message> { - let space_s = cosmic::theme::spacing().space_s; - - let monitor = self.monitors.iter().find(|m| m.info.id == monitor_id); - - match monitor { - None => { - let content: Element<'_, Message> = - if let Some(error) = self.monitor_load_errors.get(&monitor_id) { - widget::column::with_capacity(3) - .push(widget::text::title4(format!( - "Monitor {monitor_id} unavailable" - ))) - .push(widget::text::body(error)) - .push( - widget::button::standard("Retry") - .on_press(Message::RetryMonitor(monitor_id)), - ) - .spacing(space_s) - .into() - } else { - widget::text::body("Loading monitor data...").into() - }; - widget::container(content) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Horizontal::Center) - .into() - } - - Some(mon) => { - // Header - let header_label = if mon.info.name.is_empty() { - format!("Monitor {}", mon.info.id) - } else { - mon.info.name.clone() - }; - let header = widget::text::title3(header_label); - - let resolution_text = format!( - "{}x{} at ({}, {}){}", - mon.info.width, - mon.info.height, - mon.info.x, - mon.info.y, - if mon.info.is_primary { - " [Primary]" - } else { - "" - } - ); - let resolution_label = widget::text::caption(resolution_text); - - let pending = MonitorPending::new( - mon.info.id, - self.action_executor - .outstanding() - .filter_map(|job| job.preview_for(&mon.info, self.monitor_generation)), - &self.slider_debounce, - ); - let input = input_presentation( - mon.input_source, - mon.input_source_read_error.as_deref(), - pending.input_source, - ); - let mid = mon.info.id; - let inputs = input_choices(mon.advertised_inputs.as_deref(), input.value); - let sources = inputs.options; - let labels: Vec = sources.iter().map(ToString::to_string).collect(); - let selected_idx = input - .value - .as_ref() - .and_then(|source| sources.iter().position(|candidate| candidate == source)); - let control: Element<'_, Message> = if sources.is_empty() { - widget::text::body("No input sources advertised").into() - } else { - widget::dropdown(labels, selected_idx, move |idx| { - Message::SelectInputSource(mid, sources[idx]) - }) - .into() - }; - let mut section = cosmic::widget::settings::section() - .title("Input Source") - .add(cosmic::widget::settings::item::builder(input.label).control(control)); - if let Some(error) = input.read_error { - section = section.add(widget::text::body(format!( - "Could not read input source: {error}" - ))); - } - let input_section = section; - - let mut content = widget::column::with_capacity(6) - .push(header) - .push(resolution_label) - .spacing(space_s) - .width(Length::Fill); - content = content.push(input_section); - let brightness = scalar_presentation( - mon.brightness, - mon.brightness_max, - mon.brightness_read_error.as_deref(), - pending.brightness, - ); - let brightness_control: Element<'_, Message> = match brightness.value { - Some(value) => widget::slider( - 0.0..=f64::from(mon.brightness_max), - f64::from(value), - move |v| Message::BrightnessSliderChanged(mid, v as u16), - ) - .width(Length::Fixed(300.0)) - .into(), - None => widget::text::body("Refresh to adjust").into(), - }; - let mut brightness_section = - cosmic::widget::settings::section().title("Brightness").add( - cosmic::widget::settings::item::builder(brightness.label) - .control(brightness_control), - ); - if let Some(error) = brightness.read_error { - brightness_section = brightness_section.add(widget::text::body(format!( - "Could not read brightness: {error}" - ))); - } - content = content.push(brightness_section); - let contrast = scalar_presentation( - mon.contrast, - mon.contrast_max, - mon.contrast_read_error.as_deref(), - pending.contrast, - ); - let contrast_control: Element<'_, Message> = match contrast.value { - Some(value) => widget::slider( - 0.0..=f64::from(mon.contrast_max), - f64::from(value), - move |v| Message::ContrastSliderChanged(mid, v as u16), - ) - .width(Length::Fixed(300.0)) - .into(), - None => widget::text::body("Refresh to adjust").into(), - }; - let mut contrast_section = - cosmic::widget::settings::section().title("Contrast").add( - cosmic::widget::settings::item::builder(contrast.label) - .control(contrast_control), - ); - if let Some(error) = contrast.read_error { - contrast_section = contrast_section.add(widget::text::body(format!( - "Could not read contrast: {error}" - ))); - } - content = content.push(contrast_section); - if let Some(error) = self.monitor_load_errors.get(&monitor_id) { - content = content.push(widget::text::body(format!( - "Could not refresh monitor: {error}" - ))); - } - widget::scrollable(content) - .width(Length::Fill) - .height(Length::Fill) - .into() - } - } - } -} - #[cfg(test)] mod tests { use super::*; - use crate::app::actions::ActionExecutor; + use crate::actions::ActionExecutor; use crate::ddc::STANDARD_INPUT_SOURCES; - use crate::ddc::tests::monitor_state; + use crate::ddc::test_util::monitor_state; fn input_source_index(source: &InputSource) -> Option { STANDARD_INPUT_SOURCES.iter().position(|s| s == source) diff --git a/src/profiles.rs b/data/src/profiles.rs similarity index 100% rename from src/profiles.rs rename to data/src/profiles.rs diff --git a/src/startup.rs b/data/src/startup.rs similarity index 100% rename from src/startup.rs rename to data/src/startup.rs diff --git a/data/src/state.rs b/data/src/state.rs new file mode 100644 index 0000000..308928a --- /dev/null +++ b/data/src/state.rs @@ -0,0 +1,606 @@ +//! UI-agnostic application state. The UI feeds [`Input`]s to +//! [`AppState::update`] and carries out the returned [`Effect`]s. + +mod config_ops; +mod hardware_queue; +mod hotkey_editor; +mod monitors; +mod profiles; + +pub use config_ops::{ConfigOp, ConfigOpDone}; +pub use hotkey_editor::{ + action_master_selected, action_monitor_selected, parse_value_draft, parse_vcp_draft, +}; + +use crate::actions::HardwareJob; +use crate::config::{ + ActionTarget, ActionType, AppConfig, ConfigStore, HotkeyActionSpec, HotkeyBinding, + MonitorTarget, TurnOffBehavior, +}; +use crate::coordination::{HardwareCoordinator, HardwareResult, WindowsHardware, Work}; +use crate::ddc::{InputSource, MonitorInfo, MonitorKey, MonitorState, PowerMode}; +use crate::debounce::{DebounceToken, SliderDebounce, SliderFeature}; +use crate::hotkeys::HotkeyManager; +use crate::persistence::LoadOutcome; +use crate::presentation::MonitorPending; +use crate::startup; +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; +use std::time::Duration; + +const SLIDER_DEBOUNCE: Duration = Duration::from_millis(150); + +#[derive(Clone, Debug)] +pub enum Input { + // Monitor controls + RefreshMonitors, + RetryMonitor(u32), + SelectInputSource(u32, InputSource), + HardwareJobFinished(u64, Result), + // Debounced slider changes + BrightnessSliderChanged(u32, u16), + ContrastSliderChanged(u32, u16), + ApplyBrightnessDebounced(u32, DebounceToken), + ApplyContrastDebounced(u32, DebounceToken), + TurnOffMonitors, + // Hotkeys + HotkeyTriggered(u32), + ToggleHotkeys(bool), + AddHotkey, + SetHotkeyLabel(String, String), + ToggleHotkeyEditor(String), + RequestDeleteHotkey(String), + CancelDeleteHotkey, + DeleteHotkey(String), + StartRecording(String), + CancelRecording, + ClearBinding(String), + /// A key chord captured while recording; empty `key` means unsupported. + KeyRecorded(HotkeyBinding), + AddAction(String), + DeleteAction(String, usize), + SetActionType(String, usize, ActionType), + SetActionTarget(String, usize, ActionTarget), + ActionValueDraftChanged(String, usize, String), + ActionVcpDraftChanged(String, usize, String), + SetActionInputSource(String, usize, InputSource), + SetMonitorInput(String, usize, MonitorTarget, Option), + SetActionPowerMode(String, usize, PowerMode), + SetActionProfile(String, usize, String), + ToggleActionAllMonitors(String, usize, bool), + ToggleActionMonitor(String, usize, MonitorTarget, bool), + RebindMonitorTarget(String, usize, MonitorTarget, MonitorKey), + RemoveMonitorTarget(String, usize, MonitorTarget), + // Settings and configuration + SetTurnOffBehavior(TurnOffBehavior), + ToggleStartWithWindows(bool), + ToggleStartMinimized(bool), + SaveConfig, + RetryConfig, + RecoverConfigBackup, + RequestResetConfig, + CancelResetConfig, + ConfirmResetConfig, + ConfigOpFinished(ConfigOp, ConfigOpDone), + // Profiles + RefreshProfiles, + ProfilesListed(u64, Result, String>), + ProfileNameInput(String), + SaveCurrentProfile(String), + ConfirmReplaceProfile, + CancelReplaceProfile, + ApplyProfile(String), + RequestDeleteProfile(String), + CancelDeleteProfile, + DeleteProfile(String), + AddProfileHotkey(String), + /// The user copied the configuration path. + ConfigPathCopied, + /// Opening a link failed. + OpenUrlFailed(String), +} + +impl Input { + /// Inputs that edit or act on configuration, which is read-only during recovery. + fn needs_writable_config(&self) -> bool { + matches!( + self, + Input::HotkeyTriggered(_) + | Input::ToggleHotkeys(_) + | Input::SaveConfig + | Input::AddHotkey + | Input::SetHotkeyLabel(_, _) + | Input::RequestDeleteHotkey(_) + | Input::DeleteHotkey(_) + | Input::StartRecording(_) + | Input::ClearBinding(_) + | Input::KeyRecorded(_) + | Input::AddAction(_) + | Input::DeleteAction(_, _) + | Input::SetActionType(_, _, _) + | Input::SetActionTarget(_, _, _) + | Input::ActionValueDraftChanged(_, _, _) + | Input::ActionVcpDraftChanged(_, _, _) + | Input::SetActionInputSource(_, _, _) + | Input::SetMonitorInput(_, _, _, _) + | Input::SetActionPowerMode(_, _, _) + | Input::SetActionProfile(_, _, _) + | Input::ToggleActionAllMonitors(_, _, _) + | Input::ToggleActionMonitor(_, _, _, _) + | Input::RebindMonitorTarget(_, _, _, _) + | Input::RemoveMonitorTarget(_, _, _) + | Input::SetTurnOffBehavior(_) + | Input::ToggleStartWithWindows(_) + | Input::ToggleStartMinimized(_) + | Input::AddProfileHotkey(_) + ) + } +} + +/// Side effects the UI must carry out after an update. +pub enum Effect { + /// Run blocking work off the UI thread, then feed back its completion input. + Spawn(Job), + /// Feed back the input after the delay. + Delay(Duration, Input), + /// Detected monitors changed; rebuild navigation. + MonitorsDetected, + /// The profile list changed; refresh the tray menu. + ProfilesUpdated, + /// Navigate to the hotkeys page. + ShowHotkeys, +} + +/// Blocking work produced by the state; opaque to the UI. +pub struct Job(JobKind); + +enum JobKind { + Hardware(Work), + Config(ConfigOp, ConfigStore, AppConfig), + ListProfiles(u64), +} + +impl Job { + /// Runs the work on the current thread. Call from a blocking-safe context. + pub fn run(self) -> Input { + match self.0 { + JobKind::Hardware(work) => { + let id = work.id; + Input::HardwareJobFinished(id, work.execute(&mut WindowsHardware)) + } + JobKind::Config(op, store, config) => { + let done = config_ops::run(&op, store, config); + Input::ConfigOpFinished(op, done) + } + JobKind::ListProfiles(request) => Input::ProfilesListed( + request, + crate::profiles::list_profiles() + .map_err(|error| format!("List profiles error: {error}")), + ), + } + } + + /// Completion input reporting that the worker running this job failed. + pub fn on_failure(&self) -> impl FnOnce(String) -> Input + Send + 'static { + let failed: Box Input + Send> = match &self.0 { + JobKind::Hardware(work) => { + let id = work.id; + Box::new(move |error| { + Input::HardwareJobFinished(id, Err(format!("Task join error: {error}"))) + }) + } + JobKind::Config(op, _, _) => { + let op = op.clone(); + Box::new(move |error| Input::ConfigOpFinished(op, ConfigOpDone::join_failed(error))) + } + JobKind::ListProfiles(request) => { + let request = *request; + Box::new(move |error| { + Input::ProfilesListed(request, Err(format!("Task join error: {error}"))) + }) + } + }; + failed + } +} + +#[derive(Clone, Debug)] +enum RecordingState { + NotRecording, + Recording { hotkey_id: String }, +} + +pub struct AppState { + monitor_generation: u64, + detected_monitors: Vec, + monitors: Vec, + monitor_load_errors: HashMap, + config: AppConfig, + config_store: ConfigStore, + config_ops: VecDeque, + config_op_active: bool, + pending_config_reset: bool, + hotkey_manager: Option, + hotkey_action_map: Arc>>, + hotkey_status: HashMap, + hotkey_generation: u64, + status_message: String, + recording_state: RecordingState, + expanded_hotkey: Option, + pending_hotkey_delete: Option, + value_drafts: HashMap<(String, usize), String>, + vcp_drafts: HashMap<(String, usize), String>, + config_dirty: bool, + config_path: String, + slider_debounce: SliderDebounce, + profiles: Vec, + profiles_request: u64, + profile_name_input: String, + pending_profile_delete: Option, + pending_profile_replace: Option, + action_executor: HardwareCoordinator, + refresh_profiles_after_jobs: bool, + effects: Vec, +} + +impl AppState { + /// Loads configuration and registers hotkeys. Call on the UI thread, which + /// owns the hotkey message window. + pub fn load() -> (Self, Vec) { + let (config_store, outcome) = match AppConfig::config_path() { + Ok(path) => ConfigStore::open(path), + Err(error) => ConfigStore::unavailable(error), + }; + let config = match outcome { + LoadOutcome::Missing => AppConfig::default(), + LoadOutcome::Loaded(config) => config, + LoadOutcome::Failed(error) => { + log::error!("Configuration requires recovery: {error}"); + AppConfig::recovery_placeholder() + } + }; + // Keep the Run key aligned with saved settings. Skip recovery placeholders + // so a failed load cannot delete a registration the user already chose. + if config_store.recovery_error().is_none() + && let Err(error) = startup::apply(config.start_with_windows, config.start_minimized) + { + log::warn!("Failed to sync Windows startup registration: {error}"); + } + + let hotkey_manager = HotkeyManager::new(&config); + let hotkey_action_map = hotkey_manager + .as_ref() + .map(|m| m.action_map()) + .unwrap_or_else(|| Arc::new(HashMap::new())); + let hotkey_status = hotkey_manager + .as_ref() + .map(|m| m.status()) + .unwrap_or_default(); + + let mut value_drafts = HashMap::new(); + let mut vcp_drafts = HashMap::new(); + for hotkey in &config.hotkeys.hotkeys { + for (idx, action) in hotkey.actions.iter().enumerate() { + value_drafts.insert((hotkey.id.clone(), idx), action.value.to_string()); + vcp_drafts.insert( + (hotkey.id.clone(), idx), + format!("0x{:02X}", action.vcp_code), + ); + } + } + + let config_path = config_store.path().display().to_string(); + let mut state = Self { + monitor_generation: 0, + detected_monitors: Vec::new(), + monitors: Vec::new(), + monitor_load_errors: HashMap::new(), + config, + config_store, + config_ops: VecDeque::new(), + config_op_active: false, + pending_config_reset: false, + hotkey_manager, + hotkey_action_map, + hotkey_status, + hotkey_generation: 0, + status_message: "Starting...".into(), + recording_state: RecordingState::NotRecording, + expanded_hotkey: None, + pending_hotkey_delete: None, + value_drafts, + vcp_drafts, + config_dirty: false, + config_path, + slider_debounce: SliderDebounce::default(), + profiles: Vec::new(), + profiles_request: 0, + profile_name_input: String::new(), + pending_profile_delete: None, + pending_profile_replace: None, + action_executor: HardwareCoordinator::default(), + refresh_profiles_after_jobs: false, + effects: Vec::new(), + }; + state.refresh_monitors(); + state.refresh_profiles(); + let effects = std::mem::take(&mut state.effects); + (state, effects) + } + + pub fn update(&mut self, input: Input) -> Vec { + if self + .pending_hotkey_delete + .as_ref() + .is_some_and(|id| self.hotkey_delete_title(id).is_none()) + { + self.pending_hotkey_delete = None; + } + + // The store also guards saves; this gate prevents editing or executing + // placeholder settings while a failed load awaits the user's decision. + if self.config_store.recovery_error().is_some() && input.needs_writable_config() { + self.status_message = + "Configuration is read-only until you retry, recover a backup, or confirm a reset." + .into(); + return Vec::new(); + } + + match input { + Input::RefreshMonitors => self.refresh_monitors(), + Input::RetryMonitor(monitor_id) => self.retry_monitor(monitor_id), + Input::SelectInputSource(monitor_id, source) => { + self.set_input_source(monitor_id, source) + } + Input::HardwareJobFinished(id, result) => self.hardware_job_finished(id, result), + Input::BrightnessSliderChanged(monitor_id, value) => { + self.slider_changed(monitor_id, SliderFeature::Brightness, value) + } + Input::ContrastSliderChanged(monitor_id, value) => { + self.slider_changed(monitor_id, SliderFeature::Contrast, value) + } + Input::ApplyBrightnessDebounced(monitor_id, token) => { + self.apply_slider_debounced(monitor_id, SliderFeature::Brightness, token) + } + Input::ApplyContrastDebounced(monitor_id, token) => { + self.apply_slider_debounced(monitor_id, SliderFeature::Contrast, token) + } + Input::TurnOffMonitors => { + let monitor_ids = self.detected_monitors.iter().map(|m| m.id).collect(); + self.enqueue_hardware_jobs([HardwareJob::SoftTurnOff { monitor_ids }]); + } + + Input::HotkeyTriggered(id) => self.handle_hotkey_triggered(id), + Input::ToggleHotkeys(enabled) => self.toggle_hotkeys(enabled), + Input::AddHotkey => self.add_hotkey(), + Input::SetHotkeyLabel(id, label) => self.set_hotkey_label(id, label), + Input::ToggleHotkeyEditor(id) => self.toggle_hotkey_editor(id), + Input::RequestDeleteHotkey(id) => self.request_delete_hotkey(id), + Input::CancelDeleteHotkey => self.pending_hotkey_delete = None, + Input::DeleteHotkey(id) => self.delete_hotkey(id), + Input::StartRecording(hotkey_id) => self.start_recording(hotkey_id), + Input::CancelRecording => self.cancel_recording(), + Input::ClearBinding(id) => self.clear_binding(id), + Input::KeyRecorded(binding) => self.key_recorded(binding), + Input::AddAction(id) => self.add_action(id), + Input::DeleteAction(id, idx) => self.delete_action(id, idx), + Input::SetActionType(id, idx, action_type) => { + self.set_action_type(id, idx, action_type) + } + Input::SetActionTarget(id, idx, target) => self.set_action_target(id, idx, target), + Input::ActionValueDraftChanged(id, idx, draft) => { + self.action_value_draft_changed(id, idx, draft) + } + Input::ActionVcpDraftChanged(id, idx, draft) => { + self.action_vcp_draft_changed(id, idx, draft) + } + Input::SetActionInputSource(id, idx, source) => { + self.set_action_input_source(id, idx, source) + } + Input::SetMonitorInput(id, idx, monitor_id, source) => { + self.set_monitor_input(id, idx, monitor_id, source) + } + Input::SetActionPowerMode(id, idx, mode) => self.set_action_power_mode(id, idx, mode), + Input::SetActionProfile(id, idx, name) => self.set_action_profile(id, idx, name), + Input::ToggleActionAllMonitors(id, idx, checked) => { + self.toggle_action_all_monitors(id, idx, checked) + } + Input::ToggleActionMonitor(id, idx, monitor_id, checked) => { + self.toggle_action_monitor(id, idx, monitor_id, checked) + } + Input::RebindMonitorTarget(id, idx, old, key) => { + self.rebind_monitor_target(id, idx, old, key) + } + Input::RemoveMonitorTarget(id, idx, target) => { + self.remove_monitor_target(id, idx, target) + } + + Input::SetTurnOffBehavior(behavior) => { + self.config.turn_off_behavior = behavior; + self.config_dirty = true; + } + Input::ToggleStartWithWindows(enabled) => { + self.set_windows_startup(enabled, self.config.start_minimized) + } + Input::ToggleStartMinimized(minimized) => { + self.set_windows_startup(self.config.start_with_windows, minimized) + } + Input::SaveConfig => self.save_config(), + Input::RetryConfig => self.retry_config(), + Input::RecoverConfigBackup => self.recover_config_backup(), + Input::RequestResetConfig => { + self.pending_config_reset = self.config_store.recovery_error().is_some(); + } + Input::CancelResetConfig => self.pending_config_reset = false, + Input::ConfirmResetConfig => self.confirm_reset_config(), + Input::ConfigOpFinished(op, done) => self.config_op_finished(op, done), + + Input::RefreshProfiles => self.refresh_profiles(), + Input::ProfilesListed(request, listed) => self.profiles_listed(request, listed), + Input::ProfileNameInput(value) => self.profile_name_input = value, + Input::SaveCurrentProfile(name) => self.save_current_profile(name), + Input::ConfirmReplaceProfile => self.confirm_replace_profile(), + Input::CancelReplaceProfile => self.pending_profile_replace = None, + Input::ApplyProfile(name) => self.apply_profile(name), + Input::RequestDeleteProfile(name) => self.request_delete_profile(name), + Input::CancelDeleteProfile => self.pending_profile_delete = None, + Input::DeleteProfile(name) => self.delete_profile(name), + Input::AddProfileHotkey(profile_name) => self.add_profile_hotkey(profile_name), + + Input::ConfigPathCopied => self.status_message = "Copied configuration path".into(), + Input::OpenUrlFailed(error) => { + self.status_message = format!("Failed to open URL: {error}"); + } + } + std::mem::take(&mut self.effects) + } + + fn emit(&mut self, effect: Effect) { + self.effects.push(effect); + } + + // ----------------------------------------------------------------------- + // Read-only accessors for views + // ----------------------------------------------------------------------- + + pub fn detected_monitors(&self) -> &[MonitorInfo] { + &self.detected_monitors + } + + pub fn monitor(&self, monitor_id: u32) -> Option<&MonitorState> { + self.monitors.iter().find(|m| m.info.id == monitor_id) + } + + pub fn monitor_state(&self, key: &MonitorKey) -> Option<&MonitorState> { + self.monitors.iter().find(|m| &m.info.key == key) + } + + pub fn monitor_load_error(&self, monitor_id: u32) -> Option<&str> { + self.monitor_load_errors + .get(&monitor_id) + .map(String::as_str) + } + + /// Queued writes and slider drafts to preview on a monitor's controls. + pub fn monitor_pending(&self, info: &MonitorInfo) -> MonitorPending { + MonitorPending::new( + info.id, + self.action_executor + .outstanding() + .filter_map(|job| job.preview_for(info, self.monitor_generation)), + &self.slider_debounce, + ) + } + + /// Advertised input lists of the monitors an action targets. + pub fn targeted_advertised_inputs( + &self, + action: &HotkeyActionSpec, + ) -> Vec>> { + let infos: Vec = if action.all_monitors { + self.detected_monitors.clone() + } else { + action + .explicit_targets() + .iter() + .filter_map(|target| target.resolve(&self.detected_monitors).ok().cloned()) + .collect() + }; + infos + .iter() + .filter_map(|info| { + self.monitor_state(&info.key) + .map(|monitor| monitor.advertised_inputs.clone()) + }) + .collect() + } + + pub fn config(&self) -> &AppConfig { + &self.config + } + + pub fn config_dirty(&self) -> bool { + self.config_dirty + } + + pub fn config_path(&self) -> &str { + &self.config_path + } + + pub fn recovery_error(&self) -> Option<&str> { + self.config_store.recovery_error() + } + + pub fn store_path(&self) -> &std::path::Path { + self.config_store.path() + } + + pub fn pending_config_reset(&self) -> bool { + self.pending_config_reset + } + + pub fn status_message(&self) -> &str { + &self.status_message + } + + pub fn is_hotkey_active(&self, id: &str) -> bool { + self.hotkey_status.get(id).copied().unwrap_or(false) + } + + pub fn is_expanded(&self, id: &str) -> bool { + self.expanded_hotkey.as_deref() == Some(id) + } + + pub fn is_recording(&self) -> bool { + matches!(self.recording_state, RecordingState::Recording { .. }) + } + + pub fn is_recording_hotkey(&self, id: &str) -> bool { + matches!(&self.recording_state, RecordingState::Recording { hotkey_id } if hotkey_id == id) + } + + /// OS ids to listen for, or `None` while hotkeys are off or unregistered. + pub fn listened_hotkey_ids(&self) -> Option> { + (self.config.hotkeys_enabled && !self.hotkey_action_map.is_empty()) + .then(|| self.hotkey_action_map.keys().copied().collect()) + } + + /// Changes whenever registrations change, so listeners restart. + pub fn hotkey_generation(&self) -> u64 { + self.hotkey_generation + } + + pub fn value_draft(&self, id: &str, idx: usize) -> &str { + self.value_drafts + .get(&(id.to_string(), idx)) + .map(String::as_str) + .unwrap_or("") + } + + pub fn vcp_draft(&self, id: &str, idx: usize) -> &str { + self.vcp_drafts + .get(&(id.to_string(), idx)) + .map(String::as_str) + .unwrap_or("") + } + + pub fn pending_hotkey_delete(&self) -> Option<&str> { + self.pending_hotkey_delete.as_deref() + } + + pub fn profiles(&self) -> &[String] { + &self.profiles + } + + pub fn profile_name_input(&self) -> &str { + &self.profile_name_input + } + + pub fn pending_profile_delete(&self) -> Option<&str> { + self.pending_profile_delete.as_deref() + } + + pub fn pending_profile_replace(&self) -> Option<&str> { + self.pending_profile_replace.as_deref() + } +} diff --git a/src/app/config_ops.rs b/data/src/state/config_ops.rs similarity index 80% rename from src/app/config_ops.rs rename to data/src/state/config_ops.rs index 986ce5c..23700ce 100644 --- a/src/app/config_ops.rs +++ b/data/src/state/config_ops.rs @@ -1,10 +1,10 @@ -use super::{AppModel, Message}; +use super::{AppState, Effect, Job, JobKind}; use crate::config::{AppConfig, ConfigStore}; use crate::persistence::LoadOutcome; use crate::startup; /// Config and registry writes run one at a time off the UI thread. Each -/// operation reads `AppModel::config` when it starts, not when requested. +/// operation reads `AppState::config` when it starts, not when requested. #[derive(Clone, Debug)] pub enum ConfigOp { Save, @@ -23,7 +23,16 @@ pub struct ConfigOpDone { outcome: Result, String>, } -fn run(op: &ConfigOp, mut store: ConfigStore, mut config: AppConfig) -> ConfigOpDone { +impl ConfigOpDone { + pub(super) fn join_failed(error: String) -> Self { + Self { + store: None, + outcome: Err(format!("Task join error: {error}")), + } + } +} + +pub(super) fn run(op: &ConfigOp, mut store: ConfigStore, mut config: AppConfig) -> ConfigOpDone { let outcome = match *op { ConfigOp::Save => store .save(&config) @@ -77,44 +86,29 @@ fn set_startup( }) } -impl AppModel { - pub(super) fn enqueue_config_op(&mut self, op: ConfigOp) -> cosmic::app::Task { +impl AppState { + pub(super) fn enqueue_config_op(&mut self, op: ConfigOp) { self.config_ops.push_back(op); - self.start_next_config_op() + self.start_next_config_op(); } - fn start_next_config_op(&mut self) -> cosmic::app::Task { + fn start_next_config_op(&mut self) { if self.config_op_active { - return cosmic::app::Task::none(); + return; } let Some(op) = self.config_ops.pop_front() else { - return cosmic::app::Task::none(); + return; }; self.config_op_active = true; if matches!(op, ConfigOp::Save) { // Edits made while the save is in flight mark the config dirty again. self.config_dirty = false; } - let store = self.config_store.clone(); - let config = self.config.clone(); - let task_op = op.clone(); - cosmic::app::Task::perform( - async move { tokio::task::spawn_blocking(move || run(&task_op, store, config)).await }, - move |result| { - let done = result.unwrap_or_else(|error| ConfigOpDone { - store: None, - outcome: Err(format!("Task join error: {error}")), - }); - cosmic::Action::App(Message::ConfigOpFinished(op.clone(), done)) - }, - ) + let job = JobKind::Config(op, self.config_store.clone(), self.config.clone()); + self.emit(Effect::Spawn(Job(job))); } - pub(super) fn config_op_finished( - &mut self, - op: ConfigOp, - done: ConfigOpDone, - ) -> cosmic::app::Task { + pub(super) fn config_op_finished(&mut self, op: ConfigOp, done: ConfigOpDone) { self.config_op_active = false; if let Some(store) = done.store { self.config_store = store; @@ -166,6 +160,11 @@ impl AppModel { ), (ConfigOp::RecoverBackup | ConfigOp::Reset, Ok(None)) => {} } - self.start_next_config_op() + self.start_next_config_op(); + } + + /// Persist startup settings and keep the per-user Run key in step. + pub(super) fn set_windows_startup(&mut self, enabled: bool, minimized: bool) { + self.enqueue_config_op(ConfigOp::SetStartup { enabled, minimized }); } } diff --git a/src/app/hardware_queue.rs b/data/src/state/hardware_queue.rs similarity index 87% rename from src/app/hardware_queue.rs rename to data/src/state/hardware_queue.rs index 0c54bb5..d1f9c21 100644 --- a/src/app/hardware_queue.rs +++ b/data/src/state/hardware_queue.rs @@ -1,8 +1,7 @@ -use super::actions::HardwareOutcome; -use super::coordination::{HardwareResult, QueueEvent, WindowsHardware}; -use super::{AppModel, Message}; +use super::{AppState, Effect, Job, JobKind}; +use crate::actions::{HardwareJob, HardwareOutcome}; +use crate::coordination::{HardwareResult, QueueEvent}; use crate::ddc::MonitorState; -use cosmic::Application; /// Only completed, successful writes may change confirmed values. In particular, /// a scalar write cannot establish the maximum that a failed read did not provide. @@ -44,16 +43,13 @@ fn apply_current_outcome( } } -impl AppModel { - pub(super) fn enqueue_hardware_jobs( - &mut self, - jobs: impl IntoIterator, - ) -> cosmic::app::Task { +impl AppState { + pub(super) fn enqueue_hardware_jobs(&mut self, jobs: impl IntoIterator) { if let Err(error) = self.action_executor.enqueue_ui(jobs.into_iter().collect()) { self.status_message = error; - return cosmic::app::Task::none(); + return; } - self.start_next_hardware_job() + self.start_next_hardware_job(); } pub(super) fn sync_hardware_generation(&mut self) { @@ -66,39 +62,25 @@ impl AppModel { } } - pub(super) fn start_next_hardware_job(&mut self) -> cosmic::app::Task { + pub(super) fn start_next_hardware_job(&mut self) { let work = self.action_executor.start_next(&self.monitors); self.sync_hardware_generation(); - let Some(work) = work else { - return cosmic::app::Task::none(); - }; - let id = work.id; - cosmic::app::Task::perform( - async move { tokio::task::spawn_blocking(move || work.execute(&mut WindowsHardware)).await }, - move |result| { - cosmic::Action::App(Message::HardwareJobFinished( - id, - match result { - Ok(result) => result, - Err(error) => Err(format!("Task join error: {error}")), - }, - )) - }, - ) + if let Some(work) = work { + self.emit(Effect::Spawn(Job(JobKind::Hardware(work)))); + } } pub(super) fn hardware_job_finished( &mut self, id: u64, result: Result, - ) -> cosmic::app::Task { + ) { let event = self.action_executor.finish(id, result); self.sync_hardware_generation(); - let mut follow_up = Vec::new(); match event { QueueEvent::Ignored => {} QueueEvent::Discovered(Ok(infos)) => { - follow_up.push(self.monitors_detected(self.monitor_generation, infos)); + self.monitors_detected(self.monitor_generation, infos); } QueueEvent::Discovered(Err(error)) => { for info in &self.detected_monitors { @@ -108,10 +90,10 @@ impl AppModel { format!("Discovery failed; dependent jobs canceled. Retry refresh: {error}"); } QueueEvent::Read(key, Ok(state)) => { - follow_up.push(self.monitor_state_loaded(self.monitor_generation, key, *state)); + self.monitor_state_loaded(self.monitor_generation, key, *state); } QueueEvent::Read(key, Err(error)) => { - follow_up.push(self.monitor_state_failed(self.monitor_generation, key, error)); + self.monitor_state_failed(self.monitor_generation, key, error); } QueueEvent::Applied(result) => { apply_current_outcome( @@ -123,11 +105,10 @@ impl AppModel { self.hardware_outcome_status(result); } } - follow_up.push(self.start_next_hardware_job()); + self.start_next_hardware_job(); if self.action_executor.is_idle() && std::mem::take(&mut self.refresh_profiles_after_jobs) { - follow_up.push(self.update(Message::RefreshProfiles)); + self.refresh_profiles(); } - cosmic::app::Task::batch(follow_up) } fn hardware_outcome_status(&mut self, result: Result) { @@ -177,14 +158,14 @@ impl AppModel { #[cfg(test)] mod tests { use super::*; - use crate::app::actions::{ActionExecutor, HardwareJob}; - use crate::app::debounce::SliderDebounce; - use crate::app::monitor_views::{ - MonitorPending, PendingValue, input_presentation, scalar_presentation, - }; + use crate::actions::ActionExecutor; use crate::ddc::{ InputSource, - tests::{key, monitor_state}, + test_util::{key, monitor_state}, + }; + use crate::debounce::SliderDebounce; + use crate::presentation::{ + MonitorPending, PendingValue, input_presentation, scalar_presentation, }; #[test] diff --git a/src/app/hotkey_editor.rs b/data/src/state/hotkey_editor.rs similarity index 80% rename from src/app/hotkey_editor.rs rename to data/src/state/hotkey_editor.rs index f725de0..b70d8b3 100644 --- a/src/app/hotkey_editor.rs +++ b/data/src/state/hotkey_editor.rs @@ -1,17 +1,15 @@ -use super::AppModel; use super::config_ops::ConfigOp; -use super::{Message, Page, RecordingState}; +use super::{AppState, Effect, RecordingState}; use crate::config::{ ActionTarget, ActionType, AppConfig, Hotkey, HotkeyActionSpec, HotkeyBinding, MonitorInput, MonitorTarget, hotkey_headings, }; use crate::ddc::{InputSource, MonitorKey, PowerMode}; use crate::hotkeys::HotkeyManager; -use cosmic::iced::keyboard::{Key, Modifiers}; use std::collections::HashMap; use std::sync::Arc; -pub(super) fn parse_value_draft(value: &str) -> Result, &'static str> { +pub fn parse_value_draft(value: &str) -> Result, &'static str> { let value = value.trim(); if value.is_empty() || value == "+" || value == "-" { return Ok(None); @@ -25,7 +23,7 @@ pub(super) fn parse_value_draft(value: &str) -> Result, &'static str Ok(Some(parsed)) } -pub(super) fn parse_vcp_draft(value: &str) -> Result, &'static str> { +pub fn parse_vcp_draft(value: &str) -> Result, &'static str> { let value = value.trim(); if value.is_empty() || value.eq_ignore_ascii_case("0x") { return Ok(None); @@ -40,25 +38,19 @@ pub(super) fn parse_vcp_draft(value: &str) -> Result, &'static str> { .map_err(|_| "VCP code must be between 00 and FF") } -pub(super) fn uses_monitor_inputs(action: &HotkeyActionSpec) -> bool { +fn uses_monitor_inputs(action: &HotkeyActionSpec) -> bool { action.action_type != ActionType::Off && action.target == ActionTarget::InputSource } -pub(super) fn explicit_monitor_ids(action: &HotkeyActionSpec) -> Vec { +fn explicit_monitor_ids(action: &HotkeyActionSpec) -> Vec { action.explicit_targets() } -pub(super) fn action_monitor_selected( - action: &HotkeyActionSpec, - monitor_id: &MonitorTarget, -) -> bool { +pub fn action_monitor_selected(action: &HotkeyActionSpec, monitor_id: &MonitorTarget) -> bool { action.all_monitors || explicit_monitor_ids(action).contains(monitor_id) } -pub(super) fn action_master_selected( - action: &HotkeyActionSpec, - detected_ids: &[MonitorTarget], -) -> bool { +pub fn action_master_selected(action: &HotkeyActionSpec, detected_ids: &[MonitorTarget]) -> bool { action.all_monitors || (!detected_ids.is_empty() && detected_ids @@ -66,7 +58,7 @@ pub(super) fn action_master_selected( .all(|monitor_id| action_monitor_selected(action, monitor_id))) } -pub(super) fn toggle_action_monitor( +fn toggle_action_monitor( action: &mut HotkeyActionSpec, detected_ids: &[MonitorTarget], monitor_id: MonitorTarget, @@ -101,7 +93,7 @@ pub(super) fn toggle_action_monitor( } } -pub(super) fn resolve_triggered_actions( +fn resolve_triggered_actions( enabled: bool, recording: bool, action_map: &HashMap>, @@ -113,60 +105,8 @@ pub(super) fn resolve_triggered_actions( action_map.get(&id).cloned() } -/// Convert an Iced Key to our internal string format -pub(super) fn key_to_string(key: &Key) -> String { - match key { - Key::Named(named_key) => { - use cosmic::iced::keyboard::key::Named; - match named_key { - Named::F1 => "F1", - Named::F2 => "F2", - Named::F3 => "F3", - Named::F4 => "F4", - Named::F5 => "F5", - Named::F6 => "F6", - Named::F7 => "F7", - Named::F8 => "F8", - Named::F9 => "F9", - Named::F10 => "F10", - Named::F11 => "F11", - Named::F12 => "F12", - Named::ArrowUp => "ArrowUp", - Named::ArrowDown => "ArrowDown", - Named::ArrowLeft => "ArrowLeft", - Named::ArrowRight => "ArrowRight", - Named::Home => "Home", - Named::End => "End", - Named::PageUp => "PageUp", - Named::PageDown => "PageDown", - Named::Insert => "Insert", - Named::Delete => "Delete", - Named::Enter => "Enter", - Named::Escape => "Escape", - Named::Backspace => "Backspace", - Named::Tab => "Tab", - _ => return String::new(), - } - .to_string() - } - Key::Character(c) => { - let ch = c.chars().next().unwrap_or('?'); - if ch == ' ' { - "Space".to_string() - } else if ch.is_ascii_alphabetic() { - format!("Key{}", ch.to_uppercase()) - } else if ch.is_ascii_digit() { - format!("Digit{}", ch) - } else { - String::new() - } - } - Key::Unidentified => String::new(), - } -} - /// Format a hotkey combination for display -pub(super) fn format_hotkey(ctrl: bool, alt: bool, shift: bool, win: bool, key: &str) -> String { +fn format_hotkey(ctrl: bool, alt: bool, shift: bool, win: bool, key: &str) -> String { let mut parts = Vec::new(); if ctrl { parts.push("Ctrl"); @@ -186,8 +126,8 @@ pub(super) fn format_hotkey(ctrl: bool, alt: bool, shift: bool, win: bool, key: parts.join(" + ") } -impl AppModel { - pub(super) fn hotkey_mut(&mut self, id: &str) -> Option<&mut Hotkey> { +impl AppState { + fn hotkey_mut(&mut self, id: &str) -> Option<&mut Hotkey> { self.config .hotkeys .hotkeys @@ -195,12 +135,12 @@ impl AppModel { .find(|hotkey| hotkey.id == id) } - pub(super) fn action_mut(&mut self, id: &str, idx: usize) -> Option<&mut HotkeyActionSpec> { + fn action_mut(&mut self, id: &str, idx: usize) -> Option<&mut HotkeyActionSpec> { self.hotkey_mut(id) .and_then(|hotkey| hotkey.actions.get_mut(idx)) } - pub(super) fn initialize_hotkey_drafts(&mut self, id: &str) { + fn initialize_hotkey_drafts(&mut self, id: &str) { let Some(hotkey) = self .config .hotkeys @@ -220,13 +160,13 @@ impl AppModel { } } - pub(super) fn clear_hotkey_drafts(&mut self, id: &str) { + fn clear_hotkey_drafts(&mut self, id: &str) { self.value_drafts .retain(|(hotkey_id, _), _| hotkey_id != id); self.vcp_drafts.retain(|(hotkey_id, _), _| hotkey_id != id); } - pub(super) fn validate_action_drafts(&self) -> Option { + fn validate_action_drafts(&self) -> Option { for hotkey in &self.config.hotkeys.hotkeys { for (idx, action) in hotkey.actions.iter().enumerate() { // Off actions hide their value fields, so stale drafts cannot be corrected. @@ -290,23 +230,30 @@ impl AppModel { } } - pub(super) fn refresh_hotkey_actions(&mut self) { + fn refresh_hotkey_actions(&mut self) { if let Some(manager) = &mut self.hotkey_manager { manager.rebuild_action_map(&self.config); self.hotkey_action_map = manager.action_map(); } } - pub(super) fn handle_hotkey_triggered(&mut self, id: u32) -> cosmic::app::Task { - if let Some(actions) = resolve_triggered_actions( + pub(super) fn handle_hotkey_triggered(&mut self, id: u32) { + let Some(actions) = resolve_triggered_actions( self.config.hotkeys_enabled, - !matches!(self.recording_state, RecordingState::NotRecording), + self.is_recording(), self.hotkey_action_map.as_ref(), id, - ) { - return self.handle_hotkey_actions(actions); + ) else { + return; + }; + if let Err(error) = self + .action_executor + .enqueue_actions(actions, self.config.turn_off_behavior) + { + self.status_message = format!("Hotkey not dispatched: {error}"); + return; } - cosmic::app::Task::none() + self.start_next_hardware_job(); } pub(super) fn set_hotkey_label(&mut self, id: String, label: String) { @@ -346,12 +293,8 @@ impl AppModel { self.pending_hotkey_delete = Some(id); } - pub(super) fn cancel_delete_hotkey(&mut self) { - self.pending_hotkey_delete = None; - } - /// Card heading for `id`, matching the hotkey list. `None` when that hotkey is gone. - pub(super) fn hotkey_delete_title(&self, id: &str) -> Option { + pub fn hotkey_delete_title(&self, id: &str) -> Option { let headings = hotkey_headings(&self.config.hotkeys.hotkeys); self.config .hotkeys @@ -393,27 +336,10 @@ impl AppModel { self.refresh_hotkey_registration(); } - pub(super) fn key_pressed( - &mut self, - modifiers: Modifiers, - key: Key, - ) -> cosmic::app::Task { - let key_string = key_to_string(&key); - if key_string.is_empty() { - return cosmic::app::Task::none(); + pub(super) fn key_recorded(&mut self, binding: HotkeyBinding) { + if binding.key.is_empty() { + return; } - - let ctrl = modifiers.control(); - let alt = modifiers.alt(); - let shift = modifiers.shift(); - let win = modifiers.logo(); - let binding = HotkeyBinding { - ctrl, - alt, - shift, - win, - key: key_string.clone(), - }; if let RecordingState::Recording { hotkey_id, .. } = &self.recording_state { let hotkey_id = hotkey_id.clone(); let duplicate = binding.to_hotkey().is_some_and(|candidate| { @@ -427,20 +353,24 @@ impl AppModel { }); if duplicate { self.status_message = "That key combination is already assigned.".into(); - return cosmic::app::Task::none(); + return; } + let formatted = format_hotkey( + binding.ctrl, + binding.alt, + binding.shift, + binding.win, + &binding.key, + ); if let Some(hotkey) = self.hotkey_mut(&hotkey_id) { hotkey.binding = binding; } - self.status_message = format!( - "Hotkey bound to {}. Remember to save configuration.", - format_hotkey(ctrl, alt, shift, win, &key_string) - ); + self.status_message = + format!("Hotkey bound to {formatted}. Remember to save configuration."); self.recording_state = RecordingState::NotRecording; self.config_dirty = true; self.refresh_hotkey_registration(); } - cosmic::app::Task::none() } pub(super) fn add_action(&mut self, id: String) { @@ -629,35 +559,35 @@ impl AppModel { self.refresh_hotkey_actions(); } - pub(super) fn save_config(&mut self) -> cosmic::app::Task { + pub(super) fn save_config(&mut self) { if let Some(error) = self.validate_action_drafts() { self.status_message = error; - return cosmic::app::Task::none(); + return; } - self.enqueue_config_op(ConfigOp::Save) + self.enqueue_config_op(ConfigOp::Save); } - pub(super) fn toggle_hotkeys(&mut self, enabled: bool) -> cosmic::app::Task { - self.enqueue_config_op(ConfigOp::SetHotkeysEnabled(enabled)) + pub(super) fn toggle_hotkeys(&mut self, enabled: bool) { + self.enqueue_config_op(ConfigOp::SetHotkeysEnabled(enabled)); } - pub(super) fn retry_config(&mut self) -> cosmic::app::Task { + pub(super) fn retry_config(&mut self) { self.pending_config_reset = false; - self.enqueue_config_op(ConfigOp::Retry) + self.enqueue_config_op(ConfigOp::Retry); } - pub(super) fn recover_config_backup(&mut self) -> cosmic::app::Task { + pub(super) fn recover_config_backup(&mut self) { self.pending_config_reset = false; - self.enqueue_config_op(ConfigOp::RecoverBackup) + self.enqueue_config_op(ConfigOp::RecoverBackup); } - pub(super) fn confirm_reset_config(&mut self) -> cosmic::app::Task { + pub(super) fn confirm_reset_config(&mut self) { if !std::mem::take(&mut self.pending_config_reset) || self.config_store.recovery_error().is_none() { - return cosmic::app::Task::none(); + return; } - self.enqueue_config_op(ConfigOp::Reset) + self.enqueue_config_op(ConfigOp::Reset); } pub(super) fn install_recovered_config(&mut self, config: AppConfig, status: &str) { @@ -689,9 +619,7 @@ impl AppModel { self.initialize_hotkey_drafts(&id); self.expanded_hotkey = Some(id.clone()); self.recording_state = RecordingState::Recording { hotkey_id: id }; - if let Some(position) = self.nav_position_of(Page::Hotkeys) { - self.nav.activate_position(position); - } + self.emit(Effect::ShowHotkeys); self.config_dirty = true; } } diff --git a/src/app/monitors.rs b/data/src/state/monitors.rs similarity index 50% rename from src/app/monitors.rs rename to data/src/state/monitors.rs index 673daaf..d278b93 100644 --- a/src/app/monitors.rs +++ b/data/src/state/monitors.rs @@ -1,8 +1,7 @@ -use super::debounce::{DebounceToken, SliderFeature}; -use super::{AppModel, Message, Page}; +use super::{AppState, Effect, Input, SLIDER_DEBOUNCE}; +use crate::actions::HardwareJob; use crate::ddc::{InputSource, MonitorInfo, MonitorKey, MonitorState}; -use cosmic::Application; -use cosmic::widget::nav_bar; +use crate::debounce::{DebounceToken, SliderFeature}; fn mark_monitor_read_failed(monitors: &mut [MonitorState], key: &MonitorKey, error: &str) { if let Some(monitor) = monitors.iter_mut().find(|monitor| &monitor.info.key == key) { @@ -12,67 +11,36 @@ fn mark_monitor_read_failed(monitors: &mut [MonitorState], key: &MonitorKey, err } } -impl AppModel { - pub(super) fn refresh_monitors(&mut self) -> cosmic::app::Task { +impl AppState { + pub(super) fn refresh_monitors(&mut self) { self.action_executor.refresh(); self.sync_hardware_generation(); self.status_message = "Detecting monitors (waiting drafts and jobs canceled)...".into(); - self.start_next_hardware_job() + self.start_next_hardware_job(); } - pub(super) fn retry_monitor(&mut self, monitor_id: u32) -> cosmic::app::Task { + pub(super) fn retry_monitor(&mut self, monitor_id: u32) { if !self.action_executor.retry(monitor_id) { - return self.update(Message::RefreshMonitors); + self.refresh_monitors(); + return; } // Keep the previous error visible until a serialized read succeeds. - self.start_next_hardware_job() + self.start_next_hardware_job(); } - pub(super) fn monitors_detected( - &mut self, - generation: u64, - infos: Vec, - ) -> cosmic::app::Task { + pub(super) fn monitors_detected(&mut self, generation: u64, infos: Vec) { if generation != self.monitor_generation { - return cosmic::app::Task::none(); + return; } // Also discard edits made while detection was in progress: IDs may now // refer to different monitors. Invalidation never resets draft revisions. self.slider_debounce.invalidate(); - self.detected_monitors = infos.clone(); + self.status_message = format!("{} monitor(s) detected", infos.len()); + self.detected_monitors = infos; self.monitors.clear(); self.monitor_load_errors.clear(); - // Rebuild nav bar - self.nav = nav_bar::Model::default(); - for info in &infos { - let label = if info.name.is_empty() { - format!("Monitor {}", info.id) - } else { - format!("{} ({}x{})", info.name, info.width, info.height) - }; - self.nav - .insert() - .text(label) - .data::(Page::Monitor(info.id)); - } - // Hotkeys page - self.nav - .insert() - .text("Hotkeys") - .data::(Page::Hotkeys); - self.nav - .insert() - .text("Profiles") - .data::(Page::Profiles); - self.nav.insert().text("About").data::(Page::About); - - // Activate first monitor - self.nav.activate_position(0); - - self.status_message = format!("{} monitor(s) detected", infos.len()); - + self.emit(Effect::MonitorsDetected); // Initial reads were inserted into the FIFO by the coordinator. - cosmic::app::Task::none() } pub(super) fn monitor_state_loaded( @@ -80,15 +48,15 @@ impl AppModel { generation: u64, key: MonitorKey, mut state: MonitorState, - ) -> cosmic::app::Task { + ) { if generation != self.monitor_generation { - return cosmic::app::Task::none(); + return; } let Some(info) = self.detected_monitors.iter().find(|m| m.key == key) else { - return cosmic::app::Task::none(); + return; }; if state.info.key != key { - return cosmic::app::Task::none(); + return; } state.info = info.clone(); self.monitor_load_errors.remove(&info.id); @@ -99,55 +67,33 @@ impl AppModel { self.monitors.push(state); } self.monitors.sort_by_key(|m| m.info.id); - cosmic::app::Task::none() } - pub(super) fn monitor_state_failed( - &mut self, - generation: u64, - key: MonitorKey, - error: String, - ) -> cosmic::app::Task { + pub(super) fn monitor_state_failed(&mut self, generation: u64, key: MonitorKey, error: String) { if generation != self.monitor_generation { - return cosmic::app::Task::none(); + return; } let Some(info) = self.detected_monitors.iter().find(|m| m.key == key) else { - return cosmic::app::Task::none(); + return; }; let id = info.id; mark_monitor_read_failed(&mut self.monitors, &key, &error); self.monitor_load_errors.insert(id, error.clone()); self.status_message = format!("Could not read monitor {id}: {error}"); - cosmic::app::Task::none() } - pub(super) fn slider_changed( - &mut self, - monitor_id: u32, - feature: SliderFeature, - value: u16, - ) -> cosmic::app::Task { + pub(super) fn slider_changed(&mut self, monitor_id: u32, feature: SliderFeature, value: u16) { if !self.action_executor.accepts_monitor(monitor_id) { - return cosmic::app::Task::none(); + return; } let token = self.slider_debounce .record(self.monitor_generation, monitor_id, feature, value); - - cosmic::app::Task::perform( - async move { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - token - }, - move |token| { - cosmic::Action::App(match feature { - SliderFeature::Brightness => { - Message::ApplyBrightnessDebounced(monitor_id, token) - } - SliderFeature::Contrast => Message::ApplyContrastDebounced(monitor_id, token), - }) - }, - ) + let apply = match feature { + SliderFeature::Brightness => Input::ApplyBrightnessDebounced(monitor_id, token), + SliderFeature::Contrast => Input::ApplyContrastDebounced(monitor_id, token), + }; + self.emit(Effect::Delay(SLIDER_DEBOUNCE, apply)); } pub(super) fn apply_slider_debounced( @@ -155,60 +101,37 @@ impl AppModel { monitor_id: u32, feature: SliderFeature, token: DebounceToken, - ) -> cosmic::app::Task { + ) { let Some(value) = self.slider_debounce .take_current(self.monitor_generation, monitor_id, feature, token) else { - return cosmic::app::Task::none(); + return; }; - match feature { - SliderFeature::Brightness => self.set_brightness(monitor_id, value), - SliderFeature::Contrast => self.set_contrast(monitor_id, value), - } - } - - pub(super) fn set_brightness( - &mut self, - monitor_id: u32, - value: u16, - ) -> cosmic::app::Task { - self.enqueue_hardware_jobs([super::HardwareJob::SetBrightness { monitor_id, value }]) - } - - pub(super) fn set_contrast( - &mut self, - monitor_id: u32, - value: u16, - ) -> cosmic::app::Task { - self.enqueue_hardware_jobs([super::HardwareJob::SetContrast { monitor_id, value }]) + let job = match feature { + SliderFeature::Brightness => HardwareJob::SetBrightness { monitor_id, value }, + SliderFeature::Contrast => HardwareJob::SetContrast { monitor_id, value }, + }; + self.enqueue_hardware_jobs([job]); } - pub(super) fn set_input_source( - &mut self, - monitor_id: u32, - source: InputSource, - ) -> cosmic::app::Task { - self.enqueue_hardware_jobs([super::HardwareJob::SetInputSource { monitor_id, source }]) + pub(super) fn set_input_source(&mut self, monitor_id: u32, source: InputSource) { + self.enqueue_hardware_jobs([HardwareJob::SetInputSource { monitor_id, source }]); } } #[cfg(test)] mod tests { use super::*; - use crate::app::monitor_views::{PendingValue, input_presentation, scalar_presentation}; - use crate::ddc::tests::monitor_state; + use crate::ddc::test_util::{key, monitor_state}; + use crate::presentation::{PendingValue, input_presentation, scalar_presentation}; #[test] fn whole_read_failure_marks_only_the_affected_monitor_unknown() { let mut monitors = [monitor_state(), monitor_state()]; monitors[1].info.id = 2; - monitors[1].info.key = crate::ddc::tests::key(2); - mark_monitor_read_failed( - &mut monitors, - &crate::ddc::tests::key(1), - "monitor disconnected", - ); + monitors[1].info.key = key(2); + mark_monitor_read_failed(&mut monitors, &key(1), "monitor disconnected"); let monitor = &monitors[0]; for (value, maximum, error) in [ ( diff --git a/data/src/state/profiles.rs b/data/src/state/profiles.rs new file mode 100644 index 0000000..67d241a --- /dev/null +++ b/data/src/state/profiles.rs @@ -0,0 +1,68 @@ +use super::{AppState, Effect, Job, JobKind}; +use crate::actions::HardwareJob; + +impl AppState { + pub(super) fn refresh_profiles(&mut self) { + self.profiles_request = self.profiles_request.wrapping_add(1); + let request = self.profiles_request; + self.emit(Effect::Spawn(Job(JobKind::ListProfiles(request)))); + } + + pub(super) fn profiles_listed(&mut self, request: u64, listed: Result, String>) { + if request != self.profiles_request { + return; + } + match listed { + Ok(profiles) => { + self.profiles = profiles; + self.emit(Effect::ProfilesUpdated); + } + Err(error) => { + log::error!("{error}"); + self.status_message = error; + } + } + } + + pub(super) fn save_current_profile(&mut self, name: String) { + let name = name.trim().to_string(); + if name.is_empty() { + self.status_message = "Enter a profile name first.".into(); + return; + } + // The worker reports an existing profile, which prompts for replacement. + self.pending_profile_replace = None; + self.status_message = format!("Saving profile '{name}'..."); + self.enqueue_hardware_jobs([HardwareJob::SaveProfile { + name, + replace: false, + }]); + } + + pub(super) fn confirm_replace_profile(&mut self) { + let Some(name) = self.pending_profile_replace.take() else { + return; + }; + self.status_message = format!("Replacing profile '{name}'..."); + self.enqueue_hardware_jobs([HardwareJob::SaveProfile { + name, + replace: true, + }]); + } + + pub(super) fn apply_profile(&mut self, name: String) { + self.status_message = format!("Applying profile '{name}'..."); + self.enqueue_hardware_jobs([HardwareJob::ApplyProfile { name }]); + } + + pub(super) fn request_delete_profile(&mut self, name: String) { + self.pending_hotkey_delete = None; + self.pending_profile_delete = Some(name); + } + + pub(super) fn delete_profile(&mut self, name: String) { + self.pending_profile_delete = None; + self.status_message = format!("Deleting profile '{name}'..."); + self.enqueue_hardware_jobs([HardwareJob::DeleteProfile { name }]); + } +} diff --git a/src/tray.rs b/data/src/tray.rs similarity index 78% rename from src/tray.rs rename to data/src/tray.rs index 6a7f6e2..b07067c 100644 --- a/src/tray.rs +++ b/data/src/tray.rs @@ -2,12 +2,6 @@ //! //! Provides a tray icon with menu options to show the window or exit the application. -use cosmic::iced::{ - Subscription, - futures::{SinkExt, Stream}, - stream, -}; -use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use tray_icon::{ @@ -30,7 +24,7 @@ pub enum TrayMessage { Exit, } -/// Async stream wrapper for receiving tray messages. +/// Async receiver for tray messages, shared so a restarted listener can resume. #[derive(Clone)] pub struct TrayStream { receiver: Arc>>, @@ -159,42 +153,9 @@ impl SystemTray { } impl TrayStream { - /// Build an iced `Subscription` that yields tray messages. - pub fn subscription(self) -> Subscription { - Subscription::run_with(TrayId(self), |data| { - data.0.clone().into_subscription_stream() - }) - } - - /// Convert this into an async stream suitable for iced subscriptions. - pub fn into_subscription_stream(self) -> impl Stream { - let receiver_arc = self.receiver.clone(); - - stream::channel( - 1, - |mut sender: cosmic::iced::futures::channel::mpsc::Sender| async move { - loop { - let mut receiver = receiver_arc.lock().await; - if let Some(msg) = receiver.recv().await { - if sender.send(msg).await.is_err() { - break; - } - } else { - break; - } - } - }, - ) - } -} - -/// Identity wrapper so a `TrayStream` can be used as `Subscription` data. -/// There is only ever one tray, so the identity is constant. -struct TrayId(TrayStream); - -impl Hash for TrayId { - fn hash(&self, state: &mut H) { - "system-tray".hash(state); + /// Next tray message; `None` once the tray is gone. + pub async fn recv(&self) -> Option { + self.receiver.lock().await.recv().await } } diff --git a/hardware/Cargo.toml b/hardware/Cargo.toml new file mode 100644 index 0000000..9bcbffa --- /dev/null +++ b/hardware/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hardware" +description = "CCD display layout and DDC/CI monitor access" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +test-util = [] + +[dependencies] +ddc.workspace = true +ddc-winapi.workspace = true +serde.workspace = true +thiserror.workspace = true +log.workspace = true +windows-sys.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/src/ccd.rs b/hardware/src/ccd.rs similarity index 99% rename from src/ccd.rs rename to hardware/src/ccd.rs index ee9790b..e3480a4 100644 --- a/src/ccd.rs +++ b/hardware/src/ccd.rs @@ -2,8 +2,8 @@ //! full monitor-layout profiles. //! //! Mirrors the structure of [`crate::ddc`]: a [`thiserror`] error enum, plain -//! serde data structs, and blocking functions. The application runs these via -//! `tokio::task::spawn_blocking` wrapped in `cosmic::app::Task::perform`. +//! serde data structs, and blocking functions. The application runs these off +//! the UI thread via `tokio::task::spawn_blocking`. //! //! Adapter LUIDs and endpoint IDs are not persistent monitor identities. Saved //! configurations are conservatively remapped by monitor device path before diff --git a/src/ccd/remap.rs b/hardware/src/ccd/remap.rs similarity index 100% rename from src/ccd/remap.rs rename to hardware/src/ccd/remap.rs diff --git a/src/ccd/remap/tests.rs b/hardware/src/ccd/remap/tests.rs similarity index 100% rename from src/ccd/remap/tests.rs rename to hardware/src/ccd/remap/tests.rs diff --git a/src/ddc.rs b/hardware/src/ddc.rs similarity index 98% rename from src/ddc.rs rename to hardware/src/ddc.rs index 583172c..0807e4c 100644 --- a/src/ddc.rs +++ b/hardware/src/ddc.rs @@ -250,7 +250,7 @@ pub const STANDARD_POWER_MODES: &[PowerMode] = &[ PowerMode::Off, ]; -pub(crate) const NO_SHARED_OPTION_NOTE: &str = "Selected displays do not share this option."; +pub const NO_SHARED_OPTION_NOTE: &str = "Selected displays do not share this option."; /// One VCP code from an MCCS capabilities string. /// @@ -268,7 +268,7 @@ pub struct MonitorCapabilities { } impl MonitorCapabilities { - pub(crate) fn contains(&self, code: u8) -> bool { + pub fn contains(&self, code: u8) -> bool { self.features.iter().any(|feature| feature.code == code) } @@ -276,7 +276,7 @@ impl MonitorCapabilities { /// /// `None` means the code is absent or present without a value list. /// Check [`Self::contains`] to tell those cases apart. - pub(crate) fn discrete_values(&self, code: u8) -> Option<&[u16]> { + pub fn discrete_values(&self, code: u8) -> Option<&[u16]> { self.features .iter() .find(|feature| feature.code == code) @@ -286,7 +286,7 @@ impl MonitorCapabilities { /// Dropdown contents. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct FeatureOptions { +pub struct FeatureOptions { pub options: Vec, /// Selected monitors advertise no value in common. `options` is only the saved value. pub no_shared_option: bool, @@ -419,7 +419,7 @@ fn vcp_tokens(section: &str) -> Vec { /// /// `None` uses the standard HDMI, DisplayPort, and USB-C list. The current /// source is appended when it is not already present. -pub(crate) fn input_choices( +pub fn input_choices( advertised: Option<&[InputSource]>, current: Option, ) -> FeatureOptions { @@ -436,7 +436,7 @@ pub(crate) fn input_choices( /// Inputs shared by the selected monitors. /// /// A `None` list contributes the standard sources and does not shrink the result. -pub(crate) fn shared_input_choices( +pub fn shared_input_choices( advertised: &[Option>], current: Option, ) -> FeatureOptions { @@ -464,7 +464,7 @@ pub(crate) fn shared_input_choices( } /// Standard power modes, plus the current mode when it is not already listed. -pub(crate) fn power_options(current: Option) -> FeatureOptions { +pub fn power_options(current: Option) -> FeatureOptions { FeatureOptions { options: append_current(STANDARD_POWER_MODES.to_vec(), current), no_shared_option: false, @@ -620,7 +620,7 @@ fn resolve_endpoint<'a, M>( } /// Narrow transport seam used by both real operations and deterministic tests. -pub(crate) trait Vcp { +pub trait Vcp { fn read(&mut self, code: u8) -> Result<(u16, u16)>; fn write(&mut self, code: u8, value: u16) -> Result<()>; fn capabilities(&mut self) -> Result { @@ -691,7 +691,7 @@ fn validate_targets_in(endpoints: &mut [Endpoint], keys: &[MonitorKey]) -> } /// Job-local ownership: raw handles never cross a task/thread boundary. -pub(crate) struct Session { +pub struct Session { endpoints: Vec>, } @@ -786,7 +786,7 @@ fn advertised_input_sources(raw: &str) -> Option> { // Helpers // --------------------------------------------------------------------------- -pub(crate) fn decode_input_source(raw_value: u16) -> InputSource { +pub fn decode_input_source(raw_value: u16) -> InputSource { // MCCS 2.2a, Table 8-13: scalar VCP 0x60 uses SL; SH/MH/ML are reserved. // https://milek7.pl/ddcbacklight/mccs.pdf#page=81 // The transport packs SH/SL into value(), so decode only SL for this feature. @@ -805,15 +805,15 @@ fn scalar_reading(result: std::result::Result<(u16, u16), String>) -> (u16, u16, (0, 100, Some(error)) } -#[cfg(test)] -pub(crate) mod tests { +#[cfg(any(test, feature = "test-util"))] +pub mod test_util { use super::*; - pub(crate) fn key(id: u32) -> MonitorKey { + pub fn key(id: u32) -> MonitorKey { MonitorKey::from_device_path(&format!(r"\\?\DISPLAY#model#{id}")).unwrap() } - pub(crate) fn monitor_state() -> MonitorState { + pub fn monitor_state() -> MonitorState { MonitorState { info: MonitorInfo { id: 1, @@ -838,11 +838,11 @@ pub(crate) mod tests { } #[derive(Clone, Default)] - pub(crate) struct FakeVcp { - reading: (u16, u16), - fail_read: bool, - reads: Vec, - pub(crate) writes: std::rc::Rc>>, + pub struct FakeVcp { + pub reading: (u16, u16), + pub fail_read: bool, + pub reads: Vec, + pub writes: std::rc::Rc>>, } impl Vcp for FakeVcp { @@ -859,7 +859,7 @@ pub(crate) mod tests { } } - pub(crate) fn fake_session( + pub fn fake_session( monitors: &[(MonitorInfo, FakeVcp)], required: &[MonitorKey], ) -> Result> { @@ -874,6 +874,12 @@ pub(crate) mod tests { required, ) } +} + +#[cfg(test)] +mod tests { + use super::test_util::*; + use super::*; fn endpoint(id: u32) -> Endpoint { let mut info = monitor_state().info; diff --git a/hardware/src/lib.rs b/hardware/src/lib.rs new file mode 100644 index 0000000..d380682 --- /dev/null +++ b/hardware/src/lib.rs @@ -0,0 +1,2 @@ +pub mod ccd; +pub mod ddc; diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index f6f3492..0000000 --- a/src/app.rs +++ /dev/null @@ -1,807 +0,0 @@ -mod actions; -mod config_ops; -mod coordination; -mod debounce; -mod hardware_queue; -mod hotkey_editor; -mod hotkey_views; -mod modal; -mod monitor_views; -mod monitors; -mod profile_handlers; -mod profile_views; -mod settings_views; -mod views; - -use self::actions::HardwareJob; -use self::coordination::{HardwareCoordinator, HardwareResult}; -use self::debounce::{DebounceToken, SliderDebounce, SliderFeature}; - -use crate::config::{ - ActionTarget, ActionType, AppConfig, ConfigStore, HotkeyActionSpec, MonitorTarget, - TurnOffBehavior, -}; -use crate::ddc::{InputSource, MonitorInfo, MonitorKey, MonitorState, PowerMode}; -use crate::hotkeys::{self, HotkeyManager}; -use crate::persistence::LoadOutcome; -use crate::startup; -use crate::tray::{SystemTray, TrayMessage, TrayStream}; -use cosmic::iced::event::{self, Event}; -use cosmic::iced::keyboard::{Event as KeyboardEvent, Key, Modifiers}; -use cosmic::iced::{Length, Subscription, window}; -use cosmic::prelude::*; -use cosmic::widget::{self, nav_bar}; -use cosmic::{Core, executor}; -use std::collections::HashMap; -use std::sync::Arc; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -// Simple inline SVG icon (monitor symbol) -const APP_ICON: &[u8] = br#" - - - - - -"#; - -// --------------------------------------------------------------------------- -// Navigation pages -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Page { - Monitor(u32), // 1-indexed monitor ID - Hotkeys, - Profiles, - About, -} - -// --------------------------------------------------------------------------- -// Messages -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -pub enum Message { - // Monitor controls - RefreshMonitors, - RetryMonitor(u32), - SelectInputSource(u32, InputSource), - HardwareJobFinished(u64, Result), - // Debounced slider changes - BrightnessSliderChanged(u32, u16), - ContrastSliderChanged(u32, u16), - ApplyBrightnessDebounced(u32, DebounceToken), - ApplyContrastDebounced(u32, DebounceToken), - // Hotkeys - HotkeyTriggered(u32), - ToggleHotkeys(bool), - AddHotkey, - SetHotkeyLabel(String, String), - ToggleHotkeyEditor(String), - RequestDeleteHotkey(String), - CancelDeleteHotkey, - DeleteHotkey(String), - StartRecording(String), - CancelRecording, - ClearBinding(String), - KeyPressed(Modifiers, Key), - AddAction(String), - DeleteAction(String, usize), - SetActionType(String, usize, ActionType), - SetActionTarget(String, usize, ActionTarget), - ActionValueDraftChanged(String, usize, String), - ActionVcpDraftChanged(String, usize, String), - SetActionInputSource(String, usize, InputSource), - SetMonitorInput(String, usize, MonitorTarget, Option), - SetActionPowerMode(String, usize, PowerMode), - SetActionProfile(String, usize, String), - ToggleActionAllMonitors(String, usize, bool), - ToggleActionMonitor(String, usize, MonitorTarget, bool), - RebindMonitorTarget(String, usize, MonitorTarget, MonitorKey), - RemoveMonitorTarget(String, usize, MonitorTarget), - SetTurnOffBehavior(TurnOffBehavior), - ToggleStartWithWindows(bool), - ToggleStartMinimized(bool), - SaveConfig, - ToggleSettings, - CloseSettings, - RetryConfig, - RecoverConfigBackup, - RequestResetConfig, - CancelResetConfig, - ConfirmResetConfig, - ConfigOpFinished(config_ops::ConfigOp, config_ops::ConfigOpDone), - // Profiles - RefreshProfiles, - ProfilesListed(u64, Result, String>), - ProfileNameInput(String), - SaveCurrentProfile(String), - ConfirmReplaceProfile, - CancelReplaceProfile, - ApplyProfile(String), - RequestDeleteProfile(String), - CancelDeleteProfile, - DeleteProfile(String), - AddProfileHotkey(String), - // System tray - Tray(TrayMessage), - /// Hide window (close-to-tray) - HideWindow, - WindowClosed(window::Id), - OpenUrl(String), - /// Discard edits to the read-only configuration path field. - ConfigPathInput, - CopyConfigPath, -} - -// --------------------------------------------------------------------------- -// Hotkey recording state -// --------------------------------------------------------------------------- - -#[derive(Clone, Debug)] -pub enum RecordingState { - NotRecording, - Recording { hotkey_id: String }, -} - -// --------------------------------------------------------------------------- -// Application model -// --------------------------------------------------------------------------- - -pub struct AppModel { - core: Core, - nav: nav_bar::Model, - monitor_generation: u64, - detected_monitors: Vec, - monitors: Vec, - monitor_load_errors: HashMap, - config: AppConfig, - config_store: ConfigStore, - config_ops: std::collections::VecDeque, - config_op_active: bool, - pending_config_reset: bool, - hotkey_manager: Option, - hotkey_action_map: Arc>>, - hotkey_status: HashMap, - hotkey_generation: u64, - status_message: String, - recording_state: RecordingState, - expanded_hotkey: Option, - pending_hotkey_delete: Option, - value_drafts: HashMap<(String, usize), String>, - vcp_drafts: HashMap<(String, usize), String>, - config_dirty: bool, - config_path: String, - about: widget::about::About, - slider_debounce: SliderDebounce, - profiles: Vec, - profiles_request: u64, - profile_name_input: String, - pending_profile_delete: Option, - pending_profile_replace: Option, - action_executor: HardwareCoordinator, - refresh_profiles_after_jobs: bool, - // System tray - tray: Option<(SystemTray, TrayStream)>, -} - -// --------------------------------------------------------------------------- -// cosmic::Application implementation -// --------------------------------------------------------------------------- - -impl cosmic::Application for AppModel { - type Executor = executor::Default; - type Flags = (); - type Message = Message; - - const APP_ID: &'static str = "com.windisplaymanager.app"; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - fn init(core: Core, _flags: Self::Flags) -> (Self, cosmic::app::Task) { - // Load persistent config - let (config_store, outcome) = match AppConfig::config_path() { - Ok(path) => ConfigStore::open(path), - Err(error) => ConfigStore::unavailable(error), - }; - let config = match outcome { - LoadOutcome::Missing => AppConfig::default(), - LoadOutcome::Loaded(config) => config, - LoadOutcome::Failed(error) => { - log::error!("Configuration requires recovery: {error}"); - AppConfig::recovery_placeholder() - } - }; - // Keep the Run key aligned with saved settings. Skip recovery placeholders - // so a failed load cannot delete a registration the user already chose. - if config_store.recovery_error().is_none() - && let Err(error) = startup::apply(config.start_with_windows, config.start_minimized) - { - log::warn!("Failed to sync Windows startup registration: {error}"); - } - - // Set up hotkey manager - let hotkey_manager = HotkeyManager::new(&config); - let hotkey_action_map = hotkey_manager - .as_ref() - .map(|m| m.action_map()) - .unwrap_or_else(|| Arc::new(HashMap::new())); - let hotkey_status = hotkey_manager - .as_ref() - .map(|m| m.status()) - .unwrap_or_default(); - - let mut value_drafts = HashMap::new(); - let mut vcp_drafts = HashMap::new(); - for hotkey in &config.hotkeys.hotkeys { - for (idx, action) in hotkey.actions.iter().enumerate() { - value_drafts.insert((hotkey.id.clone(), idx), action.value.to_string()); - vcp_drafts.insert( - (hotkey.id.clone(), idx), - format!("0x{:02X}", action.vcp_code), - ); - } - } - - // Build nav model with a placeholder; will be rebuilt after detection - let mut nav = nav_bar::Model::default(); - nav.insert() - .text("Detecting monitors...") - .data::(Page::Hotkeys) - .activate(); - - let about = widget::about::About::default() - .name("Windows Display Manager") - .icon(widget::icon::from_svg_bytes(APP_ICON)) - .version(env!("CARGO_PKG_VERSION")) - .comments("DDC/CI monitor control with global hotkeys."); - - // Set up system tray - let tray = match SystemTray::new() { - Ok(t) => { - log::info!("System tray created successfully"); - Some(t) - } - Err(e) => { - log::warn!("Failed to create system tray: {e}"); - None - } - }; - - let config_path = config_store.path().display().to_string(); - let mut app = AppModel { - core, - nav, - monitor_generation: 0, - detected_monitors: Vec::new(), - monitors: Vec::new(), - monitor_load_errors: HashMap::new(), - config, - config_store, - config_ops: Default::default(), - config_op_active: false, - pending_config_reset: false, - hotkey_manager, - hotkey_action_map, - hotkey_status, - hotkey_generation: 0, - status_message: "Starting...".into(), - recording_state: RecordingState::NotRecording, - expanded_hotkey: None, - pending_hotkey_delete: None, - value_drafts, - vcp_drafts, - config_dirty: false, - config_path, - about, - slider_debounce: SliderDebounce::default(), - profiles: Vec::new(), - profiles_request: 0, - profile_name_input: String::new(), - pending_profile_delete: None, - pending_profile_replace: None, - action_executor: HardwareCoordinator::default(), - refresh_profiles_after_jobs: false, - tray, - }; - - let cmd = cosmic::app::Task::batch([ - app.update(Message::RefreshMonitors), - app.update(Message::RefreshProfiles), - ]); - (app, cmd) - } - - fn nav_model(&self) -> Option<&nav_bar::Model> { - Some(&self.nav) - } - - fn on_nav_select(&mut self, id: nav_bar::Id) -> cosmic::app::Task { - self.nav.activate(id); - self.update_title() - } - - fn dialog(&self) -> Option> { - if let Some(name) = self.pending_profile_delete.clone() { - return Some(modal::confirm_dialog( - "Delete profile?", - format!("Delete \"{name}\"? This removes the saved layout."), - "Delete", - Message::CancelDeleteProfile, - Message::DeleteProfile(name), - )); - } - let id = self.pending_hotkey_delete.clone()?; - let title = self.hotkey_delete_title(&id)?; - Some(modal::confirm_dialog( - "Delete hotkey?", - format!("Delete \"{title}\"? This removes the hotkey and its actions."), - "Delete", - Message::CancelDeleteHotkey, - Message::DeleteHotkey(id), - )) - } - - fn on_escape(&mut self) -> cosmic::app::Task { - if self.pending_profile_delete.is_some() { - self.cancel_delete_profile(); - } - if self.pending_hotkey_delete.is_some() { - self.cancel_delete_hotkey(); - } - cosmic::app::Task::none() - } - - // Intercept the header-bar close button → hide to tray instead of exiting - fn on_app_exit(&mut self) -> Option { - if self.tray.is_some() { - Some(Message::HideWindow) - } else { - None // no tray → exit normally - } - } - - // Intercept window surface close (e.g. Alt+F4) → hide to tray - fn on_close_requested(&self, id: window::Id) -> Option { - if self.tray.is_some() && self.core.main_window_id().is_some_and(|main| main == id) { - return Some(Message::HideWindow); - } - None - } - - // ----------------------------------------------------------------------- - // Subscriptions - // ----------------------------------------------------------------------- - - fn subscription(&self) -> Subscription { - let mut subs: Vec> = Vec::new(); - - // Global hotkey polling subscription (only when enabled) - if self.config.hotkeys_enabled && !self.hotkey_action_map.is_empty() { - let registered_ids = self.hotkey_action_map.keys().copied().collect(); - subs.push( - hotkeys::hotkey_subscription(registered_ids, self.hotkey_generation) - .map(Message::HotkeyTriggered), - ); - } - - // Keyboard event subscription when recording hotkeys - if !matches!(self.recording_state, RecordingState::NotRecording) { - subs.push(event::listen_with(|event, _status, _id| { - if let Event::Keyboard(KeyboardEvent::KeyPressed { key, modifiers, .. }) = event { - Some(Message::KeyPressed(modifiers, key)) - } else { - None - } - })); - } - - // System tray subscription - if let Some((_, ref tray_stream)) = self.tray { - subs.push(tray_stream.clone().subscription().map(Message::Tray)); - } - - subs.push(event::listen_with(|event, _status, id| { - if let Event::Window(window::Event::Closed) = event { - Some(Message::WindowClosed(id)) - } else { - None - } - })); - - Subscription::batch(subs) - } - - // ----------------------------------------------------------------------- - // Update - // ----------------------------------------------------------------------- - - fn update(&mut self, message: Self::Message) -> cosmic::app::Task { - // The store also guards saves; this gate prevents editing or executing - // placeholder settings while a failed load awaits the user's decision. - if self - .pending_hotkey_delete - .as_ref() - .is_some_and(|id| self.hotkey_delete_title(id).is_none()) - { - self.pending_hotkey_delete = None; - } - - if self.config_store.recovery_error().is_some() - && matches!( - message, - Message::HotkeyTriggered(_) - | Message::ToggleHotkeys(_) - | Message::SaveConfig - | Message::AddHotkey - | Message::SetHotkeyLabel(_, _) - | Message::RequestDeleteHotkey(_) - | Message::DeleteHotkey(_) - | Message::StartRecording(_) - | Message::ClearBinding(_) - | Message::KeyPressed(_, _) - | Message::AddAction(_) - | Message::DeleteAction(_, _) - | Message::SetActionType(_, _, _) - | Message::SetActionTarget(_, _, _) - | Message::ActionValueDraftChanged(_, _, _) - | Message::ActionVcpDraftChanged(_, _, _) - | Message::SetActionInputSource(_, _, _) - | Message::SetMonitorInput(_, _, _, _) - | Message::SetActionPowerMode(_, _, _) - | Message::SetActionProfile(_, _, _) - | Message::ToggleActionAllMonitors(_, _, _) - | Message::ToggleActionMonitor(_, _, _, _) - | Message::RebindMonitorTarget(_, _, _, _) - | Message::RemoveMonitorTarget(_, _, _) - | Message::SetTurnOffBehavior(_) - | Message::ToggleStartWithWindows(_) - | Message::ToggleStartMinimized(_) - | Message::AddProfileHotkey(_) - ) - { - self.status_message = - "Configuration is read-only until you retry, recover a backup, or confirm a reset." - .into(); - return cosmic::app::Task::none(); - } - match message { - // -- Monitor detection ------------------------------------------ - Message::RefreshMonitors => return self.refresh_monitors(), - Message::RetryMonitor(monitor_id) => return self.retry_monitor(monitor_id), - - // -- Brightness ------------------------------------------------- - Message::BrightnessSliderChanged(monitor_id, value) => { - return self.slider_changed(monitor_id, SliderFeature::Brightness, value); - } - Message::ApplyBrightnessDebounced(monitor_id, token) => { - return self.apply_slider_debounced(monitor_id, SliderFeature::Brightness, token); - } - - // -- Contrast --------------------------------------------------- - Message::ContrastSliderChanged(monitor_id, value) => { - return self.slider_changed(monitor_id, SliderFeature::Contrast, value); - } - Message::ApplyContrastDebounced(monitor_id, token) => { - return self.apply_slider_debounced(monitor_id, SliderFeature::Contrast, token); - } - - // -- Input source ----------------------------------------------- - Message::SelectInputSource(monitor_id, source) => { - return self.set_input_source(monitor_id, source); - } - - Message::HardwareJobFinished(id, result) => { - return self.hardware_job_finished(id, result); - } - Message::RebindMonitorTarget(id, idx, old, key) => { - self.rebind_monitor_target(id, idx, old, key) - } - Message::RemoveMonitorTarget(id, idx, target) => { - self.remove_monitor_target(id, idx, target) - } - - // -- Hotkey actions --------------------------------------------- - Message::HotkeyTriggered(id) => return self.handle_hotkey_triggered(id), - Message::AddHotkey => self.add_hotkey(), - Message::SetHotkeyLabel(id, label) => self.set_hotkey_label(id, label), - Message::ToggleHotkeyEditor(id) => self.toggle_hotkey_editor(id), - Message::RequestDeleteHotkey(id) => self.request_delete_hotkey(id), - Message::CancelDeleteHotkey => self.cancel_delete_hotkey(), - Message::DeleteHotkey(id) => self.delete_hotkey(id), - Message::StartRecording(hotkey_id) => self.start_recording(hotkey_id), - Message::CancelRecording => self.cancel_recording(), - Message::ClearBinding(id) => self.clear_binding(id), - Message::KeyPressed(modifiers, key) => return self.key_pressed(modifiers, key), - - Message::AddAction(id) => self.add_action(id), - Message::DeleteAction(id, idx) => self.delete_action(id, idx), - Message::SetActionType(id, idx, action_type) => { - self.set_action_type(id, idx, action_type) - } - Message::SetActionTarget(id, idx, target) => self.set_action_target(id, idx, target), - Message::ActionValueDraftChanged(id, idx, draft) => { - self.action_value_draft_changed(id, idx, draft) - } - Message::ActionVcpDraftChanged(id, idx, draft) => { - self.action_vcp_draft_changed(id, idx, draft) - } - Message::SetActionInputSource(id, idx, source) => { - self.set_action_input_source(id, idx, source) - } - Message::SetMonitorInput(id, idx, monitor_id, source) => { - self.set_monitor_input(id, idx, monitor_id, source) - } - Message::SetActionPowerMode(id, idx, mode) => self.set_action_power_mode(id, idx, mode), - Message::SetActionProfile(id, idx, name) => self.set_action_profile(id, idx, name), - Message::ToggleActionAllMonitors(id, idx, checked) => { - self.toggle_action_all_monitors(id, idx, checked) - } - Message::ToggleActionMonitor(id, idx, monitor_id, checked) => { - self.toggle_action_monitor(id, idx, monitor_id, checked) - } - - Message::SetTurnOffBehavior(behavior) => { - self.config.turn_off_behavior = behavior; - self.config_dirty = true; - } - Message::ToggleStartWithWindows(enabled) => { - return self.set_windows_startup(enabled, self.config.start_minimized); - } - Message::ToggleStartMinimized(minimized) => { - return self.set_windows_startup(self.config.start_with_windows, minimized); - } - - Message::SaveConfig => return self.save_config(), - Message::ToggleSettings => { - self.set_show_context(!self.core.window.show_context); - } - Message::CloseSettings => self.set_show_context(false), - Message::ToggleHotkeys(enabled) => return self.toggle_hotkeys(enabled), - Message::RetryConfig => return self.retry_config(), - Message::RecoverConfigBackup => return self.recover_config_backup(), - Message::RequestResetConfig => { - self.pending_config_reset = self.config_store.recovery_error().is_some(); - } - Message::CancelResetConfig => self.pending_config_reset = false, - Message::ConfirmResetConfig => return self.confirm_reset_config(), - Message::ConfigOpFinished(op, done) => return self.config_op_finished(op, done), - - // -- Profiles --------------------------------------------------- - Message::RefreshProfiles => return self.refresh_profiles(), - Message::ProfilesListed(request, listed) => self.profiles_listed(request, listed), - Message::ProfileNameInput(value) => self.profile_name_input(value), - Message::SaveCurrentProfile(name) => return self.save_current_profile(name), - Message::ConfirmReplaceProfile => return self.confirm_replace_profile(), - Message::CancelReplaceProfile => self.cancel_replace_profile(), - Message::ApplyProfile(name) => return self.apply_profile(name), - Message::RequestDeleteProfile(name) => self.request_delete_profile(name), - Message::CancelDeleteProfile => self.cancel_delete_profile(), - Message::DeleteProfile(name) => return self.delete_profile(name), - Message::AddProfileHotkey(profile_name) => self.add_profile_hotkey(profile_name), - // -- Hide window (close-to-tray) -------------------------------- - Message::HideWindow => { - log::info!("Hiding window to tray"); - if let Some(id) = self.core.main_window_id() { - return window::close(id); - } - } - - Message::WindowClosed(id) => { - if self.core.main_window_id() == Some(id) { - self.core_mut().set_main_window_id(None); - } - } - - // -- System tray ------------------------------------------------ - Message::Tray(tray_msg) => { - match tray_msg { - TrayMessage::ShowWindow => { - log::info!("Tray: Show window requested"); - if let Some(id) = self.core.main_window_id() { - // Window still exists — try to focus it - return window::gain_focus(id); - } else { - // Window was closed — open a new one - let (new_id, open_task) = window::open(window::Settings { - min_size: Some(cosmic::iced::Size::new(600.0, 400.0)), - decorations: false, - ..window::Settings::default() - }); - self.core_mut().set_main_window_id(Some(new_id)); - let title_task = self.update_title(); - return cosmic::app::Task::batch([open_task.discard(), title_task]); - } - } - TrayMessage::LoadProfile(name) => { - return self.update(Message::ApplyProfile(name)); - } - TrayMessage::SaveCurrentProfile => { - if let Some(position) = self.nav_position_of(Page::Profiles) { - self.nav.activate_position(position); - } - return self.update(Message::Tray(TrayMessage::ShowWindow)); - } - TrayMessage::TurnOffMonitors => { - return self.enqueue_hardware_jobs([HardwareJob::SoftTurnOff { - monitor_ids: self.detected_monitors.iter().map(|m| m.id).collect(), - }]); - } - TrayMessage::Exit => { - log::info!("Tray: Exit requested"); - return cosmic::iced::exit(); - } - } - } - - Message::ConfigPathInput => {} - Message::CopyConfigPath => { - self.status_message = "Copied configuration path".into(); - return cosmic::iced::clipboard::write(self.config_path.clone()); - } - Message::OpenUrl(url) => { - if let Err(error) = std::process::Command::new("rundll32.exe") - .args(["url.dll,FileProtocolHandler", &url]) - .spawn() - { - self.status_message = format!("Failed to open URL: {error}"); - } - } - } - - cosmic::app::Task::none() - } - - // ----------------------------------------------------------------------- - // View - // ----------------------------------------------------------------------- - - fn view(&self) -> Element<'_, Self::Message> { - let space_s = cosmic::theme::spacing().space_s; - let space_m = cosmic::theme::spacing().space_m; - - // Determine which page is active - let page = self - .nav - .active_data::() - .cloned() - .unwrap_or(Page::Hotkeys); - - let content: Element<_> = match page { - Page::Monitor(monitor_id) => self.view_monitor(monitor_id), - Page::Hotkeys => self.view_hotkeys_current(), - Page::Profiles => self.view_profiles(), - Page::About => self.view_about(), - }; - - // Wrap in a container with status bar at the bottom - let status_bar = widget::text::caption(&self.status_message); - - let mut layout = widget::column::with_capacity(4); - let recovering = self.config_store.recovery_error().is_some(); - if recovering { - layout = layout.push(self.view_config_recovery()); - } - if !recovering || !matches!(page, Page::Hotkeys) { - layout = layout.push(content); - } - let layout = layout - .push(widget::divider::horizontal::default()) - .push( - widget::container(status_bar) - .padding([4, 12]) - .width(Length::Fill), - ) - .spacing(space_s) - .height(Length::Fill) - .width(Length::Fill); - - widget::container(layout) - .width(Length::Fill) - .height(Length::Fill) - .padding(space_m) - .into() - } - - fn header_start(&self) -> Vec> { - vec![crate::icons::header_icon_button( - crate::icons::AppIcon::Refresh, - "Refresh", - false, - Message::RefreshMonitors, - )] - } - - fn header_end(&self) -> Vec> { - let save_tip = if self.config_dirty { - "Save configuration (unsaved changes)" - } else { - "Save configuration" - }; - vec![ - crate::icons::header_icon_button( - crate::icons::AppIcon::Save, - save_tip, - self.config_dirty, - Message::SaveConfig, - ), - crate::icons::header_icon_button( - crate::icons::AppIcon::Settings, - "Settings", - self.core.window.show_context, - Message::ToggleSettings, - ), - ] - } - - fn context_drawer(&self) -> Option> { - if !self.core.window.show_context { - return None; - } - Some( - cosmic::app::context_drawer(self.view_settings(), Message::CloseSettings) - .title("Settings"), - ) - } -} - -// --------------------------------------------------------------------------- -// View helpers -// --------------------------------------------------------------------------- - -impl AppModel { - // ----------------------------------------------------------------------- - // Hotkey action handler - // ----------------------------------------------------------------------- - - fn nav_position_of(&self, target: Page) -> Option { - self.nav.iter().find_map(|id| { - if self.nav.data::(id) == Some(&target) { - self.nav.position(id) - } else { - None - } - }) - } - - fn handle_hotkey_actions( - &mut self, - actions: Vec, - ) -> cosmic::app::Task { - if let Err(error) = self - .action_executor - .enqueue_actions(actions, self.config.turn_off_behavior) - { - self.status_message = format!("Hotkey not dispatched: {error}"); - return cosmic::app::Task::none(); - } - self.start_next_hardware_job() - } - - // ----------------------------------------------------------------------- - // Title helper - // ----------------------------------------------------------------------- - - pub fn update_title(&mut self) -> cosmic::app::Task { - let mut title = String::from("Windows Display Manager"); - if let Some(text) = self.nav.text(self.nav.active()) { - title.push_str(" - "); - title.push_str(text); - } - if let Some(id) = self.core.main_window_id() { - self.set_window_title(title, id) - } else { - cosmic::app::Task::none() - } - } -} diff --git a/src/app/profile_handlers.rs b/src/app/profile_handlers.rs deleted file mode 100644 index 7a09c65..0000000 --- a/src/app/profile_handlers.rs +++ /dev/null @@ -1,92 +0,0 @@ -use super::{AppModel, Message}; -use crate::profiles; - -impl AppModel { - pub(super) fn refresh_profiles(&mut self) -> cosmic::app::Task { - self.profiles_request = self.profiles_request.wrapping_add(1); - let request = self.profiles_request; - cosmic::app::Task::perform( - async { tokio::task::spawn_blocking(profiles::list_profiles).await }, - move |result| { - let listed = match result { - Ok(Ok(list)) => Ok(list), - Ok(Err(error)) => Err(format!("List profiles error: {error}")), - Err(error) => Err(format!("Task join error: {error}")), - }; - cosmic::Action::App(Message::ProfilesListed(request, listed)) - }, - ) - } - - pub(super) fn profiles_listed(&mut self, request: u64, listed: Result, String>) { - if request != self.profiles_request { - return; - } - match listed { - Ok(profiles) => { - self.profiles = profiles; - if let Some((tray, _)) = &self.tray { - tray.update_menu(&self.profiles); - } - } - Err(error) => { - log::error!("{error}"); - self.status_message = error; - } - } - } - - pub(super) fn profile_name_input(&mut self, value: String) { - self.profile_name_input = value; - } - - pub(super) fn save_current_profile(&mut self, name: String) -> cosmic::app::Task { - let name = name.trim().to_string(); - if name.is_empty() { - self.status_message = "Enter a profile name first.".into(); - return cosmic::app::Task::none(); - } - // The worker reports an existing profile, which prompts for replacement. - self.pending_profile_replace = None; - self.status_message = format!("Saving profile '{name}'..."); - self.enqueue_hardware_jobs([super::HardwareJob::SaveProfile { - name, - replace: false, - }]) - } - - pub(super) fn confirm_replace_profile(&mut self) -> cosmic::app::Task { - let Some(name) = self.pending_profile_replace.take() else { - return cosmic::app::Task::none(); - }; - self.status_message = format!("Replacing profile '{name}'..."); - self.enqueue_hardware_jobs([super::HardwareJob::SaveProfile { - name, - replace: true, - }]) - } - - pub(super) fn cancel_replace_profile(&mut self) { - self.pending_profile_replace = None; - } - - pub(super) fn apply_profile(&mut self, name: String) -> cosmic::app::Task { - self.status_message = format!("Applying profile '{name}'..."); - self.enqueue_hardware_jobs([super::HardwareJob::ApplyProfile { name }]) - } - - pub(super) fn request_delete_profile(&mut self, name: String) { - self.pending_hotkey_delete = None; - self.pending_profile_delete = Some(name); - } - - pub(super) fn cancel_delete_profile(&mut self) { - self.pending_profile_delete = None; - } - - pub(super) fn delete_profile(&mut self, name: String) -> cosmic::app::Task { - self.pending_profile_delete = None; - self.status_message = format!("Deleting profile '{name}'..."); - self.enqueue_hardware_jobs([super::HardwareJob::DeleteProfile { name }]) - } -} diff --git a/src/main.rs b/src/main.rs index 8da66f3..f961809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,5 @@ #![windows_subsystem = "windows"] -mod app; -mod ccd; -mod config; -mod ddc; -mod hotkeys; -mod icons; -mod persistence; -mod profiles; -mod startup; -mod tray; - fn main() -> cosmic::iced::Result { env_logger::init(); @@ -21,20 +10,6 @@ fn main() -> cosmic::iced::Result { std::env::set_var("ICED_BACKEND", "tiny-skia"); } - let mut settings = cosmic::app::Settings::default() - // Disable antialiasing for better performance - .antialiasing(false) - // Don't exit when the window is closed — keep running in the tray - .exit_on_close(false) - .size_limits( - cosmic::iced::Limits::NONE - .min_width(600.0) - .min_height(400.0), - ); // Tray-only launch is reserved for the Windows sign-in command. - if startup::launched_minimized() { - settings = settings.no_main_window(true); - } - - cosmic::app::run::(settings, ()) + ui::run(data::startup::launched_minimized()) } diff --git a/ui/Cargo.toml b/ui/Cargo.toml new file mode 100644 index 0000000..af3120c --- /dev/null +++ b/ui/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ui" +description = "libcosmic user interface for Windows Display Manager" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +data.workspace = true +libcosmic.workspace = true +tokio.workspace = true +log.workspace = true diff --git a/ui/src/app.rs b/ui/src/app.rs new file mode 100644 index 0000000..e75f3cd --- /dev/null +++ b/ui/src/app.rs @@ -0,0 +1,453 @@ +use crate::keys; +use crate::modal; +use crate::subscriptions; +use cosmic::iced::event::{self, Event}; +use cosmic::iced::keyboard::{Event as KeyboardEvent, Key, Modifiers}; +use cosmic::iced::{Length, Subscription, window}; +use cosmic::prelude::*; +use cosmic::widget::{self, nav_bar}; +use cosmic::{Core, executor}; +use data::state::{AppState, Input}; +use data::tray::{SystemTray, TrayMessage, TrayStream}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Simple inline SVG icon (monitor symbol) +const APP_ICON: &[u8] = br#" + + + + + +"#; + +// --------------------------------------------------------------------------- +// Navigation pages +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Page { + Monitor(u32), // 1-indexed monitor ID + Hotkeys, + Profiles, + About, +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub enum Message { + /// Handled by the UI-agnostic application state. + Data(Input), + /// Keyboard input while recording a hotkey. + KeyPressed(Modifiers, Key), + ToggleSettings, + CloseSettings, + // System tray + Tray(TrayMessage), + /// Hide window (close-to-tray) + HideWindow, + WindowClosed(window::Id), + OpenUrl(String), + /// Discard edits to the read-only configuration path field. + ConfigPathInput, + CopyConfigPath, +} + +// --------------------------------------------------------------------------- +// Application model +// --------------------------------------------------------------------------- + +pub struct AppModel { + core: Core, + pub(crate) nav: nav_bar::Model, + pub(crate) about: widget::about::About, + pub(crate) state: AppState, + // System tray + pub(crate) tray: Option<(SystemTray, TrayStream)>, +} + +// --------------------------------------------------------------------------- +// cosmic::Application implementation +// --------------------------------------------------------------------------- + +impl cosmic::Application for AppModel { + type Executor = executor::Default; + type Flags = (); + type Message = Message; + + const APP_ID: &'static str = "com.windisplaymanager.app"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + fn init(core: Core, _flags: Self::Flags) -> (Self, cosmic::app::Task) { + let (state, effects) = AppState::load(); + + // Build nav model with a placeholder; will be rebuilt after detection + let mut nav = nav_bar::Model::default(); + nav.insert() + .text("Detecting monitors...") + .data::(Page::Hotkeys) + .activate(); + + let about = widget::about::About::default() + .name("Windows Display Manager") + .icon(widget::icon::from_svg_bytes(APP_ICON)) + .version(env!("CARGO_PKG_VERSION")) + .comments("DDC/CI monitor control with global hotkeys."); + + // Set up system tray + let tray = match SystemTray::new() { + Ok(t) => { + log::info!("System tray created successfully"); + Some(t) + } + Err(e) => { + log::warn!("Failed to create system tray: {e}"); + None + } + }; + + let mut app = AppModel { + core, + nav, + about, + state, + tray, + }; + let task = app.run_effects(effects); + (app, task) + } + + fn nav_model(&self) -> Option<&nav_bar::Model> { + Some(&self.nav) + } + + fn on_nav_select(&mut self, id: nav_bar::Id) -> cosmic::app::Task { + self.nav.activate(id); + self.update_title() + } + + fn dialog(&self) -> Option> { + if let Some(name) = self.state.pending_profile_delete() { + return Some(modal::confirm_dialog( + "Delete profile?", + format!("Delete \"{name}\"? This removes the saved layout."), + "Delete", + Message::Data(Input::CancelDeleteProfile), + Message::Data(Input::DeleteProfile(name.to_string())), + )); + } + let id = self.state.pending_hotkey_delete()?; + let title = self.state.hotkey_delete_title(id)?; + Some(modal::confirm_dialog( + "Delete hotkey?", + format!("Delete \"{title}\"? This removes the hotkey and its actions."), + "Delete", + Message::Data(Input::CancelDeleteHotkey), + Message::Data(Input::DeleteHotkey(id.to_string())), + )) + } + + fn on_escape(&mut self) -> cosmic::app::Task { + let mut effects = Vec::new(); + if self.state.pending_profile_delete().is_some() { + effects.extend(self.state.update(Input::CancelDeleteProfile)); + } + if self.state.pending_hotkey_delete().is_some() { + effects.extend(self.state.update(Input::CancelDeleteHotkey)); + } + self.run_effects(effects) + } + + // Intercept the header-bar close button → hide to tray instead of exiting + fn on_app_exit(&mut self) -> Option { + if self.tray.is_some() { + Some(Message::HideWindow) + } else { + None // no tray → exit normally + } + } + + // Intercept window surface close (e.g. Alt+F4) → hide to tray + fn on_close_requested(&self, id: window::Id) -> Option { + if self.tray.is_some() && self.core.main_window_id().is_some_and(|main| main == id) { + return Some(Message::HideWindow); + } + None + } + + // ----------------------------------------------------------------------- + // Subscriptions + // ----------------------------------------------------------------------- + + fn subscription(&self) -> Subscription { + let mut subs: Vec> = Vec::new(); + + // Global hotkey polling subscription (only when enabled) + if let Some(registered_ids) = self.state.listened_hotkey_ids() { + subs.push( + subscriptions::hotkeys(registered_ids, self.state.hotkey_generation()) + .map(|id| Message::Data(Input::HotkeyTriggered(id))), + ); + } + + // Keyboard event subscription when recording hotkeys + if self.state.is_recording() { + subs.push(event::listen_with(|event, _status, _id| { + if let Event::Keyboard(KeyboardEvent::KeyPressed { key, modifiers, .. }) = event { + Some(Message::KeyPressed(modifiers, key)) + } else { + None + } + })); + } + + // System tray subscription + if let Some((_, ref tray_stream)) = self.tray { + subs.push(subscriptions::tray(tray_stream.clone()).map(Message::Tray)); + } + + subs.push(event::listen_with(|event, _status, id| { + if let Event::Window(window::Event::Closed) = event { + Some(Message::WindowClosed(id)) + } else { + None + } + })); + + Subscription::batch(subs) + } + + // ----------------------------------------------------------------------- + // Update + // ----------------------------------------------------------------------- + + fn update(&mut self, message: Self::Message) -> cosmic::app::Task { + match message { + Message::Data(input) => { + let effects = self.state.update(input); + return self.run_effects(effects); + } + Message::KeyPressed(modifiers, key) => { + let binding = keys::binding(modifiers, &key); + let effects = self.state.update(Input::KeyRecorded(binding)); + return self.run_effects(effects); + } + Message::ToggleSettings => { + self.set_show_context(!self.core.window.show_context); + } + Message::CloseSettings => self.set_show_context(false), + + // -- Hide window (close-to-tray) -------------------------------- + Message::HideWindow => { + log::info!("Hiding window to tray"); + if let Some(id) = self.core.main_window_id() { + return window::close(id); + } + } + + Message::WindowClosed(id) => { + if self.core.main_window_id() == Some(id) { + self.core_mut().set_main_window_id(None); + } + } + + // -- System tray ------------------------------------------------ + Message::Tray(tray_msg) => { + match tray_msg { + TrayMessage::ShowWindow => { + log::info!("Tray: Show window requested"); + if let Some(id) = self.core.main_window_id() { + // Window still exists — try to focus it + return window::gain_focus(id); + } else { + // Window was closed — open a new one + let (new_id, open_task) = window::open(window::Settings { + min_size: Some(cosmic::iced::Size::new(600.0, 400.0)), + decorations: false, + ..window::Settings::default() + }); + self.core_mut().set_main_window_id(Some(new_id)); + let title_task = self.update_title(); + return cosmic::app::Task::batch([open_task.discard(), title_task]); + } + } + TrayMessage::LoadProfile(name) => { + return self.update(Message::Data(Input::ApplyProfile(name))); + } + TrayMessage::SaveCurrentProfile => { + self.activate_page(Page::Profiles); + return self.update(Message::Tray(TrayMessage::ShowWindow)); + } + TrayMessage::TurnOffMonitors => { + return self.update(Message::Data(Input::TurnOffMonitors)); + } + TrayMessage::Exit => { + log::info!("Tray: Exit requested"); + return cosmic::iced::exit(); + } + } + } + + Message::ConfigPathInput => {} + Message::CopyConfigPath => { + let effects = self.state.update(Input::ConfigPathCopied); + return cosmic::app::Task::batch([ + self.run_effects(effects), + cosmic::iced::clipboard::write(self.state.config_path().to_string()), + ]); + } + Message::OpenUrl(url) => { + if let Err(error) = std::process::Command::new("rundll32.exe") + .args(["url.dll,FileProtocolHandler", &url]) + .spawn() + { + let effects = self.state.update(Input::OpenUrlFailed(error.to_string())); + return self.run_effects(effects); + } + } + } + + cosmic::app::Task::none() + } + + // ----------------------------------------------------------------------- + // View + // ----------------------------------------------------------------------- + + fn view(&self) -> Element<'_, Self::Message> { + let space_s = cosmic::theme::spacing().space_s; + let space_m = cosmic::theme::spacing().space_m; + + // Determine which page is active + let page = self + .nav + .active_data::() + .cloned() + .unwrap_or(Page::Hotkeys); + + let content: Element<_> = match page { + Page::Monitor(monitor_id) => self.view_monitor(monitor_id), + Page::Hotkeys => self.view_hotkeys_current(), + Page::Profiles => self.view_profiles(), + Page::About => self.view_about(), + }; + + // Wrap in a container with status bar at the bottom + let status_bar = widget::text::caption(self.state.status_message()); + + let mut layout = widget::column::with_capacity(4); + let recovering = self.state.recovery_error().is_some(); + if recovering { + layout = layout.push(self.view_config_recovery()); + } + if !recovering || !matches!(page, Page::Hotkeys) { + layout = layout.push(content); + } + let layout = layout + .push(widget::divider::horizontal::default()) + .push( + widget::container(status_bar) + .padding([4, 12]) + .width(Length::Fill), + ) + .spacing(space_s) + .height(Length::Fill) + .width(Length::Fill); + + widget::container(layout) + .width(Length::Fill) + .height(Length::Fill) + .padding(space_m) + .into() + } + + fn header_start(&self) -> Vec> { + vec![crate::icons::header_icon_button( + crate::icons::AppIcon::Refresh, + "Refresh", + false, + Message::Data(Input::RefreshMonitors), + )] + } + + fn header_end(&self) -> Vec> { + let dirty = self.state.config_dirty(); + let save_tip = if dirty { + "Save configuration (unsaved changes)" + } else { + "Save configuration" + }; + vec![ + crate::icons::header_icon_button( + crate::icons::AppIcon::Save, + save_tip, + dirty, + Message::Data(Input::SaveConfig), + ), + crate::icons::header_icon_button( + crate::icons::AppIcon::Settings, + "Settings", + self.core.window.show_context, + Message::ToggleSettings, + ), + ] + } + + fn context_drawer(&self) -> Option> { + if !self.core.window.show_context { + return None; + } + Some( + cosmic::app::context_drawer(self.view_settings(), Message::CloseSettings) + .title("Settings"), + ) + } +} + +// --------------------------------------------------------------------------- +// View helpers +// --------------------------------------------------------------------------- + +impl AppModel { + pub(crate) fn activate_page(&mut self, target: Page) { + let position = self.nav.iter().find_map(|id| { + if self.nav.data::(id) == Some(&target) { + self.nav.position(id) + } else { + None + } + }); + if let Some(position) = position { + self.nav.activate_position(position); + } + } + + // ----------------------------------------------------------------------- + // Title helper + // ----------------------------------------------------------------------- + + pub fn update_title(&mut self) -> cosmic::app::Task { + let mut title = String::from("Windows Display Manager"); + if let Some(text) = self.nav.text(self.nav.active()) { + title.push_str(" - "); + title.push_str(text); + } + if let Some(id) = self.core.main_window_id() { + self.set_window_title(title, id) + } else { + cosmic::app::Task::none() + } + } +} diff --git a/ui/src/effects.rs b/ui/src/effects.rs new file mode 100644 index 0000000..3b4b2ed --- /dev/null +++ b/ui/src/effects.rs @@ -0,0 +1,69 @@ +use crate::app::{AppModel, Message, Page}; +use cosmic::widget::nav_bar; +use data::state::Effect; + +impl AppModel { + /// Carries out state effects, returning the tasks that feed completions back. + pub(crate) fn run_effects(&mut self, effects: Vec) -> cosmic::app::Task { + let mut tasks = Vec::new(); + for effect in effects { + match effect { + Effect::Spawn(job) => { + let on_failure = job.on_failure(); + tasks.push(cosmic::app::Task::perform( + async move { tokio::task::spawn_blocking(move || job.run()).await }, + move |result| { + cosmic::Action::App(Message::Data( + result.unwrap_or_else(|error| on_failure(error.to_string())), + )) + }, + )); + } + Effect::Delay(delay, input) => { + tasks.push(cosmic::app::Task::perform( + async move { + tokio::time::sleep(delay).await; + input + }, + |input| cosmic::Action::App(Message::Data(input)), + )); + } + Effect::MonitorsDetected => self.rebuild_nav(), + Effect::ProfilesUpdated => { + if let Some((tray, _)) = &self.tray { + tray.update_menu(self.state.profiles()); + } + } + Effect::ShowHotkeys => self.activate_page(Page::Hotkeys), + } + } + cosmic::app::Task::batch(tasks) + } + + fn rebuild_nav(&mut self) { + self.nav = nav_bar::Model::default(); + for info in self.state.detected_monitors() { + let label = if info.name.is_empty() { + format!("Monitor {}", info.id) + } else { + format!("{} ({}x{})", info.name, info.width, info.height) + }; + self.nav + .insert() + .text(label) + .data::(Page::Monitor(info.id)); + } + self.nav + .insert() + .text("Hotkeys") + .data::(Page::Hotkeys); + self.nav + .insert() + .text("Profiles") + .data::(Page::Profiles); + self.nav.insert().text("About").data::(Page::About); + + // Activate first monitor + self.nav.activate_position(0); + } +} diff --git a/src/app/hotkey_views.rs b/ui/src/hotkey_views.rs similarity index 79% rename from src/app/hotkey_views.rs rename to ui/src/hotkey_views.rs index ed552b3..42822c7 100644 --- a/src/app/hotkey_views.rs +++ b/ui/src/hotkey_views.rs @@ -1,21 +1,20 @@ -use super::hotkey_editor::{ - action_master_selected, action_monitor_selected, parse_value_draft, parse_vcp_draft, -}; -use super::{AppModel, Message, RecordingState}; -use crate::config::{ - ActionTarget, ActionType, Hotkey, HotkeyActionSpec, HotkeyHeading, MonitorTarget, - hotkey_headings, -}; -use crate::ddc::{ - FeatureOptions, InputSource, MonitorInfo, NO_SHARED_OPTION_NOTE, input_choices, power_options, - shared_input_choices, -}; use crate::icons::{self, AppIcon}; +use crate::{AppModel, Message}; use cosmic::Element; use cosmic::iced::alignment::Horizontal; use cosmic::iced::{Alignment, Length}; use cosmic::theme; use cosmic::widget; +use data::config::{ + ActionTarget, ActionType, Hotkey, HotkeyActionSpec, HotkeyHeading, MonitorTarget, + hotkey_headings, +}; +use data::ddc::{ + FeatureOptions, NO_SHARED_OPTION_NOTE, input_choices, power_options, shared_input_choices, +}; +use data::state::{ + Input, action_master_selected, action_monitor_selected, parse_value_draft, parse_vcp_draft, +}; fn option_picker( choices: FeatureOptions, @@ -43,43 +42,20 @@ where } impl AppModel { - fn targeted_advertised_inputs( - &self, - action: &HotkeyActionSpec, - ) -> Vec>> { - let infos: Vec = if action.all_monitors { - self.detected_monitors.clone() - } else { - action - .explicit_targets() - .iter() - .filter_map(|target| target.resolve(&self.detected_monitors).ok().cloned()) - .collect() - }; - infos - .iter() - .filter_map(|info| { - self.monitors - .iter() - .find(|monitor| monitor.info.key == info.key) - .map(|monitor| monitor.advertised_inputs.clone()) - }) - .collect() - } - pub(super) fn view_hotkeys_current(&self) -> Element<'_, Message> { let space_s = cosmic::theme::spacing().space_s; - let mut hotkeys = widget::column::with_capacity(self.config.hotkeys.hotkeys.len() + 1) + let hotkey_list = &self.state.config().hotkeys.hotkeys; + let mut hotkeys = widget::column::with_capacity(hotkey_list.len() + 1) .spacing(space_s) .width(Length::Fill); - let headings = hotkey_headings(&self.config.hotkeys.hotkeys); - for (hotkey, heading) in self.config.hotkeys.hotkeys.iter().zip(headings) { + let headings = hotkey_headings(hotkey_list); + for (hotkey, heading) in hotkey_list.iter().zip(headings) { hotkeys = hotkeys.push(self.view_hotkey_card(hotkey, heading, space_s)); } let add_row = widget::row::with_capacity(2) - .push(widget::button::suggested("Add Hotkey").on_press(Message::AddHotkey)) + .push(widget::button::suggested("Add Hotkey").on_press(Message::Data(Input::AddHotkey))) .push(widget::Space::new().width(Length::Fill)) .spacing(space_s); @@ -110,8 +86,8 @@ impl AppModel { space_s: u16, ) -> Element<'a, Message> { let id = hotkey.id.clone(); - let expanded = self.expanded_hotkey.as_deref() == Some(id.as_str()); - let active = self.hotkey_status.get(&id).copied().unwrap_or(false); + let expanded = self.state.is_expanded(&id); + let active = self.state.is_hotkey_active(&id); let status = if hotkey.binding.key.is_empty() { "Unbound" } else if active { @@ -130,13 +106,13 @@ impl AppModel { }, if expanded { "Collapse" } else { "Edit" }, theme::Button::Standard, - Message::ToggleHotkeyEditor(id.clone()), + Message::Data(Input::ToggleHotkeyEditor(id.clone())), )) .push(icons::icon_button( AppIcon::Trash, "Delete", theme::Button::Destructive, - Message::RequestDeleteHotkey(id.clone()), + Message::Data(Input::RequestDeleteHotkey(id.clone())), )) .spacing(space_s) .align_y(Alignment::Center); @@ -145,23 +121,23 @@ impl AppModel { .push(summary) .spacing(space_s); if expanded { - let recording_here = matches!( - &self.recording_state, - RecordingState::Recording { hotkey_id, .. } if hotkey_id == &id - ); + let recording_here = self.state.is_recording_hotkey(&id); let binding_row = if recording_here { widget::row::with_capacity(2) .push(widget::text::body("Press a key combination...").width(Length::Fill)) - .push(widget::button::standard("Cancel").on_press(Message::CancelRecording)) + .push( + widget::button::standard("Cancel") + .on_press(Message::Data(Input::CancelRecording)), + ) } else { widget::row::with_capacity(3) .push( widget::button::standard("Record") - .on_press(Message::StartRecording(id.clone())), + .on_press(Message::Data(Input::StartRecording(id.clone()))), ) .push( widget::button::standard("Clear") - .on_press(Message::ClearBinding(id.clone())), + .on_press(Message::Data(Input::ClearBinding(id.clone()))), ) .push(widget::Space::new().width(Length::Fill)) } @@ -173,7 +149,7 @@ impl AppModel { widget::text_input(heading.fallback, &hotkey.label) .on_input({ let id = id.clone(); - move |value| Message::SetHotkeyLabel(id.clone(), value) + move |value| Message::Data(Input::SetHotkeyLabel(id.clone(), value)) }) .width(Length::Fill), ) @@ -186,7 +162,8 @@ impl AppModel { content = content.push(self.view_action_editor(&id, idx, action, space_s)); } content = content.push( - widget::button::standard("Add Action").on_press(Message::AddAction(id.clone())), + widget::button::standard("Add Action") + .on_press(Message::Data(Input::AddAction(id.clone()))), ); } @@ -216,14 +193,20 @@ impl AppModel { let action_row = widget::row::with_capacity(3) .push(widget::dropdown(type_labels, selected_type, { let id = id.clone(); - move |selected| Message::SetActionType(id.clone(), idx, type_options[selected]) + move |selected| { + Message::Data(Input::SetActionType( + id.clone(), + idx, + type_options[selected], + )) + } })) .push(widget::Space::new().width(Length::Fill)) .push(icons::icon_button( AppIcon::Trash, "Delete Action", theme::Button::Destructive, - Message::DeleteAction(id.clone(), idx), + Message::Data(Input::DeleteAction(id.clone(), idx)), )) .spacing(space_s) .align_y(Alignment::Center); @@ -256,11 +239,11 @@ impl AppModel { widget::container(widget::dropdown(targets, selected, { let id = id.clone(); move |selected| { - Message::SetActionTarget( + Message::Data(Input::SetActionTarget( id.clone(), idx, ActionTarget::ALL[selected], - ) + )) } })) .width(Length::Fill) @@ -271,16 +254,14 @@ impl AppModel { ); if action.target == ActionTarget::CustomVcp { - let draft = self - .vcp_drafts - .get(&(id.clone(), idx)) - .map(String::as_str) - .unwrap_or(""); + let draft = self.state.vcp_draft(&id, idx); let mut code = widget::column::with_capacity(2).push( widget::text_input("0x10", draft) .on_input({ let id = id.clone(); - move |value| Message::ActionVcpDraftChanged(id.clone(), idx, value) + move |value| { + Message::Data(Input::ActionVcpDraftChanged(id.clone(), idx, value)) + } }) .width(Length::Fixed(96.0)), ); @@ -302,17 +283,17 @@ impl AppModel { let value_control: Option> = match action.target { ActionTarget::Brightness | ActionTarget::Contrast | ActionTarget::CustomVcp => { - let draft = self - .value_drafts - .get(&(id.clone(), idx)) - .map(String::as_str) - .unwrap_or(""); + let draft = self.state.value_draft(&id, idx); let mut input = widget::column::with_capacity(2).push( widget::text_input("0", draft) .on_input({ let id = id.clone(); move |value| { - Message::ActionValueDraftChanged(id.clone(), idx, value) + Message::Data(Input::ActionValueDraftChanged( + id.clone(), + idx, + value, + )) } }) .width(Length::Fixed(96.0)), @@ -324,23 +305,23 @@ impl AppModel { } ActionTarget::InputSource if action.all_monitors => { let choices = shared_input_choices( - &self.targeted_advertised_inputs(action), + &self.state.targeted_advertised_inputs(action), Some(action.input_source), ); let id = id.clone(); Some(option_picker(choices, action.input_source, move |source| { - Message::SetActionInputSource(id.clone(), idx, source) + Message::Data(Input::SetActionInputSource(id.clone(), idx, source)) })) } ActionTarget::PowerMode => { let choices = power_options(Some(action.power_mode)); let id = id.clone(); Some(option_picker(choices, action.power_mode, move |mode| { - Message::SetActionPowerMode(id.clone(), idx, mode) + Message::Data(Input::SetActionPowerMode(id.clone(), idx, mode)) })) } ActionTarget::Profile => { - let mut profiles = self.profiles.clone(); + let mut profiles = self.state.profiles().to_vec(); if !action.profile_name.is_empty() && !profiles.contains(&action.profile_name) { profiles.push(action.profile_name.clone()); } @@ -354,11 +335,11 @@ impl AppModel { widget::dropdown(profiles.clone(), selected, { let id = id.clone(); move |selected| { - Message::SetActionProfile( + Message::Data(Input::SetActionProfile( id.clone(), idx, profiles[selected].clone(), - ) + )) } }) .into(), @@ -382,8 +363,8 @@ impl AppModel { } } - let mut display_ids: Vec = self - .detected_monitors + let detected = self.state.detected_monitors(); + let mut display_ids: Vec = detected .iter() .map(|monitor| MonitorTarget::Stable(monitor.key.clone())) .collect(); @@ -409,13 +390,15 @@ impl AppModel { .push(widget::Space::new().width(Length::Fixed(space_s as f32))) .push(widget::toggler(master_selected).on_toggle({ let id = id.clone(); - move |checked| Message::ToggleActionAllMonitors(id.clone(), idx, checked) + move |checked| { + Message::Data(Input::ToggleActionAllMonitors(id.clone(), idx, checked)) + } })) .align_y(Alignment::Center), ); for monitor_id in display_ids { - let available = monitor_id.resolve(&self.detected_monitors).ok(); + let available = monitor_id.resolve(detected).ok(); let label = available .map(|monitor| format!("Monitor {}: {}", monitor.id, monitor.name)) .unwrap_or_else(|| format!("{monitor_id} (unresolved)")); @@ -428,44 +411,39 @@ impl AppModel { let id = id.clone(); let target = monitor_id.clone(); move |checked| { - Message::ToggleActionMonitor( + Message::Data(Input::ToggleActionMonitor( id.clone(), idx, target.clone(), checked, - ) + )) } })) .align_y(Alignment::Center), ); } else { - let labels: Vec = self - .detected_monitors + let labels: Vec = detected .iter() .map(|m| format!("Rebind to Monitor {}: {}", m.id, m.name)) .collect(); - let keys: Vec<_> = self - .detected_monitors - .iter() - .map(|m| m.key.clone()) - .collect(); + let keys: Vec<_> = detected.iter().map(|m| m.key.clone()).collect(); let mut row = widget::row::with_capacity(3) .push(widget::text::body(label).width(Length::Fill)) - .push(widget::button::standard("Remove").on_press( - Message::RemoveMonitorTarget(id.clone(), idx, monitor_id.clone()), - )) + .push(widget::button::standard("Remove").on_press(Message::Data( + Input::RemoveMonitorTarget(id.clone(), idx, monitor_id.clone()), + ))) .spacing(space_s); if !keys.is_empty() { row = row.push(widget::dropdown(labels, None, { let id = id.clone(); let old = monitor_id.clone(); move |selected| { - Message::RebindMonitorTarget( + Message::Data(Input::RebindMonitorTarget( id.clone(), idx, old.clone(), keys[selected].clone(), - ) + )) } })); } @@ -484,9 +462,7 @@ impl AppModel { .map(|input| input.input_source) .unwrap_or(action.input_source); let key = available.map(|info| info.key.clone()); - let monitor = key.and_then(|key| { - self.monitors.iter().find(|monitor| monitor.info.key == key) - }); + let monitor = key.and_then(|key| self.state.monitor_state(&key)); let choices = input_choices( monitor.and_then(|monitor| monitor.advertised_inputs.as_deref()), Some(current), @@ -498,12 +474,12 @@ impl AppModel { let id = id.clone(); let target = monitor_id.clone(); move |source| { - Message::SetMonitorInput( + Message::Data(Input::SetMonitorInput( id.clone(), idx, target.clone(), Some(source), - ) + )) } })) .spacing(space_s) diff --git a/src/icons.rs b/ui/src/icons.rs similarity index 100% rename from src/icons.rs rename to ui/src/icons.rs diff --git a/ui/src/keys.rs b/ui/src/keys.rs new file mode 100644 index 0000000..ec08009 --- /dev/null +++ b/ui/src/keys.rs @@ -0,0 +1,63 @@ +use cosmic::iced::keyboard::key::Named; +use cosmic::iced::keyboard::{Key, Modifiers}; +use data::config::HotkeyBinding; + +/// Binding for a pressed chord; `key` is empty when the key is unsupported. +pub(crate) fn binding(modifiers: Modifiers, key: &Key) -> HotkeyBinding { + HotkeyBinding { + ctrl: modifiers.control(), + alt: modifiers.alt(), + shift: modifiers.shift(), + win: modifiers.logo(), + key: key_to_string(key), + } +} + +/// Convert an Iced Key to our internal string format +fn key_to_string(key: &Key) -> String { + match key { + Key::Named(named_key) => match named_key { + Named::F1 => "F1", + Named::F2 => "F2", + Named::F3 => "F3", + Named::F4 => "F4", + Named::F5 => "F5", + Named::F6 => "F6", + Named::F7 => "F7", + Named::F8 => "F8", + Named::F9 => "F9", + Named::F10 => "F10", + Named::F11 => "F11", + Named::F12 => "F12", + Named::ArrowUp => "ArrowUp", + Named::ArrowDown => "ArrowDown", + Named::ArrowLeft => "ArrowLeft", + Named::ArrowRight => "ArrowRight", + Named::Home => "Home", + Named::End => "End", + Named::PageUp => "PageUp", + Named::PageDown => "PageDown", + Named::Insert => "Insert", + Named::Delete => "Delete", + Named::Enter => "Enter", + Named::Escape => "Escape", + Named::Backspace => "Backspace", + Named::Tab => "Tab", + _ => return String::new(), + } + .to_string(), + Key::Character(c) => { + let ch = c.chars().next().unwrap_or('?'); + if ch == ' ' { + "Space".to_string() + } else if ch.is_ascii_alphabetic() { + format!("Key{}", ch.to_uppercase()) + } else if ch.is_ascii_digit() { + format!("Digit{}", ch) + } else { + String::new() + } + } + Key::Unidentified => String::new(), + } +} diff --git a/ui/src/lib.rs b/ui/src/lib.rs new file mode 100644 index 0000000..2f7ff84 --- /dev/null +++ b/ui/src/lib.rs @@ -0,0 +1,31 @@ +mod app; +mod effects; +mod hotkey_views; +mod icons; +mod keys; +mod modal; +mod monitor_views; +mod profile_views; +mod settings_views; +mod subscriptions; +mod views; + +use app::{AppModel, Message}; + +/// Runs the UI until exit. `minimized` starts in the tray without a window. +pub fn run(minimized: bool) -> cosmic::iced::Result { + let mut settings = cosmic::app::Settings::default() + // Disable antialiasing for better performance + .antialiasing(false) + // Don't exit when the window is closed — keep running in the tray + .exit_on_close(false) + .size_limits( + cosmic::iced::Limits::NONE + .min_width(600.0) + .min_height(400.0), + ); + if minimized { + settings = settings.no_main_window(true); + } + cosmic::app::run::(settings, ()) +} diff --git a/src/app/modal.rs b/ui/src/modal.rs similarity index 98% rename from src/app/modal.rs rename to ui/src/modal.rs index bf52234..2cb7ffb 100644 --- a/src/app/modal.rs +++ b/ui/src/modal.rs @@ -1,4 +1,4 @@ -use super::Message; +use crate::Message; use cosmic::Element; use cosmic::iced::widget::{center, container, mouse_area, opaque}; use cosmic::iced::{Color, Length}; diff --git a/ui/src/monitor_views.rs b/ui/src/monitor_views.rs new file mode 100644 index 0000000..9813024 --- /dev/null +++ b/ui/src/monitor_views.rs @@ -0,0 +1,169 @@ +use crate::{AppModel, Message}; +use cosmic::Element; +use cosmic::iced::Length; +use cosmic::iced::alignment::Horizontal; +use cosmic::widget; +use data::ddc::input_choices; +use data::presentation::{input_presentation, scalar_presentation}; +use data::state::Input; + +impl AppModel { + /// View for a single monitor page. + pub(super) fn view_monitor(&self, monitor_id: u32) -> Element<'_, Message> { + let space_s = cosmic::theme::spacing().space_s; + + let monitor = self.state.monitor(monitor_id); + + match monitor { + None => { + let content: Element<'_, Message> = + if let Some(error) = self.state.monitor_load_error(monitor_id) { + widget::column::with_capacity(3) + .push(widget::text::title4(format!( + "Monitor {monitor_id} unavailable" + ))) + .push(widget::text::body(error)) + .push( + widget::button::standard("Retry") + .on_press(Message::Data(Input::RetryMonitor(monitor_id))), + ) + .spacing(space_s) + .into() + } else { + widget::text::body("Loading monitor data...").into() + }; + widget::container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Horizontal::Center) + .into() + } + + Some(mon) => { + // Header + let header_label = if mon.info.name.is_empty() { + format!("Monitor {}", mon.info.id) + } else { + mon.info.name.clone() + }; + let header = widget::text::title3(header_label); + + let resolution_text = format!( + "{}x{} at ({}, {}){}", + mon.info.width, + mon.info.height, + mon.info.x, + mon.info.y, + if mon.info.is_primary { + " [Primary]" + } else { + "" + } + ); + let resolution_label = widget::text::caption(resolution_text); + + let pending = self.state.monitor_pending(&mon.info); + let input = input_presentation( + mon.input_source, + mon.input_source_read_error.as_deref(), + pending.input_source, + ); + let mid = mon.info.id; + let inputs = input_choices(mon.advertised_inputs.as_deref(), input.value); + let sources = inputs.options; + let labels: Vec = sources.iter().map(ToString::to_string).collect(); + let selected_idx = input + .value + .as_ref() + .and_then(|source| sources.iter().position(|candidate| candidate == source)); + let control: Element<'_, Message> = if sources.is_empty() { + widget::text::body("No input sources advertised").into() + } else { + widget::dropdown(labels, selected_idx, move |idx| { + Message::Data(Input::SelectInputSource(mid, sources[idx])) + }) + .into() + }; + let mut section = cosmic::widget::settings::section() + .title("Input Source") + .add(cosmic::widget::settings::item::builder(input.label).control(control)); + if let Some(error) = input.read_error { + section = section.add(widget::text::body(format!( + "Could not read input source: {error}" + ))); + } + let input_section = section; + + let mut content = widget::column::with_capacity(6) + .push(header) + .push(resolution_label) + .spacing(space_s) + .width(Length::Fill); + content = content.push(input_section); + let brightness = scalar_presentation( + mon.brightness, + mon.brightness_max, + mon.brightness_read_error.as_deref(), + pending.brightness, + ); + let brightness_control: Element<'_, Message> = match brightness.value { + Some(value) => widget::slider( + 0.0..=f64::from(mon.brightness_max), + f64::from(value), + move |v| Message::Data(Input::BrightnessSliderChanged(mid, v as u16)), + ) + .width(Length::Fixed(300.0)) + .into(), + None => widget::text::body("Refresh to adjust").into(), + }; + let mut brightness_section = + cosmic::widget::settings::section().title("Brightness").add( + cosmic::widget::settings::item::builder(brightness.label) + .control(brightness_control), + ); + if let Some(error) = brightness.read_error { + brightness_section = brightness_section.add(widget::text::body(format!( + "Could not read brightness: {error}" + ))); + } + content = content.push(brightness_section); + let contrast = scalar_presentation( + mon.contrast, + mon.contrast_max, + mon.contrast_read_error.as_deref(), + pending.contrast, + ); + let contrast_control: Element<'_, Message> = match contrast.value { + Some(value) => widget::slider( + 0.0..=f64::from(mon.contrast_max), + f64::from(value), + move |v| Message::Data(Input::ContrastSliderChanged(mid, v as u16)), + ) + .width(Length::Fixed(300.0)) + .into(), + None => widget::text::body("Refresh to adjust").into(), + }; + let mut contrast_section = + cosmic::widget::settings::section().title("Contrast").add( + cosmic::widget::settings::item::builder(contrast.label) + .control(contrast_control), + ); + if let Some(error) = contrast.read_error { + contrast_section = contrast_section.add(widget::text::body(format!( + "Could not read contrast: {error}" + ))); + } + content = content.push(contrast_section); + if let Some(error) = self.state.monitor_load_error(monitor_id) { + content = content.push(widget::text::body(format!( + "Could not refresh monitor: {error}" + ))); + } + widget::scrollable(content) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + } + } +} diff --git a/src/app/profile_views.rs b/ui/src/profile_views.rs similarity index 69% rename from src/app/profile_views.rs rename to ui/src/profile_views.rs index d0bf88a..70cd476 100644 --- a/src/app/profile_views.rs +++ b/ui/src/profile_views.rs @@ -1,30 +1,33 @@ -use super::{AppModel, Message}; use crate::icons::{self, AppIcon}; +use crate::{AppModel, Message}; use cosmic::Element; use cosmic::iced::{Alignment, Length}; use cosmic::theme; use cosmic::widget; +use data::state::Input; impl AppModel { pub(super) fn view_profiles(&self) -> Element<'_, Message> { let space_s = cosmic::theme::spacing().space_s; + let name_input = self.state.profile_name_input(); let save_row = widget::row::with_capacity(2) .push( - widget::text_input("New profile name", &self.profile_name_input) - .on_input(Message::ProfileNameInput) - .on_submit(Message::SaveCurrentProfile) + widget::text_input("New profile name", name_input) + .on_input(|value| Message::Data(Input::ProfileNameInput(value))) + .on_submit(|value| Message::Data(Input::SaveCurrentProfile(value))) .width(Length::Fill), ) .push( - widget::button::suggested("Save Current Layout") - .on_press(Message::SaveCurrentProfile(self.profile_name_input.clone())), + widget::button::suggested("Save Current Layout").on_press(Message::Data( + Input::SaveCurrentProfile(name_input.to_string()), + )), ) .spacing(space_s) .align_y(Alignment::Center); let mut save = widget::column::with_capacity(2) .push(save_row) .spacing(space_s); - if let Some(name) = &self.pending_profile_replace { + if let Some(name) = self.state.pending_profile_replace() { save = save.push( widget::row::with_capacity(3) .push( @@ -32,36 +35,38 @@ impl AppModel { .width(Length::Fill), ) .push( - widget::button::standard("Cancel").on_press(Message::CancelReplaceProfile), + widget::button::standard("Cancel") + .on_press(Message::Data(Input::CancelReplaceProfile)), ) .push( widget::button::destructive("Replace") - .on_press(Message::ConfirmReplaceProfile), + .on_press(Message::Data(Input::ConfirmReplaceProfile)), ) .spacing(space_s) .align_y(Alignment::Center), ); } - let mut profiles = widget::column::with_capacity(self.profiles.len() + 1).spacing(space_s); - if self.profiles.is_empty() { + let saved = self.state.profiles(); + let mut profiles = widget::column::with_capacity(saved.len() + 1).spacing(space_s); + if saved.is_empty() { profiles = profiles.push(widget::text::body("No profiles saved yet.")); } - for name in &self.profiles { + for name in saved { let row = widget::row::with_capacity(4) .push(widget::text::body(name.clone()).width(Length::Fill)) .push( widget::button::suggested("Apply") - .on_press(Message::ApplyProfile(name.clone())), + .on_press(Message::Data(Input::ApplyProfile(name.clone()))), ) .push( widget::button::standard("Hotkey") - .on_press(Message::AddProfileHotkey(name.clone())), + .on_press(Message::Data(Input::AddProfileHotkey(name.clone()))), ) .push(icons::icon_button( AppIcon::Trash, "Delete", theme::Button::Destructive, - Message::RequestDeleteProfile(name.clone()), + Message::Data(Input::RequestDeleteProfile(name.clone())), )) .spacing(space_s) .align_y(Alignment::Center); diff --git a/src/app/settings_views.rs b/ui/src/settings_views.rs similarity index 68% rename from src/app/settings_views.rs rename to ui/src/settings_views.rs index 8eb6874..9b38e60 100644 --- a/src/app/settings_views.rs +++ b/ui/src/settings_views.rs @@ -1,23 +1,15 @@ -use super::config_ops::ConfigOp; -use super::{AppModel, Message}; -use crate::config::TurnOffBehavior; +use crate::{AppModel, Message}; use cosmic::Element; use cosmic::iced::Length; use cosmic::widget; +use data::config::TurnOffBehavior; +use data::state::Input; impl AppModel { - /// Persist startup settings and keep the per-user Run key in step. - pub(super) fn set_windows_startup( - &mut self, - enabled: bool, - minimized: bool, - ) -> cosmic::app::Task { - self.enqueue_config_op(ConfigOp::SetStartup { enabled, minimized }) - } - /// Settings shown in the header context drawer. pub(super) fn view_settings(&self) -> Element<'_, Message> { let space_s = cosmic::theme::spacing().space_s; + let config = self.state.config(); let description = widget::text::body("Configure startup, hotkeys, and how displays are turned off."); @@ -27,7 +19,8 @@ impl AppModel { cosmic::widget::settings::item::builder("Enable global hotkeys") .description("When disabled, hotkeys will not trigger any actions") .control( - widget::toggler(self.config.hotkeys_enabled).on_toggle(Message::ToggleHotkeys), + widget::toggler(config.hotkeys_enabled) + .on_toggle(|enabled| Message::Data(Input::ToggleHotkeys(enabled))), ), ); @@ -37,13 +30,13 @@ impl AppModel { .collect(); let selected_turn_off = TurnOffBehavior::ALL .iter() - .position(|behavior| behavior == &self.config.turn_off_behavior); + .position(|behavior| behavior == &config.turn_off_behavior); let power_section = cosmic::widget::settings::section() .title("Turn Off Displays") .add( cosmic::widget::settings::item::builder("Power-off method").control( widget::dropdown(turn_off_labels, selected_turn_off, |selected| { - Message::SetTurnOffBehavior(TurnOffBehavior::ALL[selected]) + Message::Data(Input::SetTurnOffBehavior(TurnOffBehavior::ALL[selected])) }), ), ); @@ -52,17 +45,18 @@ impl AppModel { cosmic::widget::settings::item::builder("Start with Windows") .description("Launch when you sign in") .control( - widget::toggler(self.config.start_with_windows) - .on_toggle(Message::ToggleStartWithWindows), + widget::toggler(config.start_with_windows) + .on_toggle(|enabled| Message::Data(Input::ToggleStartWithWindows(enabled))), ), ); - if self.config.start_with_windows { + if config.start_with_windows { startup_section = startup_section.add( cosmic::widget::settings::item::builder("Start minimized") .description("Stay in the system tray until you open it") .control( - widget::toggler(self.config.start_minimized) - .on_toggle(Message::ToggleStartMinimized), + widget::toggler(config.start_minimized).on_toggle(|minimized| { + Message::Data(Input::ToggleStartMinimized(minimized)) + }), ), ); } diff --git a/ui/src/subscriptions.rs b/ui/src/subscriptions.rs new file mode 100644 index 0000000..caeddde --- /dev/null +++ b/ui/src/subscriptions.rs @@ -0,0 +1,70 @@ +use cosmic::iced::futures::SinkExt; +use cosmic::iced::futures::channel::mpsc::Sender; +use cosmic::iced::{Subscription, stream}; +use data::tray::{TrayMessage, TrayStream}; +use std::hash::{Hash, Hasher}; +use std::time::Duration; + +/// Identity for the hotkey subscription; it restarts when bindings change. +struct HotkeyData { + generation: u64, + registered_ids: Vec, +} + +impl Hash for HotkeyData { + fn hash(&self, state: &mut H) { + self.generation.hash(state); + self.registered_ids.hash(state); + } +} + +/// Emits the OS id of each registered hotkey press. The caller resolves the +/// current action chain by id. +pub(crate) fn hotkeys(mut registered_ids: Vec, generation: u64) -> Subscription { + registered_ids.sort_unstable(); + registered_ids.dedup(); + + Subscription::run_with( + HotkeyData { + generation, + registered_ids, + }, + |subscription| { + let registered_ids = subscription.registered_ids.clone(); + stream::channel(16, move |mut emitter: Sender| async move { + data::hotkeys::discard_pending_events(); + loop { + for id in data::hotkeys::take_pressed(®istered_ids) { + if emitter.send(id).await.is_err() { + return; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + }, + ) +} + +/// Identity wrapper so a `TrayStream` can be used as `Subscription` data. +/// There is only ever one tray, so the identity is constant. +struct TrayId(TrayStream); + +impl Hash for TrayId { + fn hash(&self, state: &mut H) { + "system-tray".hash(state); + } +} + +pub(crate) fn tray(tray_stream: TrayStream) -> Subscription { + Subscription::run_with(TrayId(tray_stream), |data| { + let receiver = data.0.clone(); + stream::channel(1, move |mut sender: Sender| async move { + while let Some(msg) = receiver.recv().await { + if sender.send(msg).await.is_err() { + break; + } + } + }) + }) +} diff --git a/src/app/views.rs b/ui/src/views.rs similarity index 81% rename from src/app/views.rs rename to ui/src/views.rs index 6ef1cba..8d1522e 100644 --- a/src/app/views.rs +++ b/ui/src/views.rs @@ -1,7 +1,8 @@ -use super::{AppModel, Message}; +use crate::{AppModel, Message}; use cosmic::Element; use cosmic::iced::{Alignment, Length}; use cosmic::widget; +use data::state::Input; impl AppModel { pub(super) fn view_about(&self) -> Element<'_, Message> { @@ -11,7 +12,7 @@ impl AppModel { .push( widget::row::with_capacity(2) .push( - widget::text_input("", &self.config_path) + widget::text_input("", self.state.config_path()) .on_input(|_| Message::ConfigPathInput) .width(Length::Fill), ) @@ -35,26 +36,23 @@ impl AppModel { } pub(super) fn view_config_recovery(&self) -> Element<'_, Message> { - let error = self - .config_store - .recovery_error() - .unwrap_or("Unknown load error"); + let error = self.state.recovery_error().unwrap_or("Unknown load error"); let mut content = widget::column::with_capacity(6) .push(widget::text::title3("Configuration recovery required")) - .push(widget::text::body(format!("{}: {error}", self.config_store.path().display()))) + .push(widget::text::body(format!("{}: {error}", self.state.store_path().display()))) .push(widget::text::body("The file has not been replaced. Configuration edits, saves, and hotkeys are blocked until recovery.")) .push(widget::row::with_capacity(3) - .push(widget::button::standard("Retry loading").on_press(Message::RetryConfig)) - .push(widget::button::standard("Recover backup").on_press(Message::RecoverConfigBackup)) - .push(widget::button::standard("Reset to defaults...").on_press(Message::RequestResetConfig)) + .push(widget::button::standard("Retry loading").on_press(Message::Data(Input::RetryConfig))) + .push(widget::button::standard("Recover backup").on_press(Message::Data(Input::RecoverConfigBackup))) + .push(widget::button::standard("Reset to defaults...").on_press(Message::Data(Input::RequestResetConfig))) .spacing(8)) .spacing(8); - if self.pending_config_reset { + if self.state.pending_config_reset() { content = content .push(widget::text::body("Discard the configuration file and write defaults? This cannot be undone. Any existing backup will be kept.")) .push(widget::row::with_capacity(2) - .push(widget::button::destructive("Confirm reset").on_press(Message::ConfirmResetConfig)) - .push(widget::button::standard("Cancel").on_press(Message::CancelResetConfig)) + .push(widget::button::destructive("Confirm reset").on_press(Message::Data(Input::ConfirmResetConfig))) + .push(widget::button::standard("Cancel").on_press(Message::Data(Input::CancelResetConfig))) .spacing(8)); } widget::container(content)