//! T M10.9 — color + label palette for other-frontend cursor overlays. //! //! # Contract //! //! Each attached frontend gets one slot from a fixed palette. The //! palette has [`PALETTE_LEN`] entries, mapping slot index → distinct //! terminal color. Slots are assigned per Unix uid in the daemon //! ([`crate::daemon::DaemonState::color_slot_for_uid`]); same uid //! across reconnect → same slot (the M10.9 spec's "stable across //! reconnect within a session" criterion). //! //! # Wire-format note //! //! The slot index → color mapping is not on the wire. The daemon //! paints overlay cells directly into the recipient's grid using //! the resolved color; frontends just render the resulting //! `CellDelta`. Changing the palette is a daemon-only visual update, //! not a protocol change. use crate::cell::Color; use crate::protocol::FrontendId; /// Number of distinct slots in the palette. /// /// 8 colors covers the typical multi-frontend deployment (2–4 /// attached frontends) with headroom. Beyond [`PALETTE_LEN`] /// distinct uids, slot assignment wraps; two uids may share a /// color (the same shape as a hash collision). pub const PALETTE_LEN: usize = 8; /// 8-color palette. Indexed by slot (0..[`PALETTE_LEN`]). /// /// Chosen for visibility against typical terminal backgrounds /// (both light and dark themes). All entries are explicit Rgb /// tuples so the rendering is theme-independent — the palette /// doesn't rely on terminal palette overrides. pub const PALETTE: [Color; PALETTE_LEN] = [ Color::Rgb(0x00, 0xB7, 0xC3), // cyan Color::Rgb(0xC2, 0x4F, 0xC2), // magenta Color::Rgb(0x3A, 0xA0, 0x4F), // green Color::Rgb(0xD8, 0xA0, 0x10), // gold Color::Rgb(0x4A, 0x90, 0xE2), // blue Color::Rgb(0xE0, 0x50, 0x50), // red Color::Rgb(0xB0, 0xB0, 0xB0), // gray Color::Rgb(0xA8, 0x70, 0x30), // brown ]; /// Resolve a slot index to a `Color`. Wraps via modulo for /// safety, though the daemon should never produce out-of-range /// slots. #[must_use] pub fn color_for_slot(slot: u8) -> Color { PALETTE[(slot as usize) % PALETTE_LEN] } /// Label character for a `FrontendId`. /// /// Returns `Some('A'..'Z')` for FrontendId(2)..FrontendId(27) /// (daemon-attached frontends start at 2 because FrontendId(1) /// is reserved for the in-process TUI). Returns `None` for /// FrontendId(28) and beyond — labels are an aid, not a /// requirement, and the colored cursor cell still distinguishes /// the frontend. /// /// v0.2+ may add username-based labels when user-identity /// infrastructure lands. The v1.0 contract caps labels at 26 and /// degrades gracefully past that. #[must_use] pub fn label_for_frontend_id(fid: FrontendId) -> Option { let raw = fid.0; if (2..=27).contains(&raw) { // FrontendId(2) → 'A', FrontendId(3) → 'B', etc. let idx = u8::try_from(raw - 2).ok()?; Some((b'A' + idx) as char) } else { None } } #[cfg(test)] mod tests { use super::*; #[test] fn palette_has_documented_length() { assert_eq!(PALETTE.len(), PALETTE_LEN); } #[test] fn color_for_slot_returns_palette_entry() { for slot in 0..PALETTE_LEN as u8 { let color = color_for_slot(slot); assert_eq!(color, PALETTE[slot as usize]); } } #[test] fn color_for_slot_wraps_modulo() { assert_eq!(color_for_slot(0), color_for_slot(PALETTE_LEN as u8)); assert_eq!(color_for_slot(1), color_for_slot(PALETTE_LEN as u8 + 1)); } #[test] fn label_for_first_daemon_attached_is_a() { assert_eq!(label_for_frontend_id(FrontendId(2)), Some('A')); } #[test] fn label_for_z_boundary() { assert_eq!(label_for_frontend_id(FrontendId(27)), Some('Z')); } #[test] fn label_beyond_z_is_none() { assert_eq!(label_for_frontend_id(FrontendId(28)), None); assert_eq!(label_for_frontend_id(FrontendId(100)), None); } #[test] fn label_for_local_is_none() { // FrontendId(1) is LOCAL; never gets a label (it's the // in-process TUI, not a peer that needs to be distinguished). assert_eq!(label_for_frontend_id(FrontendId::LOCAL), None); } #[test] fn label_for_zero_is_none() { // Defensive — FrontendId(0) shouldn't exist but handle // gracefully. assert_eq!(label_for_frontend_id(FrontendId(0)), None); } }