From 61407d8763d91c29d557270a8da98260051209f1 Mon Sep 17 00:00:00 2001 From: Mike Clark Date: Mon, 11 May 2026 11:33:04 -0600 Subject: [PATCH 1/2] Add custom right sidebar settings - persist on/off/custom sidebar mode and per-screen visibility - add settings modal controls for custom sidebar screens - honor custom sidebar visibility in rendering and input layout --- CONTEXT.md | 9 +- late-core/src/models/profile.rs | 48 +++++++--- late-core/src/models/user.rs | 114 +++++++++++++++++++++++ late-ssh/src/app/help_modal/data.rs | 2 +- late-ssh/src/app/input.rs | 23 ++++- late-ssh/src/app/render.rs | 92 +++++++++++++++--- late-ssh/src/app/rooms/poker/ui.rs | 20 ++-- late-ssh/src/app/settings_modal/input.rs | 41 +++++++- late-ssh/src/app/settings_modal/state.rs | 59 +++++++++++- late-ssh/src/app/settings_modal/ui.rs | 90 +++++++++++++++++- late-ssh/tests/app_dashboard_flow.rs | 7 ++ late-ssh/tests/chat/svc.rs | 4 +- late-ssh/tests/profile/svc.rs | 8 +- 13 files changed, 469 insertions(+), 48 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 50e581fa..d54ee4b0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,7 +38,7 @@ This file is the primary working context for the entire late.sh project. The system is a Rust workspace with four crates (`late-cli`, `late-core`, `late-ssh`, `late-web`) backed by PostgreSQL, Icecast audio streaming, and Liquidsoap playlist management. - **Primary entry points:** SSH server (russh on port 2222), HTTP API (axum on port 4000), Web server (axum on port 3000) -- **Main responsibilities:** Multi-screen TUI over SSH (Dashboard, Chat, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent feeds, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable right-side panels: the global app sidebar (now playing, activity, visualizer, bonsai) plus the Arcade lobby leaderboard sidebar, both default-on. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it. +- **Main responsibilities:** Multi-screen TUI over SSH (Dashboard, Chat, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent feeds, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable right-side panels: the global app sidebar (now playing, activity, visualizer, bonsai) supports on/off/custom per-screen visibility, and the Arcade lobby leaderboard sidebar is separately default-on. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it. - **Highest-risk areas:** SSH render loop backpressure, connection limiting, chat sync consistency, paired-client WS routing/state drift --- @@ -1012,8 +1012,11 @@ Content invariants worth preserving when editing `data.rs`: | `j` / `k` / `↑` / `↓` | Terminal-help modal | Scroll the current tab | | `Esc` / `q` / `Ctrl+L` | Terminal-help modal | Close | | `↑` / `↓` / `j` / `k` | Settings modal | Move between rows (Username, IDE, Terminal, OS, Langs, Theme, Background, Right sidebar, Arcade sidebar, Country, Timezone, DMs, @mentions, Game events, Bell, Cooldown, Format) | -| `←` / `→` | Settings modal | Cycle the current row's setting (theme, toggles, cooldown, notification format) | -| `Space` / `Enter` / `e` | Settings modal | Activate row — edit username/system fields/bio, cycle a setting, or open the country/timezone picker | +| `←` / `→` | Settings modal | Cycle the current row's setting (theme, toggles, right sidebar on/off/custom, cooldown, notification format) | +| `Space` / `Enter` / `e` | Settings modal | Activate row — edit username/system fields/bio, cycle a setting, open the country/timezone picker, or open the right-sidebar custom modal when the row is set to custom | +| `↑` / `↓` / `j` / `k` | Right-sidebar custom modal | Move between Screen 1 through Screen 5 checkboxes | +| `Enter` / `e` | Right-sidebar custom modal | Toggle the selected screen's right sidebar visibility | +| `Esc` / `q` | Right-sidebar custom modal | Close | | `Alt+Enter` / `Ctrl+J` | Settings modal (bio editing) | Insert newline | | `?` | Settings modal | Open help modal on top | | `j` / `k` / `↑` / `↓` | Read-only profile modal | Scroll | diff --git a/late-core/src/models/profile.rs b/late-core/src/models/profile.rs index 0902e174..42f60064 100644 --- a/late-core/src/models/profile.rs +++ b/late-core/src/models/profile.rs @@ -5,9 +5,10 @@ use tokio_postgres::Client; use uuid::Uuid; use super::user::{ - User, extract_bio, extract_country, extract_enable_background_color, extract_favorite_room_ids, - extract_ide, extract_langs, extract_notify_bell, extract_notify_cooldown_mins, - extract_notify_format, extract_notify_kinds, extract_os, extract_show_arcade_sidebar, + RightSidebarMode, User, extract_bio, extract_country, extract_enable_background_color, + extract_favorite_room_ids, extract_ide, extract_langs, extract_notify_bell, + extract_notify_cooldown_mins, extract_notify_format, extract_notify_kinds, extract_os, + extract_right_sidebar_mode, extract_right_sidebar_screens, extract_show_arcade_sidebar, extract_show_dashboard_header, extract_show_right_sidebar, extract_show_settings_on_connect, extract_terminal, extract_theme_id, extract_timezone, }; @@ -32,6 +33,8 @@ pub struct Profile { pub enable_background_color: bool, pub show_dashboard_header: bool, pub show_right_sidebar: bool, + pub right_sidebar_mode: RightSidebarMode, + pub right_sidebar_screens: Vec, pub show_arcade_sidebar: bool, /// When false, the settings modal is not auto-opened on connect. pub show_settings_on_connect: bool, @@ -59,6 +62,8 @@ impl Default for Profile { enable_background_color: true, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=5).collect(), show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -84,6 +89,8 @@ pub struct ProfileParams { pub enable_background_color: bool, pub show_dashboard_header: bool, pub show_right_sidebar: bool, + pub right_sidebar_mode: RightSidebarMode, + pub right_sidebar_screens: Vec, pub show_arcade_sidebar: bool, pub show_settings_on_connect: bool, pub favorite_room_ids: Vec, @@ -112,6 +119,9 @@ impl Profile { .map(Uuid::to_string) .collect::>(), )?; + let right_sidebar_screens_json = serde_json::to_value(normalize_right_sidebar_screens( + ¶ms.right_sidebar_screens, + ))?; let cooldown = params.notify_cooldown_mins.max(0); let bio = params.bio.trim().to_string(); let country = params @@ -167,16 +177,18 @@ impl Profile { 'notify_format', $10::text, 'show_dashboard_header', $11::bool, 'show_right_sidebar', $12::bool, - 'show_arcade_sidebar', $13::bool, - 'show_settings_on_connect', $14::bool, - 'favorite_room_ids', $15::jsonb, - 'ide', $16::text, - 'terminal', $17::text, - 'os', $18::text, - 'langs', $19::jsonb + 'right_sidebar_mode', $13::text, + 'right_sidebar_screens', $14::jsonb, + 'show_arcade_sidebar', $15::bool, + 'show_settings_on_connect', $16::bool, + 'favorite_room_ids', $17::jsonb, + 'ide', $18::text, + 'terminal', $19::text, + 'os', $20::text, + 'langs', $21::jsonb ), updated = current_timestamp - WHERE id = $20 + WHERE id = $22 RETURNING *", &[ ¶ms.username, @@ -191,6 +203,8 @@ impl Profile { ¬ify_format, ¶ms.show_dashboard_header, ¶ms.show_right_sidebar, + ¶ms.right_sidebar_mode.as_str(), + &right_sidebar_screens_json, ¶ms.show_arcade_sidebar, ¶ms.show_settings_on_connect, &favorite_room_ids_json, @@ -225,6 +239,8 @@ impl Profile { enable_background_color: extract_enable_background_color(&user.settings), show_dashboard_header: extract_show_dashboard_header(&user.settings), show_right_sidebar: extract_show_right_sidebar(&user.settings), + right_sidebar_mode: extract_right_sidebar_mode(&user.settings), + right_sidebar_screens: extract_right_sidebar_screens(&user.settings), show_arcade_sidebar: extract_show_arcade_sidebar(&user.settings), show_settings_on_connect: extract_show_settings_on_connect(&user.settings), favorite_room_ids: extract_favorite_room_ids(&user.settings), @@ -232,6 +248,16 @@ impl Profile { } } +fn normalize_right_sidebar_screens(screens: &[u8]) -> Vec { + let mut seen = BTreeSet::new(); + for screen in screens { + if (1..=5).contains(screen) { + seen.insert(*screen); + } + } + seen.into_iter().collect() +} + fn normalize_profile_text(value: Option<&str>) -> Option { value .map(str::trim) diff --git a/late-core/src/models/user.rs b/late-core/src/models/user.rs index 8df0333c..1a1fe1e1 100644 --- a/late-core/src/models/user.rs +++ b/late-core/src/models/user.rs @@ -24,6 +24,34 @@ crate::model! { pub const USERNAME_MAX_LEN: usize = 32; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RightSidebarMode { + On, + Off, + Custom, +} + +impl RightSidebarMode { + pub fn as_str(self) -> &'static str { + match self { + Self::On => "on", + Self::Off => "off", + Self::Custom => "custom", + } + } + + pub fn cycle(self, forward: bool) -> Self { + match (self, forward) { + (Self::On, true) => Self::Off, + (Self::Off, true) => Self::Custom, + (Self::Custom, true) => Self::On, + (Self::On, false) => Self::Custom, + (Self::Off, false) => Self::On, + (Self::Custom, false) => Self::Off, + } + } +} + const IGNORED_USER_IDS_KEY: &str = "ignored_user_ids"; const THEME_ID_KEY: &str = "theme_id"; const NOTIFY_KINDS_KEY: &str = "notify_kinds"; @@ -33,6 +61,8 @@ const NOTIFY_FORMAT_KEY: &str = "notify_format"; const ENABLE_BACKGROUND_COLOR_KEY: &str = "enable_background_color"; const SHOW_DASHBOARD_HEADER_KEY: &str = "show_dashboard_header"; const SHOW_RIGHT_SIDEBAR_KEY: &str = "show_right_sidebar"; +const RIGHT_SIDEBAR_MODE_KEY: &str = "right_sidebar_mode"; +const RIGHT_SIDEBAR_SCREENS_KEY: &str = "right_sidebar_screens"; const SHOW_ARCADE_SIDEBAR_KEY: &str = "show_arcade_sidebar"; const LEGACY_SHOW_GAMES_SIDEBAR_KEY: &str = "show_games_sidebar"; const SHOW_SETTINGS_ON_CONNECT_KEY: &str = "show_settings_on_connect"; @@ -442,12 +472,57 @@ pub fn extract_show_dashboard_header(settings: &Value) -> bool { } pub fn extract_show_right_sidebar(settings: &Value) -> bool { + match settings + .get(RIGHT_SIDEBAR_MODE_KEY) + .and_then(Value::as_str) + .map(str::trim) + { + Some("on" | "custom") => return true, + Some("off") => return false, + _ => {} + } + settings .get(SHOW_RIGHT_SIDEBAR_KEY) .and_then(Value::as_bool) .unwrap_or(true) } +pub fn extract_right_sidebar_mode(settings: &Value) -> RightSidebarMode { + match settings + .get(RIGHT_SIDEBAR_MODE_KEY) + .and_then(Value::as_str) + .map(str::trim) + { + Some("on") => RightSidebarMode::On, + Some("off") => RightSidebarMode::Off, + Some("custom") => RightSidebarMode::Custom, + _ if extract_show_right_sidebar(settings) => RightSidebarMode::On, + _ => RightSidebarMode::Off, + } +} + +pub fn extract_right_sidebar_screens(settings: &Value) -> Vec { + let Some(values) = settings + .get(RIGHT_SIDEBAR_SCREENS_KEY) + .and_then(Value::as_array) + else { + return (1..=5).collect(); + }; + + let mut screens = BTreeSet::new(); + for value in values { + let Some(raw) = value.as_u64() else { + continue; + }; + if (1..=5).contains(&raw) { + screens.insert(raw as u8); + } + } + + screens.into_iter().collect() +} + pub fn extract_show_arcade_sidebar(settings: &Value) -> bool { settings .get(SHOW_ARCADE_SIDEBAR_KEY) @@ -679,6 +754,45 @@ mod tests { assert!(!extract_show_right_sidebar(&settings)); } + #[test] + fn extract_show_right_sidebar_prefers_new_mode() { + let settings = json!({ + "show_right_sidebar": true, + "right_sidebar_mode": "off", + }); + assert!(!extract_show_right_sidebar(&settings)); + } + + #[test] + fn extract_right_sidebar_mode_reads_custom() { + let settings = json!({ "right_sidebar_mode": "custom" }); + assert_eq!( + extract_right_sidebar_mode(&settings), + RightSidebarMode::Custom + ); + } + + #[test] + fn extract_right_sidebar_mode_falls_back_to_legacy_bool() { + let settings = json!({ "show_right_sidebar": false }); + assert_eq!(extract_right_sidebar_mode(&settings), RightSidebarMode::Off); + } + + #[test] + fn extract_right_sidebar_screens_defaults_to_all_screens() { + let settings = json!({}); + assert_eq!( + extract_right_sidebar_screens(&settings), + vec![1, 2, 3, 4, 5] + ); + } + + #[test] + fn extract_right_sidebar_screens_dedupes_and_drops_invalid_values() { + let settings = json!({ "right_sidebar_screens": [3, 1, 3, 9, "2"] }); + assert_eq!(extract_right_sidebar_screens(&settings), vec![1, 3]); + } + #[test] fn extract_show_arcade_sidebar_defaults_to_true() { let settings = json!({}); diff --git a/late-ssh/src/app/help_modal/data.rs b/late-ssh/src/app/help_modal/data.rs index c673a09d..df239cbc 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -649,7 +649,7 @@ fn settings_help_lines() -> Vec { " country via picker, with Unicode flag rendering".to_string(), " timezone via picker".to_string(), " IDE, terminal, OS, and languages for profile/late.fetch surfaces".to_string(), - " stream/vote-header visibility and right-sidebar visibility".to_string(), + " stream/vote-header visibility and right-sidebar mode (on/off/custom)".to_string(), " favorite rooms (dashboard quick-switch strip)".to_string(), " private RSS/Atom feed subscriptions".to_string(), "".to_string(), diff --git a/late-ssh/src/app/input.rs b/late-ssh/src/app/input.rs index f621c8e1..7d8526fc 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::app::common::primitives::Screen; use crate::app::common::readline::ctrl_byte_to_input; +use late_core::models::{profile::Profile, user::RightSidebarMode}; use ratatui::{ layout::{Constraint, Layout, Rect}, widgets::{Block, Borders}, @@ -1495,13 +1496,33 @@ fn handle_notifications_hud_click(app: &mut App, mouse: MouseEvent) -> bool { fn app_content_area(app: &App) -> Rect { let area = Rect::new(0, 0, app.size.0, app.size.1); let inner = Block::default().borders(Borders::ALL).inner(area); - if app.profile_state.profile().show_right_sidebar { + if profile_right_sidebar_enabled(app.profile_state.profile(), app.screen) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(24)]).split(inner)[0] } else { inner } } +fn profile_right_sidebar_enabled(profile: &Profile, screen: Screen) -> bool { + match profile.right_sidebar_mode { + RightSidebarMode::On => true, + RightSidebarMode::Off => false, + RightSidebarMode::Custom => profile + .right_sidebar_screens + .contains(&screen_number(screen)), + } +} + +fn screen_number(screen: Screen) -> u8 { + match screen { + Screen::Dashboard => 1, + Screen::Chat => 2, + Screen::Arcade => 3, + Screen::Rooms => 4, + Screen::Artboard => 5, + } +} + fn mouse_scroll_delta(mouse: MouseEvent) -> Option { match mouse.kind { MouseEventKind::ScrollUp => Some(1), diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index 136f28cf..0d29ca99 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Context; use late_core::MutexRecover; use late_core::api_types::NowPlaying; +use late_core::models::{profile::Profile, user::RightSidebarMode}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -62,11 +63,19 @@ fn desktop_notification_bytes( } } -fn sidebar_enabled(show_settings: bool, draft_enabled: bool, profile_enabled: bool) -> bool { - if show_settings { - draft_enabled - } else { - profile_enabled +fn sidebar_enabled( + show_settings: bool, + draft: &Profile, + profile: &Profile, + screen: Screen, +) -> bool { + let active = if show_settings { draft } else { profile }; + match active.right_sidebar_mode { + RightSidebarMode::On => true, + RightSidebarMode::Off => false, + RightSidebarMode::Custom => active + .right_sidebar_screens + .contains(&screen_number(screen)), } } @@ -90,6 +99,16 @@ fn dashboard_header_enabled( } } +fn screen_number(screen: Screen) -> u8 { + match screen { + Screen::Dashboard => 1, + Screen::Chat => 2, + Screen::Arcade => 3, + Screen::Rooms => 4, + Screen::Artboard => 5, + } +} + fn dashboard_daily_statuses( completion: &DailyCompletionStatus, ) -> [dashboard::ui::DashboardDailyStatus; 4] { @@ -228,10 +247,12 @@ impl App { } let area = Rect::new(0, 0, self.size.0, self.size.1); + let screen = self.screen; let show_right_sidebar = sidebar_enabled( self.show_settings, - self.settings_modal_state.draft().show_right_sidebar, - self.profile_state.profile().show_right_sidebar, + self.settings_modal_state.draft(), + self.profile_state.profile(), + screen, ); let show_dashboard_header = dashboard_header_enabled( self.show_settings, @@ -243,7 +264,6 @@ impl App { self.settings_modal_state.draft().show_arcade_sidebar, self.profile_state.profile().show_arcade_sidebar, ); - let screen = self.screen; let now_playing: Option = self .now_playing_rx .as_mut() @@ -1062,6 +1082,8 @@ mod tests { NotificationMode, arcade_sidebar_enabled, desktop_notification_bytes, mentions_hud_title, sidebar_enabled, }; + use crate::app::common::primitives::Screen; + use late_core::models::{profile::Profile, user::RightSidebarMode}; #[test] fn desktop_notification_bytes_both_mode_with_bell_emits_osc_777_and_osc_9() { @@ -1119,14 +1141,60 @@ mod tests { #[test] fn sidebar_enabled_prefers_settings_draft_while_modal_is_open() { - assert!(!sidebar_enabled(true, false, true)); - assert!(sidebar_enabled(true, true, false)); + let draft = Profile { + right_sidebar_mode: RightSidebarMode::Off, + ..Profile::default() + }; + let profile = Profile::default(); + assert!(!sidebar_enabled(true, &draft, &profile, Screen::Dashboard)); + + let draft = Profile { + right_sidebar_mode: RightSidebarMode::On, + ..Profile::default() + }; + let profile = Profile { + right_sidebar_mode: RightSidebarMode::Off, + ..Profile::default() + }; + assert!(sidebar_enabled(true, &draft, &profile, Screen::Dashboard)); } #[test] fn sidebar_enabled_uses_saved_profile_when_modal_is_closed() { - assert!(sidebar_enabled(false, false, true)); - assert!(!sidebar_enabled(false, true, false)); + let draft = Profile { + right_sidebar_mode: RightSidebarMode::Off, + ..Profile::default() + }; + let profile = Profile::default(); + assert!(sidebar_enabled(false, &draft, &profile, Screen::Dashboard)); + + let draft = Profile::default(); + let profile = Profile { + right_sidebar_mode: RightSidebarMode::Off, + ..Profile::default() + }; + assert!(!sidebar_enabled(false, &draft, &profile, Screen::Dashboard)); + } + + #[test] + fn sidebar_enabled_honors_custom_screen_list() { + let profile = Profile { + right_sidebar_mode: RightSidebarMode::Custom, + right_sidebar_screens: vec![2, 5], + ..Profile::default() + }; + assert!(sidebar_enabled( + false, + &Profile::default(), + &profile, + Screen::Chat + )); + assert!(!sidebar_enabled( + false, + &Profile::default(), + &profile, + Screen::Dashboard + )); } #[test] diff --git a/late-ssh/src/app/rooms/poker/ui.rs b/late-ssh/src/app/rooms/poker/ui.rs index 94f92de0..c084b35a 100644 --- a/late-ssh/src/app/rooms/poker/ui.rs +++ b/late-ssh/src/app/rooms/poker/ui.rs @@ -904,18 +904,16 @@ fn key_line(state: &State, snapshot: &PokerPublicSnapshot) -> Line<'static> { "", ) } + } else if state.can_raise() { + key_hint( + &format!( + "C check · B bet {} · A all-in · {auto_hint} · F fold · [/] bet", + state.selected_raise().max(state.min_raise()) + ), + "", + ) } else { - if state.can_raise() { - key_hint( - &format!( - "C check · B bet {} · A all-in · {auto_hint} · F fold · [/] bet", - state.selected_raise().max(state.min_raise()) - ), - "", - ) - } else { - key_hint(&format!("C check · {auto_hint} · F fold"), "") - } + key_hint(&format!("C check · {auto_hint} · F fold"), "") } } PokerPhase::PreFlop | PokerPhase::Flop | PokerPhase::Turn | PokerPhase::River => key_hint( diff --git a/late-ssh/src/app/settings_modal/input.rs b/late-ssh/src/app/settings_modal/input.rs index ee83ea53..6e616cbe 100644 --- a/late-ssh/src/app/settings_modal/input.rs +++ b/late-ssh/src/app/settings_modal/input.rs @@ -1,5 +1,6 @@ use crate::app::input::{MouseButton, MouseEventKind, ParsedInput, sanitize_paste_markers}; use crate::app::state::App; +use late_core::models::user::RightSidebarMode; use super::gem::GemKey; use super::state::{PickerKind, Row, Tab}; @@ -16,6 +17,11 @@ pub fn handle_input(app: &mut App, event: ParsedInput) { return; } + if app.settings_modal_state.right_sidebar_custom_open() { + handle_right_sidebar_custom_input(app, event); + return; + } + if app.settings_modal_state.editing_username() { handle_username_input(app, event); return; @@ -95,8 +101,9 @@ pub fn handle_input(app: &mut App, event: ParsedInput) { | ParsedInput::Arrow(b'A') => app.settings_modal_state.move_row(-1), ParsedInput::Arrow(b'C') => app.settings_modal_state.cycle_setting(true), ParsedInput::Arrow(b'D') => app.settings_modal_state.cycle_setting(false), - ParsedInput::Byte(b' ') | ParsedInput::Byte(b'\r') => activate_selected_row(app), - ParsedInput::Char('e') | ParsedInput::Char('E') => activate_selected_row(app), + ParsedInput::Byte(b' ') => activate_selected_row(app, false), + ParsedInput::Byte(b'\r') => activate_selected_row(app, true), + ParsedInput::Char('e') | ParsedInput::Char('E') => activate_selected_row(app, true), _ => {} } } @@ -277,7 +284,7 @@ fn is_close_event(event: &ParsedInput) -> bool { ) } -fn activate_selected_row(app: &mut App) { +fn activate_selected_row(app: &mut App, open_custom_sidebar: bool) { match app.settings_modal_state.selected_row() { Row::Username => app.settings_modal_state.start_username_edit(), Row::Ide | Row::Terminal | Row::Os | Row::Langs => { @@ -290,18 +297,44 @@ fn activate_selected_row(app: &mut App) { Row::Theme | Row::BackgroundColor | Row::DashboardHeader - | Row::RightSidebar | Row::DirectMessages | Row::Mentions | Row::GameEvents | Row::Bell | Row::Cooldown | Row::NotifyFormat => app.settings_modal_state.cycle_setting(true), + Row::RightSidebar => { + if open_custom_sidebar + && app.settings_modal_state.draft().right_sidebar_mode == RightSidebarMode::Custom + { + app.settings_modal_state.open_right_sidebar_custom(); + } else { + app.settings_modal_state.cycle_setting(true); + } + } Row::Country => app.settings_modal_state.open_picker(PickerKind::Country), Row::Timezone => app.settings_modal_state.open_picker(PickerKind::Timezone), } } +fn handle_right_sidebar_custom_input(app: &mut App, event: ParsedInput) { + match event { + ParsedInput::Byte(0x1B | b'q' | b'Q') | ParsedInput::Char('q' | 'Q') => { + app.settings_modal_state.close_right_sidebar_custom(); + } + ParsedInput::Byte(b'j' | b'J') + | ParsedInput::Char('j' | 'J') + | ParsedInput::Arrow(b'B') => app.settings_modal_state.move_right_sidebar_custom(1), + ParsedInput::Byte(b'k' | b'K') + | ParsedInput::Char('k' | 'K') + | ParsedInput::Arrow(b'A') => app.settings_modal_state.move_right_sidebar_custom(-1), + ParsedInput::Byte(b' ' | b'\r') | ParsedInput::Char('e' | 'E') => app + .settings_modal_state + .toggle_right_sidebar_custom_screen(), + _ => {} + } +} + fn handle_system_input(app: &mut App, event: ParsedInput) { let state = &mut app.settings_modal_state; match event { diff --git a/late-ssh/src/app/settings_modal/state.rs b/late-ssh/src/app/settings_modal/state.rs index 2ac8090a..6212dad5 100644 --- a/late-ssh/src/app/settings_modal/state.rs +++ b/late-ssh/src/app/settings_modal/state.rs @@ -2,7 +2,7 @@ use std::cell::Cell; use late_core::models::profile::{Profile, ProfileParams, normalize_profile_tags}; use late_core::models::rss_feed::RssFeed; -use late_core::models::user::sanitize_username_input; +use late_core::models::user::{RightSidebarMode, sanitize_username_input}; use ratatui::style::{Modifier, Style}; use ratatui_textarea::{CursorMove, TextArea, WrapMode}; use tokio::sync::{broadcast, watch}; @@ -196,6 +196,8 @@ pub struct DeleteAccountDialogState { pending: bool, } +const RIGHT_SIDEBAR_SCREEN_COUNT: usize = 5; + impl DeleteAccountDialogState { fn new() -> Self { Self { @@ -249,6 +251,8 @@ pub struct SettingsModalState { /// the final slot (favorites.len()) selects the "Add favorite…" row. favorites_index: usize, delete_account: DeleteAccountDialogState, + right_sidebar_custom_open: bool, + right_sidebar_custom_index: usize, feeds: Vec, feed_index: usize, editing_feed_url: bool, @@ -287,6 +291,8 @@ impl SettingsModalState { available_rooms: Vec::new(), favorites_index: 0, delete_account: DeleteAccountDialogState::new(), + right_sidebar_custom_open: false, + right_sidebar_custom_index: 0, feeds: Vec::new(), feed_index: 0, editing_feed_url: false, @@ -326,6 +332,8 @@ impl SettingsModalState { self.picker = PickerState::default(); self.favorites_index = 0; self.delete_account = DeleteAccountDialogState::new(); + self.right_sidebar_custom_open = false; + self.right_sidebar_custom_index = 0; self.feed_service.list_task(self.user_id); } @@ -422,6 +430,49 @@ impl SettingsModalState { Row::ALL[self.row_index] } + pub fn right_sidebar_custom_open(&self) -> bool { + self.right_sidebar_custom_open + } + + pub fn open_right_sidebar_custom(&mut self) { + self.right_sidebar_custom_open = true; + self.right_sidebar_custom_index = 0; + } + + pub fn close_right_sidebar_custom(&mut self) { + self.right_sidebar_custom_open = false; + } + + pub fn right_sidebar_custom_index(&self) -> usize { + self.right_sidebar_custom_index + } + + pub fn move_right_sidebar_custom(&mut self, delta: isize) { + let last = RIGHT_SIDEBAR_SCREEN_COUNT.saturating_sub(1) as isize; + self.right_sidebar_custom_index = + (self.right_sidebar_custom_index as isize + delta).clamp(0, last) as usize; + } + + pub fn right_sidebar_screen_enabled(&self, screen_number: u8) -> bool { + self.draft.right_sidebar_screens.contains(&screen_number) + } + + pub fn toggle_right_sidebar_custom_screen(&mut self) { + let screen_number = (self.right_sidebar_custom_index + 1) as u8; + if let Some(index) = self + .draft + .right_sidebar_screens + .iter() + .position(|screen| *screen == screen_number) + { + self.draft.right_sidebar_screens.remove(index); + } else { + self.draft.right_sidebar_screens.push(screen_number); + self.draft.right_sidebar_screens.sort_unstable(); + } + self.save(); + } + pub fn delete_account_dialog(&self) -> &DeleteAccountDialogState { &self.delete_account } @@ -1400,7 +1451,9 @@ impl SettingsModalState { true } Row::RightSidebar => { - self.draft.show_right_sidebar ^= true; + self.draft.right_sidebar_mode = self.draft.right_sidebar_mode.cycle(forward); + self.draft.show_right_sidebar = + self.draft.right_sidebar_mode != RightSidebarMode::Off; true } Row::DirectMessages => { @@ -1463,6 +1516,8 @@ impl SettingsModalState { enable_background_color: self.draft.enable_background_color, show_dashboard_header: self.draft.show_dashboard_header, show_right_sidebar: self.draft.show_right_sidebar, + right_sidebar_mode: self.draft.right_sidebar_mode, + right_sidebar_screens: self.draft.right_sidebar_screens.clone(), show_arcade_sidebar: self.draft.show_arcade_sidebar, show_settings_on_connect: self.draft.show_settings_on_connect, favorite_room_ids: self.draft.favorite_room_ids.clone(), diff --git a/late-ssh/src/app/settings_modal/ui.rs b/late-ssh/src/app/settings_modal/ui.rs index 7a7ab188..cc3f9e51 100644 --- a/late-ssh/src/app/settings_modal/ui.rs +++ b/late-ssh/src/app/settings_modal/ui.rs @@ -6,6 +6,8 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; +use late_core::models::user::RightSidebarMode; + use crate::app::common::{markdown::render_body_to_lines, theme}; use super::{ @@ -59,6 +61,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &SettingsModalState) { if state.picker_open() { draw_picker(frame, popup, state); } + if state.right_sidebar_custom_open() { + draw_right_sidebar_custom_dialog(frame, popup, state); + } if state.delete_account_dialog().open() { draw_delete_account_dialog(frame, popup, state); } @@ -523,7 +528,7 @@ fn draw_settings_tab(frame: &mut Frame, area: Rect, state: &SettingsModalState) Row::RightSidebar, width, "Right sidebar", - toggle_span(state.draft().show_right_sidebar), + right_sidebar_mode_span(state.draft().right_sidebar_mode), )), sections[11], ); @@ -1477,6 +1482,68 @@ fn draw_picker(frame: &mut Frame, area: Rect, state: &SettingsModalState) { frame.render_widget(Paragraph::new(footer), layout[3]); } +fn draw_right_sidebar_custom_dialog(frame: &mut Frame, area: Rect, state: &SettingsModalState) { + let popup = centered_rect(42, 12, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .title(" Right Sidebar ") + .title_style( + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER_ACTIVE())); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let layout = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(inner); + + let width = inner.width as usize; + for screen_number in 1..=5 { + let selected = state.right_sidebar_custom_index() == screen_number - 1; + let checked = state.right_sidebar_screen_enabled(screen_number as u8); + let marker = if selected { ">" } else { " " }; + let checkbox = if checked { "[x]" } else { "[ ]" }; + let text = format!(" {marker} {checkbox} Screen {screen_number}"); + let style = if selected { + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT()) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + pad_to_width(&text, width, selected), + style, + ))), + layout[screen_number - 1], + ); + } + + let footer = Line::from(vec![ + Span::raw(" "), + Span::styled("Enter", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" toggle ", Style::default().fg(theme::TEXT_DIM())), + Span::styled("Esc", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" close", Style::default().fg(theme::TEXT_DIM())), + ]); + frame.render_widget(Paragraph::new(footer), layout[7]); +} + fn draw_delete_account_dialog(frame: &mut Frame, area: Rect, state: &SettingsModalState) { let popup = centered_rect(64, 12, area); frame.render_widget(Clear, popup); @@ -1664,6 +1731,27 @@ fn toggle_span(enabled: bool) -> ValueSpan { } } +fn right_sidebar_mode_span(mode: RightSidebarMode) -> ValueSpan { + match mode { + RightSidebarMode::On => ValueSpan { + text: "● on".to_string(), + style: Style::default() + .fg(theme::SUCCESS()) + .add_modifier(Modifier::BOLD), + }, + RightSidebarMode::Off => ValueSpan { + text: "○ off".to_string(), + style: Style::default().fg(theme::TEXT_FAINT()), + }, + RightSidebarMode::Custom => ValueSpan { + text: "◐ custom ... ⏎".to_string(), + style: Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + }, + } +} + fn value_with_picker_hint(text: String) -> ValueSpan { ValueSpan { text: format!("{text} …"), diff --git a/late-ssh/tests/app_dashboard_flow.rs b/late-ssh/tests/app_dashboard_flow.rs index dc242d53..202ecc66 100644 --- a/late-ssh/tests/app_dashboard_flow.rs +++ b/late-ssh/tests/app_dashboard_flow.rs @@ -11,6 +11,7 @@ use late_core::models::{ chat_room::ChatRoom, chat_room_member::ChatRoomMember, profile::{Profile, ProfileParams}, + user::RightSidebarMode, vote::Vote, }; use late_core::test_utils::create_test_user; @@ -286,6 +287,8 @@ async fn dashboard_lazy_primes_favorite_histories_without_opening_chat() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: vec![alpha.id, beta.id], @@ -358,6 +361,8 @@ async fn dashboard_switching_to_favorite_clears_strip_unread_count() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: vec![alpha.id, beta.id], @@ -463,6 +468,8 @@ async fn dashboard_favorites_strip_is_mouse_clickable() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: vec![alpha.id, beta.id], diff --git a/late-ssh/tests/chat/svc.rs b/late-ssh/tests/chat/svc.rs index 54ac4583..f258d27a 100644 --- a/late-ssh/tests/chat/svc.rs +++ b/late-ssh/tests/chat/svc.rs @@ -9,7 +9,7 @@ use late_core::models::{ profile::{Profile, ProfileParams}, room_ban::RoomBan, server_ban::ServerBan, - user::User, + user::{RightSidebarMode, User}, }; use late_ssh::app::artboard::provenance::ArtboardProvenance; use late_ssh::app::chat::notifications::svc::NotificationService; @@ -704,6 +704,8 @@ async fn room_tail_task_loads_favorite_room_history() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: vec![favorite_room.id], diff --git a/late-ssh/tests/profile/svc.rs b/late-ssh/tests/profile/svc.rs index 2ecb3247..59c98820 100644 --- a/late-ssh/tests/profile/svc.rs +++ b/late-ssh/tests/profile/svc.rs @@ -9,7 +9,7 @@ use late_core::models::{ chat_room::ChatRoom, profile::{Profile, ProfileParams}, server_ban::ServerBan, - user::{User, UserParams}, + user::{RightSidebarMode, User, UserParams}, }; use late_core::test_utils::create_test_user; use late_ssh::app::profile::svc::{ProfileEvent, ProfileService}; @@ -94,6 +94,8 @@ async fn edit_profile_emits_saved_event_and_refreshes_snapshot() { enable_background_color: false, show_dashboard_header: false, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -160,6 +162,8 @@ async fn edit_profile_normalizes_username_before_persisting() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -220,6 +224,8 @@ async fn edit_profile_preserves_unrelated_settings_keys() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: vec![1, 2, 3, 4, 5], show_arcade_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), From 59adb8074c6f6b8568e1484d2463a4035c5950ee Mon Sep 17 00:00:00 2001 From: Mike Clark Date: Wed, 13 May 2026 14:27:50 -0600 Subject: [PATCH 2/2] custom right sidebar visibility settings --- CONTEXT.md | 2 +- late-core/src/models/profile.rs | 58 ++++++++--- late-core/src/models/user.rs | 123 +++++++++++++++++++++++ late-ssh/src/app/help_modal/data.rs | 3 +- late-ssh/src/app/input.rs | 7 +- late-ssh/src/app/profile/state.rs | 2 + late-ssh/src/app/render.rs | 37 ++++++- late-ssh/src/app/settings_modal/input.rs | 41 +++++++- late-ssh/src/app/settings_modal/state.rs | 59 ++++++++++- late-ssh/src/app/settings_modal/ui.rs | 87 +++++++++++++++- late-ssh/tests/chat/svc.rs | 4 +- late-ssh/tests/profile/svc.rs | 8 +- 12 files changed, 403 insertions(+), 28 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 49184fa5..efc11bda 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,7 +38,7 @@ This file is the primary working context for the entire late.sh project. The system is a Rust workspace with four crates (`late-cli`, `late-core`, `late-ssh`, `late-web`) backed by PostgreSQL, Icecast audio streaming, and Liquidsoap playlist management. - **Primary entry points:** SSH server (russh on port 2222), HTTP API (axum on port 4000), Web server (axum on port 3000) -- **Main responsibilities:** Multi-screen TUI over SSH (Home/Dashboard, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent feeds inside Home, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable Home layout surfaces: the global right sidebar (time, visualizer, hot rooms, bonsai), the Home room-list rail, and the general-room lounge info strip (top boxes plus wire); `v` then `v` cycles persisted combinations of those panels. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it. +- **Main responsibilities:** Multi-screen TUI over SSH (Home/Dashboard, The Arcade, Rooms, Artboard), public web frontend, genre voting, paired browser/CLI audio control plus visualizer, real-time chat and chat-adjacent feeds inside Home, private per-user RSS/Atom inboxes that can be shared into News, link/YouTube sharing with AI summaries/ASCII thumbnails, Arcade games, persistent game-backed Rooms, a shared multi-user ASCII Artboard, a global Hub modal for leaderboard/dailies/shop/events surfaces, and one structured global Activity stream for user actions. Detailed CLI behavior lives in `late-cli/CONTEXT.md`; detailed Web behavior lives in `late-web/CONTEXT.md`; detailed Arcade behavior lives in `late-ssh/src/app/arcade/CONTEXT.md`; detailed Rooms/Blackjack behavior lives in `late-ssh/src/app/rooms/CONTEXT.md`; detailed Chat behavior lives in `late-ssh/src/app/chat/CONTEXT.md`; detailed Artboard/dartboard behavior lives in `late-ssh/src/app/artboard/CONTEXT.md`. Configurable Home layout surfaces: the global right sidebar (time, visualizer, hot rooms, bonsai) with on/off/custom per-screen visibility, the Home room-list rail, and the general-room lounge info strip (top boxes plus wire); `v` then `v` cycles persisted combinations of those panels. Global `q` opens quit confirm; pressing `q` again exits and `Esc` dismisses it. - **Highest-risk areas:** SSH render loop backpressure, connection limiting, chat sync consistency, paired-client WS routing/state drift --- diff --git a/late-core/src/models/profile.rs b/late-core/src/models/profile.rs index 74a200b8..fed3dad3 100644 --- a/late-core/src/models/profile.rs +++ b/late-core/src/models/profile.rs @@ -5,11 +5,12 @@ use tokio_postgres::Client; use uuid::Uuid; use super::user::{ - User, extract_bio, extract_country, extract_enable_background_color, extract_favorite_room_ids, - extract_ide, extract_langs, extract_notify_bell, extract_notify_cooldown_mins, - extract_notify_format, extract_notify_kinds, extract_os, extract_show_dashboard_header, - extract_show_right_sidebar, extract_show_room_list_sidebar, extract_show_settings_on_connect, - extract_terminal, extract_theme_id, extract_timezone, + RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode, User, extract_bio, extract_country, + extract_enable_background_color, extract_favorite_room_ids, extract_ide, extract_langs, + extract_notify_bell, extract_notify_cooldown_mins, extract_notify_format, extract_notify_kinds, + extract_os, extract_right_sidebar_mode, extract_right_sidebar_screens, + extract_show_dashboard_header, extract_show_right_sidebar, extract_show_room_list_sidebar, + extract_show_settings_on_connect, extract_terminal, extract_theme_id, extract_timezone, }; #[derive(Clone, Debug)] @@ -33,6 +34,11 @@ pub struct Profile { /// Controls the general-room lounge chrome: top info boxes plus wire strip. pub show_dashboard_header: bool, pub show_right_sidebar: bool, + pub right_sidebar_mode: RightSidebarMode, + /// Per-screen visibility when `right_sidebar_mode == Custom`. Each entry is + /// a 1-based screen index in `1..=RIGHT_SIDEBAR_SCREEN_COUNT` + /// (Dashboard=1, Arcade=2, Rooms=3, Artboard=4). + pub right_sidebar_screens: Vec, pub show_room_list_sidebar: bool, /// When false, the settings modal is not auto-opened on connect. pub show_settings_on_connect: bool, @@ -60,6 +66,8 @@ impl Default for Profile { enable_background_color: true, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(), show_room_list_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -85,6 +93,8 @@ pub struct ProfileParams { pub enable_background_color: bool, pub show_dashboard_header: bool, pub show_right_sidebar: bool, + pub right_sidebar_mode: RightSidebarMode, + pub right_sidebar_screens: Vec, pub show_room_list_sidebar: bool, pub show_settings_on_connect: bool, pub favorite_room_ids: Vec, @@ -101,7 +111,8 @@ impl Profile { /// Atomic partial update — merges /// bio/country/timezone/theme_id/notify_kinds/notify_bell/notify_cooldown_mins/ /// enable_background_color/show_dashboard_header/show_right_sidebar/ - /// show_room_list_sidebar/show_settings_on_connect into settings via + /// right_sidebar_mode/right_sidebar_screens/show_room_list_sidebar/ + /// show_settings_on_connect into settings via /// `settings || jsonb_build_object(...)`, so concurrent writes to unrelated keys /// (ignored_user_ids) are preserved. pub async fn update(client: &Client, user_id: Uuid, params: ProfileParams) -> Result { @@ -113,6 +124,9 @@ impl Profile { .map(Uuid::to_string) .collect::>(), )?; + let right_sidebar_screens_json = serde_json::to_value(normalize_right_sidebar_screens( + ¶ms.right_sidebar_screens, + ))?; let cooldown = params.notify_cooldown_mins.max(0); let bio = params.bio.trim().to_string(); let country = params @@ -168,16 +182,18 @@ impl Profile { 'notify_format', $10::text, 'show_dashboard_header', $11::bool, 'show_right_sidebar', $12::bool, - 'show_room_list_sidebar', $13::bool, - 'show_settings_on_connect', $14::bool, - 'favorite_room_ids', $15::jsonb, - 'ide', $16::text, - 'terminal', $17::text, - 'os', $18::text, - 'langs', $19::jsonb + 'right_sidebar_mode', $13::text, + 'right_sidebar_screens', $14::jsonb, + 'show_room_list_sidebar', $15::bool, + 'show_settings_on_connect', $16::bool, + 'favorite_room_ids', $17::jsonb, + 'ide', $18::text, + 'terminal', $19::text, + 'os', $20::text, + 'langs', $21::jsonb ), updated = current_timestamp - WHERE id = $20 + WHERE id = $22 RETURNING *", &[ ¶ms.username, @@ -192,6 +208,8 @@ impl Profile { ¬ify_format, ¶ms.show_dashboard_header, ¶ms.show_right_sidebar, + ¶ms.right_sidebar_mode.as_str(), + &right_sidebar_screens_json, ¶ms.show_room_list_sidebar, ¶ms.show_settings_on_connect, &favorite_room_ids_json, @@ -226,6 +244,8 @@ impl Profile { enable_background_color: extract_enable_background_color(&user.settings), show_dashboard_header: extract_show_dashboard_header(&user.settings), show_right_sidebar: extract_show_right_sidebar(&user.settings), + right_sidebar_mode: extract_right_sidebar_mode(&user.settings), + right_sidebar_screens: extract_right_sidebar_screens(&user.settings), show_room_list_sidebar: extract_show_room_list_sidebar(&user.settings), show_settings_on_connect: extract_show_settings_on_connect(&user.settings), favorite_room_ids: extract_favorite_room_ids(&user.settings), @@ -233,6 +253,16 @@ impl Profile { } } +fn normalize_right_sidebar_screens(screens: &[u8]) -> Vec { + let mut seen = BTreeSet::new(); + for screen in screens { + if (1..=RIGHT_SIDEBAR_SCREEN_COUNT).contains(screen) { + seen.insert(*screen); + } + } + seen.into_iter().collect() +} + fn normalize_profile_text(value: Option<&str>) -> Option { value .map(str::trim) diff --git a/late-core/src/models/user.rs b/late-core/src/models/user.rs index 5840cf27..d115cd4f 100644 --- a/late-core/src/models/user.rs +++ b/late-core/src/models/user.rs @@ -24,6 +24,37 @@ crate::model! { pub const USERNAME_MAX_LEN: usize = 32; +/// Number of top-level screens (Dashboard, Arcade, Rooms, Artboard). +pub const RIGHT_SIDEBAR_SCREEN_COUNT: u8 = 4; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RightSidebarMode { + On, + Off, + Custom, +} + +impl RightSidebarMode { + pub fn as_str(self) -> &'static str { + match self { + Self::On => "on", + Self::Off => "off", + Self::Custom => "custom", + } + } + + pub fn cycle(self, forward: bool) -> Self { + match (self, forward) { + (Self::On, true) => Self::Off, + (Self::Off, true) => Self::Custom, + (Self::Custom, true) => Self::On, + (Self::On, false) => Self::Custom, + (Self::Off, false) => Self::On, + (Self::Custom, false) => Self::Off, + } + } +} + const IGNORED_USER_IDS_KEY: &str = "ignored_user_ids"; const THEME_ID_KEY: &str = "theme_id"; const NOTIFY_KINDS_KEY: &str = "notify_kinds"; @@ -33,6 +64,8 @@ const NOTIFY_FORMAT_KEY: &str = "notify_format"; const ENABLE_BACKGROUND_COLOR_KEY: &str = "enable_background_color"; const SHOW_DASHBOARD_HEADER_KEY: &str = "show_dashboard_header"; const SHOW_RIGHT_SIDEBAR_KEY: &str = "show_right_sidebar"; +const RIGHT_SIDEBAR_MODE_KEY: &str = "right_sidebar_mode"; +const RIGHT_SIDEBAR_SCREENS_KEY: &str = "right_sidebar_screens"; const SHOW_ROOM_LIST_SIDEBAR_KEY: &str = "show_room_list_sidebar"; const SHOW_SETTINGS_ON_CONNECT_KEY: &str = "show_settings_on_connect"; const FAVORITE_ROOM_IDS_KEY: &str = "favorite_room_ids"; @@ -441,12 +474,63 @@ pub fn extract_show_dashboard_header(settings: &Value) -> bool { } pub fn extract_show_right_sidebar(settings: &Value) -> bool { + match settings + .get(RIGHT_SIDEBAR_MODE_KEY) + .and_then(Value::as_str) + .map(str::trim) + { + Some("on" | "custom") => return true, + Some("off") => return false, + _ => {} + } + settings .get(SHOW_RIGHT_SIDEBAR_KEY) .and_then(Value::as_bool) .unwrap_or(true) } +pub fn extract_right_sidebar_mode(settings: &Value) -> RightSidebarMode { + match settings + .get(RIGHT_SIDEBAR_MODE_KEY) + .and_then(Value::as_str) + .map(str::trim) + { + Some("on") => RightSidebarMode::On, + Some("off") => RightSidebarMode::Off, + Some("custom") => RightSidebarMode::Custom, + _ if settings + .get(SHOW_RIGHT_SIDEBAR_KEY) + .and_then(Value::as_bool) + .unwrap_or(true) => + { + RightSidebarMode::On + } + _ => RightSidebarMode::Off, + } +} + +pub fn extract_right_sidebar_screens(settings: &Value) -> Vec { + let Some(values) = settings + .get(RIGHT_SIDEBAR_SCREENS_KEY) + .and_then(Value::as_array) + else { + return (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(); + }; + + let mut screens = BTreeSet::new(); + for value in values { + let Some(raw) = value.as_u64() else { + continue; + }; + if (1..=u64::from(RIGHT_SIDEBAR_SCREEN_COUNT)).contains(&raw) { + screens.insert(raw as u8); + } + } + + screens.into_iter().collect() +} + pub fn extract_show_room_list_sidebar(settings: &Value) -> bool { settings .get(SHOW_ROOM_LIST_SIDEBAR_KEY) @@ -677,6 +761,45 @@ mod tests { assert!(!extract_show_right_sidebar(&settings)); } + #[test] + fn extract_show_right_sidebar_prefers_new_mode() { + let settings = json!({ + "show_right_sidebar": true, + "right_sidebar_mode": "off", + }); + assert!(!extract_show_right_sidebar(&settings)); + } + + #[test] + fn extract_right_sidebar_mode_reads_custom() { + let settings = json!({ "right_sidebar_mode": "custom" }); + assert_eq!( + extract_right_sidebar_mode(&settings), + RightSidebarMode::Custom + ); + } + + #[test] + fn extract_right_sidebar_mode_falls_back_to_legacy_bool() { + let settings = json!({ "show_right_sidebar": false }); + assert_eq!(extract_right_sidebar_mode(&settings), RightSidebarMode::Off); + } + + #[test] + fn extract_right_sidebar_screens_defaults_to_all_screens() { + let settings = json!({}); + assert_eq!( + extract_right_sidebar_screens(&settings), + (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect::>() + ); + } + + #[test] + fn extract_right_sidebar_screens_dedupes_and_drops_invalid_values() { + let settings = json!({ "right_sidebar_screens": [3, 1, 3, 9, "2"] }); + assert_eq!(extract_right_sidebar_screens(&settings), vec![1, 3]); + } + #[test] fn extract_show_room_list_sidebar_defaults_to_true() { let settings = json!({}); diff --git a/late-ssh/src/app/help_modal/data.rs b/late-ssh/src/app/help_modal/data.rs index 84541ea5..724ffb32 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -639,7 +639,8 @@ fn settings_help_lines() -> Vec { " country via picker, with Unicode flag rendering".to_string(), " timezone via picker".to_string(), " IDE, terminal, OS, and languages for profile/late.fetch surfaces".to_string(), - " background color, right sidebar, room list, and lounge info visibility".to_string(), + " background color, room list, and lounge info visibility".to_string(), + " right sidebar mode (on/off/custom) with per-screen visibility".to_string(), " private RSS/Atom feed subscriptions".to_string(), "".to_string(), "How to open it".to_string(), diff --git a/late-ssh/src/app/input.rs b/late-ssh/src/app/input.rs index a538af6f..9a0304b0 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -1518,7 +1518,12 @@ fn handle_notifications_hud_click(app: &mut App, mouse: MouseEvent) -> bool { fn app_content_area(app: &App) -> Rect { let area = Rect::new(0, 0, app.size.0, app.size.1); let inner = Block::default().borders(Borders::ALL).inner(area); - if app.profile_state.profile().show_right_sidebar { + let profile = app.profile_state.profile(); + if crate::app::render::resolve_right_sidebar_enabled( + profile.right_sidebar_mode, + &profile.right_sidebar_screens, + app.screen, + ) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(24)]).split(inner)[0] } else { inner diff --git a/late-ssh/src/app/profile/state.rs b/late-ssh/src/app/profile/state.rs index 6c6b1e0d..bab7c349 100644 --- a/late-ssh/src/app/profile/state.rs +++ b/late-ssh/src/app/profile/state.rs @@ -148,6 +148,8 @@ fn profile_params_from_profile(profile: &Profile) -> ProfileParams { enable_background_color: profile.enable_background_color, show_dashboard_header: profile.show_dashboard_header, show_right_sidebar: profile.show_right_sidebar, + right_sidebar_mode: profile.right_sidebar_mode, + right_sidebar_screens: profile.right_sidebar_screens.clone(), show_room_list_sidebar: profile.show_room_list_sidebar, show_settings_on_connect: profile.show_settings_on_connect, favorite_room_ids: profile.favorite_room_ids.clone(), diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index 3ca8ac01..52f64c1c 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -12,6 +12,7 @@ use ratatui::{ }; use late_core::models::leaderboard::LeaderboardData; +use late_core::models::user::RightSidebarMode; use super::{ artboard, bonsai, chat, @@ -69,6 +70,30 @@ fn sidebar_enabled(show_settings: bool, draft_enabled: bool, profile_enabled: bo } } +/// Map a top-level screen to its 1-based slot in `right_sidebar_screens`. +pub(crate) fn screen_number(screen: Screen) -> u8 { + match screen { + Screen::Dashboard => 1, + Screen::Arcade => 2, + Screen::Rooms => 3, + Screen::Artboard => 4, + } +} + +/// Resolve whether the right sidebar should render on `screen` given a profile +/// (or draft) sidebar mode and per-screen visibility set. +pub(crate) fn resolve_right_sidebar_enabled( + mode: RightSidebarMode, + screens: &[u8], + screen: Screen, +) -> bool { + match mode { + RightSidebarMode::On => true, + RightSidebarMode::Off => false, + RightSidebarMode::Custom => screens.contains(&screen_number(screen)), + } +} + fn room_list_sidebar_enabled( show_settings: bool, draft_enabled: bool, @@ -214,8 +239,16 @@ impl App { let area = Rect::new(0, 0, self.size.0, self.size.1); let show_right_sidebar = sidebar_enabled( self.show_settings, - self.settings_modal_state.draft().show_right_sidebar, - self.profile_state.profile().show_right_sidebar, + resolve_right_sidebar_enabled( + self.settings_modal_state.draft().right_sidebar_mode, + &self.settings_modal_state.draft().right_sidebar_screens, + self.screen, + ), + resolve_right_sidebar_enabled( + self.profile_state.profile().right_sidebar_mode, + &self.profile_state.profile().right_sidebar_screens, + self.screen, + ), ); let show_room_list_sidebar = room_list_sidebar_enabled( self.show_settings, diff --git a/late-ssh/src/app/settings_modal/input.rs b/late-ssh/src/app/settings_modal/input.rs index d55fb588..6f8bf793 100644 --- a/late-ssh/src/app/settings_modal/input.rs +++ b/late-ssh/src/app/settings_modal/input.rs @@ -1,5 +1,6 @@ use crate::app::input::{MouseButton, MouseEventKind, ParsedInput, sanitize_paste_markers}; use crate::app::state::App; +use late_core::models::user::RightSidebarMode; use super::gem::GemKey; use super::state::{PickerKind, Row, Tab}; @@ -11,6 +12,11 @@ pub fn handle_input(app: &mut App, event: ParsedInput) { return; } + if app.settings_modal_state.right_sidebar_custom_open() { + handle_right_sidebar_custom_input(app, event); + return; + } + if app.settings_modal_state.picker_open() { handle_picker_input(app, event); return; @@ -90,8 +96,9 @@ pub fn handle_input(app: &mut App, event: ParsedInput) { | ParsedInput::Arrow(b'A') => app.settings_modal_state.move_row(-1), ParsedInput::Arrow(b'C') => app.settings_modal_state.cycle_setting(true), ParsedInput::Arrow(b'D') => app.settings_modal_state.cycle_setting(false), - ParsedInput::Byte(b' ') | ParsedInput::Byte(b'\r') => activate_selected_row(app), - ParsedInput::Char('e') | ParsedInput::Char('E') => activate_selected_row(app), + ParsedInput::Byte(b' ') => activate_selected_row(app, false), + ParsedInput::Byte(b'\r') => activate_selected_row(app, true), + ParsedInput::Char('e') | ParsedInput::Char('E') => activate_selected_row(app, true), _ => {} } } @@ -238,7 +245,7 @@ fn is_close_event(event: &ParsedInput) -> bool { ) } -fn activate_selected_row(app: &mut App) { +fn activate_selected_row(app: &mut App, open_custom_sidebar: bool) { match app.settings_modal_state.selected_row() { Row::Username => app.settings_modal_state.start_username_edit(), Row::Ide | Row::Terminal | Row::Os | Row::Langs => { @@ -250,7 +257,6 @@ fn activate_selected_row(app: &mut App) { } Row::Theme | Row::BackgroundColor - | Row::RightSidebar | Row::RoomListSidebar | Row::LoungeInfo | Row::DirectMessages @@ -259,11 +265,38 @@ fn activate_selected_row(app: &mut App) { | Row::Bell | Row::Cooldown | Row::NotifyFormat => app.settings_modal_state.cycle_setting(true), + Row::RightSidebar => { + if open_custom_sidebar + && app.settings_modal_state.draft().right_sidebar_mode == RightSidebarMode::Custom + { + app.settings_modal_state.open_right_sidebar_custom(); + } else { + app.settings_modal_state.cycle_setting(true); + } + } Row::Country => app.settings_modal_state.open_picker(PickerKind::Country), Row::Timezone => app.settings_modal_state.open_picker(PickerKind::Timezone), } } +fn handle_right_sidebar_custom_input(app: &mut App, event: ParsedInput) { + match event { + ParsedInput::Byte(0x1B | b'q' | b'Q') | ParsedInput::Char('q' | 'Q') => { + app.settings_modal_state.close_right_sidebar_custom(); + } + ParsedInput::Byte(b'j' | b'J') + | ParsedInput::Char('j' | 'J') + | ParsedInput::Arrow(b'B') => app.settings_modal_state.move_right_sidebar_custom(1), + ParsedInput::Byte(b'k' | b'K') + | ParsedInput::Char('k' | 'K') + | ParsedInput::Arrow(b'A') => app.settings_modal_state.move_right_sidebar_custom(-1), + ParsedInput::Byte(b' ' | b'\r') | ParsedInput::Char('e' | 'E') => app + .settings_modal_state + .toggle_right_sidebar_custom_screen(), + _ => {} + } +} + fn handle_system_input(app: &mut App, event: ParsedInput) { let state = &mut app.settings_modal_state; match event { diff --git a/late-ssh/src/app/settings_modal/state.rs b/late-ssh/src/app/settings_modal/state.rs index 4e0703f4..0d0f3107 100644 --- a/late-ssh/src/app/settings_modal/state.rs +++ b/late-ssh/src/app/settings_modal/state.rs @@ -2,7 +2,9 @@ use std::cell::Cell; use late_core::models::profile::{Profile, ProfileParams, normalize_profile_tags}; use late_core::models::rss_feed::RssFeed; -use late_core::models::user::sanitize_username_input; +use late_core::models::user::{ + RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode, sanitize_username_input, +}; use ratatui::style::{Modifier, Style}; use ratatui_textarea::{CursorMove, TextArea, WrapMode}; use tokio::sync::{broadcast, watch}; @@ -229,6 +231,8 @@ pub struct SettingsModalState { bio_input: TextArea<'static>, picker: PickerState, delete_account: DeleteAccountDialogState, + right_sidebar_custom_open: bool, + right_sidebar_custom_index: usize, feeds: Vec, feed_index: usize, editing_feed_url: bool, @@ -265,6 +269,8 @@ impl SettingsModalState { bio_input: new_bio_textarea(false), picker: PickerState::default(), delete_account: DeleteAccountDialogState::new(), + right_sidebar_custom_open: false, + right_sidebar_custom_index: 0, feeds: Vec::new(), feed_index: 0, editing_feed_url: false, @@ -296,6 +302,8 @@ impl SettingsModalState { self.bio_input = bio_textarea_for_readonly_text(&self.draft.bio); self.picker = PickerState::default(); self.delete_account = DeleteAccountDialogState::new(); + self.right_sidebar_custom_open = false; + self.right_sidebar_custom_index = 0; self.feed_service.list_task(self.user_id); } @@ -392,6 +400,49 @@ impl SettingsModalState { Row::ALL[self.row_index] } + pub fn right_sidebar_custom_open(&self) -> bool { + self.right_sidebar_custom_open + } + + pub fn open_right_sidebar_custom(&mut self) { + self.right_sidebar_custom_open = true; + self.right_sidebar_custom_index = 0; + } + + pub fn close_right_sidebar_custom(&mut self) { + self.right_sidebar_custom_open = false; + } + + pub fn right_sidebar_custom_index(&self) -> usize { + self.right_sidebar_custom_index + } + + pub fn move_right_sidebar_custom(&mut self, delta: isize) { + let last = (RIGHT_SIDEBAR_SCREEN_COUNT as isize - 1).max(0); + self.right_sidebar_custom_index = + (self.right_sidebar_custom_index as isize + delta).clamp(0, last) as usize; + } + + pub fn right_sidebar_screen_enabled(&self, screen_number: u8) -> bool { + self.draft.right_sidebar_screens.contains(&screen_number) + } + + pub fn toggle_right_sidebar_custom_screen(&mut self) { + let screen_number = (self.right_sidebar_custom_index + 1) as u8; + if let Some(index) = self + .draft + .right_sidebar_screens + .iter() + .position(|screen| *screen == screen_number) + { + self.draft.right_sidebar_screens.remove(index); + } else { + self.draft.right_sidebar_screens.push(screen_number); + self.draft.right_sidebar_screens.sort_unstable(); + } + self.save(); + } + pub fn delete_account_dialog(&self) -> &DeleteAccountDialogState { &self.delete_account } @@ -1267,7 +1318,9 @@ impl SettingsModalState { true } Row::RightSidebar => { - self.draft.show_right_sidebar ^= true; + self.draft.right_sidebar_mode = self.draft.right_sidebar_mode.cycle(forward); + self.draft.show_right_sidebar = + self.draft.right_sidebar_mode != RightSidebarMode::Off; true } Row::RoomListSidebar => { @@ -1338,6 +1391,8 @@ impl SettingsModalState { enable_background_color: self.draft.enable_background_color, show_dashboard_header: self.draft.show_dashboard_header, show_right_sidebar: self.draft.show_right_sidebar, + right_sidebar_mode: self.draft.right_sidebar_mode, + right_sidebar_screens: self.draft.right_sidebar_screens.clone(), show_room_list_sidebar: self.draft.show_room_list_sidebar, show_settings_on_connect: self.draft.show_settings_on_connect, favorite_room_ids: self.draft.favorite_room_ids.clone(), diff --git a/late-ssh/src/app/settings_modal/ui.rs b/late-ssh/src/app/settings_modal/ui.rs index 948213d4..971c9d80 100644 --- a/late-ssh/src/app/settings_modal/ui.rs +++ b/late-ssh/src/app/settings_modal/ui.rs @@ -6,6 +6,8 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; +use late_core::models::user::{RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode}; + use crate::app::common::{markdown::render_body_to_lines, theme}; use super::{ @@ -58,6 +60,9 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &SettingsModalState) { if state.picker_open() { draw_picker(frame, popup, state); } + if state.right_sidebar_custom_open() { + draw_right_sidebar_custom_dialog(frame, popup, state); + } if state.delete_account_dialog().open() { draw_delete_account_dialog(frame, popup, state); } @@ -497,7 +502,7 @@ fn draw_settings_tab(frame: &mut Frame, area: Rect, state: &SettingsModalState) Row::RightSidebar, width, "Right sidebar", - toggle_span(state.draft().show_right_sidebar), + right_sidebar_mode_span(state.draft().right_sidebar_mode), )), sections[10], ); @@ -1345,6 +1350,65 @@ fn draw_picker(frame: &mut Frame, area: Rect, state: &SettingsModalState) { frame.render_widget(Paragraph::new(footer), layout[3]); } +fn draw_right_sidebar_custom_dialog(frame: &mut Frame, area: Rect, state: &SettingsModalState) { + let count = RIGHT_SIDEBAR_SCREEN_COUNT as u16; + let popup = centered_rect(42, count + 5, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .title(" Right Sidebar ") + .title_style( + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER_ACTIVE())); + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let mut constraints = vec![Constraint::Length(1); count as usize]; + constraints.push(Constraint::Min(0)); + constraints.push(Constraint::Length(1)); + let layout = Layout::vertical(constraints).split(inner); + + const SCREEN_LABELS: [&str; RIGHT_SIDEBAR_SCREEN_COUNT as usize] = + ["Home", "Arcade", "Rooms", "Artboard"]; + + let width = inner.width as usize; + for screen_idx in 0..RIGHT_SIDEBAR_SCREEN_COUNT as usize { + let selected = state.right_sidebar_custom_index() == screen_idx; + let checked = state.right_sidebar_screen_enabled((screen_idx + 1) as u8); + let marker = if selected { ">" } else { " " }; + let checkbox = if checked { "[x]" } else { "[ ]" }; + let text = format!(" {marker} {checkbox} {}", SCREEN_LABELS[screen_idx]); + let style = if selected { + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT()) + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + pad_to_width(&text, width, selected), + style, + ))), + layout[screen_idx], + ); + } + + let footer = Line::from(vec![ + Span::raw(" "), + Span::styled("Enter", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" toggle ", Style::default().fg(theme::TEXT_DIM())), + Span::styled("Esc", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" close", Style::default().fg(theme::TEXT_DIM())), + ]); + frame.render_widget(Paragraph::new(footer), layout[layout.len() - 1]); +} + fn draw_delete_account_dialog(frame: &mut Frame, area: Rect, state: &SettingsModalState) { let popup = centered_rect(64, 12, area); frame.render_widget(Clear, popup); @@ -1532,6 +1596,27 @@ fn toggle_span(enabled: bool) -> ValueSpan { } } +fn right_sidebar_mode_span(mode: RightSidebarMode) -> ValueSpan { + match mode { + RightSidebarMode::On => ValueSpan { + text: "● on".to_string(), + style: Style::default() + .fg(theme::SUCCESS()) + .add_modifier(Modifier::BOLD), + }, + RightSidebarMode::Off => ValueSpan { + text: "○ off".to_string(), + style: Style::default().fg(theme::TEXT_FAINT()), + }, + RightSidebarMode::Custom => ValueSpan { + text: "◐ custom … ⏎".to_string(), + style: Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + }, + } +} + fn value_with_picker_hint(text: String) -> ValueSpan { ValueSpan { text: format!("{text} …"), diff --git a/late-ssh/tests/chat/svc.rs b/late-ssh/tests/chat/svc.rs index 00e44950..fc5dc148 100644 --- a/late-ssh/tests/chat/svc.rs +++ b/late-ssh/tests/chat/svc.rs @@ -9,7 +9,7 @@ use late_core::models::{ profile::{Profile, ProfileParams}, room_ban::RoomBan, server_ban::ServerBan, - user::User, + user::{RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode, User}, }; use late_ssh::app::artboard::provenance::ArtboardProvenance; use late_ssh::app::chat::notifications::svc::NotificationService; @@ -706,6 +706,8 @@ async fn room_tail_task_loads_favorite_room_history() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(), show_room_list_sidebar: true, show_settings_on_connect: true, favorite_room_ids: vec![favorite_room.id], diff --git a/late-ssh/tests/profile/svc.rs b/late-ssh/tests/profile/svc.rs index 331d063e..db8fbf73 100644 --- a/late-ssh/tests/profile/svc.rs +++ b/late-ssh/tests/profile/svc.rs @@ -9,7 +9,7 @@ use late_core::models::{ chat_room::ChatRoom, profile::{Profile, ProfileParams}, server_ban::ServerBan, - user::{User, UserParams}, + user::{RIGHT_SIDEBAR_SCREEN_COUNT, RightSidebarMode, User, UserParams}, }; use late_core::test_utils::create_test_user; use late_ssh::app::profile::svc::{ProfileEvent, ProfileService}; @@ -94,6 +94,8 @@ async fn edit_profile_emits_saved_event_and_refreshes_snapshot() { enable_background_color: false, show_dashboard_header: false, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(), show_room_list_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -160,6 +162,8 @@ async fn edit_profile_normalizes_username_before_persisting() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(), show_room_list_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(), @@ -220,6 +224,8 @@ async fn edit_profile_preserves_unrelated_settings_keys() { enable_background_color: false, show_dashboard_header: true, show_right_sidebar: true, + right_sidebar_mode: RightSidebarMode::On, + right_sidebar_screens: (1..=RIGHT_SIDEBAR_SCREEN_COUNT).collect(), show_room_list_sidebar: true, show_settings_on_connect: true, favorite_room_ids: Vec::new(),