diff --git a/src/app/mod.rs b/src/app/mod.rs index 6c6ae09..b8f01c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -30,6 +30,55 @@ pub enum Screen { Settings, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsCategory { + Audio, + Recording, + Profile, + Appearance, + Network, + Notifications, +} + +impl SettingsCategory { + const ALL: [SettingsCategory; 6] = [ + SettingsCategory::Audio, + SettingsCategory::Recording, + SettingsCategory::Profile, + SettingsCategory::Appearance, + SettingsCategory::Network, + SettingsCategory::Notifications, + ]; + + fn label(self) -> &'static str { + match self { + SettingsCategory::Audio => "Audio", + SettingsCategory::Recording => "Recording", + SettingsCategory::Profile => "Profile", + SettingsCategory::Appearance => "Appearance", + SettingsCategory::Network => "Network", + SettingsCategory::Notifications => "Notifications", + } + } + + fn hint(self) -> &'static str { + match self { + SettingsCategory::Audio => "Devices, mic gate, echo", + SettingsCategory::Recording => "Mixed and stem capture", + SettingsCategory::Profile => "Avatar and identity", + SettingsCategory::Appearance => "Layout and theme", + SettingsCategory::Network => "Relay and privacy mode", + SettingsCategory::Notifications => "Chimes and sounds", + } + } +} + +impl std::fmt::Display for SettingsCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + /// One rendered room-chat line. `mine` distinguishes our own (locally echoed) /// messages from peers' for colouring. #[derive(Debug, Clone)] @@ -161,6 +210,7 @@ pub enum AppMessage { EventOccurred(Event), NavigateToSettings, NavigateBack, + SelectSettingsCategory(SettingsCategory), ToggleNotifications(bool), ToggleEchoCancellation(bool), CustomSoundPathChanged(Sound, String), @@ -276,6 +326,7 @@ pub struct AppState { ever_connected: HashSet, controller: Arc, current_screen: Screen, + settings_category: SettingsCategory, /// Whether we're currently sharing our own screen (confirmed by the core). self_sharing: bool, /// Whether the `pixelpass` binary is available, gating the Share controls. @@ -401,6 +452,7 @@ impl Default for AppState { ever_connected: HashSet::new(), controller, current_screen: Screen::Home, + settings_category: SettingsCategory::Audio, self_sharing: false, pixelpass_available, self_node_id: None, @@ -935,6 +987,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { crate::recents::remove_recent(&mut state.config.recents, &ticket); state.config.save(); } + AppMessage::SelectSettingsCategory(category) => { + state.settings_category = category; + } AppMessage::ToggleNotifications(enabled) => { state.config.notifications_enabled = enabled; state.config.save(); @@ -2118,9 +2173,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Presence + Friends moved to the home screen (see `friends_panel`). - let settings_content = scrollable( - column![ - // --- Audio Devices --- + let settings_body: Element<'_, AppMessage> = match state.settings_category { + SettingsCategory::Audio => column![ section_header("Audio Devices"), row![ column![ @@ -2149,8 +2203,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ].spacing(8).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), vertical_space(section_gap), - - // --- Microphone --- section_header("Microphone"), column![ mic_meter, @@ -2161,9 +2213,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_toggle(AppMessage::ToggleEchoCancellation), text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext), ].spacing(8).width(iced::Length::Fill), - vertical_space(section_gap), - - // --- Recording --- + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), + SettingsCategory::Recording => column![ section_header("Recording"), column![ mode_radio(RecordingMode::Mixed, "Mixed (single file)"), @@ -2172,22 +2226,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { vertical_space(2.0), text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext), ].spacing(8).width(iced::Length::Fill), + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), + SettingsCategory::Profile => column![ + section_header("Avatar"), + avatar_section, vertical_space(section_gap), - - // --- Network & Privacy --- - section_header("Network & Privacy"), - column![ - pick_list( - &NetworkMode::ALL[..], - Some(state.config.network_mode), - AppMessage::NetworkModeSelected, - ).width(iced::Length::Fill), - text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext), - text("Takes effect on your next room join.").size(11).color(color_subtext), - ].spacing(4).width(iced::Length::Fill), - vertical_space(section_gap), - - // --- Room Layout --- + section_header("Identity"), + identity_section, + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), + SettingsCategory::Appearance => column![ section_header("Room Layout"), column![ row![ @@ -2198,25 +2251,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext), ].spacing(8).width(iced::Length::Fill), vertical_space(section_gap), - - // --- Theme --- section_header("Theme"), theme_section, - vertical_space(section_gap), - - // --- Avatar --- - section_header("Avatar"), - avatar_section, - vertical_space(section_gap), - - // --- Identity --- - section_header("Identity"), - identity_section, - vertical_space(section_gap), - - // (Presence + Friends now live on the home screen.) - - // --- Notifications & Sounds --- + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), + SettingsCategory::Network => column![ + section_header("Network & Privacy"), + column![ + pick_list( + &NetworkMode::ALL[..], + Some(state.config.network_mode), + AppMessage::NetworkModeSelected, + ).width(iced::Length::Fill), + text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext), + text("Takes effect on your next room join.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), + SettingsCategory::Notifications => column![ section_header("Notifications & Sounds"), column![ checkbox(state.config.notifications_enabled) @@ -2244,9 +2300,92 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(10) .width(iced::Length::Fill) - ) - .width(iced::Length::Fill) - .height(iced::Length::Fill); + .into(), + }; + + let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> { + let selected = state.settings_category == category; + let label_color = if selected { color_blue } else { color_text }; + let border_color = if selected { color_blue } else { Color::TRANSPARENT }; + let bg = if selected { color_surface } else { Color::TRANSPARENT }; + button( + container( + column![ + text(category.label()).size(14).color(label_color), + text(category.hint()).size(11).color(color_subtext), + ] + .spacing(2) + .width(iced::Length::Fill), + ) + .width(iced::Length::Fill), + ) + .on_press(AppMessage::SelectSettingsCategory(category)) + .style(move |_theme: &Theme, status: button::Status| { + let active_bg = match status { + button::Status::Hovered if selected => color_surface, + button::Status::Hovered => color_crust, + _ => bg, + }; + button::Style { + background: Some(Background::Color(active_bg)), + text_color: label_color, + border: Border { + color: border_color, + width: if selected { 1.0 } else { 0.0 }, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .padding(10) + .width(iced::Length::Fill) + .into() + }; + + let mut settings_nav = column![ + text("SETTINGS").size(11).color(color_subtext), + ] + .spacing(8) + .width(iced::Length::Fill); + for category in SettingsCategory::ALL { + settings_nav = settings_nav.push(category_button(category)); + } + let settings_nav = container(settings_nav) + .padding(12) + .width(iced::Length::Fixed(220.0)) + .height(iced::Length::Fill) + .style(c_style(color_crust, color_surface, 8.0)); + + let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 { + scrollable( + column![ + text("Category").size(12).color(color_subtext), + pick_list( + &SettingsCategory::ALL[..], + Some(state.settings_category), + AppMessage::SelectSettingsCategory, + ).width(iced::Length::Fill), + vertical_space(10.0), + settings_body, + ] + .spacing(8) + .width(iced::Length::Fill), + ) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() + } else { + row![ + settings_nav, + scrollable(settings_body) + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ] + .spacing(16) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() + }; // Sticky header bar: stays fixed above the scrollable content so the Back // button is always reachable. The "Settings" title is centered by flanking @@ -4088,6 +4227,19 @@ mod tests { assert_eq!(format_duration(3661), "1:01:01"); assert_eq!(format_duration(3725), "1:02:05"); } + + #[test] + fn settings_categories_are_stable_and_grouped_for_navigation() { + use super::SettingsCategory; + let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect(); + assert_eq!( + labels, + vec!["Audio", "Recording", "Profile", "Appearance", "Network", "Notifications"] + ); + assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo"); + assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); + } + use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W}; #[test]