diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 2acb6c301..57cc3a0da 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6359,6 +6359,15 @@ dependencies = [ "slint", ] +[[package]] +name = "qbz-dac-wizard-core" +version = "2.0.2" +dependencies = [ + "qbz-audio", + "qbz-i18n", + "qbz-models", +] + [[package]] name = "qbz-dsd" version = "2.0.2" @@ -6634,6 +6643,7 @@ dependencies = [ "qbz-cast", "qbz-core", "qbz-credentials", + "qbz-dac-wizard-core", "qbz-external-reco", "qbz-i18n", "qbz-integrations", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index 30d4429da..f010ebf9d 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -24,6 +24,14 @@ members = [ "qconnect-transport-ws", "qconnect-app", "qbz-ui", + # The HiFi Wizard's frontend-agnostic core (ADR-006), extracted for the Qt + # port. `qbz-dac-wizard` is the SLINT one and is deliberately NOT wired to + # this crate: the Slint tree is frozen (owner, 2026-08-11 — "ya no hacemos + # cambios para Slint"), and rewiring it would have been a change to a tree + # nobody compiles or tests any more. The two therefore hold the same logic + # twice; that is safe only BECAUSE the Slint side is frozen, and it is the + # reason a fix here never needs porting back. + "qbz-dac-wizard-core", "qbz-dac-wizard", "qbz-text-utils", "qbz-slint-common", diff --git a/crates/qbz-dac-wizard-core/Cargo.toml b/crates/qbz-dac-wizard-core/Cargo.toml new file mode 100644 index 000000000..fb8d611e0 --- /dev/null +++ b/crates/qbz-dac-wizard-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "qbz-dac-wizard-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "HiFi/DAC setup wizard — frontend-agnostic core (probes, remediations, config generation)." + +[lib] +name = "qbz_dac_wizard_core" +path = "src/lib.rs" + +# Deliberately NO frontend dependency (no slint, no qbz-ui, no cxx-qt). ADR-006: +# the core is frontend-agnostic so Slint, Qt and qbzd consume the SAME logic. +# Everything here returns plain data; pushing it into a UI is the frontend's job. +[dependencies] +qbz-audio = { path = "../qbz-audio" } +qbz-models = { path = "../qbz-models" } +qbz-i18n = { path = "../qbz-i18n" } diff --git a/crates/qbz-dac-wizard-core/src/lib.rs b/crates/qbz-dac-wizard-core/src/lib.rs new file mode 100644 index 000000000..bf68427af --- /dev/null +++ b/crates/qbz-dac-wizard-core/src/lib.rs @@ -0,0 +1,774 @@ +//! HiFi Wizard (DAC setup) — the frontend-agnostic core. +//! +//! # Why this crate exists +//! +//! The wizard shipped as `qbz-dac-wizard`, whose every public function takes a +//! `&AppWindow` and writes straight into Slint globals — so the Qt port could +//! not reach any of it. This crate is that logic, lifted out and returning +//! plain data: `qbz-qt` drives it through `dac_wizard_qt.rs`. ADR-006, +//! frontend-agnostic core. +//! +//! # Its relationship to `qbz-dac-wizard` — read before "deduplicating" +//! +//! `qbz-dac-wizard` still holds its own copy of this logic and is NOT wired to +//! this crate. That is deliberate: the Slint tree is FROZEN (owner, +//! 2026-08-11 — "ya no hacemos cambios para Slint"), so rewiring it would have +//! been an unverifiable change to a tree nobody compiles or tests any more, +//! bought for a deduplication that a frozen tree cannot benefit from. +//! +//! The usual objection to two copies is drift. It does not apply while the +//! other copy never changes — and if Slint is ever unfrozen, the merge is the +//! diff of one file. Fixes land HERE; nothing needs porting back. +//! +//! `qbzd` duplicates parts of this again in `tui/wizard_core.rs`. THAT one is +//! live code and is a genuine candidate to fold onto this crate. +//! +//! # What is guaranteed +//! +//! READ-ONLY. Nothing here writes a system file, opens a stream or runs a +//! command. It probes (`qbz_audio::health`, sink enumeration, rate queries) +//! and it formats text. That property is the wizard's whole safety story and +//! it is enforced by construction: this crate has no `std::process::Command` +//! and no write-side `std::fs`. + +use qbz_audio::{AudioStackHealth, Distro, InitSystem, Sandbox}; + +// =========================================================================== +// Environment detection (thin re-exports so a frontend needs ONE dependency) +// =========================================================================== + +/// Distro dropdown options, in `Distro::ALL` order (index = the dropdown's). +pub fn distro_options() -> Vec { + Distro::ALL.iter().map(|d| d.label().to_string()).collect() +} + +/// Init-system dropdown options, in `InitSystem::ALL` order. +pub fn init_options() -> Vec { + InitSystem::ALL + .iter() + .map(|i| i.label().to_string()) + .collect() +} + +/// Auto-detected distro, as a dropdown index. +pub fn detected_distro_index() -> i32 { + qbz_audio::detect_distro().index() as i32 +} + +/// Auto-detected init system, as a dropdown index. +pub fn detected_init_index() -> i32 { + qbz_audio::detect_init().index() as i32 +} + +/// `("Flatpak" | "Snap" | "")` — empty when the process is not sandboxed. +pub fn sandbox_name() -> &'static str { + match qbz_audio::detect_sandbox() { + Sandbox::Flatpak => "Flatpak", + Sandbox::Snap => "Snap", + Sandbox::None => "", + } +} + +/// Blocking audio-stack probe (shells out through `qbz_audio`; call it off the +/// UI thread). +pub fn probe_health() -> AudioStackHealth { + qbz_audio::audio_stack_health() +} + +fn distro_at(index: i32) -> Distro { + Distro::ALL + .get(index.max(0) as usize) + .copied() + .unwrap_or(Distro::Other) +} + +fn init_at(index: i32) -> InitSystem { + InitSystem::ALL + .get(index.max(0) as usize) + .copied() + .unwrap_or(InitSystem::Unknown) +} + +// =========================================================================== +// Check step — health verdict + per-distro remediations +// =========================================================================== + +/// One "you're missing X" fix: a caption and a copy-paste command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Remediation { + pub caption: String, + pub command: String, +} + +/// Everything the check step renders, for one (health, distro, init) triple. +#[derive(Clone, Debug, Default)] +pub struct CheckView { + pub health_ok: bool, + /// Empty in a sandbox — there is no verdict to give, only reference + /// commands (the UI shows its sandbox banner instead). + pub summary: String, + pub remediations: Vec, +} + +/// Build the check step from a completed probe and the two dropdown indices +/// (either of which the user may have overridden). +/// +/// In a sandbox the host probes are blind, so no verdict is rendered — the +/// caller gets reference setup commands for the chosen distro/init instead +/// (Tauri-style, which never probed either). +pub fn check_view( + health: AudioStackHealth, + distro_index: i32, + init_index: i32, + sandboxed: bool, +) -> CheckView { + let distro = distro_at(distro_index); + let init = init_at(init_index); + + if sandboxed { + return CheckView { + health_ok: false, + summary: String::new(), + remediations: reference_commands(distro, init), + }; + } + + let rows = remediations(health, distro, init); + let ok = health.is_ready(); + let summary = if ok { + qbz_i18n::t("Your audio stack is ready for bit-perfect playback.") + } else { + let n = rows.len(); + qbz_i18n::tf( + "{} item needs attention before bit-perfect playback will work.", + "{} items need attention before bit-perfect playback will work.", + n as i64, + &[&n.to_string()], + ) + }; + CheckView { + health_ok: ok, + summary, + remediations: rows, + } +} + +/// (caption, copy-paste command) per missing probe, for the given distro. +/// +/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo, +/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector +/// `restartCommands`. Installs and the restart are kept as separate blocks so +/// the multi-line Gentoo guidance never gets `&&`-joined. +fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec { + // NixOS is declarative: you don't `apt/pacman install` pieces — you enable + // the PipeWire module and rebuild. So collapse all the missing pieces into + // one config block instead of per-package commands. + if d == Distro::NixOS { + if h.is_ready() { + return Vec::new(); + } + return vec![Remediation { + caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"), + command: NIXOS_PIPEWIRE_BLOCK.to_string(), + }]; + } + + let mut out = Vec::new(); + let mut needs_restart = false; + if !h.has_pw_dump { + out.push(Remediation { + caption: qbz_i18n::t("Install the PipeWire tools (pw-dump)"), + command: install(d, pkg_pw_tools(d)), + }); + needs_restart = true; + } + if !h.cpal_sees_pipewire { + // THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM. + out.push(Remediation { + caption: qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"), + command: install(d, "pipewire-alsa"), + }); + needs_restart = true; + } + if !h.has_pactl { + out.push(Remediation { + caption: qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"), + command: install(d, pkg_pulse(d)), + }); + needs_restart = true; + } + if !h.any_devices { + out.push(Remediation { + caption: qbz_i18n::t( + "No sinks detected — reinstall the ALSA UCM profiles, then reboot", + ), + command: install_reinstall(d, "alsa-ucm-conf"), + }); + } + // WirePlumber down, or we just installed something → (re)start the stack + // with the ACTUAL init system running on this machine (not guessed from the + // distro — Gentoo+systemd and Gentoo+OpenRC must differ). + if !h.wireplumber_active || needs_restart { + out.push(Remediation { + caption: qbz_i18n::t("(Re)start the PipeWire audio services"), + command: restart_cmd(init).to_string(), + }); + } + out +} + +/// Init-system-aware "(re)start the audio services" command. PipeWire is a +/// user-session service, so only systemd has a first-class `--user` restart; +/// the others either use their user-service supervisor or a re-login. +fn restart_cmd(init: InitSystem) -> &'static str { + match init { + InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber", + InitSystem::OpenRc => { + "# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\ + # Log out and back in to restart it." + } + InitSystem::Runit => { + "sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in" + } + InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in", + InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in", + InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in", + } +} + +/// The restart command for a dropdown index — the review step's step 3. +pub fn restart_command_for(init_index: i32) -> String { + restart_cmd(init_at(init_index)).to_string() +} + +fn pkg_pw_tools(d: Distro) -> &'static str { + match d { + // Debian-family (incl. antiX) ship pw-* in pipewire-bin. + Distro::Debian | Distro::Antix => "pipewire-bin", + Distro::Fedora => "pipewire-utils", + // Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire. + _ => "pipewire", + } +} + +fn pkg_pulse(d: Distro) -> &'static str { + match d { + Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils", + Distro::Fedora => "pipewire-pulseaudio", + _ => "pipewire-pulse", + } +} + +fn install(d: Distro, pkgs: &str) -> String { + match d { + // Package manager is a property of the distro family, NOT the init. + Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"), + Distro::Fedora => format!("sudo dnf install {pkgs}"), + Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"), + Distro::OpenSuse => format!("sudo zypper install {pkgs}"), + Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"), + Distro::Void => format!("sudo xbps-install -S {pkgs}"), + // NixOS is special-cased in remediations(); this is an unreached fallback. + Distro::NixOS => { + format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}") + } + Distro::Other => format!("Install with your package manager: {pkgs}"), + } +} + +fn install_reinstall(d: Distro, pkg: &str) -> String { + match d { + Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"), + Distro::Fedora => format!("sudo dnf reinstall {pkg}"), + _ => install(d, pkg), + } +} + +const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\ + services.pipewire = {\n\ + \u{20}\u{20}enable = true;\n\ + \u{20}\u{20}alsa.enable = true;\n\ + \u{20}\u{20}pulse.enable = true;\n\ + \u{20}\u{20}wireplumber.enable = true;\n\ + };\n\ + # then apply:\n\ + sudo nixos-rebuild switch"; + +/// Full reference setup commands for the chosen distro/init, shown when QBZ +/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which +/// always showed per-distro install + restart commands (no probing). +fn reference_commands(d: Distro, init: InitSystem) -> Vec { + if d == Distro::NixOS { + return vec![Remediation { + caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"), + command: NIXOS_PIPEWIRE_BLOCK.to_string(), + }]; + } + vec![ + Remediation { + caption: qbz_i18n::t("Install the PipeWire audio stack"), + command: install(d, full_stack_pkgs(d)), + }, + Remediation { + caption: qbz_i18n::t("(Re)start the PipeWire audio services"), + command: restart_cmd(init).to_string(), + }, + ] +} + +/// The full recommended package set (incl. `pipewire-alsa`, the bit the old +/// Tauri list omitted — the cause of the Ubuntu empty-list bug). +fn full_stack_pkgs(d: Distro) -> &'static str { + match d { + Distro::Debian | Distro::Antix => { + "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" + } + Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", + Distro::Arch | Distro::Artix => { + "pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils" + } + Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils", + Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils", + Distro::Void => "pipewire wireplumber alsa-utils", + Distro::NixOS => "", + Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils", + } +} + +// =========================================================================== +// Select-DACs step — enumeration + the manual escape hatch +// =========================================================================== + +/// One enumerated sink the user can pick to configure. +#[derive(Clone, Debug, Default)] +pub struct DacCandidate { + /// PipeWire `node.name` — the value the manual escape hatch asks for. + pub id: String, + /// Pretty name, e.g. "DacMagic Plus Analog Stereo". + pub description: String, + /// "usb" | "pci" | "bluetooth" | "" + pub bus: String, + pub is_default: bool, + /// hardware && bus ∈ {usb,pci} → pre-selected. + pub looks_like_dac: bool, + /// Pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz". + pub rates_label: String, +} + +/// Enumerate sinks via the pw-dump-robust path and probe rates for the likely +/// DACs. BLOCKING — call it off the UI thread. +pub fn detect_blocking() -> Vec { + let devices = qbz_audio::backend::BackendManager::create_backend( + qbz_audio::backend::AudioBackendType::PipeWire, + ) + .and_then(|b| b.enumerate_devices()) + .unwrap_or_default(); + + let mut out = Vec::new(); + for d in devices { + let bus = d.device_bus.unwrap_or_default(); + let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci"); + // Only probe rates for likely DACs (skip virtual/monitor sinks). + let rates_label = if looks_like_dac { + format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates) + } else { + String::new() + }; + let description = if d.name.is_empty() { + d.id.clone() + } else { + d.name + }; + out.push(DacCandidate { + id: d.id, + description, + bus, + is_default: d.is_default, + looks_like_dac, + rates_label, + }); + } + out +} + +/// Validate a manually-pasted `node.name`. 1:1 with the Tauri +/// `validateNodeName`. +pub fn validate_node_name(name: &str) -> bool { + let t = name.trim(); + !t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input")) +} + +/// Classify a pasted node name. 1:1 with the Tauri `detectDacType`. +pub fn detect_dac_type(name: &str) -> &'static str { + let l = name.to_lowercase(); + if l.contains("usb-") || l.contains(".usb") { + "usb" + } else if l.contains("pci-") || l.contains(".pci") { + "pci" + } else if l.contains("bluez") || l.contains("bluetooth") { + "bluetooth" + } else if l.contains("virtual") || l.contains("null") || l.contains("dummy") { + "virtual" + } else { + "unknown" + } +} + +/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer). +pub fn format_rates(rates: &[u32]) -> String { + if rates.is_empty() { + return String::new(); + } + let parts: Vec = rates + .iter() + .map(|&r| { + if r % 1000 == 0 { + format!("{}", r / 1000) + } else { + format!("{:.1}", r as f64 / 1000.0) + } + }) + .collect(); + format!("{} kHz", parts.join(" / ")) +} + +// =========================================================================== +// Test step — the four curated tracks + the N6 read-back +// =========================================================================== + +/// One curated test track (owner-provided). Resolved by id-hint first, then by +/// "artist title" search if the id 404s (a pulled license) — never raw-id-only. +pub struct TestSeed { + pub depth: u32, + pub rate: f64, + pub id_hint: u64, + pub artist: &'static str, + pub title: &'static str, +} + +pub const TEST_SEEDS: [TestSeed; 4] = [ + TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" }, + TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" }, + TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" }, + TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" }, +]; + +/// True if a resolved track matches this seed's family (rate + bit depth — the +/// two 44.1 seeds only differ by depth). +pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool { + let rate_ok = track + .maximum_sampling_rate + .map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0) + .unwrap_or(false); + let depth_ok = track + .maximum_bit_depth + .map(|d| d == seed.depth) + .unwrap_or(false); + rate_ok && depth_ok +} + +/// The two read-back lines plus the truth signal, for one poll. +#[derive(Clone, Debug, Default)] +pub struct PollView { + pub requested_label: String, + pub negotiated_label: String, + /// The DAC's real clock matches what QBZ asked for. + pub rate_matched: bool, +} + +/// Format one poll: the rate QBZ requested vs the DAC's real negotiated rate +/// (N6). +pub fn poll_view( + requested_rate: u32, + requested_bits: u32, + negotiated: Option, +) -> PollView { + let requested_label = if requested_rate > 0 { + qbz_i18n::t_args( + "QBZ requesting {} · {}-bit", + &[&khz(requested_rate), &requested_bits.to_string()], + ) + } else { + qbz_i18n::t("Nothing playing") + }; + match negotiated { + Some(n) => PollView { + requested_label, + // The DAC's REAL hardware params (N6): rate + ALSA container format + // (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the + // bit-perfect proof — exactly what the hardware is clocked at. + negotiated_label: qbz_i18n::t_args( + "DAC: {} · {} · {} ch", + &[&khz(n.sample_rate), &n.format, &n.channels.to_string()], + ), + rate_matched: requested_rate > 0 && n.sample_rate == requested_rate, + }, + None => PollView { + requested_label, + negotiated_label: qbz_i18n::t("Waiting for the DAC to start playing…"), + rate_matched: false, + }, + } +} + +fn khz(hz: u32) -> String { + if hz % 1000 == 0 { + format!("{} kHz", hz / 1000) + } else { + format!("{:.1} kHz", hz as f64 / 1000.0) + } +} + +// =========================================================================== +// Review-and-apply step — per-DAC config generation +// =========================================================================== + +/// One selected DAC's generated copy-paste config (read-only). +#[derive(Clone, Debug, Default)] +pub struct DacConfig { + pub name: String, + pub node_name: String, + pub pipewire_conf: String, + pub pulse_conf: String, + pub wireplumber_conf: String, +} + +/// Re-probe rates + build the three config snippets per DAC. BLOCKING. +pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec { + dacs.into_iter() + .map(|(node_name, name)| { + let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates; + let short = short_name(&name, &node_name); + DacConfig { + pipewire_conf: pipewire_conf(&short, &rates), + pulse_conf: pulse_conf(&short), + wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name), + name, + node_name, + } + }) + .collect() +} + +/// The three `~/.config/...` paths one generated config would create — the +/// done step's "Config files you can create" list, in the same order the +/// review step shows the snippets. +pub fn created_paths(configs: &[DacConfig]) -> Vec { + let mut paths = Vec::new(); + for d in configs { + let short = short_name(&d.name, &d.node_name); + paths.push(format!( + "~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf" + )); + paths.push(format!( + "~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf" + )); + paths.push(format!( + "~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf" + )); + } + paths +} + +pub const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\""; + +/// A short, filename-safe DAC name: slug of the description, else the node.name. +fn short_name(name: &str, node_name: &str) -> String { + let slug = slugify(name); + if !slug.is_empty() { + return slug; + } + let nslug = slugify(node_name); + if nslug.is_empty() { + "dac".to_string() + } else { + nslug + } +} + +fn slugify(s: &str) -> String { + let mut out = String::new(); + let mut prev_dash = false; + for ch in s.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + prev_dash = false; + } else if !prev_dash && !out.is_empty() { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} + +fn rates_list(rates: &[u32]) -> String { + if rates.is_empty() { + "44100 48000 88200 96000 176400 192000".to_string() + } else { + rates + .iter() + .map(|r| r.to_string()) + .collect::>() + .join(" ") + } +} + +fn pipewire_conf(short: &str, rates: &[u32]) -> String { + let rates = rates_list(rates); + [ + "mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(), + format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), + "# QBZ DAC Setup - Sample Rate Switching".to_string(), + "context.properties = {".to_string(), + format!(" default.clock.allowed-rates = [ {rates} ]"), + "}".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +fn pulse_conf(short: &str) -> String { + [ + "mkdir -p ~/.config/pipewire/client.conf.d".to_string(), + format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"), + "# QBZ DAC Setup - Per-App Bit-Perfect".to_string(), + "stream.rules = [".to_string(), + " {".to_string(), + " matches = [".to_string(), + " { application.process.binary = \"qbz\" }".to_string(), + " { application.name = \"PipeWire ALSA [qbz]\" }".to_string(), + " ]".to_string(), + " actions = { update-props = { resample.disable = true, channelmix.disable = true } }" + .to_string(), + " }".to_string(), + "]".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String { + let rates = rates_list(rates); + [ + "mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(), + format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"), + format!("# QBZ DAC Setup - {description}"), + "monitor.alsa.rules = [".to_string(), + " {".to_string(), + " matches = [".to_string(), + format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"), + " ]".to_string(), + " actions = {".to_string(), + " update-props = {".to_string(), + format!(" audio.allowed-rates = [ {rates} ]"), + " resample.disable = true".to_string(), + " channelmix.disable = true".to_string(), + " }".to_string(), + " }".to_string(), + " }".to_string(), + "]".to_string(), + "EOF".to_string(), + ] + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_node_names_like_tauri() { + assert!(validate_node_name( + "alsa_output.usb-Cambridge-00.analog-stereo" + )); + assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo")); + assert!(!validate_node_name("")); + assert!(!validate_node_name(" ")); + assert!(!validate_node_name("bluez_output.AA_BB")); + } + + #[test] + fn detects_dac_type() { + assert_eq!( + detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"), + "usb" + ); + assert_eq!( + detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"), + "pci" + ); + assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth"); + assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual"); + assert_eq!(detect_dac_type("something.else"), "unknown"); + } + + #[test] + fn formats_rates_khz() { + assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz"); + assert_eq!(format_rates(&[]), ""); + } + + #[test] + fn slugifies_descriptions() { + assert_eq!( + slugify("DacMagic Plus Analog Stereo"), + "dacmagic-plus-analog-stereo" + ); + assert_eq!( + slugify("Built-in Audio Analog Stereo"), + "built-in-audio-analog-stereo" + ); + assert_eq!(slugify(" weird__name!! "), "weird-name"); + assert_eq!(slugify(""), ""); + } + + #[test] + fn wireplumber_conf_pins_node_and_rates() { + let c = wireplumber_conf( + "dacmagic", + "alsa_output.usb-x.analog-stereo", + &[44100, 192000], + "DacMagic", + ); + assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\"")); + assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]")); + assert!(c.contains("99-qbz-dac-dacmagic.conf")); + assert!(c.contains("resample.disable = true")); + } + + /// The done step's path list must line up with the snippets the review step + /// shows — three paths per DAC, in the pipewire / client / wireplumber + /// order the accordions use. + #[test] + fn created_paths_are_three_per_dac_in_order() { + let cfg = DacConfig { + name: "DacMagic Plus".to_string(), + node_name: "alsa_output.usb-x.analog-stereo".to_string(), + ..Default::default() + }; + let paths = created_paths(&[cfg]); + assert_eq!(paths.len(), 3); + assert!(paths[0].contains("pipewire.conf.d/99-qbz-dac-dacmagic-plus.conf")); + assert!(paths[1].contains("client.conf.d/99-qbz-bitperfect-dacmagic-plus.conf")); + assert!(paths[2].contains("wireplumber.conf.d/99-qbz-dac-dacmagic-plus.conf")); + } + + /// A sandbox has no verdict to give: no summary, and the rows are the + /// reference install + restart rather than probe-derived fixes. + #[test] + fn sandbox_renders_reference_commands_and_no_verdict() { + // A fully-healthy probe, so the assertion cannot pass by accident: in + // a sandbox the verdict is suppressed REGARDLESS of what was probed. + let health = AudioStackHealth { + wireplumber_active: true, + has_pw_dump: true, + cpal_sees_pipewire: true, + has_pactl: true, + any_devices: true, + }; + let view = check_view(health, 0, 0, true); + assert!(view.summary.is_empty()); + assert!(!view.health_ok); + assert_eq!(view.remediations.len(), 2); + } +} diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index 13405ef12..63f76c5e6 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -55,6 +55,10 @@ arboard = { version = "3", features = ["wayland-data-control"] } qbz-core = { path = "../qbz-core" } qbz-app = { path = "../qbz-app" } qbz-audio = { path = "../qbz-audio" } +# The HiFi Wizard's frontend-agnostic core. NOT `qbz-dac-wizard` — that one is +# the Slint adapter and pulls in qbz-ui + slint, the ~1.6 M-line generated +# module this whole track exists to stay out of. +qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" } qbz-qobuz = { path = "../qbz-qobuz" } qbz-models = { path = "../qbz-models" } qbz-cache = { path = "../qbz-cache" } diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index f79a74667..8ba5fefa1 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -51,7 +51,7 @@ fn main() { // (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain // modules and must NOT be listed — only files that declare a // #[cxx_qt::bridge] mod belong in this array. - rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"], + rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"], qml_files: &[ "qml/LoginScreen.qml", "qml/Main.qml", @@ -67,6 +67,7 @@ fn main() { "qml/cards/TrackCard.qml", "qml/controls/AddToMixtapeModal.qml", "qml/controls/CardMenu.qml", + "qml/controls/CommandBlock.qml", "qml/controls/CardOverlayButton.qml", "qml/controls/CardOverlayRow.qml", "qml/controls/FolderEditPanel.qml", @@ -244,6 +245,7 @@ fn main() { "qml/controls/WarningBanner.qml", "qml/settings/AudioSettings.qml", "qml/settings/BlacklistSettings.qml", + "qml/settings/DacWizardModal.qml", "qml/settings/DeveloperSettings.qml", "qml/settings/LibFolderEditModal.qml", "qml/settings/LibraryFolderTable.qml", diff --git a/crates/qbz-qt/qml/Main.qml b/crates/qbz-qt/qml/Main.qml index a69c0e307..545eae46b 100644 --- a/crates/qbz-qt/qml/Main.qml +++ b/crates/qbz-qt/qml/Main.qml @@ -273,6 +273,14 @@ ApplicationWindow { // importer makes (the whole progress panel, the log, the summary) is // dropped on the floor with nothing logged on either side. QbzPlaylistImport.boot() + // HiFi Wizard. Nothing to seed — the modal is closed until the + // Settings > Audio row opens it — but without this line every publish + // the wizard makes is dropped on the floor: the health verdict, the + // enumerated DACs, the generated config and the live read-back would + // all stay at their defaults, and nothing would be logged on either + // side. The wizard is the loudest case after QbzBlacklist, because its + // first step ends on "Checking your audio stack…" forever. + QbzDacWizard.boot() // Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted // like every other domain singleton, after QbzSession: `boot()` is what // registers the Qt-thread hop, and without it `mini_bridge::ui()` is a diff --git a/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg b/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg new file mode 100644 index 000000000..89ad5bbf2 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/accent/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/black/gandalf.svg b/crates/qbz-qt/qml/assets/icons/black/gandalf.svg new file mode 100644 index 000000000..4c5d0af65 --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/black/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg b/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg new file mode 100644 index 000000000..2cdae4ddd --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/muted/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg b/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg new file mode 100644 index 000000000..ff0b359cf --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/primary/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg b/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg new file mode 100644 index 000000000..0e8277d2c --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/secondary/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg b/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg new file mode 100644 index 000000000..3ed8da3ba --- /dev/null +++ b/crates/qbz-qt/qml/assets/icons/warning/gandalf.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + diff --git a/crates/qbz-qt/qml/controls/CommandBlock.qml b/crates/qbz-qt/qml/controls/CommandBlock.qml new file mode 100644 index 000000000..053cefb4d --- /dev/null +++ b/crates/qbz-qt/qml/controls/CommandBlock.qml @@ -0,0 +1,142 @@ +// CommandBlock — a copy-paste shell command / config snippet. QML port of +// crates/qbz-ui/ui/primitives/CommandBlock.slint. +// +// Surface-elevated card (radiusSm, no pills — ADR-008) with an optional +// caption, a monospace body that WRAPS, and a copy button that flips to a +// green check for 2 s. READ-ONLY: nothing here is ever executed. The height is +// content-sized, so a multi-line command grows the block; callers place it in +// a Column and never set a fixed height. +// +// THE CLIPBOARD. QML has no clipboard API of its own and this port has no +// clipboard invokable, so the copy rides an off-screen TextEdit's own +// `copy()` — the same carrier SandboxSettings.qml already uses. It is +// duplicated there rather than shared because that file's block is a +// different shape (a one-line row with a labelled "Copy" button); this one is +// the wizard's, with a caption above and an icon button pinned to the top. +// +// WHY THE ICON IS `accent` AND NOT `success`. The check glyph after a copy is +// green in the reference (a literal #3fae6a). `tintName: "success"` has NO qrc +// bake directory, so whenever the runtime tint set is unreachable the glyph +// would render as NOTHING at all rather than in a fallback colour +// (theme/QbzIcon.qml, "the one way a bake still silently lies"). The port +// already made this exact call once — WarningBanner.qml maps its `success` +// variant's glyph to the `accent` tint for the same reason — so this follows +// that precedent instead of inventing a one-glyph `success/` directory. The +// TEXT colours in this modal are unaffected: `theme.success` is #3fae6a +// verbatim, which is what the stepper dots and the read-back tick use. + +import QtQuick +import "../theme" + +Rectangle { + id: root + + property string command: "" + // Optional caption rendered above the command (e.g. what it does). + property string caption: "" + property bool justCopied: false + + QbzTheme { id: theme } + + width: parent ? parent.width : 0 + height: col.implicitHeight + 24 + radius: theme.radiusSm + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 8 + + Text { + visible: root.caption !== "" + width: parent.width + text: root.caption + color: theme.textSecondary + font.pixelSize: theme.fontLegal + font.weight: theme.weightMedium + wrapMode: Text.WordWrap + } + + Item { + width: parent.width + height: Math.max(cmdText.implicitHeight, 30) + + // Monospace body — wraps, takes the width left by the button. + // WrapAnywhere, not WordWrap: these are shell lines and long paths + // with no spaces to break on, and WordWrap would push them out of + // the card instead of folding them. + Text { + id: cmdText + anchors.left: parent.left + anchors.right: copyBtn.left + anchors.rightMargin: 10 + anchors.top: parent.top + text: root.command + color: theme.textPrimary + font.family: "monospace" + font.pixelSize: theme.fontLegal + wrapMode: Text.WrapAnywhere + } + + // Off-screen carrier: TextEdit.copy() is the only clipboard write + // QML offers without a C++ seam. + TextEdit { + id: carrier + visible: false + text: root.command + } + + // Copy button — fixed 30x30, pinned to the TOP of the row so a + // tall command does not centre it halfway down the block. + Rectangle { + id: copyBtn + anchors.right: parent.right + anchors.top: parent.top + width: 30 + height: 30 + radius: theme.radiusSm + border.width: 1 + border.color: theme.borderSubtle + color: copyTa.containsMouse ? theme.surfaceHover : theme.surfaceCard + + QbzIcon { + anchors.centerIn: parent + width: 15 + height: 15 + name: root.justCopied ? "check" : "copy" + tintName: root.justCopied + ? "accent" + : (copyTa.containsMouse ? "textPrimary" : "muted") + } + + MouseArea { + id: copyTa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + carrier.selectAll() + carrier.copy() + carrier.deselect() + root.justCopied = true + resetTimer.restart() + } + } + } + } + } + + // Resets the "copied" check after 2s (reference: `reset-timer`, 2s). + Timer { + id: resetTimer + interval: 2000 + repeat: false + onTriggered: root.justCopied = false + } +} diff --git a/crates/qbz-qt/qml/settings/AudioSettings.qml b/crates/qbz-qt/qml/settings/AudioSettings.qml index 19a69ac72..6110ff19a 100644 --- a/crates/qbz-qt/qml/settings/AudioSettings.qml +++ b/crates/qbz-qt/qml/settings/AudioSettings.qml @@ -9,8 +9,12 @@ // - "Detected device limit" + its fallback disclosure (#638): the device-cap // probe (`device_cap.rs`) is Slint glue that was never ported, so there is // no measured value to show. -// - "HiFi Wizard": the guided PipeWire setup (DacWizardActions) is a whole -// modal flow, not a settings row. +// +// The HiFi Wizard IS shipped (2026-08-11): the last row of OUTPUT opens +// settings/DacWizardModal.qml, mounted at the SettingsView root. Its logic +// lives in `qbz-dac-wizard-core`, the frontend-agnostic crate extracted for +// this port. Placement and backend gating diverge from the reference — see +// the row itself. import QtQuick import com.blitzfc.qbz @@ -125,6 +129,28 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("dsd-mode", i) } } } + // HiFi Wizard — guided bit-perfect DAC setup. The LAST row of OUTPUT, + // under "Output device", and shown for EVERY backend. + // + // TWO DIVERGENCES FROM THE REFERENCE, both owner rulings (2026-08-11): + // it lives in OUTPUT, not at the end of BIT-PERFECT; and it is NOT gated + // on `backend-is-pipewire` the way `AudioSettings.slint:282` gates it. + // The reference hides it off PipeWire because the config it generates is + // PipeWire/WirePlumber — but that is exactly who needs it: someone on ALSA + // direct who wants to set their DAC up cannot reach the wizard that would + // walk them through it. Do not "restore parity" by putting the gate back. + // + // Opening it resets the wizard and kicks the audio-stack probe off the UI + // thread, so there is nothing to arm here. + SettingRow { + label: QbzSession.tr("HiFi Wizard", QbzSession.trRev) + description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev) + SettingsButton { + iconName: "gandalf" + text: QbzSession.tr("Open Wizard", QbzSession.trRev) + onClicked: QbzDacWizard.open() + } + } SettingsSpacer { } SettingsDivider { } diff --git a/crates/qbz-qt/qml/settings/DacWizardModal.qml b/crates/qbz-qt/qml/settings/DacWizardModal.qml new file mode 100644 index 000000000..df6c30532 --- /dev/null +++ b/crates/qbz-qt/qml/settings/DacWizardModal.qml @@ -0,0 +1,1091 @@ +// HiFi Wizard (DAC setup) — QML port of +// crates/qbz-ui/ui/primitives/DacWizardModal.slint. +// +// Six steps: Welcome · Check · DACs · Configure · Test · Done. The wizard is +// READ-ONLY — it auto-detects DACs, shows the exact commands and config to +// copy, and plays four test tracks so the user can watch their DAC switch +// rates. It NEVER writes a system file. +// +// MODAL RECIPE, from the reference (:8-9): an overlay Item, NOT a window grab, +// so playback keeps running underneath while the test step plays. The scrim, +// the header X and the footer Close all just hide it; Rust's `close()` stops +// nothing. +// +// MOUNTED AT THE SETTINGS VIEW ROOT, beside SettingsConfirmHost — the panels +// are Columns inside a Flickable, so a modal mounted in one would be sized by +// the scrolled content and ride the scroll. ADR-009: z >= 3000. +// +// ── WHAT THIS FILE OWNS AND WHAT RUST OWNS ──────────────────────────────── +// Rust (dac_wizard_qt.rs) owns everything it COMPUTES — the probe verdict, the +// candidate list AND its checkboxes, the manual node name's validity, the +// generated configs and their accordion state, the read-back labels — plus +// `open`, because the Settings row's click must reset and probe atomically. +// +// This file owns the pure NAVIGATION state: `step`, the welcome checkbox, the +// three review progress checkboxes and the manual-entry disclosure. That is +// the reference's own split — DacWizardModal.slint mutates those inline and +// never calls Rust for them (:817 `step -= 1`, :853 `step += 1`). They reset +// on `openSeq`, which Rust bumps on every open. +// +// The one field that is deliberately BOTH: the manual node.name. Rust stores +// it (checked_dacs reads it) but this file keeps the TextInput's own text and +// never writes the document back into it — republishing into a focused input +// is how a cursor jumps to the end mid-word. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" +import "../views/local" + +Item { + id: root + + // An unopened overlay is an invisible, non-interactive Item and costs + // nothing. `anchors.fill` + `enabled` live HERE, not at the mount site — + // the sibling LibFolderEditModal.qml sets the same three, so a mount is + // one line. + anchors.fill: parent + z: 3000 + visible: doc.open === true + enabled: root.visible + + readonly property var doc: JSON.parse(QbzDacWizard.wizardJson) + readonly property var candidates: doc.candidates || [] + readonly property var remediations: doc.remediations || [] + readonly property var dacConfigs: doc.dacConfigs || [] + readonly property var createdPaths: doc.createdPaths || [] + readonly property var testTrackLines: doc.testTracks || [] + + // ---- QML-owned navigation state (see the header) ---------------------- + // 0 welcome · 1 check · 2 select-dacs · 3 review · 4 test · 5 done + property int step: 0 + property bool welcomeConfirmed: false + property bool reviewBackupDone: false + property bool reviewConfigDone: false + property bool reviewRestartDone: false + // "Can't see your DAC?" is collapsed by default when enumeration found + // devices; forced open when it found none. + property bool showManual: false + + readonly property int openSeq: doc.openSeq || 0 + onOpenSeqChanged: { + root.step = 0 + root.welcomeConfirmed = false + root.reviewBackupDone = false + root.reviewConfigDone = false + root.reviewRestartDone = false + root.showManual = false + manualInput.text = "" + } + + // Step labels for the header rail (index = step). + readonly property var stepLabels: [ + QbzSession.tr("Welcome", QbzSession.trRev), + QbzSession.tr("Check", QbzSession.trRev), + QbzSession.tr("DACs", QbzSession.trRev), + QbzSession.tr("Configure", QbzSession.trRev), + QbzSession.tr("Test", QbzSession.trRev), + QbzSession.tr("Done", QbzSession.trRev) + ] + + // The footer primary's gate, per step (reference :832-840): + // 0 → the accept checkbox + // 2 → at least one DAC checked, or a valid manual node.name + // 3 → all three review checkboxes + // else → always enabled + readonly property bool primaryEnabled: { + if (root.step === 0) + return root.welcomeConfirmed + if (root.step === 2) + return (root.doc.anyDacSelected === true) || (root.doc.manualValid === true) + if (root.step === 3) + return root.reviewBackupDone && root.reviewConfigDone && root.reviewRestartDone + return true + } + + QbzTheme { id: theme } + + // A whole-row-clickable checkbox (HTML