feat(myqbz): My QBZ rename/change-icon in Settings>Appearance (DQ3)

Phase-2 Slice 12a. Per the user's decision (DQ3), the "My QBZ" rename/change-icon
lives in Settings > Appearance, not a sidebar context menu:

- src/myqbz_prefs.rs: per-user {label, icon_path} persisted to
  users/<id>/myqbz_branding.json (scoped like plex_settings/tray_settings);
  init_for_user/seed/set_label/pick_icon(rfd)/reset_icon. Blank label coerces to
  "My QBZ"; a missing/deleted custom icon falls back to the default my-qbz.svg
  without crashing (path preserved). 4 unit tests.
- ui/settings/AppearanceSettings.slint: a "My QBZ" SettingRow (icon preview +
  label LineEdit + Change icon... + Reset).
- ui/shell/Sidebar.slint: the My QBZ row binds label/icon to MyQbzBrandingState
  (raw-icon flag renders a custom user image untinted; default glyph stays tinted).
- Seeded on enter_shell.

Backend crates untouched. cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 07:40:44 -06:00
1 parent c6aa61a09e
commit 0bffd32555
6 files changed
+419 -6

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
import { Toast } from "primitives/Toast.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -14,7 +14,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { ThemeState } from "../foundation/theme-state.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { AppearanceState, SidebarState, ShellState, ToastState } from "../state.slint";
import { AppearanceState, SidebarState, ShellState, ToastState, MyQbzBrandingState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
@@ -298,6 +298,64 @@ export component AppearanceSettings inherits VerticalLayout {
}
}
}
// "My QBZ" nav branding — rename + change-icon, re-homed from the Tauri
// sidebar context-menu modal (DQ3). The label input commits on edit
// (blank coerces to "My QBZ" in Rust); the icon picker / reset persist a
// custom image path. The sidebar "My QBZ" row reflects both live.
SettingRow {
label: @tr("My QBZ");
description: @tr("Rename the My QBZ hub and pick a custom icon. Leave the name blank to restore the default.");
HorizontalLayout {
spacing: 8px;
// Icon preview — the user image as-is (custom) or the default
// branded glyph tinted to text colour (matches the picker preview
// being a raw image, not the sidebar's accent mask).
Rectangle {
width: 34px;
height: 34px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
if MyQbzBrandingState.has-custom-icon: Image {
source: MyQbzBrandingState.custom-icon;
image-fit: contain;
width: 22px;
height: 22px;
}
if !MyQbzBrandingState.has-custom-icon: QbzIcon {
source: @image-url("../assets/icons/my-qbz.svg");
width: 18px;
height: 18px;
tint: Theme.text-secondary;
}
}
// Label input — commits on edit; the placeholder is the default.
VerticalLayout {
alignment: center;
width: 150px;
name-edit := LineEdit {
text: MyQbzBrandingState.label;
placeholder-text: @tr("My QBZ");
edited(s) => {
MyQbzBrandingState.set-label(s);
}
}
}
SecondaryButton {
label: @tr("Change icon...");
clicked => {
MyQbzBrandingState.pick-icon();
}
}
SecondaryButton {
label: @tr("Reset");
clicked => {
MyQbzBrandingState.reset-icon();
}
}
}
}
SettingRow {
label: @tr("Show playlist cover collages in sidebar");
description: @tr("Render a 2×2 thumbnail of track covers next to each playlist. Disable on low-end machines to skip the extra images.");
+23 -4
View File
@@ -15,7 +15,7 @@ import { QbzIcon } from "../primitives/QbzIcon.slint";
import { SidebarPlaylistCollage } from "../primitives/SidebarPlaylistCollage.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState, SidebarTooltipState } from "../state.slint";
import { ShellState, SidebarState, SidebarActions, SidebarEntry, DragState, HeaderMenuState, NavMenuEntry, SidebarFolderPopupState, SidebarTooltipState, MyQbzBrandingState } from "../state.slint";
import { ScrollView, LineEdit } from "std-widgets.slint";
// A single context-menu item row.
@@ -501,6 +501,10 @@ component SidebarNavRow inherits Rectangle {
in property <int> tab-index;
in property <[NavMenuEntry]> items;
in property <int> home-item: -1;
// When true the leading icon is a user-supplied image rendered as-is
// (no theme tint). Used by the My QBZ row to show a custom icon with its
// own colours; default branded glyphs stay tinted via QbzIcon.
in property <bool> raw-icon: false;
property <bool> active: HeaderMenuState.open-index == root.tab-index;
height: 34px;
@@ -528,7 +532,15 @@ component SidebarNavRow inherits Rectangle {
alignment: ShellState.sidebar-mini ? center : start;
VerticalLayout {
alignment: center;
QbzIcon {
// Custom user icon — rendered raw (no tint) so its own colours show.
if root.raw-icon: Image {
source: root.icon;
image-fit: contain;
width: 16px;
height: 16px;
}
// Default branded glyph — tinted to the theme text colour.
if !root.raw-icon: QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
@@ -616,8 +628,15 @@ export component Sidebar inherits Rectangle {
}
SidebarNavRow {
tab-index: 3;
icon: @image-url("../assets/icons/my-qbz.svg");
label: @tr("My QBZ");
// Label + icon are user-customizable (Settings > Appearance).
// The icon is the custom user image when set (rendered raw) or
// the default branded glyph (tinted). The label coerces back to
// "My QBZ" on the Rust side when cleared.
icon: MyQbzBrandingState.has-custom-icon
? MyQbzBrandingState.custom-icon
: @image-url("../assets/icons/my-qbz.svg");
label: MyQbzBrandingState.label;
raw-icon: MyQbzBrandingState.has-custom-icon;
items: [
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette.svg"), has-glyph: true, route: "myqbz-mixtapes" },
{ label: @tr("Collections"), icon: @image-url("../assets/icons/collection.svg"), has-glyph: true, route: "myqbz-collections" },
+37
View File
@@ -2130,6 +2130,43 @@ export global AppearanceState {
callback appearance-action(string /* key */);
}
// "My QBZ" navigation branding (custom label + icon), surfaced in
// Settings > Appearance and reflected on the sidebar "My QBZ" row.
//
// Mirrors the Tauri `myQbzNavStore` contract (label default "My QBZ",
// icon default the branded /my-qbz.svg). Persistence is per-user JSON
// (see crate::myqbz_prefs); these properties are the live UI mirror and
// the callbacks are the action surface (the Rust side persists + re-seeds).
//
// - `label` : the current label (never empty — blank input coerces
// back to the default "My QBZ" string on the Rust side).
// - `custom-icon` : the user's image, loaded straight from the picked file
// path and rendered as-is (no tint). Only meaningful when
// `has-custom-icon` is true; otherwise the consumer falls
// back to the default branded glyph in markup (mask-tinted
// via QbzIcon on the sidebar). The default glyph stays a
// compile-time `@image-url` — the Rust side never decodes it.
// - `has-custom-icon` : true when a usable user image is set. A stale /
// deleted custom path falls back to the default on the Rust
// side (the seed loads the file; a load failure clears the
// flag without mutating the store), so this flag tracks "a
// usable custom icon is present".
export global MyQbzBrandingState {
in-out property <string> label: "My QBZ";
in-out property <image> custom-icon;
in-out property <bool> has-custom-icon: false;
// Commit a new label (trimmed-empty coerces to the default on the Rust
// side, which also re-seeds `label`). Fired on the text input's edit.
callback set-label(string /* label */);
// Open the native image picker; on pick, persist the path, load it, and
// re-seed `icon` + `has-custom-icon`. No-op on cancel.
callback pick-icon();
// Reset the icon to the default branded glyph (clears the persisted
// custom path). The label is reset separately by clearing the input.
callback reset-icon();
}
// One entry of a nav-tab dropdown.
export struct NavMenuEntry {
label: string,