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:
@@ -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 |
|
||||
|
||||
@@ -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<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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user