feat(qbzd): wide sidebar, wrapped descriptions and fixed control column in setup TUI

cc
This commit is contained in:
vicrodh committed 2026-07-15 12:15:32 -06:00
1 parent e6cb5114a9
commit b4bff581ae
10 files changed
+896 -175

No files matched your search

+54 -11
View File
@@ -71,11 +71,9 @@ pub const SCREENS: [Screen; 7] = [
Screen::Wizard,
];
/// Sidebar width (columns, incl. border). 14 keeps the content frame at 64 inner
/// columns on the 80-col floor — wide enough that the common field lines do NOT
/// clip their `[toggle]`/`[select]` hints; the inner 12 fits every sidebar label
/// (dirty-capable ones ≤ 8, so `▸ Name *` lands exactly).
const SIDEBAR_W: u16 = 14;
// Sidebar width is now responsive (FB5): `widgets::sidebar_width(term_width)` —
// 28 cols at ≥ 100 (roomy: spelled-out labels + a dim summary line), 14 below so
// the 80×24 floor keeps its 64-col content frame.
/// Which pane holds the keyboard focus. The frames shell has two: the persistent
/// left navigation sidebar and the right content frame (FB3).
@@ -1131,11 +1129,13 @@ impl App {
self.draw_header(f, rows[0]);
self.draw_breadcrumb(f, rows[1]);
let sidebar_w = widgets::sidebar_width(area.width);
let wide = widgets::sidebar_is_wide(area.width);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(SIDEBAR_W), Constraint::Min(0)])
.constraints([Constraint::Length(sidebar_w), Constraint::Min(0)])
.split(rows[2]);
self.draw_sidebar(f, body[0]);
self.draw_sidebar(f, body[0], wide);
// Content frame: accent border when the content owns focus, dim otherwise
// (its title is gone — the breadcrumb names the section now).
@@ -1225,10 +1225,12 @@ impl App {
f.render_widget(Paragraph::new(line), area);
}
/// Persistent left navigation: the six sections by name. The active one gets
/// Persistent left navigation: the sections by name. The active one gets
/// `▸` + accent; a dirty active section gets a warn `*`. When the nav owns
/// focus, the highlighted row reverses (serial-safe) and the border accents.
fn draw_sidebar(&self, f: &mut Frame, area: Rect) {
/// focus, the highlighted NAME row reverses (serial-safe) and the border
/// accents. In the wide tier (FB5) the labels spell out (`Import / Export`)
/// and each name carries a dim static summary line beneath it.
fn draw_sidebar(&self, f: &mut Frame, area: Rect, wide: bool) {
let border = if self.focus == Focus::Nav {
theme::accent()
} else {
@@ -1247,7 +1249,7 @@ impl App {
let is_active = *screen == self.active_section;
let dirty = sidebar_dirty_marker(*screen, self.active_section, active_dirty);
let highlighted = self.focus == Focus::Nav && i == self.nav_cursor;
let label = s::SIDEBAR_LABELS[i];
let label = if wide { s::SIDEBAR_LABELS_WIDE[i] } else { s::SIDEBAR_LABELS[i] };
let marker = if is_active { "▸ " } else { " " };
if highlighted {
@@ -1273,6 +1275,13 @@ impl App {
}
lines.push(Line::from(spans));
}
// Wide tier: a dim static summary under each name (never live state).
if wide {
lines.push(Line::from(Span::styled(
format!(" {}", s::SIDEBAR_SUMMARIES[i]),
theme::dim(),
)));
}
}
f.render_widget(Paragraph::new(lines), inner);
}
@@ -1814,6 +1823,40 @@ mod tests {
assert!(out.contains("terminal too small"));
}
// ---- 120×30 wide: the roomy sidebar tier + every section still renders (FB5) ----
#[test]
fn every_section_fits_the_120x30_wide() {
for screen in SCREENS {
for focus in [Focus::Nav, Focus::Content] {
let app = bare_app(screen, focus);
let out = render(&app, 120, 30);
assert!(out.contains("QBZ Daemon Setup"), "header missing {screen:?}/{focus:?}");
assert!(
!out.contains("terminal too small"),
"120x30 must not trip the resize guard for {screen:?}"
);
// Wide tier: labels spell out and each name carries a dim summary.
assert!(out.contains("Import / Export"), "wide sidebar label missing {screen:?}");
assert!(
out.contains("output · bit-perfect"),
"wide sidebar summary missing {screen:?}"
);
}
}
}
#[test]
fn sidebar_is_compact_at_the_floor_and_wide_above_100() {
// At the 80-col floor the compact label is used, not the spelled-out one.
let floor = render(&bare_app(Screen::Audio, Focus::Nav), 80, 24);
assert!(floor.contains("Import/Exp"), "compact label at the floor");
assert!(!floor.contains("Import / Export"), "no wide label at the floor");
// The 28-col sidebar is at least double the 14-col compact one.
assert!(widgets::sidebar_width(120) >= 2 * widgets::sidebar_width(80));
}
#[test]
fn footer_state_maps_the_three_daemon_states() {
// Unreachable → dim, regardless of auth.
+20 -2
View File
@@ -138,6 +138,8 @@ impl AccountState {
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let width = area.width.saturating_sub(2); // section inner width
let ctrl_col = widgets::control_column(&[s::ACCOUNT_STATUS], width);
let mut lines: Vec<Line> = Vec::new();
// Status row — never fabricates a name (§3.1).
@@ -152,16 +154,32 @@ impl AccountState {
} else {
s::ACCOUNT_NOT_LOGGED_IN.to_string()
};
lines.push(widgets::field_line(s::ACCOUNT_STATUS, &status, false, true, None, ""));
lines.extend(widgets::field_block(
&widgets::Field {
label: s::ACCOUNT_STATUS,
value: status,
widget: "",
focused: false,
enabled: true,
reason: None,
description: None,
},
ctrl_col,
width,
));
lines.push(widgets::blank());
let mut anchor: Option<widgets::FocusAnchor> = None;
for (i, action) in self.actions().iter().enumerate() {
let focused = i == self.focus && !self.is_editing();
if focused {
anchor = Some(widgets::FocusAnchor { section: 0, inner_line: lines.len() as u16, height: 1 });
}
lines.push(widgets::action_line(&format!("> {action}"), focused, true));
}
let secs = [widgets::Section::new(s::ACCOUNT_SECTION, true, lines)];
widgets::sections(f, area, &secs);
widgets::sections_scroll(f, area, &secs, anchor);
if let Some(input) = &self.token_input {
widgets::modal(f, area, s::ACCOUNT_PASTE_TOKEN, &input.display(), s::HELP_INPUT);
+44 -14
View File
@@ -20,6 +20,7 @@ use ratatui::Frame;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::theme;
use crate::tui::widgets::{self, SelectOutcome, SelectPopup};
// ============================ staged form ============================
@@ -664,37 +665,42 @@ impl AudioState {
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let width = area.width.saturating_sub(2); // section inner width
let fields = visible_fields(&self.staged);
let focused_field = fields.get(self.focus).copied();
let active = |members: &[AField]| {
focused_field.map(|ff| members.contains(&ff)).unwrap_or(false)
};
// ONE control column for the whole screen (owner's "misma área de columna").
let labels: Vec<&str> = fields.iter().map(|f| self.field_display(*f).0).collect();
let ctrl_col = widgets::control_column(&labels, width);
use AField::*;
let mut secs: Vec<widgets::Section> = Vec::new();
let mut anchor: Option<widgets::FocusAnchor> = None;
let out_members: &[AField] = &[Backend, Device, AlsaPlugin, HwVolume, Dsd];
let mut out_lines = self.group_lines(&fields, out_members);
let (mut out_lines, out_a) = self.group_block(&fields, out_members, focused_field, ctrl_col, width);
if self.staged.backend == AudioBackendType::Jack {
out_lines.push(widgets::warn_line(s::JACK_WARNING));
out_lines.extend(widgets::wrapped_note(s::JACK_WARNING, width, theme::warn()));
}
if !out_lines.is_empty() {
secs.push(widgets::Section::new(s::AUDIO_GROUP_OUTPUT, active(out_members), out_lines));
widgets::push_section(&mut secs, &mut anchor, s::AUDIO_GROUP_OUTPUT, active(out_members), out_lines, out_a);
}
let bp_members: &[AField] = &[Exclusive, Reserve, Passthrough, ForceBp, LockOutput];
let bp_lines = self.group_lines(&fields, bp_members);
let (bp_lines, bp_a) = self.group_block(&fields, bp_members, focused_field, ctrl_col, width);
if !bp_lines.is_empty() {
secs.push(widgets::Section::new(s::AUDIO_GROUP_BITPERFECT, active(bp_members), bp_lines));
widgets::push_section(&mut secs, &mut anchor, s::AUDIO_GROUP_BITPERFECT, active(bp_members), bp_lines, bp_a);
}
let tr_members: &[AField] = &[StreamUncached, Buffer, StreamingOnly];
let tr_lines = self.group_lines(&fields, tr_members);
let (tr_lines, tr_a) = self.group_block(&fields, tr_members, focused_field, ctrl_col, width);
if !tr_lines.is_empty() {
secs.push(widgets::Section::new(s::AUDIO_GROUP_TRANSPORT, active(tr_members), tr_lines));
widgets::push_section(&mut secs, &mut anchor, s::AUDIO_GROUP_TRANSPORT, active(tr_members), tr_lines, tr_a);
}
widgets::sections(f, area, &secs);
widgets::sections_scroll(f, area, &secs, anchor);
// Overlays.
match &self.editor {
@@ -718,22 +724,46 @@ 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>> {
/// The field blocks of one group, in declared order (skipping hidden fields).
/// Returns the flattened lines plus, when the focused field is in this group,
/// its (first-line, height) inside the group for follow-focus scrolling.
fn group_block(
&self,
fields: &[AField],
members: &[AField],
focused_field: Option<AField>,
ctrl_col: u16,
width: u16,
) -> (Vec<Line<'static>>, Option<(u16, u16)>) {
let mut lines = Vec::new();
let mut within = None;
for gf in members {
if let Some(pos) = fields.iter().position(|x| x == gf) {
lines.push(self.field_line(*gf, pos));
let start = lines.len() as u16;
let block = self.field_block(*gf, pos, ctrl_col, width);
if focused_field == Some(*gf) {
within = Some((start, block.len() as u16));
}
lines.extend(block);
}
}
lines
(lines, within)
}
fn field_line(&self, field: AField, focus_pos: usize) -> Line<'static> {
fn field_block(&self, field: AField, focus_pos: usize, ctrl_col: u16, width: u16) -> Vec<Line<'static>> {
let (_, enabled, reason) = row_state(field, &self.staged);
let focused = focus_pos == self.focus && self.editor.is_none();
let (label, value, widget) = self.field_display(field);
widgets::field_line(label, &value, focused, enabled, reason, widget)
let f = widgets::Field {
label,
value,
widget,
focused,
enabled,
reason,
description: None,
};
widgets::field_block(&f, ctrl_col, width)
}
fn field_display(&self, field: AField) -> (&'static str, String, &'static str) {
+67 -31
View File
@@ -353,7 +353,17 @@ impl BundleState {
return;
}
let width = area.width.saturating_sub(2); // section inner width
let cur = FIELDS[self.focus];
let ed = self.editor.is_none();
// ONE control column across BOTH boxes (consistent for the whole screen).
let ctrl_col = widgets::control_column(
&[s::B_IMPORT_PATH, s::B_EXPORT_DEST, s::B_EXPORT_INCLUDE_AUTH],
width,
);
let mut secs: Vec<widgets::Section> = Vec::new();
let mut anchor: Option<widgets::FocusAnchor> = None;
// IMPORT box.
let path_val = if self.import_path.is_empty() {
@@ -361,41 +371,30 @@ impl BundleState {
} else {
self.import_path.clone()
};
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 mut import_lines: Vec<Line> = Vec::new();
let mut import_within: Option<(u16, u16)> = None;
self.push_field(&mut import_lines, &mut import_within, cur, ed, BField::ImportPath, s::B_IMPORT_PATH, path_val, "[input]", ctrl_col, width);
self.push_action(&mut import_lines, &mut import_within, cur, ed, BField::Review, s::B_IMPORT_ACTION);
let import_active = matches!(cur, BField::ImportPath | BField::Review);
widgets::push_section(&mut secs, &mut anchor, s::BUNDLE_IMPORT_HEADER, import_active, import_lines, import_within);
// 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]",
),
];
let mut export_lines: Vec<Line> = Vec::new();
let mut export_within: Option<(u16, u16)> = None;
self.push_field(&mut export_lines, &mut export_within, cur, ed, BField::ExportDest, s::B_EXPORT_DEST, self.export_dest.clone(), "[input]", ctrl_col, width);
self.push_field(&mut export_lines, &mut export_within, cur, ed, BField::IncludeAuth, s::B_EXPORT_INCLUDE_AUTH, if self.include_auth { "on" } else { "off" }.to_string(), "[toggle]", ctrl_col, width);
if self.include_auth {
for l in s::B_EXPORT_AUTH_WARNING.lines() {
export_lines.push(widgets::warn_line(l));
}
export_lines.extend(widgets::wrapped_note(s::B_EXPORT_AUTH_WARNING, width, theme::warn()));
}
export_lines.push(self.action_row(BField::Export, s::B_EXPORT_ACTION));
self.push_action(&mut export_lines, &mut export_within, cur, ed, BField::Export, s::B_EXPORT_ACTION);
if self.has_desktop {
export_lines.push(widgets::blank());
for l in s::B_DESKTOP_HINT.lines() {
export_lines.push(widgets::note_line(l));
}
export_lines.extend(widgets::wrapped_note(s::B_DESKTOP_HINT, width, theme::dim()));
}
let export_active = matches!(cur, BField::ExportDest | BField::IncludeAuth | BField::Export);
widgets::push_section(&mut secs, &mut anchor, s::BUNDLE_EXPORT_HEADER, export_active, export_lines, export_within);
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);
widgets::sections_scroll(f, area, &secs, anchor);
match &self.editor {
Some(Editor::ImportPath(input)) => {
@@ -408,13 +407,50 @@ impl BundleState {
}
}
fn row(&self, field: BField, label: &str, value: &str, widget: &str) -> Line<'static> {
let focused = FIELDS[self.focus] == field && self.editor.is_none();
widgets::field_line(label, value, focused, true, None, widget)
/// Append a field block to `lines`, recording the focus anchor within the box.
#[allow(clippy::too_many_arguments)]
fn push_field(
&self,
lines: &mut Vec<Line<'static>>,
within: &mut Option<(u16, u16)>,
cur: BField,
ed: bool,
field: BField,
label: &'static str,
value: String,
widget: &'static str,
ctrl_col: u16,
width: u16,
) {
let focused = cur == field && ed;
let start = lines.len() as u16;
let block = widgets::field_block(
&widgets::Field { label, value, widget, focused, enabled: true, reason: None, description: None },
ctrl_col,
width,
);
if focused {
*within = Some((start, block.len() as u16));
}
lines.extend(block);
}
fn action_row(&self, field: BField, label: &str) -> Line<'static> {
let focused = FIELDS[self.focus] == field && self.editor.is_none();
widgets::action_line(&format!("> {label}"), focused, true)
/// Append a single-line action row, recording the focus anchor within the box.
#[allow(clippy::too_many_arguments)]
fn push_action(
&self,
lines: &mut Vec<Line<'static>>,
within: &mut Option<(u16, u16)>,
cur: BField,
ed: bool,
field: BField,
label: &str,
) {
let focused = cur == field && ed;
if focused {
*within = Some((lines.len() as u16, 1));
}
lines.push(widgets::action_line(&format!("> {label}"), focused, true));
}
fn draw_review(&self, f: &mut Frame, area: Rect) {
+32 -9
View File
@@ -16,6 +16,7 @@ use ratatui::Frame;
use crate::config::QbzdConfig;
use crate::tui::app::{DrawCtx, ScreenAction};
use crate::tui::strings as s;
use crate::tui::theme;
use crate::tui::widgets::{self, InputOutcome, TextInput};
#[derive(Debug, Clone, PartialEq)]
@@ -155,7 +156,19 @@ impl NetworkState {
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let width = area.width.saturating_sub(2); // section inner width
let labels: Vec<&str> = FIELDS
.iter()
.map(|f| match f {
NField::Bind => s::N_BIND,
NField::Port => s::N_PORT,
NField::Token => s::N_TOKEN,
})
.collect();
let ctrl_col = widgets::control_column(&labels, width);
let mut lines: Vec<Line> = Vec::new();
let mut within: Option<(u16, u16)> = None;
for (i, field) in FIELDS.iter().enumerate() {
let focused = i == self.focus && self.editor.is_none();
let editing = self.editor.as_ref().map(|(nf, _)| *nf == *field).unwrap_or(false);
@@ -173,17 +186,25 @@ impl NetworkState {
(s::N_TOKEN, v, "[input]")
}
};
lines.push(widgets::field_line(label, &value, focused, true, None, widget));
let start = lines.len() as u16;
let block = widgets::field_block(
&widgets::Field { label, value, widget, focused, enabled: true, reason: None, description: None },
ctrl_col,
width,
);
if focused {
within = Some((start, block.len() as u16));
}
lines.extend(block);
}
// Field-level validation notes (§4.2): errors in red, exposure in yellow.
// Field-level validation notes (§4.2): the short errors stay one line; the
// long exposure/preservation copy is now WORD-WRAPPED so it never clips.
if self.staged.bind.parse::<IpAddr>().is_err() {
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_POSTURE.lines() {
lines.push(widgets::note_line(l));
}
lines.extend(widgets::wrapped_note(s::NETWORK_LAN_POSTURE, width, theme::dim()));
}
if self.port_invalid() {
lines.push(widgets::err_line(s::N_BAD_PORT));
@@ -192,12 +213,14 @@ impl NetworkState {
// Pre-save unknown-key warning (§3.5).
if !self.unknown_keys.is_empty() {
lines.push(widgets::blank());
lines.push(widgets::warn_line(s::N_DROP_UNKNOWN));
lines.push(widgets::warn_line(&format!(" {}", self.unknown_keys.join(", "))));
lines.extend(widgets::wrapped_note(s::N_DROP_UNKNOWN, width, theme::warn()));
lines.extend(widgets::wrapped_note(&self.unknown_keys.join(", "), width, theme::warn()));
}
let secs = [widgets::Section::new(s::NETWORK_SECTION, true, lines)];
widgets::sections(f, area, &secs);
let mut secs: Vec<widgets::Section> = Vec::new();
let mut anchor: Option<widgets::FocusAnchor> = None;
widgets::push_section(&mut secs, &mut anchor, s::NETWORK_SECTION, true, lines, within);
widgets::sections_scroll(f, area, &secs, anchor);
if let Some((field, input)) = &self.editor {
let title = match field {
+42 -13
View File
@@ -322,34 +322,39 @@ impl PlaybackState {
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, _ctx: &DrawCtx) {
let width = area.width.saturating_sub(2); // section inner width
let fields = visible_fields(&self.staged);
let focused_field = fields.get(self.focus).copied();
let active = |members: &[PField]| {
focused_field.map(|ff| members.contains(&ff)).unwrap_or(false)
};
// ONE control column for the whole screen (owner's "misma área de columna").
let labels: Vec<&str> = fields.iter().map(|f| self.field_display(*f).0).collect();
let ctrl_col = widgets::control_column(&labels, width);
use PField::*;
let mut secs: Vec<widgets::Section> = Vec::new();
let mut anchor: Option<widgets::FocusAnchor> = None;
let quality: &[PField] = &[Quality, Limit, MaxRate, AllowFallback, RetryFail];
let q_lines = self.group_lines(&fields, quality);
let (q_lines, q_a) = self.group_block(&fields, quality, focused_field, ctrl_col, width);
if !q_lines.is_empty() {
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_QUALITY, active(quality), q_lines));
widgets::push_section(&mut secs, &mut anchor, s::PLAYBACK_GROUP_QUALITY, active(quality), q_lines, q_a);
}
let behavior: &[PField] = &[Continue, Gapless];
let b_lines = self.group_lines(&fields, behavior);
let (b_lines, b_a) = self.group_block(&fields, behavior, focused_field, ctrl_col, width);
if !b_lines.is_empty() {
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_BEHAVIOR, active(behavior), b_lines));
widgets::push_section(&mut secs, &mut anchor, s::PLAYBACK_GROUP_BEHAVIOR, active(behavior), b_lines, b_a);
}
let session: &[PField] = &[Restore, Resume];
let sess_lines = self.group_lines(&fields, session);
let (sess_lines, sess_a) = self.group_block(&fields, session, focused_field, ctrl_col, width);
if !sess_lines.is_empty() {
secs.push(widgets::Section::new(s::PLAYBACK_GROUP_SESSION, active(session), sess_lines));
widgets::push_section(&mut secs, &mut anchor, s::PLAYBACK_GROUP_SESSION, active(session), sess_lines, sess_a);
}
widgets::sections(f, area, &secs);
widgets::sections_scroll(f, area, &secs, anchor);
match &self.editor {
Some(Editor::Quality(p)) | Some(Editor::MaxRate(p)) | Some(Editor::Retry(p)) => {
@@ -359,22 +364,46 @@ impl PlaybackState {
}
}
/// The field rows of one group, in declared order (skipping hidden fields).
fn group_lines(&self, fields: &[PField], members: &[PField]) -> Vec<Line<'static>> {
/// The field blocks of one group, in declared order (skipping hidden fields).
/// Returns the flattened lines plus, when the focused field is in this group,
/// its (first-line, height) inside the group for follow-focus scrolling.
fn group_block(
&self,
fields: &[PField],
members: &[PField],
focused_field: Option<PField>,
ctrl_col: u16,
width: u16,
) -> (Vec<Line<'static>>, Option<(u16, u16)>) {
let mut lines = Vec::new();
let mut within = None;
for gf in members {
if let Some(pos) = fields.iter().position(|x| x == gf) {
lines.push(self.field_line(*gf, pos));
let start = lines.len() as u16;
let block = self.field_block(*gf, pos, ctrl_col, width);
if focused_field == Some(*gf) {
within = Some((start, block.len() as u16));
}
lines.extend(block);
}
}
lines
(lines, within)
}
fn field_line(&self, field: PField, focus_pos: usize) -> Line<'static> {
fn field_block(&self, field: PField, focus_pos: usize, ctrl_col: u16, width: u16) -> Vec<Line<'static>> {
let (_, enabled, reason) = row_state(field, &self.staged);
let focused = focus_pos == self.focus && self.editor.is_none();
let (label, value, widget) = self.field_display(field);
widgets::field_line(label, &value, focused, enabled, reason, widget)
let f = widgets::Field {
label,
value,
widget,
focused,
enabled,
reason,
description: None,
};
widgets::field_block(&f, ctrl_col, width)
}
fn field_display(&self, field: PField) -> (&'static str, String, &'static str) {
+33 -21
View File
@@ -15,6 +15,7 @@ use serde_json::Value;
use crate::qconnect::transport as qconnect_kv;
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};
#[derive(Debug, Clone, PartialEq)]
@@ -176,14 +177,19 @@ impl QConnectState {
// -------------------------- render --------------------------
pub fn draw(&self, f: &mut Frame, area: Rect, ctx: &DrawCtx) {
let width = area.width.saturating_sub(2); // section inner width
let ctrl_col =
widgets::control_column(&[s::QC_ENABLE, s::QC_DEVICE_NAME, s::QC_VOLUME_MODE], width);
let mut lines: Vec<Line> = Vec::new();
let mut within: Option<(u16, u16)> = None;
for (i, field) in FIELDS.iter().enumerate() {
let focused = i == self.focus && self.editor.is_none();
match field {
QField::Enable => {
let v = if self.staged.enable { "on" } else { "off" }.to_string();
lines.push(widgets::field_line(s::QC_ENABLE, &v, focused, true, None, "[toggle]"));
}
let (label, value, widget) = match field {
QField::Enable => (
s::QC_ENABLE,
if self.staged.enable { "on" } else { "off" }.to_string(),
"[toggle]",
),
QField::DeviceName => {
let shown = if let Some(Editor::Name(input)) = &self.editor {
input.display()
@@ -192,31 +198,37 @@ impl QConnectState {
} else {
self.staged.device_name.clone()
};
lines.push(widgets::field_line(s::QC_DEVICE_NAME, &shown, focused, true, None, "[input]"));
lines.push(widgets::note_line(&s::qc_preview(&self.effective_name())));
lines.push(widgets::note_line(s::QC_APPLIES_NEXT));
}
QField::VolumeMode => {
lines.push(widgets::field_line(
s::QC_VOLUME_MODE,
&self.staged.volume_mode,
focused,
true,
None,
"[select]",
));
(s::QC_DEVICE_NAME, shown, "[input]")
}
QField::VolumeMode => (s::QC_VOLUME_MODE, self.staged.volume_mode.clone(), "[select]"),
};
let start = lines.len() as u16;
let mut block = widgets::field_block(
&widgets::Field { label, value, widget, focused, enabled: true, reason: None, description: None },
ctrl_col,
width,
);
// The device-name field carries a live preview + applies-next note.
if *field == QField::DeviceName {
block.extend(widgets::wrapped_note(&s::qc_preview(&self.effective_name()), width, theme::dim()));
block.extend(widgets::wrapped_note(s::QC_APPLIES_NEXT, width, theme::dim()));
}
if focused {
within = Some((start, block.len() as u16));
}
lines.extend(block);
}
// Live status line (§3.4) — from GET /api/status when the daemon runs.
if let Some(live) = ctx.status.and_then(qconnect_live_line) {
lines.push(widgets::blank());
lines.push(widgets::note_line(&live));
lines.extend(widgets::wrapped_note(&live, width, theme::dim()));
}
let secs = [widgets::Section::new(s::QCONNECT_SECTION, true, lines)];
widgets::sections(f, area, &secs);
let mut secs: Vec<widgets::Section> = Vec::new();
let mut anchor: Option<widgets::FocusAnchor> = None;
widgets::push_section(&mut secs, &mut anchor, s::QCONNECT_SECTION, true, lines, within);
widgets::sections_scroll(f, area, &secs, anchor);
match &self.editor {
Some(Editor::Volume(p)) => p.draw(f, area),
+69 -25
View File
@@ -654,8 +654,15 @@ impl WizardState {
Line::from(Span::styled(s::WIZ_WELCOME_TITLE, theme::accent_bold())),
widgets::blank(),
];
for l in s::WIZ_WELCOME_BODY.lines() {
lines.push(Line::from(l.to_string()));
// Word-wrap the body, preserving intentional paragraph breaks (FB5).
for l in s::WIZ_WELCOME_BODY.split('\n') {
if l.trim().is_empty() {
lines.push(widgets::blank());
} else {
for wl in widgets::wrap(l, area.width.max(1) as usize) {
lines.push(Line::from(wl));
}
}
}
lines.push(widgets::blank());
lines.push(Line::from(widgets::help_spans(s::WIZ_WELCOME_CTA)));
@@ -665,11 +672,12 @@ impl WizardState {
fn draw_check(&self, f: &mut Frame, area: Rect) {
let distro = Distro::ALL.get(self.distro_index).copied().unwrap_or(Distro::Other);
let init = InitSystem::ALL.get(self.init_index).copied().unwrap_or(InitSystem::Unknown);
let width = area.width;
let mut lines: Vec<Line> = Vec::new();
// Health verdict (blind inside a sandbox — show reference commands only).
if self.sandbox != Sandbox::None {
lines.push(widgets::warn_line(&s::wiz_sandbox_note(sandbox_name(self.sandbox))));
lines.extend(widgets::wrapped_note(&s::wiz_sandbox_note(sandbox_name(self.sandbox)), width, theme::warn()));
} else if let Some(h) = &self.health {
if h.is_ready() {
lines.push(Line::from(Span::styled(s::WIZ_HEALTH_READY, theme::ok())));
@@ -681,9 +689,10 @@ impl WizardState {
}
lines.push(widgets::blank());
// The two overrides (focusable rows).
lines.push(self.check_row(0, s::WIZ_DISTRO, distro.label()));
lines.push(self.check_row(1, s::WIZ_INIT, init.label()));
// The two overrides (focusable rows), sharing one control column.
let ctrl_col = widgets::control_column(&[s::WIZ_DISTRO, s::WIZ_INIT], width);
lines.extend(self.check_block(0, s::WIZ_DISTRO, distro.label(), ctrl_col, width));
lines.extend(self.check_block(1, s::WIZ_INIT, init.label(), ctrl_col, width));
lines.push(widgets::blank());
// Remediation / reference commands.
@@ -695,10 +704,13 @@ impl WizardState {
Vec::new()
};
if rows.is_empty() && self.sandbox == Sandbox::None && self.health.is_some() {
lines.push(widgets::note_line(s::WIZ_NO_REMEDIATION));
lines.extend(widgets::wrapped_note(s::WIZ_NO_REMEDIATION, width, theme::dim()));
}
for (caption, command) in &rows {
lines.push(Line::from(Span::styled(format!(" • {caption}"), Style::default())));
// Captions are prose → wrap; commands are copy-paste → never wrap.
for cl in widgets::wrap(caption, width.saturating_sub(4).max(1) as usize) {
lines.push(Line::from(Span::styled(format!(" • {cl}"), Style::default())));
}
for cmd_line in command.lines() {
lines.push(Line::from(Span::styled(format!(" {cmd_line}"), theme::dim())));
}
@@ -706,9 +718,21 @@ impl WizardState {
f.render_widget(Paragraph::new(lines), area);
}
fn check_row(&self, idx: usize, label: &str, value: &str) -> Line<'static> {
fn check_block(&self, idx: usize, label: &'static str, value: &str, ctrl_col: u16, width: u16) -> Vec<Line<'static>> {
let focused = self.check_focus == idx && self.check_editor.is_none();
widgets::field_line(label, value, focused, true, None, "[select]")
widgets::field_block(
&widgets::Field {
label,
value: value.to_string(),
widget: "[select]",
focused,
enabled: true,
reason: None,
description: None,
},
ctrl_col,
width,
)
}
fn draw_select(&self, f: &mut Frame, area: Rect) {
@@ -716,11 +740,9 @@ impl WizardState {
if self.detecting {
lines.push(Line::from(Span::styled(s::WIZ_DETECTING, theme::dim())));
} else if self.candidates.is_empty() {
for l in s::WIZ_NO_DACS.lines() {
lines.push(widgets::warn_line(l));
}
lines.extend(widgets::wrapped_note(s::WIZ_NO_DACS, area.width, theme::warn()));
} else {
lines.push(Line::from(Span::styled(s::WIZ_SELECT_INTRO, theme::dim())));
lines.extend(widgets::wrapped_note(s::WIZ_SELECT_INTRO, area.width, theme::dim()));
lines.push(widgets::blank());
for (i, c) in self.candidates.iter().enumerate() {
let mark = if c.checked { "[x]" } else { "[ ]" };
@@ -787,9 +809,7 @@ impl WizardState {
fn draw_test(&self, f: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for l in s::WIZ_TEST_INTRO.lines() {
lines.push(widgets::note_line(l));
}
lines.extend(widgets::wrapped_note(s::WIZ_TEST_INTRO, area.width, theme::dim()));
lines.push(widgets::blank());
if let Some(note) = &self.test_note {
@@ -857,11 +877,11 @@ impl WizardState {
widgets::blank(),
];
let selected = self.configs.len();
lines.push(Line::from(s::wiz_done_summary(selected)));
lines.push(widgets::blank());
for l in s::WIZ_DONE_REMINDER.lines() {
lines.push(widgets::warn_line(l));
for wl in widgets::wrap(&s::wiz_done_summary(selected), area.width.max(1) as usize) {
lines.push(Line::from(wl));
}
lines.push(widgets::blank());
lines.extend(widgets::wrapped_note(s::WIZ_DONE_REMINDER, area.width, theme::warn()));
// The init-aware "(re)start the audio services" command for this box.
let init = InitSystem::ALL.get(self.init_index).copied().unwrap_or(InitSystem::Unknown);
lines.push(widgets::blank());
@@ -1102,10 +1122,13 @@ mod tests {
}
fn render_step(w: &WizardState) -> String {
render_step_sized(w, 80, 24)
}
fn render_step_sized(w: &WizardState, width: u16, height: u16) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
// Mirror the App's content-frame inner rect on the 80×24 floor.
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let ctx = DrawCtx { status: None };
terminal
.draw(|f| {
@@ -1115,8 +1138,8 @@ mod tests {
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..24 {
for x in 0..80 {
for y in 0..height {
for x in 0..width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
@@ -1146,4 +1169,25 @@ mod tests {
assert!(review.contains("→ ~/.config"), "review shows the target file paths");
assert!(review.contains("NEVER"), "review footer states the wizard never writes files");
}
#[test]
fn every_wizard_step_renders_at_120x30_wide() {
// The wizard body draws into the content frame (no sidebar of its own);
// this mirrors it at a comfortable wide size and asserts each step's
// content survives the word-wrap rework without a panic (FB5).
let mut w = populated();
let expect: [(WStep, &str); 6] = [
(WStep::Welcome, "HiFi"),
(WStep::Check, "ready"),
(WStep::SelectDacs, "Cambridge"),
(WStep::Review, "Cambridge"),
(WStep::Test, "DAC:"),
(WStep::Done, "All set"),
];
for (step, needle) in expect {
w.step = step;
let out = render_step_sized(&w, 120, 30);
assert!(out.contains(needle), "wide step {step:?} should render {needle:?}");
}
}
}
+30 -5
View File
@@ -27,11 +27,11 @@ pub const HELP_TITLE: &str = "Help";
/// while editing, the field label) carries the accent.
pub const BREADCRUMB_ROOT: &str = "Setup";
/// Persistent left-nav labels (fixed order). NAME only; the old menu's live
/// summaries are dropped — the content is the detail. Dirty-capable sections
/// (Audio/Playback/QConnect/Network) stay ≤ 8 chars so a trailing `*` fits the
/// 14-col sidebar; Account/Import/Wizard are never dirty. Seven since FB4 added
/// the HiFi Wizard (owner-sanctioned cap break over the old six-screen D7 cap).
/// Persistent left-nav labels (fixed order), COMPACT tier (< 100 cols). NAME
/// only; Dirty-capable sections (Audio/Playback/QConnect/Network) stay ≤ 8 chars
/// so a trailing `*` fits the 14-col sidebar; Account/Import/Wizard are never
/// dirty. Seven since FB4 added the HiFi Wizard (owner-sanctioned cap break over
/// the old six-screen D7 cap).
pub const SIDEBAR_LABELS: [&str; 7] = [
"Account",
"Audio",
@@ -42,6 +42,31 @@ pub const SIDEBAR_LABELS: [&str; 7] = [
"Wizard",
];
/// Left-nav labels, WIDE tier (≥ 100 cols → the 28-col sidebar, FB5). The extra
/// room lets `Import / Export` spell itself out; everything else is already whole.
pub const SIDEBAR_LABELS_WIDE: [&str; 7] = [
"Account",
"Audio",
"Playback",
"QConnect",
"Network",
"Import / Export",
"Wizard",
];
/// A terse, static one-line summary shown dim UNDER each name in the wide sidebar
/// only (FB5). Static (not live device state) — it names what the section is for
/// so the roomy sidebar reads intentionally; it never needs the section loaded.
pub const SIDEBAR_SUMMARIES: [&str; 7] = [
"sign in",
"output · bit-perfect",
"quality · behavior",
"cast target",
"http control",
"settings bundle",
"DAC setup",
];
// Global help-bar hints (context-sensitive; assembled per focus + screen).
pub const HELP_NAV: &str = "up/down move · Enter open · 1-7 jump · Tab content · ? help · q quit";
pub const HELP_CONTENT_CLEAN: &str = "up/down move · Enter edit · Tab nav · Esc nav · ? help · q quit";
+505 -44
View File
@@ -5,11 +5,12 @@
// bottom help bar, centered modals, spinner). No screen logic lives here —
// screens own their staged state and drive these.
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap};
use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Widget, Wrap};
use ratatui::Frame;
use super::theme;
@@ -26,56 +27,179 @@ pub fn mask(s: &str) -> String {
"•".repeat(s.chars().count())
}
// ============================ field rows ============================
// ============================ sidebar width (FB5) ============================
/// 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,
focused: bool,
enabled: bool,
reason: Option<&str>,
widget: &str,
) -> Line<'static> {
let shown_value = match reason {
Some(r) if !enabled => format!("{value} ({r})"),
_ => value.to_string(),
};
// 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();
/// The left-nav sidebar width in columns (incl. border), a pure function of the
/// terminal width so the 80×24 floor keeps working (FB5). At ≥ 100 cols the
/// operator gets the roomy 28-col sidebar the owner asked for (labels spelled
/// out, room for a dim summary line); below that we fall back to the compact
/// 14-col rendering so the content frame never starves at the floor.
pub fn sidebar_width(term_width: u16) -> u16 {
if term_width >= 100 {
28
} else {
14
}
}
if focused {
/// True when `sidebar_width` is in its roomy tier (spelled-out labels + summary).
pub fn sidebar_is_wide(term_width: u16) -> bool {
sidebar_width(term_width) >= 28
}
// ============================ word wrap (FB5) ============================
/// Word-boundary wrap `text` to `width` columns, no new dependency (FB5). Splits
/// on ASCII whitespace; a word longer than `width` is hard-split (the only place
/// a word is broken mid-word). Blank/whitespace-only input yields no lines. Each
/// embedded `\n` in the source is honored as a hard break first, then each
/// segment is word-wrapped, so pre-formatted copy keeps its intentional breaks.
pub fn wrap(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return text.lines().map(str::to_string).collect();
}
let mut out: Vec<String> = Vec::new();
for segment in text.split('\n') {
if segment.trim().is_empty() {
continue;
}
let mut cur = String::new();
for word in segment.split_whitespace() {
if word.chars().count() > width {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
let mut chunk = String::new();
for ch in word.chars() {
if chunk.chars().count() == width {
out.push(std::mem::take(&mut chunk));
}
chunk.push(ch);
}
cur = chunk;
continue;
}
let extra = usize::from(!cur.is_empty());
if cur.chars().count() + extra + word.chars().count() > width {
out.push(std::mem::take(&mut cur));
cur.push_str(word);
} else {
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
}
if !cur.is_empty() {
out.push(cur);
}
}
out
}
// ============================ field blocks (FB5) ============================
/// One field to render as a block. Row 1 is `label` + the CONTROL (value +
/// right-aligned widget marker) anchored at a screen-consistent column; rows
/// 2..n are the wrapped, dim DESCRIPTION under the label — the disabled `reason`
/// takes precedence over the static `description`.
pub struct Field<'a> {
pub label: &'a str,
pub value: String,
/// `[select]`/`[toggle]`/`[input]`/`[slider]`, or `""` for a plain value.
pub widget: &'a str,
pub focused: bool,
pub enabled: bool,
/// Why the field is inert (only meaningful when `!enabled`).
pub reason: Option<&'a str>,
/// Static one-line help; wrapped under the label when present.
pub description: Option<&'a str>,
}
/// The column (0-based, from the section inner edge) where every field's control
/// starts — the owner's "misma área de columna". ONE mechanism, applied on every
/// screen: `2` (indent) + the longest label + `2` (gap), clamped so the control
/// still has room. Pure so each screen derives an identical column for its own
/// label set.
pub fn control_column(labels: &[&str], width: u16) -> u16 {
let max_label = labels
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0) as u16;
let ceiling = width.saturating_sub(12).max(14);
(2 + max_label + 2).clamp(14, ceiling)
}
/// Render a field as a block of lines (FB5). `ctrl_col` is the shared control
/// column; `width` is the section inner width. The value is truncated with `…`
/// (values never wrap — the control stays one line); the widget marker is
/// right-aligned so the marker column reads cleanly too. When `focused` the whole
/// control row is one accent-reverse bar (serial-safe, §1.2).
pub fn field_block(field: &Field, ctrl_col: u16, width: u16) -> Vec<Line<'static>> {
let width = width.max(ctrl_col + 4) as usize;
let ctrl = ctrl_col as usize;
let widget_len = field.widget.chars().count();
// Reserve the widget + a one-column gap on the right; the rest is the value.
let reserved = if widget_len == 0 { 0 } else { widget_len + 1 };
let value_space = width.saturating_sub(ctrl + reserved);
let value = truncate(&field.value, value_space);
let value_len = value.chars().count();
let label_piece = pad_to(&format!(" {}", field.label), ctrl);
let mid_pad = width.saturating_sub(ctrl + value_len + widget_len);
let value_piece = format!("{value}{}", " ".repeat(mid_pad));
let widget_piece = field.widget.to_string();
let row1 = if field.focused {
let mut sel = focus_style();
if !enabled {
if !field.enabled {
sel = sel.patch(theme::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)
Line::from(vec![
Span::styled(label_piece, sel),
Span::styled(value_piece, sel),
Span::styled(widget_piece, sel),
])
} else {
Style::default()
let label_style = if field.enabled { Style::default() } else { theme::dim() };
let value_style = if !field.enabled {
theme::dim()
} else if field.widget == "[toggle]" {
toggle_tone(&field.value)
} else {
Style::default()
};
Line::from(vec![
Span::styled(label_piece, label_style),
Span::styled(value_piece, value_style),
Span::styled(widget_piece, theme::dim()),
])
};
Line::from(vec![
Span::styled(label_txt, label_style),
Span::styled(value_txt, value_style),
Span::styled(widget_txt, theme::dim()),
])
let mut out = vec![row1];
// Disabled reason wins over the static description; both wrap under the label.
let desc = if !field.enabled {
field.reason.or(field.description)
} else {
field.description
};
if let Some(text) = desc {
for wl in wrap(text, width.saturating_sub(4)) {
out.push(Line::from(Span::styled(format!(" {wl}"), theme::dim())));
}
}
out
}
/// Pad `s` with spaces to `n` columns, or truncate it (with `…`) if it is longer.
fn pad_to(s: &str, n: usize) -> String {
let len = s.chars().count();
if len >= n {
truncate(s, n)
} else {
format!("{s}{}", " ".repeat(n - len))
}
}
/// ok for an on/enabled toggle, dim for off — the text ("on"/"off") carries the
@@ -122,6 +246,17 @@ pub fn err_line(text: &str) -> Line<'static> {
Line::from(Span::styled(format!(" {text}"), theme::err()))
}
/// A multi-line note WORD-WRAPPED to `width` (FB5) — the long LAN/auth/export
/// copy no longer clips at the frame edge. `style` picks the tone (dim note,
/// warn, err). The 4-space indent matches `note_line`.
pub fn wrapped_note(text: &str, width: u16, style: Style) -> Vec<Line<'static>> {
let inner = (width as usize).saturating_sub(4).max(1);
wrap(text, inner)
.into_iter()
.map(|l| Line::from(Span::styled(format!(" {l}"), style)))
.collect()
}
// ============================ section boxes ============================
/// One titled, rounded section box's worth of content. `active` (the group that
@@ -170,6 +305,143 @@ pub fn sections(f: &mut Frame, area: Rect, secs: &[Section]) {
}
}
// ============================ follow-focus scroll (FB5) ============================
/// The virtual-line span of the focused field block inside a stacked-sections
/// render, so the viewport can scroll to keep it fully visible.
pub struct FocusAnchor {
/// Index into the `secs` slice of the section that owns the focused block.
pub section: usize,
/// Line index of the block's first row WITHIN that section's `lines`.
pub inner_line: u16,
/// Rows the focused block occupies (control row + wrapped description).
pub height: u16,
}
/// The minimal vertical scroll (in rows) that keeps `[focus_top, focus_top +
/// focus_height)` inside a `viewport`-tall window over `total` rows (FB5). Pure so
/// the follow-focus math is unit-tested independent of any buffer.
pub fn follow_scroll(focus_top: u16, focus_height: u16, viewport: u16, total: u16) -> u16 {
if total <= viewport || viewport == 0 {
return 0;
}
let max_scroll = total - viewport;
let mut scroll = 0u16;
let focus_bottom = focus_top.saturating_add(focus_height.max(1));
if focus_bottom > viewport {
scroll = focus_bottom - viewport;
}
// A block taller than the viewport (or above the current offset): pin its top.
if focus_top < scroll {
scroll = focus_top;
}
scroll.min(max_scroll)
}
/// Push a section and, if `within` names the focused block's (line, height)
/// inside it, record the screen-wide [`FocusAnchor`] (FB5). Keeps every screen's
/// section assembly a one-liner instead of hand-tracking section indices.
pub fn push_section(
secs: &mut Vec<Section>,
anchor: &mut Option<FocusAnchor>,
title: impl Into<String>,
active: bool,
lines: Vec<Line<'static>>,
within: Option<(u16, u16)>,
) {
if let Some((inner_line, height)) = within {
*anchor = Some(FocusAnchor { section: secs.len(), inner_line, height });
}
secs.push(Section::new(title, active, lines));
}
/// Total rows the stacked section boxes need (each box = its lines + 2 borders).
fn sections_height(secs: &[Section]) -> u16 {
secs.iter().map(|s| s.lines.len() as u16 + 2).sum()
}
/// Stack section boxes like [`sections`], but follow-focus SCROLL when the
/// content is taller than `area` (FB5). When everything fits, it defers to
/// `sections` verbatim (top-aligned, no indicator). When it overflows, the boxes
/// render into a virtual buffer of the full height, the window that keeps the
/// focused block visible is blitted into `area`, and dim `▲`/`▼` indicators mark
/// hidden content above/below.
pub fn sections_scroll(f: &mut Frame, area: Rect, secs: &[Section], focus: Option<FocusAnchor>) {
if secs.is_empty() {
return;
}
let total = sections_height(secs);
if total <= area.height {
sections(f, area, secs);
return;
}
let scroll = match &focus {
Some(a) => {
let mut y = 0u16;
for s in secs.iter().take(a.section) {
y = y.saturating_add(s.lines.len() as u16 + 2);
}
let focus_top = y + 1 + a.inner_line; // +1 for the box top border
follow_scroll(focus_top, a.height, area.height, total)
}
None => 0,
};
// Render the stacked boxes into a full-height off-screen buffer.
let mut buf = Buffer::empty(Rect { x: 0, y: 0, width: area.width, height: total });
let mut y = 0u16;
for sec in secs {
let h = sec.lines.len() as u16 + 2;
let rect = Rect { x: 0, y, width: area.width, height: h };
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(rect);
Widget::render(block, rect, &mut buf);
Widget::render(Paragraph::new(sec.lines.clone()), inner, &mut buf);
y = y.saturating_add(h);
}
// Blit the visible window into the frame.
let fb = f.buffer_mut();
for row in 0..area.height {
let sy = scroll + row;
if sy >= total {
break;
}
for col in 0..area.width {
if let Some(src) = buf.cell((col, sy)) {
let cell = src.clone();
if let Some(dst) = fb.cell_mut((area.x + col, area.y + row)) {
*dst = cell;
}
}
}
}
// Dim scroll indicators at the right edge (content hidden above / below).
let right = area.x + area.width.saturating_sub(1);
if scroll > 0 {
if let Some(c) = fb.cell_mut((right, area.y)) {
c.set_symbol("▲");
c.set_style(theme::dim());
}
}
if scroll + area.height < total {
if let Some(c) = fb.cell_mut((right, area.y + area.height.saturating_sub(1))) {
c.set_symbol("▼");
c.set_style(theme::dim());
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
@@ -545,3 +817,192 @@ impl TextInput {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- sidebar_width: 28 wide, 14 compact, with the 100-col boundary ----
#[test]
fn sidebar_width_doubles_only_at_and_above_100_cols() {
assert_eq!(sidebar_width(80), 14, "the 80x24 floor keeps the compact sidebar");
assert_eq!(sidebar_width(99), 14, "just below the boundary stays compact");
assert_eq!(sidebar_width(100), 28, "at 100 the sidebar at least doubles");
assert_eq!(sidebar_width(120), 28);
assert!(sidebar_is_wide(120) && !sidebar_is_wide(80));
// The wide sidebar is at least double the compact one (owner's ask).
assert!(sidebar_width(120) >= 2 * sidebar_width(80));
}
// ---- wrap(): word boundaries, hard-split, edges ----
#[test]
fn wrap_empty_and_whitespace_yield_no_lines() {
assert!(wrap("", 10).is_empty());
assert!(wrap(" ", 10).is_empty());
assert!(wrap("\n\n", 10).is_empty());
}
#[test]
fn wrap_keeps_short_text_on_one_line() {
assert_eq!(wrap("short note", 20), vec!["short note".to_string()]);
// Exact-fit boundary: width == text length stays one line.
assert_eq!(wrap("exactly ten", 11), vec!["exactly ten".to_string()]);
}
#[test]
fn wrap_breaks_on_word_boundaries_not_mid_word() {
// "anyone on your network" at width 12 wraps between words.
let out = wrap("anyone on your network can control playback", 12);
assert!(out.iter().all(|l| l.chars().count() <= 12), "no line exceeds width: {out:?}");
// No word is split (every input word survives intact somewhere).
for word in "anyone on your network can control playback".split(' ') {
assert!(out.iter().any(|l| l.split(' ').any(|w| w == word)), "word {word:?} intact");
}
}
#[test]
fn wrap_hard_splits_a_word_longer_than_width() {
// A 20-char token at width 8 is the only case a word breaks.
let out = wrap("supercalifragilistic", 8);
assert!(out.len() >= 3);
assert!(out.iter().all(|l| l.chars().count() <= 8));
assert_eq!(out.concat(), "supercalifragilistic", "no characters are lost");
}
#[test]
fn wrap_honors_embedded_newlines_as_hard_breaks() {
let out = wrap("line one\nline two", 40);
assert_eq!(out, vec!["line one".to_string(), "line two".to_string()]);
}
// ---- control_column: one mechanism, consistent + clamped ----
#[test]
fn control_column_is_longest_label_plus_gutter_clamped() {
// 2 indent + 17-char label + 2 gap = 21.
assert_eq!(control_column(&["Streaming quality", "Gapless"], 62), 21);
// A short label set still lands at the floor of 14.
assert_eq!(control_column(&["Port"], 62), 14);
// A pathological label is clamped so the control keeps room.
let ceiling = 30u16.saturating_sub(12).max(14);
assert_eq!(control_column(&["a very very very long label here"], 30), ceiling);
}
// ---- follow_scroll: keep the focused block visible, minimally ----
#[test]
fn follow_scroll_no_scroll_when_everything_fits() {
assert_eq!(follow_scroll(0, 3, 18, 12), 0);
assert_eq!(follow_scroll(9, 3, 18, 12), 0);
}
#[test]
fn follow_scroll_brings_a_block_below_the_fold_into_view() {
// total 30, viewport 18. A block at [20,23) needs scroll so its bottom (23)
// sits at the viewport bottom: 23 - 18 = 5.
assert_eq!(follow_scroll(20, 3, 18, 30), 5);
// Clamped to max_scroll = 30 - 18 = 12.
assert_eq!(follow_scroll(29, 3, 18, 30), 12);
}
#[test]
fn follow_scroll_pins_top_of_a_block_taller_than_the_viewport() {
// A 25-row block starting at 4, viewport 18 → pin to the block top (4).
assert_eq!(follow_scroll(4, 25, 18, 40), 4);
}
#[test]
fn sections_scroll_indicates_and_brings_the_focused_block_into_view() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
// Four 4-line boxes = 4 * (4 + 2) = 24 rows into a 12-row area → overflow.
let mk = |t: &str| {
Section::new(t, false, (0..4).map(|i| Line::from(format!("{t}-{i}"))).collect())
};
let secs = vec![mk("A"), mk("B"), mk("C"), mk("D")];
// Focus the LAST box's first line — it starts well below the fold.
let anchor = Some(FocusAnchor { section: 3, inner_line: 0, height: 1 });
let mut term = Terminal::new(TestBackend::new(30, 12)).unwrap();
term.draw(|f| sections_scroll(f, Rect::new(0, 0, 30, 12), &secs, anchor))
.unwrap();
let buf = term.backend().buffer().clone();
let mut out = String::new();
for y in 0..12 {
for x in 0..30 {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
assert!(out.contains('▲'), "content hidden above → up indicator: \n{out}");
assert!(out.contains('▼'), "content hidden below → down indicator");
assert!(out.contains("D-0"), "the focused block is scrolled into view");
assert!(!out.contains("A-0"), "the top box scrolled out of view");
}
// ---- field_block: fixed column, truncation, wrapped description ----
fn cells(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn field_block_anchors_the_control_and_right_aligns_the_widget() {
let f = Field {
label: "Backend",
value: "PipeWire".to_string(),
widget: "[select]",
focused: false,
enabled: true,
reason: None,
description: None,
};
let block = field_block(&f, 21, 62);
assert_eq!(block.len(), 1, "no description → just the control row");
let row = cells(&block[0]);
assert_eq!(row.chars().count(), 62, "the row spans the full width");
// Label indented by 2, value starts at the control column.
assert!(row.starts_with(" Backend"));
assert_eq!(&row[21..29], "PipeWire", "value begins exactly at the control column");
assert!(row.trim_end().ends_with("[select]"), "widget marker right-aligned");
}
#[test]
fn field_block_truncates_a_long_value_with_ellipsis() {
let f = Field {
label: "Output device",
value: "a-really-long-alsa-hardware-device-identifier-that-overflows".to_string(),
widget: "[select]",
focused: false,
enabled: true,
reason: None,
description: None,
};
let block = field_block(&f, 21, 40); // narrow → must truncate
let row = cells(&block[0]);
assert_eq!(row.chars().count(), 40);
assert!(row.contains('…'), "the overflowing value is truncated with an ellipsis");
}
#[test]
fn field_block_wraps_the_disabled_reason_under_the_label() {
let f = Field {
label: "Gapless playback",
value: "off".to_string(),
widget: "[toggle]",
focused: false,
enabled: false,
reason: Some("off while Audio > Streaming only on"),
description: None,
};
let block = field_block(&f, 21, 40);
assert!(block.len() >= 2, "the reason wraps onto its own dim row(s)");
// Every description row is indented and within the width.
for row in &block[1..] {
let t = cells(row);
assert!(t.starts_with(" "), "description indented under the label");
assert!(t.chars().count() <= 40);
}
}
}