feat(qbzd): ratatui setup TUI, six screens with explicit-save semantics

Implements owner requirement 2 (03-setup-tui.md): `qbzd setup`, a six-screen
raspi-config-grade configurator over the daemon's real stores — zero new
persistence. ratatui 0.30 (IV5: unifies on the lock's unicode-width 0.2.2;
0.29 forbidden). A synchronous event loop on a blocking thread; disk/HTTP only
at screen entry, `r`, save and the immediate actions, on workers with a spinner
(no I/O on keystrokes). Non-tty exits 2 with the verbatim copy; alt-screen +
panic-hook restore.

Screens: Account (T5 login engine; inline masked token paste, suspend-and-run
browser login, logout confirm), Audio (the §3.2.3 constraint matrix + 7
cascades verified 1:1 vs the desktop code, device picker re-deriving the
§3.2.2 grouping/BP-badge rules, DSD guard), Playback (ask->fallback rule,
infinite-radio read-only), QConnect (startup/name/volume_mode, live preview),
Network (whole-file qbzd.toml rewrite preserving every key), Import/Export
(pure renderer of the T12 bundle plan; interactive device re-pick; default-off
auth gate). Saves reuse T11 write_one + reload/status classification (§4.3);
dirty marker + Save/Discard/Stay. All user-facing strings centralized.

cc
This commit is contained in:
vicrodh committed 2026-07-14 20:42:42 -06:00
1 parent 8021393925
commit a1fbbafc79
15 files changed
+5554 -145

No files matched your search

