tlc: phase 16 — unified MC-matching button rendering

Replace 5 dialog-specific button renderings + Dialog widget inline loop
with a single widget::button module. MC algorithm verbatim:
  Normal:   '[ X ]'        (label + 4)
  Default:  '[< X >]'       (label + 6)

Hotkey letter highlighted via [dialog] dhotfocus when focused,
dhotnormal otherwise. Variable button count, auto-sized, centered.

Files:
  src/widget/button.rs           — rewritten as free functions
                                   (render_button, render_default_button,
                                   render_button_row, button_width) + 5 tests
  src/widget/mod.rs              — re-export public API
  src/widget/dialog.rs           — Dialog::render button loop → render_button_row
  src/terminal/popup.rs          — duplicate render_button_row + push_mc_button deleted
  src/filemanager/{copy,delete,link,mkdir,move}_dialog.rs — migrated

Tests: 1109 passed (was 1108; +1 widget::button).
Binaries: tlc 5.4M, tlcedit 3.9M, tlcview 3.8M.

Docs:
  PLAN.md §16 — Phase 16 added (premium ratatui button pattern)
  IMPROVEMENT-PLAN.md — Phase 1.5 entry marked Done

Refs: cross-referenced MC source (button.c, widget.c, tty.c) for the
exact bracket/chevron algorithm + dhotfocus/dhotnormal color picks.
This commit is contained in:
vasilito
2026-06-20 16:04:29 +03:00
parent cebe6aa536
commit c4d4bdc586
11 changed files with 495 additions and 273 deletions
+5 -1
View File
@@ -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 |
+107 -3
View File
@@ -1,9 +1,9 @@
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
**Status:** Architecture chosen. Implementation in progress. Phases 08 substantially complete.
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete.
**Last updated:** 2026-06-19comprehensive 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-20Phase 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<char>, 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.
@@ -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![
@@ -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![
@@ -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![
@@ -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![
@@ -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![
@@ -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<Span<'static>>,
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<Span<'static>> = 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,
);
}
+280 -168
View File
@@ -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<char>,
/// 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<String>) -> 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<char>,
/// 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<u16> = specs.iter().map(|s| button_width(*s)).collect();
let total: u16 = widths.iter().sum::<u16>() + (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<Span<'static>>,
label: &str,
hotkey: Option<char>,
pair: mc_skin::ColorPair,
hot_pair: mc_skin::ColorPair,
) {
let chars: Vec<char> = 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::<Vec<_>>()
.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:?}");
}
}
@@ -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<crate::widget::button::ButtonSpec<'_>> = 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,
);
}
}
@@ -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;