feat(slint): DAC wizard shell — 6-step modal + state + trigger (Slice 5)

DacWizardState/DacWizardActions/DacCandidateRow in state.slint (re-exported in
app.slint). DacWizardModal: scrim + 620px card + dot/label stepper rail +
Flickable body with if-step blocks + footer Back/Close/primary. Navigation is
inline state mutation (no Rust yet); the Actions callbacks (copy-command/
run-detect/query-caps/start-test) are declared for Slices 6-9. welcome step is
real (disclaimer + accept checkbox gating Start); steps 1-5 are titled
placeholders. Mounted in AppShell; "Open Wizard" 160x34 trigger added to Audio
settings, gated on PipeWire.

cc
This commit is contained in:
vicrodh committed 2026-06-15 20:09:35 -06:00
1 parent 5e57db7a0c
commit 9bb392c262
5 files changed
+366 -2

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ import { Toast } from "primitives/Toast.slint";
import { ShellState } from "state.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, 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, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState } from "state.slint";
export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, 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, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState } 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`.
@@ -0,0 +1,273 @@
// HiFi Wizard (DAC setup) — Slint shell. Slice 5: the 6-step modal skeleton
// (scrim + card + stepper + footer nav). Navigation is handled inline by
// mutating DacWizardState (open/close/next/back need no Rust). Step bodies are
// filled by later slices: welcome (here) · check (S6) · select-dacs (S7) ·
// review (S10) · test (S9) · done (S10). The wizard stays READ-ONLY — it never
// writes a system audio file.
//
// Modal recipe mirrors PlaylistImportModal: overlay Rectangle (not a window
// grab), so playback keeps running underneath while the test step plays.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { DacWizardState } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { QbzPrimaryButton } from "QbzPrimaryButton.slint";
import { SecondaryButton } from "SecondaryButton.slint";
import { QbzCheckbox } from "QbzCheckbox.slint";
// Temporary titled placeholder for steps not yet built. Removed as each step
// lands (Slices 6/7/9/10).
component StepPlaceholder inherits VerticalLayout {
in property <string> title;
in property <string> note;
spacing: 10px;
Text {
text: root.title;
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
wrap: word-wrap;
}
Text {
text: root.note;
color: Theme.text-muted;
font-size: Typography.body;
wrap: word-wrap;
}
}
export component DacWizardModal inherits Rectangle {
visible: DacWizardState.open;
// Step labels for the header rail (index = DacWizardState.step).
property <[string]> steps: [
@tr("Welcome"),
@tr("Check"),
@tr("DACs"),
@tr("Configure"),
@tr("Test"),
@tr("Done"),
];
if DacWizardState.open: Rectangle {
background: #000000bf;
// Scrim — click outside closes.
TouchArea {
mouse-cursor: default;
clicked => {
DacWizardState.open = false;
}
}
card := Rectangle {
width: Math.min(root.width - 80px, 620px);
height: Math.min(Math.max(500px, body.preferred-height + 210px), root.height * 0.9);
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
border-radius: Radius.lg;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
// Swallow clicks so they never reach the scrim.
TouchArea { }
VerticalLayout {
padding: 0px;
spacing: 0px;
// Header: wand icon + title + close-x.
HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 20px;
padding-bottom: 20px;
spacing: 10px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/circle-check-big.svg");
width: 24px;
height: 24px;
tint: #3fae6a;
}
}
Text {
text: @tr("HiFi Wizard — DAC Setup");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
DacWizardState.open = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Stepper rail — dots + labels, colored by progress.
HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 14px;
padding-bottom: 14px;
spacing: 6px;
for label[idx] in root.steps: HorizontalLayout {
spacing: 6px;
horizontal-stretch: 1;
VerticalLayout {
alignment: center;
Rectangle {
width: 10px;
height: 10px;
border-radius: 5px;
background: idx <= DacWizardState.step ? #3fae6a : Theme.border-muted;
}
}
Text {
text: label;
color: idx == DacWizardState.step ? Theme.text-primary
: (idx < DacWizardState.step ? Theme.text-secondary : Theme.text-muted);
font-size: Typography.legal;
font-weight: idx == DacWizardState.step ? Typography.semibold : Typography.regular;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Body — scrolls when a step outgrows the card.
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: body.preferred-height;
body := VerticalLayout {
padding: 24px;
spacing: 16px;
alignment: start;
// ── Step 0: welcome ──────────────────────────────
if DacWizardState.step == 0: VerticalLayout {
spacing: 14px;
Text {
text: @tr("Guided DAC Setup");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
}
Text {
text: @tr("This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here.");
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
Text {
text: @tr("You are responsible for your own backups. This is a helper, not a system recovery tool.");
color: Theme.text-muted;
font-size: Typography.legal;
wrap: word-wrap;
}
HorizontalLayout {
spacing: 10px;
alignment: start;
// QbzCheckbox self-flips `checked` and emits
// toggled(); the two-way bind syncs the state, so
// no toggled handler (it would double-flip).
QbzCheckbox {
checked <=> DacWizardState.welcome-confirmed;
}
Text {
text: @tr("I understand and accept these terms");
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
}
}
}
// ── Steps 1..5: placeholders (filled by later slices)
if DacWizardState.step == 1: StepPlaceholder {
title: @tr("Check Audio Stack");
note: @tr("Auto-probes PipeWire / WirePlumber and surfaces only the packages you are missing (Slice 6).");
}
if DacWizardState.step == 2: StepPlaceholder {
title: @tr("Which DACs do you want to configure?");
note: @tr("Auto-detected sink list with checkboxes — no manual node.name paste (Slice 7).");
}
if DacWizardState.step == 3: StepPlaceholder {
title: @tr("Review & Apply");
note: @tr("Backup, per-DAC sample rates, and the copy-paste config files (Slice 10).");
}
if DacWizardState.step == 4: StepPlaceholder {
title: @tr("Test Playback");
note: @tr("Plays tracks at 16/44.1, 24/44.1, 24/96 and 24/192 and reads the DAC's real negotiated rate (Slice 9).");
}
if DacWizardState.step == 5: StepPlaceholder {
title: @tr("All set");
note: @tr("Summary and the config paths you created (Slice 10).");
}
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Footer: Back · Close · primary (Start/Next/Finish).
HorizontalLayout {
padding-left: 24px;
padding-right: 24px;
padding-top: 16px;
padding-bottom: 20px;
spacing: 12px;
// Back — hidden on the first step.
if DacWizardState.step > 0: SecondaryButton {
label: @tr("Back");
clicked => {
DacWizardState.step -= 1;
}
}
Rectangle { horizontal-stretch: 1; }
SecondaryButton {
label: @tr("Close");
clicked => {
DacWizardState.open = false;
}
}
QbzPrimaryButton {
height: 34px;
label: DacWizardState.step == 0 ? @tr("Start")
: (DacWizardState.step == 5 ? @tr("Finish") : @tr("Next"));
// Step 0 gated on the accept checkbox; later gates added per slice.
enabled: DacWizardState.step == 0 ? DacWizardState.welcome-confirmed : true;
clicked => {
if (DacWizardState.step == 5) {
DacWizardState.open = false;
} else {
DacWizardState.step += 1;
}
}
}
}
}
}
}
}
@@ -6,7 +6,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { SettingsState } from "../state.slint";
import { SettingsState, DacWizardState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
@@ -269,6 +269,37 @@ export component AudioSettings inherits VerticalLayout {
}
}
// HiFi Wizard — guided DAC setup (PipeWire only, mirrors the Tauri gate).
if SettingsState.backend-is-pipewire: GroupHeader { text: @tr("DAC SETUP"); }
if SettingsState.backend-is-pipewire: SettingRow {
label: @tr("HiFi Wizard");
description: @tr("Auto-detect your DACs and set up bit-perfect playback, step by step.");
wizard-btn := Rectangle {
width: 160px;
height: 34px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: wizard-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
Text {
text: @tr("Open Wizard");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
vertical-alignment: center;
}
wizard-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
DacWizardState.open = true;
DacWizardState.step = 0;
DacWizardState.welcome-confirmed = false;
}
}
}
}
Rectangle { height: 20px; }
// Reset — restores Audio + Playback defaults.
+5
View File
@@ -31,6 +31,7 @@ import { FolderEditModal } from "../primitives/FolderEditModal.slint";
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
import { TagEditorModal } from "../primitives/TagEditorModal.slint";
import { PlaylistImportModal } from "../primitives/PlaylistImportModal.slint";
import { DacWizardModal } from "../primitives/DacWizardModal.slint";
import { TrackInfoModal } from "../album/TrackInfoModal.slint";
import { AlbumCreditsModal } from "../album/AlbumCreditsModal.slint";
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
@@ -490,6 +491,10 @@ export component AppShell inherits Rectangle {
// `open`), keeping QconnectDevModal topmost.
PlaylistImportModal { }
// HiFi Wizard (DAC setup) — opened from Audio settings (PipeWire only).
// Overlay modal; playback keeps running underneath so the test step works.
DacWizardModal { }
// Track Info + Album Info (Credits/Review) modals — fed by crate::info_modals
// through TrackInfoState / AlbumInfoState. Conditional-mount on their own
// `open`; stacked above the feature modals (ADR-009/010).
+55
View File
@@ -3851,3 +3851,58 @@ export global PlaylistImportActions {
// Step B: import_public_playlist(...) with rename + folder choice.
callback execute();
}
// ── HiFi Wizard (DAC setup) ─────────────────────────────────────────────
// Native-Slint port + redesign of the Tauri DACSetupWizard. 6-step adaptive
// flow: welcome → check → select-dacs → review-and-apply → test → done.
// READ-ONLY by contract: the wizard never writes a system audio config — it
// auto-detects DACs (pw-dump, Slice 0), generates copy-paste config, and runs
// an in-app playback test reading the real negotiated rate (N6, Slice 8b).
// One enumerated sink the user can pick to configure.
export struct DacCandidateRow {
id: string, // PipeWire node.name (the value the user used to paste)
description: string, // pretty name, e.g. "DacMagic Plus Analog Stereo"
bus: string, // "usb" | "pci" | "bluetooth" | ""
is-default: bool, // current system default sink
looks-like-dac: bool, // hardware && bus ∈ {usb,pci} → pre-selected
checked: bool, // user selection
rates-label: string, // pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz"
}
export global DacWizardState {
in-out property <bool> open: false;
// 0 welcome · 1 check · 2 select-dacs · 3 review · 4 test · 5 done
in-out property <int> step: 0;
// welcome
in-out property <bool> welcome-confirmed: false;
// check (Slice 6): distro auto-detected, override always visible (Decision 4)
in-out property <[string]> distro-options: [];
in-out property <int> distro-index: 0;
in-out property <bool> health-ok: false;
in-out property <string> health-summary: "";
// select-dacs (Slice 7)
in-out property <[DacCandidateRow]> candidates: [];
in-out property <bool> has-enumeration: true; // false → manual escape hatch
in-out property <string> manual-node-name: "";
in-out property <bool> manual-valid: false;
// test (Slice 9): live read-back lines, set by the controller
in-out property <string> test-requested-label: ""; // "QBZ requesting 96000 Hz · 24-bit"
in-out property <string> test-negotiated-label: ""; // "DAC running at 96000 Hz" (N6)
in-out property <bool> test-rate-matched: false;
in-out property <bool> test-playing: false;
}
export global DacWizardActions {
// Wired in Rust from Slice 6 onward (crate::dac_wizard); navigation
// (open/close/next/back) is handled inline in the modal via state mutation.
callback copy-command(string); // → crate::share::copy_to_clipboard
callback run-detect(); // enumerate sinks → candidates (Slice 7)
callback query-caps(string); // probe one DAC's rates (Slice 7)
callback start-test(); // queue + play the 4 test tracks (Slice 9)
callback stop-test();
}