Files
qbz/crates/qbz-ui/ui/app.slint
T
vicrodh 897ba7128c feat(qbz-ui): settings export modal + Developer row (04 §4.2)
Add the desktop "Export settings…" surface for the qbzd daemon epic (T18):
a Developer-settings row that opens a modal with the single default-off
"Include Qobuz login and integration tokens" gate, its warning line, and a
Cancel/Export footer. Confirm() is a Rust callback (wired next). New
SettingsExportState/SettingsExportActions globals, exported from app.slint
so the Rust layer binds them, and the modal mounted in AppShell. New strings
translated across all 8 locales (en es de fr pt ru ja nl).

cc
2026-07-15 07:50:27 -06:00

306 lines
15 KiB
Plaintext

// QBZ Slint MVP — UI entry point.
//
// Stays thin: imports the feature screens and exports the single
// top-level window. No raw visual constants or domain logic here
// (MVP ADR, sections 13 and 21).
// Bundles + registers the Inter 18pt faces (compile-time embed) so the
// app can render with Inter — see the FONT_TEST_INTER toggle in main.rs.
import "assets/fonts/Inter_18pt-Regular.ttf";
import "assets/fonts/Inter_18pt-Medium.ttf";
import "assets/fonts/Inter_18pt-SemiBold.ttf";
import "assets/fonts/Inter_18pt-Bold.ttf";
import { Palette } from "std-widgets.slint";
import { Theme, ThemeColors } from "foundation/semantic-colors.slint";
import { ThemeState } from "foundation/theme-state.slint";
import { SplashScreen } from "splash/SplashScreen.slint";
import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
import { KioskShell } from "shell/KioskShell.slint";
import { Toast } from "primitives/Toast.slint";
import { ShellState, MiniPlayerState, NowPlayingState, AppearanceState, UiScale, WindowControlActions } from "state.slint";
import { MiniShell } from "miniplayer/MiniShell.slint";
// Re-export the theming globals so the Rust layer can push palettes (Theme.c /
// Theme.is-system) and keep the dropdown index (ThemeState.mode) in sync.
export { Theme, ThemeColors } from "foundation/semantic-colors.slint";
export { ThemeState } from "foundation/theme-state.slint";
// Typography is exported for the Small-preset font compensation only: Rust
// seeds `Typography.boost = 1.1` when the interface-size preset is Small.
export { Typography } from "foundation/typography.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, 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, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } 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`.
export enum AppScreen { splash, login, shell, kiosk }
export component AppWindow inherits Window {
// "Show track in window title" (Appearance settings): when enabled and a
// track is loaded, the OS window title becomes "{track} - {artist} | qbz";
// otherwise the plain app name. Reactive — updates on every track change.
title: AppearanceState.window-title-show && NowPlayingState.has-track
? NowPlayingState.title + " - " + NowPlayingState.artist + " | qbz"
: "QBZ";
preferred-width: 1180px;
preferred-height: 760px;
// Divide the interface-size preset back out so the PHYSICAL minimum stays
// constant across presets (a plain 940px logical minimum at the XL preset
// would be 1410 physical px — taller than a 1366x768 screen).
min-width: 940px / UiScale.factor;
min-height: 600px / UiScale.factor;
background: Theme.surface-main;
// Custom window chrome (owner decision 2026-07-03: the header IS the
// title bar — it is always present; custom mode only drops the system
// decorations while HeaderBar gains the window controls + drag).
// Linux-only: macOS keeps native decorations and gets the overlay
// attributes (transparent titlebar + fullsize content view) from the
// winit attributes hook instead. `no-frame` is the authoritative switch
// (the adapter re-applies it at every realization — same lesson as the
// miniplayer, see MiniPlayerWindow below); the persisted pref is seeded
// into `system-title-bar-active` BEFORE the first show; the settings
// toggle edits the pref (`use-system-title-bar`) and Rust decides per
// platform whether it reaches this applied state live (Linux) or only
// on the next launch (macOS — attributes fixed at surface creation).
no-frame: !AppearanceState.is-macos && !AppearanceState.system-title-bar-active;
// Edge-resize for the frameless mode (the winit backend only engages it
// on undecorated windows, so this is inert under system chrome).
resize-border-width: 6px;
// Resolved by the Rust layer from the desktop environment (e.g. the
// KDE Plasma general font). Empty falls back to the Slint default.
in property <string> system-font: "";
default-font-family: root.system-font;
// Publish the live window width so content-area views can make
// responsiveness decisions (see ShellState.content-constrained).
init => {
ShellState.window-width = self.width;
root.sync-native-palette();
}
changed width => {
ShellState.window-width = self.width;
}
// Keep the std-widgets `Palette` (native LineEdit/TextEdit/… colors) in
// step with the active QBZ theme. Without this, native inputs stayed on the
// OS palette: pick a light QBZ theme while the OS palette was dark and the
// input text rendered in the dark-scheme foreground over the light page —
// text the same tone as its background, effectively invisible (Settings
// input fields on light themes). `unknown` lets System keep following the OS.
property <bool> pal-is-dark: Theme.is-dark;
property <bool> pal-is-system: Theme.is-system;
function sync-native-palette() {
Palette.color-scheme = Theme.is-system
? ColorScheme.unknown
: (Theme.is-dark ? ColorScheme.dark : ColorScheme.light);
}
changed pal-is-dark => { root.sync-native-palette(); }
changed pal-is-system => { root.sync-native-palette(); }
in-out property <AppScreen> screen: AppScreen.splash;
// Forwarded to the Rust layer.
callback sign-in-via-browser();
callback cancel-login();
callback start-offline();
callback open-tos();
callback open-album(string);
callback open-artist(string);
callback open-purchase-album(string);
callback media-action(string, string, string);
callback header-menu-navigate(string);
callback discover-view-all(string, string);
callback settings-bool(string, bool);
callback settings-select(string, int);
callback settings-slider(string, int);
callback settings-string(string, string);
callback settings-reset();
callback settings-release-device();
callback logout();
callback close-app();
// D2 recovery banner: sign back in with the saved token.
callback recovery-login();
if root.screen == AppScreen.splash: SplashScreen { }
if root.screen == AppScreen.login: LoginScreen {
sign-in-via-browser => {
root.sign-in-via-browser();
}
cancel-login => {
root.cancel-login();
}
start-offline => {
root.start-offline();
}
open-tos => {
root.open-tos();
}
}
if root.screen == AppScreen.shell: AppShell {
// Fill the window. A conditionally-mounted child sizes to its PREFERRED
// size by default, so without this the shell sized to its content — fine
// while the right panels were always mounted (tall content), but once
// they became conditional, with both closed the shell collapsed to the
// top with dead space below. Bind to the real window size (`root` here is
// the AppWindow); `100%` did not pin it.
width: root.width;
height: root.height;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
open-purchase-album(id) => {
root.open-purchase-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
header-menu-navigate(route) => {
root.header-menu-navigate(route);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
settings-slider(k, v) => {
root.settings-slider(k, v);
}
settings-string(k, s) => {
root.settings-string(k, s);
}
settings-reset => {
root.settings-reset();
}
settings-release-device => {
root.settings-release-device();
}
logout => {
root.logout();
}
close-app => {
root.close-app();
}
recovery-login => {
root.recovery-login();
}
}
// Kiosk touch shell — a sibling of AppShell (small-panel profile). Same
// conditional-mount geometry pin as AppShell (a conditional child sizes to
// preferred size, so width/height are bound explicitly). Forwards the same
// callbacks to the AppWindow root.
if root.screen == AppScreen.kiosk: KioskShell {
width: root.width;
height: root.height;
open-album(id) => {
root.open-album(id);
}
open-artist(id) => {
root.open-artist(id);
}
open-purchase-album(id) => {
root.open-purchase-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
header-menu-navigate(route) => {
root.header-menu-navigate(route);
}
discover-view-all(e, t) => {
root.discover-view-all(e, t);
}
settings-bool(k, v) => {
root.settings-bool(k, v);
}
settings-select(k, i) => {
root.settings-select(k, i);
}
settings-slider(k, v) => {
root.settings-slider(k, v);
}
settings-string(k, s) => {
root.settings-string(k, s);
}
settings-reset => {
root.settings-reset();
}
settings-release-device => {
root.settings-release-device();
}
logout => {
root.logout();
}
close-app => {
root.close-app();
}
recovery-login => {
root.recovery-login();
}
}
// App-global in-app toast overlay. Declared last so it floats over
// every screen (splash / login / shell). Driven by ToastState via
// crate::toast.
Toast {
width: 100%;
height: 100%;
}
// Frameless hairline: with the system decorations gone (custom chrome on
// Linux) the compositor draws no border or shadow, so a 1px in-app frame
// keeps the window edge readable against same-tone backgrounds. Skipped
// while maximized / fullscreen (no visible edge to mark). Declared last
// so it paints at the very rim, over everything.
if root.no-frame && !WindowControlActions.is-maximized
&& !WindowControlActions.is-fullscreen: Rectangle {
width: 100%;
height: 100%;
background: transparent;
border-width: 1px;
border-color: Theme.border-subtle;
}
}
// Second top-level window — the miniplayer. Borderless + transparent (set at
// the winit level in crate::miniplayer); the 6px transparent gutter + the
// rounded card recreate Tauri's floating-card look. The first QBZ surface with
// no system titlebar.
export component MiniPlayerWindow inherits Window {
title: "QBZ Mini Player";
min-width: 340px;
min-height: 57px;
background: transparent;
// BORDERLESS — the real fix. Slint's winit adapter OVERRIDES winit
// decorations at every window realization from this property
// (`set_decorations(!no_frame() || fullscreen)`, winitwindowadapter.rs:1268),
// so the BackendSelector attributes hook alone was being undone. `no-frame`
// is the native, authoritative borderless switch. First QBZ surface with no
// system titlebar; AppWindow keeps `no-frame: false` (default).
no-frame: true;
in property <string> system-font: "";
default-font-family: root.system-font;
// 6px transparent gutter around the rounded card.
MiniShell {
x: 6px;
y: 6px;
width: parent.width - 12px;
height: parent.height - 12px;
}
}