diff --git a/crates/qbzd/src/tui/app.rs b/crates/qbzd/src/tui/app.rs index ba76c1701..f9066d273 100644 --- a/crates/qbzd/src/tui/app.rs +++ b/crates/qbzd/src/tui/app.rs @@ -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. diff --git a/crates/qbzd/src/tui/screens/account.rs b/crates/qbzd/src/tui/screens/account.rs index d0ab8e645..8cdde9eae 100644 --- a/crates/qbzd/src/tui/screens/account.rs +++ b/crates/qbzd/src/tui/screens/account.rs @@ -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 = 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 = 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); diff --git a/crates/qbzd/src/tui/screens/audio.rs b/crates/qbzd/src/tui/screens/audio.rs index 9a6c6a665..0ef165eda 100644 --- a/crates/qbzd/src/tui/screens/audio.rs +++ b/crates/qbzd/src/tui/screens/audio.rs @@ -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 = Vec::new(); + let mut anchor: Option = 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> { + /// 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, + ctrl_col: u16, + width: u16, + ) -> (Vec>, 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> { 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) { diff --git a/crates/qbzd/src/tui/screens/bundle.rs b/crates/qbzd/src/tui/screens/bundle.rs index da6a7a29b..4619404bd 100644 --- a/crates/qbzd/src/tui/screens/bundle.rs +++ b/crates/qbzd/src/tui/screens/bundle.rs @@ -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 = Vec::new(); + let mut anchor: Option = 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 = 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 = 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>, + 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>, + 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) { diff --git a/crates/qbzd/src/tui/screens/network.rs b/crates/qbzd/src/tui/screens/network.rs index 1afb6b07c..04a08e16f 100644 --- a/crates/qbzd/src/tui/screens/network.rs +++ b/crates/qbzd/src/tui/screens/network.rs @@ -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 = 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::().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 = Vec::new(); + let mut anchor: Option = 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 { diff --git a/crates/qbzd/src/tui/screens/playback.rs b/crates/qbzd/src/tui/screens/playback.rs index 42fd1477f..769341e04 100644 --- a/crates/qbzd/src/tui/screens/playback.rs +++ b/crates/qbzd/src/tui/screens/playback.rs @@ -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 = Vec::new(); + let mut anchor: Option = 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> { + /// 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, + ctrl_col: u16, + width: u16, + ) -> (Vec>, 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> { 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) { diff --git a/crates/qbzd/src/tui/screens/qconnect.rs b/crates/qbzd/src/tui/screens/qconnect.rs index 305b34cc5..adf3753cd 100644 --- a/crates/qbzd/src/tui/screens/qconnect.rs +++ b/crates/qbzd/src/tui/screens/qconnect.rs @@ -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 = 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 = Vec::new(); + let mut anchor: Option = 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), diff --git a/crates/qbzd/src/tui/screens/wizard.rs b/crates/qbzd/src/tui/screens/wizard.rs index 96d39779b..d410f7094 100644 --- a/crates/qbzd/src/tui/screens/wizard.rs +++ b/crates/qbzd/src/tui/screens/wizard.rs @@ -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 = 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> { 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 = 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:?}"); + } + } } diff --git a/crates/qbzd/src/tui/strings.rs b/crates/qbzd/src/tui/strings.rs index 251f23280..b7733a480 100644 --- a/crates/qbzd/src/tui/strings.rs +++ b/crates/qbzd/src/tui/strings.rs @@ -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"; diff --git a/crates/qbzd/src/tui/widgets.rs b/crates/qbzd/src/tui/widgets.rs index d43f911ae..94db2b057 100644 --- a/crates/qbzd/src/tui/widgets.rs +++ b/crates/qbzd/src/tui/widgets.rs @@ -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 { + if width == 0 { + return text.lines().map(str::to_string).collect(); + } + let mut out: Vec = 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> { + 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> { + 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
, + anchor: &mut Option, + title: impl Into, + active: bool, + lines: Vec>, + 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) { + 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); + } + } +}