From 93105e1c3a29dcd53af4c27a8df98d6d370205b8 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Fri, 11 Sep 2026 23:15:29 +0700 Subject: [PATCH 1/5] feat(daemon): Discord-style mute/deafen glyphs in roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the M/D text badges with MDI microphone-off / headphones-off vector glyphs (Apache-2.0 path data, tinted through iced's svg loader), drawn inline after the username at 16px. One mark per row — deafening overrides muting — red (red-600) when server-set, grey (neutral-400) when self-set. The mark decision lives in overlay/glyph.rs so the presence/override/color rules stay unit-pinned. Adds iced's svg feature (resvg tree, +1.9MB on the release daemon). Records the glyph change in the changelog Unreleased Overlay section. --- CHANGELOG.md | 4 + CONTEXT.md | 9 +- Cargo.lock | 241 +++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + docs/dev/code-layout.md | 1 + src/daemon/overlay/glyph.rs | 190 ++++++++++++++++++++++++++++ src/daemon/overlay/mod.rs | 1 + src/daemon/overlay/view.rs | 87 ++++++------- src/gui/fields.rs | 2 +- 9 files changed, 482 insertions(+), 54 deletions(-) create mode 100644 src/daemon/overlay/glyph.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e9e34..d404d52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Changed the app and tray icon to a circular design - Fixed source installs failing on a fresh dependency resolution +### 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 + ## 0.3.0 (2026-09-03) ### Platforms diff --git a/CONTEXT.md b/CONTEXT.md index 48674a9..f87aab5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,9 +52,12 @@ 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 — + never connection or status text. An empty roster renders an empty + transparent surface. - **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/docs/dev/code-layout.md b/docs/dev/code-layout.md index 403b1c3..0bd59f7 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -55,6 +55,7 @@ boundary. | `src/daemon/overlay/state.rs` | `Overlay` model methods (`desired_size`, `displayed`, `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/src/daemon/overlay/glyph.rs b/src/daemon/overlay/glyph.rs new file mode 100644 index 0000000..e625c18 --- /dev/null +++ b/src/daemon/overlay/glyph.rs @@ -0,0 +1,190 @@ +//! 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 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(" 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), @@ -104,6 +108,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 +139,50 @@ 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)); - } - if p.deafened() { - badges = badges.push(badge("D", DEAF_ORANGE, 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. - row![ + let mut row = row![ Space::new().width(Length::Fill), - name_right_aligned(state, name_area), - badges, - avatar + name_right_aligned(state, name_area) ] .spacing(8.0) - .align_y(iced::Alignment::Center) - .into() + .align_y(iced::Alignment::Center); + if let Some(inline) = inline { + row = row.push(inline); + } + row.push(avatar).into() } else { - row![avatar, name_area, Space::new().width(Length::Fill), badges] + let mut row = row![avatar, name_area] .spacing(8.0) - .align_y(iced::Alignment::Center) - .into() + .align_y(iced::Alignment::Center); + if let Some(inline) = inline { + row = row.push(inline); + } + row.push(Space::new().width(Length::Fill)).into() } } +/// 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() + } +} + +/// 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 +196,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/fields.rs b/src/gui/fields.rs index b0469b4..e57438b 100644 --- a/src/gui/fields.rs +++ b/src/gui/fields.rs @@ -247,7 +247,7 @@ pub(super) const FIELDS: &[Field] = &[ 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 { From bd4a87bedbfc7768063a59b3cce4299c0c4db081 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Sat, 12 Sep 2026 01:20:21 +0700 Subject: [PATCH 2/5] docs: sync changelog with released 0.3.1 section The v0.3.2 line was cut from the pre-release tip, so its CHANGELOG still listed the 0.3.1 entries under [Unreleased] and lacked the materialized 0.3.1 section. Release previews counted the shipped entries twice. --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d404d52..2a9a2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,17 @@ ## [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 + +## 0.3.1 (2026-09-11) + ### Platforms - Changed the app and tray icon to a circular design - Fixed source installs failing on a fresh dependency resolution -### 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 - ## 0.3.0 (2026-09-03) ### Platforms From 7d9be09acfa06e59d0bd060168e64e67a8849107 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Sat, 12 Sep 2026 01:56:24 +0700 Subject: [PATCH 3/5] feat: add roster-order sorting (join order, name, recent speakers) New roster-order config key (Layout group, wire grammar + GUI chip row) with a join-order | name | recent-speakers picker. The overlay stamps a runtime-only last-spoke tick per speaker (cleared on stop and on channel switch, never serialized) and sorts the filtered roster before rendering; every strategy is a stable sort, so ties keep join order. --- CHANGELOG.md | 1 + crates/hyprlay-core/src/config.rs | 90 +++++++++++++++ crates/hyprlay-core/src/domain.rs | 77 ++++++++++++- src/daemon/overlay/state.rs | 176 +++++++++++++++++++++++++++++- src/gui/commands.rs | 20 ++++ src/gui/fields.rs | 45 ++++++++ src/gui/mod.rs | 2 + 7 files changed, 403 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9a2b3..ba9a5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 ## 0.3.1 (2026-09-11) diff --git a/crates/hyprlay-core/src/config.rs b/crates/hyprlay-core/src/config.rs index d430dfd..ffd67a1 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; @@ -113,6 +152,8 @@ 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, /// Master visibility switch: false collapses the overlay to an empty /// surface while the daemon keeps running and tracking state. pub visible: bool, @@ -158,6 +199,7 @@ impl Default for Config { max_username_length: 16, show_own_user: true, show_only_talking_users: false, + roster_order: RosterOrder::JoinOrder, visible: true, auto_save: true, show_on_fullscreen: true, @@ -274,6 +316,7 @@ 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), visible: Some(self.visible), auto_save: Some(self.auto_save), show_on_fullscreen: Some(self.show_on_fullscreen), @@ -318,6 +361,7 @@ 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), 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 +405,7 @@ struct LayoutTable { max_name: Option, talking_only: Option, own_user: Option, + roster_order: Option, visible: Option, auto_save: Option, show_on_fullscreen: Option, @@ -708,6 +753,51 @@ 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 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..80da583 100644 --- a/crates/hyprlay-core/src/domain.rs +++ b/crates/hyprlay-core/src/domain.rs @@ -20,6 +20,7 @@ use crate::config::HorizontalAnchor; use crate::config::MAX_NAME; 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 +280,7 @@ pub enum Key { ShowOnFullscreen, DimOnHover, HoverOpacity, + RosterOrder, } /// Config sections, shared by `reset ` and the TOML layout. @@ -325,6 +327,7 @@ pub enum Value { Color(HexColor), Corner(Corner), Anchor(crate::config::AnchorMode), + RosterOrder(RosterOrder), Target(MonitorTarget), Cycle, } @@ -338,6 +341,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 +351,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; 29] = [ Key::Position, Key::Anchor, Key::Monitor, @@ -376,6 +380,7 @@ impl Key { Key::ShowOnFullscreen, Key::DimOnHover, Key::HoverOpacity, + Key::RosterOrder, ]; pub fn name(self) -> &'static str { @@ -408,6 +413,7 @@ impl Key { Self::ShowOnFullscreen => "show-on-fullscreen", Self::DimOnHover => "dim-on-hover", Self::HoverOpacity => "hover-opacity", + Self::RosterOrder => "roster-order", } } @@ -436,7 +442,8 @@ impl Key { | Self::Visible | Self::AutoSave | Self::ShowOnFullscreen - | Self::DimOnHover => Group::Layout, + | Self::DimOnHover + | Self::RosterOrder => Group::Layout, Self::Opacity | Self::AvatarOpacity | Self::TextOpacity @@ -484,6 +491,7 @@ 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::SpeakingColor => Value::Color(cfg.speaking_color), Self::TextColor => Value::Color(cfg.text_color), Self::BoxColor => Value::Color(cfg.box_color), @@ -566,6 +574,7 @@ fn cycle_able(key: Key) -> bool { | Key::AutoSave | Key::ShowOnFullscreen | Key::DimOnHover + | Key::RosterOrder ) } @@ -622,6 +631,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())), @@ -663,6 +680,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 +718,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"), @@ -1657,6 +1679,57 @@ 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 new_keys_display_canonical_and_get_roundtrip() { assert_eq!( diff --git a/src/daemon/overlay/state.rs b/src/daemon/overlay/state.rs index 97735ab..ac4c5bb 100644 --- a/src/daemon/overlay/state.rs +++ b/src/daemon/overlay/state.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::collections::HashSet; use hyprlay_core::config::Config; +use hyprlay_core::config::RosterOrder; use hyprlay_core::domain::ConnectionStatus; use iced::widget::image::Handle; @@ -76,6 +77,11 @@ pub struct Overlay { me_id: Option, 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,19 +206,57 @@ 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. + /// Participants after applying the config filters (own user, talking) + /// and the configured roster order. 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. pub fn displayed(&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 @@ -308,6 +355,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 { 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 e57438b..ebead13 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", @@ -381,6 +388,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) } @@ -883,4 +919,13 @@ mod tests { .expect("anchor field registered"); assert_eq!(field.section, Section::Position); } + + #[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 From 604226a4000eaaacf79e4c4da7d1ccbbf35f3bb2 Mon Sep 17 00:00:00 2001 From: FAZuH Date: Sat, 12 Sep 2026 02:06:13 +0700 Subject: [PATCH 4/5] feat: cap roster height with max rows and a +N pill New max-rows config key (0 = unlimited, clamped 0..=200, GUI number row). Truncation happens after sort and filters and before the desired-size math, so the surface shrinks to the capped rows plus one pill row; the pill counts exactly the rows the cap hides, in the glyphs' muted grey. Presentation-only: cache and wire format are untouched. --- CHANGELOG.md | 1 + CONTEXT.md | 18 +++- crates/hyprlay-core/src/config.rs | 32 ++++++ crates/hyprlay-core/src/domain.rs | 53 +++++++++- docs/dev/code-layout.md | 2 +- src/daemon/overlay/glyph.rs | 4 + src/daemon/overlay/state.rs | 165 ++++++++++++++++++++++++++++-- src/daemon/overlay/view.rs | 53 +++++++++- src/gui/fields.rs | 19 ++++ 9 files changed, 333 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9a5c2..6ac29ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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) diff --git a/CONTEXT.md b/CONTEXT.md index f87aab5..ccfcf1f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,9 +55,21 @@ Platform mechanics live behind ports in `src/platform/`; see 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 — - never connection or status text. An empty roster renders an empty - transparent surface. + 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/crates/hyprlay-core/src/config.rs b/crates/hyprlay-core/src/config.rs index ffd67a1..c889c9d 100644 --- a/crates/hyprlay-core/src/config.rs +++ b/crates/hyprlay-core/src/config.rs @@ -115,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, @@ -154,6 +156,9 @@ pub struct Config { 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, @@ -200,6 +205,7 @@ impl Default for Config { 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, @@ -317,6 +323,7 @@ impl Config { 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), @@ -362,6 +369,7 @@ impl Config { 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), @@ -406,6 +414,7 @@ struct LayoutTable { talking_only: Option, own_user: Option, roster_order: Option, + max_rows: Option, visible: Option, auto_save: Option, show_on_fullscreen: Option, @@ -479,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) { @@ -798,6 +808,28 @@ speaking = \"#00ff00\" 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 80da583..5fedd32 100644 --- a/crates/hyprlay-core/src/domain.rs +++ b/crates/hyprlay-core/src/domain.rs @@ -18,6 +18,7 @@ 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; @@ -281,6 +282,7 @@ pub enum Key { DimOnHover, HoverOpacity, RosterOrder, + MaxRows, } /// Config sections, shared by `reset ` and the TOML layout. @@ -351,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; 29] = [ + pub const ALL: [Key; 30] = [ Key::Position, Key::Anchor, Key::Monitor, @@ -381,6 +383,7 @@ impl Key { Key::DimOnHover, Key::HoverOpacity, Key::RosterOrder, + Key::MaxRows, ]; pub fn name(self) -> &'static str { @@ -414,6 +417,7 @@ impl Key { Self::DimOnHover => "dim-on-hover", Self::HoverOpacity => "hover-opacity", Self::RosterOrder => "roster-order", + Self::MaxRows => "max-rows", } } @@ -443,7 +447,8 @@ impl Key { | Self::AutoSave | Self::ShowOnFullscreen | Self::DimOnHover - | Self::RosterOrder => Group::Layout, + | Self::RosterOrder + | Self::MaxRows => Group::Layout, Self::Opacity | Self::AvatarOpacity | Self::TextOpacity @@ -492,6 +497,7 @@ impl Key { 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), @@ -516,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, } } @@ -661,6 +668,7 @@ impl Key { | Self::TextSize | Self::Spacing | Self::MaxName + | Self::MaxRows | Self::Opacity | Self::AvatarOpacity | Self::TextOpacity @@ -816,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") @@ -1730,6 +1745,40 @@ mod tests { 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/dev/code-layout.md b/docs/dev/code-layout.md index 0bd59f7..399059a 100644 --- a/docs/dev/code-layout.md +++ b/docs/dev/code-layout.md @@ -52,7 +52,7 @@ 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 | diff --git a/src/daemon/overlay/glyph.rs b/src/daemon/overlay/glyph.rs index e625c18..a38adbe 100644 --- a/src/daemon/overlay/glyph.rs +++ b/src/daemon/overlay/glyph.rs @@ -34,6 +34,10 @@ 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 { diff --git a/src/daemon/overlay/state.rs b/src/daemon/overlay/state.rs index ac4c5bb..30810e4 100644 --- a/src/daemon/overlay/state.rs +++ b/src/daemon/overlay/state.rs @@ -206,12 +206,34 @@ impl Overlay { // -- derived views ------------------------------------------------------ - /// Participants after applying the config filters (own user, talking) - /// and the configured roster order. 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(); } @@ -260,12 +282,16 @@ impl Overlay { } /// 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; @@ -505,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 1008cde..e06656e 100644 --- a/src/daemon/overlay/view.rs +++ b/src/daemon/overlay/view.rs @@ -52,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 @@ -174,6 +178,53 @@ fn alpha(mark: Mark, alphas: Alphas) -> 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 { + row![ + Space::new().width(Length::Fill), + chip, + Space::new().width(Length::Fixed(indent)) + ] + .into() + } else { + 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())) diff --git a/src/gui/fields.rs b/src/gui/fields.rs index ebead13..4f26712 100644 --- a/src/gui/fields.rs +++ b/src/gui/fields.rs @@ -251,6 +251,12 @@ 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", @@ -441,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) } @@ -920,6 +930,15 @@ mod tests { 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 From 19218290a69aee866cd14527bde900c7fd6cd95b Mon Sep 17 00:00:00 2001 From: FAZuH Date: Wed, 16 Sep 2026 14:37:02 +0700 Subject: [PATCH 5/5] docs: add release checklist and correct changelog-mode wording Co-Authored-By: OpenCode --- docs/agents/release-checklist.md | 63 ++++++++++++++++++++++++++++++++ docs/dev/commit-changelog.md | 32 +++++++++++----- 2 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 docs/agents/release-checklist.md 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/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