Merge codex-settings-category-nav: category-navigated Settings

Settings is split into navigable categories (left sidebar ≥820px wide,
pick_list dropdown below) instead of one long scroll. Integrated with the
wishlist branch's hotkey editor by giving it its own "Hotkeys" category
(7 categories total: Audio, Hotkeys, Recording, Profile, Appearance,
Network, Notifications).

Conflict resolution: the wishlist branch had inserted a Hotkeys section
into the old long-scroll between Microphone and Recording; relocated it
into a dedicated SettingsCategory::Hotkeys arm and updated the category
stability test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 01:32:17 -04:00
co-authored by Claude Opus 4.8
+202 -44
View File
@@ -32,6 +32,59 @@ pub enum Screen {
Settings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsCategory {
Audio,
Hotkeys,
Recording,
Profile,
Appearance,
Network,
Notifications,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 7] = [
SettingsCategory::Audio,
SettingsCategory::Hotkeys,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Notifications,
];
fn label(self) -> &'static str {
match self {
SettingsCategory::Audio => "Audio",
SettingsCategory::Hotkeys => "Hotkeys",
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::Hotkeys => "Focused keyboard shortcuts",
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)]
@@ -175,6 +228,7 @@ pub enum AppMessage {
EventOccurred(Event),
NavigateToSettings,
NavigateBack,
SelectSettingsCategory(SettingsCategory),
ToggleNotifications(bool),
ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String),
@@ -298,6 +352,7 @@ pub struct AppState {
ever_connected: HashSet<EndpointId>,
controller: Arc<CoreController>,
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.
@@ -435,6 +490,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,
@@ -1097,6 +1153,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
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();
@@ -2404,9 +2463,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![
@@ -2435,8 +2493,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,
@@ -2447,14 +2503,18 @@ 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),
// --- Hotkeys ---
]
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Hotkeys => column![
section_header("Hotkeys"),
hotkey_section,
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)"),
@@ -2463,22 +2523,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![
@@ -2489,25 +2548,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)
@@ -2535,9 +2597,92 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.spacing(10)
.width(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);
.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
@@ -4517,6 +4662,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", "Hotkeys", "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]