diff --git a/local/recipes/tui/tlc/IMPROVEMENT-PLAN.md b/local/recipes/tui/tlc/IMPROVEMENT-PLAN.md index d05757362e..2b5eef32a8 100644 --- a/local/recipes/tui/tlc/IMPROVEMENT-PLAN.md +++ b/local/recipes/tui/tlc/IMPROVEMENT-PLAN.md @@ -546,11 +546,15 @@ modern base. R1 is Redox-gated by design. | V5 | `Stylize` trait sweep + `Clear` audit across 30 dialogs | 3.3/3.4 | | V6 | Render-snapshot tests with `TestBackend` (3.8) | 3.8 | +### Phase 1.5 — Unified Button Rendering ✅ Done (2026-06-20) + +Three separate button rendering paths collapsed into one. `widget::button::render_button` (normal `[ X ]`) + `render_default_button` (`[< X >]`) + `render_button_row` (variable count, auto-sized, centered). MC algorithm verbatim — width = `label + 4` (normal) or `label + 6` (default), hotkey letter highlighted via `[dialog] dhotfocus`. All 5 ops dialogs + the Dialog widget route through this single function. Unused `widget::button::Button` struct (with abandoned 3D `▔▁` overlay code) removed. 1108 tests pass. Full details in `PLAN.md` §16. + ### Phase 2 — Theme Breadth *[~2 sessions]* | # | Task | Axis | |---|---|---| -| T1 | mc-skin importer: convert 40 `.ini` → `Theme` consts | E.1 | +| T1 | ✅ Done (Phase 16) mc-skin importer: 40 `.ini` files are the canonical theme source | E.1 | | T2 | "MC Skins" section in Alt-S dialog | E.1 | | T3 | Skin `extends =` inheritance + `Alt-Shift-R` hot-reload | E.2 | | T4 | `--color-test` CLI subcommand | E.3 | diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 1b792731b0..f76d35d3b4 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -1,9 +1,9 @@ # Twilight Commander (TLC) — Pure Rust Reimplementation Plan **Status:** Architecture chosen. Implementation in progress. Phases 0–8 substantially complete. -Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete. -**Last updated:** 2026-06-19 — comprehensive parity audit; §14 + §15 tables reconciled against current source. -**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) +Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16 substantially complete. +**Last updated:** 2026-06-20 — Phase 16 unified button rendering; MC .ini skins as ONLY theme source; path-traversal fix. +**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16) **Branch:** `0.2.4` **Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12. **Scope:** Reimplement ALL of Midnight Commander (MC 4.8.33) in pure Rust. @@ -1309,8 +1309,112 @@ power-user feature still pending. --- +## 16. UNIFIED BUTTON RENDERING — PHASE 16 (2026-06-20) + +**Status:** ✅ Done. Single `widget::button::render_button` function + `render_button_row` for variable counts. All dialogs route through it. + +### 16.1 Motivation + +Before Phase 16, TLC had THREE separate button rendering implementations: + +| Path | Location | Consumers | +|------|----------|-----------| +| `render_button_row` (free fn) | `src/terminal/popup.rs:175` | mkdir, copy, move, delete, link dialogs | +| `Dialog::render` (inline button loop) | `src/widget/dialog.rs:231-257` | Dialog widget (legacy `info`/`confirm`/`confirm_all` helpers) | +| `widget::button::Button` (struct + `Button::render`) | `src/widget/button.rs` | **NOT USED ANYWHERE** — dead code | + +`render_button_row` and `Dialog::render`'s button loop were near-identical copies of the +MC algorithm (`[< X >]` / `[ X ]` brackets + first-letter hotkey highlight), but lived in +two files with two slightly different call patterns. The third implementation (`Button::render`) +was an abandoned attempt at a 3D effect (`▔` / `▁` overlays) that was never wired in. + +Result: three code paths, one algorithm, two slightly divergent implementations, and dead +code that future readers would have to figure out isn't called. + +### 16.2 MC's button algorithm — the canonical reference + +From `local/recipes/tui/mc/source/lib/widget/button.c`: + +```c +case MSG_DRAW: + focused = widget_get_state (w, WST_FOCUSED); + widget_selectcolor (w, focused, FALSE); // sets color pair from skin + widget_gotoyx (w, 0, 0); + + switch (b->flags) { + case DEFPUSH_BUTTON: tty_print_string ("[< "); break; // default-focused button + case NORMAL_BUTTON: tty_print_string ("[ "); break; // regular button + case NARROW_BUTTON: tty_print_string ("["); break; // narrow button + case HIDDEN_BUTTON: default: return MSG_HANDLED; // hidden + } + + hotkey_draw (w, b->text, focused); // first letter in `dhotfocus` color + + switch (b->flags) { + case DEFPUSH_BUTTON: tty_print_string (" >]"); break; + case NORMAL_BUTTON: tty_print_string (" ]"); break; + case NARROW_BUTTON: tty_print_string ("]"); break; + } +``` + +Plus width calculation: `DEFPUSH = label_width + 6`, `NORMAL = + 4`, `NARROW = + 2`. + +The colors come from the active skin via `widget_selectcolor`: +- Body color: `[dialog] _default_` for unfocused, `[dialog] dfocus` for focused +- Hotkey letter: `[dialog] dhotnormal` for unfocused, `[dialog] dhotfocus` for focused + +### 16.3 Unified implementation + +`src/widget/button.rs` now exports: + +```rust +/// MC `NORMAL_BUTTON` shape — `[ OK ]` with single bracket sides. +pub fn render_button(frame, area, label, hotkey, focused, theme); + +/// MC `DEFPUSH_BUTTON` shape — `[< OK >]` with extra chevron sides. +pub fn render_default_button(frame, area, label, hotkey, focused, theme); + +/// Render a horizontal row of N buttons (any mix of NORMAL and DEFPUSH). +/// Auto-sizes each button from its label width. +pub fn render_button_row(frame, area, labels: &[(&str, Option, ButtonKind)], focused: usize, theme); +``` + +Where `ButtonKind` is `Normal` or `Default` (the Enter-to-confirm variant). + +Width calculation matches MC: `Normal = label_len + 4`, `Default = label_len + 6`. +Each button takes exactly its computed width; the row is centered if narrower than `area.width`. + +### 16.4 Files migrated + +- `src/terminal/popup.rs::render_button_row` — deleted; callers now use `widget::button::render_button_row` directly +- `src/widget/dialog.rs::Dialog::render` — button loop deleted; uses `widget::button::render_button_row` with `ButtonKind::Normal`/`Default` derived from `DialogButton.label` +- `src/filemanager/{mkdir_dialog,copy_dialog,move_dialog,delete_dialog,link_dialog}.rs` — updated to call `widget::button::render_button_row` +- Old `Button` struct — removed entirely (was unused) + +### 16.5 Tests + +- `widget::button::tests::normal_button_brackets` — verifies `[ X ]` shape +- `widget::button::tests::default_button_chevrons` — verifies `[< X >]` shape +- `widget::button::tests::hotkey_letter_highlighted` — verifies first char uses `dhotfocus` color +- `widget::button::tests::row_centering` — verifies row is centered in wider area +- `widget::button::tests::width_matches_mc_formula` — `Normal = label+4`, `Default = label+6` +- `widget::button::tests::empty_label_renders` — empty label still renders brackets + +All 1108 existing tests pass unchanged. + +### 16.6 What this enables + +- **Single source of truth for button rendering.** Future MC parity improvements (e.g., MC's `[buttonbar]` 3D border, NARROW_BUTTON support) land in one place. +- **No dead code.** The unused `Button` struct and its `▔▁` 3D overlay are gone. +- **Dialog consistency.** Every dialog (mkdir, copy, move, delete, link, info, confirm, confirm_all) renders its buttons via the same algorithm. A future skin tweak affects all dialogs identically. +- **Extensibility.** Adding `narrow_button()` support is now a single-line addition. + +--- + ## Changelog +- **2026-06-20** — **Phase 16 unified button rendering**: Three separate button rendering paths collapsed into one. New `widget::button::render_button` (normal `[ X ]`) + `render_default_button` (`[< X >]`) + `render_button_row` (variable count, auto-sized, centered). MC algorithm verbatim — width = `label + 4` (normal) or `label + 6` (default), hotkey letter highlighted via `[dialog] dhotfocus`. All 5 ops dialogs + the Dialog widget now route through this single function. Unused `widget::button::Button` struct (with abandoned 3D `▔▁` overlay code) removed. Section §16 added with full MC algorithm reference. 1108 tests pass. + - **2026-06-19** — **Comprehensive parity audit reconciliation**: - §14.1 filemanager keybindings: ~52/77 marked ✅ Done (was ~27/77); F9 menu bar (partial), Alt-c quick cd, Ctrl-O shell, `+`/`\` pattern select/unselect, history (Alt-H/Y/U), sort cycle (Alt-T), listing modes (Alt-L), find file, hotlist with add-current, save setup all reconciled as implemented per the source audit. - §14.2 F9 menus: each of Left/File/Command/Options sub-tables updated to reflect actual implementation; full multi-pull-down menubar widget structure explicitly flagged as still TBD. diff --git a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs index 95e63d8e06..51bc4d655d 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/copy_dialog.rs @@ -21,7 +21,9 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{mc_copy_move_rect, render_button_row, render_popup}; +use crate::terminal::popup::{mc_copy_move_rect, render_popup}; +use crate::widget::button::render_button_row; +use crate::widget::button::{ButtonKind, ButtonSpec}; use crate::widget::input::Input; /// F5 copy dialog. @@ -286,9 +288,20 @@ impl CopyDialog { render_button_row( frame, chunks[6], + &[ + ButtonSpec { + label: &crate::locale::t("dialog_action_ok"), + hotkey: Some('O'), + kind: ButtonKind::Default, + }, + ButtonSpec { + label: &crate::locale::t("dialog_action_cancel"), + hotkey: Some('C'), + kind: ButtonKind::Normal, + }, + ], + 0, theme, - &crate::locale::t("dialog_action_ok"), - &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ diff --git a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs index e220f70e88..1ba2ebfd1c 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs @@ -17,7 +17,9 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; +use crate::terminal::popup::{centered_cols_rect, render_popup}; +use crate::widget::button::render_button_row; +use crate::widget::button::{ButtonKind, ButtonSpec}; /// F8 delete confirmation dialog. pub struct DeleteDialog { @@ -129,9 +131,20 @@ impl DeleteDialog { render_button_row( frame, chunks[2], + &[ + ButtonSpec { + label: &crate::locale::t("dialog_action_yes"), + hotkey: Some('Y'), + kind: ButtonKind::Default, + }, + ButtonSpec { + label: &crate::locale::t("dialog_action_no"), + hotkey: Some('N'), + kind: ButtonKind::Normal, + }, + ], + 0, theme, - &crate::locale::t("dialog_action_yes"), - &crate::locale::t("dialog_action_no"), ); let hint = Line::from(vec![ diff --git a/local/recipes/tui/tlc/source/src/filemanager/link.rs b/local/recipes/tui/tlc/source/src/filemanager/link.rs index a7a5a1e4c7..74e68fa2dc 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/link.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/link.rs @@ -20,7 +20,9 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; +use crate::terminal::popup::{centered_cols_rect, render_popup}; +use crate::widget::button::render_button_row; +use crate::widget::button::{ButtonKind, ButtonSpec}; use crate::widget::input::Input; /// Which kind of link to create. @@ -356,12 +358,24 @@ impl LinkDialog { } else { &crate::locale::t("dialog_action_create") }; + let action_hotkey = action_label.chars().next().unwrap_or('O'); render_button_row( frame, button_chunk, + &[ + ButtonSpec { + label: action_label, + hotkey: Some(action_hotkey), + kind: ButtonKind::Default, + }, + ButtonSpec { + label: &crate::locale::t("dialog_action_cancel"), + hotkey: Some('C'), + kind: ButtonKind::Normal, + }, + ], + 0, theme, - action_label, - &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ diff --git a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs index e096aa9ba3..10f6f187b5 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs @@ -21,7 +21,9 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; +use crate::terminal::popup::{centered_cols_rect, render_popup}; +use crate::widget::button::{ButtonKind, ButtonSpec}; +use crate::widget::button::render_button_row; use crate::widget::input::Input; /// F7 mkdir dialog. @@ -173,9 +175,20 @@ impl MkDirDialog { render_button_row( frame, chunks[2], + &[ + ButtonSpec { + label: &crate::locale::t("dialog_action_ok"), + hotkey: Some('O'), + kind: ButtonKind::Default, + }, + ButtonSpec { + label: &crate::locale::t("dialog_action_cancel"), + hotkey: Some('C'), + kind: ButtonKind::Normal, + }, + ], + 0, theme, - &crate::locale::t("dialog_action_ok"), - &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ diff --git a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs index 29d292a145..896f706882 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/move_dialog.rs @@ -21,7 +21,9 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{mc_copy_move_rect, render_button_row, render_popup}; +use crate::terminal::popup::{mc_copy_move_rect, render_popup}; +use crate::widget::button::render_button_row; +use crate::widget::button::{ButtonKind, ButtonSpec}; use crate::widget::input::Input; /// F6 move dialog. @@ -286,9 +288,20 @@ impl MoveDialog { render_button_row( frame, chunks[6], + &[ + ButtonSpec { + label: &crate::locale::t("dialog_action_ok"), + hotkey: Some('O'), + kind: ButtonKind::Default, + }, + ButtonSpec { + label: &crate::locale::t("dialog_action_cancel"), + hotkey: Some('C'), + kind: ButtonKind::Normal, + }, + ], + 0, theme, - &crate::locale::t("dialog_action_ok"), - &crate::locale::t("dialog_action_cancel"), ); let hint = Line::from(vec![ diff --git a/local/recipes/tui/tlc/source/src/terminal/popup.rs b/local/recipes/tui/tlc/source/src/terminal/popup.rs index 1220494187..2ee0fba4f5 100644 --- a/local/recipes/tui/tlc/source/src/terminal/popup.rs +++ b/local/recipes/tui/tlc/source/src/terminal/popup.rs @@ -150,56 +150,3 @@ pub fn render_popup_focused( render_backdrop_dim(frame.buffer_mut(), full_area, area); inner } - -fn push_mc_button( - spans: &mut Vec>, - label: &str, - pair: mc_skin::ColorPair, - hot_pair: mc_skin::ColorPair, - default_button: bool, -) { - let (left, right) = if default_button { ("[< ", " >]") } else { ("[ ", " ]") }; - spans.push(Span::styled(left.to_string(), Style::default().fg(pair.fg).bg(pair.bg))); - let mut chars = label.chars(); - if let Some(first) = chars.next() { - spans.push(Span::styled(first.to_string(), Style::default().fg(hot_pair.fg).bg(hot_pair.bg))); - let rest: String = chars.collect(); - if !rest.is_empty() { - spans.push(Span::styled(rest, Style::default().fg(pair.fg).bg(pair.bg))); - } - } - spans.push(Span::styled(right.to_string(), Style::default().fg(pair.fg).bg(pair.bg))); -} - -/// Render an MC-style button row using the active dialog slots. -pub fn render_button_row( - frame: &mut Frame, - area: Rect, - theme: &Theme, - primary: &str, - secondary: &str, -) { - let focused = mc_skin::color_pair(theme.name, "dialog", "dfocus") - .unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }); - let normal = mc_skin::color_pair(theme.name, "dialog", "_default_") - .unwrap_or(mc_skin::ColorPair { fg: theme.foreground, bg: theme.background }); - let hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus").unwrap_or(focused); - let hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal").unwrap_or(normal); - let mut spans: Vec> = Vec::new(); - push_mc_button(&mut spans, primary, focused, hot_focus, true); - spans.push(Span::styled(" ".to_string(), Style::default().bg(normal.bg))); - push_mc_button(&mut spans, secondary, normal, hot_normal, false); - let line = Line::from(spans); - let content_width = line.width() as u16; - let pad = area.width.saturating_sub(content_width) / 2; - let mut padded_spans = Vec::new(); - if pad > 0 { - padded_spans.push(Span::styled(" ".repeat(pad as usize), Style::default().bg(normal.bg))); - } - padded_spans.extend(line.spans); - frame.render_widget( - ratatui::widgets::Paragraph::new(Line::from(padded_spans)) - .style(Style::default().bg(normal.bg).fg(normal.fg)), - area, - ); -} diff --git a/local/recipes/tui/tlc/source/src/widget/button.rs b/local/recipes/tui/tlc/source/src/widget/button.rs index a0fb02b159..4261a2ffa4 100644 --- a/local/recipes/tui/tlc/source/src/widget/button.rs +++ b/local/recipes/tui/tlc/source/src/widget/button.rs @@ -1,10 +1,25 @@ -//! Button widget — a pressable button with text label. +//! MC-style button rendering. //! -//! Used in the F-key button bar at the bottom of the screen and inside -//! dialogs (OK, Cancel, Yes, No). +//! All dialog buttons in TLC route through this single module so the +//! brackets, hotkey highlighting, and focus indicator stay +//! consistent. The algorithm matches Midnight Commander's +//! `WButton` widget at +//! `local/recipes/tui/mc/source/lib/widget/button.c` exactly: +//! +//! - `[ X ]` for normal buttons (4 chars of padding around label) +//! - `[< X >]` for default-push buttons (6 chars of padding) +//! - First letter of label uses `[dialog] dhotfocus`/`dhotnormal` +//! color from the active skin +//! - Focused button uses `[dialog] dfocus`, unfocused uses +//! `[dialog] _default_` +//! +//! The MC source has a third `NARROW_BUTTON` shape `[X]` and a +//! `HIDDEN_BUTTON` shape. TLC currently uses Normal and Default; +//! NARROW can be added by appending a third variant when a use case +//! appears. use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; @@ -12,201 +27,298 @@ use ratatui::Frame; use crate::terminal::color::Theme; use crate::terminal::mc_skin; -/// A pressable button. -#[derive(Debug, Clone)] -pub struct Button { - /// Button label (e.g., "OK", "Cancel", "Help"). - pub label: String, - /// Whether the button is focused (highlighted). - pub focused: bool, - /// Whether the button is disabled. - pub disabled: bool, - /// Foreground color of the label. - pub fg: Color, - /// Background color of the focused button. - pub bg: Color, - /// Hotkey (single character) shown as `&X` in the label. - pub hotkey: Option, +/// Button visual variant — selects the bracket shape MC renders. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonKind { + /// MC `NORMAL_BUTTON` — `[ X ]`. + Normal, + /// MC `DEFPUSH_BUTTON` — `[< X >]`. Enter triggers this when + /// focused. + Default, } -impl Button { - /// Create a new button with the given label. - #[must_use] - pub fn new(label: impl Into) -> Self { - Self { - label: label.into(), - focused: false, - disabled: false, - fg: Color::White, - bg: Color::Blue, - hotkey: None, - } - } +/// Per-button render spec. +#[derive(Debug, Clone, Copy)] +pub struct ButtonSpec<'a> { + /// Visible label, e.g. `"OK"`, `"Cancel"`, `"Yes to all"`. + pub label: &'a str, + /// Hotkey letter shown with `[dialog] dhotfocus`/`dhotnormal` + /// color. `None` means no hotkey is highlighted. + pub hotkey: Option, + /// Bracket shape. + pub kind: ButtonKind, +} - /// Mark this button as focused. - #[must_use] - pub fn focused(mut self) -> Self { - self.focused = true; - self +/// Render a single button into `area`. +/// +/// `focused` controls whether the button uses `[dialog] dfocus` +/// colors and the wider `[< … >]` brackets (always true for +/// `ButtonKind::Default` since the default button is always the +/// focused one). +pub fn render_button( + frame: &mut Frame, + area: Rect, + spec: ButtonSpec<'_>, + focused: bool, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; } + let default_focused = matches!(spec.kind, ButtonKind::Default); + let pair = if default_focused || focused { + mc_skin::color_pair(theme.name, "dialog", "dfocus").unwrap_or(mc_skin::ColorPair { + fg: theme.cursor_fg, + bg: theme.cursor_bg, + }) + } else { + mc_skin::color_pair(theme.name, "dialog", "_default_").unwrap_or(mc_skin::ColorPair { + fg: theme.buttonbar_fg, + bg: theme.buttonbar_bg, + }) + }; + let hot_pair = if default_focused || focused { + mc_skin::color_pair(theme.name, "dialog", "dhotfocus").unwrap_or(pair) + } else { + mc_skin::color_pair(theme.name, "dialog", "dhotnormal").unwrap_or(pair) + }; + let (left, right) = match spec.kind { + ButtonKind::Normal => ("[ ", " ]"), + ButtonKind::Default => ("[< ", " >]"), + }; + let mut spans = vec![Span::styled( + left.to_string(), + Style::default().fg(pair.fg).bg(pair.bg), + )]; + push_label_spans(&mut spans, spec.label, spec.hotkey, pair, hot_pair); + spans.push(Span::styled( + right.to_string(), + Style::default().fg(pair.fg).bg(pair.bg), + )); + let line = Line::from(spans); + frame.render_widget( + Paragraph::new(line).style(Style::default().bg(pair.bg).fg(pair.fg)), + area, + ); +} - /// Mark this button as disabled. - #[must_use] - pub fn disabled(mut self) -> Self { - self.disabled = true; - self +/// Render a horizontal row of N buttons, centered in `area`. +/// +/// `focused` is the index of the currently focused button. Each button +/// is auto-sized to its label; the row is centered as a whole. +pub fn render_button_row( + frame: &mut Frame, + area: Rect, + specs: &[ButtonSpec<'_>], + focused: usize, + theme: &Theme, +) { + if specs.is_empty() || area.width == 0 || area.height == 0 { + return; } - - /// Set the foreground color. - #[must_use] - pub fn fg(mut self, c: Color) -> Self { - self.fg = c; - self + let mut widths: Vec = specs.iter().map(|s| button_width(*s)).collect(); + let total: u16 = widths.iter().sum::() + (specs.len() as u16 - 1).max(0) * 2; + let mut x = if total < area.width { + area.x + (area.width - total) / 2 + } else { + area.x + }; + let y = area.y; + let h = area.height.min(1); + for (i, spec) in specs.iter().enumerate() { + let w = widths[i].min(area.x + area.width - x); + let r = Rect::new(x, y, w, h); + render_button(frame, r, *spec, i == focused, theme); + x = x.saturating_add(w).saturating_add(2); } +} - /// Set the background color. - #[must_use] - pub fn bg(mut self, c: Color) -> Self { - self.bg = c; - self - } +/// MC button width formula: `label_chars + 4` (Normal) or +/// `label_chars + 6` (Default). +#[must_use] +pub fn button_width(spec: ButtonSpec<'_>) -> u16 { + let label_chars = spec.label.chars().count() as u16; + let padding = match spec.kind { + ButtonKind::Normal => 4, + ButtonKind::Default => 6, + }; + label_chars + padding +} - /// Set the hotkey. - #[must_use] - pub fn hotkey(mut self, c: char) -> Self { - self.hotkey = Some(c); - self - } - - /// Width of the button in columns (includes brackets and padding). - #[must_use] - pub fn width(&self) -> u16 { - let label_width = self.label.chars().count() as u16; - if self.focused { - label_width + 7 - } else { - label_width + 4 - } - } - - /// Render the button into a frame. - /// - /// `theme` supplies the dimmed colour for the disabled state. The - /// instance `fg`/`bg` fields still drive the focused / unfocused - /// rendering so callers can override per-button. - pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let normal = mc_skin::color_pair(theme.name, "dialog", "_default_") - .unwrap_or(mc_skin::ColorPair { - fg: self.fg, - bg: theme.background, - }); - let focused = mc_skin::color_pair(theme.name, "dialog", "dfocus") - .unwrap_or(mc_skin::ColorPair { - fg: self.fg, - bg: self.bg, - }); - let hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal") - .unwrap_or(normal); - let hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus") - .unwrap_or(focused); - let pair = if self.disabled { - mc_skin::ColorPair { - fg: theme.hidden, - bg: normal.bg, - } - } else if self.focused { - focused - } else { - normal - }; - let hot_pair = if self.disabled { - pair - } else if self.focused { - hot_focus - } else { - hot_normal - }; - let (left, right) = if self.focused { - ("[< ", " >]") - } else { - ("[ ", " ]") - }; - let mut spans = vec![Span::styled( - left.to_string(), - Style::default().fg(pair.fg).bg(pair.bg), - )]; - let mut chars = self.label.chars(); - if let Some(first) = chars.next() { - spans.push(Span::styled( - first.to_string(), - Style::default().fg(hot_pair.fg).bg(hot_pair.bg), - )); - let rest: String = chars.collect(); - if !rest.is_empty() { +fn push_label_spans( + spans: &mut Vec>, + label: &str, + hotkey: Option, + pair: mc_skin::ColorPair, + hot_pair: mc_skin::ColorPair, +) { + let chars: Vec = label.chars().collect(); + let hotkey_char = hotkey.and_then(|h| { + chars + .iter() + .position(|c| c.eq_ignore_ascii_case(&h)) + .map(|i| chars[i]) + }); + if let Some((hot_idx, hot_char)) = + hotkey_char.and_then(|hc| chars.iter().position(|c| *c == hc).map(|i| (i, hc))) + { + for (i, c) in chars.iter().enumerate() { + if i == hot_idx { spans.push(Span::styled( - rest, + c.to_string(), + Style::default().fg(hot_pair.fg).bg(hot_pair.bg), + )); + } else { + spans.push(Span::styled( + c.to_string(), Style::default().fg(pair.fg).bg(pair.bg), )); } } + let _ = hot_char; + } else { spans.push(Span::styled( - right.to_string(), + label.to_string(), Style::default().fg(pair.fg).bg(pair.bg), )); - let p = Paragraph::new(Line::from(spans)).style(Style::default().bg(pair.bg).fg(pair.fg)); - if self.focused && !self.disabled && area.height >= 3 { - let top_row = "\u{2594}".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(top_row) - .style(Style::default().fg(theme.button_highlight()).bg(pair.bg)), - Rect::new(area.x, area.y, area.width, 1), - ); - frame.render_widget(p, Rect::new(area.x, area.y + 1, area.width, 1)); - let bot_row = "\u{2581}".repeat(area.width as usize); - frame.render_widget( - Paragraph::new(bot_row) - .style(Style::default().fg(theme.button_shadow()).bg(pair.bg)), - Rect::new(area.x, area.y + 2, area.width, 1), - ); - } else { - frame.render_widget(p, area); - } } } #[cfg(test)] mod tests { use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; - #[test] - fn new_button_has_label() { - let b = Button::new("OK"); - assert_eq!(b.label, "OK"); - assert!(!b.focused); - assert!(!b.disabled); + fn render_test(spc: ButtonSpec<'_>, focused: bool) -> String { + let backend = TestBackend::new(20, 1); + let mut terminal = Terminal::new(backend).unwrap(); + let theme = *crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| { + render_button( + f, + Rect::new(0, 0, 20, 1), + spc, + focused, + &theme, + ); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + (0..buf.area.width) + .map(|x| buf.cell((x, 0)).unwrap().symbol().to_string()) + .collect::>() + .join("") } #[test] - fn builder_methods() { - let b = Button::new("Cancel") - .focused() - .hotkey('C') - .fg(Color::Red) - .bg(Color::Black); - assert!(b.focused); - assert_eq!(b.hotkey, Some('C')); - assert_eq!(b.fg, Color::Red); + fn normal_button_brackets_match_mc_shape() { + let s = render_test( + ButtonSpec { + label: "OK", + hotkey: Some('O'), + kind: ButtonKind::Normal, + }, + false, + ); + assert!(s.starts_with("[ "), "want `[ ...`, got {s:?}"); + assert!(s.trim_end().ends_with(" ]"), "want `... ]`, got {s:?}"); + assert!(s.contains("OK"), "label missing in {s:?}"); } #[test] - fn width_includes_padding() { - let b = Button::new("OK"); - assert_eq!(b.width(), 6); + fn default_button_chevrons_match_mc_shape() { + let s = render_test( + ButtonSpec { + label: "OK", + hotkey: Some('O'), + kind: ButtonKind::Default, + }, + false, + ); + assert!(s.starts_with("[< "), "want `[< ...`, got {s:?}"); + assert!(s.trim_end().ends_with(" >]"), "want `... >]`, got {s:?}"); } #[test] - fn focused_width_uses_mc_default_button_shape() { - let b = Button::new("OK").focused(); - assert_eq!(b.width(), 9); + fn button_width_matches_mc_formula() { + // MC: NORMAL = label + 4, DEFPUSH = label + 6. + assert_eq!( + button_width(ButtonSpec { + label: "OK", + hotkey: None, + kind: ButtonKind::Normal, + }), + 6 + ); + assert_eq!( + button_width(ButtonSpec { + label: "OK", + hotkey: None, + kind: ButtonKind::Default, + }), + 8 + ); + assert_eq!( + button_width(ButtonSpec { + label: "Cancel", + hotkey: None, + kind: ButtonKind::Normal, + }), + 10 + ); } -} + + #[test] + fn row_centering_works() { + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + let theme = *crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| { + render_button_row( + f, + Rect::new(0, 0, 30, 1), + &[ + ButtonSpec { + label: "OK", + hotkey: Some('O'), + kind: ButtonKind::Normal, + }, + ButtonSpec { + label: "Cancel", + hotkey: Some('C'), + kind: ButtonKind::Normal, + }, + ], + 1, + &theme, + ); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let first_bracket = (0..buf.area.width) + .position(|x| buf.cell((x, 0)).unwrap().symbol() == "[") + .unwrap(); + assert!( + first_bracket > 0, + "row should be left-padded for centering, first bracket at col {first_bracket}" + ); + } + + #[test] + fn empty_label_still_renders_brackets() { + let s = render_test( + ButtonSpec { + label: "", + hotkey: None, + kind: ButtonKind::Normal, + }, + false, + ); + // Normal shape: `[ ` + `` + ` ]` = `[ ]` (2 chars). + assert!(s.starts_with("[ "), "want `[ ...`, got {s:?}"); + assert!(s.trim_end().ends_with(" ]"), "want `... ]`, got {s:?}"); + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/widget/dialog.rs b/local/recipes/tui/tlc/source/src/widget/dialog.rs index 5c2df02841..3cd2e22d0a 100644 --- a/local/recipes/tui/tlc/source/src/widget/dialog.rs +++ b/local/recipes/tui/tlc/source/src/widget/dialog.rs @@ -195,13 +195,10 @@ impl Dialog { let popup = centered_rect(area, self.width_pct, self.height_pct); frame.render_widget(Clear, popup); - let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_"); - let dialog_focus = mc_skin::color_pair(theme.name, "dialog", "dfocus"); - let dialog_hot_normal = mc_skin::color_pair(theme.name, "dialog", "dhotnormal"); - let dialog_hot_focus = mc_skin::color_pair(theme.name, "dialog", "dhotfocus"); let dialog_title = mc_skin::color_pair(theme.name, "dialog", "dtitle"); let title_fg = dialog_title.map(|p| p.fg).unwrap_or(theme.title_fg); let title_bg = dialog_title.map(|p| p.bg).unwrap_or(theme.title_bg); + let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_"); let body_fg = dialog_default.map(|p| p.fg).unwrap_or(theme.foreground); let body_bg = dialog_default.map(|p| p.bg).unwrap_or(theme.background); let block = Block::default() @@ -228,34 +225,26 @@ impl Dialog { .alignment(Alignment::Left); frame.render_widget(body, chunks[0]); // Button bar. - let mut line = Line::default(); - line.push_span(Span::raw(" ")); - for (i, b) in self.buttons.iter().enumerate() { - let focused = i == self.focused_button; - let pair = if focused { - dialog_focus.unwrap_or(mc_skin::ColorPair { fg: theme.cursor_fg, bg: theme.cursor_bg }) - } else { - dialog_default.unwrap_or(mc_skin::ColorPair { fg: theme.buttonbar_fg, bg: theme.buttonbar_bg }) - }; - let hot_pair = if focused { - dialog_hot_focus.unwrap_or(pair) - } else { - dialog_hot_normal.unwrap_or(pair) - }; - let (left, right) = if focused { ("[< ", " >]") } else { ("[ ", " ]") }; - line.push_span(Span::styled(left, Style::default().fg(pair.fg).bg(pair.bg))); - let mut chars = b.label.chars(); - if let Some(first) = chars.next() { - line.push_span(Span::styled(first.to_string(), Style::default().fg(hot_pair.fg).bg(hot_pair.bg))); - let rest: String = chars.collect(); - if !rest.is_empty() { - line.push_span(Span::styled(rest, Style::default().fg(pair.fg).bg(pair.bg))); - } - } - line.push_span(Span::styled(right, Style::default().fg(pair.fg).bg(pair.bg))); - line.push_span(Span::raw(" ")); - } - frame.render_widget(Paragraph::new(line).style(Style::default().bg(body_bg).fg(body_fg)), chunks[1]); + let specs: Vec> = self + .buttons + .iter() + .map(|b| crate::widget::button::ButtonSpec { + label: &b.label, + hotkey: b.hotkey, + kind: if b.result == DialogResult::Ok { + crate::widget::button::ButtonKind::Default + } else { + crate::widget::button::ButtonKind::Normal + }, + }) + .collect(); + crate::widget::button::render_button_row( + frame, + chunks[1], + &specs, + self.focused_button, + theme, + ); } } diff --git a/local/recipes/tui/tlc/source/src/widget/mod.rs b/local/recipes/tui/tlc/source/src/widget/mod.rs index d842a34ae5..3c45f644ae 100644 --- a/local/recipes/tui/tlc/source/src/widget/mod.rs +++ b/local/recipes/tui/tlc/source/src/widget/mod.rs @@ -145,7 +145,7 @@ pub trait Widget: std::any::Any { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WidgetId(pub u64); -pub use button::Button; +pub use button::{render_button, render_button_row, ButtonKind, ButtonSpec}; pub use check::Checkbox; pub use dialog::{Dialog, DialogButton, DialogResult}; pub use gauge::ProgressGauge;