diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9a2b3..6ac29ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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) 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 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/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 97735ab..30810e4 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,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 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/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..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,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", @@ -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