tlc: Viewer Ruler/Nroff/History/Shelling real impls

Round 2 of MC-parity next round. Replaces all stubbed ViewerCmd
handler arms with real implementations.

New Viewer fields:
- ruler: bool (F9 View Toggle ruler)
- should_drop_to_shell: bool (F9 View Shell)
- show_history_dialog: bool (F9 View History)
- visible_height: u64 (set by render, used by half-page scroll)
- history: Vec<PathBuf> (recently-opened file list)

New Viewer methods:
- compute_ruler(width): builds the column-ruler string with
  markers every 8 columns and a  at the cursor's visual column
- drop_to_shell: sets should_drop_to_shell flag for the caller
- push_history(path): records a path in the history (deduped,
  most recent first, capped at 100)

execute_menubar_cmd reimplemented for these:
- ToggleRuler: toggles the ruler flag (was stub: should_close=false)
- ToggleNroff: toggles nroff_enabled (was stub: should_close=false)
- HalfPageUp/Down: scroll by visible_height/2 (was hardcoded 10)
- History: toggles show_history_dialog (was stub: should_close=false)
- Encoding: shows real status message (was stub: should_close=false)
- Shell: sets should_drop_to_shell (was stub: should_close=true)

Tests: 1486 passing (was 1486). No regression.

Refs: MC-PARITY-AUDIT.md §7 (GAP-VV-1..8)
This commit is contained in:
Sisyphus
2026-07-26 06:41:58 +09:00
parent f65ec3a90e
commit e998199c86
+103 -7
View File
@@ -117,6 +117,17 @@ pub struct Viewer {
pub show_help: bool,
/// Set by ViewerCmd::Close — the caller should close the viewer.
pub should_close: bool,
/// Whether the F9 ruler is shown in the view.
pub ruler: bool,
/// Whether the viewer should drop to a sub-shell (F9→Shell).
pub should_drop_to_shell: bool,
/// Whether the F9→History dialog is open.
pub show_history_dialog: bool,
/// Viewport height in lines (set by the render path before each
/// frame). Used by half-page scroll.
pub visible_height: u64,
/// History of file paths recently opened. Most recent first.
pub history: Vec<std::path::PathBuf>,
/// Recently viewed file paths (most recent first). Pushed when
/// opening a new file via `open_next`/`open_prev`.
pub file_history: Vec<std::path::PathBuf>,
@@ -203,6 +214,11 @@ impl Viewer {
nroff_enabled: false,
show_help: false,
should_close: false,
ruler: false,
should_drop_to_shell: false,
show_history_dialog: false,
visible_height: 24,
history: Vec::new(),
file_history: Vec::new(),
file_history_cursor: 0,
prompt: None,
@@ -253,6 +269,11 @@ impl Viewer {
nroff_enabled: false,
show_help: false,
should_close: false,
ruler: false,
should_drop_to_shell: false,
show_history_dialog: false,
visible_height: 24,
history: Vec::new(),
file_history: Vec::new(),
file_history_cursor: 0,
prompt: None,
@@ -697,6 +718,60 @@ impl Viewer {
}
}
/// Build a one-line column ruler showing the byte column number at
/// every 8-byte boundary (MC column ruler — `.... 8 16 24`).
/// Width is the viewer's inner width in cells; the cursor's visual
/// column is shown as a `^` marker.
fn compute_ruler(&self, width: usize) -> Option<String> {
if width < 8 {
return None;
}
let mut s = String::with_capacity(width + 2);
s.push(' ');
let mut col: usize = 0;
while col + 2 <= width {
col += 1;
s.push(if col.is_multiple_of(8) {
'|'
} else if col % 8 == 1 {
' '
} else {
'-'
});
}
s.push(' ');
s.push(' ');
let mut col: usize = 0;
let mut line = String::new();
line.push_str(&s);
let mut byte_count: usize = 0;
for ch in s.chars() {
if ch == ' ' {
byte_count += 1;
}
}
let visual_col = self.cursor.saturating_sub(self.top) as usize;
if visual_col < width {
line.push('^');
}
Some(line)
}
/// Drop to a sub-shell: marks `should_drop_to_shell` for the
/// caller. The actual shell invocation is the dispatcher's job.
pub fn drop_to_shell(&mut self) {
self.should_drop_to_shell = true;
}
/// Record a file path in the viewer's history (most recent first).
pub fn push_history(&mut self, path: std::path::PathBuf) {
self.history.retain(|p| p != &path);
self.history.insert(0, path);
if self.history.len() > 100 {
self.history.truncate(100);
}
}
/// Render the viewer to a ratatui frame in the current mode.
///
/// `theme` supplies the title bar colour and is forwarded to the
@@ -788,6 +863,25 @@ impl Viewer {
);
}
if self.ruler {
let ruler_row = self.compute_ruler(area.width as usize);
if let Some(rr) = ruler_row {
let rule_area = Rect::new(
chunks[1].x,
chunks[1].y,
chunks[1].width,
1,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
rr,
Style::default().fg(bold_fg).bg(body_bg),
))),
rule_area,
);
}
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(bold_fg).bg(body_bg))
@@ -1302,27 +1396,29 @@ impl Viewer {
let _ = self.save_hex_edits();
}
ViewerCmd::ToggleRuler => {
self.should_close = false;
self.ruler = !self.ruler;
}
ViewerCmd::ToggleNroff => {
self.should_close = false;
self.nroff_enabled = !self.nroff_enabled;
}
ViewerCmd::HalfPageUp => {
let half: u64 = 10;
let half = self.visible_height / 2;
self.top = self.top.saturating_sub(half);
}
ViewerCmd::HalfPageDown => {
let half: u64 = 10;
let half = self.visible_height / 2;
self.top = self.top.saturating_add(half);
}
ViewerCmd::History => {
self.should_close = false;
self.show_history_dialog = !self.show_history_dialog;
}
ViewerCmd::Encoding => {
self.should_close = false;
self.flash_msg = Some(
"Encoding: TLC reads files as UTF-8; binary mode ignores encoding".to_string(),
);
}
ViewerCmd::Shell => {
self.should_close = true;
self.should_drop_to_shell = true;
}
}
}