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