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:
4 files changed
+237
-4
No files matched your search
@@ -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"
|
||||
@@ -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" {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user