feat(qbzd): wide sidebar, wrapped descriptions and fixed control column in setup TUI
cc
This commit is contained in:
10 files changed
+896
-175
No files matched your search
+54
-11
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user