Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 15 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions crates/hyprlay-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -76,6 +115,8 @@ pub const AVATAR_SIZE: Bounds<u32> = Bounds { min: 16, max: 64 };
pub const TEXT_SIZE: Bounds<u32> = Bounds { min: 8, max: 32 };
pub const SPACING: Bounds<u32> = Bounds { min: 0, max: 24 };
pub const MAX_NAME: Bounds<usize> = Bounds { min: 4, max: 64 };
/// Roster height cap in rows; 0 means unlimited.
pub const MAX_ROWS: Bounds<u32> = Bounds { min: 0, max: 200 };
pub const OFFSETS: Bounds<i32> = Bounds {
min: -OFFSET_LIMIT,
max: OFFSET_LIMIT,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -361,6 +413,8 @@ struct LayoutTable {
max_name: Option<usize>,
talking_only: Option<bool>,
own_user: Option<bool>,
roster_order: Option<RosterOrder>,
max_rows: Option<u32>,
visible: Option<bool>,
auto_save: Option<bool>,
show_on_fullscreen: Option<bool>,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading