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:
6 files changed
+419
-6
No files matched your search
@@ -43,6 +43,7 @@ mod myqbz_detail;
|
||||
mod myqbz_edit;
|
||||
mod myqbz_mix;
|
||||
mod myqbz_play;
|
||||
mod myqbz_prefs;
|
||||
mod nav;
|
||||
mod play_history;
|
||||
mod strip_html;
|
||||
@@ -130,6 +131,11 @@ async fn enter_shell(
|
||||
// Slint-only). Seeded into PlexSettingsState lazily on panel open.
|
||||
plex_settings::init_for_user(session.user_id);
|
||||
|
||||
// Bind "My QBZ" nav branding (custom label + icon) to this user
|
||||
// (per-user myqbz_branding.json). Seeded into MyQbzBrandingState below so
|
||||
// the sidebar row + Settings row reflect the persisted values on entry.
|
||||
myqbz_prefs::init_for_user(session.user_id);
|
||||
|
||||
// Create the system tray from this user's persisted settings (gated by
|
||||
// enable_tray). Reflects the chosen icon variant. On Linux the ksni
|
||||
// service runs on its own thread; macOS/Windows are no-ops until the
|
||||
@@ -162,6 +168,9 @@ async fn enter_shell(
|
||||
appearance.set_tray_close_to_tray(tray.close_to_tray);
|
||||
appearance.set_tray_mac_hide_dock(tray.mac_hide_dock);
|
||||
appearance.set_tray_icon_theme_index(tray_settings::icon_theme_index(&tray.tray_icon_theme));
|
||||
// Seed the My QBZ branding (label + icon) from the per-user store so
|
||||
// the sidebar row + Settings row paint the custom values immediately.
|
||||
myqbz_prefs::seed(&w);
|
||||
w.global::<HomeState>().set_loading(true);
|
||||
w.set_screen(AppScreen::Shell);
|
||||
});
|
||||
@@ -3647,6 +3656,39 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// "My QBZ" nav branding (Settings > Appearance) — persist the label /
|
||||
// custom icon per-user and re-seed MyQbzBrandingState so the sidebar row
|
||||
// updates live. Re-homed from the Tauri sidebar context-menu modal (DQ3).
|
||||
{
|
||||
let branding = window.global::<MyQbzBrandingState>();
|
||||
// Label: persist (blank coerces to "My QBZ" in the store) and push the
|
||||
// coerced value onto the shared `label` property so the sidebar row
|
||||
// updates live. We set only `label` (not a full re-seed) so the bound
|
||||
// LineEdit isn't disturbed mid-edit beyond the documented blank->default
|
||||
// coercion. The icon state is left untouched here.
|
||||
let weak = window.as_weak();
|
||||
branding.on_set_label(move |label| {
|
||||
let coerced = myqbz_prefs::set_label(label.as_str());
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<MyQbzBrandingState>().set_label(coerced.into());
|
||||
}
|
||||
});
|
||||
// Change icon: async native picker; persists + re-seeds on pick.
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
branding.on_pick_icon(move || {
|
||||
myqbz_prefs::pick_icon(weak.clone(), handle.clone());
|
||||
});
|
||||
// Reset icon: clear the custom path, re-seed to the default glyph.
|
||||
let weak = window.as_weak();
|
||||
branding.on_reset_icon(move || {
|
||||
myqbz_prefs::reset_icon();
|
||||
if let Some(w) = weak.upgrade() {
|
||||
myqbz_prefs::seed(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Context-menu / overlay media actions — route play / queue actions
|
||||
// into the playback controller; favorite / download stay logged.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Per-user persistence for the "My QBZ" navigation branding (custom label
|
||||
//! + custom icon path).
|
||||
//!
|
||||
//! Mirrors the Tauri `myQbzNavStore` contract (spec 20 §0.1), re-homed so the
|
||||
//! ENTRY POINT is Settings > Appearance (DQ3) rather than a sidebar context
|
||||
//! menu. The persisted shape is `{ label, icon_path }`:
|
||||
//!
|
||||
//! - `label` : the custom label. A trimmed-empty value is coerced to the
|
||||
//! default `"My QBZ"` and that default string is what gets
|
||||
//! persisted (matching `setMyQbzLabel`).
|
||||
//! - `icon_path` : an absolute filesystem path to a user-chosen image, or the
|
||||
//! empty string for "default" (the branded `my-qbz.svg`). The
|
||||
//! reset action stores the empty string — i.e. removes the
|
||||
//! custom icon — rather than persisting a default path.
|
||||
//!
|
||||
//! Storage is per-user JSON, scoped the same way as the per-user Plex / tray
|
||||
//! DBs so different Qobuz accounts keep independent branding:
|
||||
//!
|
||||
//! <data_dir>/qbz/users/<user_id>/myqbz_branding.json
|
||||
//!
|
||||
//! The store is intentionally minimal: read-modify-write the whole tiny file
|
||||
//! on every set. `init_for_user` binds it on shell entry; the Slint-facing
|
||||
//! `seed` / `apply_*` helpers bridge it to `MyQbzBrandingState`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::{AppWindow, MyQbzBrandingState};
|
||||
|
||||
/// The default "My QBZ" label (spec 20 §0.1 `DEFAULT_LABEL`).
|
||||
pub const DEFAULT_LABEL: &str = "My QBZ";
|
||||
|
||||
/// The active user id, set by `init_for_user`. `None` before login (the
|
||||
/// store degrades to defaults — there is no pre-login branding surface).
|
||||
static USER_ID: LazyLock<Mutex<Option<u64>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// Persisted branding. Missing fields default sanely so an older file still
|
||||
/// deserializes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Branding {
|
||||
#[serde(default = "default_label")]
|
||||
label: String,
|
||||
/// Absolute path to a custom icon, or empty for the default glyph.
|
||||
#[serde(default)]
|
||||
icon_path: String,
|
||||
}
|
||||
|
||||
fn default_label() -> String {
|
||||
DEFAULT_LABEL.to_string()
|
||||
}
|
||||
|
||||
impl Default for Branding {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
label: default_label(),
|
||||
icon_path: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/qbz/users/<user_id>/myqbz_branding.json` for the active user.
|
||||
/// `None` before login or when the data dir is unavailable.
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
let user_id = (*USER_ID.lock().ok()?)?;
|
||||
Some(
|
||||
dirs::data_dir()?
|
||||
.join("qbz")
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("myqbz_branding.json"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Load the active user's branding. A missing / unreadable / unparseable file
|
||||
/// degrades to defaults.
|
||||
fn read() -> Branding {
|
||||
let Some(path) = store_path() else {
|
||||
return Branding::default();
|
||||
};
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Branding::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the branding (best-effort — failures are logged).
|
||||
fn write(b: &Branding) {
|
||||
let Some(path) = store_path() else {
|
||||
log::warn!("[qbz-slint] myqbz branding: no active user, not saving");
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::error!("[qbz-slint] myqbz branding: create dir failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(b) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::error!("[qbz-slint] myqbz branding: write failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("[qbz-slint] myqbz branding: serialize failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the store to `user_id` on shell entry. Subsequent reads/writes target
|
||||
/// that user's JSON file.
|
||||
pub fn init_for_user(user_id: u64) {
|
||||
if let Ok(mut guard) = USER_ID.lock() {
|
||||
*guard = Some(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Coerce a raw label input to the persisted value: trimmed-empty → the
|
||||
/// default "My QBZ" (and the default string is what's stored).
|
||||
fn coerce_label(label: &str) -> String {
|
||||
let trimmed = label.trim();
|
||||
if trimmed.is_empty() {
|
||||
DEFAULT_LABEL.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a new label (empty/whitespace coerces to the default) and return
|
||||
/// the coerced value the sidebar should display.
|
||||
pub fn set_label(label: &str) -> String {
|
||||
let mut b = read();
|
||||
b.label = coerce_label(label);
|
||||
write(&b);
|
||||
b.label
|
||||
}
|
||||
|
||||
/// Persist a custom icon path. An empty / whitespace path clears the custom
|
||||
/// icon (reset to default), mirroring `setMyQbzIconPath(null)`.
|
||||
pub fn set_icon_path(path: &str) {
|
||||
let mut b = read();
|
||||
b.icon_path = path.trim().to_string();
|
||||
write(&b);
|
||||
}
|
||||
|
||||
/// Reset the icon to the default branded glyph (clears the persisted path).
|
||||
pub fn reset_icon() {
|
||||
set_icon_path("");
|
||||
}
|
||||
|
||||
/// Resolve a (label, custom_icon) pair for the UI from the persisted
|
||||
/// branding. `custom_icon` is `Some` only when a custom path is set AND the
|
||||
/// file loads; a missing path or a stale / deleted file yields `None` (the
|
||||
/// markup then falls back to the default branded glyph). A load failure does
|
||||
/// NOT mutate the store — the user can re-pick, and the path is preserved in
|
||||
/// case the file returns.
|
||||
fn resolve() -> (String, Option<slint::Image>) {
|
||||
let b = read();
|
||||
if b.icon_path.is_empty() {
|
||||
return (b.label, None);
|
||||
}
|
||||
match slint::Image::load_from_path(std::path::Path::new(&b.icon_path)) {
|
||||
Ok(img) => (b.label, Some(img)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[qbz-slint] myqbz branding: custom icon '{}' failed to load, using default: {e}",
|
||||
b.icon_path
|
||||
);
|
||||
(b.label, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the persisted branding onto `MyQbzBrandingState`. Runs on the UI
|
||||
/// thread (it touches the Slint global + decodes an image). Call on shell
|
||||
/// entry and after every set/reset so the sidebar row reflects the change.
|
||||
///
|
||||
/// The default glyph stays a compile-time `@image-url` in the markup; Rust
|
||||
/// only supplies the custom image (and the flag that selects it).
|
||||
pub fn seed(window: &AppWindow) {
|
||||
let (label, custom_icon) = resolve();
|
||||
let st = window.global::<MyQbzBrandingState>();
|
||||
st.set_label(label.into());
|
||||
match custom_icon {
|
||||
Some(img) => {
|
||||
st.set_custom_icon(img);
|
||||
st.set_has_custom_icon(true);
|
||||
}
|
||||
None => {
|
||||
// Clear any stale custom image and fall back to the default glyph.
|
||||
st.set_custom_icon(slint::Image::default());
|
||||
st.set_has_custom_icon(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed the branding state on the UI thread via a weak handle. Used by the
|
||||
/// async icon picker once it has persisted a new path.
|
||||
pub fn reseed_weak(weak: &slint::Weak<AppWindow>) {
|
||||
let _ = weak.upgrade_in_event_loop(|w| seed(&w));
|
||||
}
|
||||
|
||||
/// Open the native image picker; on pick, persist the chosen path and re-seed
|
||||
/// the branding state (sidebar row + Settings preview reflect it). No-op on
|
||||
/// cancel. The filter matches the Tauri modal's set (`svg, png, jpg, jpeg,
|
||||
/// webp`).
|
||||
pub fn pick_icon(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_title("Choose a My QBZ icon")
|
||||
.add_filter("Image", &["svg", "png", "jpg", "jpeg", "webp"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return; // cancelled — leave branding unchanged.
|
||||
};
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
set_icon_path(&path);
|
||||
reseed_weak(&weak);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coerce_blank_label_yields_default() {
|
||||
assert_eq!(coerce_label(""), "My QBZ");
|
||||
assert_eq!(coerce_label(" "), "My QBZ");
|
||||
assert_eq!(coerce_label(" Tapes "), "Tapes");
|
||||
assert_eq!(coerce_label("Tapes"), "Tapes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branding_defaults() {
|
||||
let b = Branding::default();
|
||||
assert_eq!(b.label, "My QBZ");
|
||||
assert!(b.icon_path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_fields_deserializes() {
|
||||
let b: Branding = serde_json::from_str("{}").expect("empty object deserializes");
|
||||
assert_eq!(b.label, "My QBZ");
|
||||
assert!(b.icon_path.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_icon_path_field_keeps_label() {
|
||||
let b: Branding =
|
||||
serde_json::from_str(r#"{"label":"Tapes"}"#).expect("partial object deserializes");
|
||||
assert_eq!(b.label, "Tapes");
|
||||
assert!(b.icon_path.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user