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:
15 files changed
+5554
-145
No files matched your search
@@ -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"
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(" · "))
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user