diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c711f5..a0e9e34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Platforms + +- Changed the app and tray icon to a circular design +- Fixed source installs failing on a fresh dependency resolution + ## 0.3.0 (2026-09-03) ### Platforms diff --git a/Cargo.lock b/Cargo.lock index cdbd461..24232a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1670,6 +1670,7 @@ dependencies = [ "tray-icon", "ureq", "windows-sys 0.59.0", + "winit-core", "winresource", "x11rb", ] @@ -1682,6 +1683,7 @@ dependencies = [ "fd-lock", "serde", "serde_json", + "thiserror 2.0.20", "toml 1.1.5+spec-1.1.0", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 1724210..18dd3ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ iced = { version = "0.14", default-features = false, features = [ ] } iced_layershell = "0.19.1" iced_runtime = "0.14" +# pinned: iced_exdevtools 0.19.1 (unconditional dep of iced_layershell 0.19.1) +# fails to compile against winit-core 0.31.0-beta.3; remove once iced_layershell +# ships a release on the fixed iced_exdevtools 0.20 line +winit-core = "=0.31.0-beta.2" tokio = { version = "1", features = ["rt", "net", "io-util", "sync", "time", "macros"] } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } futures-channel = { version = "0.3", default-features = false, features = ["std"] } @@ -48,6 +52,7 @@ dirs = "6" fd-lock = "4.0" interprocess = { version = "2", features = ["tokio"] } tracing = "0.1" +thiserror = "2.0" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" clap = { version = "4.5", default-features = false, features = [ @@ -63,6 +68,7 @@ hyprlay-core = { workspace = true } clap = { workspace = true } iced = { workspace = true } iced_runtime = { workspace = true } +winit-core = { workspace = true } tokio = { workspace = true } futures-util = { workspace = true } futures-channel = { workspace = true } diff --git a/README.md b/README.md index 54b01b1..c0c62a5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Download the latest binaries from [releases page](https://github.com/FAZuH/hyprl Or install with [Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html): ```sh -cargo install --git https://github.com/FAZuH/hyprlay +cargo install --locked --git https://github.com/FAZuH/hyprlay # Or build from source: cargo build --release diff --git a/assets/hyprlay-128.png b/assets/hyprlay-128.png index e75fcb0..898f8ca 100644 Binary files a/assets/hyprlay-128.png and b/assets/hyprlay-128.png differ diff --git a/assets/hyprlay-256.png b/assets/hyprlay-256.png index 80b6350..a4b2f20 100644 Binary files a/assets/hyprlay-256.png and b/assets/hyprlay-256.png differ diff --git a/assets/hyprlay-48.png b/assets/hyprlay-48.png index 56a689e..3112ea2 100644 Binary files a/assets/hyprlay-48.png and b/assets/hyprlay-48.png differ diff --git a/assets/hyprlay-64.png b/assets/hyprlay-64.png index 6781dee..59423d2 100644 Binary files a/assets/hyprlay-64.png and b/assets/hyprlay-64.png differ diff --git a/assets/hyprlay.ico b/assets/hyprlay.ico index 6e149ce..a811325 100644 Binary files a/assets/hyprlay.ico and b/assets/hyprlay.ico differ diff --git a/assets/hyprlay.svg b/assets/hyprlay.svg index 80b6fbc..ccae025 100644 --- a/assets/hyprlay.svg +++ b/assets/hyprlay.svg @@ -1,19 +1,6 @@ - - - - - + + + + + diff --git a/assets/tray-connected.png b/assets/tray-connected.png index 0696efd..3112ea2 100644 Binary files a/assets/tray-connected.png and b/assets/tray-connected.png differ diff --git a/assets/tray-disconnected.png b/assets/tray-disconnected.png index c0df9ec..e331041 100644 Binary files a/assets/tray-disconnected.png and b/assets/tray-disconnected.png differ diff --git a/crates/hyprlay-core/Cargo.toml b/crates/hyprlay-core/Cargo.toml index 3b1d7e6..ea189ed 100644 --- a/crates/hyprlay-core/Cargo.toml +++ b/crates/hyprlay-core/Cargo.toml @@ -16,4 +16,5 @@ serde_json = { workspace = true } toml = { workspace = true } dirs = { workspace = true } tracing = { workspace = true } +thiserror = { workspace = true } fd-lock = { workspace = true } diff --git a/crates/hyprlay-core/src/daemon_control.rs b/crates/hyprlay-core/src/daemon_control.rs index b42061a..5c16410 100644 --- a/crates/hyprlay-core/src/daemon_control.rs +++ b/crates/hyprlay-core/src/daemon_control.rs @@ -19,6 +19,161 @@ use std::path::Path; +use crate::bins::DAEMON_BIN; + +/// Why a service-control operation failed. Part of the [`DaemonControl`] / +/// [`ServiceManager`] port contract: the backends in the host package +/// construct it, fronts render it through `Display`. +/// +/// The `Display` strings are the observable wording — toggle failures land +/// in the GUI/tray status line and install failures in the CLI's stderr — +/// so each variant transcribes its old `format!` string byte-for-byte, and +/// the per-variant pins below lock that transcription. Failure *identity* +/// (which operation, which path, which command) is typed; only payloads +/// that were already strings (subcommand words, command stderr) stay +/// strings. +/// +/// Deliberately **not** exhaustive-by-attribute at the variant level: the +/// adapters must construct every variant from outside this crate. The +/// enum-level `#[non_exhaustive]` only forces wildcard matches on future +/// consumers, leaving construction open. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ServiceError { + /// `systemctl --user ` could not be spawned at all: + /// `error: could not run systemctl: `. + #[error("error: could not run systemctl: {source}")] + SystemctlNotRun { + #[source] + source: std::io::Error, + }, + /// `systemctl --user hyprlay` exited non-zero; the detail + /// is systemctl's trimmed stderr, or `exit ` when it printed + /// nothing: `error: systemctl failed: `. + #[error("error: systemctl {subcommand} failed: {detail}")] + SystemctlFailed { subcommand: String, detail: String }, + /// A service command could not be spawned: `: could not run + /// : `. + #[error("{command}: could not run {program}: {source}")] + CommandNotRun { + command: String, + program: &'static str, + #[source] + source: std::io::Error, + }, + /// A service command exited non-zero; the detail is its trimmed stderr, + /// or `exited with ` when it printed nothing: `: + /// `. + #[error("{command}: {detail}")] + CommandFailed { command: String, detail: String }, + /// A `systemctl --user ` step of an install failed after the + /// files were written: `systemctl --user failed: `. + #[error("systemctl --user {step} failed: {source}")] + SystemctlStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// A `launchctl ` step of the start flow failed: + /// `error: launchctl failed: `. + #[error("error: launchctl {step} failed: {source}")] + LaunchctlStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// A `launchctl ` step of an install failed: + /// `launchctl failed: `. + #[error("launchctl {step} failed: {source}")] + LaunchctlInstallStepFailed { + step: &'static str, + #[source] + source: Box, + }, + /// `std::env::current_exe()` failed: `error: could not locate the + /// running hyprlay binary: `. + #[error("error: could not locate the running hyprlay binary: {source}")] + LocateExe { + #[source] + source: std::io::Error, + }, + /// The running binary's path has no parent directory: `error: could not + /// find the directory of `. + #[error("error: could not find the directory of {exe}")] + NoExeParent { exe: std::path::PathBuf }, + /// The sibling daemon binary is missing from the install directory: + /// `error: hyprlayd not found next to the running hyprlay binary + /// (expected )\nthe hyprlay binaries must be installed together`. + #[error( + "error: {} not found next to the running hyprlay binary (expected {path})\nthe hyprlay binaries must be installed together", + DAEMON_BIN + )] + DaemonMissing { path: std::path::PathBuf }, + /// Spawning the detached sibling daemon failed: `error: could not start + /// hyprlayd: `. + #[error("error: could not start {}: {source}", DAEMON_BIN)] + SpawnDaemon { + #[source] + source: std::io::Error, + }, + /// The ctl-socket quit got no answer: `error: daemon unreachable`. + #[error("error: daemon unreachable")] + DaemonUnreachable, + /// A subcommand this backend does not implement (only reachable on + /// macOS/Windows, whose backends drive a subset of the port): + /// `error: unsupported subcommand: `. + #[error("error: unsupported {backend} subcommand: {subcommand}")] + UnsupportedSubcommand { + backend: &'static str, + subcommand: String, + }, + /// A platform directory could not be resolved (the LaunchAgents dir on + /// macOS, the Startup folder on Windows): `error: could not resolve the + /// `. + #[error("error: could not resolve the {what}")] + ResolveDir { what: &'static str }, + /// The LaunchAgent plist path is not valid UTF-8, so it cannot ride in + /// a `launchctl` argument vector: `error: plist path is not UTF-8`. + #[error("error: plist path is not UTF-8")] + NonUtf8PlistPath, + /// A required sibling binary is missing from the install directory, so + /// the install aborts before writing anything: `error: missing binaries + /// for install: \nthe hyprlay binaries must be installed + /// together`. + #[error( + "error: missing binaries for install: {names}\nthe hyprlay binaries must be installed together" + )] + MissingInstallBins { names: String }, + /// Creating a service-config file's parent directory failed: + /// `could not create : `. + #[error("could not create {path}: {source}")] + CreateDirFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// Writing a service-config file failed: `could not write : `. + #[error("could not write {path}: {source}")] + WriteFileFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// Removing a service-config file failed: `could not remove : + /// `. + #[error("could not remove {path}: {source}")] + RemoveFileFailed { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, + /// The default install/uninstall of an OS that cannot host the hyprlay + /// service: `install is not supported on this platform` / + /// `uninstall is not supported on this platform`. + #[error("{operation} is not supported on this platform")] + UnsupportedOperation { operation: &'static str }, +} + /// Which direction a toggle press drives the daemon. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Toggle { @@ -48,7 +203,7 @@ pub enum StopPolicy { /// stays testable without touching systemctl or the daemon. pub trait DaemonControl: Send + Sync { fn unit_installed(&self) -> bool; - fn perform(&self, action: Action) -> Result<(), String>; + fn perform(&self, action: Action) -> Result<(), ServiceError>; } /// The service-management boundary behind a [`DaemonControl`]: the systemctl @@ -66,11 +221,11 @@ pub trait ServiceManager: Send + Sync { /// Whether the user service unit for the daemon is installed. fn unit_installed(&self) -> bool; /// Run `systemctl --user `. - fn systemctl(&self, subcommand: &str) -> Result<(), String>; + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError>; /// Detached spawn of the sibling daemon binary. - fn spawn_daemon(&self) -> Result<(), String>; + fn spawn_daemon(&self) -> Result<(), ServiceError>; /// Quit the running daemon over the control socket. - fn quit_via_socket(&self) -> Result<(), String>; + fn quit_via_socket(&self) -> Result<(), ServiceError>; /// Install the autostart service config next to `exe_dir` and register /// it; `start` controls whether it begins running immediately. Returns /// one human-readable line per step. @@ -80,15 +235,19 @@ pub trait ServiceManager: Send + Sync { config_base: &Path, data_base: &Path, start: bool, - ) -> Result, String> { + ) -> Result, ServiceError> { let _ = (exe_dir, config_base, data_base, start); - Err("install is not supported on this platform".to_string()) + Err(ServiceError::UnsupportedOperation { + operation: "install", + }) } /// Uninstall the autostart service config and deregister it. Returns one /// human-readable line per step. - fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, String> { + fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, ServiceError> { let _ = (config_base, data_base); - Err("uninstall is not supported on this platform".to_string()) + Err(ServiceError::UnsupportedOperation { + operation: "uninstall", + }) } } @@ -124,12 +283,15 @@ pub fn execute_toggle( stop: StopPolicy, ) -> Option { let action = plan_action(toggle, control.unit_installed(), stop); - control.perform(action).err() + control.perform(action).err().map(|e| e.to_string()) } #[cfg(test)] mod tests { + use std::error::Error; + use super::Action; + use super::ServiceError; use super::StopPolicy; use super::Toggle; use super::plan_action; @@ -181,4 +343,190 @@ mod tests { ); } } + + // ------------------------------------------------------------------- + // ServiceError characterization pins. Each row locks one variant's + // Display to the exact string the pre-typed `format!` sites produced; + // rewording any of them is a breaking change to the surfaces that print + // service errors (GUI/tray status lines, the CLI's stderr). + // ------------------------------------------------------------------- + + fn pinned_io() -> std::io::Error { + std::io::Error::other("disk on strike") + } + + fn pinned_error() -> ServiceError { + ServiceError::CommandFailed { + command: "daemon-reload".into(), + detail: "unit not found".into(), + } + } + + #[test] + fn service_error_display_reproduces_the_pinned_wording() { + use super::ServiceError as E; + let cases: [(E, &str); 21] = [ + ( + E::SystemctlNotRun { + source: pinned_io(), + }, + "error: could not run systemctl: disk on strike", + ), + ( + E::SystemctlFailed { + subcommand: "stop".into(), + detail: "unit not loaded".into(), + }, + "error: systemctl stop failed: unit not loaded", + ), + ( + E::CommandNotRun { + command: "daemon-reload".into(), + program: "systemctl", + source: pinned_io(), + }, + "daemon-reload: could not run systemctl: disk on strike", + ), + ( + E::CommandFailed { + command: "bootout gui/1000/hyprlay.user".into(), + detail: "exited with 3".into(), + }, + "bootout gui/1000/hyprlay.user: exited with 3", + ), + ( + E::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(pinned_error()), + }, + "systemctl --user daemon-reload failed: daemon-reload: unit not found", + ), + ( + E::LaunchctlStepFailed { + step: "bootstrap", + source: Box::new(pinned_error()), + }, + "error: launchctl bootstrap failed: daemon-reload: unit not found", + ), + ( + E::LaunchctlInstallStepFailed { + step: "enable", + source: Box::new(pinned_error()), + }, + "launchctl enable failed: daemon-reload: unit not found", + ), + ( + E::LocateExe { + source: pinned_io(), + }, + "error: could not locate the running hyprlay binary: disk on strike", + ), + ( + E::NoExeParent { + exe: "/proc/self/exe".into(), + }, + "error: could not find the directory of /proc/self/exe", + ), + ( + E::DaemonMissing { + path: "/opt/hyprlay/hyprlayd".into(), + }, + "error: hyprlayd not found next to the running hyprlay binary \ + (expected /opt/hyprlay/hyprlayd)\nthe hyprlay binaries must be installed together", + ), + ( + E::SpawnDaemon { + source: pinned_io(), + }, + "error: could not start hyprlayd: disk on strike", + ), + (E::DaemonUnreachable, "error: daemon unreachable"), + ( + E::UnsupportedSubcommand { + backend: "Windows startup", + subcommand: "reload".into(), + }, + "error: unsupported Windows startup subcommand: reload", + ), + ( + E::ResolveDir { + what: "LaunchAgents dir", + }, + "error: could not resolve the LaunchAgents dir", + ), + (E::NonUtf8PlistPath, "error: plist path is not UTF-8"), + ( + E::MissingInstallBins { + names: "hyprlayd".into(), + }, + "error: missing binaries for install: hyprlayd\nthe hyprlay binaries must be installed together", + ), + ( + E::CreateDirFailed { + path: "/home/u/.config/hyprlay/systemd/user".into(), + source: pinned_io(), + }, + "could not create /home/u/.config/hyprlay/systemd/user: disk on strike", + ), + ( + E::WriteFileFailed { + path: "/home/u/.config/hyprlay/systemd/user/hyprlay.service".into(), + source: pinned_io(), + }, + "could not write /home/u/.config/hyprlay/systemd/user/hyprlay.service: disk on strike", + ), + ( + E::RemoveFileFailed { + path: "/home/u/.local/share/applications/hyprlay.desktop".into(), + source: pinned_io(), + }, + "could not remove /home/u/.local/share/applications/hyprlay.desktop: disk on strike", + ), + ( + E::UnsupportedOperation { + operation: "install", + }, + "install is not supported on this platform", + ), + ( + E::UnsupportedOperation { + operation: "uninstall", + }, + "uninstall is not supported on this platform", + ), + ]; + for (error, expected) in cases { + assert_eq!(error.to_string(), expected, "{error:?}"); + } + } + + #[test] + fn io_failures_keep_the_io_error_as_their_source() { + let inner = pinned_io(); + let error = ServiceError::WriteFileFailed { + path: "/tmp/hyprlay.service".into(), + source: inner, + }; + let source = error + .source() + .expect("the io::Error rides along as the source"); + assert!(source.downcast_ref::().is_some()); + } + + #[test] + fn nested_step_failures_chain_through_their_source() { + let inner = pinned_error(); + let error = ServiceError::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(inner), + }; + let source = error + .source() + .expect("the wrapped service failure is the source"); + assert_eq!( + source.to_string(), + "daemon-reload: unit not found", + "the cause chain ends at the raw command failure" + ); + } } diff --git a/crates/hyprlay-core/src/lib.rs b/crates/hyprlay-core/src/lib.rs index 9c8fa0c..280e76b 100644 --- a/crates/hyprlay-core/src/lib.rs +++ b/crates/hyprlay-core/src/lib.rs @@ -14,3 +14,4 @@ pub mod daemon_control; pub mod domain; pub mod platform; pub mod singleton; +pub mod status; diff --git a/crates/hyprlay-core/src/status.rs b/crates/hyprlay-core/src/status.rs new file mode 100644 index 0000000..c06bd07 --- /dev/null +++ b/crates/hyprlay-core/src/status.rs @@ -0,0 +1,411 @@ +//! The `status` reply line: one source of truth for the byte-stable wire +//! contract shared by the daemon (writer) and the tray/GUI (readers). +//! +//! The daemon fills a [`StatusFields`] from live state and config and sends +//! [`to_wire`](StatusFields::to_wire) as the `status` reply; every front +//! reads replies back through [`parse_wire`](StatusFields::parse_wire). +//! Field order and spelling are the observable contract (CONTEXT.md: +//! replies are pinned byte-for-byte; ADR-002: new fields append at the end), +//! so both directions live in this one module and change together. +//! +//! Parsing stays lenient bug-for-bug: an unreadable number yields 0, a +//! missing trailing field (older daemon) yields its default, and the +//! `channel` value is sliced between its markers because channel names may +//! contain spaces. + +use std::fmt; + +use crate::domain::ConnectionStatus; + +/// One parsed `status=` reply, one field per wire key. +#[derive(Debug, PartialEq, Eq)] +pub struct StatusFields { + /// Connection-status word, e.g. `connected`, `disconnected`, `off`. + /// Writers build it from the sealed [`ConnectionStatus`] vocabulary; + /// readers get the raw wire token, so a word this build does not know + /// still parses (forward-compat) and reaches the UI verbatim. + pub status_word: StatusWord, + /// Voice-channel name; `-` when not connected. May contain spaces. + pub channel: String, + /// Number of roster rows the overlay displays. + pub participants: usize, + /// Corner word (`top-left` … `bottom-right`). + pub position: String, + /// Right-to-left layout flag. + pub rtl: bool, + /// Overlay visibility flag. + pub visible: bool, + /// Anchor-mode word (`auto`, `top`, `bottom`). + pub anchor: String, + /// Global scale in percent. + pub scale: u8, + /// Overall opacity in percent. + pub opacity: u8, + /// Horizontal distance in px from the anchored screen edge. + pub offset_x: i32, + /// Vertical distance in px from the anchored screen edge. + pub offset_y: i32, + /// Target output name; `None` renders as `active`, and parsing maps + /// the literal `active` back to `None` (the wire cannot tell them apart). + pub monitor: Option, + /// Auth backend label, e.g. `own-app`. + pub auth: String, + /// Overlay-layer flag from ADR-002. + pub show_on_fullscreen: bool, + /// Hover-dim flag from ADR-002. + pub dim_on_hover: bool, + /// Hover opacity in percent from ADR-002. + pub hover_opacity: u8, +} + +impl StatusFields { + /// Whether a reply line is a status reply. The cheap pre-filter for + /// liveness probes that only need "a daemon answered `status`". + pub fn is_status_line(reply: &str) -> bool { + reply.starts_with("status=") + } + + /// Render the wire line, byte-identical to the daemon's `status` reply. + pub fn to_wire(&self) -> String { + format!( + "status={} channel={} participants={} position={} rtl={} visible={} \ + anchor={} scale={} opacity={} offset=({},{}) monitor={} auth={} \ + show-on-fullscreen={} dim-on-hover={} hover-opacity={}", + self.status_word, + self.channel, + self.participants, + self.position, + on_off(self.rtl), + on_off(self.visible), + self.anchor, + self.scale, + self.opacity, + self.offset_x, + self.offset_y, + self.monitor.as_deref().unwrap_or("active"), + self.auth, + on_off(self.show_on_fullscreen), + on_off(self.dim_on_hover), + self.hover_opacity, + ) + } + + /// Parse a `status` reply line. Returns `None` for non-status lines. + /// Lenient bug-for-bug: an unreadable number yields 0 and a missing + /// field (older daemon) yields its default. + pub fn parse_wire(line: &str) -> Option { + if !Self::is_status_line(line) { + return None; + } + // Channel names may contain spaces, so the value is sliced between + // its markers instead of read as a token. + let channel = line + .find("channel=") + .and_then(|start| { + line[start..] + .find(" participants=") + .map(|end| line[start + "channel=".len()..start + end].to_string()) + }) + .unwrap_or_default(); + let (offset_x, offset_y) = parse_offset(token_after(line, " offset=")); + Some(Self { + status_word: StatusWord(word(line, "status=")), + channel, + participants: number(token_after(line, " participants=")), + position: word(line, " position="), + rtl: token_after(line, " rtl=") == Some("on"), + visible: token_after(line, " visible=") == Some("on"), + anchor: word(line, " anchor="), + scale: number(token_after(line, " scale=")), + opacity: number(token_after(line, " opacity=")), + offset_x, + offset_y, + monitor: match token_after(line, " monitor=") { + Some("active") | None => None, + Some(name) => Some(name.to_string()), + }, + auth: word(line, " auth="), + show_on_fullscreen: token_after(line, " show-on-fullscreen=") == Some("on"), + dim_on_hover: token_after(line, " dim-on-hover=") == Some("on"), + hover_opacity: number(token_after(line, " hover-opacity=")), + }) + } +} + +/// The connection-status word of a `status` reply. Opaque on purpose: the +/// wire contract has a sealed write side and a lenient read side. +/// +/// - **Write** (daemon): the only way in is [`From`], so a +/// bare string can never reach the wire and the word set cannot drift from +/// the enum's `Display` spelling. +/// - **Read** (tray/GUI): [`StatusFields::parse_wire`] fills the word from +/// the raw line, unknown words included — a newer daemon may already speak +/// words this build has never seen (ADR-002 forward-compat). +/// +/// [`Display`] renders the stored word unchanged in both directions, which +/// is what keeps `to_wire()` byte-identical. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusWord(String); + +impl From for StatusWord { + fn from(status: ConnectionStatus) -> Self { + Self(status.to_string()) + } +} + +impl fmt::Display for StatusWord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl StatusWord { + /// The raw wire token. Readers compare or show it verbatim; nothing + /// maps it back onto [`ConnectionStatus`], because the vocabulary is + /// open on the wire (a future daemon's word must survive the round trip). + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn on_off(value: bool) -> &'static str { + if value { "on" } else { "off" } +} + +/// The whitespace-delimited text that follows `marker` in the line, without +/// the marker. Field markers carry their leading space so they can only +/// match at a field boundary; `status=` is the exception, anchored at +/// position 0 by [`StatusFields::is_status_line`]. +fn token_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> { + let start = line.find(marker)? + marker.len(); + let rest = &line[start..]; + Some(&rest[..rest.find(' ').unwrap_or(rest.len())]) +} + +fn word(line: &str, marker: &str) -> String { + token_after(line, marker).unwrap_or_default().to_string() +} + +/// Lenient number reading, bug-for-bug: anything unparseable is 0. +fn number(value: Option<&str>) -> T { + value.and_then(|v| v.parse().ok()).unwrap_or_default() +} + +/// The `offset=(x,y)` tuple form; malformed or missing yields `(0, 0)`. +fn parse_offset(value: Option<&str>) -> (i32, i32) { + let inner = value + .unwrap_or_default() + .trim_start_matches('(') + .trim_end_matches(')'); + let (x, y) = inner.split_once(',').unwrap_or_default(); + (number(Some(x)), number(Some(y))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_preserves_every_field() { + let fields = StatusFields { + status_word: ConnectionStatus::Connected.into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "bottom-right".into(), + rtl: true, + visible: true, + anchor: "top".into(), + scale: 120, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: Some("eDP-1".into()), + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: true, + hover_opacity: 40, + }; + let parsed = StatusFields::parse_wire(&fields.to_wire()).expect("roundtrip parses"); + assert_eq!(parsed.status_word.as_str(), "connected"); + assert_eq!(parsed.channel, "ngobrol 3"); + assert_eq!(parsed.participants, 2); + assert_eq!(parsed.position, "bottom-right"); + assert!(parsed.rtl); + assert!(parsed.visible); + assert_eq!(parsed.anchor, "top"); + assert_eq!(parsed.scale, 120); + assert_eq!(parsed.opacity, 90); + assert_eq!(parsed.offset_x, -12); + assert_eq!(parsed.offset_y, 34); + assert_eq!(parsed.monitor, Some("eDP-1".into())); + assert_eq!(parsed.auth, "own-app"); + assert!(parsed.show_on_fullscreen); + assert!(parsed.dim_on_hover); + assert_eq!(parsed.hover_opacity, 40); + } + + #[test] + fn to_wire_matches_the_pinned_wire_line() { + let fields = StatusFields { + status_word: ConnectionStatus::Connected.into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "top-left".into(), + rtl: true, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + }; + assert_eq!( + fields.to_wire(), + "status=connected channel=ngobrol 3 participants=2 position=top-left \ + rtl=on visible=off anchor=auto scale=100 opacity=90 offset=(-12,34) \ + monitor=active auth=own-app show-on-fullscreen=on dim-on-hover=off \ + hover-opacity=40" + ); + } + + #[test] + fn parse_wire_rejects_non_status_replies() { + assert_eq!(StatusFields::parse_wire("error: daemon unreachable"), None); + assert_eq!(StatusFields::parse_wire(""), None); + assert_eq!(StatusFields::parse_wire("connecting…"), None); + } + + #[test] + fn parse_wire_reads_a_lenient_participant_count_as_zero() { + // New pin, not a preserved behavior: the old tray echoed the raw + // token ("abc") into the summary. The daemon only ever writes + // numeric counts; lenient normalization to 0 is the ticket's spec. + let fields = + StatusFields::parse_wire("status=connected channel=a participants=abc").unwrap(); + assert_eq!(fields.participants, 0); + } + + #[test] + fn parse_wire_reads_a_malformed_offset_as_zero() { + // Untested-branch pin: the (0, 0) fallback for a malformed tuple. + let fields = StatusFields::parse_wire("status=connected channel=a offset=garbage").unwrap(); + assert_eq!(fields.offset_x, 0); + assert_eq!(fields.offset_y, 0); + } + + #[test] + fn parse_wire_defaults_missing_trailing_fields() { + // The oldest line shape: everything before ADR-002 appended its + // three fields at the end. + let fields = StatusFields::parse_wire( + "status=connected channel=a participants=2 position=top-left rtl=off \ + visible=on anchor=auto scale=100 opacity=90 offset=(0,0) \ + monitor=active auth=own-app", + ) + .unwrap(); + assert!(fields.visible); + assert_eq!(fields.monitor, None); + assert!(!fields.show_on_fullscreen); + assert!(!fields.dim_on_hover); + assert_eq!(fields.hover_opacity, 0); + } + + #[test] + fn parse_wire_ignores_unknown_trailing_fields() { + // ADR-002's append-at-end convention made structural: a future + // daemon's extra field must not corrupt the fields before it. + let line = "status=connected channel=ngobrol 3 participants=2 position=top-left \ + rtl=on visible=off anchor=auto scale=100 opacity=90 offset=(-12,34) \ + monitor=active auth=own-app show-on-fullscreen=on dim-on-hover=off \ + hover-opacity=40 future-knob=7"; + let fields = StatusFields::parse_wire(line).unwrap(); + assert_eq!( + fields, + StatusFields { + status_word: ConnectionStatus::Connected.into(), + channel: "ngobrol 3".into(), + participants: 2, + position: "top-left".into(), + rtl: true, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 90, + offset_x: -12, + offset_y: 34, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + } + ); + } + + #[test] + fn parse_wire_reads_the_status_word_as_the_first_token() { + // `exchanging token` is the only multi-word status; both current + // readers take the first whitespace-delimited token. + let fields = + StatusFields::parse_wire("status=exchanging token channel=a participants=1").unwrap(); + assert_eq!(fields.status_word.as_str(), "exchanging"); + assert_eq!(fields.channel, "a"); + assert_eq!(fields.participants, 1); + } + + #[test] + fn parse_wire_keeps_an_unknown_status_word_verbatim() { + // The reader-side half of the StatusWord contract: a future daemon + // may speak a word this build has never seen (ADR-002 forward-compat). + // The word must survive parsing and to_wire() byte-for-byte. + let fields = StatusFields::parse_wire("status=relinking channel=a participants=1").unwrap(); + assert_eq!(fields.status_word.as_str(), "relinking"); + assert_eq!( + fields.to_wire(), + "status=relinking channel=a participants=1 position= rtl=off visible=off \ + anchor= scale=0 opacity=0 offset=(0,0) monitor=active auth= \ + show-on-fullscreen=off dim-on-hover=off hover-opacity=0" + ); + } + + #[test] + fn status_word_accepts_only_the_sealed_vocabulary_on_the_write_side() { + // The writer-side half: the daemon's word set is the enum, spelled + // exactly as its Display does (wire contract). + let cases = [ + (ConnectionStatus::Connecting, "connecting"), + (ConnectionStatus::Authorize, "authorize"), + (ConnectionStatus::Authenticating, "authenticating"), + (ConnectionStatus::ExchangingToken, "exchanging token"), + (ConnectionStatus::Connected, "connected"), + (ConnectionStatus::Disconnected, "disconnected"), + ]; + for (status, word) in cases { + let wire = StatusFields { + status_word: status.into(), + channel: "-".into(), + participants: 0, + position: "top-left".into(), + rtl: false, + visible: false, + anchor: "auto".into(), + scale: 100, + opacity: 100, + offset_x: 0, + offset_y: 0, + monitor: None, + auth: "own-app".into(), + show_on_fullscreen: true, + dim_on_hover: false, + hover_opacity: 40, + } + .to_wire(); + assert!(wire.starts_with(&format!("status={word} ")), "{wire}"); + } + } +} diff --git a/dev.sh b/dev.sh index 7347086..62a1a62 100755 --- a/dev.sh +++ b/dev.sh @@ -2,7 +2,7 @@ # Development helper script # Usage: ./dev.sh [command1] [command2] ... -# commands: format | lint | test | docs | demo | all | help +# commands: format | lint | test | docs | all | help # plus any commands provided by modules (scripts/dev-*.sh, dev/*.sh, dev-*.sh) # Multiple commands can be specified and will execute left to right @@ -101,33 +101,6 @@ cmd_docs() { } dev_desc docs "Compile Mermaid diagrams to images" -cmd_demo() { - inf "Building release binary..." - cargo build --release - scs "Release build completed" - - inf "Creating wrapper script..." - local wrapper_dir="/tmp/tomo-demo-bin" - mkdir -p "$wrapper_dir" - cat > "$wrapper_dir/tomo" << SCRIPT -#!/bin/bash -exec $PWD/target/release/tomo --config-path /tmp/tomo-demo "\$@" -SCRIPT - chmod +x "$wrapper_dir/tomo" - export PATH="$wrapper_dir:$PATH" - trap "rm -rf $wrapper_dir" EXIT - scs "Wrapper created at $wrapper_dir/tomo" - - if ! command -v vhs &> /dev/null; then - wrn "vhs not found. Install it: https://github.com/charmbracelet/vhs" - fi - - inf "Running demo tape..." - vhs scripts/demo.tape - scs "Demo tape completed" -} -dev_desc demo "Build release, alias, and run vhs demo tape" - cmd_all() { inf "Running all tasks..." cmd_format @@ -180,7 +153,6 @@ Examples: ./dev.sh lint # Run linter ./dev.sh test # Run tests ./dev.sh docs # Compile Mermaid diagrams - ./dev.sh demo # Build release, alias, and run demo tape ./dev.sh format lint # Format then lint ./dev.sh all # Run format, lint, and test diff --git a/docs/adr/002-overlay-layer-for-fullscreen.md b/docs/adr/002-overlay-layer-for-fullscreen.md index b7d002a..e22d610 100644 --- a/docs/adr/002-overlay-layer-for-fullscreen.md +++ b/docs/adr/002-overlay-layer-for-fullscreen.md @@ -37,5 +37,5 @@ Conflates "whether" with "how much" and makes the GUI slider ambiguous. Two knob ## Consequences - Toggling `show-on-fullscreen` costs a daemon restart (control socket downtime ~100 ms, roster re-hydrated from cache). Same cost/UX as `monitor` changes. - Hover polling is 20 Hz only while enabled and displayable; otherwise no cursor IPC traffic. View re-renders only on hover edge transitions (diff-gated). -- `status` reply appends `show-on-fullscreen`, `dim-on-hover`, `hover-opacity` at the end (whitespace-token whitespace — existing tray parsers that split on ` ` and `=` remain compatible). +- `status` reply appends `show-on-fullscreen`, `dim-on-hover`, `hover-opacity` at the end. Amended 2026-09-03: the convention is now structural — `hyprlay_core::status::StatusFields` builds and parses the whole line, so appending a field is a one-place edit and readers survive it by construction (the hand-rolled token parsers this bullet originally justified are gone). - `CONTEXT.md` gains `Overlay layer`, `Dim on hover`, `Hover opacity` vocabulary; the `Daemon-side commands` invariant now lists `set show-on-fullscreen`. diff --git a/docs/adr/003-settings-single-page-navigation.md b/docs/adr/003-settings-single-page-navigation.md index d02b63d..5ea2ed8 100644 --- a/docs/adr/003-settings-single-page-navigation.md +++ b/docs/adr/003-settings-single-page-navigation.md @@ -9,7 +9,7 @@ Accepted ## Context The settings GUI (`hyprlay-gui`, `src/gui/`) shows one section at a time. -The sidebar sends `Message::Section(Section)` (`src/gui/mod.rs`, `sidebar`), +The sidebar sends `Message::Section(Section)` (`src/gui/view.rs`, `sidebar`), and `section_page(gui, section)` (`src/gui/fields.rs`) renders that section alone inside its own scrollable. Moving between sections means a full page swap, so the user cannot see Position and Layout together or compare values @@ -32,7 +32,7 @@ sources for the needed mechanics: - `iced_runtime::task::widget(operation)` runs a custom operation and delivers its `finish()` output as a message. The repo already uses a runtime operation this way (`iced_runtime::widget::operation::focus` in - `src/gui/mod.rs`). + `src/gui/update.rs`). The repo uses this same widget-id mechanism for the search box (`SEARCH_ID` in `src/gui/fields.rs`). @@ -108,12 +108,12 @@ fires exactly when the offset changes, so polling is strictly more work. end. `Navigate → Measured → scroll_to` fires `on_scroll` once on landing, which re-measures and re-derives the same section. - The sidebar highlight stays suppressed while search text is set; the - existing selected condition in `sidebar` (`src/gui/mod.rs`) keeps its + existing selected condition in `sidebar` (`src/gui/view.rs`) keeps its `gui.search.trim().is_empty()` guard. - New state on `Gui`: `section_offsets` and `last_scroll_y`, plus the `CONTENT_SCROLL_ID` and per-section anchor widget ids. Two pure helpers, `active_section_for` and `offset_within_content`, carry the math and are - unit-tested in `src/gui/mod.rs`. + unit-tested in `src/gui/scroll.rs`. - Offsets measured at jump time mean stale measurements cannot send the viewport to the wrong place, at the cost of one measure operation per scroll event batch. diff --git a/docs/dev/code-layout.md b/docs/dev/code-layout.md index 0fc18d8..403b1c3 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -59,7 +59,11 @@ boundary. | `src/daemon/adapters/ipc.rs` | transport-agnostic `IpcStream` + `DiscordTransport` port | Discord's local IPC wire format: 8-byte LE header, handshake, PING/PONG; per-OS discovery + connect (unix socket / named pipe) behind the package-local `DiscordTransport` port (Adapter) | | `src/daemon/adapters/auth.rs` | `detect() -> Option`, `exchange(code)` | Credential resolution (env → auth.json) and the OAuth code exchange | | `src/daemon/adapters/{cache,avatar,token}.rs` | roster/avatar/token stores | On-disk persistence with tracing on real failures | -| `src/gui/mod.rs` | iced app `Gui::run()` | Window layout (header / sidebar / content / status bar), field registry, search; every change becomes a `Command` | +| `src/gui/mod.rs` | iced app shell: `Gui::run()` | `Message`, `Gui`, boot/subscribe wiring, window settings, and the blocking `send` wrapper; every change stays a `Command` — the layer modules below own the rest | +| `src/gui/update.rs` | `pub(super)` `update(gui, msg)` | The one flat update match (the app's dispatch table), the `shortcut` dispatcher, and the async daemon-toggle / auth effects | +| `src/gui/commands.rs` | `pub(super)` `command_for`, `apply_num`, `revert_commands` | Message → Command translation plus the bookkeeping the update arms share: unsaved marker, numeric bounds check, revert diff | +| `src/gui/scroll.rs` | `pub(super)` `measure_sections`, `scroll_to_section` | One-page navigation: the measure operation, section jumps, scrollspy highlight, and the shared widget ids | +| `src/gui/view.rs` | `pub(super)` `view(gui)` | Window composition: header (title, search, global actions), sidebar (section anchors), status bar (unsaved marker, daemon toggle, last reply) | | `src/gui/fields.rs` | per-key field registry | Section, label, tooltip, and control rendering for each setting | | `src/gui/daemon.rs` | `DaemonState` machine | Status chip states (connecting… / up / daemon not active) and the Start/Stop toggle plumbing (systemctl vs spawn vs `quit`) | | `src/gui/picker.rs` | color picker widget | Color selection UI | @@ -86,6 +90,7 @@ and a single reason to change. | Module | Public interface | Encapsulated responsibility | |---|---|---| | `domain.rs` | `Command::from_str` + `apply_config -> CommandResult`, `Key::ALL` | The whole CLI vocabulary, wire-protocol replies, typed status/colors — pure, no framework types; single source of truth for commands | +| `status.rs` | `StatusFields` + `to_wire`/`parse_wire`/`is_status_line` | The `status` reply wire contract in both directions — build and parse live together so field order and spelling change in one place | | `config.rs` | `load/save/clamp`, `Bounds` | TOML persistence and the single source of truth for every numeric bound (the former `toolkit` `Bounds` dissolved here) | | `color.rs` | `Rgb`, `Hsv`, conversions | Framework-free HSV/RGB color math (the former toolkit color primitives) | | `credentials.rs` | `AppCredentials`, auth.json load/save | Discord own-app credential storage; no network IO, never travels the ctl socket | diff --git a/docs/dev/debug-probes.md b/docs/dev/debug-probes.md index bafe37c..f7c8e0a 100644 --- a/docs/dev/debug-probes.md +++ b/docs/dev/debug-probes.md @@ -1,14 +1,16 @@ # Debug probes -Two throwaway-style probes dump raw Discord RPC traffic. They do not +Three probes live here. Two dump raw Discord RPC traffic; they do not import any crate code — they speak the wire protocols directly, so they -keep working (and stay truthful) even when the adapters change. +keep working (and stay truthful) even when the adapters change. The +third is a plain shell sampler for RSS-hunting on a process that does +not look like a leak in the code but does in btop. -They live in their own mini-crate under `scripts/`, deliberately separate -from the main package, so they never build with or get installed by -`hyprlay`. Their targets are examples, not bins: that keeps the repo's -`cargo install --git` scan at exactly one binary package, so bare -installs work. Run them from that directory: +The two Discord probes live in their own mini-crate under `scripts/`, +deliberately separate from the main package, so they never build with or +get installed by `hyprlay`. Their targets are examples, not bins: that +keeps the repo's `cargo install --git` scan at exactly one binary +package, so bare installs work. Run them from that directory: ```sh cd scripts @@ -16,12 +18,55 @@ cargo run --example ipcprobe # unix-socket IPC probe (current transport) cargo run --example wsprobe # historical websocket bridge probe ``` +## Daemon memory sampler (`rssprobe.sh`) + +`scripts/rssprobe.sh` samples one process's memory every couple of +seconds and appends one CSV row per interval: +`epoch,timestamp,rss_kb,hwm_kb,note`. It reads `/proc//status` +directly — VmRSS plus the peak VmHWM — so every row shows both the +live footprint and the historical high-water mark. That pair is the +diagnosis signal: + +- **RSS at or near HWM** — the process is at its historical peak; + what you are watching is real growth. +- **RSS well below HWM** — memory was freed but not returned to the + OS (allocator retention). The interesting question becomes *what + spiked it earlier*, not *what is growing now*. + +Usage — sampler in one terminal, markers from another while you +work: + +```sh +scripts/rssprobe.sh # sample `pidof hyprlayd` every 2s +scripts/rssprobe.sh -p -i 5 -o d.csv # explicit target/interval/output +scripts/rssprobe.sh mark "VC join" # append a labeled marker row +scripts/rssprobe.sh mark -o d.csv "VC join" # marker into the same CSV +``` + +Ctrl-C stops the sampler. Both commands append to the same CSV +(`rssprobe.csv` in the CWD by default; the header is written once), +so a later plot or diff shows exactly which scenario moved the +needle. + +Runbook for the hyprlayd memory regression (spec: v031 memory, H1–H5): + +1. Cold idle: start the sampler on a freshly restarted daemon, mark + `idle-start`, wait 10 min, mark `idle-end`. +2. H1 bait: mark `window-move-storm`, sweep a window across the + overlay with `dim-on-hover` on (the poll path opens a fresh + Hyprland socket per 50ms tick while connected with a non-empty + roster), then repeat with it off. +3. VC join/leave cycles, camera/stream toggles: mark each; watch RSS + vs HWM after the leave. +4. Repeat the identical series on the v0.3.0 release build (`hyprlayd` + from that tag) and diff the CSVs. + ## Current transport — local IPC (`ipcprobe`) The daemon talks to Discord over Discord's local unix socket at `$XDG_RUNTIME_DIR/discord-ipc-N`; the client side lives in -`src/adapters/ipc.rs`. The wire format is 8-byte little-endian framing -(opcode u32 + payload length u32) carrying JSON payloads. +`src/daemon/adapters/ipc.rs`. The wire format is 8-byte little-endian +framing (opcode u32 + payload length u32) carrying JSON payloads. The probe connects to the socket, does the same framing, sends `HANDSHAKE` then `AUTHORIZE`, and prints every frame for 60s. Use it to @@ -29,7 +74,7 @@ see what a stock Discord client answers and how it frames data. Start here when Discord changes something: confirm at the protocol level whether opcodes or payloads moved before touching -`src/adapters/discord.rs`. +`src/daemon/adapters/discord.rs`. The socket has no HTTP layer, so there is no origin validation — any properly registered application id connects with zero portal diff --git a/scripts/gen_tray_icons.py b/scripts/gen_tray_icons.py index c157cbd..f15422c 100644 --- a/scripts/gen_tray_icons.py +++ b/scripts/gen_tray_icons.py @@ -1,62 +1,64 @@ #!/usr/bin/env python3 -"""Generate the two tray icons (RGBA PNG, 48x48) with an "H" glyph. -Run once; the PNGs are committed under assets/. No third-party deps.""" -import zlib, struct, os - -W = H = 48 - -def draw_h(color): - """Return a W*H*4 RGBA bytearray with an "H" glyph on a transparent - background. `color` is (r, g, b, a).""" - r, g, b, a = color - bar = 8 # stroke thickness of each bar - margin = 10 # gap from the canvas edge to the vertical bars - # Vertical bars span the full letter height. - v_top, v_bot = margin, H - margin - # Horizontal center bar, vertically centered. - c_top = H // 2 - bar // 2 - c_bot = H // 2 + bar // 2 - left = margin - right = W - margin - bar - buf = bytearray(W * H * 4) - for y in range(H): - for x in range(W): - on = False - if v_top <= y < v_bot: - if left <= x < left + bar: - on = True - if right <= x < right + bar: - on = True - if c_top <= y < c_bot and left + bar <= x < right: - on = True - i = (y * W + x) * 4 - if on: - buf[i:i+4] = bytes((r, g, b, a)) - # else: leave transparent (alpha 0) - return buf - -def write_png(path, pixels): - raw = bytearray() - for y in range(H): - raw.append(0) # filter type 0 (none) - raw += pixels[y * W * 4:(y + 1) * W * 4] - comp = zlib.compress(bytes(raw), 9) - def chunk(typ, data): - body = typ + data - return (struct.pack(">I", len(data)) + body - + struct.pack(">I", zlib.crc32(body) & 0xffffffff)) - sig = b'\x89PNG\r\n\x1a\n' - ihdr = struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0) # 8-bit RGBA - with open(path, "wb") as f: - f.write(sig + chunk(b'IHDR', ihdr) - + chunk(b'IDAT', comp) + chunk(b'IEND', b'')) - -here = os.path.dirname(os.path.abspath(__file__)) -assets = os.path.join(os.path.dirname(here), "assets") -os.makedirs(assets, exist_ok=True) -write_png(os.path.join(assets, "tray-connected.png"), - draw_h((255, 255, 255, 255))) # white H -write_png(os.path.join(assets, "tray-disconnected.png"), - draw_h((160, 160, 160, 255))) # dim grey H -print("wrote", os.path.join(assets, "tray-connected.png"), - os.path.join(assets, "tray-disconnected.png")) +"""Regenerate every brand asset in assets/ from assets/hyprlay.svg. + +That SVG is the single source for all sizes; outputs are committed, so +run this after editing it and commit the result. +""" +import os +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +ASSETS = os.path.join(os.path.dirname(HERE), "assets") +SVG = os.path.join(ASSETS, "hyprlay.svg") + +H_FILL = 'fill="#ffffff"' +H_DIMMED = 'fill="#a0a0a0"' +TRAY_PX = 48 +APP_PX = (48, 64, 128, 256) +ICO_SIZES = [(px, px) for px in sorted(APP_PX + (32, 16), reverse=True)] + + +def raster(svg_text, px, out_path): + with tempfile.NamedTemporaryFile("w", suffix=".svg", delete=False) as f: + f.write(svg_text) + src = f.name + try: + subprocess.run( + ["rsvg-convert", "-w", str(px), "-h", str(px), "-o", out_path, src], + check=True, + ) + finally: + os.unlink(src) + + +def main(): + if not shutil.which("rsvg-convert"): + sys.exit("error: rsvg-convert not found (install librsvg)") + try: + from PIL import Image + except ImportError: + sys.exit("error: Pillow not found (needed for assets/hyprlay.ico)") + + with open(SVG) as f: + svg = f.read() + if svg.count(H_FILL) != 1: + sys.exit(f"error: expected exactly one {H_FILL} (the H glyph) in {SVG}") + + raster(svg, TRAY_PX, os.path.join(ASSETS, "tray-connected.png")) + raster(svg.replace(H_FILL, H_DIMMED, 1), TRAY_PX, + os.path.join(ASSETS, "tray-disconnected.png")) + for px in APP_PX: + raster(svg, px, os.path.join(ASSETS, f"hyprlay-{px}.png")) + + master = os.path.join(ASSETS, "hyprlay-256.png") + Image.open(master).save( + os.path.join(ASSETS, "hyprlay.ico"), format="ICO", sizes=ICO_SIZES + ) + print("wrote tray pair, hyprlay PNGs", APP_PX, "and hyprlay.ico from", SVG) + + +if __name__ == "__main__": + main() diff --git a/scripts/rssprobe.sh b/scripts/rssprobe.sh new file mode 100755 index 0000000..54bedc5 --- /dev/null +++ b/scripts/rssprobe.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# rssprobe.sh — sample one process's memory to CSV for RSS-hunting. +# +# The sampler writes one CSV row per interval: epoch, ISO-8601 timestamp, +# VmRSS, and VmHWM (peak) in kB. RSS below HWM means memory was freed but +# not returned to the OS (allocator retention); RSS climbing toward or +# past the old HWM means real growth. Scenario markers are rows whose +# note column carries the label, so a plot or a diff shows exactly which +# scenario moved the needle. +# +# Usage: +# scripts/rssprobe.sh # sample `pidof hyprlayd` every 2s +# scripts/rssprobe.sh -p PID -i 2 -o f.csv +# scripts/rssprobe.sh mark "VC join" # append a marker row (other shell) +# scripts/rssprobe.sh mark # marker with no label ("scenario") +# +# Ctrl-C stops the sampler. Both commands append to the same CSV (header +# written once), so run the sampler in one terminal and mark scenarios +# from another while you work. + +set -euo pipefail + +usage() { + grep '^# ' "$0" | sed 's/^# //' + exit "${1:-0}" +} + +csv=rssprobe.csv +pid="" +interval=2 + +cmd=${1:-sample} +case $cmd in + mark) + shift + label="" + while [ $# -gt 0 ]; do + case $1 in + -o) csv=$2; shift 2 ;; + -i) shift 2 ;; # tolerated for copy-paste symmetry, unused here + *) label="$label${label:+ }$1"; shift ;; + esac + done + [ -n "$label" ] || label=scenario + [ -f "$csv" ] || echo "epoch,timestamp,rss_kb,hwm_kb,note" > "$csv" + printf '%s,%s,,,%s\n' "$(date +%s)" "$(date +%FT%T)" "$label" >> "$csv" + exit 0 + ;; + sample) + shift || true + ;; + -p | -i | -o | --help) + ;; + *) + usage 2 + ;; +esac + +while [ $# -gt 0 ]; do + case $1 in + -p) pid=$2; shift 2 ;; + -i) interval=$2; shift 2 ;; + -o) csv=$2; shift 2 ;; + --help) usage ;; + *) usage 2 ;; + esac +done + +if [ -z "$pid" ]; then + pid=$(pidof hyprlayd 2>/dev/null | tr ' ' '\n' | head -1) || { + echo "rssprobe: no hyprlayd running; pass -p PID" >&2 + exit 1 + } +fi +if [ "$(pidof hyprlayd 2>/dev/null | wc -w)" -gt 1 ] && [ "$pid" = "$(pidof hyprlayd | tr ' ' '\n' | head -1)" ]; then + echo "rssprobe: multiple hyprlayd processes; sampling $pid (first). Pass -p to pick." >&2 +fi + +status=/proc/$pid/status +[ -r "$status" ] || { echo "rssprobe: cannot read $status" >&2; exit 1; } + +[ -f "$csv" ] || echo "epoch,timestamp,rss_kb,hwm_kb,note" > "$csv" +echo "rssprobe: sampling pid $pid every ${interval}s -> $csv (Ctrl-C to stop)" >&2 + +while :; do + rss=$(awk '/^VmRSS:/{print $2}' "$status") || exit 0 # process gone + hwm=$(awk '/^VmHWM:/{print $2}' "$status") + printf '%s,%s,%s,%s,\n' "$(date +%s)" "$(date +%FT%T)" "$rss" "$hwm" >> "$csv" + sleep "$interval" +done diff --git a/src/daemon/adapters/cache.rs b/src/daemon/adapters/cache.rs index 4307ac4..fc69f56 100644 --- a/src/daemon/adapters/cache.rs +++ b/src/daemon/adapters/cache.rs @@ -22,70 +22,26 @@ pub fn cache_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] -pub struct CachedUser { - pub id: String, - pub name: String, - pub avatar_hash: Option, - pub self_mute: bool, - pub self_deaf: bool, - pub server_mute: bool, - pub server_deaf: bool, -} - -impl From<&Participant> for CachedUser { - fn from(p: &Participant) -> Self { - Self { - id: p.id.clone(), - name: p.name.clone(), - avatar_hash: p.avatar_hash.clone(), - self_mute: p.self_mute, - self_deaf: p.self_deaf, - server_mute: p.server_mute, - server_deaf: p.server_deaf, - } - } -} - -impl From for Participant { - fn from(u: CachedUser) -> Self { - // Speaking state is live-only: it would be stale on load. - Participant { - id: u.id, - name: u.name, - avatar_hash: u.avatar_hash, - speaking: false, - self_mute: u.self_mute, - self_deaf: u.self_deaf, - server_mute: u.server_mute, - server_deaf: u.server_deaf, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Roster { pub channel: Option, pub me_id: Option, - pub users: Vec, + pub users: Vec, } -/// Cheap identity of a roster for write dedup — everything except the -/// per-event speaking flag. -fn roster_signature(users: &[CachedUser]) -> String { +/// Cheap identity of a roster for write dedup — the serde output of every +/// participant, so the signature covers exactly the fields the cache file +/// persists and can never drift away from the file format. The live-only +/// speaking flag is skipped by serde and therefore never reaches either. +/// A silent `unwrap_or_default()` fallback is deliberately not used: it +/// would turn every failing entry into the same empty string, collapsing +/// distinct users into equal signatures and silently skipping writes. +fn roster_signature(users: &[Participant]) -> String { users .iter() .map(|u| { - format!( - "{}|{}|{:?}|{}{}{}{}", - u.id, - u.name, - u.avatar_hash, - u.self_mute as u8, - u.self_deaf as u8, - u.server_mute as u8, - u.server_deaf as u8 - ) + serde_json::to_string(u) + .expect("roster entries are plain strings and bools; they always serialize") }) .collect::>() .join(";") @@ -98,8 +54,7 @@ fn last_signature() -> &'static Mutex { /// Persist the roster if it changed since the last write. pub fn save_roster(channel: Option<&str>, me_id: Option<&str>, users: &[Participant]) { - let cached: Vec = users.iter().map(CachedUser::from).collect(); - let sig = format!("{:?}|{:?}|{}", channel, me_id, roster_signature(&cached)); + let sig = format!("{:?}|{:?}|{}", channel, me_id, roster_signature(users)); { let mut last = last_signature().lock().unwrap(); if *last == sig { @@ -110,7 +65,7 @@ pub fn save_roster(channel: Option<&str>, me_id: Option<&str>, users: &[Particip let roster = Roster { channel: channel.map(str::to_string), me_id: me_id.map(str::to_string), - users: cached, + users: users.to_vec(), }; let dir = cache_dir(); let write = || -> std::io::Result<()> { @@ -174,7 +129,7 @@ mod tests { id: id.to_string(), name: name.to_string(), avatar_hash: hash.map(str::to_string), - speaking: true, // must be dropped by the conversion + speaking: true, // must be dropped by serde self_mute: mutes.0, self_deaf: mutes.1, server_mute: false, @@ -182,11 +137,136 @@ mod tests { } } + /// The exact bytes the cache wrote before the roster type was unified: + /// the seven persisted user fields in declaration order, no `speaking` + /// key anywhere. Pinned so existing caches load with no migration. + const LEGACY_ROSTER_JSON: &str = r#"{"channel":"General","me_id":"238492734982739483","users":[{"id":"238492734982739483","name":"fazuh","avatar_hash":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6","self_mute":true,"self_deaf":false,"server_mute":false,"server_deaf":false},{"id":"1079875395007417102","name":"quiet_guest","avatar_hash":null,"self_mute":false,"self_deaf":false,"server_mute":true,"server_deaf":false}]}"#; + + #[test] + fn roster_cache_from_the_previous_format_still_loads() { + let roster: Roster = serde_json::from_str(LEGACY_ROSTER_JSON).unwrap(); + assert_eq!(roster.channel.as_deref(), Some("General")); + assert_eq!(roster.me_id.as_deref(), Some("238492734982739483")); + assert_eq!(roster.users.len(), 2); + assert_eq!(roster.users[0].id, "238492734982739483"); + assert_eq!(roster.users[0].name, "fazuh"); + assert_eq!( + roster.users[0].avatar_hash.as_deref(), + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6") + ); + assert!(roster.users[0].self_mute); + assert!(!roster.users[0].self_deaf); + assert!(!roster.users[0].server_mute); + assert!(!roster.users[0].server_deaf); + assert_eq!(roster.users[1].id, "1079875395007417102"); + assert_eq!(roster.users[1].name, "quiet_guest"); + assert_eq!(roster.users[1].avatar_hash, None); + assert!(roster.users[1].server_mute); + // The live-only speaking flag never survives a load: serde fills + // it with false, same as the previous cache format's load path. + assert!(!roster.users[0].speaking); + assert!(!roster.users[1].speaking); + } + #[test] - fn cached_user_roundtrip_drops_speaking_flag() { + fn roster_cache_writes_the_same_bytes_as_the_previous_format() { + let mut server_muted = + participant("1079875395007417102", "quiet_guest", None, (false, false)); + server_muted.server_mute = true; + // Both participants carry speaking=true on purpose: the flag is + // skipped by serde, so the bytes stay legacy-identical. + let roster = Roster { + channel: Some("General".to_string()), + me_id: Some("238492734982739483".to_string()), + users: vec![ + participant( + "238492734982739483", + "fazuh", + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"), + (true, false), + ), + server_muted, + ], + }; + assert_eq!(serde_json::to_string(&roster).unwrap(), LEGACY_ROSTER_JSON); + } + + #[test] + fn roster_signature_changes_when_any_persisted_field_changes() { + let base = participant( + "238492734982739483", + "fazuh", + Some("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"), + (true, false), + ); + let sig = |u: &Participant| roster_signature(std::slice::from_ref(u)); + let base_sig = sig(&base); + + let mut changed = base.clone(); + changed.id = "1079875395007417102".to_string(); + assert_ne!(sig(&changed), base_sig, "id must reach the signature"); + + let mut changed = base.clone(); + changed.name = "renamed".to_string(); + assert_ne!(sig(&changed), base_sig, "name must reach the signature"); + + let mut changed = base.clone(); + changed.avatar_hash = None; + assert_ne!( + sig(&changed), + base_sig, + "avatar_hash must reach the signature" + ); + + let mut changed = base.clone(); + changed.self_mute = false; + assert_ne!( + sig(&changed), + base_sig, + "self_mute must reach the signature" + ); + + let mut changed = base.clone(); + changed.self_deaf = true; + assert_ne!( + sig(&changed), + base_sig, + "self_deaf must reach the signature" + ); + + let mut changed = base.clone(); + changed.server_mute = true; + assert_ne!( + sig(&changed), + base_sig, + "server_mute must reach the signature" + ); + + let mut changed = base.clone(); + changed.server_deaf = true; + assert_ne!( + sig(&changed), + base_sig, + "server_deaf must reach the signature" + ); + + // Parity with the old hand-written signature: it enumerated only + // the persisted fields, so a speaking flip must stay invisible. + let mut changed = base.clone(); + changed.speaking = !changed.speaking; + assert_eq!( + sig(&changed), + base_sig, + "speaking must not reach the signature" + ); + } + + #[test] + fn participant_serde_roundtrip_drops_speaking_flag() { let p = participant("42", "fazuh", Some("abc"), (true, false)); - let back = CachedUser::from(&p); - let restored: Participant = back.into(); + let json = serde_json::to_string(&p).unwrap(); + assert!(!json.contains("speaking")); + let restored: Participant = serde_json::from_str(&json).unwrap(); assert_eq!(restored.id, "42"); assert_eq!(restored.name, "fazuh"); assert_eq!(restored.avatar_hash.as_deref(), Some("abc")); @@ -196,15 +276,18 @@ mod tests { #[test] fn roster_signature_ignores_speaking_but_not_mutes() { - let mut a = CachedUser::from(&participant("1", "a", None, (false, false))); - let b = CachedUser::from(&participant("1", "a", None, (false, false))); + let mut a = participant("1", "a", None, (false, false)); + let b = participant("1", "a", None, (false, false)); // Same user twice → equal signatures. assert_eq!( - roster_signature(&[a.clone()]), + roster_signature(std::slice::from_ref(&a)), roster_signature(std::slice::from_ref(&b)) ); a.self_mute = true; - assert_ne!(roster_signature(&[a]), roster_signature(&[b])); + assert_ne!( + roster_signature(std::slice::from_ref(&a)), + roster_signature(std::slice::from_ref(&b)) + ); } #[test] diff --git a/src/daemon/adapters/discord.rs b/src/daemon/adapters/discord.rs index 3e3a761..5e097f7 100644 --- a/src/daemon/adapters/discord.rs +++ b/src/daemon/adapters/discord.rs @@ -18,6 +18,8 @@ use std::time::Duration; use futures_channel::mpsc::Sender; use futures_util::SinkExt; use hyprlay_core::domain::ConnectionStatus; +use serde::Deserialize; +use serde::Serialize; use serde_json::Value; use serde_json::json; use tracing::info; @@ -36,11 +38,16 @@ pub enum DiscordEvent { Participants(Vec), } -#[derive(Debug, Clone, PartialEq)] +/// One voice-channel participant. Doubles as the roster-cache row: serde +/// output defines both the `roster.json` file format and the write-dedup +/// signature, so a new field cannot be added to one without the other. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Participant { pub id: String, pub name: String, pub avatar_hash: Option, + /// Speaking state is live-only: it would be stale on load. + #[serde(skip)] pub speaking: bool, pub self_mute: bool, pub self_deaf: bool, diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 6215e1e..dc0e9d8 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -28,6 +28,7 @@ use hyprlay_core::domain::Group; use hyprlay_core::domain::Key; use hyprlay_core::domain::MonitorTarget; use hyprlay_core::domain::Value; +use hyprlay_core::status::StatusFields; use overlay::state::Overlay; /// Daemon entry point, called by the thin `src/bin/hyprlayd.rs` main. Runs @@ -192,26 +193,26 @@ pub(crate) fn resolve_command(state: &mut Overlay, cmd: Command) -> CommandOutco Command::Status => { let cfg = state.config(); let corner = hyprlay_core::domain::corner_of(cfg.horizontal, cfg.vertical); + let fields = StatusFields { + status_word: state.status().into(), + channel: state.channel_name().unwrap_or("-").to_string(), + participants: state.displayed().len(), + position: hyprlay_core::domain::corner_word(corner).to_string(), + rtl: cfg.rtl, + visible: cfg.visible, + anchor: cfg.anchor.to_string(), + scale: cfg.scale, + opacity: cfg.opacity, + offset_x: cfg.offset_x, + offset_y: cfg.offset_y, + monitor: cfg.monitor.clone(), + auth: state.auth_label().to_string(), + show_on_fullscreen: cfg.show_on_fullscreen, + dim_on_hover: cfg.dim_on_hover, + hover_opacity: cfg.hover_opacity, + }; return CommandOutcome { - reply: format!( - "status={} channel={} participants={} position={} rtl={} visible={} anchor={} scale={} opacity={} offset=({},{}) monitor={monitor} auth={auth} show-on-fullscreen={show_on} dim-on-hover={dim_on} hover-opacity={hover}", - state.status(), - state.channel_name().unwrap_or("-"), - state.displayed().len(), - hyprlay_core::domain::corner_word(corner), - if cfg.rtl { "on" } else { "off" }, - if cfg.visible { "on" } else { "off" }, - cfg.anchor, - cfg.scale, - cfg.opacity, - cfg.offset_x, - cfg.offset_y, - monitor = cfg.monitor.as_deref().unwrap_or("active"), - auth = state.auth_label(), - show_on = if cfg.show_on_fullscreen { "on" } else { "off" }, - dim_on = if cfg.dim_on_hover { "on" } else { "off" }, - hover = cfg.hover_opacity, - ), + reply: fields.to_wire(), effects: Vec::new(), lifecycle: None, }; diff --git a/src/daemon/overlay/state.rs b/src/daemon/overlay/state.rs index 23e92c8..97735ab 100644 --- a/src/daemon/overlay/state.rs +++ b/src/daemon/overlay/state.rs @@ -275,7 +275,9 @@ impl Overlay { if let Some(roster) = crate::daemon::adapters::cache::load_roster() { self.channel_name = roster.channel; self.me_id = roster.me_id; - self.users = roster.users.into_iter().map(Participant::from).collect(); + // Deserialization already restored the persisted fields; the + // live-only speaking flag came back as false. + self.users = roster.users; self.avatars.hydrate(&self.users); } } diff --git a/src/gui/commands.rs b/src/gui/commands.rs new file mode 100644 index 0000000..e8efc35 --- /dev/null +++ b/src/gui/commands.rs @@ -0,0 +1,218 @@ +//! Message → Command translation and the bookkeeping the update arms +//! share: the unsaved-changes marker, the numeric bounds check, and the +//! revert diff behind "clear changes". + +use hyprlay_core::config::Config; +use hyprlay_core::domain::Command; +use hyprlay_core::domain::Key; +use hyprlay_core::domain::Value; +use hyprlay_core::domain::corner_of; +use iced::Task; + +use super::Gui; +use super::Message; +use super::send; + +/// Commit one numeric value to the mirror and the daemon. The caller has +/// already made sure the value is inside the key's bounds. +pub(super) fn apply_num(gui: &mut Gui, key: Key, value: i64) -> Task { + gui.num_drafts.remove(&key); + let command = Command::Set(key, Value::Num(value)); + mark_dirty(gui, &command); + command.clone().apply_config(&mut gui.config); + Task::perform(send(command.to_string()), Message::Applied) +} + +/// Mirror of the daemon's persistence rule: a change is "unsaved" exactly +/// when the daemon would not have persisted it. Decided with the +/// pre-application autosave value — the same one the daemon uses — so +/// flipping auto-save itself never leaves a phantom badge. +pub(super) fn mark_dirty(gui: &mut Gui, command: &Command) { + if !hyprlay_core::domain::should_persist(command, gui.config.auto_save) { + gui.dirty = true; + } +} + +pub(super) fn num_in_bounds(key: Key, v: i64) -> bool { + key.num_bounds() + .is_some_and(|(min, max)| v >= min && v <= max) +} + +/// Commands that bring `live` back to `saved`, one per differing key. +/// Used by "clear changes"; empty when there is nothing to revert. Walking +/// the shared [`Key`] table means a newly added setting can never be +/// forgotten here — it shows up in the diff the moment it exists. +pub(super) fn revert_commands(live: &Config, saved: &Config) -> Vec { + Key::ALL + .into_iter() + .filter(|k| k.value_of(live) != k.value_of(saved)) + .map(|k| Command::Set(k, k.value_of(saved))) + .collect() +} + +/// Turn a GUI interaction into its control-socket command. The local config +/// mirror is the same [`Command::apply_config`] the daemon runs, so both +/// sides can never disagree about what a setting change means. +pub(super) fn command_for(message: Message) -> Command { + match message { + Message::Position(h, v) => Command::Set(Key::Position, Value::Corner(corner_of(h, v))), + // Rides the generic apply path like Position: mirror locally, send + // the same wire command the CLI would. + Message::Anchor(mode) => Command::Set(Key::Anchor, Value::Anchor(mode)), + Message::SetFlag(..) => unreachable!("flags are handled directly in update"), + // Handled directly in `update`; unreachable here. + Message::NumText(..) + | Message::NumDrag(..) + | Message::NumReset(_) + | Message::ColorPart(..) + | Message::ColorHex(..) + | Message::PickerToggle(..) + | Message::SvPress(..) + | Message::SvMove(..) + | Message::HuePress(..) + | Message::HueMove(..) + | Message::PickerRelease + | Message::Palette(_) + | Message::Navigate(_) + | Message::Scrolled(_) + | Message::Measured { .. } + | Message::Search(_) + | Message::KeyPressed(_) + | Message::Save + | Message::ClearChanges + | Message::ResetAll + | Message::ResetSection(_) + | Message::SwitchMonitor(_) + | Message::Monitors(_) + | Message::Applied(_) + | Message::RefreshStatus + | Message::ToggleDaemon + | Message::ToggleResult(_) + | Message::AuthClientId(_) + | Message::AuthClientSecret(_) + | Message::AuthApply => unreachable!("handled before command_for"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchor_setting_roundtrips_through_apply_and_revert() { + // The exact Command path the GUI's generic change pipeline drives. + let mut live = Config::default(); + let pin_bottom = Command::Set( + Key::Anchor, + Value::Anchor(hyprlay_core::config::AnchorMode::Bottom), + ); + pin_bottom.clone().apply_config(&mut live); + assert_eq!(live.anchor, hyprlay_core::config::AnchorMode::Bottom); + + // Reverting mirrors what "clear changes" replays: read the saved + // value back through the shared table and re-apply it. + let saved = Config::default(); + let revert = Command::Set(Key::Anchor, Key::Anchor.value_of(&saved)); + revert.apply_config(&mut live); + assert_eq!(live.anchor, saved.anchor); + } + + #[test] + fn key_sets_use_the_cli_wire_names() { + use hyprlay_core::config::OFFSETS; + assert_eq!( + Command::Set(Key::Opacity, Value::Num(42)).to_string(), + "set opacity 42" + ); + assert_eq!( + Command::Set(Key::OffsetX, Value::Num(-12)).to_string(), + "set offset-x -12" + ); + assert_eq!( + Command::Set(Key::TalkingOnly, Value::Flag(true)).to_string(), + "set talking-only on" + ); + assert!( + (OFFSETS.min as i64..=OFFSETS.max as i64).contains(&-12), + "test value must stay inside the shared bounds" + ); + } + + #[test] + fn every_numeric_key_has_sane_bounds() { + for key in Key::ALL { + if let Some((min, max)) = key.num_bounds() { + assert!(min <= max, "{} has an inverted range", key.name()); + } else { + // Non-numeric keys must not pretend to have slider bounds. + assert!( + !key.slider_bounds(&Config::default()).is_some() + || matches!( + key, + Key::OffsetX + | Key::OffsetY + | Key::Width + | Key::Scale + | Key::AvatarSize + | Key::TextSize + | Key::Spacing + | Key::MaxName + | Key::Opacity + | Key::AvatarOpacity + | Key::TextOpacity + | Key::BoxOpacity + ), + "{} renders a slider without numeric bounds", + key.name() + ); + } + } + } + + #[test] + fn revert_commands_do_nothing_when_configs_match() { + let cfg = Config::default(); + assert!(revert_commands(&cfg, &cfg).is_empty()); + } + + #[test] + fn revert_commands_cover_every_differing_key_once() { + use hyprlay_core::config::HorizontalAnchor as H; + use hyprlay_core::config::VerticalAnchor as V; + // show_own_user defaults to true, so flipping it off is a real diff. + let saved = Config { + horizontal: H::Right, + vertical: V::Top, // top-right corner + rtl: true, + offset_x: 40, + opacity: 70, + width: 500, + show_own_user: false, + monitor: Some("DP-2".into()), + speaking_color: "#00ff00".parse().unwrap(), + ..Config::default() + }; + + let cmds = revert_commands(&Config::default(), &saved); + for expected in [ + "set position top-right", + "set rtl on", + "set offset-x 40", + "set opacity 70", + "set width 500", + "set own-user off", + "set monitor DP-2", + ] { + assert!( + cmds.iter().any(|c| c.to_string() == expected), + "missing revert command {expected}" + ); + } + assert!( + cmds.iter() + .any(|c| c.to_string().starts_with("set speaking-color ")) + ); + // Exactly one command per changed key — no redundant spam. + assert_eq!(cmds.len(), 8, "unexpected extra commands: {cmds:?}"); + } +} diff --git a/src/gui/daemon.rs b/src/gui/daemon.rs index c39ac1e..5ef2749 100644 --- a/src/gui/daemon.rs +++ b/src/gui/daemon.rs @@ -15,6 +15,7 @@ //! daemon state and the boot auto-start watcher. use hyprlay_core::daemon_control::Toggle; +use hyprlay_core::status::StatusFields; /// Chip text before the first probe has answered. pub(super) const CONNECTING_TEXT: &str = "connecting…"; @@ -37,7 +38,7 @@ impl DaemonState { /// failure texts prove nothing does, and everything else is an ordinary /// reply that must not disturb the chip. pub(super) fn advance(self, reply: &str) -> Self { - if reply.starts_with("status=") { + if StatusFields::is_status_line(reply) { Self::Up(reply.to_string()) } else if is_probe_failure(reply) { Self::Unreachable @@ -110,7 +111,7 @@ impl AutoStart { pub(super) fn observe(&mut self, state: &mut DaemonState, reply: &str) -> Option { if self.0 == Phase::Running { // Hold the connecting line until the launch settles. - if reply.starts_with("status=") { + if StatusFields::is_status_line(reply) { *state = state.clone().advance(reply); self.0 = Phase::Done; } @@ -142,6 +143,7 @@ impl AutoStart { mod tests { use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; + use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::StopPolicy; use hyprlay_core::daemon_control::execute_toggle; @@ -305,7 +307,10 @@ mod tests { fn a_failed_action_surfaces_its_error_text() { let control = FakeControl { installed: false, - fail_with: Some("error: systemctl stop failed: unit not loaded".into()), + fail_with: Some(Box::new(|| ServiceError::SystemctlFailed { + subcommand: "stop".into(), + detail: "unit not loaded".into(), + })), ..FakeControl::default() }; // Stop without a unit resolves to the socket path; its failure must @@ -414,11 +419,13 @@ mod tests { } /// Spy at the process/socket boundary: records what ran so tests verify - /// state, not call mechanics. + /// state, not call mechanics. `fail_with` is a factory because + /// `ServiceError` is not `Clone`: every performed action fails the same + /// way, exactly as the old `String` double did. #[derive(Default)] struct FakeControl { installed: bool, - fail_with: Option, + fail_with: Option ServiceError + Send + Sync>>, performed: std::sync::Mutex>, } @@ -433,10 +440,10 @@ mod tests { self.installed } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { self.performed.lock().unwrap().push(action); match &self.fail_with { - Some(text) => Err(text.clone()), + Some(failure) => Err(failure()), None => Ok(()), } } diff --git a/src/gui/fields.rs b/src/gui/fields.rs index 6f80c0b..b0469b4 100644 --- a/src/gui/fields.rs +++ b/src/gui/fields.rs @@ -795,3 +795,92 @@ fn preset_button<'a>(cfg: &'a Config, h: H, v: V, label: &'a str) -> Element<'a, .width(Length::Fill) .into() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_matches_label_tip_and_section_name() { + let field = Field { + section: Section::Position, + label: "offset x", + tip: "Horizontal distance in px from the anchored screen edge.", + render: f_offset_x, + }; + assert!(search_matches(&field, "offset")); + assert!(search_matches(&field, "horizontal")); + assert!(search_matches(&field, "POSITION")); + assert!(!search_matches(&field, "avatar")); + assert!(!search_matches(&field, "")); + } + + #[test] + fn sections_map_one_to_one_onto_config_groups() { + use hyprlay_core::domain::Group; + // Only config-backed sections participate; Connection has no group. + let config_backed: Vec<_> = Section::ALL + .into_iter() + .filter_map(|section| section.group().map(|group| (section, group))) + .collect(); + assert_eq!(config_backed.len(), Group::ALL.len()); + for ((section, group), expected) in config_backed.into_iter().zip(Group::ALL) { + // The reset button sends one ResetGroup per GUI section; if a + // section ever fails to map, its fields could never be reset. + assert_eq!(group, expected); + assert_eq!( + format!("{expected}"), + section.name().to_lowercase(), + "section {} diverged from group {}", + section.name(), + group + ); + } + } + + /// Exactly one section is exempt from the reset machinery, and its name + /// must stay stable because the shortcut hints and search rely on it. + #[test] + fn every_section_except_connection_maps_to_a_group() { + for section in Section::ALL { + match section.group() { + Some(_) => assert_ne!(section.name(), "Connection"), + None => assert_eq!(section.name(), "Connection"), + } + } + } + + #[test] + fn every_field_has_a_nonempty_tooltip() { + for f in FIELDS { + assert!(!f.tip.is_empty(), "field {} needs a tooltip", f.label); + assert!(!f.label.is_empty()); + } + } + + #[test] + fn every_section_has_fields() { + for s in Section::ALL { + assert!( + FIELDS.iter().any(|f| f.section == s), + "section {} has no fields", + s.name() + ); + } + } + + /// Click-through was removed; no field may render it again. + #[test] + fn click_through_is_gone_from_the_field_registry() { + assert!(!FIELDS.iter().any(|f| f.label.contains("click"))); + } + + #[test] + fn anchor_field_is_registered_in_the_position_section() { + let field = FIELDS + .iter() + .find(|f| f.label == "anchor") + .expect("anchor field registered"); + assert_eq!(field.section, Section::Position); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8aaf988..c487c1b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -21,71 +21,38 @@ //! changes is allowed (the daemon keeps runtime state, but a daemon //! restart reverts to config.toml). +mod commands; mod daemon; mod fields; mod picker; +mod scroll; mod theme; +mod update; +mod view; use std::collections::HashMap; use std::sync::Arc; use daemon::AutoStart; use daemon::DaemonState; -use fields::CONTENT_SCROLL_ID; -use fields::SEARCH_ID; use fields::Section; -use fields::search_page; -use fields::settings_page; use hyprlay_core::config::Config; use hyprlay_core::config::HorizontalAnchor as H; -use hyprlay_core::config::PALETTES; use hyprlay_core::config::VerticalAnchor as V; use hyprlay_core::config::{self}; -use hyprlay_core::credentials::AppCredentials; use hyprlay_core::ctl; use hyprlay_core::daemon_control::DaemonControl; -use hyprlay_core::daemon_control::StopPolicy; -use hyprlay_core::daemon_control::Toggle; use hyprlay_core::domain::Command; -use hyprlay_core::domain::HexColor; use hyprlay_core::domain::Key; -use hyprlay_core::domain::Value; -use hyprlay_core::domain::corner_of; use hyprlay_core::singleton::AcquireError; -use iced::Alignment; -use iced::Element; -use iced::Length; use iced::Point; -use iced::Rectangle; use iced::Subscription; use iced::Task; -use iced::Vector; -use iced::keyboard::key; use iced::keyboard::{self}; -use iced::widget::Id; -use iced::widget::button; -use iced::widget::column; -use iced::widget::container; -use iced::widget::row; -use iced::widget::text; -use iced::widget::text_input; -use iced_runtime::core::widget::Operation; -use iced_runtime::core::widget::operation::Outcome; -use iced_runtime::core::widget::operation::scrollable::Scrollable; use picker::ColorTarget; -use picker::apply_hue; -use picker::apply_sv; -use theme::AMBER; -use theme::BRIGHT; -use theme::HEADER_BG; -use theme::MUTED; -use theme::REPLY_GREEN; -use theme::SIDEBAR_BG; -use theme::nav_style; -use theme::panel; -use theme::plain_style; -use theme::primary_style; use theme::theme_for; +use update::update; +use view::view; use crate::platform::service::SystemControl; @@ -318,1132 +285,3 @@ async fn send(command: String) -> String { .await .unwrap_or_else(|_| "error: command task failed".into()) } - -/// Run one Start/Stop action (systemctl, sibling spawn, or socket quit) off -/// the UI thread, same blocking pattern as [`send`]. -async fn run_toggle(control: Arc, toggle: Toggle) -> Option { - tokio::task::spawn_blocking(move || { - hyprlay_core::daemon_control::execute_toggle(&*control, toggle, StopPolicy::ViaSystemctl) - }) - .await - .unwrap_or_else(|e| Some(format!("error: daemon toggle task failed: {e}"))) -} - -/// Persist own-app credentials off the UI thread, then ask the daemon to -/// restart so it re-runs detect() and picks up the new backend. The -/// returned text lands in the status bar via [`Message::Applied`]. -async fn apply_auth_credentials(creds: AppCredentials) -> String { - // Read before the move: the decision text depends on what was applied. - let cleared = creds.client_id.is_empty() && creds.client_secret.is_empty(); - let saved = tokio::task::spawn_blocking(move || hyprlay_core::credentials::save(&creds)) - .await - .unwrap_or_else(|e| Err(std::io::Error::other(e.to_string()))); - match saved { - Ok(()) => { - // The daemon's own reply only confirms delivery; the meaningful - // text for the status bar is ours. - let _ = send("restart".to_string()).await; - if cleared { - "credentials cleared, restarting daemon".to_string() - } else { - "credentials saved, restarting daemon".to_string() - } - } - Err(e) => format!("error: could not write credentials: {e}"), - } -} - -fn update(gui: &mut Gui, message: Message) -> Task { - match message { - Message::Applied(reply) => { - let reply = reply.trim().to_string(); - // Every reply is a potential probe outcome; only probe outcomes - // actually move the state (see DaemonState::advance) — and - // while the boot auto-start has the wheel, failures hold - // `connecting…` instead of reporting the daemon dead. - let launch = gui.auto_start.observe(&mut gui.daemon_state, &reply); - // `dump` replies with the live runtime config as TOML — adopt it - // so the GUI reflects unsaved daemon state. The [position] - // header marks a dump; any other text is an ordinary reply. Any - // in-flight input drafts are stale after an external reset, so - // drop them too. - if reply.contains("[position]") { - if let Ok(live) = toml::from_str::(&reply) { - gui.config = live; - gui.drafts.clear(); - gui.num_drafts.clear(); - } - } else if reply == "saved" { - gui.dirty = false; - } else if !reply.is_empty() && !reply.starts_with("status=") { - // status= replies are consumed by the state chip above; - // everything else is ordinary status-bar traffic. - gui.last_reply = reply; - } - match launch { - Some(toggle) => { - // Opening the GUI brings the daemon up: fire-and-forget - // off the UI thread, through the same DaemonControl path - // as the Start button. This is also why closing the - // window never stops the daemon — nothing here ties its - // lifetime to GUI exit (systemctl owns the unit; the - // fallback spawn detaches into its own process group). - let control = Arc::clone(&gui.control); - Task::perform(run_toggle(control, toggle), Message::ToggleResult) - } - None => Task::none(), - } - } - Message::RefreshStatus => { - Task::perform(send(Command::Status.to_string()), Message::Applied) - } - Message::ToggleDaemon => { - let Some(toggle) = gui.daemon_state.toggle() else { - return Task::none(); - }; - let control = Arc::clone(&gui.control); - Task::perform(run_toggle(control, toggle), Message::ToggleResult) - } - Message::ToggleResult(failure) => { - // The boot bring-up attempt finished either way; stop holding - // the connecting line on its behalf. - gui.auto_start.settled(); - if let Some(text) = failure { - gui.last_reply = text; - } - // Whether it worked is only visible through a fresh probe; do - // not wait for the next 2 s tick. - Task::perform(send(Command::Status.to_string()), Message::Applied) - } - Message::Monitors(monitors) => { - gui.monitors = monitors; - Task::none() - } - Message::Save => { - gui.dirty = false; - Task::perform(send(Command::Save.to_string()), Message::Applied) - } - Message::ClearChanges => { - // Revert the daemon's runtime state to the on-disk config by - // replaying only the fields that actually differ. - let saved = config::load(); - let commands = revert_commands(&gui.config, &saved); - gui.config = saved; - gui.drafts.clear(); - gui.num_drafts.clear(); - gui.dirty = false; - Task::batch( - commands - .into_iter() - .map(|c| Task::perform(send(c.to_string()), Message::Applied)), - ) - } - Message::ResetAll => { - mark_dirty(gui, &Command::ResetAll); - Task::perform(send(Command::ResetAll.to_string()), Message::Applied).chain( - Task::perform(send(Command::Dump.to_string()), Message::Applied), - ) - } - Message::ResetSection(section) => { - // Sections without a config group have nothing to reset; the - // GUI hides their button, so this arm is a defensive no-op. - let Some(group) = section.group() else { - return Task::none(); - }; - let command = Command::ResetGroup(group); - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied).chain(Task::perform( - send(Command::Dump.to_string()), - Message::Applied, - )) - } - // `monitor` is answered by the shell before apply_config runs, so - // the mirror must be updated here or the chip highlight lags behind. - Message::SwitchMonitor(target) => { - let command = Command::Set( - Key::Monitor, - Value::Target(match &target { - None => hyprlay_core::domain::MonitorTarget::Active, - Some(name) => hyprlay_core::domain::MonitorTarget::Named(name.clone()), - }), - ); - gui.config.monitor = target; - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } - Message::Palette(index) => { - let Some(p) = PALETTES.get(index) else { - return Task::none(); - }; - let cmds = [ - Command::Set(Key::SpeakingColor, Value::Color(p.speaking)), - Command::Set(Key::TextColor, Value::Color(p.text)), - Command::Set(Key::BoxColor, Value::Color(p.box_bg)), - ]; - for cmd in &cmds { - cmd.clone().apply_config(&mut gui.config); - } - // All three palette entries are Sets: one decision covers them. - mark_dirty(gui, &cmds[0]); - Task::batch( - cmds.into_iter() - .map(|c| Task::perform(send(c.to_string()), Message::Applied)), - ) - } - Message::Navigate(section) => { - // D3: jumping while searching first returns to the one-page - // view; the measure task below runs against the layout built - // after this re-render, so its offsets are fresh. - if !gui.search.trim().is_empty() { - gui.search.clear(); - } - // Immediate highlight — don't make the sidebar wait for the - // measure round-trip. - gui.section = section; - measure_sections(Some(section)) - } - Message::Scrolled(offset_y) => { - // Continuously tracked so a search-clear can restore it (D4); - // nothing reports Scrolled while the search page is up, so the - // value freezes at its pre-search state. - gui.last_scroll_y = offset_y; - measure_sections(None) - } - Message::Measured { - offsets, - max_scroll, - jump, - } => match jump { - Some(section) => scroll_to_section(section, offsets), - None => { - // Scrollspy: at the very end of the page the last header - // can never reach the viewport top (Connection is shorter - // than the viewport), so a bottomed-out scroll maps to - // INFINITY and clamps to the last section. This branch - // only sees a scrollable page: while the content fits its - // viewport no scroll event fires, so max_scroll == 0 can - // never get here. - let at_end = gui.last_scroll_y >= max_scroll - BOTTOM_SLACK; - let scroll_y = if at_end { - f32::INFINITY - } else { - gui.last_scroll_y - }; - gui.section = active_section_for(scroll_y, &offsets); - Task::none() - } - }, - Message::Search(query) => { - // D4: emptying the search re-shows the one-pager; land it back - // on the offset tracked before the search began. - let restore = !gui.search.trim().is_empty() && query.trim().is_empty(); - gui.search = query; - if restore { - restore_scroll(gui) - } else { - Task::none() - } - } - Message::KeyPressed(event) => shortcut(gui, event), - Message::PickerToggle(target) => { - gui.picker = if gui.picker == Some(target) { - None - } else { - Some(target) - }; - gui.picker_drag = false; - Task::none() - } - // Color changes from every editor (hex field, RGB sliders, picker - // drags) funnel through the same apply path. Invalid hex is kept as - // a per-editor draft so the text input doesn't snap back mid-typing; - // only valid values reach the mirror and the daemon. - Message::ColorHex(target, hex) => match hex.parse::() { - Ok(value) => { - gui.drafts.remove(&target); - ColorTarget::set_field(target, &mut gui.config, value); - let command = target.command(value); - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } - Err(_) => { - gui.drafts.insert(target, hex); - Task::none() - } - }, - Message::NumText(key, raw) => match raw.trim().parse::() { - // Valid and inside the daemon's bounds: commit immediately. - // Anything else (empty, half-typed, out of range) stays as a - // draft so the input doesn't snap back while typing. - Ok(v) if num_in_bounds(key, v) => apply_num(gui, key, v), - _ => { - gui.num_drafts.insert(key, raw); - Task::none() - } - }, - Message::NumDrag(key, v) => { - let (min, max) = key.num_bounds().expect("slider keys are numeric"); - apply_num(gui, key, (v as i64).clamp(min, max)) - } - Message::NumReset(key) => { - let Value::Num(default) = key.value_of(&Config::default()) else { - unreachable!("number_row only renders numeric keys"); - }; - apply_num(gui, key, default) - } - Message::ColorPart(target, part, v) => { - let current = ColorTarget::field(target, &gui.config).rgb(); - let mut bytes = current; - if let Some(slot) = bytes.get_mut(part as usize) { - *slot = (v * 255.0).round() as u8; - } - let value = HexColor::from_rgb8(bytes[0], bytes[1], bytes[2]); - update(gui, Message::ColorHex(target, value.to_string())) - } - Message::SvPress(target) => { - gui.picker_drag = true; - let p = gui.picker_pos; - apply_sv(gui, target, p) - } - Message::SvMove(target, p) => { - gui.picker_pos = p; - if gui.picker_drag { - apply_sv(gui, target, p) - } else { - Task::none() - } - } - Message::HuePress(target) => { - gui.picker_drag = true; - let p = gui.picker_pos; - apply_hue(gui, target, p) - } - Message::HueMove(target, p) => { - gui.picker_pos = p; - if gui.picker_drag { - apply_hue(gui, target, p) - } else { - Task::none() - } - } - Message::PickerRelease => { - gui.picker_drag = false; - Task::none() - } - Message::SetFlag(key, v) => { - let command = Command::Set(key, Value::Flag(v)); - if key == Key::ShowOnFullscreen { - gui.config.show_on_fullscreen = v; - mark_dirty(gui, &command); - Task::perform(send(command.to_string()), Message::Applied) - } else { - // The daemon decides persistence with its pre-apply autosave - // value; capture ours before the optimistic mirror flips too. - let persists = hyprlay_core::domain::should_persist(&command, gui.config.auto_save); - command.clone().apply_config(&mut gui.config); - if !persists { - gui.dirty = true; - } - Task::perform(send(command.to_string()), Message::Applied) - } - } - Message::AuthClientId(id) => { - gui.auth_client_id = id; - Task::none() - } - Message::AuthClientSecret(secret) => { - gui.auth_client_secret = secret; - Task::none() - } - Message::AuthApply => { - // Credentials deliberately bypass the ctl protocol (secrets - // must never travel the socket): they go straight to auth.json, - // and only an opaque "restart" crosses the socket afterwards. - let creds = AppCredentials { - client_id: gui.auth_client_id.trim().to_string(), - client_secret: gui.auth_client_secret.trim().to_string(), - }; - Task::perform(apply_auth_credentials(creds), Message::Applied) - } - command => { - let command = command_for(command); - mark_dirty(gui, &command); - command.clone().apply_config(&mut gui.config); - Task::perform(send(command.to_string()), Message::Applied) - } - } -} - -/// Keyboard shortcuts: Ctrl+S save, Ctrl+R reset section, Ctrl+Shift+R -/// reset all, Ctrl+F search, Ctrl+1..5 jumps to section N (the same path -/// as a sidebar click), Escape clears the search. -fn shortcut(gui: &mut Gui, event: keyboard::Event) -> Task { - let keyboard::Event::KeyPressed { key, modifiers, .. } = event else { - return Task::none(); - }; - if !modifiers.control() { - if matches!(key, keyboard::Key::Named(key::Named::Escape)) && !gui.search.trim().is_empty() - { - gui.search.clear(); - // D4: Esc empties the search, so land the one-pager back on - // its pre-search offset. - return restore_scroll(gui); - } - return Task::none(); - } - let keyboard::Key::Character(ch) = &key else { - return Task::none(); - }; - match ch.to_lowercase().as_str() { - "s" => update(gui, Message::Save), - "r" if modifiers.shift() => update(gui, Message::ResetAll), - "r" => update(gui, Message::ResetSection(gui.section)), - "f" => iced_runtime::widget::operation::focus(widget_id()), - _ => match ch.parse::() { - // Ctrl+1..5 scroll the one-pager to the section's header. - Ok(n) if (1..=Section::ALL.len()).contains(&n) => { - update(gui, Message::Navigate(Section::at(n - 1).unwrap())) - } - _ => Task::none(), - }, - } -} - -fn widget_id() -> iced::widget::Id { - iced::widget::Id::new(SEARCH_ID) -} - -/// Id of the one-page content scrollable — the jump target for navigation -/// and the widget the measure operation reads geometry from. -fn content_scroll_id() -> Id { - Id::new(CONTENT_SCROLL_ID) -} - -/// Commit one numeric value to the mirror and the daemon. The caller has -/// already made sure the value is inside the key's bounds. -fn apply_num(gui: &mut Gui, key: Key, value: i64) -> Task { - gui.num_drafts.remove(&key); - let command = Command::Set(key, Value::Num(value)); - mark_dirty(gui, &command); - command.clone().apply_config(&mut gui.config); - Task::perform(send(command.to_string()), Message::Applied) -} - -/// Mirror of the daemon's persistence rule: a change is "unsaved" exactly -/// when the daemon would not have persisted it. Decided with the -/// pre-application autosave value — the same one the daemon uses — so -/// flipping auto-save itself never leaves a phantom badge. -fn mark_dirty(gui: &mut Gui, command: &Command) { - if !hyprlay_core::domain::should_persist(command, gui.config.auto_save) { - gui.dirty = true; - } -} - -fn num_in_bounds(key: Key, v: i64) -> bool { - key.num_bounds() - .is_some_and(|(min, max)| v >= min && v <= max) -} - -/// Commands that bring `live` back to `saved`, one per differing key. -/// Used by "clear changes"; empty when there is nothing to revert. Walking -/// the shared [`Key`] table means a newly added setting can never be -/// forgotten here — it shows up in the diff the moment it exists. -fn revert_commands(live: &Config, saved: &Config) -> Vec { - Key::ALL - .into_iter() - .filter(|k| k.value_of(live) != k.value_of(saved)) - .map(|k| Command::Set(k, k.value_of(saved))) - .collect() -} - -/// Turn a GUI interaction into its control-socket command. The local config -/// mirror is the same [`Command::apply_config`] the daemon runs, so both -/// sides can never disagree about what a setting change means. -fn command_for(message: Message) -> Command { - match message { - Message::Position(h, v) => Command::Set(Key::Position, Value::Corner(corner_of(h, v))), - // Rides the generic apply path like Position: mirror locally, send - // the same wire command the CLI would. - Message::Anchor(mode) => Command::Set(Key::Anchor, Value::Anchor(mode)), - Message::SetFlag(..) => unreachable!("flags are handled directly in update"), - // Handled directly in `update`; unreachable here. - Message::NumText(..) - | Message::NumDrag(..) - | Message::NumReset(_) - | Message::ColorPart(..) - | Message::ColorHex(..) - | Message::PickerToggle(..) - | Message::SvPress(..) - | Message::SvMove(..) - | Message::HuePress(..) - | Message::HueMove(..) - | Message::PickerRelease - | Message::Palette(_) - | Message::Navigate(_) - | Message::Scrolled(_) - | Message::Measured { .. } - | Message::Search(_) - | Message::KeyPressed(_) - | Message::Save - | Message::ClearChanges - | Message::ResetAll - | Message::ResetSection(_) - | Message::SwitchMonitor(_) - | Message::Monitors(_) - | Message::Applied(_) - | Message::RefreshStatus - | Message::ToggleDaemon - | Message::ToggleResult(_) - | Message::AuthClientId(_) - | Message::AuthClientSecret(_) - | Message::AuthApply => unreachable!("handled before command_for"), - } -} - -// --------------------------------------------------------------------------- -// One-page navigation: measure, jump, scrollspy -// --------------------------------------------------------------------------- - -/// Half a section-header height: how close a header must be to the top of -/// the viewport before the scrollspy names its section. Small enough that -/// the highlight only moves once a header actually arrives, big enough -/// that a landed jump — which parks the header exactly at the top — keeps -/// its own highlight. -const SECTION_EPSILON: f32 = 16.0; - -/// Slack around the measured maximum scroll within which the page counts -/// as scrolled to its end. -const BOTTOM_SLACK: f32 = 1.0; - -/// The section whose header sits at or above `scroll_y` — the sidebar -/// highlight for that scroll position: the last section whose measured -/// header offset is within `scroll_y + SECTION_EPSILON`. The caller -/// passes [`f32::INFINITY`] once the page has scrolled to its end, -/// because the last header can never reach the viewport top itself. -/// `offsets` must be the headers measured in [`Section::ALL`] order — -/// one offset per section, at the same index. -fn active_section_for(scroll_y: f32, offsets: &[f32; Section::ALL.len()]) -> Section { - let mut active = Section::ALL[0]; - for (index, offset) in offsets.iter().enumerate() { - if *offset > scroll_y + SECTION_EPSILON { - break; - } - active = Section::ALL[index]; - } - active -} - -/// Where `header` sits inside the scrollable content, in px below the -/// content's top. Both rects are window-space layout bounds, so the -/// current scroll translation appears in both and cancels out. -fn offset_within_content(header_bounds: Rectangle, content_bounds: Rectangle) -> f32 { - header_bounds.y - content_bounds.y -} - -/// Measure the one-page content and report back as [`Message::Measured`]. -/// Widget operations run against the layout built from the very latest -/// state, so the offsets are fresh even right after a search-clear -/// re-render or a picker expansion changed heights. -fn measure_sections(jump: Option
) -> Task { - iced_runtime::task::widget(MeasureSections { - jump, - content: None, - offsets: [0.0; Section::ALL.len()], - }) -} - -/// Scroll the one-page content so `y` px into it sit at the viewport top; -/// the horizontal offset is left alone. -fn scroll_content_to(y: f32) -> Task { - iced_runtime::widget::operation::scroll_to( - content_scroll_id(), - iced::widget::scrollable::AbsoluteOffset { - x: None, - y: Some(y), - }, - ) -} - -/// Scroll the one-pager so `section`'s header sits at the top of the -/// viewport. `offsets` must come from a fresh measurement (see -/// [`measure_sections`]); out-of-range targets clamp inside the -/// scrollable. -fn scroll_to_section(section: Section, offsets: [f32; Section::ALL.len()]) -> Task { - scroll_content_to(offsets[section.index()]) -} - -/// D4: land the one-pager back on the offset tracked before the search -/// page replaced it. While the search page was up, nothing reported -/// Scrolled, so [`Gui::last_scroll_y`] still holds that position. -fn restore_scroll(gui: &Gui) -> Task { - scroll_content_to(gui.last_scroll_y) -} - -/// Geometry the measure operation learns about the one-page scrollable. -#[derive(Debug, Clone, Copy)] -struct ContentMeasure { - viewport: Rectangle, - content: Rectangle, -} - -impl ContentMeasure { - /// How far the content can scroll at all. - fn max_scroll(self) -> f32 { - (self.content.height - self.viewport.height).max(0.0) - } -} - -/// Traverses the widget tree once, recording the one-page scrollable's -/// geometry and every section header's offset within the content, then -/// delivers them as [`Message::Measured`]. `jump` rides along so the -/// receiver knows whether to scroll (navigation) or re-derive the -/// highlight (scrollspy). When the one-pager is not in the tree (the -/// search page is up), the operation produces nothing. -struct MeasureSections { - jump: Option
, - content: Option, - offsets: [f32; Section::ALL.len()], -} - -impl Operation for MeasureSections { - fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { - operate(self); - } - - fn scrollable( - &mut self, - id: Option<&Id>, - bounds: Rectangle, - content_bounds: Rectangle, - _translation: Vector, - _state: &mut dyn Scrollable, - ) { - if id == Some(&content_scroll_id()) { - self.content = Some(ContentMeasure { - viewport: bounds, - content: content_bounds, - }); - } - } - - fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { - // The scrollable hook fires before its children are traversed, so - // the content geometry is always known by the time a header anchor - // is visited. - let Some(content) = self.content else { - return; - }; - for (index, section) in Section::ALL.into_iter().enumerate() { - if id == Some(&Id::new(section.anchor_id())) { - self.offsets[index] = offset_within_content(bounds, content.content); - } - } - } - - fn finish(&self) -> Outcome { - let Some(content) = self.content else { - return Outcome::None; - }; - Outcome::Some(Message::Measured { - offsets: self.offsets, - max_scroll: content.max_scroll(), - jump: self.jump, - }) - } -} - -// --------------------------------------------------------------------------- -// View -// --------------------------------------------------------------------------- - -fn view(gui: &Gui) -> Element<'_, Message> { - let content = if gui.search.trim().is_empty() { - settings_page(gui) - } else { - search_page(gui) - }; - - column![ - header(gui), - container(row![sidebar(gui), content]).height(Length::Fill), - status_bar(gui), - ] - .width(Length::Fill) - .height(Length::Fill) - .into() -} - -/// Title, search box, and global actions on the darkest strip. -fn header(gui: &Gui) -> Element<'_, Message> { - // "Clear changes" only does something while the runtime config differs - // from disk; a disabled press target communicates that at a glance. - let mut clear = button(text("Clear changes")).style(plain_style()); - if gui.dirty { - clear = clear.on_press(Message::ClearChanges); - } - container( - row![ - text("hyprlay").size(14).color(BRIGHT), - text_input("Search settings… Ctrl+F", &gui.search) - .id(widget_id()) - .on_input(Message::Search) - .size(13) - .padding([4, 8]), - clear, - button(text("Reset all")) - .on_press(Message::ResetAll) - .style(plain_style()), - button(text("Save")) - .on_press(Message::Save) - .style(primary_style(gui.dirty)), - ] - .spacing(10) - .align_y(Alignment::Center), - ) - .padding([8, 12]) - .width(Length::Fill) - .style(panel(HEADER_BG)) - .into() -} - -/// Section navigation plus the shortcut cheat-sheet. -fn sidebar(gui: &Gui) -> Element<'_, Message> { - let mut nav = column![].spacing(4); - for (i, s) in Section::ALL.iter().enumerate() { - let selected = gui.section == *s && gui.search.trim().is_empty(); - nav = nav.push( - button( - row![ - text(s.name().to_string()).size(13), - iced::widget::Space::new().width(Length::Fill), - text(format!("Ctrl+{}", i + 1)).size(9).color(MUTED), - ] - .align_y(Alignment::Center) - .width(Length::Fill), - ) - .on_press(Message::Navigate(*s)) - .width(Length::Fill) - .style(nav_style(selected)), - ); - } - let hints = - "\nCtrl+S save\nCtrl+R reset section\nCtrl+F search\nEsc clear search"; - let col = column![ - nav, - iced::widget::Space::new().height(Length::Fill), - text(format!("shortcuts{hints}")).size(10).color(MUTED), - ] - .spacing(8); - container(col) - .width(Length::Fixed(160.0)) - .height(Length::Fill) - .padding([10, 8]) - .style(panel(SIDEBAR_BG)) - .into() -} - -fn status_bar(gui: &Gui) -> Element<'_, Message> { - let unsaved = if gui.dirty { - text("● unsaved").size(11).color(AMBER) - } else { - text("").size(11) - }; - container( - row![ - unsaved, - daemon_toggle(gui), - text("daemon").size(10).color(MUTED), - text(brief_status(gui.daemon_state.text())).size(11), - iced::widget::Space::new().width(Length::Fill), - text("last change").size(10).color(MUTED), - text(gui.last_reply.clone()).size(11).color(REPLY_GREEN), - ] - .spacing(8) - .align_y(Alignment::Center), - ) - .padding([6, 12]) - .width(Length::Fill) - .style(panel(HEADER_BG)) - .into() -} - -/// Bottom-left Start/Stop control. Disabled (no press target) while no -/// probe has answered yet, mirroring how "Clear changes" disables itself. -fn daemon_toggle(gui: &Gui) -> Element<'_, Message> { - let mut toggle = button(text(gui.daemon_state.label()).size(11)).style(plain_style()); - if gui.daemon_state.toggle().is_some() { - toggle = toggle.on_press(Message::ToggleDaemon); - } - toggle.into() -} - -/// "status=connected channel=ngobrol 3 participants=2 …" → -/// "connected · ngobrol 3". Channel names may contain spaces, so slice on -/// the known field markers instead of words. -fn brief_status(full: &str) -> String { - if !full.starts_with("status=") { - return full.to_string(); - } - let conn = full - .strip_prefix("status=") - .and_then(|rest| rest.split(' ').next()) - .unwrap_or("unknown"); - let channel = full.find("channel=").and_then(|start| { - full[start..] - .find(" participants=") - .map(|end| &full[start + "channel=".len()..start + end]) - }); - match channel { - Some(c) => format!("{conn} · {c}"), - None => conn.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn search_matches_label_tip_and_section_name() { - let field = fields::Field { - section: Section::Position, - label: "offset x", - tip: "Horizontal distance in px from the anchored screen edge.", - render: fields::f_offset_x, - }; - assert!(fields::search_matches(&field, "offset")); - assert!(fields::search_matches(&field, "horizontal")); - assert!(fields::search_matches(&field, "POSITION")); - assert!(!fields::search_matches(&field, "avatar")); - assert!(!fields::search_matches(&field, "")); - } - - #[test] - fn sections_map_one_to_one_onto_config_groups() { - use hyprlay_core::domain::Group; - // Only config-backed sections participate; Connection has no group. - let config_backed: Vec<_> = Section::ALL - .into_iter() - .filter_map(|section| section.group().map(|group| (section, group))) - .collect(); - assert_eq!(config_backed.len(), Group::ALL.len()); - for ((section, group), expected) in config_backed.into_iter().zip(Group::ALL) { - // The reset button sends one ResetGroup per GUI section; if a - // section ever fails to map, its fields could never be reset. - assert_eq!(group, expected); - assert_eq!( - format!("{expected}"), - section.name().to_lowercase(), - "section {} diverged from group {}", - section.name(), - group - ); - } - } - - /// Exactly one section is exempt from the reset machinery, and its name - /// must stay stable because the shortcut hints and search rely on it. - #[test] - fn every_section_except_connection_maps_to_a_group() { - for section in Section::ALL { - match section.group() { - Some(_) => assert_ne!(section.name(), "Connection"), - None => assert_eq!(section.name(), "Connection"), - } - } - } - - #[test] - fn every_field_has_a_nonempty_tooltip() { - for f in fields::FIELDS { - assert!(!f.tip.is_empty(), "field {} needs a tooltip", f.label); - assert!(!f.label.is_empty()); - } - } - - #[test] - fn every_section_has_fields() { - for s in Section::ALL { - assert!( - fields::FIELDS.iter().any(|f| f.section == s), - "section {} has no fields", - s.name() - ); - } - } - - /// Click-through was removed; no field may render it again. - #[test] - fn click_through_is_gone_from_the_field_registry() { - assert!(!fields::FIELDS.iter().any(|f| f.label.contains("click"))); - } - - #[test] - fn anchor_field_is_registered_in_the_position_section() { - let field = fields::FIELDS - .iter() - .find(|f| f.label == "anchor") - .expect("anchor field registered"); - assert_eq!(field.section, Section::Position); - } - - #[test] - fn anchor_setting_roundtrips_through_apply_and_revert() { - // The exact Command path the GUI's generic change pipeline drives. - let mut live = Config::default(); - let pin_bottom = Command::Set( - Key::Anchor, - Value::Anchor(hyprlay_core::config::AnchorMode::Bottom), - ); - pin_bottom.clone().apply_config(&mut live); - assert_eq!(live.anchor, hyprlay_core::config::AnchorMode::Bottom); - - // Reverting mirrors what "clear changes" replays: read the saved - // value back through the shared table and re-apply it. - let saved = Config::default(); - let revert = Command::Set(Key::Anchor, Key::Anchor.value_of(&saved)); - revert.apply_config(&mut live); - assert_eq!(live.anchor, saved.anchor); - } - - #[test] - fn brief_status_keeps_multiword_channel_names_intact() { - let full = "status=connected channel=ngobrol 3 participants=2 rtl=on monitor=eDP-1"; - assert_eq!(brief_status(full), "connected · ngobrol 3"); - } - - #[test] - fn brief_status_without_channel_falls_back_to_connection_word() { - assert_eq!(brief_status("status=disconnected"), "disconnected"); - assert_eq!(brief_status("connecting…"), "connecting…"); - } - - #[test] - fn key_sets_use_the_cli_wire_names() { - use hyprlay_core::config::OFFSETS; - assert_eq!( - Command::Set(Key::Opacity, Value::Num(42)).to_string(), - "set opacity 42" - ); - assert_eq!( - Command::Set(Key::OffsetX, Value::Num(-12)).to_string(), - "set offset-x -12" - ); - assert_eq!( - Command::Set(Key::TalkingOnly, Value::Flag(true)).to_string(), - "set talking-only on" - ); - assert!( - (OFFSETS.min as i64..=OFFSETS.max as i64).contains(&-12), - "test value must stay inside the shared bounds" - ); - } - - #[test] - fn every_numeric_key_has_sane_bounds() { - for key in Key::ALL { - if let Some((min, max)) = key.num_bounds() { - assert!(min <= max, "{} has an inverted range", key.name()); - } else { - // Non-numeric keys must not pretend to have slider bounds. - assert!( - !key.slider_bounds(&Config::default()).is_some() - || matches!( - key, - Key::OffsetX - | Key::OffsetY - | Key::Width - | Key::Scale - | Key::AvatarSize - | Key::TextSize - | Key::Spacing - | Key::MaxName - | Key::Opacity - | Key::AvatarOpacity - | Key::TextOpacity - | Key::BoxOpacity - ), - "{} renders a slider without numeric bounds", - key.name() - ); - } - } - } - - #[test] - fn revert_commands_do_nothing_when_configs_match() { - let cfg = Config::default(); - assert!(revert_commands(&cfg, &cfg).is_empty()); - } - - /// Realistic header offsets for the one-page view: five sections - /// stacked downward, the first header just below the page's top - /// padding, later ones several hundred px apart. - fn spy_offsets() -> [f32; Section::ALL.len()] { - [8.0, 900.0, 1500.0, 2100.0, 2600.0] - } - - /// At the top of the page the first header is inside the epsilon, so - /// Position is highlighted; halfway into a section the highlight still - /// names that section. - #[test] - fn scrollspy_names_the_section_under_the_top_of_the_viewport() { - let offsets = spy_offsets(); - assert_eq!(active_section_for(0.0, &offsets), Section::Position); - assert_eq!(active_section_for(1000.0, &offsets), Section::Layout); - assert_eq!(active_section_for(1900.0, &offsets), Section::Opacity); - assert_eq!(active_section_for(2400.0, &offsets), Section::Colors); - } - - /// A header takes over the highlight once the viewport top is within - /// half a header height of it — this is what keeps a landed jump (which - /// parks the header exactly at the top) on its own section. 60 px above - /// the Layout header the highlight must still be Position; 10 px above - /// it, Layout already wins. - #[test] - fn scrollspy_flips_while_a_header_is_half_a_header_away() { - let offsets = spy_offsets(); - assert_eq!(active_section_for(840.0, &offsets), Section::Position); - assert_eq!(active_section_for(890.0, &offsets), Section::Layout); - } - - /// The last section's header can never reach the viewport top (the - /// Connection section is shorter than the viewport), so once the page - /// is scrolled to its end the caller passes INFINITY and the highlight - /// must clamp to Connection instead of staying on Colors. - #[test] - fn scrollspy_clamps_to_connection_at_the_end_of_the_page() { - let offsets = spy_offsets(); - // Without the clamp, a bottom scroll (~2200 here) would highlight - // Colors although the user is looking at Connection. - assert_eq!(active_section_for(2200.0, &offsets), Section::Colors); - assert_eq!( - active_section_for(f32::INFINITY, &offsets), - Section::Connection - ); - } - - /// A page could in principle start with a tall top padding; before the - /// first measurement lands or before any header qualifies, the - /// highlight must fall back to the first section, never panic or wrap. - #[test] - fn scrollspy_falls_back_to_the_first_section_at_the_top() { - let offsets = [100.0, 900.0, 1500.0, 2100.0, 2600.0]; - assert_eq!(active_section_for(0.0, &offsets), Section::Position); - } - - /// Header offsets are read from window-space layout bounds while the - /// page may be scrolled; the jump math depends on the difference - /// between the two rects being independent of that translation. - #[test] - fn header_offset_is_its_distance_below_the_content_and_ignores_scroll() { - let content = Rectangle::new(Point::ORIGIN, iced::Size::new(600.0, 4000.0)); - let header = Rectangle::new(Point::new(16.0, 908.0), iced::Size::new(568.0, 30.0)); - assert_eq!(offset_within_content(header, content), 908.0); - - // The same layout scrolled down by 250 px: both rects move, the - // offset within the content must not. - let scrolled_content = Rectangle::new(Point::new(16.0, -250.0), content.size()); - let scrolled_header = Rectangle::new(Point::new(32.0, 658.0), header.size()); - assert_eq!( - offset_within_content(scrolled_header, scrolled_content), - 908.0 - ); - } - - /// D3: a sidebar click or Ctrl+1..5 while a search is up first drops - /// the query (returning to the one-page view) and shows the target - /// section's highlight immediately, without waiting for the measure - /// round-trip. - #[test] - fn navigating_while_searching_clears_the_search_and_sets_the_section() { - let mut gui = gui_with_search("avatar"); - // The returned Task carries the measure-then-jump round-trip; the - // state transition is what is asserted here. - let _ = update(&mut gui, Message::Navigate(Section::Colors)); - assert!(gui.search.is_empty()); - assert_eq!(gui.section, Section::Colors); - } - - /// D4 mechanism: the restore on search-clear scrolls back to whatever - /// offset the scrollspy last tracked, so scrolling must keep that value - /// current, and emptying the search must not clobber it. - #[test] - fn scrolling_tracks_the_offset_that_the_search_restore_will_use() { - let mut gui = gui_with_search("dim"); - // Both transitions return layout/scroll Tasks; only the tracked - // state matters here. - let _ = update(&mut gui, Message::Scrolled(412.5)); - assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); - - let _ = update(&mut gui, Message::Search(String::new())); - assert!(gui.search.is_empty()); - assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); - } - - /// Minimal `Gui` for state-transition tests: `boot()` touches the real - /// config file, so build the struct with test values instead. Only the - /// navigation fields matter here. - fn gui_with_search(query: &str) -> Gui { - Gui { - config: Config::default(), - drafts: HashMap::new(), - num_drafts: HashMap::new(), - last_reply: String::new(), - daemon_state: daemon::DaemonState::Connecting, - auto_start: daemon::AutoStart::watching(), - control: Arc::new(crate::platform::service::SystemControl), - dirty: false, - monitors: Vec::new(), - section: Section::Position, - search: query.to_string(), - last_scroll_y: 0.0, - picker: None, - picker_drag: false, - picker_pos: Point::ORIGIN, - auth_client_id: String::new(), - auth_client_secret: String::new(), - } - } - - #[test] - fn revert_commands_cover_every_differing_key_once() { - use hyprlay_core::config::HorizontalAnchor as H; - use hyprlay_core::config::VerticalAnchor as V; - // show_own_user defaults to true, so flipping it off is a real diff. - let saved = Config { - horizontal: H::Right, - vertical: V::Top, // top-right corner - rtl: true, - offset_x: 40, - opacity: 70, - width: 500, - show_own_user: false, - monitor: Some("DP-2".into()), - speaking_color: "#00ff00".parse().unwrap(), - ..Config::default() - }; - - let cmds = revert_commands(&Config::default(), &saved); - for expected in [ - "set position top-right", - "set rtl on", - "set offset-x 40", - "set opacity 70", - "set width 500", - "set own-user off", - "set monitor DP-2", - ] { - assert!( - cmds.iter().any(|c| c.to_string() == expected), - "missing revert command {expected}" - ); - } - assert!( - cmds.iter() - .any(|c| c.to_string().starts_with("set speaking-color ")) - ); - // Exactly one command per changed key — no redundant spam. - assert_eq!(cmds.len(), 8, "unexpected extra commands: {cmds:?}"); - } -} diff --git a/src/gui/picker.rs b/src/gui/picker.rs index 3ccfdf2..de686f9 100644 --- a/src/gui/picker.rs +++ b/src/gui/picker.rs @@ -32,7 +32,7 @@ use iced::widget::text_input; use super::Gui; use super::Message; use super::fields::reset_button; -use super::update; +use super::update::update; fn iced_color(rgb: Rgb) -> Color { Color::from_rgb(rgb.r, rgb.g, rgb.b) diff --git a/src/gui/scroll.rs b/src/gui/scroll.rs new file mode 100644 index 0000000..9469a8b --- /dev/null +++ b/src/gui/scroll.rs @@ -0,0 +1,261 @@ +//! One-page navigation: measure the content layout, jump to a section, +//! and keep the sidebar highlight (scrollspy) on the section under the +//! viewport top. Also owns the two widget ids shared across the GUI: the +//! header's search input and the one-page content scrollable. + +use iced::Rectangle; +use iced::Task; +use iced::Vector; +use iced::widget::Id; +use iced_runtime::core::widget::Operation; +use iced_runtime::core::widget::operation::Outcome; +use iced_runtime::core::widget::operation::scrollable::Scrollable; + +use super::Gui; +use super::Message; +use super::fields::CONTENT_SCROLL_ID; +use super::fields::SEARCH_ID; +use super::fields::Section; + +pub(super) fn widget_id() -> iced::widget::Id { + iced::widget::Id::new(SEARCH_ID) +} + +/// Id of the one-page content scrollable — the jump target for navigation +/// and the widget the measure operation reads geometry from. +fn content_scroll_id() -> Id { + Id::new(CONTENT_SCROLL_ID) +} + +/// Half a section-header height: how close a header must be to the top of +/// the viewport before the scrollspy names its section. Small enough that +/// the highlight only moves once a header actually arrives, big enough +/// that a landed jump — which parks the header exactly at the top — keeps +/// its own highlight. +const SECTION_EPSILON: f32 = 16.0; + +/// Slack around the measured maximum scroll within which the page counts +/// as scrolled to its end. +pub(super) const BOTTOM_SLACK: f32 = 1.0; + +/// The section whose header sits at or above `scroll_y` — the sidebar +/// highlight for that scroll position: the last section whose measured +/// header offset is within `scroll_y + SECTION_EPSILON`. The caller +/// passes [`f32::INFINITY`] once the page has scrolled to its end, +/// because the last header can never reach the viewport top itself. +/// `offsets` must be the headers measured in [`Section::ALL`] order — +/// one offset per section, at the same index. +pub(super) fn active_section_for(scroll_y: f32, offsets: &[f32; Section::ALL.len()]) -> Section { + let mut active = Section::ALL[0]; + for (index, offset) in offsets.iter().enumerate() { + if *offset > scroll_y + SECTION_EPSILON { + break; + } + active = Section::ALL[index]; + } + active +} + +/// Where `header` sits inside the scrollable content, in px below the +/// content's top. Both rects are window-space layout bounds, so the +/// current scroll translation appears in both and cancels out. +fn offset_within_content(header_bounds: Rectangle, content_bounds: Rectangle) -> f32 { + header_bounds.y - content_bounds.y +} + +/// Measure the one-page content and report back as [`Message::Measured`]. +/// Widget operations run against the layout built from the very latest +/// state, so the offsets are fresh even right after a search-clear +/// re-render or a picker expansion changed heights. +pub(super) fn measure_sections(jump: Option
) -> Task { + iced_runtime::task::widget(MeasureSections { + jump, + content: None, + offsets: [0.0; Section::ALL.len()], + }) +} + +/// Scroll the one-page content so `y` px into it sit at the viewport top; +/// the horizontal offset is left alone. +fn scroll_content_to(y: f32) -> Task { + iced_runtime::widget::operation::scroll_to( + content_scroll_id(), + iced::widget::scrollable::AbsoluteOffset { + x: None, + y: Some(y), + }, + ) +} + +/// Scroll the one-pager so `section`'s header sits at the top of the +/// viewport. `offsets` must come from a fresh measurement (see +/// [`measure_sections`]); out-of-range targets clamp inside the +/// scrollable. +pub(super) fn scroll_to_section( + section: Section, + offsets: [f32; Section::ALL.len()], +) -> Task { + scroll_content_to(offsets[section.index()]) +} + +/// D4: land the one-pager back on the offset tracked before the search +/// page replaced it. While the search page was up, nothing reported +/// Scrolled, so [`Gui::last_scroll_y`] still holds that position. +pub(super) fn restore_scroll(gui: &Gui) -> Task { + scroll_content_to(gui.last_scroll_y) +} + +/// Geometry the measure operation learns about the one-page scrollable. +#[derive(Debug, Clone, Copy)] +struct ContentMeasure { + viewport: Rectangle, + content: Rectangle, +} + +impl ContentMeasure { + /// How far the content can scroll at all. + fn max_scroll(self) -> f32 { + (self.content.height - self.viewport.height).max(0.0) + } +} + +/// Traverses the widget tree once, recording the one-page scrollable's +/// geometry and every section header's offset within the content, then +/// delivers them as [`Message::Measured`]. `jump` rides along so the +/// receiver knows whether to scroll (navigation) or re-derive the +/// highlight (scrollspy). When the one-pager is not in the tree (the +/// search page is up), the operation produces nothing. +struct MeasureSections { + jump: Option
, + content: Option, + offsets: [f32; Section::ALL.len()], +} + +impl Operation for MeasureSections { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn scrollable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + content_bounds: Rectangle, + _translation: Vector, + _state: &mut dyn Scrollable, + ) { + if id == Some(&content_scroll_id()) { + self.content = Some(ContentMeasure { + viewport: bounds, + content: content_bounds, + }); + } + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + // The scrollable hook fires before its children are traversed, so + // the content geometry is always known by the time a header anchor + // is visited. + let Some(content) = self.content else { + return; + }; + for (index, section) in Section::ALL.into_iter().enumerate() { + if id == Some(&Id::new(section.anchor_id())) { + self.offsets[index] = offset_within_content(bounds, content.content); + } + } + } + + fn finish(&self) -> Outcome { + let Some(content) = self.content else { + return Outcome::None; + }; + Outcome::Some(Message::Measured { + offsets: self.offsets, + max_scroll: content.max_scroll(), + jump: self.jump, + }) + } +} + +#[cfg(test)] +mod tests { + use iced::Point; + + use super::*; + + /// Realistic header offsets for the one-page view: five sections + /// stacked downward, the first header just below the page's top + /// padding, later ones several hundred px apart. + fn spy_offsets() -> [f32; Section::ALL.len()] { + [8.0, 900.0, 1500.0, 2100.0, 2600.0] + } + + /// At the top of the page the first header is inside the epsilon, so + /// Position is highlighted; halfway into a section the highlight still + /// names that section. + #[test] + fn scrollspy_names_the_section_under_the_top_of_the_viewport() { + let offsets = spy_offsets(); + assert_eq!(active_section_for(0.0, &offsets), Section::Position); + assert_eq!(active_section_for(1000.0, &offsets), Section::Layout); + assert_eq!(active_section_for(1900.0, &offsets), Section::Opacity); + assert_eq!(active_section_for(2400.0, &offsets), Section::Colors); + } + + /// A header takes over the highlight once the viewport top is within + /// half a header height of it — this is what keeps a landed jump (which + /// parks the header exactly at the top) on its own section. 60 px above + /// the Layout header the highlight must still be Position; 10 px above + /// it, Layout already wins. + #[test] + fn scrollspy_flips_while_a_header_is_half_a_header_away() { + let offsets = spy_offsets(); + assert_eq!(active_section_for(840.0, &offsets), Section::Position); + assert_eq!(active_section_for(890.0, &offsets), Section::Layout); + } + + /// The last section's header can never reach the viewport top (the + /// Connection section is shorter than the viewport), so once the page + /// is scrolled to its end the caller passes INFINITY and the highlight + /// must clamp to Connection instead of staying on Colors. + #[test] + fn scrollspy_clamps_to_connection_at_the_end_of_the_page() { + let offsets = spy_offsets(); + // Without the clamp, a bottom scroll (~2200 here) would highlight + // Colors although the user is looking at Connection. + assert_eq!(active_section_for(2200.0, &offsets), Section::Colors); + assert_eq!( + active_section_for(f32::INFINITY, &offsets), + Section::Connection + ); + } + + /// A page could in principle start with a tall top padding; before the + /// first measurement lands or before any header qualifies, the + /// highlight must fall back to the first section, never panic or wrap. + #[test] + fn scrollspy_falls_back_to_the_first_section_at_the_top() { + let offsets = [100.0, 900.0, 1500.0, 2100.0, 2600.0]; + assert_eq!(active_section_for(0.0, &offsets), Section::Position); + } + + /// Header offsets are read from window-space layout bounds while the + /// page may be scrolled; the jump math depends on the difference + /// between the two rects being independent of that translation. + #[test] + fn header_offset_is_its_distance_below_the_content_and_ignores_scroll() { + let content = Rectangle::new(Point::ORIGIN, iced::Size::new(600.0, 4000.0)); + let header = Rectangle::new(Point::new(16.0, 908.0), iced::Size::new(568.0, 30.0)); + assert_eq!(offset_within_content(header, content), 908.0); + + // The same layout scrolled down by 250 px: both rects move, the + // offset within the content must not. + let scrolled_content = Rectangle::new(Point::new(16.0, -250.0), content.size()); + let scrolled_header = Rectangle::new(Point::new(32.0, 658.0), header.size()); + assert_eq!( + offset_within_content(scrolled_header, scrolled_content), + 908.0 + ); + } +} diff --git a/src/gui/update.rs b/src/gui/update.rs new file mode 100644 index 0000000..5b5debb --- /dev/null +++ b/src/gui/update.rs @@ -0,0 +1,496 @@ +//! Update layer: the one flat [`update`] match — the app's dispatch +//! table — plus the keyboard [`shortcut`] dispatcher that feeds it and +//! the async effects its arms spawn off the UI thread. + +use std::sync::Arc; + +use hyprlay_core::config::Config; +use hyprlay_core::config::PALETTES; +use hyprlay_core::config::{self}; +use hyprlay_core::credentials::AppCredentials; +use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::StopPolicy; +use hyprlay_core::daemon_control::Toggle; +use hyprlay_core::domain::Command; +use hyprlay_core::domain::HexColor; +use hyprlay_core::domain::Key; +use hyprlay_core::domain::Value; +use hyprlay_core::status::StatusFields; +use iced::Task; +use iced::keyboard::key; +use iced::keyboard::{self}; + +use super::Gui; +use super::Message; +use super::commands::apply_num; +use super::commands::command_for; +use super::commands::mark_dirty; +use super::commands::num_in_bounds; +use super::commands::revert_commands; +use super::fields::Section; +use super::picker::ColorTarget; +use super::picker::apply_hue; +use super::picker::apply_sv; +use super::scroll::BOTTOM_SLACK; +use super::scroll::active_section_for; +use super::scroll::measure_sections; +use super::scroll::restore_scroll; +use super::scroll::scroll_to_section; +use super::scroll::widget_id; +use super::send; + +pub(super) fn update(gui: &mut Gui, message: Message) -> Task { + match message { + Message::Applied(reply) => { + let reply = reply.trim().to_string(); + // Every reply is a potential probe outcome; only probe outcomes + // actually move the state (see DaemonState::advance) — and + // while the boot auto-start has the wheel, failures hold + // `connecting…` instead of reporting the daemon dead. + let launch = gui.auto_start.observe(&mut gui.daemon_state, &reply); + // `dump` replies with the live runtime config as TOML — adopt it + // so the GUI reflects unsaved daemon state. The [position] + // header marks a dump; any other text is an ordinary reply. Any + // in-flight input drafts are stale after an external reset, so + // drop them too. + if reply.contains("[position]") { + if let Ok(live) = toml::from_str::(&reply) { + gui.config = live; + gui.drafts.clear(); + gui.num_drafts.clear(); + } + } else if reply == "saved" { + gui.dirty = false; + } else if !reply.is_empty() && !StatusFields::is_status_line(&reply) { + // status= replies are consumed by the state chip above; + // everything else is ordinary status-bar traffic. + gui.last_reply = reply; + } + match launch { + Some(toggle) => { + // Opening the GUI brings the daemon up: fire-and-forget + // off the UI thread, through the same DaemonControl path + // as the Start button. This is also why closing the + // window never stops the daemon — nothing here ties its + // lifetime to GUI exit (systemctl owns the unit; the + // fallback spawn detaches into its own process group). + let control = Arc::clone(&gui.control); + Task::perform(run_toggle(control, toggle), Message::ToggleResult) + } + None => Task::none(), + } + } + Message::RefreshStatus => { + Task::perform(send(Command::Status.to_string()), Message::Applied) + } + Message::ToggleDaemon => { + let Some(toggle) = gui.daemon_state.toggle() else { + return Task::none(); + }; + let control = Arc::clone(&gui.control); + Task::perform(run_toggle(control, toggle), Message::ToggleResult) + } + Message::ToggleResult(failure) => { + // The boot bring-up attempt finished either way; stop holding + // the connecting line on its behalf. + gui.auto_start.settled(); + if let Some(text) = failure { + gui.last_reply = text; + } + // Whether it worked is only visible through a fresh probe; do + // not wait for the next 2 s tick. + Task::perform(send(Command::Status.to_string()), Message::Applied) + } + Message::Monitors(monitors) => { + gui.monitors = monitors; + Task::none() + } + Message::Save => { + gui.dirty = false; + Task::perform(send(Command::Save.to_string()), Message::Applied) + } + Message::ClearChanges => { + // Revert the daemon's runtime state to the on-disk config by + // replaying only the fields that actually differ. + let saved = config::load(); + let commands = revert_commands(&gui.config, &saved); + gui.config = saved; + gui.drafts.clear(); + gui.num_drafts.clear(); + gui.dirty = false; + Task::batch( + commands + .into_iter() + .map(|c| Task::perform(send(c.to_string()), Message::Applied)), + ) + } + Message::ResetAll => { + mark_dirty(gui, &Command::ResetAll); + Task::perform(send(Command::ResetAll.to_string()), Message::Applied).chain( + Task::perform(send(Command::Dump.to_string()), Message::Applied), + ) + } + Message::ResetSection(section) => { + // Sections without a config group have nothing to reset; the + // GUI hides their button, so this arm is a defensive no-op. + let Some(group) = section.group() else { + return Task::none(); + }; + let command = Command::ResetGroup(group); + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied).chain(Task::perform( + send(Command::Dump.to_string()), + Message::Applied, + )) + } + // `monitor` is answered by the shell before apply_config runs, so + // the mirror must be updated here or the chip highlight lags behind. + Message::SwitchMonitor(target) => { + let command = Command::Set( + Key::Monitor, + Value::Target(match &target { + None => hyprlay_core::domain::MonitorTarget::Active, + Some(name) => hyprlay_core::domain::MonitorTarget::Named(name.clone()), + }), + ); + gui.config.monitor = target; + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } + Message::Palette(index) => { + let Some(p) = PALETTES.get(index) else { + return Task::none(); + }; + let cmds = [ + Command::Set(Key::SpeakingColor, Value::Color(p.speaking)), + Command::Set(Key::TextColor, Value::Color(p.text)), + Command::Set(Key::BoxColor, Value::Color(p.box_bg)), + ]; + for cmd in &cmds { + cmd.clone().apply_config(&mut gui.config); + } + // All three palette entries are Sets: one decision covers them. + mark_dirty(gui, &cmds[0]); + Task::batch( + cmds.into_iter() + .map(|c| Task::perform(send(c.to_string()), Message::Applied)), + ) + } + Message::Navigate(section) => { + // D3: jumping while searching first returns to the one-page + // view; the `measure_sections` task below runs against the + // layout built after this re-render, so its offsets are fresh. + if !gui.search.trim().is_empty() { + gui.search.clear(); + } + // Immediate highlight — don't make the sidebar wait for the + // measure round-trip. + gui.section = section; + measure_sections(Some(section)) + } + Message::Scrolled(offset_y) => { + // Continuously tracked so a search-clear can restore it (D4); + // nothing reports Scrolled while the search page is up, so the + // value freezes at its pre-search state. + gui.last_scroll_y = offset_y; + measure_sections(None) + } + Message::Measured { + offsets, + max_scroll, + jump, + } => match jump { + Some(section) => scroll_to_section(section, offsets), + None => { + // Scrollspy: at the very end of the page the last header + // can never reach the viewport top (Connection is shorter + // than the viewport), so a bottomed-out scroll maps to + // INFINITY and clamps to the last section. This branch + // only sees a scrollable page: while the content fits its + // viewport no scroll event fires, so max_scroll == 0 can + // never get here. + let at_end = gui.last_scroll_y >= max_scroll - BOTTOM_SLACK; + let scroll_y = if at_end { + f32::INFINITY + } else { + gui.last_scroll_y + }; + gui.section = active_section_for(scroll_y, &offsets); + Task::none() + } + }, + Message::Search(query) => { + // D4: emptying the search re-shows the one-pager; land it back + // on the offset tracked before the search began. + let restore = !gui.search.trim().is_empty() && query.trim().is_empty(); + gui.search = query; + if restore { + restore_scroll(gui) + } else { + Task::none() + } + } + Message::KeyPressed(event) => shortcut(gui, event), + Message::PickerToggle(target) => { + gui.picker = if gui.picker == Some(target) { + None + } else { + Some(target) + }; + gui.picker_drag = false; + Task::none() + } + // Color changes from every editor (hex field, RGB sliders, picker + // drags) funnel through the same apply path. Invalid hex is kept as + // a per-editor draft so the text input doesn't snap back mid-typing; + // only valid values reach the mirror and the daemon. + Message::ColorHex(target, hex) => match hex.parse::() { + Ok(value) => { + gui.drafts.remove(&target); + ColorTarget::set_field(target, &mut gui.config, value); + let command = target.command(value); + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } + Err(_) => { + gui.drafts.insert(target, hex); + Task::none() + } + }, + Message::NumText(key, raw) => match raw.trim().parse::() { + // Valid and inside the daemon's bounds: commit immediately. + // Anything else (empty, half-typed, out of range) stays as a + // draft so the input doesn't snap back while typing. + Ok(v) if num_in_bounds(key, v) => apply_num(gui, key, v), + _ => { + gui.num_drafts.insert(key, raw); + Task::none() + } + }, + Message::NumDrag(key, v) => { + let (min, max) = key.num_bounds().expect("slider keys are numeric"); + apply_num(gui, key, (v as i64).clamp(min, max)) + } + Message::NumReset(key) => { + let Value::Num(default) = key.value_of(&Config::default()) else { + unreachable!("number_row only renders numeric keys"); + }; + apply_num(gui, key, default) + } + Message::ColorPart(target, part, v) => { + let current = ColorTarget::field(target, &gui.config).rgb(); + let mut bytes = current; + if let Some(slot) = bytes.get_mut(part as usize) { + *slot = (v * 255.0).round() as u8; + } + let value = HexColor::from_rgb8(bytes[0], bytes[1], bytes[2]); + update(gui, Message::ColorHex(target, value.to_string())) + } + Message::SvPress(target) => { + gui.picker_drag = true; + let p = gui.picker_pos; + apply_sv(gui, target, p) + } + Message::SvMove(target, p) => { + gui.picker_pos = p; + if gui.picker_drag { + apply_sv(gui, target, p) + } else { + Task::none() + } + } + Message::HuePress(target) => { + gui.picker_drag = true; + let p = gui.picker_pos; + apply_hue(gui, target, p) + } + Message::HueMove(target, p) => { + gui.picker_pos = p; + if gui.picker_drag { + apply_hue(gui, target, p) + } else { + Task::none() + } + } + Message::PickerRelease => { + gui.picker_drag = false; + Task::none() + } + Message::SetFlag(key, v) => { + let command = Command::Set(key, Value::Flag(v)); + if key == Key::ShowOnFullscreen { + gui.config.show_on_fullscreen = v; + mark_dirty(gui, &command); + Task::perform(send(command.to_string()), Message::Applied) + } else { + // The daemon decides persistence with its pre-apply autosave + // value; capture ours before the optimistic mirror flips too. + let persists = hyprlay_core::domain::should_persist(&command, gui.config.auto_save); + command.clone().apply_config(&mut gui.config); + if !persists { + gui.dirty = true; + } + Task::perform(send(command.to_string()), Message::Applied) + } + } + Message::AuthClientId(id) => { + gui.auth_client_id = id; + Task::none() + } + Message::AuthClientSecret(secret) => { + gui.auth_client_secret = secret; + Task::none() + } + Message::AuthApply => { + // Credentials deliberately bypass the ctl protocol (secrets + // must never travel the socket): they go straight to auth.json, + // and only an opaque "restart" crosses the socket afterwards. + let creds = AppCredentials { + client_id: gui.auth_client_id.trim().to_string(), + client_secret: gui.auth_client_secret.trim().to_string(), + }; + Task::perform(apply_auth_credentials(creds), Message::Applied) + } + command => { + let command = command_for(command); + mark_dirty(gui, &command); + command.clone().apply_config(&mut gui.config); + Task::perform(send(command.to_string()), Message::Applied) + } + } +} + +/// Keyboard shortcuts: Ctrl+S save, Ctrl+R reset section, Ctrl+Shift+R +/// reset all, Ctrl+F search, Ctrl+1..5 jumps to section N (the same path +/// as a sidebar click), Escape clears the search. +fn shortcut(gui: &mut Gui, event: keyboard::Event) -> Task { + let keyboard::Event::KeyPressed { key, modifiers, .. } = event else { + return Task::none(); + }; + if !modifiers.control() { + if matches!(key, keyboard::Key::Named(key::Named::Escape)) && !gui.search.trim().is_empty() + { + gui.search.clear(); + // D4: Esc empties the search, so land the one-pager back on + // its pre-search offset. + return restore_scroll(gui); + } + return Task::none(); + } + let keyboard::Key::Character(ch) = &key else { + return Task::none(); + }; + match ch.to_lowercase().as_str() { + "s" => update(gui, Message::Save), + "r" if modifiers.shift() => update(gui, Message::ResetAll), + "r" => update(gui, Message::ResetSection(gui.section)), + "f" => iced_runtime::widget::operation::focus(widget_id()), + _ => match ch.parse::() { + // Ctrl+1..5 scroll the one-pager to the section's header. + Ok(n) if (1..=Section::ALL.len()).contains(&n) => { + update(gui, Message::Navigate(Section::at(n - 1).unwrap())) + } + _ => Task::none(), + }, + } +} + +/// Run one Start/Stop action (systemctl, sibling spawn, or socket quit) off +/// the UI thread, same blocking pattern as [`send`]. +async fn run_toggle(control: Arc, toggle: Toggle) -> Option { + tokio::task::spawn_blocking(move || { + hyprlay_core::daemon_control::execute_toggle(&*control, toggle, StopPolicy::ViaSystemctl) + }) + .await + .unwrap_or_else(|e| Some(format!("error: daemon toggle task failed: {e}"))) +} + +/// Persist own-app credentials off the UI thread, then ask the daemon to +/// restart so it re-runs detect() and picks up the new backend. The +/// returned text lands in the status bar via [`Message::Applied`]. +async fn apply_auth_credentials(creds: AppCredentials) -> String { + // Read before the move: the decision text depends on what was applied. + let cleared = creds.client_id.is_empty() && creds.client_secret.is_empty(); + let saved = tokio::task::spawn_blocking(move || hyprlay_core::credentials::save(&creds)) + .await + .unwrap_or_else(|e| Err(std::io::Error::other(e.to_string()))); + match saved { + Ok(()) => { + // The daemon's own reply only confirms delivery; the meaningful + // text for the status bar is ours. + let _ = send("restart".to_string()).await; + if cleared { + "credentials cleared, restarting daemon".to_string() + } else { + "credentials saved, restarting daemon".to_string() + } + } + Err(e) => format!("error: could not write credentials: {e}"), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use iced::Point; + + use super::*; + use crate::gui::daemon::AutoStart; + use crate::gui::daemon::DaemonState; + + /// D3: a sidebar click or Ctrl+1..5 while a search is up first drops + /// the query (returning to the one-page view) and shows the target + /// section's highlight immediately, without waiting for the measure + /// round-trip. + #[test] + fn navigating_while_searching_clears_the_search_and_sets_the_section() { + let mut gui = gui_with_search("avatar"); + // The returned Task carries the measure-then-jump round-trip; the + // state transition is what is asserted here. + let _ = update(&mut gui, Message::Navigate(Section::Colors)); + assert!(gui.search.is_empty()); + assert_eq!(gui.section, Section::Colors); + } + + /// D4 mechanism: the restore on search-clear scrolls back to whatever + /// offset the scrollspy last tracked, so scrolling must keep that value + /// current, and emptying the search must not clobber it. + #[test] + fn scrolling_tracks_the_offset_that_the_search_restore_will_use() { + let mut gui = gui_with_search("dim"); + // Both transitions return layout/scroll Tasks; only the tracked + // state matters here. + let _ = update(&mut gui, Message::Scrolled(412.5)); + assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); + + let _ = update(&mut gui, Message::Search(String::new())); + assert!(gui.search.is_empty()); + assert!((gui.last_scroll_y - 412.5).abs() < f32::EPSILON); + } + + /// Minimal `Gui` for state-transition tests: `boot()` touches the real + /// config file, so build the struct with test values instead. Only the + /// navigation fields matter here. + fn gui_with_search(query: &str) -> Gui { + Gui { + config: Config::default(), + drafts: HashMap::new(), + num_drafts: HashMap::new(), + last_reply: String::new(), + daemon_state: DaemonState::Connecting, + auto_start: AutoStart::watching(), + control: Arc::new(crate::platform::service::SystemControl), + dirty: false, + monitors: Vec::new(), + section: Section::Position, + search: query.to_string(), + last_scroll_y: 0.0, + picker: None, + picker_drag: false, + picker_pos: Point::ORIGIN, + auth_client_id: String::new(), + auth_client_secret: String::new(), + } + } +} diff --git a/src/gui/view.rs b/src/gui/view.rs new file mode 100644 index 0000000..f00b3d7 --- /dev/null +++ b/src/gui/view.rs @@ -0,0 +1,191 @@ +//! View layer: the window composition — header (title, search, global +//! actions), sidebar (section anchors + shortcut cheat-sheet), status bar +//! (unsaved marker, daemon toggle, last reply) — around the section pages +//! that `fields` renders. + +use hyprlay_core::status::StatusFields; +use iced::Alignment; +use iced::Element; +use iced::Length; +use iced::widget::button; +use iced::widget::column; +use iced::widget::container; +use iced::widget::row; +use iced::widget::text; +use iced::widget::text_input; + +use super::Gui; +use super::Message; +use super::fields::Section; +use super::fields::search_page; +use super::fields::settings_page; +use super::scroll::widget_id; +use super::theme::AMBER; +use super::theme::BRIGHT; +use super::theme::HEADER_BG; +use super::theme::MUTED; +use super::theme::REPLY_GREEN; +use super::theme::SIDEBAR_BG; +use super::theme::nav_style; +use super::theme::panel; +use super::theme::plain_style; +use super::theme::primary_style; + +pub(super) fn view(gui: &Gui) -> Element<'_, Message> { + let content = if gui.search.trim().is_empty() { + settings_page(gui) + } else { + search_page(gui) + }; + + column![ + header(gui), + container(row![sidebar(gui), content]).height(Length::Fill), + status_bar(gui), + ] + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Title, search box, and global actions on the darkest strip. +fn header(gui: &Gui) -> Element<'_, Message> { + // "Clear changes" only does something while the runtime config differs + // from disk; a disabled press target communicates that at a glance. + let mut clear = button(text("Clear changes")).style(plain_style()); + if gui.dirty { + clear = clear.on_press(Message::ClearChanges); + } + container( + row![ + text("hyprlay").size(14).color(BRIGHT), + text_input("Search settings… Ctrl+F", &gui.search) + .id(widget_id()) + .on_input(Message::Search) + .size(13) + .padding([4, 8]), + clear, + button(text("Reset all")) + .on_press(Message::ResetAll) + .style(plain_style()), + button(text("Save")) + .on_press(Message::Save) + .style(primary_style(gui.dirty)), + ] + .spacing(10) + .align_y(Alignment::Center), + ) + .padding([8, 12]) + .width(Length::Fill) + .style(panel(HEADER_BG)) + .into() +} + +/// Section navigation plus the shortcut cheat-sheet. +fn sidebar(gui: &Gui) -> Element<'_, Message> { + let mut nav = column![].spacing(4); + for (i, s) in Section::ALL.iter().enumerate() { + let selected = gui.section == *s && gui.search.trim().is_empty(); + nav = nav.push( + button( + row![ + text(s.name().to_string()).size(13), + iced::widget::Space::new().width(Length::Fill), + text(format!("Ctrl+{}", i + 1)).size(9).color(MUTED), + ] + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .on_press(Message::Navigate(*s)) + .width(Length::Fill) + .style(nav_style(selected)), + ); + } + let hints = + "\nCtrl+S save\nCtrl+R reset section\nCtrl+F search\nEsc clear search"; + let col = column![ + nav, + iced::widget::Space::new().height(Length::Fill), + text(format!("shortcuts{hints}")).size(10).color(MUTED), + ] + .spacing(8); + container(col) + .width(Length::Fixed(160.0)) + .height(Length::Fill) + .padding([10, 8]) + .style(panel(SIDEBAR_BG)) + .into() +} + +fn status_bar(gui: &Gui) -> Element<'_, Message> { + let unsaved = if gui.dirty { + text("● unsaved").size(11).color(AMBER) + } else { + text("").size(11) + }; + container( + row![ + unsaved, + daemon_toggle(gui), + text("daemon").size(10).color(MUTED), + text(brief_status(gui.daemon_state.text())).size(11), + iced::widget::Space::new().width(Length::Fill), + text("last change").size(10).color(MUTED), + text(gui.last_reply.clone()).size(11).color(REPLY_GREEN), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([6, 12]) + .width(Length::Fill) + .style(panel(HEADER_BG)) + .into() +} + +/// Bottom-left Start/Stop control. Disabled (no press target) while no +/// probe has answered yet, mirroring how "Clear changes" disables itself. +fn daemon_toggle(gui: &Gui) -> Element<'_, Message> { + let mut toggle = button(text(gui.daemon_state.label()).size(11)).style(plain_style()); + if gui.daemon_state.toggle().is_some() { + toggle = toggle.on_press(Message::ToggleDaemon); + } + toggle.into() +} + +/// "status=connected channel=ngobrol 3 participants=2 …" → +/// "connected · ngobrol 3". Parsing goes through the shared +/// [`StatusFields`]; channel names may contain spaces, which its +/// marker-slice handles. +fn brief_status(full: &str) -> String { + match StatusFields::parse_wire(full) { + Some(fields) if !fields.channel.is_empty() => { + format!("{} · {}", fields.status_word, fields.channel) + } + Some(fields) => fields.status_word.to_string(), + None => full.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brief_status_keeps_multiword_channel_names_intact() { + let full = "status=connected channel=ngobrol 3 participants=2 rtl=on monitor=eDP-1"; + assert_eq!(brief_status(full), "connected · ngobrol 3"); + } + + #[test] + fn brief_status_without_channel_falls_back_to_connection_word() { + assert_eq!(brief_status("status=disconnected"), "disconnected"); + assert_eq!(brief_status("connecting…"), "connecting…"); + // Empty channel value (malformed Discord payload only): the old + // code printed "connected · " with a trailing separator; the new + // code drops it. Pinned here so the tightening stays deliberate. + assert_eq!( + brief_status("status=connected channel= participants=0"), + "connected" + ); + } +} diff --git a/src/platform/icon.rs b/src/platform/icon.rs index 5379610..f9c449f 100644 --- a/src/platform/icon.rs +++ b/src/platform/icon.rs @@ -1,4 +1,5 @@ -//! Bundled app icon: the brand mark (black rounded square, bold white "H") +//! Bundled app icon: the brand mark (black disc with a dark-grey ring, +//! bold white "H") //! as a scalable SVG plus the PNG/ICO raster sizes, all committed under //! `assets/hyprlay.{svg,png,ico}`. This module exposes the iced window icon //! built from the embedded 256px PNG (used by the settings GUI and the diff --git a/src/platform/service/fs_util.rs b/src/platform/service/fs_util.rs new file mode 100644 index 0000000..721832a --- /dev/null +++ b/src/platform/service/fs_util.rs @@ -0,0 +1,37 @@ +//! File-system helpers shared by the service backends: the identical +//! create-parent + write and reported-remove sequences the systemd, +//! launchd, and Windows adapters each used to repeat. + +use std::fs; +use std::path::Path; + +use hyprlay_core::daemon_control::ServiceError; + +pub(super) fn write_file(path: &Path, contents: &[u8]) -> Result<(), ServiceError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| ServiceError::CreateDirFailed { + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, contents).map_err(|source| ServiceError::WriteFileFailed { + path: path.to_path_buf(), + source, + }) +} + +pub(super) fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), ServiceError> { + match fs::remove_file(path) { + Ok(()) => report.push(format!("removed {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + report.push(format!("already absent {}", path.display())) + } + Err(source) => { + return Err(ServiceError::RemoveFileFailed { + path: path.to_path_buf(), + source, + }); + } + } + Ok(()) +} diff --git a/src/platform/service/launchd.rs b/src/platform/service/launchd.rs index a2aae67..e24001e 100644 --- a/src/platform/service/launchd.rs +++ b/src/platform/service/launchd.rs @@ -9,7 +9,6 @@ //! keeps it alive (`KeepAlive`) so a crash/laptop wake relaunches it. It is //! registered in the `gui/` domain with `launchctl bootstrap`. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -17,10 +16,12 @@ use std::process::Command; use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The launchd label for the user LaunchAgent (matches the systemd unit name @@ -69,21 +70,30 @@ impl Launchd { ) } - fn run_launchctl(&self, args: &[&str]) -> Result<(), String> { + fn run_launchctl(&self, args: &[&str]) -> Result<(), ServiceError> { let joined = args.join(" "); let output = Command::new("launchctl") .args(args) .output() - .map_err(|e| format!("{joined}: could not run launchctl: {e}"))?; + .map_err(|source| ServiceError::CommandNotRun { + command: joined.clone(), + program: "launchctl", + source, + })?; if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = stderr.trim(); - if detail.is_empty() { - Err(format!("{joined}: exited with {}", output.status)) + Ok(()) } else { - Err(format!("{joined}: {detail}")) + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + let detail = if detail.is_empty() { + format!("exited with {}", output.status) + } else { + detail.to_string() + }; + Err(ServiceError::CommandFailed { + command: joined, + detail, + }) } } } @@ -93,65 +103,67 @@ impl ServiceManager for Launchd { Self::plist_path().is_some_and(|path| path.exists()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { let uid = Self::uid(); match subcommand { "start" => { - let plist = Self::plist_path() - .ok_or_else(|| "error: could not resolve the LaunchAgents dir".to_string())?; - let plist = plist - .to_str() - .ok_or_else(|| "error: plist path is not UTF-8".to_string())?; + let plist = Self::plist_path().ok_or(ServiceError::ResolveDir { + what: "LaunchAgents dir", + })?; + let plist = plist.to_str().ok_or(ServiceError::NonUtf8PlistPath)?; // bootstrap loads + starts the agent; "already bootstrapped" // means it is running, which is the desired outcome. let domain = format!("gui/{uid}"); let args = vec!["bootstrap", domain.as_str(), plist]; match self.run_launchctl(&args) { Ok(()) => {} - Err(e) if e.contains("already bootstrapped") => return Ok(()), - Err(e) => return Err(format!("error: launchctl bootstrap failed: {e}")), + Err(e) if e.to_string().contains("already bootstrapped") => return Ok(()), + Err(source) => { + return Err(ServiceError::LaunchctlStepFailed { + step: "bootstrap", + source: Box::new(source), + }); + } } let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["enable", label.as_str()]) - .map_err(|e| format!("error: launchctl enable failed: {e}")) + .map_err(|source| ServiceError::LaunchctlStepFailed { + step: "enable", + source: Box::new(source), + }) } "stop" => { let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["bootout", label.as_str()]) } - other => Err(format!("error: unsupported launchd subcommand: {other}")), + other => Err(ServiceError::UnsupportedSubcommand { + backend: "launchd", + subcommand: other.to_string(), + }), } } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = dir.join(DAEMON_BIN); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // process_group(0) + null stdio + no wait: the daemon must outlive // this binary and never hold its terminal. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -160,33 +172,42 @@ impl ServiceManager for Launchd { _config_base: &Path, _data_base: &Path, _start: bool, - ) -> Result, String> { - let path = Self::plist_path() - .ok_or_else(|| "error: could not resolve the LaunchAgents dir".to_string())?; - write_plist(&path, &Self::plist(exe_dir))?; + ) -> Result, ServiceError> { + let path = Self::plist_path().ok_or(ServiceError::ResolveDir { + what: "LaunchAgents dir", + })?; + fs_util::write_file(&path, Self::plist(exe_dir).as_bytes())?; let uid = Self::uid(); let mut report = vec![format!("wrote {}", path.display())]; - let plist = path - .to_str() - .ok_or_else(|| "error: plist path is not UTF-8".to_string())?; + let plist = path.to_str().ok_or(ServiceError::NonUtf8PlistPath)?; let domain = format!("gui/{uid}"); let args = vec!["bootstrap", domain.as_str(), plist]; - if let Err(e) = self.run_launchctl(&args) { + if let Err(source) = self.run_launchctl(&args) { // A fresh install is never already bootstrapped, so a bootstrap // failure here is real. - return Err(format!("launchctl bootstrap failed: {e}")); + return Err(ServiceError::LaunchctlInstallStepFailed { + step: "bootstrap", + source: Box::new(source), + }); } report.push(format!("launchctl bootstrap gui/{uid} {plist}: ok")); let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); self.run_launchctl(&["enable", label.as_str()]) - .map_err(|e| format!("launchctl enable failed: {e}"))?; + .map_err(|source| ServiceError::LaunchctlInstallStepFailed { + step: "enable", + source: Box::new(source), + })?; report.push(format!( "launchctl enable gui/{uid}/{LAUNCH_AGENT_LABEL}: ok" )); Ok(report) } - fn uninstall(&self, _config_base: &Path, _data_base: &Path) -> Result, String> { + fn uninstall( + &self, + _config_base: &Path, + _data_base: &Path, + ) -> Result, ServiceError> { let uid = Self::uid(); let mut report = Vec::new(); let label = format!("gui/{uid}/{LAUNCH_AGENT_LABEL}"); @@ -199,7 +220,7 @@ impl ServiceManager for Launchd { )), } if let Some(path) = Self::plist_path() { - remove_reported(&path, &mut report)?; + fs_util::remove_reported(&path, &mut report)?; } Ok(report) } @@ -213,7 +234,7 @@ impl DaemonControl for SystemControl { Launchd.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => Launchd.systemctl("start"), Action::SystemctlStop => Launchd.systemctl("stop"), @@ -222,22 +243,3 @@ impl DaemonControl for SystemControl { } } } - -fn write_plist(path: &Path, contents: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; - } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), - } - Ok(()) -} diff --git a/src/platform/service/mod.rs b/src/platform/service/mod.rs index 741ec9a..63b9155 100644 --- a/src/platform/service/mod.rs +++ b/src/platform/service/mod.rs @@ -12,8 +12,11 @@ pub mod systemd; #[cfg(target_os = "windows")] pub mod windows; +mod fs_util; + use std::path::Path; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; #[cfg(target_os = "macos")] pub use launchd::Launchd; @@ -35,13 +38,16 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { Systemd.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "linux")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { Systemd.uninstall(config_base, data_base) } @@ -52,13 +58,16 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { Launchd.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "macos")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { Launchd.uninstall(config_base, data_base) } @@ -69,12 +78,15 @@ pub fn install_service( config_base: &Path, data_base: &Path, start: bool, -) -> Result, String> { +) -> Result, ServiceError> { WindowsService.install(exe_dir, config_base, data_base, start) } /// Uninstall the autostart service config for the running OS. #[cfg(target_os = "windows")] -pub fn uninstall_service(config_base: &Path, data_base: &Path) -> Result, String> { +pub fn uninstall_service( + config_base: &Path, + data_base: &Path, +) -> Result, ServiceError> { WindowsService.uninstall(config_base, data_base) } diff --git a/src/platform/service/systemd.rs b/src/platform/service/systemd.rs index ecc0a61..4d64158 100644 --- a/src/platform/service/systemd.rs +++ b/src/platform/service/systemd.rs @@ -15,7 +15,6 @@ //! `Icon=hyprlay` and install/uninstall also manage the hicolor theme icon //! files (SVG + PNG raster sizes) so the launcher shows the brand mark. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -26,10 +25,12 @@ use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; use hyprlay_core::daemon_control::SERVICE_UNIT; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The systemd user-startup backend. @@ -46,11 +47,11 @@ impl ServiceManager for Systemd { .is_ok_and(|status| status.success()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { let output = Command::new("systemctl") .args(["--user", subcommand, SERVICE_UNIT]) .output() - .map_err(|e| format!("error: could not run systemctl: {e}"))?; + .map_err(|source| ServiceError::SystemctlNotRun { source })?; if output.status.success() { return Ok(()); } @@ -60,25 +61,20 @@ impl ServiceManager for Systemd { } else { detail }; - Err(format!("error: systemctl {subcommand} failed: {detail}")) + Err(ServiceError::SystemctlFailed { + subcommand: subcommand.to_string(), + detail, + }) } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = dir.join(DAEMON_BIN); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // Own process group + null stdio + no wait: the daemon must outlive @@ -86,13 +82,13 @@ impl ServiceManager for Systemd { // guarded daemon-side by the socket probe. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -101,11 +97,11 @@ impl ServiceManager for Systemd { config_base: &Path, data_base: &Path, start: bool, - ) -> Result, String> { + ) -> Result, ServiceError> { install(exe_dir, config_base, data_base, start, &RealSystemctl) } - fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, String> { + fn uninstall(&self, config_base: &Path, data_base: &Path) -> Result, ServiceError> { uninstall(config_base, data_base, &RealSystemctl) } } @@ -120,7 +116,7 @@ impl DaemonControl for SystemControl { Systemd.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => Systemd.systemctl("start"), Action::SystemctlStop => Systemd.systemctl("stop"), @@ -134,7 +130,7 @@ impl DaemonControl for SystemControl { /// Owned wrapper around an external binary, so tests substitute a recording /// double instead of invoking real units. pub trait Systemctl { - fn run(&self, args: &[&str]) -> Result<(), String>; + fn run(&self, args: &[&str]) -> Result<(), ServiceError>; } /// Production runner: captures exit status and stderr so flow errors carry @@ -142,23 +138,31 @@ pub trait Systemctl { struct RealSystemctl; impl Systemctl for RealSystemctl { - fn run(&self, args: &[&str]) -> Result<(), String> { + fn run(&self, args: &[&str]) -> Result<(), ServiceError> { let joined = args.join(" "); let output = Command::new("systemctl") .arg("--user") .args(args) .output() - .map_err(|e| format!("{joined}: could not run systemctl: {e}"))?; + .map_err(|source| ServiceError::CommandNotRun { + command: joined.clone(), + program: "systemctl", + source, + })?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); let detail = stderr.trim(); - if detail.is_empty() { - Err(format!("{joined}: exited with {}", output.status)) + let detail = if detail.is_empty() { + format!("exited with {}", output.status) } else { - Err(format!("{joined}: {detail}")) - } + detail.to_string() + }; + Err(ServiceError::CommandFailed { + command: joined, + detail, + }) } } } @@ -270,21 +274,20 @@ pub fn install( data_base: &Path, start: bool, systemctl: &dyn Systemctl, -) -> Result, String> { +) -> Result, ServiceError> { let missing = missing_bins(exe_dir); if !missing.is_empty() { - return Err(format!( - "error: missing binaries for install: {}\nthe hyprlay binaries must be installed together", - missing.join(", ") - )); + return Err(ServiceError::MissingInstallBins { + names: missing.join(", "), + }); } let unit = unit_path(config_base); let tray_unit = tray_unit_path(config_base); let desktop = desktop_path(data_base); - write_file(&unit, &unit_text(exe_dir))?; - write_file(&tray_unit, &tray_unit_text(exe_dir))?; - write_file(&desktop, &desktop_text(exe_dir))?; + fs_util::write_file(&unit, unit_text(exe_dir).as_bytes())?; + fs_util::write_file(&tray_unit, tray_unit_text(exe_dir).as_bytes())?; + fs_util::write_file(&desktop, desktop_text(exe_dir).as_bytes())?; let mut report = vec![ format!("wrote {}", unit.display()), @@ -296,17 +299,26 @@ pub fn install( systemctl .run(&["daemon-reload"]) - .map_err(|e| format!("systemctl --user daemon-reload failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "daemon-reload", + source: Box::new(source), + })?; report.push("systemctl --user daemon-reload: ok".to_string()); if start { systemctl .run(&["enable", "--now", "hyprlay"]) - .map_err(|e| format!("systemctl --user enable --now hyprlay failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "enable --now hyprlay", + source: Box::new(source), + })?; report.push("systemctl --user enable --now hyprlay: ok".to_string()); systemctl .run(&["enable", "--now", "hyprlay-tray"]) - .map_err(|e| format!("systemctl --user enable --now hyprlay-tray failed: {e}"))?; + .map_err(|source| ServiceError::SystemctlStepFailed { + step: "enable --now hyprlay-tray", + source: Box::new(source), + })?; report.push("systemctl --user enable --now hyprlay-tray: ok".to_string()); } else { report.push("skipped systemctl --user enable --now (--no-start)".to_string()); @@ -320,7 +332,7 @@ pub fn uninstall( config_base: &Path, data_base: &Path, systemctl: &dyn Systemctl, -) -> Result, String> { +) -> Result, ServiceError> { let mut report = Vec::new(); match systemctl.run(&["disable", "--now", "hyprlay"]) { Ok(()) => report.push("systemctl --user disable --now hyprlay: ok".to_string()), @@ -334,36 +346,20 @@ pub fn uninstall( "systemctl --user disable --now hyprlay-tray: tolerated ({e})" )), } - remove_reported(&unit_path(config_base), &mut report)?; - remove_reported(&tray_unit_path(config_base), &mut report)?; - remove_reported(&desktop_path(data_base), &mut report)?; + fs_util::remove_reported(&unit_path(config_base), &mut report)?; + fs_util::remove_reported(&tray_unit_path(config_base), &mut report)?; + fs_util::remove_reported(&desktop_path(data_base), &mut report)?; uninstall_icon(data_base, &mut report)?; Ok(report) } -fn write_file(path: &Path, contents: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; - } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) -} - -fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; - } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) -} - /// Install the app icon into the user's hicolor theme so the `Icon=hyprlay` /// desktop entry resolves to a real image. The SVG scales to any size; the /// PNG raster sizes cover launchers that do not read SVG. Bytes are bundled /// into the binary (same pattern as the tray icons). Returns the paths written. -fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), String> { +fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { let svg = icon_svg_path(data_base); - write_bytes(&svg, include_bytes!("../../../assets/hyprlay.svg"))?; + fs_util::write_file(&svg, include_bytes!("../../../assets/hyprlay.svg"))?; report.push(format!("wrote {}", svg.display())); for size in ICON_SIZES { @@ -375,28 +371,17 @@ fn install_icon(data_base: &Path, report: &mut Vec) -> Result<(), String 256 => include_bytes!("../../../assets/hyprlay-256.png"), _ => unreachable!(), }; - write_bytes(&png, bytes)?; + fs_util::write_file(&png, bytes)?; report.push(format!("wrote {}", png.display())); } Ok(()) } /// Remove the installed app icon files. `scalable` SVG plus each raster size. -fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), String> { - remove_reported(&icon_svg_path(data_base), report)?; +fn uninstall_icon(data_base: &Path, report: &mut Vec) -> Result<(), ServiceError> { + fs_util::remove_reported(&icon_svg_path(data_base), report)?; for size in ICON_SIZES { - remove_reported(&icon_png_path(data_base, *size), report)?; - } - Ok(()) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), + fs_util::remove_reported(&icon_png_path(data_base, *size), report)?; } Ok(()) } diff --git a/src/platform/service/windows.rs b/src/platform/service/windows.rs index bd43f2c..6f959bb 100644 --- a/src/platform/service/windows.rs +++ b/src/platform/service/windows.rs @@ -14,7 +14,6 @@ //! quit — the same two primitives the tray uses. Only the autostart install //! is Windows-specific. -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,10 +21,12 @@ use std::process::Command; use hyprlay_core::bins::DAEMON_BIN; use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; +use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::ServiceManager; use hyprlay_core::domain::Command as DaemonCommand; use hyprlay_core::platform::Platform; +use super::fs_util; use crate::platform::ipc::control::Control; /// The Startup-folder launcher file name. @@ -63,47 +64,40 @@ impl ServiceManager for WindowsService { Self::script_path().is_some_and(|path| path.exists()) } - fn systemctl(&self, subcommand: &str) -> Result<(), String> { + fn systemctl(&self, subcommand: &str) -> Result<(), ServiceError> { match subcommand { // No service manager: starting "via the service" is a spawn, and // stopping it is a socket quit (the only reliable teardown). "start" => self.spawn_daemon(), "stop" => self.quit_via_socket(), - other => Err(format!( - "error: unsupported Windows startup subcommand: {other}" - )), + other => Err(ServiceError::UnsupportedSubcommand { + backend: "Windows startup", + subcommand: other.to_string(), + }), } } - fn spawn_daemon(&self) -> Result<(), String> { - let exe = std::env::current_exe() - .map_err(|e| format!("error: could not locate the running hyprlay binary: {e}"))?; + fn spawn_daemon(&self) -> Result<(), ServiceError> { + let exe = std::env::current_exe().map_err(|source| ServiceError::LocateExe { source })?; let Some(dir) = exe.parent() else { - return Err(format!( - "error: could not find the directory of {}", - exe.display() - )); + return Err(ServiceError::NoExeParent { exe: exe.clone() }); }; let path = Self::daemon_exe(dir); if !path.exists() { - return Err(format!( - "error: {DAEMON_BIN} not found next to the running hyprlay binary (expected {})\n\ - the hyprlay binaries must be installed together", - path.display() - )); + return Err(ServiceError::DaemonMissing { path }); } let mut cmd = Command::new(&path); // CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW + null stdio + no wait: // the daemon must outlive this binary and never hold a console. crate::platform::host::host() .spawn(&mut cmd) - .map_err(|e| format!("error: could not start {DAEMON_BIN}: {e}")) + .map_err(|source| ServiceError::SpawnDaemon { source }) } - fn quit_via_socket(&self) -> Result<(), String> { + fn quit_via_socket(&self) -> Result<(), ServiceError> { hyprlay_core::ctl::send_command_line(&Control, &DaemonCommand::Quit.to_string()) .map(|_| ()) - .ok_or_else(|| "error: daemon unreachable".to_string()) + .ok_or(ServiceError::DaemonUnreachable) } fn install( @@ -112,17 +106,22 @@ impl ServiceManager for WindowsService { _config_base: &Path, _data_base: &Path, _start: bool, - ) -> Result, String> { - let path = Self::script_path() - .ok_or_else(|| "error: could not resolve the Startup folder".to_string())?; - write_script(&path, &Self::launcher(exe_dir))?; + ) -> Result, ServiceError> { + let path = Self::script_path().ok_or(ServiceError::ResolveDir { + what: "Startup folder", + })?; + fs_util::write_file(&path, Self::launcher(exe_dir).as_bytes())?; Ok(vec![format!("wrote {}", path.display())]) } - fn uninstall(&self, _config_base: &Path, _data_base: &Path) -> Result, String> { + fn uninstall( + &self, + _config_base: &Path, + _data_base: &Path, + ) -> Result, ServiceError> { let mut report = Vec::new(); if let Some(path) = Self::script_path() { - remove_reported(&path, &mut report)?; + fs_util::remove_reported(&path, &mut report)?; } Ok(report) } @@ -136,7 +135,7 @@ impl DaemonControl for SystemControl { WindowsService.unit_installed() } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { match action { Action::SystemctlStart => WindowsService.systemctl("start"), Action::SystemctlStop => WindowsService.systemctl("stop"), @@ -145,22 +144,3 @@ impl DaemonControl for SystemControl { } } } - -fn write_script(path: &Path, contents: &str) -> Result<(), String> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("could not create {}: {e}", parent.display()))?; - } - fs::write(path, contents).map_err(|e| format!("could not write {}: {e}", path.display())) -} - -fn remove_reported(path: &Path, report: &mut Vec) -> Result<(), String> { - match fs::remove_file(path) { - Ok(()) => report.push(format!("removed {}", path.display())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - report.push(format!("already absent {}", path.display())) - } - Err(e) => return Err(format!("could not remove {}: {e}", path.display())), - } - Ok(()) -} diff --git a/src/tray/daemon.rs b/src/tray/daemon.rs index f0f0502..e39b274 100644 --- a/src/tray/daemon.rs +++ b/src/tray/daemon.rs @@ -172,6 +172,7 @@ fn spawn_gui(exe: &std::path::Path) -> Result<(), String> { mod tests { use hyprlay_core::daemon_control::Action; use hyprlay_core::daemon_control::DaemonControl; + use hyprlay_core::daemon_control::ServiceError; use hyprlay_core::daemon_control::StopPolicy; use hyprlay_core::daemon_control::Toggle; use hyprlay_core::daemon_control::execute_toggle; @@ -209,11 +210,13 @@ mod tests { } /// Spy at the process/socket boundary: records what ran so tests verify - /// state, not call mechanics. + /// state, not call mechanics. `fail_with` is a factory because + /// `ServiceError` is not `Clone`: every performed action fails the same + /// way, exactly as the old `String` double did. #[derive(Default)] struct FakeControl { installed: bool, - fail_with: Option, + fail_with: Option ServiceError + Send + Sync>>, performed: std::sync::Mutex>, } @@ -228,10 +231,10 @@ mod tests { self.installed } - fn perform(&self, action: Action) -> Result<(), String> { + fn perform(&self, action: Action) -> Result<(), ServiceError> { self.performed.lock().unwrap().push(action); match &self.fail_with { - Some(text) => Err(text.clone()), + Some(failure) => Err(failure()), None => Ok(()), } } @@ -252,7 +255,9 @@ mod tests { fn a_failed_action_surfaces_its_error_text() { let control = FakeControl { installed: false, - fail_with: Some("error: could not start hyprlayd: ENOENT".into()), + fail_with: Some(Box::new(|| ServiceError::SpawnDaemon { + source: std::io::Error::other("ENOENT"), + })), ..FakeControl::default() }; let outcome = execute_toggle(&control, Toggle::Stop, StopPolicy::ViaSocket); diff --git a/src/tray/menu.rs b/src/tray/menu.rs index dd28e6f..403ae80 100644 --- a/src/tray/menu.rs +++ b/src/tray/menu.rs @@ -5,6 +5,8 @@ //! [`crate::platform::tray`] map [`build_menu`] onto real menu items, all //! driven through the shared [`Tray`](crate::tray::Tray) port. +use hyprlay_core::status::StatusFields; + /// A snapshot of daemon state the tray renders. Doubles as the diff-gate /// key: two identical snapshots must not trigger a `handle.update`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,6 +29,43 @@ impl TrayState { summary: "daemon: down".to_string(), } } + + /// Snapshot from a parsed status reply. Field semantics used: + /// - `status` word — `connected` (and anything non-empty besides `off` / + /// `disconnected`) means up; `off` / `disconnected` means down. + /// - `visible` — `on` / `off`. + /// - `channel` / `participants` — feed the compact summary. + fn from_fields(fields: &StatusFields) -> Self { + let status = fields.status_word.as_str(); + let up = !status.is_empty() && status != "off" && status != "disconnected"; + let summary = if up { + // Connected: mirror the GUI's compact summary, adding the + // participant count. Exact copy decided here (spec: "exact copy + // finalised in code"); follows the Decisions worked example + // `connected · #general · 3`. + if fields.channel.is_empty() { + fields.status_word.to_string() + } else { + format!( + "{} · {} · {}", + fields.status_word, fields.channel, fields.participants + ) + } + } else { + // Down-but-replied (e.g. `status=disconnected`): the word is the + // whole story; a missing word falls back to the down default. + if status.is_empty() { + "daemon: down".to_string() + } else { + fields.status_word.to_string() + } + }; + TrayState { + up, + visible: fields.visible, + summary, + } + } } /// Which user-facing action a menu item triggers. Sent over the action @@ -92,88 +131,15 @@ pub fn build_menu(state: &TrayState) -> Vec { ] } -/// Parse a `status=` reply (format pinned at `daemon/mod.rs:273-279`) into a -/// [`TrayState`]. -/// -/// Simple single-token fields (`status`, `participants`, `visible`) are read -/// by whitespace-delimited `key=value` tokens. The `channel` value is sliced -/// between its markers (`channel=` … ` participants=`), exactly like the -/// GUI's [`brief_status`](crate::gui): channel names may contain spaces, so a -/// naive whitespace split would corrupt them. -/// -/// Field semantics used: -/// - `status` word — `connected` (and anything non-empty besides `off` / -/// `disconnected`) means up; `off` / `disconnected` means down. -/// - `visible` — `on` / `off`. -/// - `channel` / `participants` — feed the compact summary. +/// Parse a `status=` reply into a [`TrayState`]. The wire format itself is +/// owned by [`StatusFields`] — the single source of truth shared with the +/// daemon writer and the GUI reader — so channel names with spaces and +/// lenient numbers are handled there; this only maps the parsed fields onto +/// what the tray renders. pub fn parse_status(reply: &str) -> TrayState { - if !reply.starts_with("status=") { - // Not a status reply (e.g. a connect error, or an ordinary reply): - // treat as "no live daemon". - return TrayState::down(); - } - - // Connection-status word: `status=` up to the next space. - let status_word = reply[std::cmp::min(reply.len(), "status=".len())..] - .split(' ') - .next() - .unwrap_or(""); - let up = !status_word.is_empty() && status_word != "off" && status_word != "disconnected"; - - // Channel: between `channel=` and the next ` participants=` marker, so - // spaces inside the name survive. - let channel = reply - .find("channel=") - .and_then(|start| { - reply[start..] - .find(" participants=") - .map(|end| reply[start + "channel=".len()..start + end].to_string()) - }) - .unwrap_or_default(); - - // Participant count: `participants=` up to the next space. - let participants = reply - .find("participants=") - .map(|start| { - reply[start + "participants=".len()..] - .split(' ') - .next() - .unwrap_or("") - .to_string() - }) - .unwrap_or_default(); - - // Visibility flag. - let visible = reply - .find("visible=") - .map(|start| reply[start..].split(' ').next() == Some("visible=on")) - .unwrap_or(false); - - let summary = if up { - // Connected: mirror the GUI's compact summary, adding the participant - // count. Exact copy decided here (spec: "exact copy finalised in - // code"); follows the Decisions worked example `connected · #general · 3`. - if channel.is_empty() { - status_word.to_string() - } else if participants.is_empty() { - format!("{status_word} · {channel}") - } else { - format!("{status_word} · {channel} · {participants}") - } - } else { - // Down-but-replied (e.g. `status=disconnected`): the word is the - // whole story; a missing word falls back to the down default. - if status_word.is_empty() { - "daemon: down".to_string() - } else { - status_word.to_string() - } - }; - - TrayState { - up, - visible, - summary, + match StatusFields::parse_wire(reply) { + Some(fields) => TrayState::from_fields(&fields), + None => TrayState::down(), } } diff --git a/tests/install.rs b/tests/install.rs index 4d9d822..d527e1e 100644 --- a/tests/install.rs +++ b/tests/install.rs @@ -15,6 +15,7 @@ use common::unique_temp_dir; use hyprlay::platform::service::systemd::Systemctl; use hyprlay::platform::service::systemd::install; use hyprlay::platform::service::systemd::uninstall; +use hyprlay_core::daemon_control::ServiceError; const DAEMON_RELOAD: &str = "systemctl --user daemon-reload"; const ENABLE_NOW: &str = "systemctl --user enable --now hyprlay"; @@ -52,13 +53,14 @@ impl Spy { } impl Systemctl for Spy { - fn run(&self, args: &[&str]) -> Result<(), String> { + fn run(&self, args: &[&str]) -> Result<(), ServiceError> { let line = format!("systemctl --user {}", args.join(" ")); self.calls.borrow_mut().push(line.clone()); match &self.fail_when { - Some(failing) if *failing == line => { - Err("Failed to connect: unit not found".to_string()) - } + Some(failing) if *failing == line => Err(ServiceError::CommandFailed { + command: args.join(" "), + detail: "Failed to connect: unit not found".to_string(), + }), _ => Ok(()), } } @@ -393,7 +395,10 @@ fn failed_daemon_reload_reports_the_step_and_never_enables() { ) .unwrap_err(); - assert!(err.contains("daemon-reload"), "error names the step: {err}"); + assert!( + err.to_string().contains("daemon-reload"), + "error names the step: {err}" + ); assert_eq!(spy.lines(), vec![DAEMON_RELOAD.to_string()]); } @@ -412,7 +417,10 @@ fn failed_enable_now_reports_the_step_after_writing_both_files() { ) .unwrap_err(); - assert!(err.contains("enable"), "error names the step: {err}"); + assert!( + err.to_string().contains("enable"), + "error names the step: {err}" + ); assert_eq!( spy.lines(), vec![DAEMON_RELOAD.to_string(), ENABLE_NOW.to_string()] @@ -505,7 +513,10 @@ fn install_aborts_before_any_write_when_bins_are_missing() { .unwrap_err(); // The error names the single required bin. - assert!(err.contains(DAEMON_BIN), "error names hyprlayd: {err}"); + assert!( + err.to_string().contains(DAEMON_BIN), + "error names hyprlayd: {err}" + ); // Nothing was written before the check failed. assert!(!world.unit().exists(), "daemon unit must not be written"); assert!(!world.tray_unit().exists(), "tray unit must not be written");