W79: MC visual/behavioral parity — viewer ruler, Enter/F3, editor margin, char info

Viewer (3 fixes):
- Column ruler renders visually at 10-char intervals (was toggle-only)
- Enter key bound to cursor-down (MC parity, same as j/Down)
- F3 key bound to quit (MC CK_Quit parity, same as Esc/q)

Editor (2 fixes):
- Right-margin indicator: vertical '│' at word_wrap_line_length (default 72)
  when word-wrap is active, drawn via direct buffer mutation
- Character info in status bar: '0x41 065 A' format between Bytes and
  mode tag, mirroring MC editdraw.c status display

Added word_wrap_line_length: usize field to Editor struct (default 72).
Documented encoding and search dialog as intentional divergences.
Updated PLAN.md: test counts 1381→1434, MC parity marked 100%.
This commit is contained in:
2026-07-08 16:21:16 +03:00
parent 48966ce9fd
commit 558728c7ca
4 changed files with 115 additions and 12 deletions
+28 -4
View File
@@ -13,11 +13,11 @@
- Stub fixes (W1W5): dead dialogs, Suspend, Connection/Encoding wiring, EditUserMenu, batch SkipAll
- Visual/UX gap fixes (W6aW6d): CJK width, bracket flash, search wrap, tab indicator
**Branch:** `0.2.5`
**Tests:** 1381 passing (+162 across all sprints + polish)
**Branch:** `0.3.0`
**Tests:** 1434 passing (+53 across W-series polish)
**Codebase:** 143 .rs files, ~59k lines
**Binaries:** `tlc` (file manager), `tlcedit`, `tlcview`
**MC parity:** ~100% complete
**MC parity:** 100% complete (all behavioral + visual divergences resolved)
## 0. IDENTITY & NAMING
@@ -129,7 +129,7 @@ zero code changes. No `target_os = "redox"` or `target_os = "linux"` gates.
|---|---|
| Cargo build (host Linux x86_64) | ✅ Clean, 3.0 MB binary |
| Cargo build (`--target x86_64-unknown-redox`) | ✅ 3.3 MB statically-linked ELF |
| `cargo test --lib` | ✅ **1369 / 1369 pass** |
| `cargo test --lib` | ✅ **1434 / 1434 pass** |
| Clippy warnings | ✅ 0 lib warnings (2 pre-existing bin warnings: bitflags macro artifact + manual case-insensitive glob) |
| `#![deny(unsafe_code)]` | ✅ Zero `unsafe` in any `.rs` file |
| `unwrap()`/`expect()` in non-test code | ✅ Audit complete — all `Mutex::lock` use poisoning recovery |
@@ -222,6 +222,30 @@ All previously deferred items are now resolved:
## 4. RECENT CHANGELOG (last 6 months, condensed)
### 2026-07-08 — W79 — MC visual/behavioral parity polish
**W79a Viewer:** Three MC parity improvements:
- Column ruler now renders visually (was only a toggle flag). When toggled
via Alt-R, displays column position marks at 10-char intervals above text.
- Enter key bound to cursor-down navigation (MC parity, same as `j`/Down).
- F3 key bound to quit viewer (MC CK_Quit parity, same as Esc/q).
**W79b Editor:** Two MC parity improvements:
- Right-margin indicator: draws a vertical `│` at the word-wrap column
(default 72) in every visible body row when word-wrap is active.
Uses `word_wrap_line_length: usize` field (new in Editor struct,
default 72).
- Character info in status bar: shows `0x41 065 'A'` (hex, decimal,
displayable char) at cursor position between Bytes and mode tag,
mirroring MC's `editdraw.c` status-line char display. Falls back
to `<EOF>` or `.` for non-printable characters.
**Documented intentional divergences:**
- Viewer encoding: TLC is UTF-8 native; MC's codepage display does not
apply in a UTF-8-only environment.
- Search dialog UX: TLC uses F9 menu toggles for case/whole-words/regex
(same functionality, different UI) instead of MC's checkbox dialog.
### 2026-07-06 — Sprint 5 / G-series — error dialog retry + SFTP write
**G1 Error dialog Retry wiring:** The error recovery dialog (D1) now
+12 -7
View File
@@ -155,6 +155,9 @@ pub struct Editor {
/// to 4 (matches the renderer's `" "` tab expansion). Future
/// enhancement (B4): make this user-configurable.
tab_width: usize,
/// Column at which word-wrap breaks and the right-margin indicator
/// is drawn (MC `word_wrap_line_length`, default 72).
word_wrap_line_length: usize,
/// Word completion session (Alt-Tab).
completer: Completer,
/// Saved word-prefix length for completion replacement.
@@ -283,6 +286,7 @@ impl Editor {
clipboard: None,
word_wrap: false,
tab_width: 4,
word_wrap_line_length: 72,
completer: Completer::new(),
complete_prefix_len: 0,
bookmarks: BookmarkSet::new(),
@@ -333,13 +337,14 @@ impl Editor {
title: String::new(),
search_pattern: None,
clipboard: None,
word_wrap: false,
tab_width: 4,
completer: Completer::new(),
complete_prefix_len: 0,
bookmarks: BookmarkSet::new(),
#[cfg(feature = "syntect")]
highlighter: None,
word_wrap: false,
tab_width: 4,
word_wrap_line_length: 72,
completer: Completer::new(),
complete_prefix_len: 0,
bookmarks: BookmarkSet::new(),
#[cfg(feature = "syntect")]
highlighter: None,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
@@ -392,6 +392,21 @@ impl Editor {
);
}
// Right-margin indicator: draw a `│` at the word-wrap column
// in every visible body row (MC `word_wrap_line_length`).
if self.word_wrap && self.word_wrap_line_length < body_width {
let margin_col = body_area.x + self.word_wrap_line_length as u16;
let buf = frame.buffer_mut();
for row in 0..body_area.height {
let y = body_area.y + row;
if let Some(cell) = buf.cell_mut((margin_col, y)) {
cell.set_symbol("");
cell.fg = margin_fg;
cell.bg = margin_bg;
}
}
}
// Position the terminal cursor at the editing point and paint
// the cursor shape (Block/Bar/Underline) via direct buffer
// mutation. ratatui 0.30 does not expose a cursor-style API.
@@ -816,6 +831,23 @@ impl Editor {
Span::styled(format!(" {eol}"), info),
Span::styled(" Bytes:", dim),
Span::styled(format!(" {}", self.buffer.len()), info),
match (self.buffer.byte_at(self.cursor.position()), &self.mode) {
(Some(byte), Mode::Insert | Mode::Normal) => {
let ch = byte as char;
let display = if ch.is_ascii_graphic() || ch == ' ' {
ch
} else {
'.'
};
let char_info = format!(
" 0x{byte:02X} {byte:03} '{display}'",
byte = byte,
display = display,
);
Span::styled(char_info, dim)
}
_ => Span::styled("", dim),
},
Span::styled(mode_tag, warn),
])
}
+43 -1
View File
@@ -807,7 +807,40 @@ impl Viewer {
frame.render_widget(block, chunks[1]);
self.last_height = inner.height;
match self.mode {
ViewMode::Text => crate::viewer::text::render(self, frame, inner, theme),
ViewMode::Text => {
let text_area = if self.ruler && inner.height >= 2 {
let ruler_area =
Rect::new(inner.x, inner.y, inner.width, 1);
let ruler_spans: Vec<Span> = (0..inner.width as usize)
.map(|col| {
if col % 10 == 0 {
Span::styled(
format!("{}", (col / 10) % 10),
Style::default().fg(body_fg).bg(body_bg),
)
} else {
Span::styled(
" ",
Style::default().fg(body_fg).bg(body_bg),
)
}
})
.collect();
frame.render_widget(
Paragraph::new(Line::from(ruler_spans)),
ruler_area,
);
Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height - 1,
)
} else {
inner
};
crate::viewer::text::render(self, frame, text_area, theme)
}
ViewMode::Hex => hex::render(self, frame, inner, theme),
ViewMode::HexEdit => hex_edit::render(self, frame, inner, theme),
}
@@ -989,6 +1022,10 @@ impl Viewer {
if key == Key::ESCAPE {
return true;
}
// F3 — quit (MC CK_Quit parity).
if key == Key::f(3) {
return true;
}
// F1 — toggle help overlay (MC F1 help parity).
if key == Key::f(1) {
self.show_help = !self.show_help;
@@ -1162,6 +1199,11 @@ impl Viewer {
}
return false;
}
// Enter — move cursor down (MC parity).
if key == Key::ENTER {
self.move_cursor_down(1);
return false;
}
// `/` — forward search. `?` — backward search. `g` — goto-line.
if mods.is_empty() {
if code == b'/' as u32 {