viewer: hex-edit mode with byte-level edit cursor (Phase 28)

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.
This commit is contained in:
2026-06-20 23:32:54 +03:00
parent 31e7c9d484
commit 79d00e2372
4 changed files with 500 additions and 8 deletions
@@ -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.
///
@@ -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<u8> {
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,
}
}
+317 -7
View File
@@ -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<u8> {
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!(
@@ -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),