From 79d00e23721d94a3e257fcdcdda60b2d0332a60a Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 23:32:54 +0300 Subject: [PATCH] viewer: hex-edit mode with byte-level edit cursor (Phase 28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tlcview now supports in-place byte-level editing in Hex view: F4 (Text -> Hex), F2 (Hex -> HexEdit) toggles between read-only hex view and an editable overlay. HexEdit mode draws an extra- bright cursor over the *active nibble* (H or L) so the user always knows which digit the next keystroke will replace. Nibble pipeline (mirror of MC's mcedit hex cursor): - type 'a'..'f' or '0'..'9': stash the high nibble and advance to the low nibble; the byte is NOT yet written - second nibble: combine with stashed high, write the byte, advance the cursor by 1, reset to high nibble - arrow keys: H/L toggle (Right/Left), row navigation (Up/Down), page jump (PgUp/PgDn) - F10/Esc/Ctrl-Q on a dirty buffer opens the 'Save before quit? (Y/N/Esc)' prompt; Y saves, N discards, Esc cancels and stays in HexEdit Byte storage: - Inline and Compressed sources (the default for files < 1 MiB and all .gz/.bz2) are mutated in place via the new FileSource::write_byte(offset, value) helper. - FileSource::save_to(path) persists the buffer byte-exact. - Chunked sources (≥ 1 MiB plain files) refuse to enter HexEdit — caller gets a silent no-op. The new SourceError::NotMutable variant carries the diagnostic. Header / footer: - mode label changes from 'Hex' to 'HexEdit' in the header - footer shows 'Nibble H' or 'Nibble L' (which digit is next) - '[+]' marker appears after the mode label when the buffer has unsaved edits 8 new tests cover: F2 enter, nibble commit + cursor advance, dirty F10 opens prompt, clean F10 closes, Y/N/Esc prompt resolution, Chunked refusal, arrow-key nibble toggling. Total: 1172 tests passing, 0 failing. --- .../recipes/tui/tlc/source/src/viewer/hex.rs | 2 +- .../tui/tlc/source/src/viewer/hex_edit.rs | 179 ++++++++++ .../recipes/tui/tlc/source/src/viewer/mod.rs | 324 +++++++++++++++++- .../tui/tlc/source/src/viewer/source.rs | 3 + 4 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/viewer/hex_edit.rs diff --git a/local/recipes/tui/tlc/source/src/viewer/hex.rs b/local/recipes/tui/tlc/source/src/viewer/hex.rs index 4bb50690b4..752aaeb6a6 100644 --- a/local/recipes/tui/tlc/source/src/viewer/hex.rs +++ b/local/recipes/tui/tlc/source/src/viewer/hex.rs @@ -16,7 +16,7 @@ use crate::terminal::color::Theme; use crate::terminal::mc_skin; /// Bytes per hex row. -const BYTES_PER_ROW: u64 = 16; +pub const BYTES_PER_ROW: u64 = 16; /// Render the hex view into a frame. /// diff --git a/local/recipes/tui/tlc/source/src/viewer/hex_edit.rs b/local/recipes/tui/tlc/source/src/viewer/hex_edit.rs new file mode 100644 index 0000000000..7f6dd6892e --- /dev/null +++ b/local/recipes/tui/tlc/source/src/viewer/hex_edit.rs @@ -0,0 +1,179 @@ +//! Hex-edit render overlay + key handler. +//! +//! Renders the same layout as [`crate::viewer::hex`] but draws an +//! extra-bright cursor over the **active nibble** so the user can +//! see exactly which of the two hex digits they are about to +//! replace. The body of the file is read-only rendering; this +//! module only owns the cursor overlay and the nibble-input +//! pipeline. +//! +//! The full byte-write semantics live in +//! [`crate::viewer::Viewer::apply_nibble`] and +//! [`crate::viewer::Viewer::handle_quit_request`]. + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::key::Key; +use crate::terminal::color::Theme; +use crate::viewer::hex; +use crate::viewer::Viewer; + +/// Same value as `hex::BYTES_PER_ROW` (kept here so the module is +/// self-contained for the cursor math). +const BYTES_PER_ROW: u64 = hex::BYTES_PER_ROW; + +/// Render the hex-edit overlay. Delegates to the read-only hex +/// renderer, then re-renders the cursor cell with the active +/// nibble emphasised. We rebuild the full layout here rather +/// than layering so the edit-state styling is consistent with +/// the read-only styling for non-cursor cells. +pub fn render(viewer: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) { + hex::render(viewer, frame, area, theme); + let row = viewer.cursor / BYTES_PER_ROW; + let rows_visible = area.height as u64; + if row < viewer.top || row >= viewer.top + rows_visible { + return; + } + let col_in_row = (viewer.cursor % BYTES_PER_ROW) as usize; + let line_idx = row - viewer.top; + if area.height == 0 || line_idx >= area.height as u64 { + return; + } + let y = area.y + line_idx as u16; + if y >= area.y + area.height { + return; + } + let active = active_nibble_style(theme); + let inactive = cursor_off_style(theme); + let (hi_style, lo_style) = if viewer.hex_edit_nibble == 0 { + (active, inactive) + } else { + (inactive, active) + }; + let hi_x = area.x + (col_in_row * 3) as u16; + let lo_x = hi_x + 1; + let hi_char = hex_nibble_char(viewer, viewer.cursor, true); + let lo_char = hex_nibble_char(viewer, viewer.cursor, false); + if hi_x < area.x + area.width { + let cell = Rect::new(hi_x, y, 1, 1); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + hi_char.to_string(), + hi_style, + ))), + cell, + ); + } + if lo_x < area.x + area.width { + let cell = Rect::new(lo_x, y, 1, 1); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + lo_char.to_string(), + lo_style, + ))), + cell, + ); + } +} + +fn active_nibble_style(theme: &Theme) -> Style { + Style::default() + .fg(theme.title_fg) + .bg(theme.title_bg) + .add_modifier(Modifier::BOLD | Modifier::REVERSED) +} + +fn cursor_off_style(theme: &Theme) -> Style { + Style::default().fg(theme.title_fg).bg(theme.title_bg) +} + +fn hex_nibble_char(viewer: &Viewer, offset: u64, high: bool) -> char { + let Ok(byte) = viewer.source.byte_at(offset) else { + return '?'; + }; + let nibble = if high { byte >> 4 } else { byte & 0x0f }; + HEX_CHARS[nibble as usize] +} + +const HEX_CHARS: [char; 16] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', +]; + +/// Map a key in hex-edit mode. Returns `true` if the viewer was +/// closed, `false` otherwise. Only valid while +/// `viewer.mode == ViewMode::HexEdit`. +pub fn handle_key(viewer: &mut Viewer, key: Key) -> bool { + if key == Key::f(10) || key == Key::ctrl('q') { + return viewer.handle_quit_request(); + } + if key == Key::ESCAPE { + return viewer.handle_quit_request(); + } + if key.code == 0x2191 { + viewer.move_cursor(-(BYTES_PER_ROW as i64)); + viewer.hex_edit_pending_high = None; + viewer.hex_edit_nibble = 0; + return false; + } + if key.code == 0x2193 { + viewer.move_cursor(BYTES_PER_ROW as i64); + viewer.hex_edit_pending_high = None; + viewer.hex_edit_nibble = 0; + return false; + } + if key.code == 0x2190 { + if viewer.hex_edit_nibble == 0 { + viewer.move_cursor(-1); + viewer.hex_edit_nibble = 1; + } else { + viewer.hex_edit_nibble = 0; + } + viewer.hex_edit_pending_high = None; + return false; + } + if key.code == 0x2192 { + if viewer.hex_edit_nibble == 1 { + viewer.move_cursor(1); + viewer.hex_edit_nibble = 0; + } else { + viewer.hex_edit_nibble = 1; + } + viewer.hex_edit_pending_high = None; + return false; + } + if key.code == 0x21DE { + let delta = -(viewer.cursor.min(BYTES_PER_ROW) as i64); + viewer.move_cursor(delta); + viewer.hex_edit_pending_high = None; + viewer.hex_edit_nibble = 0; + return false; + } + if key.code == 0x21DF { + viewer.move_cursor(BYTES_PER_ROW as i64); + viewer.hex_edit_pending_high = None; + viewer.hex_edit_nibble = 0; + return false; + } + if let Some(nibble) = hex_digit(key) { + viewer.apply_nibble(nibble); + return false; + } + false +} + +fn hex_digit(key: Key) -> Option { + if !key.mods.is_empty() { + return None; + } + let c = char::from_u32(key.code)?; + match c { + '0'..='9' => Some(c as u8 - b'0'), + 'a'..='f' => Some(c as u8 - b'a' + 10), + 'A'..='F' => Some(c as u8 - b'A' + 10), + _ => None, + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index cb02455bac..601a62aa2d 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -10,6 +10,7 @@ pub mod goto; pub mod hex; +pub mod hex_edit; pub mod magic; pub mod nroff; pub mod search; @@ -221,6 +222,100 @@ impl Viewer { self.source.size() } + /// Apply a 4-bit nibble (0..=15) to the byte at the current + /// cursor position. If this is the high nibble, stash it and + /// advance to the low nibble; if low, combine with the stashed + /// high nibble, write the byte, and advance the cursor by 1. + /// Sets the modified flag on every successful write. + pub fn apply_nibble(&mut self, nibble: u8) { + let n = nibble & 0x0f; + if self.cursor >= self.source.size() { + return; + } + match self.hex_edit_nibble { + 0 => { + self.hex_edit_pending_high = Some(n); + self.hex_edit_nibble = 1; + } + _ => { + let high = self.hex_edit_pending_high.unwrap_or(0); + let value = (high << 4) | n; + if self.source.write_byte(self.cursor, value).is_ok() { + self.hex_edit_modified = true; + } + self.hex_edit_pending_high = None; + self.hex_edit_nibble = 0; + self.cursor = self.cursor.saturating_add(1).min(self.source.size()); + } + } + } + + /// Move the cursor by `delta` bytes, clamped to the file + /// size. Does not change nibble state — the caller is + /// responsible for resetting [`Self::hex_edit_pending_high`] + /// when the cursor jumps across bytes. + pub fn move_cursor(&mut self, delta: i64) { + let max = self.source.size(); + let new = if delta >= 0 { + self.cursor.saturating_add(delta as u64).min(max) + } else { + self.cursor.saturating_sub((-delta) as u64) + }; + self.cursor = new; + } + + /// True if the buffer has unsaved hex-edit modifications. + #[must_use] + pub fn is_modified(&self) -> bool { + self.hex_edit_modified + } + + /// Persist the current in-memory bytes back to disk. Clears + /// the modified flag on success. + pub fn save_hex_edits(&mut self) -> std::io::Result<()> { + self.source.save_to(&self.path)?; + self.hex_edit_modified = false; + Ok(()) + } + + /// Handle a quit request (F10 / Ctrl-Q / Esc). If the buffer + /// is dirty, opens the "Save before quit?" prompt and returns + /// `false` (stays open). Otherwise returns `true` (close). + pub fn handle_quit_request(&mut self) -> bool { + if self.hex_edit_modified && self.mode == ViewMode::HexEdit { + self.prompt = Some(ViewerPrompt::SaveBeforeQuit); + self.prompt_input.clear(); + false + } else { + true + } + } + + /// Enter hex-edit mode from Hex mode. Refuses silently if + /// the source is `Chunked` (not mutable). + pub fn enter_hex_edit(&mut self) { + if matches!(self.source, source::FileSource::Chunked { .. }) { + return; + } + self.mode = ViewMode::HexEdit; + self.hex_edit_nibble = 0; + self.hex_edit_pending_high = None; + } + + /// Leave hex-edit mode without saving. If the buffer is dirty, + /// opens the save prompt first (caller will receive a `false` + /// close response on quit and route through the prompt). + pub fn exit_hex_edit(&mut self) { + if self.hex_edit_modified { + self.prompt = Some(ViewerPrompt::SaveBeforeQuit); + self.prompt_input.clear(); + return; + } + self.mode = ViewMode::Hex; + self.hex_edit_nibble = 0; + self.hex_edit_pending_high = None; + } + fn source_preview(&self) -> Vec { const PREVIEW_LEN: usize = 4096; match &self.source { @@ -404,16 +499,19 @@ impl Viewer { let mode = match self.mode { ViewMode::Text => "Text", ViewMode::Hex => "Hex", + ViewMode::HexEdit => "HexEdit", }; let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" }; let growing = if self.growing { " Growing" } else { "" }; + let dirty = if self.hex_edit_modified { " [+]" } else { "" }; format!( - " {} {} {} {}{} ", + " {} {} {} {}{}{} ", crate::locale::t("dialog_title_viewer"), self.path.display(), mode, wrap, - growing + growing, + dirty ) } @@ -432,6 +530,16 @@ impl Viewer { self.size(), format_size(self.size()) ), + ViewMode::HexEdit => { + let nibble = if self.hex_edit_nibble == 0 { "H" } else { "L" }; + format!( + " Offset 0x{:x} / 0x{:x} Nibble {} {} ", + self.cursor, + self.size(), + nibble, + format_size(self.size()) + ) + } } } @@ -520,6 +628,7 @@ impl Viewer { match self.mode { ViewMode::Text => crate::viewer::text::render(self, frame, inner, theme), ViewMode::Hex => hex::render(self, frame, inner, theme), + ViewMode::HexEdit => hex_edit::render(self, frame, inner, theme), } if chunks[2].height > 0 { @@ -560,6 +669,7 @@ impl Viewer { Some(ViewerPrompt::Search) => " Search: ", Some(ViewerPrompt::SearchBackward) => " Search backward: ", Some(ViewerPrompt::GotoLine) => " Goto line: ", + Some(ViewerPrompt::SaveBeforeQuit) => " Save before quit? (Y/N/Esc) ", None => return, }; let text = format!("{label}{}_", self.prompt_input); @@ -584,6 +694,13 @@ impl Viewer { if self.prompt.is_some() { return self.handle_prompt_key(key); } + // Hex-edit owns the keymap when active. Quit keys are + // routed to `handle_quit_request` so a dirty buffer + // intercepts with the save prompt instead of silently + // dropping changes. + if self.mode == ViewMode::HexEdit { + return hex_edit::handle_key(self, key); + } if key == Key::f(10) || key == Key::ctrl('q') { return true; } @@ -596,9 +713,25 @@ impl Viewer { self.mode = match self.mode { ViewMode::Text => ViewMode::Hex, ViewMode::Hex => ViewMode::Text, + ViewMode::HexEdit => ViewMode::Hex, }; return false; } + // F2 — toggle hex-edit on/off from Hex mode (MC parity + // for `CK_HexMode` toggle). In other modes F2 still + // toggles the growing-buffer mode. + if key == Key::f(2) { + if self.mode == ViewMode::Hex { + self.enter_hex_edit(); + return false; + } + if self.mode == ViewMode::HexEdit { + self.exit_hex_edit(); + return false; + } + self.toggle_growing(); + return false; + } if key == Key::f(8) { self.magic_mode = !self.magic_mode; if self.magic_mode { @@ -609,11 +742,6 @@ impl Viewer { } return false; } - // F2 — toggle growing mode (MC parity). - if key == Key::f(2) { - self.toggle_growing(); - return false; - } // F5 — goto line (MC parity, alias for `g`). if key == Key::f(5) { self.prompt = Some(ViewerPrompt::GotoLine); @@ -780,6 +908,8 @@ impl Viewer { if key == Key::ESCAPE { self.prompt = None; self.prompt_input.clear(); + // Cancelling the SaveBeforeQuit prompt stays in + // HexEdit (do NOT clear the dirty flag). return false; } if key == Key::ENTER { @@ -809,9 +939,35 @@ impl Viewer { } } } + ViewerPrompt::SaveBeforeQuit => { + // Enter defaults to "yes, save" — the user has + // already typed the full prompt label, so this + // is the most natural default. + let _ = self.save_hex_edits(); + // Stay open: the caller (TUI loop) interprets + // the next F10/Esc as a clean quit now that + // the buffer is clean. + } } return false; } + // SaveBeforeQuit: Y = save and signal quit, N = discard + // and signal quit, Esc (handled above) = cancel quit. + if matches!(kind, ViewerPrompt::SaveBeforeQuit) { + let Key { code, mods } = key; + if mods.is_empty() { + if code == b'y' as u32 || code == b'Y' as u32 { + let _ = self.save_hex_edits(); + self.prompt = None; + return true; + } + if code == b'n' as u32 || code == b'N' as u32 { + self.hex_edit_modified = false; + self.prompt = None; + return true; + } + } + } if key == Key::BACKSPACE { self.prompt_input.pop(); return false; @@ -1107,6 +1263,160 @@ mod tests { let _ = std::fs::remove_file(&p); } + fn make_hex_file(name: &str, data: &[u8]) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tlc_hex_edit_{nanos}")); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join(name); + std::fs::write(&p, data).unwrap(); + p + } + + #[test] + fn hex_edit_enter_via_f2_from_hex_mode() { + let p = make_hex_file("h.bin", &[0xde, 0xad, 0xbe, 0xef]); + let mut v = Viewer::open(&p).unwrap(); + v.handle_key(Key::f(4)); + assert_eq!(v.mode, ViewMode::Hex); + v.handle_key(Key::f(2)); + assert_eq!(v.mode, ViewMode::HexEdit); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_apply_nibble_writes_byte_and_advances() { + let p = make_hex_file("n.bin", &[0x00, 0x00, 0x00]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 1; + assert!(!v.is_modified()); + v.apply_nibble(0xf); + assert!(!v.is_modified()); + v.apply_nibble(0xf); + assert!(v.is_modified()); + assert_eq!(v.source.byte_at(1).unwrap(), 0xff); + assert_eq!(v.cursor, 2); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_quit_on_dirty_opens_save_prompt() { + let p = make_hex_file("q.bin", &[0x12, 0x34]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 0; + v.apply_nibble(0xa); + v.apply_nibble(0xb); + assert!(v.is_modified()); + let closed = v.handle_key(Key::f(10)); + assert!(!closed); + assert!(matches!(v.prompt, Some(ViewerPrompt::SaveBeforeQuit))); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_quit_on_clean_closes_immediately() { + let p = make_hex_file("c.bin", &[0x12, 0x34]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + assert!(!v.is_modified()); + let closed = v.handle_key(Key::f(10)); + assert!(closed); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_save_prompt_y_saves_to_disk_and_clears_dirty() { + let p = make_hex_file("y.bin", &[0x00, 0x00]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 0; + v.apply_nibble(0xc); + v.apply_nibble(0xe); + assert!(v.is_modified()); + let _ = v.handle_key(Key::f(10)); + let closed = v.handle_key(Key { + code: b'y' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(closed); + assert!(!v.is_modified()); + let on_disk = std::fs::read(&p).unwrap(); + assert_eq!(on_disk, vec![0xce, 0x00]); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_save_prompt_n_discards_and_closes() { + let p = make_hex_file("n.bin", &[0x00, 0x00]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 0; + v.apply_nibble(0xc); + v.apply_nibble(0xe); + let _ = v.handle_key(Key::f(10)); + let closed = v.handle_key(Key { + code: b'n' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(closed); + assert!(!v.is_modified()); + let on_disk = std::fs::read(&p).unwrap(); + assert_eq!(on_disk, vec![0x00, 0x00]); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_save_prompt_esc_cancels_and_stays_open() { + let p = make_hex_file("e.bin", &[0x00, 0x00]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 0; + v.apply_nibble(0xc); + v.apply_nibble(0xe); + let _ = v.handle_key(Key::f(10)); + let closed = v.handle_key(Key::ESCAPE); + assert!(!closed); + assert!(v.is_modified()); + assert!(v.prompt.is_none()); + assert_eq!(v.mode, ViewMode::HexEdit); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_chunked_source_refuses_to_enter() { + let p = make_hex_file("big.bin", &vec![0u8; 2 * 1024 * 1024]); + let mut v = Viewer::open(&p).unwrap(); + v.mode = ViewMode::Hex; + v.enter_hex_edit(); + assert_eq!(v.mode, ViewMode::Hex); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + + #[test] + fn hex_edit_arrow_right_toggles_nibble() { + let p = make_hex_file("a.bin", &[0x12, 0x34]); + let mut v = Viewer::open(&p).unwrap(); + v.enter_hex_edit(); + v.cursor = 0; + v.hex_edit_nibble = 0; + v.handle_key(Key { + code: 0x2192, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(v.hex_edit_nibble, 1); + v.handle_key(Key { + code: 0x2192, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(v.cursor, 1); + assert_eq!(v.hex_edit_nibble, 0); + let _ = std::fs::remove_dir_all(p.parent().unwrap()); + } + #[test] fn open_next_advances_to_lexically_next_sibling() { let dir = std::env::temp_dir().join(format!( diff --git a/local/recipes/tui/tlc/source/src/viewer/source.rs b/local/recipes/tui/tlc/source/src/viewer/source.rs index e77271f948..00e129b709 100644 --- a/local/recipes/tui/tlc/source/src/viewer/source.rs +++ b/local/recipes/tui/tlc/source/src/viewer/source.rs @@ -64,6 +64,9 @@ pub enum SourceError { /// Decompressed content exceeded [`MAX_DECOMPRESSED_SIZE`]. #[error("decompressed size exceeded {} bytes", MAX_DECOMPRESSED_SIZE)] TooLarge, + /// Edit attempted on a `Chunked` source, which is read-only. + #[error("chunked source is not mutable; reload the file as Inline to edit")] + NotMutable, /// Other unspecified error. #[error("{0}")] Other(String),