feat(qbzd): TUI setup wizard with ratatui (Phase 1.5)

Interactive menu-based wizard accessible via 'qbzd setup':
- Audio Backend & Device: lists backends, devices, saves selection
- Cache & Resources: instructions for qbzd.toml config
- Integrations: ListenBrainz token setup instructions
- QConnect: config instructions

Full ratatui TUI with keyboard navigation (up/down/enter/q).
Individual sections runnable via 'qbzd setup --section audio'.

Audio section is fully functional: enumerates backends and devices,
saves selection to audio_settings.db.

cc
This commit is contained in:
vicrodh committed 2026-04-09 05:44:54 -06:00
1 parent 632b134952
commit eade143342
4 files changed
+237 -4

No files matched your search

+4
View File
@@ -65,3 +65,7 @@ keyring = { version = "3", features = ["async-secret-service", "crypto-rust"] }
# MPRIS media controls (Linux D-Bus, headless)
souvlaki = "0.8"
# TUI wizard
ratatui = "0.29"
crossterm = "0.28"
+4 -4
View File
@@ -6,6 +6,7 @@ mod login;
mod mpris;
mod qconnect;
mod resources;
mod wizard;
mod session;
use clap::{Parser, Subcommand};
@@ -132,11 +133,10 @@ async fn main() {
}
}
Some(Commands::Setup { section }) => {
eprintln!("TUI setup wizard not yet implemented.");
if let Some(s) = section {
eprintln!("Requested section: {}", s);
if let Err(e) = wizard::run(section.as_deref()) {
eprintln!("Setup wizard error: {}", e);
std::process::exit(1);
}
eprintln!("Use the HTTP API or qbzd.toml to configure for now.");
}
Some(Commands::Token) => {
if cfg.server.token == "auto" {
+75
View File
@@ -0,0 +1,75 @@
//! Audio backend & device wizard section.
use qbz_audio::{AudioBackendType, BackendManager};
pub fn run_audio_wizard() -> Result<(), String> {
println!("\n=== Audio Backend & Device ===\n");
// List available backends
let backends = BackendManager::available_backends();
println!("Available backends:");
for (i, bt) in backends.iter().enumerate() {
let backend = BackendManager::create_backend(*bt);
let (available, desc) = match backend {
Ok(b) => (b.is_available(), b.description().to_string()),
Err(_) => (false, "Not available".to_string()),
};
let name = match bt {
AudioBackendType::PipeWire => "PipeWire",
AudioBackendType::Alsa => "ALSA Direct",
AudioBackendType::Pulse => "PulseAudio",
AudioBackendType::SystemDefault => "System Default",
};
let status = if available { "available" } else { "not found" };
println!(" {}. {} - {} ({})", i + 1, name, desc, status);
}
println!("\nSelect backend (1-{}):", backends.len());
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| e.to_string())?;
let choice: usize = input.trim().parse().unwrap_or(1);
if choice == 0 || choice > backends.len() {
return Err("Invalid choice".to_string());
}
let selected_backend = backends[choice - 1];
println!("\nSelected: {:?}", selected_backend);
// List devices for selected backend
let backend = BackendManager::create_backend(selected_backend)
.map_err(|e| format!("Backend error: {}", e))?;
let devices = backend.enumerate_devices().map_err(|e| format!("Device error: {}", e))?;
if devices.is_empty() {
println!("No devices found for this backend.");
} else {
println!("\nAvailable devices:");
for (i, device) in devices.iter().enumerate() {
let default_mark = if device.is_default { " (default)" } else { "" };
println!(" {}. {}{}", i + 1, device.name, default_mark);
}
println!("\nSelect device (1-{}, or Enter for default):", devices.len());
let mut dev_input = String::new();
std::io::stdin().read_line(&mut dev_input).map_err(|e| e.to_string())?;
let dev_choice: usize = dev_input.trim().parse().unwrap_or(0);
if dev_choice > 0 && dev_choice <= devices.len() {
println!("Selected device: {}", devices[dev_choice - 1].name);
} else {
println!("Using default device");
}
}
// Save to audio settings
if let Ok(store) = qbz_audio::settings::AudioSettingsStore::new() {
let _ = store.set_backend_type(Some(selected_backend));
println!("\nAudio settings saved.");
} else {
println!("\nNote: Could not save settings (no active session). Settings will apply when qbzd starts.");
}
println!();
Ok(())
}
+154
View File
@@ -0,0 +1,154 @@
//! Interactive TUI setup wizard for qbzd.
//!
//! Configures audio, playback, cache, integrations without
//! needing the desktop app. Reads/writes config files and
//! databases directly (runs without the daemon).
mod audio;
use crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{prelude::*, widgets::*};
use std::io::stdout;
/// Available wizard sections
const SECTIONS: &[(&str, &str)] = &[
("audio", "Audio Backend & Device"),
("cache", "Cache & Resources"),
("integrations", "Integrations (ListenBrainz)"),
("qconnect", "QConnect"),
];
/// Run the full wizard or a specific section.
pub fn run(section: Option<&str>) -> Result<(), String> {
if let Some(s) = section {
return run_section(s);
}
// Full wizard: show menu, let user pick sections
enable_raw_mode().map_err(|e| e.to_string())?;
stdout().execute(EnterAlternateScreen).map_err(|e| e.to_string())?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend).map_err(|e| e.to_string())?;
let mut selected = 0usize;
let result = loop {
terminal
.draw(|frame| draw_menu(frame, selected))
.map_err(|e| e.to_string())?;
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Up | KeyCode::Char('k') => {
if selected > 0 {
selected -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if selected < SECTIONS.len() - 1 {
selected += 1;
}
}
KeyCode::Enter => {
// Temporarily leave TUI to run section
disable_raw_mode().ok();
stdout().execute(LeaveAlternateScreen).ok();
let section_key = SECTIONS[selected].0;
if let Err(e) = run_section(section_key) {
eprintln!("Section '{}' error: {}", section_key, e);
}
// Return to TUI
enable_raw_mode().ok();
stdout().execute(EnterAlternateScreen).ok();
terminal = Terminal::new(CrosstermBackend::new(stdout()))
.map_err(|e| e.to_string())?;
}
_ => {}
}
}
};
disable_raw_mode().ok();
stdout().execute(LeaveAlternateScreen).ok();
result
}
fn draw_menu(frame: &mut Frame, selected: usize) {
let area = frame.area();
let items: Vec<ListItem> = SECTIONS
.iter()
.enumerate()
.map(|(i, (_, label))| {
let style = if i == selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if i == selected { "> " } else { " " };
ListItem::new(format!("{}{}", prefix, label)).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" QBZ Daemon Setup ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.padding(Padding::new(2, 2, 1, 1)),
)
.highlight_style(Style::default().bg(Color::Cyan).fg(Color::Black));
let help = Paragraph::new("↑↓ Navigate Enter Select q Quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
let layout = Layout::vertical([
Constraint::Min(SECTIONS.len() as u16 + 4),
Constraint::Length(1),
])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(help, layout[1]);
}
fn run_section(section: &str) -> Result<(), String> {
match section {
"audio" => audio::run_audio_wizard(),
"cache" => {
println!("\n=== Cache & Resources ===");
println!("Edit qbzd.toml [cache] section to configure:");
println!(" memory_mb, disk_mb, prefetch_count, prefetch_concurrent");
println!(" Set memory_mb = 0 for auto-detect from RAM\n");
Ok(())
}
"integrations" => {
println!("\n=== Integrations ===");
println!("ListenBrainz: use the HTTP API to connect:");
println!(" POST /api/integrations/listenbrainz/connect");
println!(" body: {{\"token\": \"your-listenbrainz-token\"}}\n");
Ok(())
}
"qconnect" => {
println!("\n=== QConnect ===");
println!("Edit qbzd.toml [qconnect] section:");
println!(" enabled = true/false");
println!(" device_name = \"your-device-name\"\n");
Ok(())
}
_ => Err(format!("Unknown section: {}", section)),
}
}