+5 -1
View File
@@ -44,7 +44,11 @@ urlencoding = "2" # OAuth redirect_url encode + callback decode (in l
open = "5" # system-browser launch for `qbzd login` (in lock via qbz)
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
# ratatui arrives in T13 — do NOT add it here
# T13 setup TUI. ratatui 0.30 (NEVER 0.29 — 0.29 pins unicode-width "=0.2.0",
# unresolvable against Slint's annotate-snippets 0.12.16 which needs >=0.2.2;
# ratatui-core 0.30 accepts >=0.2.0,<=0.2.2 → unifies on the lock's 0.2.2, IV5).
# The `crossterm` backend is a DEFAULT feature; re-exported as ratatui::crossterm.
ratatui = "0.30"
[dev-dependencies]
tempfile = "3"
+4 -2
View File
@@ -338,8 +338,10 @@ fn read_all(roots: &ProfileRoots) -> Result<Vec<(&'static str, String)>, String>
}
/// Validate + write ONE canonical key. Returns its [`ApplyClass`] on success
/// (the CLI's own success-line hint — see module doc).
fn write_one(roots: &ProfileRoots, key: &str, raw: &str) -> Result<ApplyClass, String> {
/// (the CLI's own success-line hint — see module doc). `pub(crate)` so the T13
/// setup TUI persists every screen through this SAME validated writer (03 §6 —
/// the TUI adds no persistence of its own).
pub(crate) fn write_one(roots: &ProfileRoots, key: &str, raw: &str) -> Result<ApplyClass, String> {
let Some(class) = classify(key) else {
return Err(unknown_key_error(key));
};
+7 -1
View File
@@ -10,6 +10,7 @@ mod login;
mod paths;
mod qconnect;
mod state;
mod tui;
pub const API_VERSION: u32 = 1; // 02-cli-and-api.md §1.6
@@ -279,7 +280,12 @@ async fn main() {
ConfigCmd::Show { json } => cli::settings::config_show(json, &roots),
}
}
_ => { eprintln!("not implemented yet"); 1 } // burned down task by task
Cmd::Setup => {
// The setup TUI edits the daemon's REAL stores at the daemon roots,
// honoring a `qbzd.toml` `data_root` override exactly like `run`.
let roots = login_roots();
tui::run(roots).await
}
};
std::process::exit(code);
}
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
// crates/qbzd/src/tui/mod.rs — `qbzd setup` entry, terminal lifecycle, event loop
// (03-setup-tui.md §2). Non-tty is rejected before any terminal mutation (§2.4).
// `ratatui::init()` enables raw mode + the alternate screen AND installs a panic
// hook that restores the terminal on any panic (§2.1 — a wedged terminal over
// SSH is a support fire); `ratatui::restore()` restores on the normal exit path.
//
// The event loop runs synchronously on a dedicated blocking thread (spawned off
// the tokio runtime), holding a runtime `Handle` so the discrete I/O actions
// (§5.5: screen entry, `r`, save, immediate actions) run on workers or a
// short-lived `block_on` — never on a keystroke.
pub mod app;
pub mod strings;
pub mod widgets;
mod screens;
use std::io::IsTerminal;
use std::time::Duration;
use ratatui::crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use tokio::runtime::Handle;
use crate::paths::ProfileRoots;
use app::{App, LoopCmd};
/// Poll cadence — also the spinner tick while a worker runs (§5.5). No I/O here;
/// the loop only redraws and drains the worker channel.
const TICK: Duration = Duration::from_millis(120);
/// `qbzd setup` entry. Exit 2 on a non-tty (§2.4); else runs the configurator and
/// returns 0.
pub async fn run(roots: ProfileRoots) -> i32 {
// §2.4: reject a non-interactive invocation BEFORE touching the terminal.
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
eprintln!("{}", strings::NON_TTY_ERROR);
return 2;
}
let handle = Handle::current();
// The synchronous ratatui loop runs on a blocking thread so its `block_on`
// calls are legal (a runtime worker thread cannot block_on itself).
tokio::task::spawn_blocking(move || run_sync(roots, handle))
.await
.unwrap_or(1)
}
fn run_sync(roots: ProfileRoots, handle: Handle) -> i32 {
let mut terminal = ratatui::init();
let mut app = App::new(roots, handle.clone());
let code = event_loop(&mut terminal, &mut app, &handle);
ratatui::restore();
code
}
fn event_loop(terminal: &mut DefaultTerminal, app: &mut App, handle: &Handle) -> i32 {
loop {
if terminal.draw(|f| app.draw(f)).is_err() {
return 1;
}
app.drain_worker();
if app.busy() {
app.busy_tick = app.busy_tick.wrapping_add(1);
}
if event::poll(TICK).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match app.on_key(key) {
LoopCmd::None => {}
LoopCmd::BrowserLogin => run_browser_login(terminal, app, handle),
},
Ok(_) => {} // resize/mouse/etc. — the next draw re-lays-out (§5.4)
Err(_) => return 1,
}
}
if app.should_quit() {
return 0;
}
}
}
/// Suspend the alt-screen and run the T5 browser-login engine on the plain
/// terminal (it prints the OAuth URL, waits 300 s, and prints the SSH-forward
/// hint on failure), then resume the TUI. Deliberate divergence from the §3.1
/// in-panel countdown — see the task report.
fn run_browser_login(terminal: &mut DefaultTerminal, app: &mut App, handle: &Handle) {
ratatui::restore();
println!("{}\n", strings::ACCOUNT_BROWSER_HANDOFF);
let roots = app.roots().clone();
let result = handle.block_on(async { crate::login::login_browser(&roots, None).await });
*terminal = ratatui::init();
let mapped = result
.map(|session| (session.email, Some(session.subscription_label)))
.map_err(|e| e.to_string());
app.after_browser_login(mapped);
}
+181
View File
@@ -0,0 +1,181 @@
// crates/qbzd/src/tui/screens/account.rs — the Account screen (03 §3.1).
//
// Status + inline auth. All auth work reuses the T5 engine (login.rs) — the TUI
// adds zero auth logic. Token paste is fully inline (masked input →
// login_with_token_arg, which validates via validate_token BEFORE persisting).
// Browser login is a suspend-and-run handoff to the engine (see the task report):
// the TUI leaves the alternate screen, the engine prints the URL + 300 s wait +
// the SSH-forward hint on failure, then the TUI resumes. The Status row NEVER
// fabricates a name offline — it shows only "credential file present".
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::widgets::{self, InputOutcome, TextInput};
/// Snapshot of the auth state the App resolves (from `GET /api/status` when the
/// daemon runs, else from credential-file presence).
#[derive(Debug, Clone, Default)]
pub struct AuthSnapshot {
pub logged_in: bool,
pub email: Option<String>,
pub plan: Option<String>,
/// Offline + daemon-down: a credential file exists but is unvalidated.
pub cred_file_present: bool,
}
pub struct AccountState {
auth: AuthSnapshot,
focus: usize,
token_input: Option<TextInput>,
confirm_logout: bool,
}
impl AccountState {
pub fn new(auth: AuthSnapshot) -> Self {
Self {
auth,
focus: 0,
token_input: None,
confirm_logout: false,
}
}
pub fn set_auth(&mut self, auth: AuthSnapshot) {
self.auth = auth;
}
// Account is all immediate actions — it never participates in dirty-state,
// so the App's `active_is_dirty` short-circuits it (no `is_dirty` here).
pub fn is_editing(&self) -> bool {
self.token_input.is_some() || self.confirm_logout
}
pub fn summary(&self) -> String {
if self.auth.logged_in {
match (&self.auth.email, &self.auth.plan) {
(Some(e), Some(p)) => format!("logged in as {e} ({p})"),
(Some(e), None) => format!("logged in as {e}"),
_ => "logged in".to_string(),
}
} else if self.auth.cred_file_present {
"credential present".to_string()
} else {
"not logged in".to_string()
}
}
/// Action rows in focus order, depending on login state.
fn actions(&self) -> Vec<&'static str> {
if self.auth.logged_in {
vec![s::ACCOUNT_LOGOUT, s::ACCOUNT_PASTE_TOKEN]
} else {
vec![s::ACCOUNT_LOGIN_BROWSER, s::ACCOUNT_PASTE_TOKEN]
}
}
// -------------------------- input --------------------------
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
if let Some(mut input) = self.token_input.take() {
match input.handle_key(key) {
InputOutcome::Accepted => {
let token = input.buf.trim().to_string();
if token.is_empty() {
return ScreenAction::Consumed;
}
return ScreenAction::LoginToken(token);
}
InputOutcome::Cancelled => return ScreenAction::Consumed,
InputOutcome::Pending => {
self.token_input = Some(input);
return ScreenAction::Consumed;
}
}
}
if self.confirm_logout {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.confirm_logout = false;
return ScreenAction::Logout;
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
self.confirm_logout = false;
return ScreenAction::Consumed;
}
_ => return ScreenAction::Consumed,
}
}
let actions = self.actions();
match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
self.focus = if self.focus == 0 { actions.len() - 1 } else { self.focus - 1 };
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
self.focus = (self.focus + 1) % actions.len();
ScreenAction::Consumed
}
KeyCode::Enter | KeyCode::Char(' ') => match actions[self.focus] {
s::ACCOUNT_LOGIN_BROWSER => ScreenAction::LoginBrowser,
s::ACCOUNT_PASTE_TOKEN => {
self.token_input = Some(TextInput::new("", true));
ScreenAction::Consumed
}
s::ACCOUNT_LOGOUT => {
self.confirm_logout = true;
ScreenAction::Consumed
}
_ => ScreenAction::Consumed,
},
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let mut lines: Vec<Line> = Vec::new();
// Status row — never fabricates a name (§3.1).
let status = if self.auth.logged_in {
match (&self.auth.email, &self.auth.plan) {
(Some(e), Some(p)) => s::account_logged_in_plan(e, p),
(Some(e), None) => s::account_logged_in(e),
_ => "logged in".to_string(),
}
} else if self.auth.cred_file_present {
s::ACCOUNT_CRED_PRESENT.to_string()
} else {
s::ACCOUNT_NOT_LOGGED_IN.to_string()
};
lines.push(widgets::field_line(s::ACCOUNT_STATUS, &status, false, true, None, ""));
lines.push(widgets::blank());
for (i, action) in self.actions().iter().enumerate() {
let focused = i == self.focus && !self.is_editing();
lines.push(widgets::action_line(&format!("> {action}"), focused, true));
}
f.render_widget(Paragraph::new(lines), area);
if let Some(input) = &self.token_input {
widgets::modal(f, area, s::ACCOUNT_PASTE_TOKEN, &input.display(), s::HELP_INPUT);
} else if self.confirm_logout {
widgets::modal(
f,
area,
s::ACCOUNT_LOGOUT_CONFIRM_TITLE,
s::ACCOUNT_LOGOUT_CONFIRM_BODY,
s::CONFIRM_YN,
);
}
}
}
File diff suppressed because it is too large. Load diff
+454
View File
@@ -0,0 +1,454 @@
// crates/qbzd/src/tui/screens/bundle.rs — Import / Export (03 §3.6).
//
// A pure RENDERER of the qbz-app::settings::bundle engine's plan — zero
// classification logic of its own (in-band classification was the D9 safety
// flaw). Import: path → the App plans on a worker → this screen shows the three
// buckets; an absent device opens the §3.2.2 device picker for a re-pick
// (replan is pure, done here); one confirm applies. The auth domain has its own
// dedicated, default-OFF gate. Export: destination + include-auth toggle
// (default off, warning while on); the source is ALWAYS this box's daemon
// profile.
use qbz_app::settings::bundle::{
self, Bundle, DeviceChoice, ImportOptions, ImportPlan, LiveSystem, PlanLine, ProfilePaths,
};
use qbz_audio::{AudioBackendType, AudioDevice};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use super::audio::{group_devices, DeviceEntry};
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::widgets::{self, InputOutcome, SelectOutcome, SelectPopup, TextInput};
/// Everything a planned import carries between the plan worker, the re-pick, and
/// the apply worker. The `live` snapshot is captured once so a re-pick replans
/// without touching hardware again.
pub struct PendingImport {
pub bundle: Bundle,
pub plan: ImportPlan,
pub live: LiveSystem,
pub opts: ImportOptions,
pub target: ProfilePaths,
pub backend: AudioBackendType,
pub devices: Vec<AudioDevice>,
pub device_choice: Option<DeviceChoice>,
pub has_auth: bool,
pub apply_with_auth: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BField {
ImportPath,
Review,
ExportDest,
IncludeAuth,
Export,
}
const FIELDS: [BField; 5] = [
BField::ImportPath,
BField::Review,
BField::ExportDest,
BField::IncludeAuth,
BField::Export,
];
enum Editor {
ImportPath(TextInput),
ExportDest(TextInput),
}
pub struct BundleState {
focus: usize,
editor: Option<Editor>,
import_path: String,
export_dest: String,
include_auth: bool,
has_desktop: bool,
// review mode:
pending: Option<PendingImport>,
device_picker: Option<SelectPopup>,
picker_entries: Vec<DeviceEntry>,
auth_confirm: bool,
scroll: u16,
}
impl BundleState {
pub fn new(has_desktop: bool) -> Self {
Self {
focus: 0,
editor: None,
import_path: String::new(),
export_dest: format!("~/{}", bundle::default_filename()),
include_auth: false,
has_desktop,
pending: None,
device_picker: None,
picker_entries: Vec::new(),
auth_confirm: false,
scroll: 0,
}
}
// Bundle is all immediate actions — never dirty (the App short-circuits it).
pub fn is_editing(&self) -> bool {
self.editor.is_some()
|| self.pending.is_some()
|| self.device_picker.is_some()
|| self.auth_confirm
}
/// Store a fresh plan from the App's worker (§3.6 step 3).
pub fn set_plan(&mut self, planned: PendingImport) {
self.scroll = 0;
self.pending = Some(planned);
}
/// The data the App's apply worker needs; None when nothing is pending.
pub fn apply_context(
&self,
) -> Option<(Bundle, ProfilePaths, LiveSystem, ImportOptions, Option<DeviceChoice>, bool)> {
self.pending.as_ref().map(|p| {
(
p.bundle.clone(),
p.target.clone(),
p.live.clone(),
p.opts.clone(),
p.device_choice.clone(),
p.apply_with_auth,
)
})
}
pub fn clear_pending(&mut self) {
self.pending = None;
self.device_picker = None;
self.auth_confirm = false;
self.import_path.clear();
}
// -------------------------- input --------------------------
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
// Overlays first.
if self.device_picker.is_some() {
return self.handle_picker_key(key);
}
if self.auth_confirm {
return self.handle_auth_confirm(key);
}
if self.pending.is_some() {
return self.handle_review_key(key);
}
if let Some(editor) = self.editor.take() {
return self.handle_editor_key(editor, key);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
self.focus = if self.focus == 0 { FIELDS.len() - 1 } else { self.focus - 1 };
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
self.focus = (self.focus + 1) % FIELDS.len();
ScreenAction::Consumed
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
fn activate(&mut self) -> ScreenAction {
match FIELDS[self.focus] {
BField::ImportPath => {
self.editor = Some(Editor::ImportPath(TextInput::new(&self.import_path, false)));
ScreenAction::Consumed
}
BField::Review => {
if self.import_path.trim().is_empty() {
ScreenAction::Consumed
} else {
ScreenAction::ImportPlan(self.import_path.trim().to_string())
}
}
BField::ExportDest => {
self.editor = Some(Editor::ExportDest(TextInput::new(&self.export_dest, false)));
ScreenAction::Consumed
}
BField::IncludeAuth => {
self.include_auth ^= true;
ScreenAction::Consumed
}
BField::Export => {
ScreenAction::Export {
dest: self.export_dest.clone(),
include_auth: self.include_auth,
}
}
}
}
fn handle_editor_key(&mut self, editor: Editor, key: KeyEvent) -> ScreenAction {
match editor {
Editor::ImportPath(mut input) => match input.handle_key(key) {
InputOutcome::Accepted => {
self.import_path = input.buf.trim().to_string();
ScreenAction::Consumed
}
InputOutcome::Cancelled => ScreenAction::Consumed,
InputOutcome::Pending => {
self.editor = Some(Editor::ImportPath(input));
ScreenAction::Consumed
}
},
Editor::ExportDest(mut input) => match input.handle_key(key) {
InputOutcome::Accepted => {
self.export_dest = input.buf.trim().to_string();
ScreenAction::Consumed
}
InputOutcome::Cancelled => ScreenAction::Consumed,
InputOutcome::Pending => {
self.editor = Some(Editor::ExportDest(input));
ScreenAction::Consumed
}
},
}
}
fn handle_review_key(&mut self, key: KeyEvent) -> ScreenAction {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll = self.scroll.saturating_sub(1);
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll = self.scroll.saturating_add(1);
ScreenAction::Consumed
}
KeyCode::Char('p') => {
self.open_device_picker();
ScreenAction::Consumed
}
KeyCode::Enter => {
let has_auth = self.pending.as_ref().map(|p| p.has_auth).unwrap_or(false);
if has_auth {
self.auth_confirm = true;
ScreenAction::Consumed
} else {
if let Some(p) = self.pending.as_mut() {
p.apply_with_auth = false;
}
ScreenAction::ImportApply
}
}
KeyCode::Esc => {
self.pending = None;
ScreenAction::Consumed
}
_ => ScreenAction::Consumed,
}
}
fn handle_auth_confirm(&mut self, key: KeyEvent) -> ScreenAction {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(p) = self.pending.as_mut() {
p.apply_with_auth = true;
}
self.auth_confirm = false;
ScreenAction::ImportApply
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
if let Some(p) = self.pending.as_mut() {
p.apply_with_auth = false;
}
self.auth_confirm = false;
ScreenAction::ImportApply
}
_ => ScreenAction::Consumed,
}
}
fn open_device_picker(&mut self) {
let Some(pending) = &self.pending else { return };
// Only meaningful when the plan flagged a device re-pick.
if pending.plan.device_pick.is_none() {
return;
}
self.picker_entries = group_devices(pending.backend, pending.devices.clone());
let options: Vec<String> = self
.picker_entries
.iter()
.map(|d| if d.bp { format!("{} {}", d.label, s::BP_BADGE) } else { d.label.clone() })
.collect();
let headers: Vec<Option<String>> = self.picker_entries.iter().map(|d| d.header.clone()).collect();
self.device_picker =
Some(SelectPopup::new(s::DEVICE_PICKER_TITLE, options, 0, true).with_headers(headers));
}
fn handle_picker_key(&mut self, key: KeyEvent) -> ScreenAction {
let mut picker = self.device_picker.take().unwrap();
match picker.handle_key(key) {
SelectOutcome::Chosen(i) => {
if let Some(entry) = self.picker_entries.get(i).cloned() {
let choice = if entry.id.is_empty() {
DeviceChoice::SystemDefault
} else {
DeviceChoice::Device { id: entry.id, label: entry.label }
};
self.replan_with(choice);
}
ScreenAction::Consumed
}
SelectOutcome::Cancelled => ScreenAction::Consumed,
SelectOutcome::Pending => {
self.device_picker = Some(picker);
ScreenAction::Consumed
}
}
}
/// Re-run the plan with the operator's device choice (pure — no I/O; the
/// `live` snapshot was captured at plan time).
fn replan_with(&mut self, choice: DeviceChoice) {
let Some(pending) = self.pending.as_mut() else { return };
match bundle::replan_with_device(
&pending.bundle,
&pending.target,
&pending.opts,
&pending.live,
choice.clone(),
) {
Ok(plan) => {
pending.plan = plan;
pending.device_choice = Some(choice);
self.scroll = 0;
}
Err(_) => {}
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
if self.pending.is_some() {
self.draw_review(f, area);
return;
}
let mut lines: Vec<Line> = Vec::new();
lines.push(widgets::group_header(s::BUNDLE_IMPORT_HEADER));
let path_val = if self.import_path.is_empty() {
s::B_IMPORT_PATH_HINT.to_string()
} else {
self.import_path.clone()
};
lines.push(self.row(BField::ImportPath, s::B_IMPORT_PATH, &path_val, "[input]"));
lines.push(self.action_row(BField::Review, s::B_IMPORT_ACTION));
lines.push(widgets::blank());
lines.push(widgets::group_header(s::BUNDLE_EXPORT_HEADER));
lines.push(self.row(BField::ExportDest, s::B_EXPORT_DEST, &self.export_dest, "[input]"));
lines.push(self.row(
BField::IncludeAuth,
s::B_EXPORT_INCLUDE_AUTH,
if self.include_auth { "on" } else { "off" },
"[toggle]",
));
if self.include_auth {
for l in s::B_EXPORT_AUTH_WARNING.lines() {
lines.push(widgets::note_line(l));
}
}
lines.push(self.action_row(BField::Export, s::B_EXPORT_ACTION));
if self.has_desktop {
lines.push(widgets::blank());
for l in s::B_DESKTOP_HINT.lines() {
lines.push(widgets::note_line(l));
}
}
f.render_widget(Paragraph::new(lines), area);
match &self.editor {
Some(Editor::ImportPath(input)) => {
widgets::modal(f, area, s::B_IMPORT_PATH, &input.display(), s::HELP_INPUT)
}
Some(Editor::ExportDest(input)) => {
widgets::modal(f, area, s::B_EXPORT_DEST, &input.display(), s::HELP_INPUT)
}
None => {}
}
}
fn row(&self, field: BField, label: &str, value: &str, widget: &str) -> Line<'static> {
let focused = FIELDS[self.focus] == field && self.editor.is_none();
widgets::field_line(label, value, focused, true, None, widget)
}
fn action_row(&self, field: BField, label: &str) -> Line<'static> {
let focused = FIELDS[self.focus] == field && self.editor.is_none();
widgets::action_line(&format!("> {label}"), focused, true)
}
fn draw_review(&self, f: &mut Frame, area: Rect) {
let Some(p) = &self.pending else { return };
let mut lines: Vec<Line> = Vec::new();
bucket(&mut lines, s::B_BUCKET_APPLIED, &p.plan.applied, BucketKind::Applied);
lines.push(widgets::blank());
bucket(&mut lines, s::B_BUCKET_ADAPTED, &p.plan.adapted, BucketKind::Adapted);
if p.plan.device_pick.is_some() {
lines.push(widgets::note_line("press p to pick a local device for the missing one"));
}
lines.push(widgets::blank());
bucket(&mut lines, s::B_BUCKET_SKIPPED, &p.plan.skipped, BucketKind::Skipped);
widgets::panel(f, area, s::BUNDLE_TITLE, lines, self.scroll);
if self.device_picker.is_some() {
self.device_picker.as_ref().unwrap().draw(f, area);
} else if self.auth_confirm {
widgets::modal(
f,
area,
s::B_IMPORT_AUTH_TITLE,
s::B_IMPORT_AUTH_BODY,
s::B_IMPORT_AUTH_HINT,
);
}
}
}
enum BucketKind {
Applied,
Adapted,
Skipped,
}
fn bucket(lines: &mut Vec<Line<'static>>, title: &str, rows: &[PlanLine], kind: BucketKind) {
lines.push(Line::from(Span::styled(
format!("{title} ({})", rows.len()),
Style::default().add_modifier(Modifier::BOLD),
)));
for l in rows {
let text = match kind {
BucketKind::Applied => format!(" {} = {}", l.key, l.new),
BucketKind::Adapted => format!(
" {} {} -> {} ({})",
l.key,
l.old.as_deref().unwrap_or(""),
l.new,
l.why
),
BucketKind::Skipped => format!(" {} {}", l.key, l.why),
};
lines.push(Line::from(text));
}
}
+7
View File
@@ -0,0 +1,7 @@
// crates/qbzd/src/tui/screens/mod.rs — the six setup screens (D7 hard cap).
pub mod account;
pub mod audio;
pub mod bundle;
pub mod network;
pub mod playback;
pub mod qconnect;
+295
View File
@@ -0,0 +1,295 @@
// crates/qbzd/src/tui/screens/network.rs — the Network screen (03 §3.5).
//
// Edits ONLY [server] bind/port/token in qbzd.toml. The save is a whole-file
// parse → update → rewrite that preserves EVERY other key (known schema keys and
// unrecognized ones alike) — the J5 silent-revert guard. bind/port/token cannot
// rebind live, so the save result names the restart. The LAN-exposure warning is
// shown verbatim when bind is non-loopback.
use std::net::IpAddr;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::config::QbzdConfig;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::widgets::{self, InputOutcome, TextInput};
#[derive(Debug, Clone, PartialEq)]
struct Staged {
bind: String,
port: String,
token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NField {
Bind,
Port,
Token,
}
const FIELDS: [NField; 3] = [NField::Bind, NField::Port, NField::Token];
pub struct NetworkState {
baseline: Staged,
staged: Staged,
focus: usize,
editor: Option<(NField, TextInput)>,
/// Unrecognized qbzd.toml keys (named in the pre-save warning, §3.5).
unknown_keys: Vec<String>,
}
impl NetworkState {
pub fn new(cfg: &QbzdConfig, unknown_keys: Vec<String>) -> Self {
let staged = Staged {
bind: cfg.server.bind.clone(),
port: cfg.server.port.to_string(),
token: cfg.server.token.clone().unwrap_or_default(),
};
Self {
baseline: staged.clone(),
staged,
focus: 0,
editor: None,
unknown_keys,
}
}
pub fn is_dirty(&self) -> bool {
self.staged != self.baseline
}
pub fn is_editing(&self) -> bool {
self.editor.is_some()
}
pub fn mark_saved(&mut self) {
self.baseline = self.staged.clone();
}
/// Validated (bind, port, token) ready for the TOML rewrite, or field errors.
pub fn validated(&self) -> Result<(String, u16, Option<String>), String> {
if self.staged.bind.parse::<IpAddr>().is_err() {
return Err(s::N_BAD_IP.to_string());
}
let port: u16 = match self.staged.port.parse() {
Ok(p) if p >= 1 => p,
_ => return Err(s::N_BAD_PORT.to_string()),
};
let token = if self.staged.token.trim().is_empty() {
None
} else {
Some(self.staged.token.clone())
};
Ok((self.staged.bind.clone(), port, token))
}
pub fn summary(&self) -> String {
let auth = if self.staged.token.trim().is_empty() { "open" } else { "token" };
format!("{}:{} · {auth}", self.staged.bind, self.staged.port)
}
/// A non-loopback bind is reachable beyond localhost (§3.5); 0.0.0.0
/// (unspecified) binds every interface, so it warns too.
fn bind_is_lan(&self) -> bool {
self.staged
.bind
.parse::<IpAddr>()
.map(|ip| !ip.is_loopback())
.unwrap_or(false)
}
fn port_invalid(&self) -> bool {
!self.staged.port.parse::<u16>().map(|p| p >= 1).unwrap_or(false)
}
// -------------------------- input --------------------------
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
if let Some((field, mut input)) = self.editor.take() {
match input.handle_key(key) {
InputOutcome::Accepted => {
let v = input.buf.trim().to_string();
match field {
NField::Bind => self.staged.bind = v,
NField::Port => self.staged.port = v,
NField::Token => self.staged.token = input.buf.clone(),
}
}
InputOutcome::Cancelled => {}
InputOutcome::Pending => self.editor = Some((field, input)),
}
return ScreenAction::Consumed;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
self.focus = if self.focus == 0 { FIELDS.len() - 1 } else { self.focus - 1 };
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
self.focus = (self.focus + 1) % FIELDS.len();
ScreenAction::Consumed
}
KeyCode::Char('s') => ScreenAction::Save,
KeyCode::Enter => {
let field = FIELDS[self.focus];
let (initial, masked) = match field {
NField::Bind => (self.staged.bind.clone(), false),
NField::Port => (self.staged.port.clone(), false),
NField::Token => (self.staged.token.clone(), true),
};
self.editor = Some((field, TextInput::new(&initial, masked)));
ScreenAction::Consumed
}
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let mut lines: Vec<Line> = Vec::new();
for (i, field) in FIELDS.iter().enumerate() {
let focused = i == self.focus && self.editor.is_none();
let editing = self.editor.as_ref().map(|(nf, _)| *nf == *field).unwrap_or(false);
let (label, value, widget) = match field {
NField::Bind => (s::N_BIND, self.field_value(NField::Bind, editing), "[input]"),
NField::Port => (s::N_PORT, self.field_value(NField::Port, editing), "[input]"),
NField::Token => {
let v = if editing {
self.editor.as_ref().map(|(_, i)| i.display()).unwrap_or_default()
} else if self.staged.token.trim().is_empty() {
s::N_TOKEN_HINT.to_string()
} else {
widgets::mask(&self.staged.token)
};
(s::N_TOKEN, v, "[input]")
}
};
lines.push(widgets::field_line(label, &value, focused, true, None, widget));
}
// Field-level validation notes (§4.2).
if self.staged.bind.parse::<IpAddr>().is_err() {
lines.push(widgets::note_line(s::N_BAD_IP));
} else if self.bind_is_lan() {
lines.push(widgets::blank());
for l in s::NETWORK_LAN_WARNING.lines() {
lines.push(widgets::note_line(l));
}
}
if self.port_invalid() {
lines.push(widgets::note_line(s::N_BAD_PORT));
}
// Pre-save unknown-key warning (§3.5).
if !self.unknown_keys.is_empty() {
lines.push(widgets::blank());
lines.push(widgets::note_line(s::N_DROP_UNKNOWN));
lines.push(widgets::note_line(&format!(" {}", self.unknown_keys.join(", "))));
}
f.render_widget(Paragraph::new(lines), area);
if let Some((field, input)) = &self.editor {
let title = match field {
NField::Bind => s::N_BIND,
NField::Port => s::N_PORT,
NField::Token => s::N_TOKEN,
};
widgets::modal(f, area, title, &input.display(), s::HELP_INPUT);
}
}
fn field_value(&self, field: NField, editing: bool) -> String {
if editing {
if let Some((_, input)) = &self.editor {
return input.display();
}
}
match field {
NField::Bind => self.staged.bind.clone(),
NField::Port => self.staged.port.clone(),
NField::Token => self.staged.token.clone(),
}
}
}
/// Whole-file rewrite: parse `existing` (or start empty), update only
/// [server] bind/port/token, keep every other key verbatim (§3.5). An empty or
/// whitespace-only token REMOVES the key (open control plane).
pub fn rewrite_toml(
existing: &str,
bind: &str,
port: u16,
token: Option<&str>,
) -> Result<String, String> {
let mut root: toml::Table = if existing.trim().is_empty() {
toml::Table::new()
} else {
toml::from_str(existing).map_err(|e| format!("cannot parse qbzd.toml: {e}"))?
};
let server = root
.entry("server".to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
let st = server
.as_table_mut()
.ok_or_else(|| "qbzd.toml [server] is not a table".to_string())?;
st.insert("bind".to_string(), toml::Value::String(bind.to_string()));
st.insert("port".to_string(), toml::Value::Integer(port as i64));
match token {
Some(t) if !t.trim().is_empty() => {
st.insert("token".to_string(), toml::Value::String(t.to_string()));
}
_ => {
st.remove("token");
}
}
toml::to_string(&root).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrite_preserves_unknown_and_known_keys() {
// A file with a released key (data_root), a schema key we don't edit
// (log.level) and an unrecognized key must ALL survive a server edit.
let existing = "config_version = 1\ndata_root = \"/srv/qbzd\"\n\n[server]\nbind = \"127.0.0.1\"\nport = 8182\n\n[log]\nlevel = \"debug\"\n\n[weird]\nkey = \"value\"\n";
let out = rewrite_toml(existing, "0.0.0.0", 9000, Some("secret")).unwrap();
let parsed: toml::Table = toml::from_str(&out).unwrap();
assert_eq!(parsed["config_version"].as_integer(), Some(1));
assert_eq!(parsed["data_root"].as_str(), Some("/srv/qbzd"));
assert_eq!(parsed["log"]["level"].as_str(), Some("debug"));
assert_eq!(parsed["weird"]["key"].as_str(), Some("value"));
assert_eq!(parsed["server"]["bind"].as_str(), Some("0.0.0.0"));
assert_eq!(parsed["server"]["port"].as_integer(), Some(9000));
assert_eq!(parsed["server"]["token"].as_str(), Some("secret"));
}
#[test]
fn empty_token_clears_the_key() {
let existing = "[server]\nport = 8182\ntoken = \"old\"\n";
let out = rewrite_toml(existing, "127.0.0.1", 8182, None).unwrap();
let parsed: toml::Table = toml::from_str(&out).unwrap();
assert!(parsed["server"].get("token").is_none(), "empty token removes the key");
}
#[test]
fn bad_ip_and_port_are_rejected() {
let cfg = QbzdConfig::default();
let mut st = NetworkState::new(&cfg, Vec::new());
st.staged.bind = "not-an-ip".to_string();
assert!(st.validated().is_err());
st.staged.bind = "127.0.0.1".to_string();
st.staged.port = "70000".to_string();
assert!(st.validated().is_err());
st.staged.port = "8182".to_string();
assert!(st.validated().is_ok());
}
}
+481
View File
@@ -0,0 +1,481 @@
// crates/qbzd/src/tui/screens/playback.rs — the Playback screen (03 §3.3).
//
// Reads three stores at entry (daemon_prefs.streaming_quality, AudioSettings
// quality/fallback rows, PlaybackPreferences) and writes back through the App's
// write_one path. The two spec subtleties, both pure + tested:
// - the ask→fallback rendering rule (§3.3.2): the select offers only the two
// concrete values; a stored `ask` renders a note until picked; the TUI never
// writes `ask`.
// - `infinite` autoplay (P1 radio) renders read-only until toggled (§3.3.1).
use qbz_app::settings::playback::{AutoplayMode, PlaybackPreferences};
use qbz_audio::settings::AudioSettings;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::widgets::{self, SelectOutcome, SelectPopup};
const MAX_RATES: &[(&str, Option<u32>)] = &[
(s::RATE_NO_LIMIT, None),
("44.1 kHz", Some(44_100)),
("48 kHz", Some(48_000)),
("88.2 kHz", Some(88_200)),
("96 kHz", Some(96_000)),
("176.4 kHz", Some(176_400)),
("192 kHz", Some(192_000)),
("352.8 kHz", Some(352_800)),
("384 kHz", Some(384_000)),
];
#[derive(Debug, Clone, PartialEq)]
pub struct StagedPlayback {
pub quality: String, // playback.quality
pub limit_to_device: bool, // audio.limit_quality_to_device
pub max_sample_rate: Option<u32>, // audio.device_max_sample_rate
pub allow_fallback: bool, // audio.allow_quality_fallback
pub fallback_behavior: String, // audio.quality_fallback_behavior
pub autoplay: String, // playback.autoplay
pub gapless: bool, // audio.gapless_enabled
pub restore_session: bool, // playback.persist_session
pub resume_position: bool, // playback.resume_playback_position
/// Read-only, from the audio store — drives the Gapless disabled reason.
pub streaming_only: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PField {
Quality,
Limit,
MaxRate,
AllowFallback,
RetryFail,
Continue,
Gapless,
Restore,
Resume,
}
/// `(shown, enabled, reason)` per §3.3.
pub fn row_state(field: PField, p: &StagedPlayback) -> (bool, bool, Option<&'static str>) {
use PField::*;
match field {
MaxRate => (
p.limit_to_device,
true,
if p.limit_to_device { None } else { Some(s::R_LIMIT_OFF) },
),
Gapless => (
true,
!p.streaming_only,
if p.streaming_only { Some(s::R_STREAMING_ONLY_ON) } else { None },
),
Resume => (
true,
p.restore_session,
if p.restore_session { None } else { Some(s::R_RESTORE_OFF) },
),
_ => (true, true, None),
}
}
pub fn visible_fields(p: &StagedPlayback) -> Vec<PField> {
use PField::*;
[Quality, Limit, MaxRate, AllowFallback, RetryFail, Continue, Gapless, Restore, Resume]
.into_iter()
.filter(|f| row_state(*f, p).0)
.collect()
}
enum Editor {
Quality(SelectPopup),
MaxRate(SelectPopup),
Retry(SelectPopup),
}
pub struct PlaybackState {
baseline: StagedPlayback,
staged: StagedPlayback,
focus: usize,
editor: Option<Editor>,
}
impl PlaybackState {
pub fn new(quality: &str, audio: &AudioSettings, prefs: &PlaybackPreferences) -> Self {
let staged = StagedPlayback {
quality: quality.to_string(),
limit_to_device: audio.limit_quality_to_device,
max_sample_rate: audio.device_max_sample_rate,
allow_fallback: audio.allow_quality_fallback,
fallback_behavior: audio.quality_fallback_behavior.clone(),
autoplay: autoplay_value(prefs.autoplay_mode).to_string(),
gapless: audio.gapless_enabled,
restore_session: prefs.persist_session,
resume_position: prefs.resume_playback_position,
streaming_only: audio.streaming_only,
};
Self {
baseline: staged.clone(),
staged,
focus: 0,
editor: None,
}
}
pub fn is_dirty(&self) -> bool {
self.staged != self.baseline
}
pub fn is_editing(&self) -> bool {
self.editor.is_some()
}
pub fn mark_saved(&mut self) {
self.baseline = self.staged.clone();
}
/// Changed dotted keys for write_one. NEVER emits `ask` (§3.3.2).
pub fn save_keys(&self) -> Vec<(String, String)> {
let b = &self.baseline;
let a = &self.staged;
let mut out = Vec::new();
if a.quality != b.quality {
out.push(("playback.quality".to_string(), a.quality.clone()));
}
if a.limit_to_device != b.limit_to_device {
out.push(("audio.limit_quality_to_device".to_string(), a.limit_to_device.to_string()));
}
if a.max_sample_rate != b.max_sample_rate {
out.push((
"audio.device_max_sample_rate".to_string(),
a.max_sample_rate.map(|r| r.to_string()).unwrap_or_else(|| "none".to_string()),
));
}
if a.allow_fallback != b.allow_fallback {
out.push(("audio.allow_quality_fallback".to_string(), a.allow_fallback.to_string()));
}
// Only write fallback_behavior when it is a concrete value (never `ask`).
if a.fallback_behavior != b.fallback_behavior && a.fallback_behavior != "ask" {
out.push((
"audio.quality_fallback_behavior".to_string(),
a.fallback_behavior.clone(),
));
}
if a.autoplay != b.autoplay {
out.push(("playback.autoplay".to_string(), a.autoplay.clone()));
}
if a.gapless != b.gapless {
out.push(("audio.gapless_enabled".to_string(), a.gapless.to_string()));
}
if a.restore_session != b.restore_session {
out.push(("playback.persist_session".to_string(), a.restore_session.to_string()));
}
if a.resume_position != b.resume_position {
out.push(("playback.resume_playback_position".to_string(), a.resume_position.to_string()));
}
out
}
pub fn summary(&self) -> String {
let q = quality_label(&self.staged.quality);
let g = if self.staged.gapless { "gapless on" } else { "gapless off" };
format!("{q} · {g}")
}
// -------------------------- input --------------------------
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
if self.editor.is_some() {
return self.handle_editor_key(key);
}
let fields = visible_fields(&self.staged);
if self.focus >= fields.len() {
self.focus = fields.len().saturating_sub(1);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
if self.focus == 0 {
self.focus = fields.len().saturating_sub(1);
} else {
self.focus -= 1;
}
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
if !fields.is_empty() {
self.focus = (self.focus + 1) % fields.len();
}
ScreenAction::Consumed
}
KeyCode::Char('s') => ScreenAction::Save,
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(f) = fields.get(self.focus).copied() {
self.activate(f);
}
ScreenAction::Consumed
}
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
fn activate(&mut self, field: PField) {
let (_, enabled, _) = row_state(field, &self.staged);
if !enabled {
return;
}
match field {
PField::Quality => {
let opts = vec![
s::Q_MP3.to_string(),
s::Q_CD.to_string(),
s::Q_HIRES.to_string(),
s::Q_HIRES_PLUS.to_string(),
];
let sel = match self.staged.quality.as_str() {
"mp3" => 0,
"cd" => 1,
"hires" => 2,
_ => 3,
};
self.editor = Some(Editor::Quality(SelectPopup::new(s::P_QUALITY, opts, sel, false)));
}
PField::MaxRate => {
let opts: Vec<String> = MAX_RATES.iter().map(|(l, _)| l.to_string()).collect();
let sel = MAX_RATES
.iter()
.position(|(_, v)| *v == self.staged.max_sample_rate)
.unwrap_or(0);
self.editor = Some(Editor::MaxRate(SelectPopup::new(s::P_MAX_RATE, opts, sel, false)));
}
PField::RetryFail => {
let opts = vec![s::RETRY_FALLBACK.to_string(), s::RETRY_SKIP.to_string()];
let sel = if self.staged.fallback_behavior == "always_skip" { 1 } else { 0 };
self.editor = Some(Editor::Retry(SelectPopup::new(s::P_RETRY_FAIL, opts, sel, false)));
}
PField::Limit => self.staged.limit_to_device ^= true,
PField::AllowFallback => self.staged.allow_fallback ^= true,
PField::Continue => {
// §3.3.1: infinite (radio) is preserved until toggled; the first
// toggle from infinite lands on off (track_only).
self.staged.autoplay = match self.staged.autoplay.as_str() {
"continue" => "track_only",
_ => "continue", // track_only or infinite → continue
}
.to_string();
}
PField::Gapless => self.staged.gapless ^= true,
PField::Restore => self.staged.restore_session ^= true,
PField::Resume => self.staged.resume_position ^= true,
}
}
fn handle_editor_key(&mut self, key: KeyEvent) -> ScreenAction {
let editor = self.editor.take().unwrap();
match editor {
Editor::Quality(mut p) => match p.handle_key(key) {
SelectOutcome::Chosen(i) => {
self.staged.quality = ["mp3", "cd", "hires", "hires_plus"]
.get(i)
.copied()
.unwrap_or("hires_plus")
.to_string();
ScreenAction::Consumed
}
SelectOutcome::Cancelled => ScreenAction::Consumed,
SelectOutcome::Pending => {
self.editor = Some(Editor::Quality(p));
ScreenAction::Consumed
}
},
Editor::MaxRate(mut p) => match p.handle_key(key) {
SelectOutcome::Chosen(i) => {
self.staged.max_sample_rate = MAX_RATES.get(i).map(|(_, v)| *v).unwrap_or(None);
ScreenAction::Consumed
}
SelectOutcome::Cancelled => ScreenAction::Consumed,
SelectOutcome::Pending => {
self.editor = Some(Editor::MaxRate(p));
ScreenAction::Consumed
}
},
Editor::Retry(mut p) => match p.handle_key(key) {
SelectOutcome::Chosen(i) => {
self.staged.fallback_behavior =
if i == 1 { "always_skip" } else { "always_fallback" }.to_string();
ScreenAction::Consumed
}
SelectOutcome::Cancelled => ScreenAction::Consumed,
SelectOutcome::Pending => {
self.editor = Some(Editor::Retry(p));
ScreenAction::Consumed
}
},
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let fields = visible_fields(&self.staged);
let mut lines: Vec<Line> = Vec::new();
use PField::*;
self.group(&mut lines, &fields, s::PLAYBACK_GROUP_QUALITY, &[Quality, Limit, MaxRate, AllowFallback, RetryFail]);
lines.push(widgets::blank());
self.group(&mut lines, &fields, s::PLAYBACK_GROUP_BEHAVIOR, &[Continue, Gapless]);
lines.push(widgets::blank());
self.group(&mut lines, &fields, s::PLAYBACK_GROUP_SESSION, &[Restore, Resume]);
f.render_widget(Paragraph::new(lines), area);
match &self.editor {
Some(Editor::Quality(p)) | Some(Editor::MaxRate(p)) | Some(Editor::Retry(p)) => {
p.draw(f, area)
}
None => {}
}
}
fn group(&self, lines: &mut Vec<Line<'static>>, fields: &[PField], header: &str, group: &[PField]) {
if !group.iter().any(|g| fields.contains(g)) {
return;
}
lines.push(widgets::group_header(header));
for gf in group {
if let Some(pos) = fields.iter().position(|x| x == gf) {
lines.push(self.field_line(*gf, pos));
}
}
}
fn field_line(&self, field: PField, focus_pos: usize) -> Line<'static> {
let (_, enabled, reason) = row_state(field, &self.staged);
let focused = focus_pos == self.focus && self.editor.is_none();
let (label, value, widget) = self.field_display(field);
widgets::field_line(label, &value, focused, enabled, reason, widget)
}
fn field_display(&self, field: PField) -> (&'static str, String, &'static str) {
let a = &self.staged;
let on_off = |b: bool| if b { "on".to_string() } else { "off".to_string() };
match field {
PField::Quality => (s::P_QUALITY, quality_label(&a.quality).to_string(), "[select]"),
PField::Limit => (s::P_LIMIT_DEVICE, on_off(a.limit_to_device), "[toggle]"),
PField::MaxRate => (s::P_MAX_RATE, max_rate_label(a.max_sample_rate).to_string(), "[select]"),
PField::AllowFallback => (s::P_ALLOW_FALLBACK, on_off(a.allow_fallback), "[toggle]"),
PField::RetryFail => (s::P_RETRY_FAIL, retry_label(&a.fallback_behavior).to_string(), "[select]"),
PField::Continue => (s::P_CONTINUE, autoplay_label(&a.autoplay).to_string(), "[toggle]"),
PField::Gapless => (s::P_GAPLESS, on_off(a.gapless), "[toggle]"),
PField::Restore => (s::P_RESTORE, on_off(a.restore_session), "[toggle]"),
PField::Resume => (s::P_RESUME_POS, on_off(a.resume_position), "[toggle]"),
}
}
}
// ============================ mappers ============================
fn quality_label(q: &str) -> &'static str {
match q {
"mp3" => s::Q_MP3,
"cd" => s::Q_CD,
"hires" => s::Q_HIRES,
_ => s::Q_HIRES_PLUS,
}
}
fn max_rate_label(v: Option<u32>) -> &'static str {
MAX_RATES
.iter()
.find(|(_, r)| *r == v)
.map(|(l, _)| *l)
.unwrap_or(s::RATE_NO_LIMIT)
}
fn retry_label(v: &str) -> &'static str {
match v {
"always_skip" => s::RETRY_SKIP,
"always_fallback" => s::RETRY_FALLBACK,
_ => s::RETRY_ASK, // stored `ask` — rendered until the operator picks
}
}
fn autoplay_label(v: &str) -> &'static str {
match v {
"track_only" => s::AUTOPLAY_OFF,
"infinite" => s::AUTOPLAY_INFINITE,
_ => s::AUTOPLAY_ON,
}
}
fn autoplay_value(mode: AutoplayMode) -> &'static str {
match mode {
AutoplayMode::ContinueWithinSource => "continue",
AutoplayMode::PlayTrackOnly => "track_only",
AutoplayMode::InfiniteRadio => "infinite",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> StagedPlayback {
PlaybackState::new(
"hires_plus",
&AudioSettings::default(),
&PlaybackPreferences::default(),
)
.staged
}
#[test]
fn ask_renders_note_and_is_never_written() {
let mut st = PlaybackState::new("hires_plus", &AudioSettings::default(), &PlaybackPreferences::default());
// AudioSettings::default() seeds fallback_behavior = "ask".
assert_eq!(st.staged.fallback_behavior, "ask");
assert_eq!(retry_label("ask"), s::RETRY_ASK);
// A save with the value still `ask` writes nothing for that key.
assert!(st.save_keys().iter().all(|(k, _)| k != "audio.quality_fallback_behavior"));
// Picking a concrete value makes it writable.
st.staged.fallback_behavior = "always_skip".to_string();
assert!(st
.save_keys()
.iter()
.any(|(k, v)| k == "audio.quality_fallback_behavior" && v == "always_skip"));
}
#[test]
fn max_rate_hidden_unless_limit_on() {
let mut p = base();
p.limit_to_device = false;
assert!(!row_state(PField::MaxRate, &p).0);
p.limit_to_device = true;
assert!(row_state(PField::MaxRate, &p).0);
}
#[test]
fn gapless_disabled_while_streaming_only_on() {
let mut p = base();
p.streaming_only = true;
let (shown, enabled, reason) = row_state(PField::Gapless, &p);
assert!(shown && !enabled);
assert_eq!(reason, Some(s::R_STREAMING_ONLY_ON));
}
#[test]
fn resume_enabled_only_under_restore_session() {
let mut p = base();
p.restore_session = false;
assert!(!row_state(PField::Resume, &p).1);
p.restore_session = true;
assert!(row_state(PField::Resume, &p).1);
}
#[test]
fn infinite_autoplay_renders_readonly_label() {
assert_eq!(autoplay_label("infinite"), s::AUTOPLAY_INFINITE);
}
}
+243
View File
@@ -0,0 +1,243 @@
// crates/qbzd/src/tui/screens/qconnect.rs — the Qobuz Connect screen (03 §3.4).
//
// Writes the daemon-root qconnect_settings.db KV via the App's write_one path
// (qconnect.startup_mode / device_name / volume_mode). `device_uuid` is never
// displayed. `remember_last` is not offered (desktop-ism). The device-name
// preview resolves through the SAME `resolve_qconnect_friendly_name` the connect
// path uses, so the phone-facing name shown here is exactly what will appear.
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use serde_json::Value;
use crate::qconnect::transport as qconnect_kv;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::widgets::{self, InputOutcome, SelectOutcome, SelectPopup, TextInput};
#[derive(Debug, Clone, PartialEq)]
struct Staged {
enable: bool,
/// Empty = clear the override (desktop write semantics, §3.4).
device_name: String,
/// "software" (OD4 default) | "locked".
volume_mode: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QField {
Enable,
DeviceName,
VolumeMode,
}
enum Editor {
Name(TextInput),
Volume(SelectPopup),
}
pub struct QConnectState {
baseline: Staged,
staged: Staged,
focus: usize,
editor: Option<Editor>,
}
const FIELDS: [QField; 3] = [QField::Enable, QField::DeviceName, QField::VolumeMode];
impl QConnectState {
pub fn new(startup_on: bool, device_name: Option<String>, volume_mode: Option<String>) -> Self {
let staged = Staged {
enable: startup_on,
device_name: device_name.unwrap_or_default(),
volume_mode: volume_mode.unwrap_or_else(|| s::VOL_SOFTWARE.to_string()),
};
Self {
baseline: staged.clone(),
staged,
focus: 0,
editor: None,
}
}
pub fn is_dirty(&self) -> bool {
self.staged != self.baseline
}
pub fn is_editing(&self) -> bool {
self.editor.is_some()
}
pub fn mark_saved(&mut self) {
self.baseline = self.staged.clone();
}
pub fn save_keys(&self) -> Vec<(String, String)> {
let b = &self.baseline;
let a = &self.staged;
let mut out = Vec::new();
if a.enable != b.enable {
out.push((
"qconnect.startup_mode".to_string(),
if a.enable { "on" } else { "off" }.to_string(),
));
}
if a.device_name != b.device_name {
// Empty clears the override; write_one's parse treats ""/system as clear.
out.push(("qconnect.device_name".to_string(), a.device_name.clone()));
}
if a.volume_mode != b.volume_mode {
out.push(("qconnect.volume_mode".to_string(), a.volume_mode.clone()));
}
out
}
pub fn summary(&self) -> String {
let state = if self.staged.enable { "on" } else { "off" };
let name = self.effective_name();
format!("{state} · \"{name}\"")
}
fn effective_name(&self) -> String {
let custom = if self.staged.device_name.trim().is_empty() {
None
} else {
Some(self.staged.device_name.as_str())
};
qconnect_kv::resolve_qconnect_friendly_name(custom)
}
// -------------------------- input --------------------------
pub fn handle_key(&mut self, key: KeyEvent) -> ScreenAction {
if self.editor.is_some() {
return self.handle_editor_key(key);
}
match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => {
self.focus = if self.focus == 0 { FIELDS.len() - 1 } else { self.focus - 1 };
ScreenAction::Consumed
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
self.focus = (self.focus + 1) % FIELDS.len();
ScreenAction::Consumed
}
KeyCode::Char('s') => ScreenAction::Save,
KeyCode::Enter | KeyCode::Char(' ') => {
match FIELDS[self.focus] {
QField::Enable => self.staged.enable ^= true,
QField::DeviceName => {
self.editor = Some(Editor::Name(TextInput::new(&self.staged.device_name, false)));
}
QField::VolumeMode => {
let opts = vec![s::VOL_SOFTWARE.to_string(), s::VOL_LOCKED.to_string()];
let sel = if self.staged.volume_mode == "locked" { 1 } else { 0 };
self.editor = Some(Editor::Volume(SelectPopup::new(s::QC_VOLUME_MODE, opts, sel, false)));
}
}
ScreenAction::Consumed
}
KeyCode::Esc => ScreenAction::Back,
_ => ScreenAction::Consumed,
}
}
fn handle_editor_key(&mut self, key: KeyEvent) -> ScreenAction {
let editor = self.editor.take().unwrap();
match editor {
Editor::Name(mut input) => match input.handle_key(key) {
InputOutcome::Accepted => {
self.staged.device_name = input.buf.trim().to_string();
ScreenAction::Consumed
}
InputOutcome::Cancelled => ScreenAction::Consumed,
InputOutcome::Pending => {
self.editor = Some(Editor::Name(input));
ScreenAction::Consumed
}
},
Editor::Volume(mut p) => match p.handle_key(key) {
SelectOutcome::Chosen(i) => {
self.staged.volume_mode = if i == 1 { "locked" } else { "software" }.to_string();
ScreenAction::Consumed
}
SelectOutcome::Cancelled => ScreenAction::Consumed,
SelectOutcome::Pending => {
self.editor = Some(Editor::Volume(p));
ScreenAction::Consumed
}
},
}
}
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, ctx: &DrawCtx) {
let mut lines: Vec<Line> = Vec::new();
for (i, field) in FIELDS.iter().enumerate() {
let focused = i == self.focus && self.editor.is_none();
match field {
QField::Enable => {
let v = if self.staged.enable { "on" } else { "off" }.to_string();
lines.push(widgets::field_line(s::QC_ENABLE, &v, focused, true, None, "[toggle]"));
}
QField::DeviceName => {
let shown = if let Some(Editor::Name(input)) = &self.editor {
input.display()
} else if self.staged.device_name.is_empty() {
self.effective_name()
} else {
self.staged.device_name.clone()
};
lines.push(widgets::field_line(s::QC_DEVICE_NAME, &shown, focused, true, None, "[input]"));
lines.push(widgets::note_line(&s::qc_preview(&self.effective_name())));
lines.push(widgets::note_line(s::QC_APPLIES_NEXT));
}
QField::VolumeMode => {
lines.push(widgets::field_line(
s::QC_VOLUME_MODE,
&self.staged.volume_mode,
focused,
true,
None,
"[select]",
));
}
}
}
// Live status line (§3.4) — from GET /api/status when the daemon runs.
if let Some(live) = ctx.status.and_then(qconnect_live_line) {
lines.push(widgets::blank());
lines.push(widgets::note_line(&live));
}
f.render_widget(Paragraph::new(lines), area);
match &self.editor {
Some(Editor::Volume(p)) => p.draw(f, area),
Some(Editor::Name(input)) => {
widgets::modal(f, area, s::QC_DEVICE_NAME, &input.display(), s::HELP_INPUT)
}
None => {}
}
}
}
/// One-line live QConnect state from the status payload's qconnect block.
fn qconnect_live_line(p: &Value) -> Option<String> {
let qc = p.get("qconnect")?;
let enabled = qc.get("enabled").and_then(Value::as_bool).unwrap_or(false);
if !enabled {
return None;
}
let state = qc.get("state").and_then(Value::as_str).unwrap_or("");
let session = qc.get("session_active").and_then(Value::as_bool).unwrap_or(false);
let mut parts = Vec::new();
parts.push(format!("live: {}", if state.is_empty() { "enabled" } else { state }));
if session {
parts.push("session active".to_string());
}
Some(parts.join(" · "))
}
+279
View File
@@ -0,0 +1,279 @@
// crates/qbzd/src/tui/strings.rs — EVERY user-facing string in the setup TUI.
//
// English-only v1 (03-setup-tui.md §1.2). Centralized here so a later gettext
// pass (qbz-i18n is Slint-free) is a P2 batch job, not a rewrite. Nothing under
// tui/ prints a bare literal — it comes from here.
// ============================ entry / guards ============================
/// Non-tty rejection (03 §2.4, VERBATIM). Printed to stderr, exit 2.
pub const NON_TTY_ERROR: &str = "error: 'qbzd setup' needs an interactive terminal
→ import a settings bundle: qbzd settings import <file.qbzb>
→ set one value: qbzd settings set <key> <value>
→ log in without the TUI: qbzd login (or: qbzd login --token <token>)";
/// Terminal-too-small line (03 §5.4). `w`/`h` are the current dimensions.
pub fn too_small(w: u16, h: u16) -> String {
format!("terminal too small — 80x24 minimum (current: {w}x{h})")
}
// ============================ shell / navigation ============================
pub const MENU_TITLE: &str = "QBZ Daemon Setup";
pub const HELP_TITLE: &str = "Help";
/// Menu row labels (fixed order — the D7 six-screen hard cap).
pub const MENU_ROWS: [&str; 6] = [
"1. Account",
"2. Audio",
"3. Playback",
"4. Qobuz Connect",
"5. Network",
"6. Import / Export",
];
// Global help-bar hints (context-sensitive; assembled per screen).
pub const HELP_MENU: &str = "up/down select · Enter open · ? help · q quit";
pub const HELP_SCREEN_CLEAN: &str = "up/down move · Enter edit · Esc back · ? help";
pub const HELP_SCREEN_DIRTY: &str = "up/down move · Enter edit · s SAVE* · Esc back · ? help";
pub const HELP_AUDIO_CLEAN: &str =
"up/down move · Enter edit · r refresh · / filter · Esc back · ? help";
pub const HELP_AUDIO_DIRTY: &str =
"up/down move · Enter edit · s SAVE* · r refresh · / filter · Esc back";
pub const HELP_SELECT: &str = "up/down choose · Enter select · Esc cancel";
pub const HELP_FILTER: &str = "type to filter · up/down choose · Enter select · Esc cancel";
pub const HELP_INPUT: &str = "type · Enter accept · Esc cancel";
pub const HELP_OVERLAY: &str = "GLOBAL KEYS
up / down (or j / k) move selection
Enter open a screen / edit a field / confirm
Esc back / cancel
Tab / Shift-Tab next / previous field
s save the current screen
r refresh (Audio: re-enumerate devices)
/ filter (device picker)
? this help
q quit (asks to save unsaved changes)
Each screen saves explicitly with 's'. A '*' in the title means unsaved edits.
The daemon does NOT need to be running — changes apply when it next starts.
Press Esc or ? to close.";
// ============================ dirty-save / quit ============================
pub const DIRTY_TITLE: &str = "Unsaved changes";
pub const DIRTY_BODY: &str = "This screen has unsaved edits.";
pub const DIRTY_HINT: &str = "s save · d discard · Esc stay";
// ============================ footer (daemon state) ============================
pub const FOOTER_UNREACHABLE: &str = "daemon: not reachable";
/// Appended to a save result when the daemon is down (03 §2.3, error-voice).
pub const APPLIES_ON_START: &str =
"changes apply when the daemon starts — systemctl --user status qbzd";
// ============================ Account (§3.1) ============================
pub const ACCOUNT_TITLE: &str = "Account";
pub const ACCOUNT_STATUS: &str = "Status";
pub const ACCOUNT_NOT_LOGGED_IN: &str = "not logged in";
/// Offline + daemon-down: a credential file exists but was never validated —
/// NEVER fabricate an email/name (§3.1 rules).
pub const ACCOUNT_CRED_PRESENT: &str = "credential file present (not validated)";
pub const ACCOUNT_LOGIN_BROWSER: &str = "Log in with browser";
pub const ACCOUNT_PASTE_TOKEN: &str = "Paste token";
pub const ACCOUNT_LOGOUT: &str = "Log out";
pub fn account_logged_in(email: &str) -> String {
format!("logged in as {email}")
}
pub fn account_logged_in_plan(email: &str, plan: &str) -> String {
format!("logged in as {email} ({plan})")
}
pub const ACCOUNT_LOGOUT_CONFIRM_TITLE: &str = "Log out";
pub const ACCOUNT_LOGOUT_CONFIRM_BODY: &str =
"Clear the Qobuz credentials on this box? If the daemon is running it will\nstop playback and wait for a new login.";
pub const CONFIRM_YN: &str = "y confirm · Esc cancel";
pub const ACCOUNT_VALIDATING: &str = "validating token with Qobuz…";
/// Suspend-and-run divergence banner (see report): the browser flow runs on the
/// plain terminal. Shown briefly before the alt-screen is left.
pub const ACCOUNT_BROWSER_HANDOFF: &str =
"Starting browser login on the terminal below. Follow the printed URL;\nthe TUI resumes when login finishes or times out.";
// ============================ Audio (§3.2) ============================
pub const AUDIO_TITLE: &str = "Audio";
pub const AUDIO_GROUP_OUTPUT: &str = "OUTPUT";
pub const AUDIO_GROUP_BITPERFECT: &str = "BIT-PERFECT";
pub const AUDIO_GROUP_TRANSPORT: &str = "STREAMING TRANSPORT";
pub const A_BACKEND: &str = "Backend";
pub const A_DEVICE: &str = "Output device";
pub const A_ALSA_PLUGIN: &str = "ALSA plugin";
pub const A_HW_VOLUME: &str = "Hardware volume";
pub const A_DSD: &str = "DSD playback";
pub const A_EXCLUSIVE: &str = "Exclusive mode";
pub const A_RESERVE: &str = "Reserve DAC";
pub const A_PASSTHROUGH: &str = "DAC passthrough";
pub const A_FORCE_BP: &str = "Force bit-perfect";
pub const A_LOCK_OUTPUT: &str = "Lock output device";
pub const A_STREAM_UNCACHED: &str = "Stream uncached";
pub const A_BUFFER: &str = "Initial buffer";
pub const A_STREAMING_ONLY: &str = "Streaming only";
// Disabled-row reasons (rendered dim in parentheses, §3).
pub const R_ALSA_ONLY: &str = "ALSA only";
pub const R_PIPEWIRE_ONLY: &str = "PipeWire only";
pub const R_PASSTHROUGH_OFF: &str = "off while DAC passthrough on";
pub const DSD_CONVERT: &str = "Convert to PCM (works everywhere)";
pub const DSD_DOP: &str = "DoP — DSD over PCM (bit-perfect)";
pub const DSD_NATIVE: &str = "Native DSD (kernel support required)";
pub const ALSA_HW: &str = "hw (Direct Hardware)";
pub const ALSA_PLUGHW: &str = "plughw (Auto-convert)";
pub const ALSA_PCM: &str = "pcm (Most compatible)";
/// DSD guard (§3.2.4). Verbatim-in-spirit of the desktop warning.
pub const DSD_GUARD_TITLE: &str = "DSD direct mode";
pub const DSD_GUARD_BODY: &str = "Choose DoP or Native only if your DAC supports it. On any other DAC they play\nas LOUD NOISE. Volume is fixed and seeking is disabled in DoP/Native; Native\nadditionally needs kernel support.";
pub const DSD_GUARD_HINT: &str = "Enter confirm · Esc revert";
pub const AUDIO_SCANNING: &str = "scanning…";
pub const DEVICE_PICKER_TITLE: &str = "Output device";
/// No-devices hint panel (§5.1).
pub const NO_DEVICES: &str = "no output devices found — is the DAC plugged in and powered? is your user in\nthe 'audio' group? PipeWire backend: is pipewire running? (r to re-scan)";
pub const JACK_WARNING: &str = "JACK is not bit-perfect (routes through the JACK graph, resamples)";
pub const BP_BADGE: &str = "[BP]";
// ============================ Playback (§3.3) ============================
pub const PLAYBACK_TITLE: &str = "Playback";
pub const PLAYBACK_GROUP_QUALITY: &str = "QUALITY";
pub const PLAYBACK_GROUP_BEHAVIOR: &str = "BEHAVIOR";
pub const PLAYBACK_GROUP_SESSION: &str = "SESSION";
pub const P_QUALITY: &str = "Streaming quality";
pub const P_LIMIT_DEVICE: &str = "Limit quality to device";
pub const P_MAX_RATE: &str = "Maximum sample rate";
pub const P_ALLOW_FALLBACK: &str = "Allow quality fallback";
pub const P_RETRY_FAIL: &str = "When retries fail";
pub const P_CONTINUE: &str = "Continue after track";
pub const P_GAPLESS: &str = "Gapless playback";
pub const P_RESTORE: &str = "Restore session";
pub const P_RESUME_POS: &str = "Resume position";
pub const R_LIMIT_OFF: &str = "Limit quality to device off";
pub const R_STREAMING_ONLY_ON: &str = "off while Audio > Streaming only on";
pub const R_RESTORE_OFF: &str = "needs Restore session";
pub const Q_MP3: &str = "MP3";
pub const Q_CD: &str = "CD Quality";
pub const Q_HIRES: &str = "Hi-Res";
pub const Q_HIRES_PLUS: &str = "Hi-Res+";
pub const RETRY_FALLBACK: &str = "Fall back (play lowest available)";
pub const RETRY_SKIP: &str = "Skip the track";
/// Stored `ask` render until the operator picks (§3.3.2). The TUI never writes `ask`.
pub const RETRY_ASK: &str = "Ask (desktop setting) — daemon falls back";
pub const AUTOPLAY_ON: &str = "on";
pub const AUTOPLAY_OFF: &str = "off";
/// A pre-existing `infinite` (radio, P1) renders read-only until toggled (§3.3.1).
pub const AUTOPLAY_INFINITE: &str = "on (infinite radio)";
pub const RATE_NO_LIMIT: &str = "No limit";
// ============================ QConnect (§3.4) ============================
pub const QCONNECT_TITLE: &str = "Qobuz Connect";
pub const QC_ENABLE: &str = "Enable";
pub const QC_DEVICE_NAME: &str = "Device name";
pub const QC_VOLUME_MODE: &str = "Volume mode";
pub const QC_APPLIES_NEXT: &str = "applies on the next connection";
pub const VOL_SOFTWARE: &str = "software";
pub const VOL_LOCKED: &str = "locked";
pub fn qc_preview(name: &str) -> String {
format!("phones will see: \"{name}\"")
}
// ============================ Network (§3.5) ============================
pub const NETWORK_TITLE: &str = "Network";
pub const N_BIND: &str = "Bind address";
pub const N_PORT: &str = "Port";
pub const N_TOKEN: &str = "Access token";
pub const N_TOKEN_HINT: &str = "(empty = open)";
/// LAN-exposure warning shown when bind is non-loopback (§3.5, copy normative).
pub const NETWORK_LAN_WARNING: &str = "! bound beyond localhost: anyone on your network can control playback\n (Sonos/Chromecast posture). Plain HTTP — never expose it past your LAN.";
/// Restart-required copy on a bind/port/token save (§3.5).
pub const NETWORK_RESTART: &str =
"bind/port change needs a restart — systemctl --user restart qbzd";
pub const N_BAD_IP: &str = "invalid IP address";
pub const N_BAD_PORT: &str = "port must be 1-65535";
/// Pre-save warning naming keys outside the daemon schema. Per 03 §3.5 they are
/// PRESERVED on save (a save must never destroy a released key) — only comments
/// and formatting are lost. (The brief said "drops"; 03 wins — flagged in the
/// report.) Keys are appended.
pub const N_DROP_UNKNOWN: &str =
"note: qbzd.toml has keys outside the daemon schema — they are kept, but\n comments and formatting are not preserved on save:";
// ============================ Import / Export (§3.6) ============================
pub const BUNDLE_TITLE: &str = "Import / Export";
pub const BUNDLE_IMPORT_HEADER: &str = "IMPORT";
pub const BUNDLE_EXPORT_HEADER: &str = "EXPORT";
pub const B_IMPORT_PATH: &str = "Bundle file";
pub const B_IMPORT_PATH_HINT: &str = "path to a .qbzb (scp it to ~ first)";
pub const B_IMPORT_ACTION: &str = "Review import";
pub const B_EXPORT_DEST: &str = "Destination";
pub const B_EXPORT_INCLUDE_AUTH: &str = "Include Qobuz login";
pub const B_EXPORT_ACTION: &str = "Export";
pub const B_BUCKET_APPLIED: &str = "applies verbatim";
pub const B_BUCKET_ADAPTED: &str = "needs your confirmation";
pub const B_BUCKET_SKIPPED: &str = "skipped";
/// Import-side auth gate (§3.6 step 5) — dedicated, default-OFF.
pub const B_IMPORT_AUTH_TITLE: &str = "Bundle carries a Qobuz login";
pub const B_IMPORT_AUTH_BODY: &str =
"Also log in with the bundled account? The token is validated with Qobuz\nbefore anything is stored.";
pub const B_IMPORT_AUTH_HINT: &str = "y log in · Esc skip auth";
/// Export include-auth warning (§3.6, shown while the toggle is on).
pub const B_EXPORT_AUTH_WARNING: &str = "embeds your decrypted Qobuz token — anyone with this file can use your\naccount. File is written 0600; move it privately (scp), delete after import.";
pub fn b_export_success(path: &str) -> String {
format!("saved. on the daemon box: qbzd settings import {path}")
}
/// Success-panel hint when a desktop profile is detected (§3.6): desktop export
/// is the CLI's job.
pub const B_DESKTOP_HINT: &str =
"a desktop QBZ profile was found on this box — to export IT instead:\n qbzd settings export --from desktop";
pub fn b_import_done(applied: usize, adapted: usize, skipped: usize) -> String {
format!("imported: {applied} applied, {adapted} adapted, {skipped} skipped")
}
// ============================ save result (§4.3) ============================
pub const SAVE_TITLE: &str = "Saved";
pub const RESULT_HINT: &str = "Enter / Esc close";
pub const SAVED_DISK_ONLY: &str =
"saved to disk — daemon didn't answer; changes apply on restart";
pub const RELOAD_REFUSED: &str =
"saved to disk — daemon answered but refused the reload; restart it:\n systemctl --user restart qbzd";
+437
View File
@@ -0,0 +1,437 @@
// crates/qbzd/src/tui/widgets.rs — reusable ratatui primitives for the setup TUI.
//
// Two interactive sub-widgets (a filterable select popup and a line input) plus
// the pure render helpers every screen shares (field rows, group headers, the
// bottom help bar, centered modals, spinner). No screen logic lives here —
// screens own their staged state and drive these.
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
use super::strings;
/// Focused-row emphasis (reverse video — terminal-theme agnostic, §1.2).
pub fn focus_style() -> Style {
Style::default().add_modifier(Modifier::REVERSED)
}
pub fn dim_style() -> Style {
Style::default().add_modifier(Modifier::DIM)
}
/// Mask a secret for display (token/paste rows never render plaintext).
pub fn mask(s: &str) -> String {
"•".repeat(s.chars().count())
}
// ============================ field rows ============================
/// A single label/value row. `focused` reverses it; `enabled = false` dims it and
/// appends `(reason)`; `widget` is a trailing `[toggle]`/`[select]`/… hint.
pub fn field_line(
label: &str,
value: &str,
focused: bool,
enabled: bool,
reason: Option<&str>,
widget: &str,
) -> Line<'static> {
let shown_value = match reason {
Some(r) if !enabled => format!("{value} ({r})"),
_ => value.to_string(),
};
// label padded to a fixed gutter so values line up.
let text = format!(
" {:<20} {:<32} {}",
label,
truncate(&shown_value, 32),
widget
);
let mut style = Style::default();
if !enabled {
style = style.add_modifier(Modifier::DIM);
}
if focused {
style = style.add_modifier(Modifier::REVERSED);
}
Line::from(Span::styled(text, style))
}
/// A plain, non-interactive info/action line (e.g. Account's status row or an
/// action item). `focused` reverses it.
pub fn action_line(text: &str, focused: bool, enabled: bool) -> Line<'static> {
let mut style = Style::default();
if !enabled {
style = style.add_modifier(Modifier::DIM);
}
if focused {
style = style.add_modifier(Modifier::REVERSED);
}
Line::from(Span::styled(format!(" {text}"), style))
}
/// A bold group header (`OUTPUT`, `QUALITY`, …).
pub fn group_header(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
Style::default().add_modifier(Modifier::BOLD),
))
}
pub fn blank() -> Line<'static> {
Line::from("")
}
/// A dim, wrapped note under a field (previews, warnings).
pub fn note_line(text: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" {text}"),
Style::default().add_modifier(Modifier::DIM),
))
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
// ============================ help bar ============================
/// The bottom help bar (one line, dim), drawn into `area`.
pub fn help_bar(f: &mut Frame, area: Rect, text: &str) {
let p = Paragraph::new(Line::from(Span::styled(
format!(" {text}"),
Style::default().add_modifier(Modifier::DIM),
)));
f.render_widget(p, area);
}
// ============================ centered modal ============================
/// Center a fixed-size rect inside `area` (clamped to fit).
pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect { x, y, width: w, height: h }
}
/// A centered, bordered modal with a title, wrapped body and a hint footer.
pub fn modal(f: &mut Frame, area: Rect, title: &str, body: &str, hint: &str) {
let lines = body.lines().count().max(1) as u16;
let height = lines + 4; // border(2) + body + spacer + hint
let width = body
.lines()
.chain(std::iter::once(title))
.chain(std::iter::once(hint))
.map(|l| l.chars().count())
.max()
.unwrap_or(20) as u16
+ 6;
let rect = centered_rect(width.max(28), height.max(6), area);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "));
let inner = block.inner(rect);
f.render_widget(block, rect);
let mut text: Vec<Line> = body.lines().map(|l| Line::from(l.to_string())).collect();
text.push(blank());
text.push(Line::from(Span::styled(
hint.to_string(),
Style::default().add_modifier(Modifier::DIM),
)));
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), inner);
}
/// A centered scrollable panel (help overlay, import summary, result panel).
pub fn panel(f: &mut Frame, area: Rect, title: &str, lines: Vec<Line<'static>>, scroll: u16) {
let rect = centered_rect(area.width.saturating_sub(6).max(40), area.height.saturating_sub(4).max(10), area);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "));
let inner = block.inner(rect);
f.render_widget(block, rect);
f.render_widget(
Paragraph::new(lines).scroll((scroll, 0)).wrap(Wrap { trim: false }),
inner,
);
}
/// Spinner glyph for the given tick (§5.5 worker spinner).
pub fn spinner_frame(tick: u64) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[(tick as usize) % FRAMES.len()]
}
/// Busy overlay: a small centered spinner + label (§5.5).
pub fn busy_overlay(f: &mut Frame, area: Rect, label: &str, tick: u64) {
let body = format!("{} {}", spinner_frame(tick), label);
let rect = centered_rect(body.chars().count() as u16 + 6, 5, area);
f.render_widget(Clear, rect);
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(rect);
f.render_widget(block, rect);
f.render_widget(
Paragraph::new(body).alignment(Alignment::Center),
inner,
);
}
// ============================ select popup ============================
#[derive(Debug, Clone)]
pub struct SelectPopup {
pub title: String,
pub options: Vec<String>,
/// Absolute index into `options` of the current selection.
pub idx: usize,
/// When true, printable keys filter the list (device picker, §3.2.2).
pub filterable: bool,
pub filter: String,
/// Parallel to `options`: a bold section header rendered ABOVE that option
/// when set (device-picker grouping, §3.2.2). Shown only when unfiltered.
pub headers: Vec<Option<String>>,
}
pub enum SelectOutcome {
Pending,
Chosen(usize),
Cancelled,
}
impl SelectPopup {
pub fn new(title: &str, options: Vec<String>, selected: usize, filterable: bool) -> Self {
let last = options.len().saturating_sub(1);
let headers = vec![None; options.len()];
Self {
title: title.to_string(),
options,
idx: selected.min(last),
filterable,
filter: String::new(),
headers,
}
}
/// Attach parallel section headers (device picker). No-op if the length
/// mismatches the option count.
pub fn with_headers(mut self, headers: Vec<Option<String>>) -> Self {
if headers.len() == self.options.len() {
self.headers = headers;
}
self
}
/// Indices of options currently visible under the filter.
pub fn visible(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.options.len()).collect();
}
let needle = self.filter.to_ascii_lowercase();
(0..self.options.len())
.filter(|i| self.options[*i].to_ascii_lowercase().contains(&needle))
.collect()
}
pub fn handle_key(&mut self, key: KeyEvent) -> SelectOutcome {
match key.code {
KeyCode::Up => {
self.step(-1);
SelectOutcome::Pending
}
KeyCode::Down => {
self.step(1);
SelectOutcome::Pending
}
// j/k move ONLY on non-filterable popups; on a filterable one they
// are filter characters.
KeyCode::Char('k') if !self.filterable => {
self.step(-1);
SelectOutcome::Pending
}
KeyCode::Char('j') if !self.filterable => {
self.step(1);
SelectOutcome::Pending
}
KeyCode::Enter => {
if self.visible().is_empty() {
SelectOutcome::Cancelled
} else {
SelectOutcome::Chosen(self.idx)
}
}
KeyCode::Esc => {
if self.filterable && !self.filter.is_empty() {
self.filter.clear();
self.reselect_first();
SelectOutcome::Pending
} else {
SelectOutcome::Cancelled
}
}
KeyCode::Char('/') if self.filterable => {
self.filter.clear();
self.reselect_first();
SelectOutcome::Pending
}
KeyCode::Backspace if self.filterable => {
self.filter.pop();
self.reselect_first();
SelectOutcome::Pending
}
KeyCode::Char(c) if self.filterable => {
self.filter.push(c);
self.reselect_first();
SelectOutcome::Pending
}
_ => SelectOutcome::Pending,
}
}
/// Move the selection by `delta` within the visible (filtered) set, wrapping.
fn step(&mut self, delta: isize) {
let vis = self.visible();
if vis.is_empty() {
return;
}
let cur = vis.iter().position(|i| *i == self.idx).unwrap_or(0) as isize;
let next = (cur + delta).rem_euclid(vis.len() as isize) as usize;
self.idx = vis[next];
}
fn reselect_first(&mut self) {
if let Some(first) = self.visible().first().copied() {
self.idx = first;
}
}
pub fn draw(&self, f: &mut Frame, area: Rect) {
let vis = self.visible();
let show_headers = self.filter.is_empty();
let mut lines: Vec<Line> = Vec::new();
let mut sel_line: u16 = 0;
for i in &vis {
if show_headers {
if let Some(Some(h)) = self.headers.get(*i) {
lines.push(Line::from(Span::styled(
h.clone(),
Style::default().add_modifier(Modifier::BOLD),
)));
}
}
let mut style = Style::default();
if *i == self.idx {
style = style.add_modifier(Modifier::REVERSED);
sel_line = lines.len() as u16;
}
lines.push(Line::from(Span::styled(
format!(" {}", self.options[*i]),
style,
)));
}
if vis.is_empty() {
lines.push(note_line("(no matches)"));
}
let hint = if self.filterable {
format!(
"{} [{}]",
strings::HELP_FILTER,
if self.filter.is_empty() { "type to filter".into() } else { self.filter.clone() }
)
} else {
strings::HELP_SELECT.to_string()
};
let height = (lines.len() as u16 + 4)
.min(area.height.saturating_sub(2))
.max(6);
let width = self
.options
.iter()
.map(|o| o.chars().count())
.chain(std::iter::once(hint.chars().count()))
.chain(std::iter::once(self.title.chars().count()))
.max()
.unwrap_or(20) as u16
+ 8;
let rect = centered_rect(width.min(area.width.saturating_sub(2)).max(24), height, area);
f.render_widget(Clear, rect);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", self.title));
let inner = block.inner(rect);
f.render_widget(block, rect);
let list_h = inner.height.saturating_sub(1);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(list_h), Constraint::Length(1)])
.split(inner);
// Scroll so the selected line (accounting for header rows) stays visible.
let scroll = sel_line.saturating_sub(list_h.saturating_sub(1));
f.render_widget(Paragraph::new(lines).scroll((scroll, 0)), chunks[0]);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hint,
Style::default().add_modifier(Modifier::DIM),
))),
chunks[1],
);
}
}
// ============================ line input ============================
#[derive(Debug, Clone, Default)]
pub struct TextInput {
pub buf: String,
pub masked: bool,
}
pub enum InputOutcome {
Pending,
Accepted,
Cancelled,
}
impl TextInput {
pub fn new(initial: &str, masked: bool) -> Self {
Self { buf: initial.to_string(), masked }
}
pub fn handle_key(&mut self, key: KeyEvent) -> InputOutcome {
match key.code {
KeyCode::Enter => InputOutcome::Accepted,
KeyCode::Esc => InputOutcome::Cancelled,
KeyCode::Backspace => {
self.buf.pop();
InputOutcome::Pending
}
KeyCode::Char(c) => {
self.buf.push(c);
InputOutcome::Pending
}
_ => InputOutcome::Pending,
}
}
pub fn display(&self) -> String {
if self.masked {
mask(&self.buf)
} else {
self.buf.clone()
}
}
}