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:
@@ -16,7 +16,7 @@ use crate::terminal::color::Theme;
|
|||||||
use crate::terminal::mc_skin;
|
use crate::terminal::mc_skin;
|
||||||
|
|
||||||
/// Bytes per hex row.
|
/// Bytes per hex row.
|
||||||
const BYTES_PER_ROW: u64 = 16;
|
pub const BYTES_PER_ROW: u64 = 16;
|
||||||
|
|
||||||
/// Render the hex view into a frame.
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
pub mod goto;
|
pub mod goto;
|
||||||
pub mod hex;
|
pub mod hex;
|
||||||
|
pub mod hex_edit;
|
||||||
pub mod magic;
|
pub mod magic;
|
||||||
pub mod nroff;
|
pub mod nroff;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
@@ -221,6 +222,100 @@ impl Viewer {
|
|||||||
self.source.size()
|
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> {
|
fn source_preview(&self) -> Vec<u8> {
|
||||||
const PREVIEW_LEN: usize = 4096;
|
const PREVIEW_LEN: usize = 4096;
|
||||||
match &self.source {
|
match &self.source {
|
||||||
@@ -404,16 +499,19 @@ impl Viewer {
|
|||||||
let mode = match self.mode {
|
let mode = match self.mode {
|
||||||
ViewMode::Text => "Text",
|
ViewMode::Text => "Text",
|
||||||
ViewMode::Hex => "Hex",
|
ViewMode::Hex => "Hex",
|
||||||
|
ViewMode::HexEdit => "HexEdit",
|
||||||
};
|
};
|
||||||
let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" };
|
let wrap = if self.wrap { "Wrap:on" } else { "Wrap:off" };
|
||||||
let growing = if self.growing { " Growing" } else { "" };
|
let growing = if self.growing { " Growing" } else { "" };
|
||||||
|
let dirty = if self.hex_edit_modified { " [+]" } else { "" };
|
||||||
format!(
|
format!(
|
||||||
" {} {} {} {}{} ",
|
" {} {} {} {}{}{} ",
|
||||||
crate::locale::t("dialog_title_viewer"),
|
crate::locale::t("dialog_title_viewer"),
|
||||||
self.path.display(),
|
self.path.display(),
|
||||||
mode,
|
mode,
|
||||||
wrap,
|
wrap,
|
||||||
growing
|
growing,
|
||||||
|
dirty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +530,16 @@ impl Viewer {
|
|||||||
self.size(),
|
self.size(),
|
||||||
format_size(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 {
|
match self.mode {
|
||||||
ViewMode::Text => crate::viewer::text::render(self, frame, inner, theme),
|
ViewMode::Text => crate::viewer::text::render(self, frame, inner, theme),
|
||||||
ViewMode::Hex => hex::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 {
|
if chunks[2].height > 0 {
|
||||||
@@ -560,6 +669,7 @@ impl Viewer {
|
|||||||
Some(ViewerPrompt::Search) => " Search: ",
|
Some(ViewerPrompt::Search) => " Search: ",
|
||||||
Some(ViewerPrompt::SearchBackward) => " Search backward: ",
|
Some(ViewerPrompt::SearchBackward) => " Search backward: ",
|
||||||
Some(ViewerPrompt::GotoLine) => " Goto line: ",
|
Some(ViewerPrompt::GotoLine) => " Goto line: ",
|
||||||
|
Some(ViewerPrompt::SaveBeforeQuit) => " Save before quit? (Y/N/Esc) ",
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let text = format!("{label}{}_", self.prompt_input);
|
let text = format!("{label}{}_", self.prompt_input);
|
||||||
@@ -584,6 +694,13 @@ impl Viewer {
|
|||||||
if self.prompt.is_some() {
|
if self.prompt.is_some() {
|
||||||
return self.handle_prompt_key(key);
|
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') {
|
if key == Key::f(10) || key == Key::ctrl('q') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -596,9 +713,25 @@ impl Viewer {
|
|||||||
self.mode = match self.mode {
|
self.mode = match self.mode {
|
||||||
ViewMode::Text => ViewMode::Hex,
|
ViewMode::Text => ViewMode::Hex,
|
||||||
ViewMode::Hex => ViewMode::Text,
|
ViewMode::Hex => ViewMode::Text,
|
||||||
|
ViewMode::HexEdit => ViewMode::Hex,
|
||||||
};
|
};
|
||||||
return false;
|
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) {
|
if key == Key::f(8) {
|
||||||
self.magic_mode = !self.magic_mode;
|
self.magic_mode = !self.magic_mode;
|
||||||
if self.magic_mode {
|
if self.magic_mode {
|
||||||
@@ -609,11 +742,6 @@ impl Viewer {
|
|||||||
}
|
}
|
||||||
return false;
|
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`).
|
// F5 — goto line (MC parity, alias for `g`).
|
||||||
if key == Key::f(5) {
|
if key == Key::f(5) {
|
||||||
self.prompt = Some(ViewerPrompt::GotoLine);
|
self.prompt = Some(ViewerPrompt::GotoLine);
|
||||||
@@ -780,6 +908,8 @@ impl Viewer {
|
|||||||
if key == Key::ESCAPE {
|
if key == Key::ESCAPE {
|
||||||
self.prompt = None;
|
self.prompt = None;
|
||||||
self.prompt_input.clear();
|
self.prompt_input.clear();
|
||||||
|
// Cancelling the SaveBeforeQuit prompt stays in
|
||||||
|
// HexEdit (do NOT clear the dirty flag).
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if key == Key::ENTER {
|
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;
|
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 {
|
if key == Key::BACKSPACE {
|
||||||
self.prompt_input.pop();
|
self.prompt_input.pop();
|
||||||
return false;
|
return false;
|
||||||
@@ -1107,6 +1263,160 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&p);
|
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]
|
#[test]
|
||||||
fn open_next_advances_to_lexically_next_sibling() {
|
fn open_next_advances_to_lexically_next_sibling() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ pub enum SourceError {
|
|||||||
/// Decompressed content exceeded [`MAX_DECOMPRESSED_SIZE`].
|
/// Decompressed content exceeded [`MAX_DECOMPRESSED_SIZE`].
|
||||||
#[error("decompressed size exceeded {} bytes", MAX_DECOMPRESSED_SIZE)]
|
#[error("decompressed size exceeded {} bytes", MAX_DECOMPRESSED_SIZE)]
|
||||||
TooLarge,
|
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.
|
/// Other unspecified error.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
|
|||||||
Reference in New Issue
Block a user