feat(qbzd): themed setup TUI with section borders and accent palette
cc
This commit is contained in:
11 files changed
+402
-192
No files matched your search
+50
-28
@@ -33,12 +33,14 @@ use super::screens::network::{self as network_screen, NetworkState};
|
||||
use super::screens::playback::PlaybackState;
|
||||
use super::screens::qconnect::QConnectState;
|
||||
use super::strings as s;
|
||||
use super::theme;
|
||||
use super::widgets;
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ============================ shared vocabulary ============================
|
||||
@@ -805,9 +807,12 @@ impl App {
|
||||
let footer_area = rows[1];
|
||||
let help_area = rows[2];
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", self.screen_title()));
|
||||
// The outer frame stays neutral (dim) so the accent-bordered ACTIVE inner
|
||||
// section is what draws the eye; the accent-bold title carries identity.
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(theme::dim())
|
||||
.title(self.screen_title_line());
|
||||
let inner = block.inner(content_area);
|
||||
f.render_widget(block, content_area);
|
||||
|
||||
@@ -850,8 +855,8 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn screen_title(&self) -> String {
|
||||
let (name, dirty) = match &self.active {
|
||||
fn screen_name_dirty(&self) -> (&'static str, bool) {
|
||||
match &self.active {
|
||||
Active::Menu => (s::MENU_TITLE, false),
|
||||
Active::Account(_) => (s::ACCOUNT_TITLE, false),
|
||||
Active::Audio(sc) => (s::AUDIO_TITLE, sc.is_dirty()),
|
||||
@@ -859,42 +864,59 @@ impl App {
|
||||
Active::QConnect(sc) => (s::QCONNECT_TITLE, sc.is_dirty()),
|
||||
Active::Network(sc) => (s::NETWORK_TITLE, sc.is_dirty()),
|
||||
Active::Bundle(_) => (s::BUNDLE_TITLE, false),
|
||||
};
|
||||
if dirty {
|
||||
format!("{name} *")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame title: accent-bold screen name, plus a warn `*` when the screen
|
||||
/// has unsaved edits (the `*` glyph is the meaning; the color reinforces it).
|
||||
fn screen_title_line(&self) -> Line<'static> {
|
||||
let (name, dirty) = self.screen_name_dirty();
|
||||
let mut spans = vec![Span::styled(format!(" {name} "), theme::accent_bold())];
|
||||
if dirty {
|
||||
spans.push(Span::styled("* ", theme::warn()));
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn draw_menu(&self, f: &mut Frame, area: Rect) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut lines: Vec<Line> = vec![widgets::blank()];
|
||||
for (i, label) in s::MENU_ROWS.iter().enumerate() {
|
||||
let focused = i == self.menu_focus;
|
||||
let marker = if focused { "▸" } else { " " };
|
||||
let summary = self.menu_summaries.get(i).map(String::as_str).unwrap_or("");
|
||||
let text = format!("{marker} {:<20} {}", label, summary);
|
||||
let style = if focused { widgets::focus_style() } else { Default::default() };
|
||||
lines.push(Line::from(ratatui::text::Span::styled(text, style)));
|
||||
let (marker, marker_style, label_style) = if focused {
|
||||
("▸ ", theme::accent(), theme::accent_bold())
|
||||
} else {
|
||||
(" ", Style::default(), Style::default())
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(marker, marker_style),
|
||||
Span::styled(format!("{label:<20}"), label_style),
|
||||
Span::styled(format!(" {summary}"), theme::dim()),
|
||||
]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
/// The daemon-state footer, color-coded: running (ok/green), running but not
|
||||
/// signed in (warn/yellow), unreachable (dim). Never color alone — every state
|
||||
/// spells itself out.
|
||||
fn draw_footer(&self, f: &mut Frame, area: Rect) {
|
||||
let text = if self.reachable {
|
||||
let extra = self.status.as_ref().and_then(playing_extra);
|
||||
match extra {
|
||||
Some(e) => format!(" daemon: running · {e}"),
|
||||
None => " daemon: running".to_string(),
|
||||
}
|
||||
let (text, style) = if !self.reachable {
|
||||
(format!(" {}", s::FOOTER_UNREACHABLE), theme::dim())
|
||||
} else if !self.auth.logged_in {
|
||||
(
|
||||
format!(" {} · {}", s::FOOTER_RUNNING, s::FOOTER_NEEDS_AUTH),
|
||||
theme::warn(),
|
||||
)
|
||||
} else {
|
||||
format!(" {}", s::FOOTER_UNREACHABLE)
|
||||
let text = match self.status.as_ref().and_then(playing_extra) {
|
||||
Some(e) => format!(" {} · {e}", s::FOOTER_RUNNING),
|
||||
None => format!(" {}", s::FOOTER_RUNNING),
|
||||
};
|
||||
(text, theme::ok())
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(ratatui::text::Span::styled(
|
||||
text,
|
||||
widgets::dim_style(),
|
||||
))),
|
||||
Paragraph::new(Line::from(Span::styled(text, style))),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod strings;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
mod screens;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
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};
|
||||
@@ -164,7 +163,8 @@ impl AccountState {
|
||||
lines.push(widgets::action_line(&format!("> {action}"), focused, true));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let secs = [widgets::Section::new(s::ACCOUNT_SECTION, true, lines)];
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
if let Some(input) = &self.token_input {
|
||||
widgets::modal(f, area, s::ACCOUNT_PASTE_TOKEN, &input.display(), s::HELP_INPUT);
|
||||
|
||||
@@ -16,7 +16,6 @@ use qbz_audio::{AlsaPlugin, AudioBackendType, AudioDevice, BackendManager};
|
||||
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};
|
||||
@@ -656,44 +655,36 @@ impl AudioState {
|
||||
|
||||
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
|
||||
let fields = visible_fields(&self.staged);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
let render_group = |lines: &mut Vec<Line>, header: &str, group: &[AField]| {
|
||||
let any = group.iter().any(|g| fields.contains(g));
|
||||
if !any {
|
||||
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));
|
||||
}
|
||||
}
|
||||
let focused_field = fields.get(self.focus).copied();
|
||||
let active = |members: &[AField]| {
|
||||
focused_field.map(|ff| members.contains(&ff)).unwrap_or(false)
|
||||
};
|
||||
|
||||
use AField::*;
|
||||
render_group(
|
||||
&mut lines,
|
||||
s::AUDIO_GROUP_OUTPUT,
|
||||
&[Backend, Device, AlsaPlugin, HwVolume, Dsd],
|
||||
);
|
||||
if self.staged.backend == AudioBackendType::Jack {
|
||||
lines.push(widgets::note_line(s::JACK_WARNING));
|
||||
}
|
||||
lines.push(widgets::blank());
|
||||
render_group(
|
||||
&mut lines,
|
||||
s::AUDIO_GROUP_BITPERFECT,
|
||||
&[Exclusive, Reserve, Passthrough, ForceBp, LockOutput],
|
||||
);
|
||||
lines.push(widgets::blank());
|
||||
render_group(
|
||||
&mut lines,
|
||||
s::AUDIO_GROUP_TRANSPORT,
|
||||
&[StreamUncached, Buffer, StreamingOnly],
|
||||
);
|
||||
let mut secs: Vec<widgets::Section> = Vec::new();
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let out_members: &[AField] = &[Backend, Device, AlsaPlugin, HwVolume, Dsd];
|
||||
let mut out_lines = self.group_lines(&fields, out_members);
|
||||
if self.staged.backend == AudioBackendType::Jack {
|
||||
out_lines.push(widgets::warn_line(s::JACK_WARNING));
|
||||
}
|
||||
if !out_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::AUDIO_GROUP_OUTPUT, active(out_members), out_lines));
|
||||
}
|
||||
|
||||
let bp_members: &[AField] = &[Exclusive, Reserve, Passthrough, ForceBp, LockOutput];
|
||||
let bp_lines = self.group_lines(&fields, bp_members);
|
||||
if !bp_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::AUDIO_GROUP_BITPERFECT, active(bp_members), bp_lines));
|
||||
}
|
||||
|
||||
let tr_members: &[AField] = &[StreamUncached, Buffer, StreamingOnly];
|
||||
let tr_lines = self.group_lines(&fields, tr_members);
|
||||
if !tr_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::AUDIO_GROUP_TRANSPORT, active(tr_members), tr_lines));
|
||||
}
|
||||
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
// Overlays.
|
||||
match &self.editor {
|
||||
@@ -717,6 +708,17 @@ impl AudioState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The field rows of one group, in declared order (skipping hidden fields).
|
||||
fn group_lines(&self, fields: &[AField], members: &[AField]) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
for gf in members {
|
||||
if let Some(pos) = fields.iter().position(|x| x == gf) {
|
||||
lines.push(self.field_line(*gf, pos));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn field_line(&self, field: AField, focus_pos: usize) -> Line<'static> {
|
||||
let (_, enabled, reason) = row_state(field, &self.staged);
|
||||
let focused = focus_pos == self.focus && self.editor.is_none();
|
||||
|
||||
@@ -15,14 +15,14 @@ use qbz_app::settings::bundle::{
|
||||
use qbz_audio::{AudioBackendType, AudioDevice};
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::style::Modifier;
|
||||
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::theme;
|
||||
use crate::tui::widgets::{self, InputOutcome, SelectOutcome, SelectPopup, TextInput};
|
||||
|
||||
/// Everything a planned import carries between the plan worker, the re-pick, and
|
||||
@@ -342,40 +342,49 @@ impl BundleState {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(widgets::group_header(s::BUNDLE_IMPORT_HEADER));
|
||||
let cur = FIELDS[self.focus];
|
||||
|
||||
// IMPORT box.
|
||||
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());
|
||||
let import_lines = vec![
|
||||
self.row(BField::ImportPath, s::B_IMPORT_PATH, &path_val, "[input]"),
|
||||
self.action_row(BField::Review, s::B_IMPORT_ACTION),
|
||||
];
|
||||
let import_active = matches!(cur, BField::ImportPath | BField::Review);
|
||||
|
||||
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]",
|
||||
));
|
||||
// EXPORT box.
|
||||
let mut export_lines = vec![
|
||||
self.row(BField::ExportDest, s::B_EXPORT_DEST, &self.export_dest, "[input]"),
|
||||
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));
|
||||
export_lines.push(widgets::warn_line(l));
|
||||
}
|
||||
}
|
||||
lines.push(self.action_row(BField::Export, s::B_EXPORT_ACTION));
|
||||
|
||||
export_lines.push(self.action_row(BField::Export, s::B_EXPORT_ACTION));
|
||||
if self.has_desktop {
|
||||
lines.push(widgets::blank());
|
||||
export_lines.push(widgets::blank());
|
||||
for l in s::B_DESKTOP_HINT.lines() {
|
||||
lines.push(widgets::note_line(l));
|
||||
export_lines.push(widgets::note_line(l));
|
||||
}
|
||||
}
|
||||
let export_active = matches!(cur, BField::ExportDest | BField::IncludeAuth | BField::Export);
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let secs = [
|
||||
widgets::Section::new(s::BUNDLE_IMPORT_HEADER, import_active, import_lines),
|
||||
widgets::Section::new(s::BUNDLE_EXPORT_HEADER, export_active, export_lines),
|
||||
];
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
match &self.editor {
|
||||
Some(Editor::ImportPath(input)) => {
|
||||
@@ -433,9 +442,16 @@ enum BucketKind {
|
||||
}
|
||||
|
||||
fn bucket(lines: &mut Vec<Line<'static>>, title: &str, rows: &[PlanLine], kind: BucketKind) {
|
||||
// Bucket headers carry a semantic tint (applies=ok, adapted=warn, skipped=dim);
|
||||
// the count and label stand on their own without it.
|
||||
let head_style = match kind {
|
||||
BucketKind::Applied => theme::ok().add_modifier(Modifier::BOLD),
|
||||
BucketKind::Adapted => theme::warn().add_modifier(Modifier::BOLD),
|
||||
BucketKind::Skipped => theme::dim().add_modifier(Modifier::BOLD),
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{title} ({})", rows.len()),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
head_style,
|
||||
)));
|
||||
for l in rows {
|
||||
let text = match kind {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -173,27 +172,28 @@ impl NetworkState {
|
||||
lines.push(widgets::field_line(label, &value, focused, true, None, widget));
|
||||
}
|
||||
|
||||
// Field-level validation notes (§4.2).
|
||||
// Field-level validation notes (§4.2): errors in red, exposure in yellow.
|
||||
if self.staged.bind.parse::<IpAddr>().is_err() {
|
||||
lines.push(widgets::note_line(s::N_BAD_IP));
|
||||
lines.push(widgets::err_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));
|
||||
lines.push(widgets::warn_line(l));
|
||||
}
|
||||
}
|
||||
if self.port_invalid() {
|
||||
lines.push(widgets::note_line(s::N_BAD_PORT));
|
||||
lines.push(widgets::err_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(", "))));
|
||||
lines.push(widgets::warn_line(s::N_DROP_UNKNOWN));
|
||||
lines.push(widgets::warn_line(&format!(" {}", self.unknown_keys.join(", "))));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let secs = [widgets::Section::new(s::NETWORK_SECTION, true, lines)];
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
if let Some((field, input)) = &self.editor {
|
||||
let title = match field {
|
||||
|
||||
@@ -13,7 +13,6 @@ 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};
|
||||
@@ -320,16 +319,33 @@ impl PlaybackState {
|
||||
|
||||
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
|
||||
let fields = visible_fields(&self.staged);
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let focused_field = fields.get(self.focus).copied();
|
||||
let active = |members: &[PField]| {
|
||||
focused_field.map(|ff| members.contains(&ff)).unwrap_or(false)
|
||||
};
|
||||
|
||||
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]);
|
||||
let mut secs: Vec<widgets::Section> = Vec::new();
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let quality: &[PField] = &[Quality, Limit, MaxRate, AllowFallback, RetryFail];
|
||||
let q_lines = self.group_lines(&fields, quality);
|
||||
if !q_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_QUALITY, active(quality), q_lines));
|
||||
}
|
||||
|
||||
let behavior: &[PField] = &[Continue, Gapless];
|
||||
let b_lines = self.group_lines(&fields, behavior);
|
||||
if !b_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_BEHAVIOR, active(behavior), b_lines));
|
||||
}
|
||||
|
||||
let session: &[PField] = &[Restore, Resume];
|
||||
let sess_lines = self.group_lines(&fields, session);
|
||||
if !sess_lines.is_empty() {
|
||||
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_SESSION, active(session), sess_lines));
|
||||
}
|
||||
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
match &self.editor {
|
||||
Some(Editor::Quality(p)) | Some(Editor::MaxRate(p)) | Some(Editor::Retry(p)) => {
|
||||
@@ -339,16 +355,15 @@ impl PlaybackState {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
/// The field rows of one group, in declared order (skipping hidden fields).
|
||||
fn group_lines(&self, fields: &[PField], members: &[PField]) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
for gf in members {
|
||||
if let Some(pos) = fields.iter().position(|x| x == gf) {
|
||||
lines.push(self.field_line(*gf, pos));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn field_line(&self, field: PField, focus_pos: usize) -> Line<'static> {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
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;
|
||||
|
||||
@@ -213,7 +212,8 @@ impl QConnectState {
|
||||
lines.push(widgets::note_line(&live));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
let secs = [widgets::Section::new(s::QCONNECT_SECTION, true, lines)];
|
||||
widgets::sections(f, area, &secs);
|
||||
|
||||
match &self.editor {
|
||||
Some(Editor::Volume(p)) => p.draw(f, area),
|
||||
|
||||
@@ -70,6 +70,8 @@ pub const DIRTY_HINT: &str = "s save · d discard · Esc stay";
|
||||
// ============================ footer (daemon state) ============================
|
||||
|
||||
pub const FOOTER_UNREACHABLE: &str = "daemon: not reachable";
|
||||
pub const FOOTER_RUNNING: &str = "daemon: running";
|
||||
pub const FOOTER_NEEDS_AUTH: &str = "not signed in";
|
||||
/// 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";
|
||||
@@ -77,6 +79,8 @@ pub const APPLIES_ON_START: &str =
|
||||
// ============================ Account (§3.1) ============================
|
||||
|
||||
pub const ACCOUNT_TITLE: &str = "Account";
|
||||
/// In-screen section box title (distinct from the screen title in the frame).
|
||||
pub const ACCOUNT_SECTION: &str = "SIGN-IN";
|
||||
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 —
|
||||
@@ -194,6 +198,8 @@ pub const RATE_NO_LIMIT: &str = "No limit";
|
||||
// ============================ QConnect (§3.4) ============================
|
||||
|
||||
pub const QCONNECT_TITLE: &str = "Qobuz Connect";
|
||||
/// In-screen section box title.
|
||||
pub const QCONNECT_SECTION: &str = "CONNECTION";
|
||||
pub const QC_ENABLE: &str = "Enable";
|
||||
pub const QC_DEVICE_NAME: &str = "Device name";
|
||||
pub const QC_VOLUME_MODE: &str = "Volume mode";
|
||||
@@ -208,6 +214,8 @@ pub fn qc_preview(name: &str) -> String {
|
||||
// ============================ Network (§3.5) ============================
|
||||
|
||||
pub const NETWORK_TITLE: &str = "Network";
|
||||
/// In-screen section box title.
|
||||
pub const NETWORK_SECTION: &str = "HTTP SERVER";
|
||||
pub const N_BIND: &str = "Bind address";
|
||||
pub const N_PORT: &str = "Port";
|
||||
pub const N_TOKEN: &str = "Access token";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// crates/qbzd/src/tui/theme.rs — the setup TUI's one small palette.
|
||||
//
|
||||
// QBZ's visual identity is restrained: ONE accent (a calm cyan) plus a handful
|
||||
// of semantic slots. Named colors only — they degrade cleanly on 16-, 8- and
|
||||
// monochrome terminals and over serial. Color NEVER carries meaning on its own:
|
||||
// the `*` dirty marker, the on/off labels, the [BP] badge and every glyph stay
|
||||
// as text (serial-console safety, 03-setup-tui.md §1.2). Color only reinforces
|
||||
// state a reader can already see without it.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// The single accent — screen/section titles, active borders, selected rows,
|
||||
/// key glyphs, the ▸ menu marker.
|
||||
pub const ACCENT: Color = Color::Cyan;
|
||||
/// OK / on / running.
|
||||
pub const OK: Color = Color::Green;
|
||||
/// Attention — the dirty `*`, needs-auth, exposure and safety warnings.
|
||||
pub const WARN: Color = Color::Yellow;
|
||||
/// Error — rejected input, failures.
|
||||
pub const ERR: Color = Color::Red;
|
||||
|
||||
/// Accent text (titles, focused labels, key glyphs).
|
||||
pub fn accent() -> Style {
|
||||
Style::default().fg(ACCENT)
|
||||
}
|
||||
|
||||
/// Accent, emphasized — section/screen titles.
|
||||
pub fn accent_bold() -> Style {
|
||||
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Dim — help descriptions, inactive borders, secondary/summary text, notes.
|
||||
pub fn dim() -> Style {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
}
|
||||
|
||||
/// The selected/focused row: accent reversed. Terminal-theme agnostic (reverse
|
||||
/// survives even where the accent foreground is invisible), so it doubles as the
|
||||
/// serial-safe selection affordance.
|
||||
pub fn selection() -> Style {
|
||||
Style::default().fg(ACCENT).add_modifier(Modifier::REVERSED)
|
||||
}
|
||||
|
||||
pub fn ok() -> Style {
|
||||
Style::default().fg(OK)
|
||||
}
|
||||
pub fn warn() -> Style {
|
||||
Style::default().fg(WARN)
|
||||
}
|
||||
pub fn err() -> Style {
|
||||
Style::default().fg(ERR)
|
||||
}
|
||||
+175
-81
@@ -9,17 +9,16 @@ 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::widgets::{Block, BorderType, Clear, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::theme;
|
||||
use super::strings;
|
||||
|
||||
/// Focused-row emphasis (reverse video — terminal-theme agnostic, §1.2).
|
||||
/// Focused-row emphasis: accent reversed (§1.2 — reverse is terminal-theme
|
||||
/// agnostic, so the selection reads even on monochrome/serial).
|
||||
pub fn focus_style() -> Style {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
}
|
||||
pub fn dim_style() -> Style {
|
||||
Style::default().add_modifier(Modifier::DIM)
|
||||
theme::selection()
|
||||
}
|
||||
|
||||
/// Mask a secret for display (token/paste rows never render plaintext).
|
||||
@@ -29,8 +28,12 @@ pub fn mask(s: &str) -> String {
|
||||
|
||||
// ============================ field rows ============================
|
||||
|
||||
/// A single label/value row. `focused` reverses it; `enabled = false` dims it and
|
||||
/// appends `(reason)`; `widget` is a trailing `[toggle]`/`[select]`/… hint.
|
||||
/// A single label/value row rendered as three aligned spans (label · value ·
|
||||
/// widget hint). When `focused` the whole row becomes one accent bar (the reverse
|
||||
/// bar wins over per-value tone so it reads uniformly). Otherwise: a disabled row
|
||||
/// dims and appends `(reason)`; an enabled `[toggle]` value is tinted ok (on) or
|
||||
/// dim (off); the `widget` hint is always dim. Meaning never rides on color alone
|
||||
/// — the on/off text, `(reason)` and hint all stay legible without it.
|
||||
pub fn field_line(
|
||||
label: &str,
|
||||
value: &str,
|
||||
@@ -43,54 +46,128 @@ pub fn field_line(
|
||||
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);
|
||||
}
|
||||
// Fixed gutters so labels/values line up regardless of per-span styling.
|
||||
let label_txt = format!(" {label:<20} ");
|
||||
let value_txt = format!("{:<32} ", truncate(&shown_value, 32));
|
||||
let widget_txt = widget.to_string();
|
||||
|
||||
if focused {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
let mut sel = focus_style();
|
||||
if !enabled {
|
||||
sel = sel.add_modifier(Modifier::DIM);
|
||||
}
|
||||
return Line::from(vec![
|
||||
Span::styled(label_txt, sel),
|
||||
Span::styled(value_txt, sel),
|
||||
Span::styled(widget_txt, sel),
|
||||
]);
|
||||
}
|
||||
|
||||
let label_style = if enabled { Style::default() } else { theme::dim() };
|
||||
let value_style = if !enabled {
|
||||
theme::dim()
|
||||
} else if widget == "[toggle]" {
|
||||
toggle_tone(value)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(label_txt, label_style),
|
||||
Span::styled(value_txt, value_style),
|
||||
Span::styled(widget_txt, theme::dim()),
|
||||
])
|
||||
}
|
||||
|
||||
/// ok for an on/enabled toggle, dim for off — the text ("on"/"off") carries the
|
||||
/// meaning; the tint only reinforces it.
|
||||
fn toggle_tone(value: &str) -> Style {
|
||||
if value == "off" {
|
||||
theme::dim()
|
||||
} else {
|
||||
theme::ok()
|
||||
}
|
||||
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.
|
||||
/// action item). `focused` gives it the accent bar.
|
||||
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);
|
||||
}
|
||||
let style = if focused {
|
||||
focus_style()
|
||||
} else if !enabled {
|
||||
theme::dim()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
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).
|
||||
/// A dim, wrapped note under a field (previews, hints).
|
||||
pub fn note_line(text: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
format!(" {text}"),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
))
|
||||
Line::from(Span::styled(format!(" {text}"), theme::dim()))
|
||||
}
|
||||
|
||||
/// A warn-tinted note (LAN exposure, DSD/auth safety, unknown-key preservation).
|
||||
/// The copy already reads as a warning; the tint is a second channel, not the
|
||||
/// only one.
|
||||
pub fn warn_line(text: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(format!(" {text}"), theme::warn()))
|
||||
}
|
||||
|
||||
/// An error-tinted note (rejected bind/port). Same rule: the text stands alone.
|
||||
pub fn err_line(text: &str) -> Line<'static> {
|
||||
Line::from(Span::styled(format!(" {text}"), theme::err()))
|
||||
}
|
||||
|
||||
// ============================ section boxes ============================
|
||||
|
||||
/// One titled, rounded section box's worth of content. `active` (the group that
|
||||
/// owns the focused field) borders in accent; the rest border dim.
|
||||
pub struct Section {
|
||||
pub title: String,
|
||||
pub active: bool,
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub fn new(title: impl Into<String>, active: bool, lines: Vec<Line<'static>>) -> Self {
|
||||
Self { title: title.into(), active, lines }
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack titled, rounded section boxes top-to-bottom in `area`. Each box is sized
|
||||
/// to its content (+2 for the border); a trailing filler keeps them compact at
|
||||
/// the top rather than stretching. The active box borders + titles in accent.
|
||||
pub fn sections(f: &mut Frame, area: Rect, secs: &[Section]) {
|
||||
if secs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut constraints: Vec<Constraint> = secs
|
||||
.iter()
|
||||
.map(|s| Constraint::Length(s.lines.len() as u16 + 2))
|
||||
.collect();
|
||||
constraints.push(Constraint::Min(0));
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
for (i, sec) in secs.iter().enumerate() {
|
||||
let (border_style, title_style) = if sec.active {
|
||||
(theme::accent(), theme::accent_bold())
|
||||
} else {
|
||||
(theme::dim(), theme::dim())
|
||||
};
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(border_style)
|
||||
.title(Line::from(Span::styled(format!(" {} ", sec.title), title_style)));
|
||||
let inner = block.inner(chunks[i]);
|
||||
f.render_widget(block, chunks[i]);
|
||||
f.render_widget(Paragraph::new(sec.lines.clone()), inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
@@ -105,13 +182,31 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
|
||||
// ============================ help bar ============================
|
||||
|
||||
/// The bottom help bar (one line, dim), drawn into `area`.
|
||||
/// Split a `key desc · key desc` hint into accent-key / dim-description spans.
|
||||
/// Segments are separated by ` · `; within a segment the first token is the key
|
||||
/// glyph (accent) and the remainder is its description (dim).
|
||||
pub fn help_spans(text: &str) -> Vec<Span<'static>> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
for (i, seg) in text.split(" · ").enumerate() {
|
||||
if i > 0 {
|
||||
spans.push(Span::styled(" · ", theme::dim()));
|
||||
}
|
||||
match seg.split_once(' ') {
|
||||
Some((key, rest)) => {
|
||||
spans.push(Span::styled(key.to_string(), theme::accent()));
|
||||
spans.push(Span::styled(format!(" {rest}"), theme::dim()));
|
||||
}
|
||||
None => spans.push(Span::styled(seg.to_string(), theme::accent())),
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// The bottom help bar (one line): accent key glyphs, dim descriptions.
|
||||
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);
|
||||
let mut spans = vec![Span::raw(" ")];
|
||||
spans.extend(help_spans(text));
|
||||
f.render_widget(Paragraph::new(Line::from(spans)), area);
|
||||
}
|
||||
|
||||
// ============================ centered modal ============================
|
||||
@@ -139,28 +234,30 @@ pub fn modal(f: &mut Frame, area: Rect, title: &str, body: &str, hint: &str) {
|
||||
+ 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 block = titled_block(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),
|
||||
)));
|
||||
text.push(Line::from(help_spans(hint)));
|
||||
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
/// A rounded, accent-bordered block with an accent-bold title — the shared frame
|
||||
/// for modals, popups and panels.
|
||||
fn titled_block(title: &str) -> Block<'static> {
|
||||
Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(theme::accent())
|
||||
.title(Line::from(Span::styled(format!(" {title} "), theme::accent_bold())))
|
||||
}
|
||||
|
||||
/// 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 block = titled_block(title);
|
||||
let inner = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
f.render_widget(
|
||||
@@ -175,16 +272,23 @@ pub fn spinner_frame(tick: u64) -> char {
|
||||
FRAMES[(tick as usize) % FRAMES.len()]
|
||||
}
|
||||
|
||||
/// Busy overlay: a small centered spinner + label (§5.5).
|
||||
/// Busy overlay: a small centered spinner + label (§5.5). The spinner glyph is
|
||||
/// accent; the label plain.
|
||||
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);
|
||||
let width = label.chars().count() as u16 + 8;
|
||||
let rect = centered_rect(width + 4, 5, area);
|
||||
f.render_widget(Clear, rect);
|
||||
let block = Block::default().borders(Borders::ALL);
|
||||
let block = Block::bordered()
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(theme::accent());
|
||||
let inner = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!("{} ", spinner_frame(tick)), theme::accent()),
|
||||
Span::raw(label.to_string()),
|
||||
]);
|
||||
f.render_widget(
|
||||
Paragraph::new(body).alignment(Alignment::Center),
|
||||
Paragraph::new(line).alignment(Alignment::Center),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
@@ -325,17 +429,15 @@ impl SelectPopup {
|
||||
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),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(h.clone(), theme::accent_bold())));
|
||||
}
|
||||
}
|
||||
let mut style = Style::default();
|
||||
if *i == self.idx {
|
||||
style = style.add_modifier(Modifier::REVERSED);
|
||||
let style = if *i == self.idx {
|
||||
sel_line = lines.len() as u16;
|
||||
}
|
||||
focus_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", self.options[*i]),
|
||||
style,
|
||||
@@ -368,9 +470,7 @@ impl SelectPopup {
|
||||
+ 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 block = titled_block(&self.title);
|
||||
let inner = block.inner(rect);
|
||||
f.render_widget(block, rect);
|
||||
|
||||
@@ -382,13 +482,7 @@ impl SelectPopup {
|
||||
// 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],
|
||||
);
|
||||
f.render_widget(Paragraph::new(Line::from(help_spans(&hint))), chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user