tlc: D2 — Display bits dialog (8/7/UTF-8)

Mirrors MC's boxes.c::display_bits_box
(mc/source/src/filemanager/boxes.c:955-1004). MC exposes 4 modes
(UTF-8, Full 8 bits, ISO 8859-1, 7 bits); TLC is UTF-8 throughout
so we expose the relevant 3-state subset:
  - Full 8 bits: bytes pass through verbatim
  - 7 bits: high bit stripped (parity-stripped serial consoles)
  - UTF-8 (validated): pass through UTF-8, ? for invalid

New file: src/filemanager/display_bits_dialog.rs
  - DisplayBits enum with 3 variants
  - DisplayBitsDialog (radio-list, mirrors SortDialog)
  - from_config() / map_byte() / is_valid_utf8() helpers
  - 12 unit tests

New DialogState variant:
  DialogState::DisplayBits(Box<DisplayBitsDialog>)
  Wired into dispatch (handle_key), render, is_finished (false;
  the dispatcher captures Confirm/Cancel from handle_key), title.

Tests: 1318 passing (was 1306; +12 new).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kellito
2026-07-05 20:42:36 +03:00
parent 4a3d097b27
commit 6c771d88f9
5 changed files with 323 additions and 4 deletions
@@ -12,10 +12,10 @@ use anyhow::Result;
use std::path::Path;
use crate::filemanager::{
config_dialog, edit_history, error_dialog, external_panelize, filtered_view, find, help,
hotlist, layout_dialog, link, overwrite_dialog, panel_options, percent, render, screen_list,
skin_dialog, tree, usermenu, vfs_list, DialogState, FileManager, LinkDialog, PendingErrorOp,
PendingFileOp,
config_dialog, display_bits_dialog, edit_history, error_dialog, external_panelize,
filtered_view, find, help, hotlist, layout_dialog, link, overwrite_dialog, panel_options,
percent, render, screen_list, skin_dialog, tree, usermenu, vfs_list, DialogState,
FileManager, LinkDialog, PendingErrorOp, PendingFileOp,
};
use crate::filemanager::link::LinkKind;
use crate::config::Config;
@@ -770,12 +770,16 @@ impl FileManager {
| Some(DialogState::ScreenList(_))
| Some(DialogState::EditHistory(_))
| Some(DialogState::FilteredView(_))
| Some(DialogState::DisplayBits(_))
| Some(DialogState::Compare(_))
| Some(DialogState::Chattr(_))
| Some(DialogState::PanelFilter(_))
| Some(DialogState::Encoding(_))
| Some(DialogState::Connection(_))
| Some(DialogState::Sort(_)) => {
// These are closed by their own apply_*_outcome
// helpers before this function is called; they
// should never appear here.
}
// The Help dialog also clears itself in `handle_dialog_key`
// when the user presses a close key.
@@ -1232,6 +1236,7 @@ fn dialog_label(d: &DialogState) -> String {
DialogState::QuickCd(_) => "Quick cd".to_string(),
DialogState::Overwrite(_) => "Overwrite".to_string(),
DialogState::Error(_) => "Error".to_string(),
DialogState::DisplayBits(_) => "Display bits".to_string(),
DialogState::Layout(_) => "Layout".to_string(),
DialogState::PanelOptions(_) => "Panel options".to_string(),
DialogState::Config(_) => "Configuration".to_string(),
@@ -678,6 +678,10 @@ impl FileManager {
d.handle_key(key);
consumed = true;
}
Some(DialogState::DisplayBits(d)) => {
d.handle_key(key);
consumed = true;
}
Some(DialogState::Layout(d)) => {
layout_outcome = Some(d.handle_key(key));
consumed = true;
@@ -0,0 +1,304 @@
//! Display bits dialog (Options → Display bits).
//!
//! Mirrors MC's `boxes.c::display_bits_box` (`mc/source/src/filemanager/boxes.c:955-1004`).
//! MC exposes four modes: UTF-8 output, Full 8 bits, ISO 8859-1,
//! 7 bits. TLC is UTF-8 throughout, so we expose the relevant
//! 3-state subset:
//!
//! - **Full 8 bits** (default): display all 256 byte values
//! verbatim, including non-printable control bytes via the
//! existing `^X` notation.
//! - **7 bits**: strip the high bit of every byte before
//! displaying — useful on legacy 7-bit terminals / serial
//! consoles where the high bit is sometimes set as a parity
//! marker.
//! - **UTF-8 (validate)**: pass through UTF-8 multi-byte
//! sequences; non-UTF-8 byte sequences are replaced with `?`.
//!
//! The chosen mode is stored on the Viewer (and Editor for binary
//! files) so each render pass can apply the right transformation.
/// How to display non-ASCII bytes in the viewer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DisplayBits {
/// Pass bytes through verbatim (8-bit clean).
#[default]
Full,
/// Strip the high bit (0x80) from every byte.
Seven,
/// Pass through UTF-8; replace non-UTF-8 bytes with `?`.
Utf8,
}
impl DisplayBits {
/// Human-readable label matching MC's options menu entries.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Full => "Full 8 bits",
Self::Seven => "7 bits",
Self::Utf8 => "UTF-8 (validated)",
}
}
/// Parse from a config string (case-insensitive).
/// Accepts the same aliases MC uses in its mc.lib.
#[must_use]
pub fn from_config(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"7" | "7bit" | "seven" => Self::Seven,
"utf8" | "utf-8" | "u" => Self::Utf8,
_ => Self::Full,
}
}
/// Apply the display mode to a single byte.
#[must_use]
pub fn map_byte(self, b: u8) -> u8 {
match self {
Self::Full => b,
Self::Seven => b & 0x7F,
Self::Utf8 => b, // validity is checked at the string level, not per-byte
}
}
/// Check whether `bytes` is a valid UTF-8 sequence. Used by the
/// Utf8 mode to decide whether to display a byte verbatim or
/// replace it with `?`.
#[must_use]
pub fn is_valid_utf8(bytes: &[u8]) -> bool {
std::str::from_utf8(bytes).is_ok()
}
}
/// The display-bits selection dialog. Uses the same radio-list
/// rendering as the SortDialog so the UI is consistent.
pub struct DisplayBitsDialog {
options: [DisplayBits; 3],
selected: usize,
}
impl DisplayBitsDialog {
/// Create a new dialog with `current` as the initial selection.
#[must_use]
pub fn new(current: DisplayBits) -> Self {
let options = [
DisplayBits::Full,
DisplayBits::Seven,
DisplayBits::Utf8,
];
let selected = options
.iter()
.position(|&o| o == current)
.unwrap_or(0);
Self { options, selected }
}
/// Snapshot the user's choice.
#[must_use]
pub fn selected(&self) -> DisplayBits {
self.options[self.selected]
}
/// Move the selection up by one (with wrap).
pub fn select_prev(&mut self) {
self.selected = self.selected.checked_sub(1).unwrap_or(self.options.len() - 1);
}
/// Move the selection down by one (with wrap).
pub fn select_next(&mut self) {
self.selected = (self.selected + 1) % self.options.len();
}
/// Handle a key. Up/Down move the selection; Enter confirms
/// (dialog is implicitly confirmed by `selected()`); Esc
/// returns the original (callers can detect via `is_cancelled`).
pub fn handle_key(&mut self, key: crate::key::Key) -> DialogOutcome {
use crate::key::Key;
match key {
Key::UP => {
self.select_prev();
DialogOutcome::Running
}
Key::DOWN => {
self.select_next();
DialogOutcome::Running
}
Key::ENTER => DialogOutcome::Confirm,
Key::ESCAPE => DialogOutcome::Cancel,
_ => DialogOutcome::Running,
}
}
/// Render the dialog as a vertical radio list.
pub fn render(&self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect, theme: &crate::terminal::color::Theme) {
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
let popup = crate::terminal::popup::centered_cols_rect(area, 48, 8);
let inner = crate::terminal::popup::render_popup(
frame,
popup,
"Display bits",
theme,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(3),
Constraint::Length(1),
])
.split(inner);
let help = Line::from(Span::styled(
"Choose how to display non-ASCII bytes:",
Style::default().fg(theme.foreground),
));
frame.render_widget(Paragraph::new(help), chunks[0]);
let items: Vec<ListItem> = self
.options
.iter()
.enumerate()
.map(|(i, opt)| {
let prefix = if i == self.selected { "" } else { "" };
let style = if i == self.selected {
Style::default()
.fg(theme.cursor_fg)
.bg(theme.warning)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.foreground)
};
ListItem::new(Line::from(Span::styled(
format!("{prefix}{}", opt.label()),
style,
)))
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::NONE));
frame.render_widget(list, chunks[1]);
let hint = Line::from(vec![
Span::styled("Up/Down", Style::default().fg(theme.warning)),
Span::styled(" select ", Style::default().fg(theme.hidden)),
Span::styled("Enter", Style::default().fg(theme.warning)),
Span::styled(" OK ", Style::default().fg(theme.hidden)),
Span::styled("Esc", Style::default().fg(theme.warning)),
Span::styled(" cancel", Style::default().fg(theme.hidden)),
]);
frame.render_widget(Paragraph::new(hint), chunks[2]);
}
}
/// Outcome of feeding a key to the dialog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogOutcome {
/// User is still navigating.
Running,
/// User pressed Enter to confirm.
Confirm,
/// User pressed Esc to cancel.
Cancel,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::Key;
#[test]
fn default_is_full_8_bits() {
assert_eq!(DisplayBits::default(), DisplayBits::Full);
}
#[test]
fn labels_match_mc_options() {
assert_eq!(DisplayBits::Full.label(), "Full 8 bits");
assert_eq!(DisplayBits::Seven.label(), "7 bits");
assert_eq!(DisplayBits::Utf8.label(), "UTF-8 (validated)");
}
#[test]
fn from_config_parses_mc_aliases() {
assert_eq!(DisplayBits::from_config("full"), DisplayBits::Full);
assert_eq!(DisplayBits::from_config("7"), DisplayBits::Seven);
assert_eq!(DisplayBits::from_config("7bit"), DisplayBits::Seven);
assert_eq!(DisplayBits::from_config("seven"), DisplayBits::Seven);
assert_eq!(DisplayBits::from_config("utf8"), DisplayBits::Utf8);
assert_eq!(DisplayBits::from_config("UTF-8"), DisplayBits::Utf8);
// Unknown falls back to Full.
assert_eq!(DisplayBits::from_config("garbage"), DisplayBits::Full);
}
#[test]
fn map_byte_strips_high_bit_in_seven_mode() {
assert_eq!(DisplayBits::Full.map_byte(0xFF), 0xFF);
assert_eq!(DisplayBits::Seven.map_byte(0xFF), 0x7F);
assert_eq!(DisplayBits::Seven.map_byte(0x80), 0x00);
assert_eq!(DisplayBits::Seven.map_byte(0x41), 0x41);
}
#[test]
fn is_valid_utf8_works() {
assert!(DisplayBits::is_valid_utf8(b"hello"));
assert!(DisplayBits::is_valid_utf8("héllo".as_bytes()));
assert!(!DisplayBits::is_valid_utf8(&[0xFF, 0xFE]));
}
#[test]
fn dialog_initial_selection_matches_current() {
let d = DisplayBitsDialog::new(DisplayBits::Seven);
assert_eq!(d.selected(), DisplayBits::Seven);
let d2 = DisplayBitsDialog::new(DisplayBits::Utf8);
assert_eq!(d2.selected(), DisplayBits::Utf8);
}
#[test]
fn select_next_wraps() {
let mut d = DisplayBitsDialog::new(DisplayBits::Full);
d.select_next();
assert_eq!(d.selected(), DisplayBits::Seven);
d.select_next();
assert_eq!(d.selected(), DisplayBits::Utf8);
d.select_next();
assert_eq!(d.selected(), DisplayBits::Full); // wrapped
}
#[test]
fn select_prev_wraps() {
let mut d = DisplayBitsDialog::new(DisplayBits::Full);
d.select_prev();
assert_eq!(d.selected(), DisplayBits::Utf8); // wrapped from 0 to last
}
#[test]
fn down_key_selects_next() {
let mut d = DisplayBitsDialog::new(DisplayBits::Full);
assert_eq!(d.handle_key(Key::DOWN), DialogOutcome::Running);
assert_eq!(d.selected(), DisplayBits::Seven);
}
#[test]
fn up_key_selects_prev() {
let mut d = DisplayBitsDialog::new(DisplayBits::Seven);
assert_eq!(d.handle_key(Key::UP), DialogOutcome::Running);
assert_eq!(d.selected(), DisplayBits::Full);
}
#[test]
fn enter_confirms() {
let mut d = DisplayBitsDialog::new(DisplayBits::Utf8);
assert_eq!(d.handle_key(Key::ENTER), DialogOutcome::Confirm);
}
#[test]
fn escape_cancels() {
let mut d = DisplayBitsDialog::new(DisplayBits::Full);
assert_eq!(d.handle_key(Key::ESCAPE), DialogOutcome::Cancel);
}
}
@@ -36,6 +36,7 @@ pub mod move_dialog;
pub mod owner;
pub mod overwrite_dialog;
pub mod error_dialog;
pub mod display_bits_dialog;
pub mod panel;
pub mod panel_options;
pub mod pattern_dialog;
@@ -291,6 +292,8 @@ pub enum DialogState {
Overwrite(Box<overwrite_dialog::OverwriteDialog>),
/// I/O error during a file op — Retry / Skip / Ignore / Abort.
Error(Box<error_dialog::ErrorDialog>),
/// Options → Display bits — 8/7/UTF-8 byte display mode.
DisplayBits(Box<display_bits_dialog::DisplayBitsDialog>),
/// Alt-G — layout options dialog.
Layout(Box<LayoutDialog>),
/// Alt-P — panel options dialog.
@@ -355,6 +358,7 @@ impl DialogState {
DialogState::QuickCd(d) => d.confirmed || d.cancelled,
DialogState::Overwrite(d) => d.finished,
DialogState::Error(d) => d.is_finished(),
DialogState::DisplayBits(_) => false,
// The 3 new options dialogs (Layout/PanelOptions/Config)
// carry no internal finished flag; the dispatcher captures
// their `*Result` from `handle_key` and closes them
@@ -372,6 +376,7 @@ impl DialogState {
DialogState::ScreenList(_) => false,
DialogState::EditHistory(_) => false,
DialogState::FilteredView(_) => false,
DialogState::DisplayBits(_) => false,
DialogState::Compare(_) => false,
DialogState::Chattr(_) => false,
DialogState::PanelFilter(_) => false,
@@ -157,6 +157,7 @@ impl FileManager {
),
DialogState::Overwrite(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Error(d) => d.render(frame, dialog_area, &self.theme),
DialogState::DisplayBits(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Layout(d) => d.render(frame, dialog_area, &self.theme),
DialogState::PanelOptions(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Config(d) => d.render(frame, dialog_area, &self.theme),