diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e9e34..6ac29ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Overlay + +- Replaced the M and D text badges with crossed-mic and crossed-headphones glyphs after each name, red for server-set and grey for self-set mute or deafen +- Added roster sorting with join order, name, and recent speakers +- Added max rows setting that hides overflow participants behind a +N pill + +## 0.3.1 (2026-09-11) + ### Platforms - Changed the app and tray icon to a circular design diff --git a/CONTEXT.md b/CONTEXT.md index 48674a9..ccfcf1f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,9 +52,24 @@ Platform mechanics live behind ports in `src/platform/`; see name and own user id. Persisted to the roster cache only while connected; speaking state is never persisted (it would be stale on load). - **Roster row** — one participant entry rendered on the overlay surface: - avatar plus username, decorated by speaking ring and mute badges. The - overlay shows only roster rows — never connection or status text. An - empty roster renders an empty transparent surface. + avatar plus username, decorated by the speaking ring and, when the + participant is silenced, by one mute glyph shown inline after the username + (crossed microphone, or crossed headphones when deafened). Red marks a + server-set state, grey a self-set one. The overlay shows only roster rows + plus the +N pill when the row cap truncates them — never connection or + status text. An empty roster renders an empty transparent surface. +- **Roster order** — the strategy that orders roster rows before rendering, + set by `roster-order` (default `join-order`): `join-order` keeps the + order Discord reports, `name` sorts case-insensitive A→Z, + `recent-speakers` puts the most recent speaker on top. Every strategy is + a stable sort, so ties keep join order. +- **Max rows** — roster height cap in rows (`max-rows`, clamped 0..=200; + 0 = unlimited). Applied after sorting and filtering: rows past the cap + are not rendered and are counted by the +N pill. +- **+N pill** — the roster overflow indicator: one extra row after a + capped roster reading `+N`, where N counts exactly the rows the cap + hides (participants removed by filters do not count). Dressed like a + name chip in the glyphs' muted grey. - **RosterChange** — `Changed`/`Unchanged` result of applying a Discord event to the `Overlay`; drives cache writes and view refreshes. - **Overlay layer** — the Wayland layer-shell layer the overlay binds to. diff --git a/Cargo.lock b/Cargo.lock index 24232a7..6dcda8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,6 +595,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -792,6 +798,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deranged" version = "0.5.8" @@ -1023,6 +1035,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1321,6 +1339,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1881,8 +1909,9 @@ dependencies = [ "cosmic-text", "iced_debug", "iced_graphics", - "kurbo", + "kurbo 0.10.4", "log", + "resvg", "rustc-hash 2.1.3", "softbuffer", "tiny-skia", @@ -1903,6 +1932,7 @@ dependencies = [ "iced_debug", "iced_graphics", "log", + "resvg", "rustc-hash 2.1.3", "thiserror 2.0.20", "wgpu", @@ -2060,9 +2090,25 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.1", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.14.1" @@ -2243,6 +2289,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "layershellev" version = "0.19.1" @@ -2556,7 +2613,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.18.1", "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -3177,6 +3234,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3232,6 +3295,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "png" version = "0.18.1" @@ -3380,6 +3456,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -3517,6 +3599,32 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -3625,6 +3733,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "same-file" version = "1.0.6" @@ -3790,6 +3916,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "skrifa" version = "0.37.0" @@ -3980,6 +4121,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "strsim" @@ -3999,6 +4143,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "swash" version = "0.2.10" @@ -4199,6 +4353,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -4466,7 +4621,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.18.1", "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -4497,6 +4652,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4509,6 +4676,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-script" version = "0.5.8" @@ -4521,6 +4694,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4563,6 +4742,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4881,6 +5087,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "27.0.1" @@ -5536,6 +5748,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yazi" version = "0.2.1" @@ -5734,6 +5952,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 18dd3ac..156503b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ iced = { version = "0.14", default-features = false, features = [ "tiny-skia", "crisp", "image-without-codecs", + "svg", "tokio", ] } iced_layershell = "0.19.1" diff --git a/crates/hyprlay-core/src/config.rs b/crates/hyprlay-core/src/config.rs index d430dfd..c889c9d 100644 --- a/crates/hyprlay-core/src/config.rs +++ b/crates/hyprlay-core/src/config.rs @@ -42,6 +42,45 @@ pub enum AnchorMode { Bottom, } +/// How roster rows are ordered on the overlay. `JoinOrder` keeps the wire +/// order Discord reports (today's behavior); `Name` sorts case-insensitive +/// A→Z; `RecentSpeakers` bubbles the most recent speaker to the top. All +/// ties fall back to join order. +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RosterOrder { + #[default] + JoinOrder, + Name, + RecentSpeakers, +} + +impl RosterOrder { + /// join-order -> name -> recent-speakers -> join-order, for bare + /// `set roster-order`. + pub fn next(self) -> Self { + match self { + Self::JoinOrder => Self::Name, + Self::Name => Self::RecentSpeakers, + Self::RecentSpeakers => Self::JoinOrder, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::JoinOrder => "join-order", + Self::Name => "name", + Self::RecentSpeakers => "recent-speakers", + } + } +} + +impl fmt::Display for RosterOrder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + /// Hard safety bound for offsets and the offset slider range. pub const OFFSET_LIMIT: i32 = 4000; @@ -76,6 +115,8 @@ pub const AVATAR_SIZE: Bounds = Bounds { min: 16, max: 64 }; pub const TEXT_SIZE: Bounds = Bounds { min: 8, max: 32 }; pub const SPACING: Bounds = Bounds { min: 0, max: 24 }; pub const MAX_NAME: Bounds = Bounds { min: 4, max: 64 }; +/// Roster height cap in rows; 0 means unlimited. +pub const MAX_ROWS: Bounds = Bounds { min: 0, max: 200 }; pub const OFFSETS: Bounds = Bounds { min: -OFFSET_LIMIT, max: OFFSET_LIMIT, @@ -113,6 +154,11 @@ pub struct Config { pub max_username_length: usize, pub show_own_user: bool, pub show_only_talking_users: bool, + /// Row ordering strategy on the overlay. + pub roster_order: RosterOrder, + /// Maximum roster rows rendered (0..=200); 0 = unlimited. Overflow + /// rows are hidden behind a "+N" pill row. + pub max_rows: u32, /// Master visibility switch: false collapses the overlay to an empty /// surface while the daemon keeps running and tracking state. pub visible: bool, @@ -158,6 +204,8 @@ impl Default for Config { max_username_length: 16, show_own_user: true, show_only_talking_users: false, + roster_order: RosterOrder::JoinOrder, + max_rows: 0, visible: true, auto_save: true, show_on_fullscreen: true, @@ -274,6 +322,8 @@ impl Config { max_name: Some(self.max_username_length), talking_only: Some(self.show_only_talking_users), own_user: Some(self.show_own_user), + roster_order: Some(self.roster_order), + max_rows: Some(self.max_rows), visible: Some(self.visible), auto_save: Some(self.auto_save), show_on_fullscreen: Some(self.show_on_fullscreen), @@ -318,6 +368,8 @@ impl Config { max_username_length: l.max_name.unwrap_or(d.max_username_length), show_only_talking_users: l.talking_only.unwrap_or(d.show_only_talking_users), show_own_user: l.own_user.unwrap_or(d.show_own_user), + roster_order: l.roster_order.unwrap_or(d.roster_order), + max_rows: l.max_rows.unwrap_or(d.max_rows), visible: l.visible.unwrap_or(d.visible), auto_save: l.auto_save.unwrap_or(d.auto_save), show_on_fullscreen: l.show_on_fullscreen.unwrap_or(d.show_on_fullscreen), @@ -361,6 +413,8 @@ struct LayoutTable { max_name: Option, talking_only: Option, own_user: Option, + roster_order: Option, + max_rows: Option, visible: Option, auto_save: Option, show_on_fullscreen: Option, @@ -434,6 +488,7 @@ impl Config { self.avatar_size = AVATAR_SIZE.clamp_value(self.avatar_size); self.text_size = TEXT_SIZE.clamp_value(self.text_size); self.spacing = SPACING.clamp_value(self.spacing); + self.max_rows = MAX_ROWS.clamp_value(self.max_rows); } pub fn save(&self) { @@ -708,6 +763,73 @@ speaking = \"#00ff00\" assert_eq!(re, back); } + #[test] + fn roster_order_lives_in_layout_section_and_roundtrips() { + let cfg = Config { + roster_order: crate::config::RosterOrder::RecentSpeakers, + ..Config::default() + }; + let toml_str = toml::to_string(&cfg).unwrap(); + assert!( + toml_str.contains("roster-order = \"recent-speakers\""), + "roster-order missing from [layout] in:\n{toml_str}" + ); + let back: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(back.roster_order, cfg.roster_order); + // An old file without the key keeps today's behavior. + let back: Config = toml::from_str("[layout]\nwidth = 400").unwrap(); + assert_eq!(back.roster_order, RosterOrder::JoinOrder); + } + + #[test] + fn roster_order_wire_words_are_kebab_case() { + for (order, word) in [ + (RosterOrder::JoinOrder, "join-order"), + (RosterOrder::Name, "name"), + (RosterOrder::RecentSpeakers, "recent-speakers"), + ] { + assert_eq!(order.as_str(), word); + let parsed: RosterOrder = toml::from_str(&format!("v = \"{word}\"")) + .map(|f: RosterFile| f.v) + .unwrap(); + assert_eq!(parsed, order); + } + } + + #[derive(Deserialize)] + struct RosterFile { + v: RosterOrder, + } + + #[test] + fn roster_order_cycles_through_all_three() { + assert_eq!(RosterOrder::JoinOrder.next(), RosterOrder::Name); + assert_eq!(RosterOrder::Name.next(), RosterOrder::RecentSpeakers); + assert_eq!(RosterOrder::RecentSpeakers.next(), RosterOrder::JoinOrder); + } + + #[test] + fn max_rows_lives_in_layout_section_and_roundtrips() { + let cfg = Config { + max_rows: 12, + ..Config::default() + }; + let toml_str = toml::to_string(&cfg).unwrap(); + assert!( + toml_str.contains("max-rows = 12"), + "max-rows missing from [layout] in:\n{toml_str}" + ); + let back: Config = toml::from_str(&toml_str).unwrap(); + assert_eq!(back.max_rows, 12); + // An old file without the key stays unlimited... + let back: Config = toml::from_str("[layout]\nwidth = 400").unwrap(); + assert_eq!(back.max_rows, 0); + // ...and a hand-edited out-of-range file clamps on load. + let mut back: Config = toml::from_str("[layout]\nmax-rows = 99999").unwrap(); + back.clamp(); + assert_eq!(back.max_rows, MAX_ROWS.max); + } + #[test] fn missing_fields_fall_back_to_defaults() { let back: Config = toml::from_str("[layout]\nwidth = 400").unwrap(); diff --git a/crates/hyprlay-core/src/domain.rs b/crates/hyprlay-core/src/domain.rs index 1beafbf..5fedd32 100644 --- a/crates/hyprlay-core/src/domain.rs +++ b/crates/hyprlay-core/src/domain.rs @@ -18,8 +18,10 @@ use crate::config::Bounds; use crate::config::Config; use crate::config::HorizontalAnchor; use crate::config::MAX_NAME; +use crate::config::MAX_ROWS; use crate::config::OFFSETS; use crate::config::OPACITY; +use crate::config::RosterOrder; use crate::config::SCALE; use crate::config::SPACING; use crate::config::TEXT_SIZE; @@ -279,6 +281,8 @@ pub enum Key { ShowOnFullscreen, DimOnHover, HoverOpacity, + RosterOrder, + MaxRows, } /// Config sections, shared by `reset ` and the TOML layout. @@ -325,6 +329,7 @@ pub enum Value { Color(HexColor), Corner(Corner), Anchor(crate::config::AnchorMode), + RosterOrder(RosterOrder), Target(MonitorTarget), Cycle, } @@ -338,6 +343,7 @@ impl fmt::Display for Value { Self::Corner(c) => f.write_str(corner_word(*c)), Self::Target(MonitorTarget::Active) => f.write_str("active"), Self::Anchor(m) => f.write_str(m.as_str()), + Self::RosterOrder(o) => f.write_str(o.as_str()), Self::Target(MonitorTarget::Named(name)) => write!(f, "{name}"), // Never sent over the wire; only ever constructed internally. Self::Cycle => f.write_str(""), @@ -347,7 +353,7 @@ impl fmt::Display for Value { impl Key { /// Every key in display order (grouped, wire order inside a group). - pub const ALL: [Key; 28] = [ + pub const ALL: [Key; 30] = [ Key::Position, Key::Anchor, Key::Monitor, @@ -376,6 +382,8 @@ impl Key { Key::ShowOnFullscreen, Key::DimOnHover, Key::HoverOpacity, + Key::RosterOrder, + Key::MaxRows, ]; pub fn name(self) -> &'static str { @@ -408,6 +416,8 @@ impl Key { Self::ShowOnFullscreen => "show-on-fullscreen", Self::DimOnHover => "dim-on-hover", Self::HoverOpacity => "hover-opacity", + Self::RosterOrder => "roster-order", + Self::MaxRows => "max-rows", } } @@ -436,7 +446,9 @@ impl Key { | Self::Visible | Self::AutoSave | Self::ShowOnFullscreen - | Self::DimOnHover => Group::Layout, + | Self::DimOnHover + | Self::RosterOrder + | Self::MaxRows => Group::Layout, Self::Opacity | Self::AvatarOpacity | Self::TextOpacity @@ -484,6 +496,8 @@ impl Key { Self::TextOpacity => Value::Num(cfg.text_opacity as i64), Self::BoxOpacity => Value::Num(cfg.box_opacity as i64), Self::HoverOpacity => Value::Num(cfg.hover_opacity as i64), + Self::RosterOrder => Value::RosterOrder(cfg.roster_order), + Self::MaxRows => Value::Num(cfg.max_rows as i64), Self::SpeakingColor => Value::Color(cfg.speaking_color), Self::TextColor => Value::Color(cfg.text_color), Self::BoxColor => Value::Color(cfg.box_color), @@ -508,6 +522,7 @@ impl Key { Self::TextSize => Some((TEXT_SIZE.min as i64, TEXT_SIZE.max as i64)), Self::Spacing => Some((SPACING.min as i64, SPACING.max as i64)), Self::MaxName => Some((MAX_NAME.min as i64, MAX_NAME.max as i64)), + Self::MaxRows => Some((MAX_ROWS.min as i64, MAX_ROWS.max as i64)), _ => None, } } @@ -566,6 +581,7 @@ fn cycle_able(key: Key) -> bool { | Key::AutoSave | Key::ShowOnFullscreen | Key::DimOnHover + | Key::RosterOrder ) } @@ -622,6 +638,14 @@ impl Key { Some("bottom") => Ok(Value::Anchor(crate::config::AnchorMode::Bottom)), _ => Err("error: anchor ".to_string()), }, + Self::RosterOrder => match arg { + Some("join-order") => Ok(Value::RosterOrder(crate::config::RosterOrder::JoinOrder)), + Some("name") => Ok(Value::RosterOrder(crate::config::RosterOrder::Name)), + Some("recent-speakers") => Ok(Value::RosterOrder( + crate::config::RosterOrder::RecentSpeakers, + )), + _ => Err("error: roster-order ".to_string()), + }, Self::Monitor => Ok(match arg { Some("active") => Value::Target(MonitorTarget::Active), Some(name) => Value::Target(MonitorTarget::Named(name.to_string())), @@ -644,6 +668,7 @@ impl Key { | Self::TextSize | Self::Spacing | Self::MaxName + | Self::MaxRows | Self::Opacity | Self::AvatarOpacity | Self::TextOpacity @@ -663,6 +688,7 @@ impl Key { Value::Cycle => match self { Self::Position => Value::Corner(corner_of(cfg.horizontal, cfg.vertical).next()), Self::Anchor => Value::Anchor(cfg.anchor.next()), + Self::RosterOrder => Value::RosterOrder(cfg.roster_order.next()), Self::Monitor => { return CommandResult::err("error: monitor cycling needs the running daemon"); } @@ -700,6 +726,10 @@ impl Key { cfg.anchor = mode; CommandResult::ok(format!("anchor={mode}"), vec![Effect::Reanchor]) } + (Self::RosterOrder, Value::RosterOrder(order)) => { + cfg.roster_order = order; + CommandResult::ok(format!("roster-order={order}"), vec![Effect::Resize]) + } // Routed by the daemon shell before config application: a change // re-creates the layer surface on another output. (Self::Monitor, _) => CommandResult::err("error: not a config command"), @@ -794,6 +824,13 @@ impl Key { "max-name", Effect::Resize, ), + (Self::MaxRows, Value::Num(v)) => set_num( + &mut cfg.max_rows, + v as u32, + MAX_ROWS, + "max-rows", + Effect::Resize, + ), (Self::Opacity, Value::Num(v)) => set_pct(&mut cfg.opacity, v as u8, "opacity"), (Self::AvatarOpacity, Value::Num(v)) => { set_pct(&mut cfg.avatar_opacity, v as u8, "avatar-opacity") @@ -1657,6 +1694,91 @@ mod tests { assert_eq!(cfg2.opacity, Config::default().opacity); } + #[test] + fn roster_order_key_roundtrips_through_the_wire_grammar() { + let mut cfg = Config::default(); + assert_eq!(Key::RosterOrder.name(), "roster-order"); + assert_eq!(Key::RosterOrder.group(), Group::Layout); + assert_eq!(Key::RosterOrder.num_bounds(), None); + assert_eq!( + apply("get roster-order", &mut cfg).reply, + "roster-order=join-order" + ); + assert_eq!( + apply("set roster-order name", &mut cfg).reply, + "roster-order=name" + ); + assert_eq!(cfg.roster_order, RosterOrder::Name); + assert_eq!( + apply("set roster-order recent-speakers", &mut cfg).reply, + "roster-order=recent-speakers" + ); + assert_eq!(cfg.roster_order, RosterOrder::RecentSpeakers); + // Bare form cycles through all three options, like `set anchor`. + assert_eq!( + apply("set roster-order", &mut cfg).reply, + "roster-order=join-order" + ); + assert_eq!(cfg.roster_order, RosterOrder::JoinOrder); + // Garbage words are refused with the full token list. + assert_eq!( + parse_err("set roster-order sideways"), + "error: roster-order " + ); + // Canonical text re-parses to the same command. + assert_eq!( + "set roster-order recent-speakers" + .parse::() + .unwrap(), + Command::Set( + Key::RosterOrder, + Value::RosterOrder(RosterOrder::RecentSpeakers) + ) + ); + assert_eq!( + Command::Set(Key::RosterOrder, Value::RosterOrder(RosterOrder::Name)).to_string(), + "set roster-order name" + ); + // reset layout restores the default order. + apply("set roster-order name", &mut cfg); + apply("reset layout", &mut cfg); + assert_eq!(cfg.roster_order, RosterOrder::JoinOrder); + } + + #[test] + fn max_rows_key_roundtrips_through_the_wire_grammar() { + let mut cfg = Config::default(); + assert_eq!(Key::MaxRows.name(), "max-rows"); + assert_eq!(Key::MaxRows.group(), Group::Layout); + assert_eq!( + Key::MaxRows.num_bounds(), + Some((MAX_ROWS.min as i64, MAX_ROWS.max as i64)) + ); + assert_eq!(apply("get max-rows", &mut cfg).reply, "max-rows=0"); + assert_eq!(apply("set max-rows 6", &mut cfg).reply, "max-rows=6"); + assert_eq!(cfg.max_rows, 6); + // 0 is the unlimited value and is settable again. + apply("set max-rows 0", &mut cfg); + assert_eq!(cfg.max_rows, 0); + // Out-of-range values are refused with the bounds hint. + assert_eq!(parse_err("set max-rows -1"), "error: max-rows <0-200>"); + assert_eq!(parse_err("set max-rows 201"), "error: max-rows <0-200>"); + assert_eq!(parse_err("set max-rows"), "error: max-rows <0-200>"); + // Canonical text re-parses to the same command. + assert_eq!( + "set max-rows 6".parse::().unwrap(), + Command::Set(Key::MaxRows, Value::Num(6)) + ); + assert_eq!( + Command::Set(Key::MaxRows, Value::Num(6)).to_string(), + "set max-rows 6" + ); + // reset layout restores unlimited. + apply("set max-rows 6", &mut cfg); + apply("reset layout", &mut cfg); + assert_eq!(cfg.max_rows, 0); + } + #[test] fn new_keys_display_canonical_and_get_roundtrip() { assert_eq!( diff --git a/docs/agents/release-checklist.md b/docs/agents/release-checklist.md new file mode 100644 index 0000000..a2769dc --- /dev/null +++ b/docs/agents/release-checklist.md @@ -0,0 +1,63 @@ +# Release Checklist + +A release ships from a version branch. One branch holds one version, for +example `v0.3.2` (tip `b501dd0`). The `version = "0.3.0"` field in +`Cargo.toml` stays old on purpose; release CI rewrites it +(`.github/workflows/patch-version.yml:60-64`). + +## Procedure + +1. Owner: human — Merge feature work into the version branch with a PR. The + `v0.3.2` branch took PR #18 and PR #19 this way. +2. Owner: human — Write every user-visible change into the top + `## [Unreleased]` section of `CHANGELOG.md`. This repo is manual mode + (`.github/changelog-mode`), so CI renames that section instead of writing + one (`docs/dev/changelog.md:7-13`, `_release.yml:215-225`). CI does not add + a new `[Unreleased]` section after a release; you write it for the next one. +3. Owner: human — Pick the bump. A patch release needs no special commit. For + a minor or major release, add one commit titled `chore!(minor): ...` or + `chore!(major): ...` (`.github/.config.cjs:4-12`). +4. Owner: human — Run the local gates below and fix every failure. +5. Owner: human — Open a PR from the version branch into `main`. Merge it. + Read the `## Release Preview` comment on that PR first; it names the next + tag and shows the release body (`changelog-preview.yml:5-6,190-216`). +6. Owner: CI — A push to `main` merges `main` into `release` + (`autopromote.yml:4-6,24-25`). You do not touch `release`. +7. Owner: CI — A push to `release` starts the release (`release.yml:3-6`, + `_release.yml`): patch `Cargo.toml` and `Cargo.lock` (96-103), build the + binaries (105-114), rename `[Unreleased]`, commit + `chore(release): vX.Y.Z [skip ci]`, tag it, push `release` and the tag + (199-238), merge `release` into `main` (240-248), create the GitHub + Release and post to Discord (250-295). crates.io publishing is off here + (`release.yml:20`). +8. Owner: human — Run the checks in the Verify section. + +## Local gates + +These mirror the PR runner (`_pr-checks.yml:49-55`): + +``` +cargo +nightly fmt --all -- --check && +cargo clippy --all-features --all-targets --no-deps -- -D warnings && +cargo test --workspace --all-features --no-fail-fast +``` + +## Verify + +``` +git fetch --tags --force origin '+refs/tags/*:refs/tags/*' +git tag -l --sort=-v:refname | head -1 # want: vX.Y.Z +git log --oneline -1 origin/main # want: chore(release): vX.Y.Z +git rev-parse origin/main origin/release # want: both lines show one SHA +head -12 CHANGELOG.md # want: "## X.Y.Z (date)" first +``` + +Then open `https://github.com/FAZuH/hyprlay/releases/latest` and read the body. + +## Two observed paths into `main` + +`autopromote.yml:4-6` reacts to any push to `main`. Both a merged PR (history: +`e33220c Merge pull request #17 from FAZuH/v0.3.1`) and a direct push start a +release. No file in this repo states which one is required; this checklist +uses the PR path. A manual push to `release` also starts a release +(`release.yml:3-6`), and it ships whatever `release` holds, so do not use it. diff --git a/docs/dev/code-layout.md b/docs/dev/code-layout.md index 403b1c3..399059a 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -52,9 +52,10 @@ boundary. | `src/daemon/surface_host/layershell.rs` | Linux/Wayland overlay shell | The existing `iced_layershell` app, behaviour byte-identical: edge anchoring with margins, hover polling | | `src/daemon/surface_host/winit.rs` | Windows/macOS overlay shell | Frameless, transparent, always-on-top `iced` window moved to the computed on-screen position; same shared logic and hover poll | | `src/daemon/ctl_server.rs` | `incoming()` stream of `CtlRequest` | Serves the core `ControlListener` on a dedicated thread (accept loop never stalls the async host), one thread per connection; the wire vocabulary itself lives in core (single source of truth) | -| `src/daemon/overlay/state.rs` | `Overlay` model methods (`desired_size`, `displayed`, `apply_discord`) | Roster filtering, sizing, avatar cache/dedup | +| `src/daemon/overlay/state.rs` | `Overlay` model methods (`desired_size`, `displayed`, `hidden_rows`, `apply_discord`) | Roster filtering, sizing, avatar cache/dedup | | `src/daemon/overlay/geometry.rs` | `anchor/margin/drag(cfg, …)` | All screen-placement math | | `src/daemon/overlay/view.rs` | `view(&Overlay)` | Widget construction only | +| `src/daemon/overlay/glyph.rs` | `mark_of(&Participant) -> Option` | Mute/deafen glyph mapping for roster rows, free of widgets | | `src/daemon/adapters/discord.rs` | `run(sender, auth) -> DiscordEvent` | Local IPC protocol over `IpcStream`, OAuth token exchange, reconnection, voice subscriptions (Adapter to external Discord API) | | `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 | diff --git a/docs/dev/commit-changelog.md b/docs/dev/commit-changelog.md index e110c1a..60ca275 100644 --- a/docs/dev/commit-changelog.md +++ b/docs/dev/commit-changelog.md @@ -1,8 +1,15 @@ # Commit & Changelog Conventions -The changelog is generated from your commits automatically. Generation uses -the stock `conventional-changelog-conventionalcommits` preset. The only -custom behavior in `.config.cjs` is the version bump logic. +This repo maintains the changelog by hand. `.github/changelog-mode` holds +`manual`, so you write entries in `CHANGELOG.md` and the release renames the +topmost `## [Unreleased]` section to `## ()`. The release does +not build entries from your commits. For entry wording and grouping, see +`changelog.md`. + +Your commit subjects still set the version bump, and the release still +creates the tag automatically. The bump uses the stock +`conventional-changelog-conventionalcommits` preset. The only custom behavior +in `.config.cjs` is the version bump logic. ## Commit message format @@ -17,8 +24,10 @@ format: - `scope` — optional; the part of the codebase you changed - `subject` — a short description of the change -The type decides the changelog section. The scope shows in the entry. The -subject becomes the entry text. +The commit type sets the version bump (see `Bump control`). In `auto` mode the +type also picks the changelog section, the scope shows in the entry, and the +subject becomes the entry text. This repo is `manual` mode, so you write the +entries yourself. Example: @@ -27,9 +36,10 @@ feat(api): add user search endpoint fix(parser): handle empty input ``` -## Which commits appear +## Which commits matter -These types create changelog entries: +In `auto` mode these types create changelog entries. In this repo (`manual` +mode) they mark the user-visible changes you must write an entry for: | Type | Section heading | Meaning | |------|-----------------|---------| @@ -38,7 +48,7 @@ These types create changelog entries: | `perf` | Performance Improvements | A performance improvement | | `revert` | Reverts | A reverted change | -These types do NOT create entries: +These types do NOT create entries in either mode: ``` docs, style, chore, refactor, test, build, ci @@ -63,8 +73,10 @@ Example: `chore!(major): drop the legacy config format` bumps the version to the next major but creates no entry. A visible commit can also declare a breaking change. Add `!` after the type, -or add a `BREAKING CHANGE:` footer to the body. The changelog then shows the -entry under a "Breaking Changes" section. +or add a `BREAKING CHANGE:` footer to the body. In `auto` mode the changelog +then shows the entry under a "Breaking Changes" section. In `manual` mode, put +that entry under `### Breaking Changes` in `[Unreleased]` yourself. See +`changelog.md`. Workspace members (crates) are bumped independently. The CI detects changed members by file path under `crates//`, not by commit scope. Commit diff --git a/src/daemon/overlay/glyph.rs b/src/daemon/overlay/glyph.rs new file mode 100644 index 0000000..a38adbe --- /dev/null +++ b/src/daemon/overlay/glyph.rs @@ -0,0 +1,194 @@ +//! The mute/deafen mark: which glyph a roster row shows and in which color. +//! +//! The decision is kept here, free of widgets, so the pins below hold no +//! matter how the row is drawn. + +use iced::Color; + +use crate::daemon::adapters::discord::Participant; + +/// Icon path data from the Material Design Icons set (Pictogrammers Free +/// License, Apache-2.0): the crossed-out microphone and crossed-out +/// headphones. Copied as path data only, in a 24×24 viewBox. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Glyph { + MicOff, + HeadphonesOff, +} + +const MIC_OFF: &str = r#""#; + +const HEADPHONES_OFF: &str = r#""#; + +impl Glyph { + pub(crate) fn svg(self) -> &'static str { + match self { + Self::MicOff => MIC_OFF, + Self::HeadphonesOff => HEADPHONES_OFF, + } + } +} + +/// Tailwind `red-600`: a moderator silenced this participant. +const SERVER_COLOR: Color = Color::from_rgb8(0xDC, 0x26, 0x26); +/// Tailwind `neutral-400`: they silenced themselves. +const SELF_COLOR: Color = Color::from_rgb8(0xA3, 0xA3, 0xA3); + +/// The same quiet grey the self-set glyphs wear, reused by the "+N" +/// overflow pill so hidden rows read as decoration, not as people. +pub(crate) const OVERFLOW_COLOR: Color = SELF_COLOR; + +/// The one badge a row carries, if it carries one at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Mark { + pub glyph: Glyph, + pub server_caused: bool, +} + +impl Mark { + pub(crate) fn color(self) -> Color { + if self.server_caused { + SERVER_COLOR + } else { + SELF_COLOR + } + } +} + +/// The mark a participant earns, or none when they are clean. Deafening +/// wins over muting: one badge per row, never two. Server-set flags decide +/// the color on their own, so a self-deafened participant who is also +/// server-muted still reads red. +pub(crate) fn mark_of(p: &Participant) -> Option { + let glyph = if p.deafened() { + Glyph::HeadphonesOff + } else if p.muted() { + Glyph::MicOff + } else { + return None; + }; + Some(Mark { + glyph, + server_caused: p.server_mute || p.server_deaf, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state( + self_mute: bool, + self_deaf: bool, + server_mute: bool, + server_deaf: bool, + ) -> Participant { + Participant { + id: "42".into(), + name: "fazuh".into(), + avatar_hash: None, + speaking: false, + self_mute, + self_deaf, + server_mute, + server_deaf, + } + } + + /// `(self_mute, self_deaf, server_mute, server_deaf)` and the mark (glyph + /// + server-caused) it must produce, if any. + type Case = ((bool, bool, bool, bool), Option<(Glyph, bool)>); + + const CASES: [Case; 16] = [ + ((false, false, false, false), None), + ((true, false, false, false), Some((Glyph::MicOff, false))), + ( + (false, true, false, false), + Some((Glyph::HeadphonesOff, false)), + ), + ( + (true, true, false, false), + Some((Glyph::HeadphonesOff, false)), + ), + ((false, false, true, false), Some((Glyph::MicOff, true))), + ((true, false, true, false), Some((Glyph::MicOff, true))), + ( + (false, true, true, false), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (true, true, true, false), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (false, false, false, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (true, false, false, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (false, true, false, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (true, true, false, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (false, false, true, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (true, false, true, true), + Some((Glyph::HeadphonesOff, true)), + ), + ( + (false, true, true, true), + Some((Glyph::HeadphonesOff, true)), + ), + ((true, true, true, true), Some((Glyph::HeadphonesOff, true))), + ]; + + #[test] + fn mark_of_maps_every_mute_flag_combination_to_one_glyph() { + for ((sm, sd, vm, vd), expected) in CASES { + let got = mark_of(&state(sm, sd, vm, vd)).map(|m| (m.glyph, m.server_caused)); + assert_eq!( + got, expected, + "self_mute={sm} self_deaf={sd} server_mute={vm} server_deaf={vd}" + ); + } + } + + #[test] + fn clean_participant_gets_no_mark() { + assert_eq!(mark_of(&state(false, false, false, false)), None); + } + + #[test] + fn server_color_is_red_600_and_self_color_is_neutral_400() { + let red = Mark { + glyph: Glyph::MicOff, + server_caused: true, + } + .color(); + let grey = Mark { + glyph: Glyph::MicOff, + server_caused: false, + } + .color(); + assert_eq!(red, Color::from_rgb8(0xDC, 0x26, 0x26)); + assert_eq!(grey, Color::from_rgb8(0xA3, 0xA3, 0xA3)); + } + + #[test] + fn both_glyphs_are_24by24_viewbox_paths() { + for glyph in [Glyph::MicOff, Glyph::HeadphonesOff] { + let doc = glyph.svg(); + assert!(doc.starts_with(r#""#), "{doc}"); + assert!(doc.contains(", channel_name: Option, users: Vec, + /// Tick when each participant last started speaking. Runtime-only + /// presentation state for the recent-speakers order — never serialized, + /// and dropped the moment someone stops speaking or leaves the channel. + speakers: HashMap, + tick: u64, avatars: AvatarCache, /// Label of the authentication path for `hyprlay status`. Own-app /// credentials are the only path, so this never varies. @@ -105,6 +111,8 @@ impl Overlay { me_id: None, channel_name: None, users: Vec::new(), + speakers: HashMap::new(), + tick: 0, avatars: AvatarCache::default(), auth_label: "own-app", offset, @@ -181,6 +189,7 @@ impl Overlay { RosterChange::Unchanged } DiscordEvent::Participants(users) => { + self.track_speakers(&users); self.users = users; if self.status == ConnectionStatus::Connected { crate::daemon::adapters::cache::save_roster( @@ -197,28 +206,92 @@ impl Overlay { // -- derived views ------------------------------------------------------ - /// Participants after applying the config filters (own user, talking). - /// Hidden short-circuits to an empty list so the surface collapses - /// through the normal empty-surface path — no layer-shell unmap games; - /// the daemon keeps tracking state while invisible. + /// The displayed rows, cut off at `max_rows` (0 = unlimited). Rows past + /// the cap are not rendered; [`Overlay::hidden_rows`] counts them. pub fn displayed(&self) -> Vec<&Participant> { + let mut rows = self.eligible(); + if self.config.max_rows > 0 { + rows.truncate(self.config.max_rows as usize); + } + rows + } + + /// Participants that overflow the row cap — the "+N" pill's N. Rows + /// removed by the config filters do not count: they are not hidden by + /// the cap. + pub fn hidden_rows(&self) -> usize { + if self.config.max_rows == 0 { + return 0; + } + self.eligible() + .len() + .saturating_sub(self.config.max_rows as usize) + } + + /// Participants after applying the config filters (own user, talking) + /// and the configured roster order, before the row cap. Hidden + /// short-circuits to an empty list so the surface collapses through the + /// normal empty-surface path — no layer-shell unmap games; the daemon + /// keeps tracking state while invisible. + fn eligible(&self) -> Vec<&Participant> { if !self.config.visible { return Vec::new(); } - self.users + let mut rows: Vec<&Participant> = self + .users .iter() .filter(|p| self.config.show_own_user || Some(&p.id) != self.me_id.as_ref()) .filter(|p| !self.config.show_only_talking_users || p.speaking) - .collect() + .collect(); + self.sort_rows(&mut rows); + rows + } + + /// One ordering pass over already-filtered rows. Every strategy is a + /// stable sort, so ties fall back to join order everywhere. + fn sort_rows(&self, rows: &mut [&Participant]) { + match self.config.roster_order { + RosterOrder::JoinOrder => {} + RosterOrder::Name => rows.sort_by_key(|p| p.name.to_lowercase()), + RosterOrder::RecentSpeakers => { + rows.sort_by(|a, b| self.speakers.get(&b.id).cmp(&self.speakers.get(&a.id))) + } + } + } + + /// Diff speaking flags against the previous roster: a start stamps the + /// participant with the next tick (the top of the recent-speakers + /// order), a stop drops the record so they sink back to join order. + /// Departed participants lose their record with the roster. + fn track_speakers(&mut self, users: &[Participant]) { + self.tick += 1; + for p in users { + let was_speaking = self.users.iter().any(|u| u.id == p.id && u.speaking); + match (was_speaking, p.speaking) { + (false, true) => { + self.speakers.insert(p.id.clone(), self.tick); + } + (true, false) => { + self.speakers.remove(&p.id); + } + _ => {} + } + } + self.speakers + .retain(|id, _| users.iter().any(|u| &u.id == id)); } /// Surface size (logical px) for the currently displayed rows. Height 0 - /// means "nothing to show". + /// means "nothing to show". A truncated roster reserves one more row + /// for the "+N" overflow pill. pub fn desired_size(&self) -> (u32, u32) { - let n = self.displayed().len() as f32; + let mut n = self.displayed().len() as f32; if n == 0.0 { return (self.config.width, 0); } + if self.hidden_rows() > 0 { + n += 1.0; + } let scale = self.config.scale_f32(); let avatar = self.config.avatar_size as f32 * scale; let spacing = self.config.spacing as f32 * scale; @@ -308,6 +381,123 @@ mod tests { o } + fn ids<'a>(rows: &[&'a Participant]) -> Vec<&'a str> { + rows.iter().map(|p| p.id.as_str()).collect() + } + + #[test] + fn join_order_keeps_wire_order() { + let state = overlay( + vec![ + participant("carol", "Carol", false), + participant("alice", "alice", false), + participant("bob", "Bob", false), + ], + Config::default(), + ); + assert_eq!(ids(&state.displayed()), ["carol", "alice", "bob"]); + } + + #[test] + fn name_order_is_case_insensitive_a_to_z() { + let cfg = Config { + roster_order: RosterOrder::Name, + ..Config::default() + }; + let state = overlay( + vec![ + participant("carol", "Carol", false), + participant("dave", "dave", false), + participant("alice", "ALICE", false), + participant("bob", "Bob", false), + ], + cfg, + ); + assert_eq!(ids(&state.displayed()), ["alice", "bob", "carol", "dave"]); + } + + #[test] + fn name_order_ties_fall_back_to_join_order() { + let cfg = Config { + roster_order: RosterOrder::Name, + ..Config::default() + }; + let state = overlay( + vec![ + participant("second", "sam", false), + participant("first", "Sam", false), + ], + cfg, + ); + assert_eq!(ids(&state.displayed()), ["second", "first"]); + } + + #[test] + fn recent_speakers_bubble_up_most_recent_first() { + let cfg = Config { + roster_order: RosterOrder::RecentSpeakers, + ..Config::default() + }; + let all = || { + vec![ + participant("a", "a", false), + participant("b", "b", false), + participant("c", "c", false), + ] + }; + let mut state = overlay(all(), cfg); + // b starts, then a joins in: a is the most recent speaker. + let mut b_speaking = all(); + b_speaking[1].speaking = true; + state.apply_discord(DiscordEvent::Participants(b_speaking.clone())); + let mut both = b_speaking.clone(); + both[0].speaking = true; + state.apply_discord(DiscordEvent::Participants(both)); + assert_eq!(ids(&state.displayed()), ["a", "b", "c"]); + } + + #[test] + fn recent_speaker_sinks_back_when_stopping() { + let cfg = Config { + roster_order: RosterOrder::RecentSpeakers, + ..Config::default() + }; + let all = || vec![participant("b", "b", false), participant("a", "a", false)]; + let mut state = overlay(all(), cfg); + let mut a_speaking = all(); + a_speaking[1].speaking = true; + state.apply_discord(DiscordEvent::Participants(a_speaking)); + assert_eq!(ids(&state.displayed()), ["a", "b"]); + // Stopping sinks a back to its join-order slot. + state.apply_discord(DiscordEvent::Participants(all())); + assert_eq!(ids(&state.displayed()), ["b", "a"]); + } + + #[test] + fn recent_speakers_cleared_on_channel_switch() { + let cfg = Config { + roster_order: RosterOrder::RecentSpeakers, + ..Config::default() + }; + let mut state = overlay( + vec![participant("a", "a", false), participant("b", "b", false)], + cfg, + ); + state.apply_discord(DiscordEvent::Participants(vec![ + participant("a", "a", true), + participant("b", "b", false), + ])); + assert_eq!(ids(&state.displayed()), ["a", "b"]); + // Empty roster then a fresh list in reverse join order: the old + // speaker record must not bubble `a` back to the top. + state.apply_discord(DiscordEvent::Participants(vec![])); + state.apply_discord(DiscordEvent::Participants(vec![ + participant("b", "b", false), + participant("a", "a", false), + ])); + assert_eq!(ids(&state.displayed()), ["b", "a"]); + } + #[test] fn desired_size_grows_one_row_and_spacing_per_participant() { let cfg = Config { @@ -341,6 +531,131 @@ mod tests { assert_eq!(state.desired_size().1, 0); } + #[test] + fn row_cap_truncates_and_reports_hidden_overflow() { + let users = || { + vec![ + participant("1", "a", false), + participant("2", "b", false), + participant("3", "c", false), + participant("4", "d", false), + ] + }; + let cfg = Config { + max_rows: 2, + ..Config::default() + }; + let state = overlay(users(), cfg); + assert_eq!(ids(&state.displayed()), ["1", "2"]); + assert_eq!(state.hidden_rows(), 2); + } + + #[test] + fn row_cap_zero_is_unlimited_and_no_pill_when_within_cap() { + let users = || { + vec![ + participant("1", "a", false), + participant("2", "b", false), + participant("3", "c", false), + ] + }; + let unlimited = overlay(users(), Config::default()); + assert_eq!(unlimited.displayed().len(), 3); + assert_eq!(unlimited.hidden_rows(), 0); + // n < cap + let roomy = overlay( + users(), + Config { + max_rows: 5, + ..Config::default() + }, + ); + assert_eq!(roomy.displayed().len(), 3); + assert_eq!(roomy.hidden_rows(), 0); + // n = cap + let exact = overlay( + users(), + Config { + max_rows: 3, + ..Config::default() + }, + ); + assert_eq!(exact.displayed().len(), 3); + assert_eq!(exact.hidden_rows(), 0); + } + + #[test] + fn row_cap_counts_hidden_rows_after_filters() { + // Talking-only hides the quiet rows before the cap applies, so the + // pill counts only eligible rows that overflow the cap. + let cfg = Config { + show_only_talking_users: true, + max_rows: 2, + ..Config::default() + }; + let state = overlay( + vec![ + participant("1", "quiet", false), + participant("2", "loud", true), + participant("3", "loud", true), + participant("4", "loud", true), + ], + cfg, + ); + assert_eq!(ids(&state.displayed()), ["2", "3"]); + assert_eq!(state.hidden_rows(), 1); + } + + #[test] + fn row_cap_truncates_after_sort() { + let cfg = Config { + roster_order: RosterOrder::Name, + max_rows: 2, + ..Config::default() + }; + let state = overlay( + vec![ + participant("z", "zed", false), + participant("a", "amy", false), + participant("m", "mo", false), + ], + cfg, + ); + assert_eq!(ids(&state.displayed()), ["a", "m"]); + assert_eq!(state.hidden_rows(), 1); + } + + #[test] + fn desired_size_adds_one_pill_row_when_truncated() { + let users = || { + vec![ + participant("1", "a", false), + participant("2", "b", false), + participant("3", "c", false), + ] + }; + let cfg = Config { + avatar_size: 34, + spacing: 4, + scale: 100, + ..Config::default() + }; + // row_h = 34 + 8 = 42, spacing = 4. + let full = overlay(users(), cfg.clone()); + let full_h = full.desired_size().1; // 3 rows: 3*42 + 2*4 + assert_eq!(full_h, 134); + // Capped to 2 rows + a pill row = still 3 rows of height. + let capped = overlay( + users(), + Config { + max_rows: 2, + ..cfg.clone() + }, + ); + assert_eq!(capped.desired_size().1, full_h); + assert_eq!(capped.hidden_rows(), 1); + } + #[test] fn displayed_hides_own_user_when_show_own_user_is_false() { let cfg = Config { diff --git a/src/daemon/overlay/view.rs b/src/daemon/overlay/view.rs index 5d64631..e06656e 100644 --- a/src/daemon/overlay/view.rs +++ b/src/daemon/overlay/view.rs @@ -13,8 +13,12 @@ use iced::widget::container; use iced::widget::container::Style as ContainerStyle; use iced::widget::image; use iced::widget::row; +use iced::widget::svg; use iced::widget::text; +use super::glyph::Glyph; +use super::glyph::Mark; +use super::glyph::mark_of; use crate::daemon::adapters::discord::Participant; use crate::daemon::overlay::state::Overlay; @@ -26,8 +30,8 @@ fn color_of(hex: HexColor) -> Color { Color::from_rgb8(r, g, b) } -const MUTE_RED: Color = Color::from_rgb(0.95, 0.25, 0.26); -const DEAF_ORANGE: Color = Color::from_rgb(0.96, 0.72, 0.24); +/// Glyph drawn inline after the username, logical px. +const INLINE_SIZE: u32 = 16; const FALLBACK_COLORS: [Color; 6] = [ Color::from_rgb(0.36, 0.44, 0.96), @@ -48,11 +52,15 @@ pub fn view<'a, M: 'static>(state: &'a Overlay) -> Element<'a, M> { // is transparent anywhere. let alphas = state.effective_alphas(); - let rows: Vec> = state + let mut rows: Vec> = state .displayed() .into_iter() .map(|p| participant_row(state, p, alphas)) .collect(); + let hidden = state.hidden_rows(); + if hidden > 0 { + rows.push(overflow_row(state, hidden, alphas)); + } // Fully transparent panel: no background, no border — only the rows. // Connect/sign-in progress is deliberately never rendered: an empty @@ -104,6 +112,8 @@ fn participant_row<'a, M: 'static>( }) .into(); + let mark = mark_of(p); + let name = truncate(&p.name, state.config().max_username_length); // Speakers are differentiated by the ring only — names stay fully // opaque so the per-part sliders are the only transparency knobs. @@ -133,33 +143,97 @@ fn participant_row<'a, M: 'static>( }) .into(); - let mut badges = row![].spacing(4.0); - if p.muted() { - badges = badges.push(badge("M", MUTE_RED, alphas.text, alphas.box_bg, state)); + // The mark, inline right after the username. + let inline = + mark.map(|mark| glyph(mark.glyph, scaled(state, INLINE_SIZE), alpha(mark, alphas))); + + if state.config().rtl { + // Avatar on the right, name to its left, text right-aligned. + let mut row = row![ + Space::new().width(Length::Fill), + name_right_aligned(state, name_area) + ] + .spacing(8.0) + .align_y(iced::Alignment::Center); + if let Some(inline) = inline { + row = row.push(inline); + } + row.push(avatar).into() + } else { + let mut row = row![avatar, name_area] + .spacing(8.0) + .align_y(iced::Alignment::Center); + if let Some(inline) = inline { + row = row.push(inline); + } + row.push(Space::new().width(Length::Fill)).into() } - if p.deafened() { - badges = badges.push(badge("D", DEAF_ORANGE, alphas.text, alphas.box_bg, state)); +} + +/// The mark's color, dimmed by the text-opacity knob like any row content. +fn alpha(mark: Mark, alphas: Alphas) -> Color { + Color { + a: alphas.text, + ..mark.color() } +} +/// The "+N" overflow pill: one quiet row after the capped roster, indented +/// to the name column and dressed like a name chip, painted in the glyphs' +/// muted grey. N counts the filtered participants the cap hides. +fn overflow_row<'a, M: 'static>( + state: &'a Overlay, + hidden: usize, + alphas: Alphas, +) -> Element<'a, M> { + let text_size = scaled(state, state.config().text_size); + let chip_bg = Color { + a: alphas.box_bg, + ..color_of(state.config().box_color) + }; + let label = text(format!("+{hidden}")).size(text_size).color(Color { + a: alphas.text, + ..super::glyph::OVERFLOW_COLOR + }); + let chip: Element<'_, M> = container(label) + .padding([2, 8]) + .style(move |_t| ContainerStyle { + background: Some(chip_bg.into()), + border: Border { + radius: (text_size * 0.6).into(), + ..Border::default() + }, + ..ContainerStyle::default() + }) + .into(); + // Indent past the avatar column so the pill sits under the names. + let indent = scaled(state, state.config().avatar_size) + 8.0; if state.config().rtl { - // Avatar on the right, name to its left, text right-aligned. row![ Space::new().width(Length::Fill), - name_right_aligned(state, name_area), - badges, - avatar + chip, + Space::new().width(Length::Fixed(indent)) ] - .spacing(8.0) - .align_y(iced::Alignment::Center) .into() } else { - row![avatar, name_area, Space::new().width(Length::Fill), badges] - .spacing(8.0) - .align_y(iced::Alignment::Center) - .into() + row![ + Space::new().width(Length::Fixed(indent)), + chip, + Space::new().width(Length::Fill) + ] + .into() } } +/// Vector glyph of one MDI icon, painted flat in `color`. +fn glyph(glyph: Glyph, px: f32, color: Color) -> Element<'static, M> { + svg::Svg::new(svg::Handle::from_memory(glyph.svg().as_bytes())) + .width(Length::Fixed(px)) + .height(Length::Fixed(px)) + .style(move |_theme, _status| svg::Style { color: Some(color) }) + .into() +} + /// In RTL mode the name hugs the avatar: right-aligned inside a filling row. fn name_right_aligned<'a, M: 'a>(state: &Overlay, name_area: Element<'a, M>) -> Element<'a, M> { let text_size = scaled(state, state.config().text_size); @@ -173,34 +247,6 @@ fn name_right_aligned<'a, M: 'a>(state: &Overlay, name_area: Element<'a, M>) -> .into() } -fn badge( - label: &str, - color: Color, - alpha: f32, - bg_alpha: f32, - state: &Overlay, -) -> Element<'static, M> { - let size = scaled(state, state.config().text_size) * 0.7; - let bg = Color { - a: bg_alpha, - ..color - }; - container(text(label.to_string()).size(size).color(Color { - a: alpha, - ..Color::WHITE - })) - .padding(2.0) - .style(move |_t| ContainerStyle { - background: Some(bg.into()), - border: Border { - radius: (size * 0.5).into(), - ..Border::default() - }, - ..ContainerStyle::default() - }) - .into() -} - fn fallback_avatar(id: &str, name: &str, px: f32, alpha: f32) -> Element<'static, M> { let color = FALLBACK_COLORS[id.bytes().map(usize::from).sum::() % FALLBACK_COLORS.len()]; let bg = Color { a: alpha, ..color }; diff --git a/src/gui/commands.rs b/src/gui/commands.rs index e8efc35..874bbe3 100644 --- a/src/gui/commands.rs +++ b/src/gui/commands.rs @@ -59,6 +59,7 @@ pub(super) fn command_for(message: Message) -> Command { // 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::RosterOrder(order) => Command::Set(Key::RosterOrder, Value::RosterOrder(order)), Message::SetFlag(..) => unreachable!("flags are handled directly in update"), // Handled directly in `update`; unreachable here. Message::NumText(..) @@ -117,6 +118,25 @@ mod tests { assert_eq!(live.anchor, saved.anchor); } + #[test] + fn roster_order_setting_roundtrips_through_apply_and_revert() { + // The exact Command path the GUI chip row drives. + let mut live = Config::default(); + let pick_name = Command::Set( + Key::RosterOrder, + Value::RosterOrder(hyprlay_core::config::RosterOrder::Name), + ); + pick_name.clone().apply_config(&mut live); + assert_eq!(live.roster_order, hyprlay_core::config::RosterOrder::Name); + + // 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::RosterOrder, Key::RosterOrder.value_of(&saved)); + revert.apply_config(&mut live); + assert_eq!(live.roster_order, saved.roster_order); + } + #[test] fn key_sets_use_the_cli_wire_names() { use hyprlay_core::config::OFFSETS; diff --git a/src/gui/fields.rs b/src/gui/fields.rs index b0469b4..4f26712 100644 --- a/src/gui/fields.rs +++ b/src/gui/fields.rs @@ -6,6 +6,7 @@ use hyprlay_core::config::AnchorMode; use hyprlay_core::config::Config; use hyprlay_core::config::HorizontalAnchor as H; use hyprlay_core::config::PALETTES; +use hyprlay_core::config::RosterOrder; use hyprlay_core::config::VerticalAnchor as V; use hyprlay_core::domain::Key; use hyprlay_core::domain::Value; @@ -208,6 +209,12 @@ pub(super) const FIELDS: &[Field] = &[ tip: "Include yourself in the overlay.", render: f_own_user, }, + Field { + section: Section::Layout, + label: "roster order", + tip: "How participants are ordered. Join order keeps Discord's arrival order, name sorts alphabetically, recent speakers bubble the last person who talked to the top.", + render: f_roster_order, + }, Field { section: Section::Layout, label: "width", @@ -244,10 +251,16 @@ pub(super) const FIELDS: &[Field] = &[ tip: "Usernames longer than this are truncated with an ellipsis.", render: f_max_name, }, + Field { + section: Section::Layout, + label: "max rows", + tip: "Cap how many participant rows render. Overflow rows hide behind a +N pill; 0 shows everyone.", + render: f_max_rows, + }, Field { section: Section::Opacity, label: "overall", - tip: "Dims everything together: avatars, usernames, badges and the speaking ring.", + tip: "Dims everything together: avatars, usernames, glyphs and the speaking ring.", render: f_opacity, }, Field { @@ -381,6 +394,35 @@ pub(super) fn f_own_user(gui: &Gui) -> Element<'_, Message> { }) } +/// Tri-state roster-order selector: join-order | name | recent-speakers as +/// chips, mirroring the anchor chip pattern (selected state highlighted). +pub(super) fn f_roster_order(gui: &Gui) -> Element<'_, Message> { + let modes = [ + (RosterOrder::JoinOrder, "join-order"), + (RosterOrder::Name, "name"), + (RosterOrder::RecentSpeakers, "recent-speakers"), + ]; + let mut chips = row![].spacing(6); + for (mode, label) in modes { + let selected = gui.config.roster_order == mode; + chips = chips.push(roster_order_chip(mode, label, selected)); + } + chips.into() +} + +fn roster_order_chip(mode: RosterOrder, label: &str, selected: bool) -> Element<'static, Message> { + let bg = if selected { ACCENT } else { FIELD_BG }; + button(text(label.to_string())) + .on_press(Message::RosterOrder(mode)) + .style(move |_t, _s| button::Style { + background: Some(bg.into()), + text_color: Color::WHITE, + ..button::Style::default() + }) + .padding([4, 10]) + .into() +} + pub(super) fn f_width(gui: &Gui) -> Element<'_, Message> { number_row(gui, Key::Width) } @@ -405,6 +447,10 @@ pub(super) fn f_max_name(gui: &Gui) -> Element<'_, Message> { number_row(gui, Key::MaxName) } +pub(super) fn f_max_rows(gui: &Gui) -> Element<'_, Message> { + number_row(gui, Key::MaxRows) +} + pub(super) fn f_opacity(gui: &Gui) -> Element<'_, Message> { number_row(gui, Key::Opacity) } @@ -883,4 +929,22 @@ mod tests { .expect("anchor field registered"); assert_eq!(field.section, Section::Position); } + + #[test] + fn max_rows_field_is_registered_in_the_layout_section() { + let field = FIELDS + .iter() + .find(|f| f.label == "max rows") + .expect("max rows field registered"); + assert_eq!(field.section, Section::Layout); + } + + #[test] + fn roster_order_field_is_registered_in_the_layout_section() { + let field = FIELDS + .iter() + .find(|f| f.label == "roster order") + .expect("roster order field registered"); + assert_eq!(field.section, Section::Layout); + } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c487c1b..eaf5824 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -61,6 +61,8 @@ enum Message { Position(H, V), /// Pin the vertical glue edge explicitly (or return it to Auto). Anchor(hyprlay_core::config::AnchorMode), + /// Pick the roster ordering strategy. + RosterOrder(hyprlay_core::config::RosterOrder), /// Flip one boolean config key (rtl, talking-only, own user). SetFlag(Key, bool), /// Integer text edited for a numeric knob; invalid or out-of-range