bos/bos-settings/src/ui/sidebar.rs
Breadway 9c2c95089c bos-settings: single-source-of-truth default page
sidebar.rs and window.rs each independently hardcoded "about" as the
default selected/visible page — harmless while both literals happened to
match, but a real latent bug: changing one without the other silently
desyncs the sidebar highlight from the actually-displayed panel. Found
while capturing screenshots of every panel for a design review, where
exactly that happened. window.rs now owns DEFAULT_PAGE and passes it to
sidebar::build().
2026-07-04 00:00:10 +08:00

136 lines
5.2 KiB
Rust

use gtk4::prelude::*;
use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation};
pub struct SidebarItem {
/// Must match the `Stack` page name registered in `window.rs`.
pub id: &'static str,
pub label: &'static str,
/// Dim second line — the underlying binary/config name, for items whose
/// human label doesn't already make that obvious.
pub sublabel: Option<&'static str>,
/// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships
/// the full Adwaita-compatible symbolic set this app relies on).
pub icon: &'static str,
}
const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem {
SidebarItem { id, label, sublabel: None, icon }
}
const fn item_sub(
id: &'static str,
label: &'static str,
sublabel: &'static str,
icon: &'static str,
) -> SidebarItem {
SidebarItem { id, label, sublabel: Some(sublabel), icon }
}
// Grouped by task, not by "app vs system internals" — a user thinks "I want
// to change my Wi-Fi" or "I want to change my wallpaper", not "which of
// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of
// the old "Apps" bucket into System for the same reason.
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
item("network", "Network", "network-wireless-symbolic"),
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"),
item("sound", "Sound", "audio-volume-high-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
];
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"),
item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"),
item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"),
item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"),
item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"),
item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"),
item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"),
];
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
item("packages", "Packages", "package-x-generic-symbolic"),
item("snapshots", "Snapshots", "document-open-recent-symbolic"),
];
pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")];
/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s
/// initial visible child — previously these were two independent hardcoded
/// "about" literals in different files with no link between them, so
/// changing one without the other silently desynced the sidebar highlight
/// from the actually-displayed page.
pub fn build(default_id: &str) -> (GBox, ListBox) {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("sidebar");
vbox.set_width_request(210);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("sidebar");
append_section(&list, "System", SYSTEM_ITEMS);
append_section(&list, "Personalization", PERSONALIZATION_ITEMS);
append_section(&list, "Maintenance", MAINTENANCE_ITEMS);
append_section(&list, None, ABOUT_ITEMS);
let mut i = 0;
loop {
match list.row_at_index(i) {
None => break,
Some(row) if row.widget_name() == default_id => {
list.select_row(Some(&row));
break;
}
_ => i += 1,
}
}
vbox.append(&list);
(vbox, list)
}
fn append_section(list: &ListBox, title: impl Into<Option<&'static str>>, items: &[SidebarItem]) {
if let Some(title) = title.into() {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(title));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
}
for entry in items {
let row = ListBoxRow::new();
row.set_widget_name(entry.id);
let hbox = GBox::new(Orientation::Horizontal, 10);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
let icon = Image::from_icon_name(entry.icon);
icon.set_pixel_size(16);
hbox.append(&icon);
let labels = GBox::new(Orientation::Vertical, 0);
let lbl = Label::new(Some(entry.label));
lbl.set_xalign(0.0);
labels.append(&lbl);
if let Some(sub) = entry.sublabel {
let sub_lbl = Label::new(Some(sub));
sub_lbl.add_css_class("dim-label");
sub_lbl.set_xalign(0.0);
// Match the sidebar's smaller "section-header" scale rather than
// the shared sheet's default dim-label size, so it reads as a
// caption under the row label, not a second full-size label.
sub_lbl.add_css_class("caption");
labels.append(&sub_lbl);
}
hbox.append(&labels);
row.set_child(Some(&hbox));
list.append(&row);
}
}