diff --git a/CONTEXT.md b/CONTEXT.md index fbdec789..8402d81f 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 surfaces 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 surfaces 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 0010c8f5..7d310066 100644 --- a/late-core/src/models/profile.rs +++ b/late-core/src/models/profile.rs @@ -5,11 +5,13 @@ 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_dashboard_wire, 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_dashboard_wire, extract_show_right_sidebar, + extract_show_room_list_sidebar, extract_show_settings_on_connect, extract_terminal, + extract_theme_id, extract_timezone, }; #[derive(Clone, Debug)] @@ -35,6 +37,11 @@ pub struct Profile { /// Controls the general-room dashboard wire strip. pub show_dashboard_wire: 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, @@ -63,6 +70,8 @@ impl Default for Profile { show_dashboard_header: true, show_dashboard_wire: 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(), @@ -89,6 +98,8 @@ pub struct ProfileParams { pub show_dashboard_header: bool, pub show_dashboard_wire: 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, @@ -105,7 +116,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_dashboard_wire/ - /// show_right_sidebar/show_room_list_sidebar/show_settings_on_connect into settings via + /// show_right_sidebar/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 { @@ -117,6 +129,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 @@ -172,17 +187,19 @@ 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, - 'show_dashboard_wire', $20::bool + '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, + 'show_dashboard_wire', $22::bool ), updated = current_timestamp - WHERE id = $21 + WHERE id = $23 RETURNING *", &[ ¶ms.username, @@ -197,6 +214,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, @@ -233,6 +252,8 @@ impl Profile { show_dashboard_header: extract_show_dashboard_header(&user.settings), show_dashboard_wire: extract_show_dashboard_wire(&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), @@ -240,6 +261,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 003768c3..ef8e03b3 100644 --- a/late-core/src/models/user.rs +++ b/late-core/src/models/user.rs @@ -49,6 +49,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 AUDIO_SOURCE_KEY: &str = "audio_source"; @@ -60,6 +91,8 @@ const ENABLE_BACKGROUND_COLOR_KEY: &str = "enable_background_color"; const SHOW_DASHBOARD_HEADER_KEY: &str = "show_dashboard_header"; const SHOW_DASHBOARD_WIRE_KEY: &str = "show_dashboard_wire"; 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"; @@ -510,12 +543,63 @@ pub fn extract_show_dashboard_wire(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) @@ -764,6 +848,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 bd459a7f..d2304550 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -640,7 +640,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 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 391d8e3e..94553e35 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -1526,7 +1526,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 f88b5c23..d5b09704 100644 --- a/late-ssh/src/app/profile/state.rs +++ b/late-ssh/src/app/profile/state.rs @@ -149,6 +149,8 @@ fn profile_params_from_profile(profile: &Profile) -> ProfileParams { show_dashboard_header: profile.show_dashboard_header, show_dashboard_wire: profile.show_dashboard_wire, 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 0ce5f1e4..e0ce5ed0 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, @@ -70,6 +71,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, @@ -228,8 +253,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 ee473ad4..0ac12da5 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::WireBox @@ -260,11 +266,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 f346e025..3fe30132 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}; @@ -231,6 +233,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, @@ -267,6 +271,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, @@ -298,6 +304,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); } @@ -394,6 +402,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 } @@ -1277,7 +1328,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 => { @@ -1353,6 +1406,8 @@ impl SettingsModalState { show_dashboard_header: self.draft.show_dashboard_header, show_dashboard_wire: self.draft.show_dashboard_wire, 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 4b355d33..2799381d 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); } @@ -498,7 +503,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], ); @@ -1356,6 +1361,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); @@ -1543,6 +1607,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 eb9a3aa2..51619090 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; @@ -707,6 +707,8 @@ async fn room_tail_task_loads_favorite_room_history() { show_dashboard_header: true, show_dashboard_wire: 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 f47d2f19..2e5426dd 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}; @@ -95,6 +95,8 @@ async fn edit_profile_emits_saved_event_and_refreshes_snapshot() { show_dashboard_header: false, show_dashboard_wire: 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(), @@ -163,6 +165,8 @@ async fn edit_profile_normalizes_username_before_persisting() { show_dashboard_header: true, show_dashboard_wire: 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(), @@ -224,6 +228,8 @@ async fn edit_profile_preserves_unrelated_settings_keys() { show_dashboard_header: true, show_dashboard_wire: 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(),