feat(qbzd): themed setup TUI with section borders and accent palette

cc
This commit is contained in:
vicrodh committed 2026-07-15 08:37:56 -06:00
1 parent 6b444f9748
commit 74dcc60b18
11 files changed
+402 -192

No files matched your search

+50 -28
View File
@@ -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,
);
}
+1
View File
@@ -11,6 +11,7 @@
pub mod app;
pub mod strings;
pub mod theme;
pub mod widgets;
mod screens;
+2 -2
View File
@@ -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);
+37 -35
View File
@@ -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();
+38 -22
View File
@@ -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 {
+8 -8
View File
@@ -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 {
+29 -14
View File
@@ -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> {
+2 -2
View File
@@ -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),
+8
View File
@@ -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";
+52
View File
@@ -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
View File
@@ -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]);
}
}