diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index f00eac017c..3c98d0cc51 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -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(), diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs index 2e9d589f08..2b0dc5e6aa 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -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; diff --git a/local/recipes/tui/tlc/source/src/filemanager/display_bits_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/display_bits_dialog.rs new file mode 100644 index 0000000000..505105bffb --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/display_bits_dialog.rs @@ -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 = 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); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index f5734911d5..46c60a877e 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -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), /// I/O error during a file op — Retry / Skip / Ignore / Abort. Error(Box), + /// Options → Display bits — 8/7/UTF-8 byte display mode. + DisplayBits(Box), /// Alt-G — layout options dialog. Layout(Box), /// 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, diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index 2766b71ddd..029cd48600 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -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),