fix(qbzd): footer state mapping test, layout parity and help-tint precision in themed TUI

cc
This commit is contained in:
vicrodh committed 2026-07-15 08:47:15 -06:00
1 parent 74dcc60b18
commit 927b5aa762
3 files changed
+80 -26

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ pub fn login_timeout(port: u16) -> String {
)
}
/// FB1 (owner feedback, post-smoke): printed once, before the URL, when
/// FB1 (owner feedback, post-smoke): printed once, after the URL, when
/// `SSH_CONNECTION` auto-detected the LAN callback host. The common real case
/// is configuring the daemon headless over SSH from another machine on the
/// LAN, so the operator should know the link isn't loopback-only.
+56 -18
View File
@@ -879,7 +879,7 @@ impl App {
}
fn draw_menu(&self, f: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = vec![widgets::blank()];
let mut lines: Vec<Line> = Vec::new();
for (i, label) in s::MENU_ROWS.iter().enumerate() {
let focused = i == self.menu_focus;
let summary = self.menu_summaries.get(i).map(String::as_str).unwrap_or("");
@@ -897,24 +897,11 @@ impl App {
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.
/// The daemon-state footer, color-coded via `footer_state`. Never color
/// alone — every state spells itself out.
fn draw_footer(&self, f: &mut Frame, area: Rect) {
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 {
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())
};
let playing = self.status.as_ref().and_then(playing_extra);
let (text, style) = footer_state(self.reachable, self.auth.logged_in, playing);
f.render_widget(
Paragraph::new(Line::from(Span::styled(text, style))),
area,
@@ -1181,6 +1168,34 @@ fn build_live(bundle: &Bundle) -> (LiveSystem, AudioBackendType, Vec<AudioDevice
)
}
/// Pure footer mapping (tested below). Three states, each spelled out in text —
/// the tone only reinforces it:
/// - unreachable → dim `daemon: not reachable`;
/// - reachable but not signed in → warn `daemon: running · not signed in`
/// (a deliberate FB2 addition over the base footer: an operator-visible
/// needs-auth cue, owner veto at the smoke);
/// - running + signed in → ok, with the optional `playing …` tail.
fn footer_state(
reachable: bool,
logged_in: bool,
playing: Option<String>,
) -> (String, ratatui::style::Style) {
if !reachable {
(format!(" {}", s::FOOTER_UNREACHABLE), theme::dim())
} else if !logged_in {
(
format!(" {} · {}", s::FOOTER_RUNNING, s::FOOTER_NEEDS_AUTH),
theme::warn(),
)
} else {
let text = match playing {
Some(e) => format!(" {} · {e}", s::FOOTER_RUNNING),
None => format!(" {}", s::FOOTER_RUNNING),
};
(text, theme::ok())
}
}
/// A "playing 192000 Hz / 24 bit" tail from a status body (§4.3), if playing.
fn playing_extra(status: &Value) -> Option<String> {
let state = status.pointer("/playback/state").and_then(Value::as_str).unwrap_or("");
@@ -1232,4 +1247,27 @@ mod tests {
// Credential file present and logged in (daemon up) → land on Menu
assert_eq!(initial_screen(true, true), None);
}
#[test]
fn footer_state_maps_the_three_daemon_states() {
// Unreachable → dim, regardless of auth.
let (text, style) = footer_state(false, true, None);
assert_eq!(text, format!(" {}", s::FOOTER_UNREACHABLE));
assert_eq!(style, theme::dim());
// Reachable but not signed in → warn, names the missing auth.
let (text, style) = footer_state(true, false, None);
assert_eq!(text, format!(" {} · {}", s::FOOTER_RUNNING, s::FOOTER_NEEDS_AUTH));
assert_eq!(style, theme::warn());
// Running + signed in → ok, with and without the playing tail.
let (text, style) = footer_state(true, true, None);
assert_eq!(text, format!(" {}", s::FOOTER_RUNNING));
assert_eq!(style, theme::ok());
let (text, _) = footer_state(true, true, Some("playing 96000 Hz / 24 bit".into()));
assert_eq!(
text,
format!(" {} · playing 96000 Hz / 24 bit", s::FOOTER_RUNNING)
);
}
}
+23 -7
View File
@@ -7,7 +7,7 @@
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap};
use ratatui::Frame;
@@ -54,7 +54,7 @@ pub fn field_line(
if focused {
let mut sel = focus_style();
if !enabled {
sel = sel.add_modifier(Modifier::DIM);
sel = sel.patch(theme::dim());
}
return Line::from(vec![
Span::styled(label_txt, sel),
@@ -182,9 +182,20 @@ fn truncate(s: &str, max: usize) -> String {
// ============================ help bar ============================
/// True only for tokens that are real key glyphs in the hint vocabulary: a
/// single character (`s`, `r`, `/`, `?`, `q`, `y`, `d`, `p`, …) or a named key.
/// Instructional words ("type") are NOT keys — their segment stays dim.
fn is_key_glyph(token: &str) -> bool {
token.chars().count() == 1
|| matches!(
token,
"Esc" | "Enter" | "Tab" | "Shift-Tab" | "up/down" | "left/right" | "up" | "down"
)
}
/// 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).
/// Segments are separated by ` · `; a segment's leading token is accent-tinted
/// only when it is a real key glyph — otherwise the whole segment is 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() {
@@ -192,11 +203,14 @@ pub fn help_spans(text: &str) -> Vec<Span<'static>> {
spans.push(Span::styled(" · ", theme::dim()));
}
match seg.split_once(' ') {
Some((key, rest)) => {
Some((key, rest)) if is_key_glyph(key) => {
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())),
None if is_key_glyph(seg) => {
spans.push(Span::styled(seg.to_string(), theme::accent()));
}
_ => spans.push(Span::styled(seg.to_string(), theme::dim())),
}
}
spans
@@ -275,8 +289,10 @@ pub fn spinner_frame(tick: u64) -> char {
/// 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) {
// Layout parity with the pre-theme overlay: body = "<spinner> <label>"
// (label + 2 chars) + 6 → total rect width = label + 8.
let width = label.chars().count() as u16 + 8;
let rect = centered_rect(width + 4, 5, area);
let rect = centered_rect(width, 5, area);
f.render_widget(Clear, rect);
let block = Block::bordered()
.border_type(BorderType::Rounded)