tlc: tlcedit/tlcview full MC parity — E1-E5 (21 features, +2525 lines)

E1 — Wire broken features:
- Auto-indent on Enter (insert_newline_with_indent)
- Shift-Arrow selection bindings (6 keys)
- Ctrl-Home/Ctrl-End document navigation
- Nroff rendering in viewer (man page bold/underline)
- Viewer search (/) and goto-line (g) prompts
- Macro recording/playback (Ctrl-R/Ctrl-P)
- Search history dedup fix + n/N navigation

E2 — Editor visual premium:
- Vertical scrollbar (direct buffer manipulation)
- Accent-bar gutter (brand red stripe)
- Relative line numbers (Ctrl-L toggle)
- Cursor shape modes (Block/Bar/Underline)
- Completion popup with accent highlight

E3 — Functional parity:
- Word-wrap toggle (Alt-W)
- Viewer syntax highlighting (syntect integration)
- OSC 52 clipboard (SSH clipboard sharing)
- Save As prompt (Shift-F2)

E4 — Advanced features:
- Code folding (folding.rs, Ctrl-F1 toggle, gutter markers)
- Tags jump (tags.rs, Ctrl-]/Ctrl-T, TagTable parser)
- Replace per-match state infrastructure

E5 — Premium transitions:
- Dialog slide-in animation ( FileManager dialog_anim)
- Large-file loading indicator (spinner for >1MiB)
- Smooth scroll interpolation (PageUp/PageDown, 25%/tick)

New modules: cursor_shape.rs, folding.rs, tags.rs, clipboard_osc52.rs
Tests: 1093 passed (up from 1062)
This commit is contained in:
2026-06-20 02:13:17 +03:00
parent b4a0d68f66
commit c55fb91e8f
18 changed files with 2526 additions and 143 deletions
+10 -1
View File
@@ -81,7 +81,16 @@ impl Application {
fm.sync_animations();
fm.spinner.tick();
let toast_active = fm.toasts.tick();
if fm.spinner.is_active() || toast_active || fm.panel_switch_anim < 100 {
// Advance any in-flight editor smooth-scroll
// animation by one tick. The handler returns true
// while the animation is in flight; we treat that
// as a reason to re-render below.
let editor_scrolling = if let Some(ed) = fm.editor.as_mut() {
ed.tick_smooth_scroll()
} else {
false
};
if fm.spinner.is_active() || toast_active || fm.panel_switch_anim < 100 || editor_scrolling {
render(&mut tui, &mut fm)?;
}
continue;
@@ -406,6 +406,24 @@ impl Buffer {
end - start
}
/// Length in characters of `line` (0-based), excluding any
/// trailing newline. Returns 0 for lines past the end. Used by
/// the word-wrap renderer to count visual width of a line for
/// wrap-point calculation.
#[must_use]
pub fn line_length_chars(&self, line: usize) -> usize {
let bytes_len = self.line_length(line);
if bytes_len == 0 {
return 0;
}
let start = self.line_offset(line);
let bytes = self.to_bytes();
let slice = bytes.get(start..start + bytes_len).unwrap_or(&[]);
std::str::from_utf8(slice)
.map(|s| s.chars().count())
.unwrap_or_else(|_| bytes_len)
}
/// Begin an undo group. While a group is open, additional edits
/// are coalesced into a single undo record. Pair with
/// [`Buffer::end_undo_group`].
@@ -0,0 +1,302 @@
//! OSC 52 terminal clipboard integration.
//!
//! OSC 52 is a terminal escape sequence that lets a TUI read and
//! write the host terminal's clipboard over the standard output
//! stream. Most modern terminal emulators (xterm, kitty, alacritty,
//! wezterm, iTerm2, recent gnome-terminal builds) implement at
//! least the read/write half of OSC 52.
//!
//! Use cases:
//!
//! * **Paste from terminal** — when Ctrl-V finds no internal
//! clipboard, the editor can query the host terminal for the
//! current selection. The terminal replies with the base64-encoded
//! clipboard payload, which we decode and insert.
//! * **Copy to terminal** — when Ctrl-C / F5 fills the internal
//! clipboard, we can also broadcast the same payload to the
//! terminal's selection. This makes clipboard-sharing work over
//! SSH sessions (where the host and remote clipboards are
//! otherwise disconnected).
//!
//! ## Failure mode
//!
//! OSC 52 is terminal-dependent. The functions in this module
//! silently return `None` / no-op on any I/O failure: an
//! unresponsive terminal must never block the editor. Callers are
//! expected to fall back to the internal clipboard when these
//! helpers return nothing.
//!
//! ## Platform
//!
//! OSC 52 is wired into stdout via raw `write(2)` calls. On
//! non-Unix targets the module compiles to no-ops (we gate on
//! `cfg(unix)`).
//!
//! ## Base64
//!
//! We bundle a tiny base64 encoder/decoder because the project
//! policy forbids adding new dependencies. The implementation
//! follows RFC 4648 §4 with `=` padding and `+/` alphabet.
//! Decoding tolerates missing padding (some terminals omit it).
use std::io::Write;
/// Standard base64 alphabet (RFC 4648 §4).
const B64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Encode `data` as base64 (RFC 4648 §4, padded).
#[must_use]
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
let mut i = 0;
while i + 3 <= data.len() {
let b0 = data[i];
let b1 = data[i + 1];
let b2 = data[i + 2];
out.push(B64_ALPHABET[(b0 >> 2) as usize] as char);
out.push(B64_ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(B64_ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char);
out.push(B64_ALPHABET[(b2 & 0x3f) as usize] as char);
i += 3;
}
let rem = data.len() - i;
if rem == 1 {
let b0 = data[i];
out.push(B64_ALPHABET[(b0 >> 2) as usize] as char);
out.push(B64_ALPHABET[((b0 & 0x03) << 4) as usize] as char);
out.push('=');
out.push('=');
} else if rem == 2 {
let b0 = data[i];
let b1 = data[i + 1];
out.push(B64_ALPHABET[(b0 >> 2) as usize] as char);
out.push(B64_ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
out.push(B64_ALPHABET[((b1 & 0x0f) << 2) as usize] as char);
out.push('=');
}
out
}
/// Decode a base64 string (RFC 4648 §4, padded or unpadded).
/// Returns `None` if the input contains characters outside the
/// standard alphabet.
#[must_use]
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let mut buf: u32 = 0;
let mut bits: u32 = 0;
for &b in bytes {
let v: u32 = match b {
b'A'..=b'Z' => (b - b'A') as u32,
b'a'..=b'z' => (b - b'a' + 26) as u32,
b'0'..=b'9' => (b - b'0' + 52) as u32,
b'+' => 62,
b'/' => 63,
b'=' => continue,
_ => return None,
};
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xff) as u8);
buf &= (1 << bits) - 1;
}
}
Some(out)
}
/// Send `text` to the host terminal's clipboard via OSC 52.
///
/// Builds the sequence `\x1b]52;c;<base64>\x07`, writes it to
/// stdout, and flushes. Returns `true` if the bytes were accepted
/// by the OS (which does NOT mean the terminal emulator will act
/// on them — OSC 52 is advisory).
///
/// Silently no-ops on non-Unix targets.
pub fn osc52_copy(text: &str) -> bool {
#[cfg(unix)]
{
let encoded = base64_encode(text.as_bytes());
let seq = format!("\x1b]52;c;{encoded}\x07");
let stdout = std::io::stdout();
let mut handle = stdout.lock();
match handle.write_all(seq.as_bytes()) {
Ok(_) => handle.flush().is_ok(),
Err(_) => false,
}
}
#[cfg(not(unix))]
{
let _ = text;
false
}
}
/// Build the OSC 52 read query bytes (c ?).
fn query_bytes() -> Vec<u8> {
b"\x1b]52;c;?\x07".to_vec()
}
/// Decode an OSC 52 read response from `buf`. The buffer may
/// contain leading noise from other stdin events; we look for the
/// `\x1b]52;c;` prefix and stop at the BEL terminator. Returns
/// `None` if the prefix is missing, the payload is `?` (terminal
/// cannot serve), or the payload is not valid UTF-8 base64.
#[must_use]
pub fn decode_osc52_response(buf: &[u8]) -> Option<Vec<u8>> {
let needle: &[u8] = b"\x1b]52;c;";
let start = buf.windows(needle.len()).position(|w| w == needle)?;
let after = &buf[start + needle.len()..];
let end = after.iter().position(|&b| b == 0x07)?;
let payload = &after[..end];
if payload == b"?" {
return None;
}
let s = std::str::from_utf8(payload).ok()?;
base64_decode(s)
}
/// Request the host terminal's current clipboard via OSC 52 and
/// return the decoded bytes. Returns `None` if the read or the
/// terminal's reply fails.
///
/// **Note:** This is a blocking read on stdin. Callers must use
/// this only when they have explicit user consent (e.g. they just
/// pressed Ctrl-V). Implementations typically reply within a few
/// milliseconds, but a slow terminal could hang the editor.
#[cfg(unix)]
pub fn osc52_paste() -> Option<String> {
use std::io::Read;
let query = query_bytes();
{
let stdout = std::io::stdout();
let mut out = stdout.lock();
if out.write_all(&query).is_err() {
return None;
}
let _ = out.flush();
}
let stdin = std::io::stdin();
let mut buf = Vec::with_capacity(4096);
let mut chunk = [0u8; 256];
loop {
match stdin.lock().read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.contains(&0x07) {
break;
}
// Cap the read window so an unresponsive terminal
// never hangs the editor.
if buf.len() > 64 * 1024 {
break;
}
}
Err(_) => return None,
}
}
let decoded = decode_osc52_response(&buf)?;
String::from_utf8(decoded).ok()
}
#[cfg(not(unix))]
pub fn osc52_paste() -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_round_trip_ascii() {
let cases: &[&[u8]] = &[
b"",
b"a",
b"ab",
b"abc",
b"abcd",
b"hello world",
b"The quick brown fox jumps over the lazy dog",
];
for input in cases {
let encoded = base64_encode(input);
let decoded = base64_decode(&encoded).expect("decode ok");
assert_eq!(&decoded[..], *input, "round-trip failed for {:?}", input);
}
}
#[test]
fn base64_known_vectors() {
// Test vectors from RFC 4648 §10.
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn base64_decode_unpadded_input() {
assert_eq!(base64_decode("Zg").expect("ok"), b"f");
assert_eq!(base64_decode("Zm8").expect("ok"), b"fo");
assert_eq!(base64_decode("Zm9v").expect("ok"), b"foo");
}
#[test]
fn base64_decode_invalid_input_returns_none() {
assert!(base64_decode("Zm9v!").is_none());
assert!(base64_decode("hello world").is_none());
}
#[test]
fn base64_handles_binary_data() {
let bytes: Vec<u8> = (0..=255).collect();
let encoded = base64_encode(&bytes);
let decoded = base64_decode(&encoded).expect("decode ok");
assert_eq!(decoded, bytes);
}
#[test]
fn decode_osc52_response_basic() {
// Encode "hello" via base64 and wrap in the OSC 52 prefix.
let payload = base64_encode(b"hello");
let mut buf = Vec::new();
buf.extend_from_slice(b"\x1b]52;c;");
buf.extend_from_slice(payload.as_bytes());
buf.push(0x07);
let decoded = decode_osc52_response(&buf).expect("ok");
assert_eq!(decoded, b"hello");
}
#[test]
fn decode_osc52_response_tolerates_leading_noise() {
let payload = base64_encode(b"x");
let mut buf = Vec::new();
buf.extend_from_slice(b"mouse-event-bytes\x1b[0m");
buf.extend_from_slice(b"\x1b]52;c;");
buf.extend_from_slice(payload.as_bytes());
buf.push(0x07);
let decoded = decode_osc52_response(&buf).expect("ok");
assert_eq!(decoded, b"x");
}
#[test]
fn decode_osc52_response_question_mark_returns_none() {
let mut buf = Vec::new();
buf.extend_from_slice(b"\x1b]52;c;?\x07");
assert!(decode_osc52_response(&buf).is_none());
}
#[test]
fn decode_osc52_response_missing_prefix_returns_none() {
let buf = b"random noise without the prefix\x07";
assert!(decode_osc52_response(buf).is_none());
}
}
@@ -90,6 +90,13 @@ impl Completer {
})
}
/// Return the index of the currently highlighted candidate.
/// Returns 0 when there are no candidates.
#[must_use]
pub fn current_index(&self) -> usize {
self.idx
}
/// Cycle to the next candidate. Returns the new current candidate.
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Option<Completion> {
@@ -0,0 +1,26 @@
//! Cursor shape modes for the editor.
//!
//! Three shapes are supported:
//!
//! * [`CursorShape::Block`] — a solid block filling the cell at the
//! cursor (vim Normal mode default).
//! * [`CursorShape::Bar`] — a thin vertical bar in the leftmost
//! column of the cursor cell (vim Insert mode default).
//! * [`CursorShape::Underline`] — an underscore at the bottom of the
//! cursor cell (vim Replace mode default).
//!
//! ratatui 0.30 does not expose a built-in cursor-shape API on
//! `Frame`; the editor renders the shape by directly mutating the
//! cursor cell's symbol and style.
/// Visual shape of the editing cursor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum CursorShape {
/// Solid block filling the cell — vim Normal mode.
#[default]
Block,
/// Thin vertical bar in the leftmost column — vim Insert mode.
Bar,
/// Underscore at the bottom of the cell — vim Replace mode.
Underline,
}
@@ -0,0 +1,157 @@
//! Code folding for the editor.
//!
//! A fold is a contiguous range of lines that the user has
//! collapsed into a single visible row. The gutter shows a `-`
//! for the start line of a folded region and a `+` for foldable
//! but currently-expanded regions.
//!
//! Folds are stored as half-open `(start_line, end_line_excl)`
//! pairs. `end_line_excl` is exclusive — a fold covering exactly
//! lines 3..7 covers the four lines `[3, 4, 5, 6]`. Collapsing a
//! fold hides every line in the range except the start line; the
//! renderer paints a `+` marker on the start line's gutter entry
//! when the fold is collapsed.
use std::collections::BTreeMap;
/// A single code fold: a half-open `[start, end)` line range that
/// the user has marked. The `end` value is exclusive — line `end`
/// is NOT part of the fold.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fold {
/// First line of the fold (inclusive).
pub start: usize,
/// First line AFTER the fold (exclusive).
pub end: usize,
}
impl Fold {
/// True if `line` falls inside this fold's covered range,
/// NOT counting the visible start line.
#[must_use]
pub fn covers_hidden(&self, line: usize) -> bool {
line > self.start && line < self.end
}
}
/// A collection of folds, keyed by start line for O(log n)
/// lookup. The collection supports toggling folds at a cursor
/// line, listing which line should be visible at any given
/// visual row, and collapsing a range on the fly.
#[derive(Debug, Clone, Default)]
pub struct FoldSet {
/// Folds keyed by start line. A `BTreeMap` keeps iteration
/// deterministic (handy for tests and reproducible rendering).
folds: BTreeMap<usize, Fold>,
}
impl FoldSet {
/// Create an empty fold set.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of folds currently tracked.
#[must_use]
pub fn len(&self) -> usize {
self.folds.len()
}
/// True when no folds are tracked.
#[must_use]
pub fn is_empty(&self) -> bool {
self.folds.is_empty()
}
/// Iterate over all folds (ordered by start line).
pub fn iter(&self) -> impl Iterator<Item = &Fold> {
self.folds.values()
}
/// Toggle a fold anchored at `line`. If a fold already exists
/// at that start line, it is removed. Otherwise, a fold of
/// size `span` is created (covering `[line, line + span)`).
/// A span of 0 is a no-op.
pub fn toggle(&mut self, line: usize, span: usize) {
if span == 0 {
return;
}
if self.folds.contains_key(&line) {
self.folds.remove(&line);
return;
}
self.folds.insert(line, Fold { start: line, end: line + span });
}
/// True if `line` is the start of an existing fold (visible
/// marker row).
#[must_use]
pub fn is_fold_start(&self, line: usize) -> bool {
self.folds.contains_key(&line)
}
/// True if `line` is hidden by any fold (i.e. is between a
/// fold's start and end, exclusive of the start).
#[must_use]
pub fn is_hidden(&self, line: usize) -> bool {
self.folds.values().any(|f| f.covers_hidden(line))
}
/// All folds (cloned, ordered by start line).
#[must_use]
pub fn folds(&self) -> Vec<Fold> {
self.folds.values().copied().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_is_empty() {
let f = FoldSet::new();
assert!(f.is_empty());
assert_eq!(f.len(), 0);
}
#[test]
fn toggle_creates_then_removes() {
let mut f = FoldSet::new();
f.toggle(3, 4);
assert_eq!(f.len(), 1);
assert!(f.is_fold_start(3));
f.toggle(3, 4);
assert_eq!(f.len(), 0);
assert!(!f.is_fold_start(3));
}
#[test]
fn zero_span_is_noop() {
let mut f = FoldSet::new();
f.toggle(0, 0);
assert!(f.is_empty());
}
#[test]
fn covers_hidden_excludes_start() {
let mut f = FoldSet::new();
f.toggle(3, 4);
assert!(!f.is_hidden(3)); // start visible
assert!(f.is_hidden(4));
assert!(f.is_hidden(5));
assert!(f.is_hidden(6));
assert!(!f.is_hidden(7)); // end exclusive
}
#[test]
fn folds_returns_sorted() {
let mut f = FoldSet::new();
f.toggle(5, 2);
f.toggle(1, 2);
let folds = f.folds();
assert_eq!(folds[0].start, 1);
assert_eq!(folds[1].start, 5);
}
}
@@ -33,6 +33,44 @@ impl Editor {
/// mode handles its own keys (Y / N / Esc / Enter depending
/// on the active prompt).
pub(crate) fn handle_key(&mut self, key: Key) -> EditorResult {
// Ctrl-R / Ctrl-P are intercepted at the dispatcher so
// they work in BOTH Normal and Insert modes (mirroring the
// Save / Close shortcut behaviour). The macro recorder
// records everything EXCEPT Ctrl-R / Ctrl-P themselves to
// avoid capturing the toggle key as part of the macro.
if key == Key::ctrl('r') && !self.mode.is_prompt() {
if self.macro_recorder.is_recording() {
if let Some((_name, keys)) = self.macro_recorder.stop_recording() {
self.last_macro = keys;
self.message = Some(format!("Macro stopped ({} keys)", self.last_macro.len()));
}
} else {
let _ = self.macro_recorder.start_recording("session".to_string());
self.last_macro.clear();
self.message = Some("Recording macro… (Ctrl-R to stop)".to_string());
}
return EditorResult::Running;
}
if key == Key::ctrl('p') && !self.mode.is_prompt() && !self.last_macro.is_empty() {
// Replay by recursing through handle_key for each
// recorded event. We clone the macro to avoid borrowing
// self while calling self.handle_key.
let keys = self.last_macro.clone();
for nk in keys {
if let Some(k) = named_key_to_key(&nk) {
let _ = self.handle_key(k);
}
}
self.message = Some(format!("Macro played ({} keys)", self.last_macro.len()));
return EditorResult::Running;
}
// Record the key AFTER macro-control handling, so the
// Ctrl-R / Ctrl-P toggles themselves are not captured.
if self.macro_recorder.is_recording() {
if let Some(nk) = key_to_named_key(&key) {
self.macro_recorder.record_key(nk);
}
}
// Prompt mode owns its own keymap (Y/N/Esc/Enter for
// SaveBeforeClose; reserved for other kinds).
if self.mode.is_prompt() {
@@ -55,6 +93,21 @@ impl Editor {
if key == Key::ctrl('s') || key == Key::f(2) {
return EditorResult::Save;
}
// Shift-F2 — Save As: open the SaveAs prompt so the user
// can type a new path. The actual write happens on Enter
// inside the prompt's commit handler.
if key.mods == Modifiers::SHIFT && key.code == Key::f(2).code {
return self.open_prompt(PromptKind::SaveAs);
}
if key == Key::ctrl('l') {
self.relative_lines = !self.relative_lines;
self.message = Some(if self.relative_lines {
"Relative line numbers: ON".to_string()
} else {
"Relative line numbers: OFF".to_string()
});
return EditorResult::Running;
}
// Alt-letter prompt shortcuts work from any non-prompt mode.
if let Some(r) = self.try_global_shortcut(key) {
return r;
@@ -114,6 +167,15 @@ impl Editor {
self.format_paragraph();
return Some(EditorResult::Running);
}
0x77 => {
let on = self.toggle_word_wrap();
self.message = Some(if on {
"Word wrap: ON".to_string()
} else {
"Word wrap: OFF".to_string()
});
return Some(EditorResult::Running);
}
_ => {}
}
}
@@ -141,7 +203,12 @@ impl Editor {
return EditorResult::Running;
}
if key == Key::ENTER {
self.insert_char('\n');
// Auto-indent: copy previous line's whitespace, plus an
// extra level if the previous line ends with `{[(:`.
let pos = self.buffer.cursor();
crate::editor::format::insert_newline_with_indent(&mut self.buffer, pos, 4, false);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
return EditorResult::Running;
}
if key == Key::TAB {
@@ -165,6 +232,10 @@ impl Editor {
if key == Key::f(5) {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
self.clipboard = Some(text.to_string());
// Mirror to the host terminal via OSC 52 so the
// selection is available to other apps on the
// host (and over SSH). Failures are silent.
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.message = Some("Block copied".to_string());
}
return EditorResult::Running;
@@ -173,6 +244,7 @@ impl Editor {
if key == Key::f(6) {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
self.clipboard = Some(text.to_string());
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.cursor.delete_selection(&mut self.buffer);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
@@ -190,15 +262,86 @@ impl Editor {
}
return EditorResult::Running;
}
// Ctrl-VPaste from internal clipboard.
// Ctrl-F1toggle a code fold at the cursor line. The
// fold spans the next 10 lines by default; a future
// language-aware impl will derive the span from
// indentation. We match Ctrl+F1 by comparing the raw
// code/modifier pair rather than building a Key constant,
// because `Key::ctrl` takes a `char` and F1 is a private-
// use code point, not a letter.
if key.code == Key::f(1).code && key.mods.contains(Modifiers::CTRL) {
let line = self.buffer_line_of(self.cursor.position());
self.folds.toggle(line, 10);
self.message = Some(if self.folds.is_fold_start(line) {
format!("Fold opened at line {}", line + 1)
} else {
format!("Fold removed at line {}", line + 1)
});
return EditorResult::Running;
}
// Ctrl-] — jump to tag under cursor. The word containing
// the cursor is taken as the tag name and resolved against
// the `tags` file in the buffer's parent directory.
if key == Key::ctrl(']') {
let name = word_at_cursor(&self.buffer, self.cursor.position());
if name.is_empty() {
self.message = Some("No tag name under cursor".to_string());
} else if let Err(e) = self.jump_to_tag(&name) {
self.message = Some(e);
}
return EditorResult::Running;
}
// Ctrl-T — pop the most recent tag-stack entry.
if key == Key::ctrl('t') {
self.pop_tag();
return EditorResult::Running;
}
// Ctrl-V — Paste from internal clipboard, falling back to
// the host terminal's selection via OSC 52 if the
// internal clipboard is empty.
if key == Key::ctrl('v') {
if let Some(text) = self.clipboard.clone() {
self.insert_str(&text);
self.message = Some("Pasted".to_string());
} else if let Some(text) = crate::editor::clipboard_osc52::osc52_paste() {
self.insert_str(&text);
self.message = Some("Pasted from terminal".to_string());
} else {
self.message = Some("Clipboard empty".to_string());
}
return EditorResult::Running;
}
match key {
// Shift-modified selection: SHIFT alone (no CTRL) extends
// the selection; SHIFT+CTRL is left to the plain
// word-movement arms below for a future Ctrl+Shift+Word
// feature.
Key { code: 0x2190, mods } if mods == Modifiers::SHIFT => {
self.cursor.select_left(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2192, mods } if mods == Modifiers::SHIFT => {
self.cursor.select_right(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2191, mods } if mods.contains(Modifiers::SHIFT) => {
self.cursor.start_selection();
self.cursor.move_up(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2193, mods } if mods.contains(Modifiers::SHIFT) => {
self.cursor.start_selection();
self.cursor.move_down(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2196, mods } if mods.contains(Modifiers::SHIFT) && !mods.contains(Modifiers::CTRL) => {
self.cursor.select_to_home(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2198, mods } if mods.contains(Modifiers::SHIFT) && !mods.contains(Modifiers::CTRL) => {
self.cursor.select_to_end(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2190, mods } if mods.is_empty() => {
self.cursor.move_left(&mut self.buffer);
EditorResult::Running
@@ -223,6 +366,18 @@ impl Editor {
self.cursor.move_down(&mut self.buffer);
EditorResult::Running
}
// Ctrl-Home / Ctrl-End: jump to start / end of buffer.
Key { code: 0x2196, mods } if mods.contains(Modifiers::CTRL) => {
self.buffer.set_cursor(0);
self.cursor.set_position(0, &self.buffer);
EditorResult::Running
}
Key { code: 0x2198, mods } if mods.contains(Modifiers::CTRL) => {
let end = self.buffer.len();
self.buffer.set_cursor(end);
self.cursor.set_position(end, &self.buffer);
EditorResult::Running
}
Key { code: 0x21A1, .. } => {
self.cursor.move_home(&mut self.buffer);
EditorResult::Running
@@ -232,10 +387,18 @@ impl Editor {
EditorResult::Running
}
Key { code: 0x21DE, .. } => {
// Smooth-scroll the view by 20 lines; the cursor
// jumps instantly and the viewport animates toward
// its new position.
let new_top = self.effective_top_line().saturating_sub(20);
self.begin_smooth_scroll(new_top);
self.cursor.move_page_up(&mut self.buffer, 20);
EditorResult::Running
}
Key { code: 0x21DF, .. } => {
let max = self.buffer.line_count().saturating_sub(1);
let new_top = (self.effective_top_line() + 20).min(max);
self.begin_smooth_scroll(new_top);
self.cursor.move_page_down(&mut self.buffer, 20);
EditorResult::Running
}
@@ -279,6 +442,48 @@ impl Editor {
/// itself accepts printable characters, Backspace, and the
/// usual cursor / home / end keys.
fn handle_text_prompt(&mut self, key: Key) -> EditorResult {
// If the search-history popup is open, route keys to it
// exclusively: Up/Down move the selection, Enter commits,
// Esc cancels.
if self.history_popup_selected.is_some() {
if key == Key::ESCAPE {
self.close_history_popup();
return EditorResult::Running;
}
if key == Key::ENTER {
if let Some(entry) = self.history_popup_selected_entry() {
self.prompt_input.text = entry.clone();
self.prompt_input.cursor = self.prompt_input.text.len();
// Also seed the search engine pattern so the
// user can immediately press Enter to run the
// search.
self.search.set_pattern(entry);
}
self.close_history_popup();
return EditorResult::Running;
}
if key.mods.is_empty() {
match key.code {
0x2191 => {
self.history_popup_up();
return EditorResult::Running;
}
0x2193 => {
self.history_popup_down();
return EditorResult::Running;
}
_ => {}
}
}
}
// Alt-/ opens the search-history popup when the active
// prompt is Find or Replace. Other prompt kinds ignore it.
if key.mods == Modifiers::ALT && key.code == b'/' as u32 {
if let Mode::Prompt(PromptKind::Find | PromptKind::Replace) = self.mode {
self.open_history_popup();
return EditorResult::Running;
}
}
// Esc cancels the prompt without committing.
if key == Key::ESCAPE {
self.prompt_input.clear();
@@ -413,18 +618,28 @@ impl Editor {
}
}
PromptKind::Find | PromptKind::Replace => {
// Find/Replace store the pattern; the live
// Find/Replace store the pattern in the search
// engine and push it onto the history. The live
// highlight UI is Phase 5d.
self.search.set_pattern(text.to_string());
self.search.push_history();
self.search_pattern = Some(text.to_string());
// Run the search so `n` / `N` work after commit.
let from = self.cursor.position();
let _ = self.search.find_next(&self.buffer, from);
if let Some(m) = self.search.last_match() {
self.buffer.set_cursor(m.start);
self.cursor.set_position(m.start, &self.buffer);
}
}
PromptKind::SaveAs => {
// SaveAs is a two-step: the user types a path in
// the prompt; on commit we either write the
// buffer to that path (full save) or just record
// the path (deferred save). For v1 we record the
// path: the next `Save` action uses it. We do
// NOT touch the filesystem here.
self.path = Some(std::path::PathBuf::from(text));
// Save As: write the buffer to the new path. On
// success, update `path` and clear the modified
// flag (handled by `save_as`). On failure, leave
// the state untouched and surface a status message.
if let Err(e) = self.save_as(text) {
self.message = Some(format!("Save As failed: {e}"));
}
}
PromptKind::SaveBeforeClose => {
// The save-before-close prompt routes through
@@ -549,3 +764,112 @@ impl Editor {
}
}
}
/// Convert a serializable [`NamedKey`] back into the runtime
/// [`Key`] used by [`Editor::handle_key`]. Always returns `Some`
/// because every variant of `NamedKey` has a direct runtime
/// counterpart — the `Option` is kept for forward compatibility
/// with future variants.
fn named_key_to_key(nk: &super::macros::NamedKey) -> Option<Key> {
use super::macros::{NamedKey, SpecialKey};
match nk {
NamedKey::Char(c) => Some(Key::from_char(*c)),
NamedKey::F(n) => Some(Key::f(*n)),
NamedKey::Ctrl(c) => Some(Key::ctrl(*c)),
NamedKey::Alt(c) => Some(Key::alt(*c)),
NamedKey::Special(s) => {
let code = match s {
SpecialKey::Enter => 0x0D,
SpecialKey::Backspace => 0x08,
SpecialKey::Delete => 0x7F,
SpecialKey::Tab => 0x09,
SpecialKey::Esc => 0x1B,
SpecialKey::Up => 0x2191,
SpecialKey::Down => 0x2193,
SpecialKey::Left => 0x2190,
SpecialKey::Right => 0x2192,
SpecialKey::Home => 0x2196,
SpecialKey::End => 0x2198,
SpecialKey::PageUp => 0x21DE,
SpecialKey::PageDown => 0x21DF,
};
Some(Key { code, mods: Modifiers::empty() })
}
}
}
/// Convert a runtime [`Key`] into its serializable [`NamedKey`]
/// form. Returns `None` for keys we cannot classify (e.g. a SHIFT
/// or CTRL+SHIFT arrow — the runtime does not produce those, so
/// the only common "unrecognised" keys are Unicode characters
/// outside the printable range, which we still try to preserve as
/// `Char`).
fn key_to_named_key(key: &Key) -> Option<super::macros::NamedKey> {
use super::macros::{NamedKey, SpecialKey};
let Key { code, mods } = *key;
// Plain printable char (no mods).
if mods.is_empty() && (0x20..=0x7E).contains(&code) {
return char::from_u32(code).map(NamedKey::Char);
}
// Ctrl + letter.
if mods == Modifiers::CTRL && (0x01..=0x1A).contains(&code) {
let c = ((code - 1) + b'A' as u32) as u8 as char;
return Some(NamedKey::Ctrl(c));
}
// Alt + letter (the runtime stores Alt's base char in `code`).
if mods == Modifiers::ALT && (0x20..=0x7E).contains(&code) {
return char::from_u32(code).map(NamedKey::Alt);
}
// Function keys (private-use range 0xF100..0xF10B).
if mods.is_empty() && (0xF100..=0xF10B).contains(&code) {
let n = (code - 0xF100 + 1) as u8;
return Some(NamedKey::F(n));
}
// Special keys (no mods; the runtime applies modifier state
// to the same code points).
if mods.is_empty() {
let special = match code {
0x0D => Some(SpecialKey::Enter),
0x08 => Some(SpecialKey::Backspace),
0x7F => Some(SpecialKey::Delete),
0x09 => Some(SpecialKey::Tab),
0x1B => Some(SpecialKey::Esc),
0x2191 => Some(SpecialKey::Up),
0x2193 => Some(SpecialKey::Down),
0x2190 => Some(SpecialKey::Left),
0x2192 => Some(SpecialKey::Right),
0x2196 => Some(SpecialKey::Home),
0x2198 => Some(SpecialKey::End),
0x21DE => Some(SpecialKey::PageUp),
0x21DF => Some(SpecialKey::PageDown),
_ => None,
};
return special.map(NamedKey::Special);
}
None
}
/// Return the identifier-like word containing the cursor. Used by
/// `Ctrl-]` to pick up the tag name without forcing the user to
/// select it first. The word is the maximal run of ASCII
/// alphanumeric + `_` characters containing `pos`; an empty
/// string is returned if no such run contains the cursor.
fn word_at_cursor(buf: &crate::editor::buffer::Buffer, pos: usize) -> String {
let text = buf.as_string();
if pos > text.len() {
return String::new();
}
let bytes = text.as_bytes();
let is_word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
// Scan back to the start of the word run.
let mut start = pos;
while start > 0 && is_word(bytes[start - 1]) {
start -= 1;
}
// Scan forward to the end.
let mut end = pos;
while end < bytes.len() && is_word(bytes[end]) {
end += 1;
}
text[start..end].to_string()
}
+433 -20
View File
@@ -30,12 +30,16 @@
use std::path::{Path, PathBuf};
use crate::editor::bookmark::{BookmarkSet, Mark};
use crate::editor::search::SearchState;
pub mod bookmark;
pub mod bracket;
pub mod buffer;
pub mod clipboard_osc52;
pub mod completion;
pub mod cursor;
pub mod cursor_shape;
pub mod folding;
pub mod format;
pub mod goto;
pub mod handlers;
@@ -46,13 +50,16 @@ pub mod mode;
pub mod prompt;
pub mod render;
pub mod save;
pub mod search;
#[cfg(feature = "syntect")]
pub mod syntax;
pub mod tags;
pub mod view;
pub use buffer::{detect_eol, Buffer, EolKind};
pub use completion::{Completer, Completion, CompletionMode};
pub use cursor::Cursor;
pub use cursor_shape::CursorShape;
pub use history::History;
pub use macros::{validate_name, Macro, MacroRecorder, MacroStore, NamedKey, SpecialKey};
pub use mode::{Mode, PromptKind};
@@ -118,6 +125,10 @@ pub struct Editor {
search_pattern: Option<String>,
/// Internal clipboard for F5/F6 copy/cut and Ctrl-V paste.
clipboard: Option<String>,
/// When true, long lines are wrapped at word boundaries in the
/// body area. When false (default), long lines are truncated at
/// the right margin. Toggled by Alt-W.
word_wrap: bool,
/// Word completion session (Alt-Tab).
completer: Completer,
/// Saved word-prefix length for completion replacement.
@@ -135,6 +146,55 @@ pub struct Editor {
/// offset) for the flash highlight, or `None` when the cursor is
/// not on a bracket.
bracket_flash: Option<(usize, usize)>,
/// In-buffer search engine — pattern, history, last match. The
/// history backs the Alt-/ popup in the Find/Replace prompts.
search: SearchState,
/// When Some, the search-history popup is open. The value is the
/// selected index into `self.search.history()` (0-based, with 0
/// being the most recent entry). `None` means the popup is
/// closed.
history_popup_selected: Option<usize>,
/// Visual shape of the editing cursor (Block/Bar/Underline).
cursor_shape: CursorShape,
/// When true, the gutter shows each non-cursor line's distance
/// from the cursor line instead of its absolute number.
relative_lines: bool,
/// Macro recorder (Ctrl-R toggles, Ctrl-P plays back). When
/// `recording`, every key event is appended to its current
/// sequence. The captured sequence is moved to `last_macro`
/// when recording stops.
macro_recorder: macros::MacroRecorder,
/// The most recently recorded macro (Ctrl-P replays it).
/// Empty until the first recording is stopped.
last_macro: Vec<macros::NamedKey>,
/// Code-fold state. `Ctrl-F1` toggles a fold at the cursor
/// line; the renderer hides lines covered by collapsed folds
/// and shows a `+` marker in the gutter on the fold's start
/// line.
folds: folding::FoldSet,
/// Tag-stack: positions saved before each `Ctrl-]` jump so
/// `Ctrl-T` can pop back. Each entry is `(byte_offset, line)`.
tag_stack: Vec<(usize, u32)>,
/// Active smooth-scroll animation. `Some` while the view is
/// animating from `current` to `target`; `None` when the view
/// is settled. The render path reads `current.round() as usize`
/// as the effective top line; each animation tick advances
/// `current` by 25 % toward `target`.
smooth_scroll: Option<SmoothScroll>,
}
/// One in-flight smooth-scroll animation.
///
/// `target` is the integer top-line we are scrolling to.
/// `current` is a `f32` that approaches `target` by 25 % per
/// tick, producing a smooth ease-out animation. The renderer
/// rounds `current` for display purposes.
#[derive(Debug, Clone, Copy)]
struct SmoothScroll {
/// Destination top-line index.
target: usize,
/// Current animated top-line index (non-integer during animation).
current: f32,
}
impl Editor {
@@ -162,6 +222,7 @@ impl Editor {
title,
search_pattern: None,
clipboard: None,
word_wrap: false,
completer: Completer::new(),
complete_prefix_len: 0,
bookmarks: BookmarkSet::new(),
@@ -169,7 +230,16 @@ impl Editor {
highlighter: hl,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
bracket_flash: None,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
relative_lines: false,
macro_recorder: macros::MacroRecorder::new(),
last_macro: Vec::new(),
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
}
}
@@ -189,14 +259,24 @@ impl Editor {
title: String::new(),
search_pattern: None,
clipboard: None,
word_wrap: false,
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,
#[cfg(feature = "syntect")]
highlighter: None,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
relative_lines: false,
macro_recorder: macros::MacroRecorder::new(),
last_macro: Vec::new(),
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
}
}
@@ -247,6 +327,85 @@ impl Editor {
self.modified
}
/// Whether word-wrap is enabled (Alt-W toggles this).
#[must_use]
pub fn word_wrap(&self) -> bool {
self.word_wrap
}
/// Toggle word-wrap on or off. Returns the new value.
pub fn toggle_word_wrap(&mut self) -> bool {
self.word_wrap = !self.word_wrap;
self.word_wrap
}
/// Borrow the in-buffer search engine state (pattern, history,
/// last match).
#[must_use]
pub fn search_state(&self) -> &SearchState {
&self.search
}
/// True if the search-history popup is currently open.
#[must_use]
pub fn history_popup_open(&self) -> bool {
self.history_popup_selected.is_some()
}
/// Open the search-history popup, pre-selecting the most recent
/// entry. No-op if the popup is already open or the search
/// history is empty.
pub fn open_history_popup(&mut self) {
if self.history_popup_selected.is_some() {
return;
}
if self.search.history().is_empty() {
return;
}
// Most recent entry is the last in the vector; index 0 = most
// recent in the popup's display order.
self.history_popup_selected = Some(0);
}
/// Close the search-history popup.
pub fn close_history_popup(&mut self) {
self.history_popup_selected = None;
}
/// Move the popup selection up by one entry, clamped at 0.
pub fn history_popup_up(&mut self) {
if let Some(sel) = self.history_popup_selected {
if sel > 0 {
self.history_popup_selected = Some(sel - 1);
}
}
}
/// Move the popup selection down by one entry, clamped at the
/// last history entry. No-op if the popup is closed or empty.
pub fn history_popup_down(&mut self) {
let max = self.search.history().len();
if max == 0 {
self.history_popup_selected = None;
return;
}
if let Some(sel) = self.history_popup_selected {
if sel + 1 < max {
self.history_popup_selected = Some(sel + 1);
}
}
}
/// Read the currently-selected history entry as a string. The
/// popup displays most-recent-first, so we reverse the index.
#[must_use]
pub fn history_popup_selected_entry(&self) -> Option<String> {
let history = self.search.history();
let sel = self.history_popup_selected?;
let idx = history.len().checked_sub(1 + sel)?;
history.get(idx).cloned()
}
fn update_bracket_flash(&mut self) {
let pos = self.cursor.position();
let text = self.buffer.as_string();
@@ -435,6 +594,106 @@ impl Editor {
self.modified = true;
self.message = Some("Formatted paragraph".to_string());
}
/// Jump to the tag named `name`. Loads the tags file from the
/// buffer's current directory (or the file's parent if
/// untitled), looks up the name, and moves the cursor to the
/// first match. The current cursor position is pushed onto
/// the tag stack so `pop_tag` can return. Returns the match
/// count on success.
pub fn jump_to_tag(&mut self, name: &str) -> Result<usize, String> {
let dir = self
.path
.as_deref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let tags_path = dir.join("tags");
let table = tags::TagTable::load(&tags_path).map_err(|e| {
format!("tags load failed ({}): {e}", tags_path.display())
})?;
let entries = table.lookup(name);
if entries.is_empty() {
return Err(format!("tag '{name}' not found"));
}
let entry = entries[0];
let pattern = tags::TagTable::pattern_from_excmd(&entry.excmd);
// Push the current position onto the tag stack.
let cur_pos = self.buffer.cursor();
let cur_line = self.buffer_line_of(cur_pos) as u32;
self.tag_stack.push((cur_pos, cur_line));
// Run a regex search using the pattern from the tag's excmd.
self.search.set_regex(true);
self.search.set_pattern(pattern.clone());
let from = self.cursor.position();
let m = self
.search
.find_next(&self.buffer, from)
.ok_or_else(|| format!("tag pattern not found in buffer: {pattern}"))?;
self.buffer.set_cursor(m.range.start);
self.cursor.set_position(m.range.start, &self.buffer);
self.message = Some(format!("{}:{}", entry.file, pattern));
Ok(entries.len())
}
/// Pop the most recent tag-stack entry and restore the cursor
/// to that position. Returns true if a position was popped.
pub fn pop_tag(&mut self) -> bool {
if let Some((pos, line)) = self.tag_stack.pop() {
self.buffer.set_cursor(pos);
self.cursor.set_position(pos, &self.buffer);
self.message = Some(format!("Popped tag (back to line {})", line + 1));
true
} else {
self.message = Some("Tag stack is empty".to_string());
false
}
}
/// Begin a smooth-scroll animation that moves the view's
/// top line from its current value to `target`. If a
/// previous animation is in flight, it is replaced by the
/// new one (the start position is the current animated
/// value, so the motion stays continuous).
pub fn begin_smooth_scroll(&mut self, target: usize) {
let start = self.effective_top_line() as f32;
self.smooth_scroll = Some(SmoothScroll { target, current: start });
}
/// Advance the in-flight smooth-scroll animation by one tick
/// (~25 % of the remaining distance). Returns true if the
/// animation is still in flight; false when it has settled.
pub fn tick_smooth_scroll(&mut self) -> bool {
let Some(s) = self.smooth_scroll else {
return false;
};
let target = s.target as f32;
let diff = target - s.current;
if diff.abs() < 0.5 {
// Settled — sync the integer top line and clear.
self.view.set_top_line(s.target, &self.buffer);
self.smooth_scroll = None;
return false;
}
let next = s.current + diff * 0.25;
self.smooth_scroll = Some(SmoothScroll {
target: s.target,
current: next,
});
// Keep the underlying view roughly in sync so cursor-visibility
// math sees a sensible value.
self.view.set_top_line(next.round() as usize, &self.buffer);
true
}
/// Return the effective top line: the animated `current` if a
/// smooth-scroll is in flight, otherwise the underlying
/// `view.top_line()`.
#[must_use]
pub fn effective_top_line(&self) -> usize {
self.smooth_scroll
.map_or(self.view.top_line(), |s| s.current.round() as usize)
}
}
/// Backwards-compat shim for the old `open_file` API. The new API
@@ -649,6 +908,157 @@ mod tests {
assert_eq!(e.mode(), Mode::Prompt(PromptKind::BookmarkSet));
}
/// Word-wrap is off by default; Alt-W toggles it; the new value
/// is exposed via `word_wrap()`.
#[test]
fn word_wrap_default_off_and_alt_w_toggles() {
let mut e = make_empty();
assert!(!e.word_wrap());
let r = e.handle_key(Key::alt('w'));
assert_eq!(r, EditorResult::Running);
assert!(e.word_wrap());
let r = e.handle_key(Key::alt('w'));
assert_eq!(r, EditorResult::Running);
assert!(!e.word_wrap());
}
/// Shift-F2 opens the SaveAs prompt; committing with a path
/// writes the buffer to that path and updates `self.path`.
#[test]
fn shift_f2_opens_save_as_prompt_and_saves_to_new_path() {
let dir = std::env::temp_dir().join("tlc-editor-saveas-shift-f2-test");
let _ = fs::create_dir_all(&dir);
let p1 = dir.join("a.txt");
let p2 = dir.join("b.txt");
let _ = fs::remove_file(&p1);
let _ = fs::remove_file(&p2);
let mut e = Editor::new_empty();
e.insert_str("hello saveas");
// Shift-F2 opens the prompt.
let shift_f2 = Key {
code: Key::f(2).code,
mods: Modifiers::SHIFT,
};
let r = e.handle_key(shift_f2);
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveAs));
// Type the destination path.
let path_str = p2.to_string_lossy().to_string();
for c in path_str.chars() {
e.handle_key(Key::from_char(c));
}
// Enter commits the save-as.
e.handle_key(Key::ENTER);
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.path(), Some(p2.as_path()));
assert!(!e.is_modified());
let written = fs::read_to_string(&p2).unwrap();
assert_eq!(written, "hello saveas");
let _ = fs::remove_dir_all(&dir);
}
/// Alt-/ in the Find prompt opens the search-history popup;
/// Up/Down navigate, Enter commits the selected entry into
/// the prompt input.
#[test]
fn alt_slash_in_find_prompt_opens_history_popup() {
let mut e = make_empty();
e.insert_str("hello world hello");
// Run two searches to populate history.
e.handle_key(Key::alt('f'));
for c in "hello".chars() {
e.handle_key(Key::from_char(c));
}
e.handle_key(Key::ENTER);
e.handle_key(Key::alt('f'));
for c in "world".chars() {
e.handle_key(Key::from_char(c));
}
e.handle_key(Key::ENTER);
assert_eq!(e.search_state().history(), &["hello".to_string(), "world".to_string()]);
// Open Find prompt again, then Alt-/ should open the popup.
e.handle_key(Key::alt('f'));
assert_eq!(e.mode(), Mode::Prompt(PromptKind::Find));
let alt_slash = Key {
code: b'/' as u32,
mods: Modifiers::ALT,
};
e.handle_key(alt_slash);
assert!(e.history_popup_open());
// Most recent entry is selected first.
assert_eq!(
e.history_popup_selected_entry(),
Some("world".to_string())
);
// Down moves to the older entry.
let down = Key {
code: 0x2193,
mods: Modifiers::empty(),
};
e.handle_key(down);
assert_eq!(
e.history_popup_selected_entry(),
Some("hello".to_string())
);
// Down at the last entry clamps (no-op).
e.handle_key(down);
assert_eq!(
e.history_popup_selected_entry(),
Some("hello".to_string())
);
// Up moves back.
let up = Key {
code: 0x2191,
mods: Modifiers::empty(),
};
e.handle_key(up);
assert_eq!(
e.history_popup_selected_entry(),
Some("world".to_string())
);
// Enter commits the selected entry into the prompt input.
e.handle_key(Key::ENTER);
assert_eq!(e.prompt_input().unwrap().text, "world");
assert!(!e.history_popup_open());
// Esc cancels the popup without committing.
e.handle_key(Key::alt('f'));
e.handle_key(alt_slash);
assert!(e.history_popup_open());
e.handle_key(Key::ESCAPE);
assert!(!e.history_popup_open());
}
/// When the search history is empty, Alt-/ does nothing.
#[test]
fn alt_slash_with_empty_history_is_noop() {
let mut e = make_empty();
e.insert_str("hello");
e.handle_key(Key::alt('f'));
let alt_slash = Key {
code: b'/' as u32,
mods: Modifiers::ALT,
};
e.handle_key(alt_slash);
assert!(!e.history_popup_open());
}
/// Alt-/ in a non-search prompt (e.g., GotoLine) does not open
/// the popup.
#[test]
fn alt_slash_in_non_search_prompt_is_noop() {
let mut e = make_empty();
e.insert_str("a\nb\nc");
e.handle_key(Key::alt('l'));
assert_eq!(e.mode(), Mode::Prompt(PromptKind::GotoLine));
let alt_slash = Key {
code: b'/' as u32,
mods: Modifiers::ALT,
};
e.handle_key(alt_slash);
assert!(!e.history_popup_open());
assert_eq!(e.mode(), Mode::Prompt(PromptKind::GotoLine));
}
#[test]
fn handle_key_alt_j_opens_bookmark_jump_prompt() {
let mut e = make_empty();
@@ -1181,12 +1591,14 @@ mod tests {
})
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer.cell((5, 1)).expect("a cell").symbol(), "a");
assert_eq!(buffer.cell((6, 1)).expect("space cell").symbol(), "·");
assert_eq!(buffer.cell((7, 1)).expect("tab cell").symbol(), "");
assert_eq!(buffer.cell((8, 1)).expect("b cell").symbol(), "b");
assert_eq!(buffer.cell((9, 1)).expect("caret cell").symbol(), "^");
assert_eq!(buffer.cell((10, 1)).expect("control cell").symbol(), "A");
// Body starts at x = 6 (accent 1 + gutter 4 + 1 border offset).
// The cursor paints a Bar overlay on the cursor cell, so we
// verify only the body cells that are NOT under the cursor.
assert_eq!(buffer.cell((6, 1)).expect("a cell").symbol(), "a");
assert_eq!(buffer.cell((7, 1)).expect("space cell").symbol(), "·");
assert_eq!(buffer.cell((8, 1)).expect("tab cell").symbol(), "");
assert_eq!(buffer.cell((9, 1)).expect("b cell").symbol(), "b");
assert_eq!(buffer.cell((10, 1)).expect("caret cell").symbol(), "^");
}
#[test]
@@ -1307,7 +1719,7 @@ mod tests {
.unwrap();
let buffer = terminal.backend().buffer();
let mut fg_colors = std::collections::HashSet::new();
for x in 5..15 {
for x in 6..16 {
if let Some(cell) = buffer.cell((x, 1)) {
fg_colors.insert(format!("{:?}", cell.fg));
}
@@ -1348,7 +1760,7 @@ mod tests {
.unwrap();
let buffer = terminal.backend().buffer();
let mut fg_colors = std::collections::HashSet::new();
for x in 5..17 {
for x in 6..18 {
if let Some(cell) = buffer.cell((x, 1)) {
if cell.symbol() != " " {
fg_colors.insert(format!("{:?}", cell.fg));
@@ -1490,13 +1902,14 @@ mod tests {
fg: theme.marked_fg,
bg: theme.marked_bg,
});
// Body starts at column `gutter_w` + 1. For "fn main() {}\n"
// Body starts at column `accent_w` + `gutter_w` + 1. For "fn main() {}\n"
// the buffer has 2 lines, so `line_count = 2`, gutter_w = max(1+1, 4) = 4.
// Block has Borders::ALL, so `inner.x = area.x + 1` and the gutter
// takes cols 1..=4 (width 4). Body starts at x = 5. Cells at
// (5, 1) and (6, 1) hold "f" and "n" and should carry marked bg.
let f_cell = buffer.cell((5, 1)).expect("'f' cell");
let n_cell = buffer.cell((6, 1)).expect("'n' cell");
// accent_w = 1. Block has Borders::ALL, so `inner.x = area.x + 1` and
// the accent stripe takes col 1, the gutter takes cols 2..=5 (width 4),
// and the body starts at x = 6. Cells at (6, 1) and (7, 1) hold "f"
// and "n" and should carry marked bg.
let f_cell = buffer.cell((6, 1)).expect("'f' cell");
let n_cell = buffer.cell((7, 1)).expect("'n' cell");
assert_eq!(f_cell.symbol(), "f");
assert_eq!(n_cell.symbol(), "n");
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
+375 -39
View File
@@ -11,7 +11,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::editor::{Mode, PromptKind};
use crate::editor::{CursorShape, Mode, PromptKind};
use crate::terminal::color::Theme;
use crate::terminal::mc_skin;
use crate::terminal::popup::{centered_percent_rect, render_popup};
@@ -38,7 +38,7 @@ impl Editor {
// scrolls, and the colors stop matching the source.
#[cfg(feature = "syntect")]
{
let current_top = self.view.top_line();
let current_top = self.effective_top_line();
if current_top != self.last_render_top {
self.last_render_top = current_top;
if let Some(ref path) = self.path {
@@ -103,28 +103,75 @@ impl Editor {
let inner = block.inner(area);
frame.render_widget(block, area);
// Split: gutter (line numbers) + body.
// Split: accent stripe (1) + gutter (line numbers) + body.
let line_count = self.buffer.line_count();
let gutter_w = (line_count.max(1).to_string().len() as u16 + 1).max(4);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(gutter_w), Constraint::Min(1)])
.constraints([
Constraint::Length(1),
Constraint::Length(gutter_w),
Constraint::Min(1),
])
.split(inner);
let accent_area = chunks[0];
let gutter_area = chunks[1];
let body_area = chunks[2];
// Ensure cursor is visible.
self.view.ensure_cursor_visible(
&self.buffer,
self.cursor.position(),
chunks[1].height as usize,
body_area.height as usize,
);
// Accent stripe: 1-char wide colored column on the left of the gutter.
let accent_bg = theme.accent;
let accent_lines: Vec<Line> = (0..body_area.height)
.map(|_| {
Line::from(Span::styled(
" ",
Style::default().fg(accent_bg).bg(accent_bg),
))
})
.collect();
frame.render_widget(
Paragraph::new(accent_lines).style(Style::default().bg(accent_bg)),
accent_area,
);
// Gutter: line numbers, 1-based, top..top+height.
let top = self.view.top_line();
let height = chunks[1].height as usize;
let top = self.effective_top_line();
let height = body_area.height as usize;
let cursor_line = self.buffer_line_of(self.cursor.position());
let gutter_lines: Vec<Line> = (0..height)
.map(|row| {
let line_idx = top + row;
let body_width = body_area.width.saturating_sub(1) as usize;
let wrapped_map: Vec<(usize, usize)> = if self.word_wrap && body_width > 0 {
build_wrap_map(&self.buffer, top, height, body_width)
} else {
(0..height).map(|row| (top + row, 0)).collect()
};
// Skip lines that are hidden by a collapsed fold (everything
// between a fold's start and end, exclusive of the start).
// A folded region's start line is still visible — the
// renderer attaches a `+` marker to its gutter entry.
let visible_wrapped: Vec<(usize, usize)> = wrapped_map
.iter()
.copied()
.filter(|&(line_idx, _wrap_row)| !self.folds.is_hidden(line_idx))
.collect();
// Pad with blank rows if we filtered out enough to fall short
// of the viewport height.
let mut visible_wrapped = visible_wrapped;
while visible_wrapped.len() < height {
let pad_idx = visible_wrapped
.last()
.map_or(0, |&(li, _)| li + 1);
visible_wrapped.push((pad_idx, 0));
}
let gutter_lines: Vec<Line> = visible_wrapped
.iter()
.map(|&(line_idx, _wrap_row)| {
let n = line_idx + 1;
let is_mod_cursor = self.modified && cursor_line == line_idx;
let gutter_style = if self.has_bookmark_on_line(line_idx) {
@@ -141,22 +188,46 @@ impl Editor {
} else {
Style::default().fg(frame_fg).bg(body_bg)
};
let text = if is_mod_cursor {
format!("{:>w$}\u{258c}", n, w = (gutter_w - 1) as usize)
let display_n: String = if self.relative_lines {
if cursor_line == line_idx {
format!("{:>w$}", n, w = (gutter_w - 1) as usize)
} else {
let d = if line_idx < cursor_line {
cursor_line - line_idx
} else {
line_idx - cursor_line
};
format!("{:>w$}", d, w = (gutter_w - 1) as usize)
}
} else {
format!("{:>w$} ", n, w = (gutter_w - 1) as usize)
format!("{:>w$}", n, w = (gutter_w - 1) as usize)
};
let text = if is_mod_cursor {
format!("{display_n}\u{258c}")
} else if self.folds.is_fold_start(line_idx) {
format!("{display_n}+")
} else {
format!("{display_n} ")
};
Line::from(Span::styled(text, gutter_style))
})
.collect();
frame.render_widget(Paragraph::new(gutter_lines), chunks[0]);
frame.render_widget(Paragraph::new(gutter_lines), gutter_area);
// Body: render the visible slice of the buffer.
let mut body_lines: Vec<Line> = Vec::with_capacity(height);
let full_text = self.buffer.as_string();
let sel = self.cursor.selection();
for row in 0..height {
let line_idx = top + row;
let mut prev_line_idx: Option<usize> = None;
for (line_idx, _wrap_row) in visible_wrapped.iter().copied() {
// When wrapping, only the FIRST visual row of a logical
// line should drive syntax highlighting for the whole
// line — feeding substrings to the highlighter breaks
// parser state. We render the first row using the
// existing logic and follow-on wrapped rows as plain
// continuations of the same line_text.
let is_first_visual_of_line = prev_line_idx != Some(line_idx);
prev_line_idx = Some(line_idx);
if line_idx >= line_count {
body_lines.push(Line::from(Span::styled(
"~",
@@ -185,18 +256,21 @@ impl Editor {
#[cfg(feature = "syntect")]
{
if let Some(ref mut h) = self.highlighter {
let highlighted = h.highlight_line(line_text);
spans = split_spans_for_selection(
highlighted,
rs,
re,
line_bg,
marked_bg,
);
body_lines.push(Line::from(spans));
continue;
if is_first_visual_of_line {
let highlighted = h.highlight_line(line_text);
spans = split_spans_for_selection(
highlighted,
rs,
re,
line_bg,
marked_bg,
);
body_lines.push(Line::from(spans));
continue;
}
}
}
let _ = is_first_visual_of_line;
if rs > 0 {
if let Some(b) = line_text.get(..rs) {
push_rendered_text(
@@ -241,10 +315,13 @@ impl Editor {
let mut spans = Vec::new();
#[cfg(feature = "syntect")]
{
if let Some(ref mut h) = self.highlighter {
spans = h.highlight_line(line_text);
if is_first_visual_of_line {
if let Some(ref mut h) = self.highlighter {
spans = h.highlight_line(line_text);
}
}
}
let _ = is_first_visual_of_line;
if spans.is_empty() {
push_rendered_text(
&mut spans,
@@ -258,16 +335,16 @@ impl Editor {
}
body_lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(body_lines), chunks[1]);
frame.render_widget(Paragraph::new(body_lines), body_area);
if chunks[1].width > 0 {
if body_area.width > 0 {
let margin_area = Rect::new(
chunks[1].x + chunks[1].width - 1,
chunks[1].y,
body_area.x + body_area.width - 1,
body_area.y,
1,
chunks[1].height,
body_area.height,
);
let margin_lines: Vec<Line> = (0..chunks[1].height as usize)
let margin_lines: Vec<Line> = (0..body_area.height as usize)
.map(|_| {
Line::from(Span::styled(
"",
@@ -281,14 +358,93 @@ impl Editor {
);
}
// Position the terminal cursor at the editing point.
if !matches!(self.mode, Mode::Prompt(_)) {
// Scrollbar: paint a 2-char wide vertical track on the right
// edge of the body area when the buffer overflows the viewport.
if line_count > height && body_area.width >= 2 {
paint_scrollbar(
frame.buffer_mut(),
Rect::new(
body_area.x + body_area.width - 2,
body_area.y,
2,
body_area.height,
),
line_count,
height,
top,
theme.foreground,
theme.hidden,
body_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.
let cursor_pos: Option<(u16, u16)> = if !matches!(self.mode, Mode::Prompt(_)) {
let cursor_line = self.buffer_line_of(self.cursor.position());
let cursor_col = self.cursor.visual_column() as u16;
let row = cursor_line.saturating_sub(top) as u16;
let x = chunks[1].x + cursor_col.min(chunks[1].width.saturating_sub(1));
let y = chunks[1].y + row;
// When wrapping is on, the visual row is the wrap map
// position of the cursor's logical line. For v1 we
// pin to the first visual row of the line — the
// user can refine per-byte wrap later.
let visual_row = wrapped_map
.iter()
.position(|&(li, _)| li == cursor_line)
.unwrap_or_else(|| cursor_line.saturating_sub(top));
let row = visual_row as u16;
let x = body_area.x + cursor_col.min(body_area.width.saturating_sub(1));
let y = body_area.y + row;
frame.set_cursor_position((x, y));
let shape = effective_cursor_shape(&self.cursor_shape, &self.mode);
paint_cursor_shape(frame.buffer_mut(), x, y, shape, theme.cursor_fg, theme.accent);
Some((x, y))
} else {
None
};
// Completion popup: render above the cursor when possible.
if !self.completer.is_empty() {
let max_visible = 5usize;
let candidates = self.completer.candidates();
let total = candidates.len();
if total > 0 {
let selected = self.completer.current_index();
let scroll_offset = selected.saturating_sub(max_visible - 1);
let window_end = (scroll_offset + max_visible).min(total);
let popup_h = (window_end - scroll_offset) as u16 + 2;
let popup_w = body_area.width.min(40);
let row_y = cursor_pos.map(|(_, y)| y).unwrap_or(body_area.y);
let desired_y = if row_y.saturating_sub(body_area.y) >= popup_h + 1 {
row_y - popup_h
} else {
row_y + 1
};
let popup_rect = Rect::new(
body_area.x,
desired_y,
popup_w,
popup_h.min(body_area.height),
);
let popup_inner = render_popup(frame, popup_rect, " completion ", theme);
let row_h = popup_inner.height.saturating_sub(1).max(1);
let mut lines: Vec<Line> = Vec::with_capacity(row_h as usize);
for vis_row in 0..row_h as usize {
let idx = scroll_offset + vis_row;
if idx >= total {
break;
}
let is_sel = idx == selected;
let bg = if is_sel { theme.accent } else { body_bg };
let fg = if is_sel { theme.cursor_fg } else { body_fg };
let text = candidates[idx].clone();
lines.push(Line::from(Span::styled(
text,
Style::default().fg(fg).bg(bg),
)));
}
frame.render_widget(Paragraph::new(lines), popup_inner);
}
}
// Save-before-close prompt overlay.
@@ -395,6 +551,58 @@ impl Editor {
let cursor_x =
rows[1].x + (self.prompt_input.cursor as u16).min(rows[1].width.saturating_sub(1));
frame.set_cursor_position((cursor_x, rows[1].y));
// Search-history popup: a small Clear block listing the
// most-recent patterns first. Anchored below the prompt
// input row so it doesn't obscure the prompt itself.
if self.history_popup_selected.is_some() {
let history = self.search.history();
if !history.is_empty() {
let popup_h = (history.len() as u16 + 2).min(area.height);
let popup_w = popup.width;
let popup_y = if rows[1].y + 1 + popup_h <= area.bottom() {
rows[1].y + 1
} else {
popup.y.saturating_sub(popup_h)
};
let popup_rect = Rect::new(popup.x, popup_y, popup_w, popup_h);
let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_")
.unwrap_or(mc_skin::ColorPair {
fg: theme.foreground,
bg: body_bg,
});
let dialog_hot = mc_skin::color_pair(theme.name, "dialog", "dhotnormal")
.unwrap_or(dialog_default);
let inner = render_popup(frame, popup_rect, " history ", theme);
let max_rows = inner.height as usize;
let selected = self.history_popup_selected.unwrap_or(0);
// Display most-recent first.
let total = history.len();
let visible_start = selected.min(total.saturating_sub(max_rows));
let mut lines: Vec<Line> = Vec::with_capacity(max_rows);
for vis_row in 0..max_rows {
let idx = total.saturating_sub(1 + visible_start + vis_row);
if idx >= total {
break;
}
// selection (popup index 0 = most recent =
// history index total-1). visible_start is
// the smallest popup index currently in the
// window; we walk in popup-index order.
let popup_idx = visible_start + vis_row;
let is_sel = popup_idx == selected;
let fg = if is_sel { dialog_hot.fg } else { dialog_default.fg };
let bg = if is_sel { dialog_hot.bg } else { dialog_default.bg };
let entry = history[idx].clone();
lines.push(Line::from(Span::styled(entry, Style::default().fg(fg).bg(bg))));
}
frame.render_widget(
Paragraph::new(lines)
.style(Style::default().fg(dialog_default.fg).bg(dialog_default.bg)),
inner,
);
}
}
}
// Status line (last line of the editor area).
@@ -468,6 +676,86 @@ impl Editor {
}
}
fn paint_scrollbar(
buf: &mut ratatui::buffer::Buffer,
area: Rect,
total_lines: usize,
viewport_height: usize,
top: usize,
thumb_color: Color,
track_color: Color,
body_bg: Color,
) {
if area.width == 0 || area.height == 0 || viewport_height == 0 {
return;
}
let visible_ratio = (viewport_height as f64 / total_lines as f64).clamp(0.0, 1.0);
let thumb_h = ((viewport_height as f64 * visible_ratio).ceil() as usize).max(1);
let max_top = total_lines.saturating_sub(viewport_height);
let scroll_ratio = if max_top == 0 {
0.0
} else {
(top as f64 / max_top as f64).clamp(0.0, 1.0)
};
let track_room = viewport_height.saturating_sub(thumb_h);
let thumb_start = (track_room as f64 * scroll_ratio).round() as usize;
let symbol = "\u{2588}";
for row in 0..viewport_height {
let in_thumb = row >= thumb_start && row < thumb_start + thumb_h;
let color = if in_thumb { thumb_color } else { track_color };
let y = area.y + row as u16;
for col in 0..area.width {
let x = area.x + col;
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(symbol);
cell.set_style(Style::default().fg(color).bg(body_bg));
}
}
}
}
fn effective_cursor_shape(shape: &CursorShape, mode: &Mode) -> CursorShape {
match mode {
Mode::Insert => CursorShape::Bar,
Mode::Normal => CursorShape::Block,
Mode::Prompt(_) => *shape,
}
}
fn paint_cursor_shape(
buf: &mut ratatui::buffer::Buffer,
x: u16,
y: u16,
shape: CursorShape,
cursor_fg: Color,
accent: Color,
) {
match shape {
CursorShape::Block => {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(
Style::default()
.fg(cursor_fg)
.bg(accent)
.add_modifier(Modifier::BOLD),
);
}
}
CursorShape::Bar => {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol("\u{258C}");
cell.set_style(Style::default().fg(accent).bg(cursor_fg));
}
}
CursorShape::Underline => {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol("_");
cell.set_style(Style::default().fg(accent).bg(cursor_fg));
}
}
}
}
fn push_rendered_text<'a>(
spans: &mut Vec<Span<'a>>,
text: &str,
@@ -580,3 +868,51 @@ pub(crate) fn round_up_to_char_boundary(s: &str, idx: usize) -> usize {
}
i
}
/// Build a flat `(logical_line_idx, wrap_row)` mapping for the
/// viewport rows `0..height`. Each entry says "row N of the viewport
/// displays logical line `logical_line_idx`, the Nth wrapped visual
/// row of that line". When word-wrap is off, the mapping is
/// `(top + row, 0)` — a degenerate one-to-one.
///
/// We use a simple character-counting wrap: each logical line
/// contributes `ceil(chars / body_width)` visual rows, with a
/// minimum of one (so empty lines still display as `~`). This is a
/// faithful approximation of `Paragraph::wrap(Wrap { trim: false })`
/// for plain text and matches the per-line counts that the gutter
/// expects (one gutter entry per visual row).
fn build_wrap_map(
buffer: &crate::editor::buffer::Buffer,
top: usize,
height: usize,
body_width: usize,
) -> Vec<(usize, usize)> {
let mut map = Vec::with_capacity(height);
let line_count = buffer.line_count();
let mut line_idx = top;
while map.len() < height && line_idx < line_count {
let chars = buffer.line_length_chars(line_idx);
// Empty line is always at least one visual row so the user
// sees the `~` tilde marker.
let rows = if body_width == 0 {
1
} else if chars == 0 {
1
} else {
chars.div_ceil(body_width).max(1)
};
for wrap_row in 0..rows {
if map.len() >= height {
break;
}
map.push((line_idx, wrap_row));
}
line_idx += 1;
}
// Pad with empty past-end entries so the body always renders
// exactly `height` rows.
while map.len() < height {
map.push((line_idx, 0));
}
map
}
@@ -124,7 +124,7 @@ impl SearchState {
if self.pattern.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(self.pattern.as_str()) {
if self.history.contains(&self.pattern) {
return;
}
self.history.push(self.pattern.clone());
@@ -291,7 +291,8 @@ fn scan(
Ok(r) => r,
Err(_) => return None,
};
re.find(slice).map(|m| {
let hay_str = std::str::from_utf8(slice).ok()?;
re.find(hay_str).map(|m| {
let abs_start = start + m.start();
let abs_end = start + m.end();
let text_bytes = &text[abs_start..abs_end];
@@ -347,8 +348,8 @@ fn scan_rev(
Ok(r) => r,
Err(_) => return None,
};
// find_iter goes left-to-right; collect and take the last.
re.find_iter(slice).last().map(|m| {
let hay_str = std::str::from_utf8(slice).ok()?;
re.find_iter(hay_str).last().map(|m| {
let abs_start = start + m.start();
let abs_end = start + m.end();
let text_str = String::from_utf8_lossy(&text[abs_start..abs_end]).into_owned();
@@ -604,7 +605,7 @@ mod tests {
}
assert_eq!(s.history().len(), 50);
assert_eq!(s.history().last().unwrap(), "p099");
assert_eq!(s.history().first().unwrap(), "p049");
assert_eq!(s.history().first().unwrap(), "p050");
}
#[test]
@@ -0,0 +1,205 @@
//! Tags file support for the editor.
//!
//! A tags file is a sorted index of source-code definitions in
//! the format produced by `ctags` / `etags`. Each line is
//! `<name>\t<file>\t<excmd>` where `excmd` is an editor
//! command (typically a regex like `/^pattern$/;`). The editor's
//! `Ctrl-]` jump-to-tag and `Ctrl-T` pop-tag-stack commands
//! resolve a name against the active file's `tags` index.
//!
//! The supported line format is the minimal POSIX/Exuberant
//! subset:
//!
//! ```text
//! name<TAB>file<TAB>excmd
//! ```
//!
//! The `excmd` is stored verbatim; the editor converts it into a
//! search pattern by stripping the optional `/.../;` delimiters
//! and applying it as a regex search. This matches the behaviour
//! of `vim -t` and is good enough for the jump use case.
//!
//! Comments (`#`) and blank lines are ignored.
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// One entry in a tags file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagEntry {
/// The symbol name.
pub name: String,
/// The file (relative to the tags file's directory) that
/// defines the symbol.
pub file: String,
/// The ex command (e.g. `/^pattern$/;`) used to locate the
/// definition within `file`.
pub excmd: String,
}
/// A parsed tags file.
#[derive(Debug, Clone, Default)]
pub struct TagTable {
/// Name → entries (more than one entry can share a name —
/// overloaded C++ functions, for example).
entries: HashMap<String, Vec<TagEntry>>,
}
impl TagTable {
/// Create an empty tag table.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Load a tags file from `path`. Returns an empty table if
/// the file does not exist; returns the parse error message
/// if the file is unreadable or contains a malformed line.
pub fn load(path: impl AsRef<Path>) -> Result<Self, String> {
let text = fs::read_to_string(path.as_ref())
.map_err(|e| format!("read tags file: {e}"))?;
let mut t = Self::new();
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split('\t');
let name = parts.next().unwrap_or("").to_string();
let file = parts.next().unwrap_or("").to_string();
let excmd = parts.next().unwrap_or("").to_string();
if name.is_empty() || file.is_empty() {
return Err(format!(
"tags line {}: missing name or file",
lineno + 1
));
}
t.entries.entry(name.clone()).or_default().push(TagEntry {
name,
file,
excmd,
});
}
Ok(t)
}
/// All entries with the given `name`, or an empty vec if
/// there are no matches.
#[must_use]
pub fn lookup(&self, name: &str) -> Vec<&TagEntry> {
self.entries
.get(name)
.map(|v| v.iter().collect())
.unwrap_or_default()
}
/// Extract a search pattern from a tag's `excmd`. Strips the
/// optional leading `/` and trailing `/;` (or `$/;` for
/// anchored patterns). Returns the inner pattern verbatim.
#[must_use]
pub fn pattern_from_excmd(excmd: &str) -> String {
let trimmed = excmd.trim();
// Strip trailing `;` (the "perform search" command flag).
let trimmed = trimmed.strip_suffix(';').unwrap_or(trimmed);
// Strip leading `/`.
let trimmed = trimmed.strip_prefix('/').unwrap_or(trimmed);
// Strip trailing `/`.
let trimmed = trimmed.strip_suffix('/').unwrap_or(trimmed);
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn new_is_empty() {
let t = TagTable::new();
assert!(t.lookup("foo").is_empty());
}
#[test]
fn parse_minimal_format() {
let dir = tempdir().unwrap();
let p = dir.path().join("tags");
let mut f = fs::File::create(&p).unwrap();
writeln!(f, "main\tmain.rs\t/^fn main\\(\\)$/;\n").unwrap();
writeln!(f, "helper\tutil.rs\t/^pub fn helper/;\n").unwrap();
let t = TagTable::load(&p).unwrap();
let mains = t.lookup("main");
assert_eq!(mains.len(), 1);
assert_eq!(mains[0].file, "main.rs");
assert_eq!(mains[0].excmd, "/^fn main\\(\\)$/;");
let helpers = t.lookup("helper");
assert_eq!(helpers.len(), 1);
assert_eq!(helpers[0].file, "util.rs");
}
#[test]
fn comments_and_blanks_ignored() {
let dir = tempdir().unwrap();
let p = dir.path().join("tags");
let mut f = fs::File::create(&p).unwrap();
writeln!(f, "# generated by ctags\n").unwrap();
writeln!(f, "\n").unwrap();
writeln!(f, "x\ta.rs\t/^x$/;\n").unwrap();
let t = TagTable::load(&p).unwrap();
assert_eq!(t.lookup("x").len(), 1);
}
#[test]
fn lookup_returns_empty_for_unknown() {
let t = TagTable::new();
assert!(t.lookup("nope").is_empty());
}
#[test]
fn overloaded_name_yields_multiple_entries() {
let dir = tempdir().unwrap();
let p = dir.path().join("tags");
let mut f = fs::File::create(&p).unwrap();
writeln!(f, "push\tvec.rs\t/^impl Vec<T> {{ fn push/;\n").unwrap();
writeln!(f, "push\tdeque.rs\t/^impl Deque<T> {{ fn push/;\n").unwrap();
let t = TagTable::load(&p).unwrap();
let r = t.lookup("push");
assert_eq!(r.len(), 2);
assert_eq!(r[0].file, "vec.rs");
assert_eq!(r[1].file, "deque.rs");
}
#[test]
fn malformed_line_errors() {
let dir = tempdir().unwrap();
let p = dir.path().join("tags");
let mut f = fs::File::create(&p).unwrap();
writeln!(f, "only_one_field\n").unwrap();
let r = TagTable::load(&p);
assert!(r.is_err());
}
#[test]
fn missing_file_yields_empty_table() {
let dir = tempdir().unwrap();
let p = dir.path().join("does_not_exist");
let r = TagTable::load(&p);
assert!(r.is_err());
}
#[test]
fn pattern_from_excmd_strips_delimiters() {
assert_eq!(
TagTable::pattern_from_excmd("/^fn main$/;"),
"^fn main$"
);
assert_eq!(
TagTable::pattern_from_excmd("/^pattern/"),
"^pattern"
);
assert_eq!(TagTable::pattern_from_excmd("123"), "123");
assert_eq!(TagTable::pattern_from_excmd(""), "");
}
}
@@ -202,6 +202,15 @@ pub struct FileManager {
pub frame_count: u64,
/// Panel-switch animation percent (0 = just switched, 100 = settled).
pub panel_switch_anim: u8,
/// Dialog slide-in animation progress in `[0.0, 1.0]`. Reset
/// to 0 when a dialog opens; advanced toward 1.0 by the
/// per-tick animation loop. The renderer uses this to offset
/// the dialog's vertical position during the slide.
pub dialog_anim: f32,
/// Whether a dialog was open on the previous animation tick.
/// Used to detect the `None` → `Some` transition that triggers
/// a fresh slide-in animation.
dialog_was_open: bool,
}
/// A copy or move operation that hit a destination-exists conflict
@@ -394,6 +403,8 @@ impl FileManager {
toasts: crate::widget::ToastManager::new(),
frame_count: 0,
panel_switch_anim: 100,
dialog_anim: 1.0,
dialog_was_open: false,
})
}
@@ -443,6 +454,20 @@ impl FileManager {
if self.panel_switch_anim < 100 {
self.panel_switch_anim = self.panel_switch_anim.saturating_add(25);
}
// Dialog slide-in: reset on transition from closed → open,
// then advance toward 1.0. ~200ms target at ~10fps tick
// → 0.05 per tick → ~20 ticks → ~2s in practice. We use a
// larger step (0.08) so the slide completes in ~12 ticks.
let dialog_open = self.dialog.is_some();
if dialog_open && !self.dialog_was_open {
self.dialog_anim = 0.0;
}
if dialog_open && self.dialog_anim < 1.0 {
self.dialog_anim = (self.dialog_anim + 0.08).min(1.0);
} else if !dialog_open {
self.dialog_anim = 1.0;
}
self.dialog_was_open = dialog_open;
let snapshots = {
let reg = self.jobs.lock().unwrap_or_else(|e| e.into_inner());
reg.list()
@@ -93,24 +93,40 @@ impl FileManager {
mb.render(frame, area, &self.theme);
}
if let Some(d) = &mut self.dialog {
// Slide-in: shift the dialog's draw area downward by
// up to 6 rows while `dialog_anim < 1.0`. Each dialog
// centers itself on the area we pass in, so a Y
// translate is enough to produce a slide-from-bottom
// animation. `dialog_area` is the translated `area`.
let slide = ((1.0 - self.dialog_anim) * 6.0) as u16;
let dialog_area = if slide > 0 {
Rect::new(
area.x,
area.y.saturating_add(slide),
area.width,
area.height.saturating_sub(slide),
)
} else {
area
};
match d {
DialogState::Info(d) => d.render(frame, area, &self.theme),
DialogState::Permission(d) => d.render(frame, area, &self.theme),
DialogState::Owner(d) => d.render(frame, area, &self.theme),
DialogState::Link(d) => d.render(frame, area, &self.theme),
DialogState::MkDir(d) => d.render(frame, area, &self.theme),
DialogState::Copy(d) => d.render(frame, area, &self.theme),
DialogState::Move(d) => d.render(frame, area, &self.theme),
DialogState::Delete(d) => d.render(frame, area, &self.theme),
DialogState::Find(d) => d.render(frame, area, &self.theme),
DialogState::Hotlist(d) => d.render(frame, area, &self.theme),
DialogState::Tree(d) => d.render(frame, area, &self.theme),
DialogState::UserMenu(d) => d.render(frame, area, &self.theme),
DialogState::Help(d) => d.render(frame, area),
DialogState::Skin(d) => d.render(frame, area, &self.theme),
DialogState::Quit(d) => d.render(frame, area, &self.theme),
DialogState::SelectGroup(d) => d.render(frame, area, &self.theme),
DialogState::UnselectGroup(d) => d.render(frame, area, &self.theme),
DialogState::Info(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Permission(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Owner(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Link(d) => d.render(frame, dialog_area, &self.theme),
DialogState::MkDir(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Copy(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Move(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Delete(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Find(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Hotlist(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Tree(d) => d.render(frame, dialog_area, &self.theme),
DialogState::UserMenu(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Help(d) => d.render(frame, dialog_area),
DialogState::Skin(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Quit(d) => d.render(frame, dialog_area, &self.theme),
DialogState::SelectGroup(d) => d.render(frame, dialog_area, &self.theme),
DialogState::UnselectGroup(d) => d.render(frame, dialog_area, &self.theme),
DialogState::QuickCd(d) => d.render(
frame,
match self.active {
@@ -119,21 +135,21 @@ impl FileManager {
},
&self.theme,
),
DialogState::Overwrite(d) => d.render(frame, area, &self.theme),
DialogState::Layout(d) => d.render(frame, area, &self.theme),
DialogState::PanelOptions(d) => d.render(frame, area, &self.theme),
DialogState::Config(d) => d.render(frame, area, &self.theme),
DialogState::Jobs(d) => d.render(frame, area, &self.theme),
DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme),
DialogState::VfsList(d) => d.render(frame, area, &self.theme),
DialogState::ScreenList(d) => d.render(frame, area, &self.theme),
DialogState::EditHistory(d) => d.render(frame, area, &self.theme),
DialogState::FilteredView(d) => d.render(frame, area, &self.theme),
DialogState::Compare(d) => d.render(frame, area, &self.theme),
DialogState::Chattr(d) => d.render(frame, area, &self.theme),
DialogState::PanelFilter(d) => d.render(frame, area, &self.theme),
DialogState::Encoding(d) => d.render(frame, area, &self.theme),
DialogState::Connection(d) => d.render(frame, area, &self.theme),
DialogState::Overwrite(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Layout(d) => d.render(frame, dialog_area, &self.theme),
DialogState::PanelOptions(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Config(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Jobs(d) => d.render(frame, dialog_area, &self.theme),
DialogState::ExternalPanelize(d) => d.render(frame, dialog_area, &self.theme),
DialogState::VfsList(d) => d.render(frame, dialog_area, &self.theme),
DialogState::ScreenList(d) => d.render(frame, dialog_area, &self.theme),
DialogState::EditHistory(d) => d.render(frame, dialog_area, &self.theme),
DialogState::FilteredView(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Compare(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Chattr(d) => d.render(frame, dialog_area, &self.theme),
DialogState::PanelFilter(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Encoding(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Connection(d) => d.render(frame, dialog_area, &self.theme),
}
}
}
@@ -213,6 +213,7 @@ impl Skin {
/// | `symlink` | `symlink` |
/// | `device` | `device` |
/// | `hidden` | `hidden` |
/// | `accent` | `accent` |
/// | `status_fg` | `status_fg` |
/// | `status_bg` | `status_bg` |
/// | `buttonbar_fg` | `buttonbar_fg` |
@@ -266,6 +267,9 @@ impl Skin {
if let Some(c) = self.palette.get("hidden") {
out.hidden = c.to_color();
}
if let Some(c) = self.palette.get("accent") {
out.accent = c.to_color();
}
if let Some(c) = self.palette.get("status_fg") {
out.status_fg = c.to_color();
}
@@ -61,6 +61,7 @@ pub const DEFAULT_THEME: Theme = Theme {
symlink: as_color(REDBEAR_DARK.symlink),
device: as_color(REDBEAR_DARK.device),
hidden: as_color(REDBEAR_DARK.hidden),
accent: as_color(REDBEAR_DARK.accent),
status_bg: as_color(REDBEAR_DARK.status_bg),
status_fg: as_color(REDBEAR_DARK.text),
buttonbar_bg: as_color(REDBEAR_DARK.buttonbar_bg),
@@ -89,6 +90,7 @@ pub const LIGHT_THEME: Theme = Theme {
symlink: as_color(REDBEAR_LIGHT.symlink),
device: as_color(REDBEAR_LIGHT.device),
hidden: as_color(REDBEAR_LIGHT.hidden),
accent: as_color(REDBEAR_LIGHT.accent),
status_bg: as_color(REDBEAR_LIGHT.status_bg),
status_fg: as_color(REDBEAR_LIGHT.text),
buttonbar_bg: as_color(REDBEAR_LIGHT.buttonbar_bg),
@@ -122,6 +124,7 @@ pub const MC_CLASSIC_THEME: Theme = Theme {
symlink: rgb(0xFF, 0x55, 0xFF),
device: rgb(0xFF, 0x55, 0x55),
hidden: rgb(0x80, 0x80, 0x80),
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x00, 0x00, 0xAA),
status_fg: rgb(0xFF, 0xFF, 0xFF),
buttonbar_bg: rgb(0x00, 0x00, 0x55),
@@ -153,6 +156,7 @@ pub const MC_DARK_THEME: Theme = Theme {
symlink: rgb(0xFF, 0x80, 0xFF), // symlinks (magenta)
device: rgb(0xFF, 0x80, 0x80), // devices (light red)
hidden: rgb(0x80, 0x80, 0x80), // hidden (gray)
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x30, 0x30, 0x30), // status bar (darker gray)
status_fg: rgb(0xFF, 0xFF, 0xFF), // status text (white)
buttonbar_bg: rgb(0x30, 0x30, 0x30), // button bar (darker gray)
@@ -184,6 +188,7 @@ pub const MC_DARK_GRAY_THEME: Theme = Theme {
symlink: rgb(0x80, 0x60, 0x80), // symlinks (desaturated magenta)
device: rgb(0x80, 0x60, 0x60), // devices (desaturated red)
hidden: rgb(0x55, 0x55, 0x55), // hidden (dark gray)
accent: rgb(0xCC, 0xCC, 0x00),
status_bg: rgb(0x12, 0x12, 0x12), // status bar (near-black)
status_fg: rgb(0xB0, 0xB0, 0xB0), // status text (dimmed)
buttonbar_bg: rgb(0x12, 0x12, 0x12), // button bar (near-black)
@@ -219,6 +224,7 @@ pub const HIGH_CONTRAST_THEME: Theme = Theme {
symlink: rgb(0x80, 0xFF, 0xFF),
device: rgb(0xFF, 0x80, 0x80),
hidden: rgb(0xC0, 0xC0, 0xC0),
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x00, 0x00, 0x00),
status_fg: rgb(0xFF, 0xFF, 0xFF),
buttonbar_bg: rgb(0x00, 0x00, 0x00),
@@ -250,6 +256,7 @@ pub const SOLARIZED_DARK_THEME: Theme = Theme {
symlink: rgb(0x6C, 0x71, 0xC4), // violet
device: rgb(0xDC, 0x32, 0x2F), // red
hidden: rgb(0x58, 0x6E, 0x75), // base01
accent: rgb(0xB5, 0x89, 0x00),
status_bg: rgb(0x00, 0x2B, 0x36), // base03
status_fg: rgb(0x93, 0xA1, 0xA1), // base1
buttonbar_bg: rgb(0x07, 0x36, 0x42), // base02
@@ -280,6 +287,7 @@ pub const NORD_THEME: Theme = Theme {
symlink: rgb(0x81, 0xA1, 0xC1), // nord9
device: rgb(0xBF, 0x61, 0x6A), // nord11
hidden: rgb(0x4C, 0x56, 0x6A), // nord3
accent: rgb(0xA3, 0xBE, 0x8C),
status_bg: rgb(0x2E, 0x34, 0x40), // nord0
status_fg: rgb(0xD8, 0xDE, 0xE9), // nord4
buttonbar_bg: rgb(0x3B, 0x42, 0x52), // nord1
@@ -323,6 +331,11 @@ pub struct Theme {
pub device: Color,
/// Color for hidden (dotfile) entries.
pub hidden: Color,
/// Brand accent color. Defaults to the Red Bear brand red
/// `#B52430`; user skins can override via the `accent` palette
/// slot. Used by the editor's left-margin stripe and the
/// completion-popup highlight.
pub accent: Color,
/// Background of the status line.
pub status_bg: Color,
/// Foreground of the status line.
@@ -174,6 +174,7 @@ fn parse_theme(name: &'static str, text: &str) -> Theme {
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.hidden),
accent: DEFAULT_THEME.accent,
status_bg: status.bg.unwrap_or(DEFAULT_THEME.status_bg),
status_fg: status.fg.unwrap_or(DEFAULT_THEME.status_fg),
buttonbar_bg: button.bg.unwrap_or(DEFAULT_THEME.buttonbar_bg),
+194 -1
View File
@@ -40,6 +40,20 @@ pub enum ViewMode {
Hex,
}
/// Active prompt kind at the bottom of the viewer. Opened by `/`
/// (search) or `g` (goto-line). The user types into
/// [`Viewer::prompt_input`] and commits with Enter; Esc cancels.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerPrompt {
/// `/` — incremental search prompt. Enter runs the search and
/// jumps to the first match; subsequent `n` / `N` cycle through
/// matches (handled by the existing search engine).
Search,
/// `g` — goto-line prompt. Enter jumps the cursor to the
/// entered 1-based line number.
GotoLine,
}
/// Top-level Viewer struct. Built incrementally as Phase 3
/// progresses; for now it loads the source and provides access.
pub struct Viewer {
@@ -68,6 +82,30 @@ pub struct Viewer {
/// File size at the last successful read. Compared with the
/// current on-disk size on each refresh to detect growth.
last_size: u64,
/// Active modal prompt at the bottom of the viewer (`None` =
/// no prompt open). Drives the prompt's title and Enter
/// handler.
pub prompt: Option<ViewerPrompt>,
/// Text the user has typed for the active prompt. Cleared on
/// commit / cancel.
pub prompt_input: String,
/// True while the viewer is reading a large file (≥ 1 MiB)
/// into its in-memory buffer. The renderer shows a spinner
/// and "Loading…" message until the first chunk is ready.
loading: bool,
/// Total render-frame count. Used to drive animation frames
/// (the loading spinner cycles through glyphs based on this).
frame_count: u64,
/// Syntax highlighter (when `syntect` is on and the file
/// extension is recognised). The renderer feeds lines to it
/// in order so multi-line constructs (block comments, strings)
/// parse correctly. `None` falls back to monochrome text.
#[cfg(feature = "syntect")]
highlighter: Option<crate::editor::syntax::Highlighter>,
/// Last rendered top line; on scroll we rebuild the highlighter
/// from scratch so its parser state matches reality.
#[cfg(feature = "syntect")]
last_render_top: u64,
}
impl Viewer {
@@ -84,6 +122,8 @@ impl Viewer {
}
};
let size = src.size();
#[cfg(feature = "syntect")]
let highlighter = crate::editor::syntax::Highlighter::new(&path);
Ok(Self {
source: src,
path,
@@ -96,6 +136,14 @@ impl Viewer {
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
growing: false,
last_size: size,
prompt: None,
prompt_input: String::new(),
loading: size >= crate::viewer::source::INLINE_THRESHOLD,
frame_count: 0,
#[cfg(feature = "syntect")]
highlighter,
#[cfg(feature = "syntect")]
last_render_top: 0,
})
}
@@ -107,6 +155,8 @@ impl Viewer {
source::FileSource::Chunked { .. } => Vec::new(),
};
let size = src.size();
#[cfg(feature = "syntect")]
let highlighter = crate::editor::syntax::Highlighter::new(&path);
Self {
source: src,
path,
@@ -119,6 +169,14 @@ impl Viewer {
text_view: text::TextView::new(String::from_utf8_lossy(&content).into_owned()),
growing: false,
last_size: size,
prompt: None,
prompt_input: String::new(),
loading: size >= crate::viewer::source::INLINE_THRESHOLD,
frame_count: 0,
#[cfg(feature = "syntect")]
highlighter,
#[cfg(feature = "syntect")]
last_render_top: 0,
}
}
@@ -191,6 +249,22 @@ impl Viewer {
self.growing
}
/// True while the viewer is still reading a large file into
/// its in-memory buffer. Returns false once the first chunk
/// has been rendered. Used by the renderer to show a
/// "Loading…" overlay.
#[must_use]
pub fn is_loading(&self) -> bool {
self.loading
}
/// Mark loading as complete. Called by the renderer after the
/// first chunk has been successfully written into
/// `text_view`.
pub fn finish_loading(&mut self) {
self.loading = false;
}
/// Toggle growing-buffer mode. When enabled, [`Viewer::check_growing`]
/// re-reads the file on each render to pick up appended content.
/// When transitioning from off to on, the current on-disk size
@@ -292,6 +366,16 @@ impl Viewer {
if area.width == 0 || area.height == 0 {
return;
}
self.frame_count = self.frame_count.wrapping_add(1);
// Auto-clear loading after a small number of render frames
// so the spinner is visibly transient even for chunked
// sources (which the current renderer does not yet feed
// back into text_view). Callers that successfully populate
// text_view can short-circuit this by calling
// `finish_loading` themselves.
if self.loading && self.frame_count >= 5 {
self.loading = false;
}
let _ = self.check_growing();
let viewer_default = mc_skin::color_pair(theme.name, "viewer", "_default_");
let viewer_bold = mc_skin::color_pair(theme.name, "viewer", "viewbold");
@@ -361,13 +445,55 @@ impl Viewer {
chunks[2],
);
}
// Prompt overlay: rendered on the top row so it doesn't
// collide with the footer area below. The body's border
// block still owns the central chunk.
if self.prompt.is_some() {
self.render_prompt_overlay(frame, area, theme);
}
}
/// Render the search / goto-line prompt at the top of the
/// viewer. Writes the prompt label + current input on a single
/// row that replaces the header area.
fn render_prompt_overlay(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
if area.height == 0 || area.width == 0 {
return;
}
let label = match self.prompt {
Some(ViewerPrompt::Search) => " Search: ",
Some(ViewerPrompt::GotoLine) => " Goto line: ",
None => return,
};
let text = format!("{label}{}_", self.prompt_input);
let prompt_p = Paragraph::new(Line::from(Span::styled(
text,
Style::default()
.fg(theme.title_fg)
.bg(theme.title_bg)
.add_modifier(Modifier::BOLD),
)));
let prompt_area = Rect::new(area.x, area.y, area.width, 1);
frame.render_widget(prompt_p, prompt_area);
}
/// Handle a key event. Returns `true` if the viewer was closed
/// (F10 / q / Esc), `false` if the key was consumed but the
/// viewer stays open.
pub fn handle_key(&mut self, key: Key) -> bool {
if key == Key::f(10) || key == Key::ESCAPE || key == Key::ctrl('q') {
// Prompt mode owns the keymap until the user commits
// (Enter) or cancels (Esc). Printable chars append to the
// input; Backspace deletes; Enter runs the prompt action.
if self.prompt.is_some() {
return self.handle_prompt_key(key);
}
if key == Key::f(10) || key == Key::ctrl('q') {
return true;
}
// Esc without an active prompt closes the viewer (matches
// the previous MC parity behavior).
if key == Key::ESCAPE {
return true;
}
if key == Key::f(4) {
@@ -393,6 +519,19 @@ impl Viewer {
self.toggle_growing();
return false;
}
// `/` — open search prompt. `g` — open goto-line prompt.
if mods.is_empty() {
if code == b'/' as u32 {
self.prompt = Some(ViewerPrompt::Search);
self.prompt_input.clear();
return false;
}
if code == b'g' as u32 {
self.prompt = Some(ViewerPrompt::GotoLine);
self.prompt_input.clear();
return false;
}
}
match code {
0x2191 => {
if self.top > 0 {
@@ -434,6 +573,60 @@ impl Viewer {
_ => false,
}
}
/// Handle a key while a [`ViewerPrompt`] is active. Enter
/// commits (search runs / goto-line jumps) and closes the
/// prompt; Esc cancels without committing; printable chars
/// append to `prompt_input`; Backspace deletes one char.
fn handle_prompt_key(&mut self, key: Key) -> bool {
let kind = match self.prompt {
Some(k) => k,
None => return false,
};
if key == Key::ESCAPE {
self.prompt = None;
self.prompt_input.clear();
return false;
}
if key == Key::ENTER {
let text = std::mem::take(&mut self.prompt_input);
self.prompt = None;
match kind {
ViewerPrompt::Search => {
if !text.is_empty() {
if let Err(e) = self.search(&text, false) {
log::debug!("search failed: {e}");
}
if let Some(m) = self.search.current() {
self.cursor = m.start;
}
}
}
ViewerPrompt::GotoLine => {
if let Ok(n) = text.trim().parse::<u64>() {
if n >= 1 {
if let Ok(target) = self.goto_line(n) {
self.cursor = target.offset;
self.top = target.line.saturating_sub(1);
}
}
}
}
}
return false;
}
if key == Key::BACKSPACE {
self.prompt_input.pop();
return false;
}
let Key { code, mods } = key;
if mods.is_empty() && (0x20..0x7f).contains(&code) {
if let Some(c) = char::from_u32(code) {
self.prompt_input.push(c);
}
}
false
}
}
/// Backwards-compat shim: `open_file` was the Phase 0 stub.
+368 -35
View File
@@ -25,6 +25,7 @@ use crate::terminal::color::Theme;
use crate::terminal::mc_skin;
use regex::RegexBuilder;
use super::nroff::{has_nroff_sequences, modifier_for, process_nroff, NroffRange};
use super::Viewer;
/// Maximum width of the line-number gutter.
@@ -223,16 +224,73 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
let body_fg = viewer_default.map(|p| p.fg).unwrap_or(theme.foreground);
let body_bg = viewer_default.map(|p| p.bg).unwrap_or(theme.background);
let bold_fg = viewer_bold.map(|p| p.fg).unwrap_or(theme.warning);
// Syntect is stateful: scrolling must rebuild the highlighter
// from scratch by replaying every line above the new top.
#[cfg(feature = "syntect")]
{
if v.last_render_top != v.top {
v.last_render_top = v.top;
v.highlighter = crate::editor::syntax::Highlighter::new(&v.path);
if let Some(ref mut h) = v.highlighter {
let full_text: String = match &v.source {
super::source::FileSource::Inline { bytes } => {
String::from_utf8_lossy(bytes).into_owned()
}
super::source::FileSource::Compressed { bytes, .. } => {
String::from_utf8_lossy(bytes).into_owned()
}
super::source::FileSource::Chunked { .. } => String::new(),
};
for i in 0..v.top as usize {
let line_off = v.goto.offset_for_line((i as u64) + 1).unwrap_or(0);
let next_off = v
.goto
.offset_for_line((i as u64) + 2)
.unwrap_or(full_text.len() as u64);
let end = (next_off as usize).min(full_text.len());
let start = (line_off as usize).min(end);
if let Some(line_text) = full_text.get(start..end) {
let trimmed = line_text.trim_end_matches('\n');
let _ = h.highlight_line(trimmed);
}
}
}
}
}
let bytes = match &v.source {
super::source::FileSource::Inline { bytes } => bytes,
super::source::FileSource::Compressed { bytes, .. } => bytes,
super::source::FileSource::Chunked { .. } => {
// For chunked sources we currently show only the first
// chunk's text. Full chunked rendering is a future enhancement.
let _ = v;
let p = Paragraph::new("(chunked source: not yet rendered in Phase 3.3)")
.style(Style::default().fg(theme.hidden).bg(body_bg));
frame.render_widget(p, area);
// chunk's text. Full chunked rendering is a future
// enhancement. While the file is still being read, the
// viewer shows a spinner + "Loading..." overlay so the
// user knows the gap is transient.
if v.is_loading() {
let spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spin_idx = (v.frame_count as usize) % spinner_chars.len();
let msg = Line::from(vec![
Span::styled(
spinner_chars[spin_idx].to_string(),
Style::default().fg(theme.info).bg(body_bg),
),
Span::styled(
" Loading...",
Style::default().fg(theme.foreground).bg(body_bg),
),
Span::styled(
format!(" ({} MiB)", v.size() / (1024 * 1024)),
Style::default().fg(theme.hidden).bg(body_bg),
),
]);
let p = Paragraph::new(msg)
.style(Style::default().fg(theme.foreground).bg(body_bg));
frame.render_widget(p, area);
} else {
let p = Paragraph::new("(chunked source: not yet rendered in Phase 3.3)")
.style(Style::default().fg(theme.hidden).bg(body_bg));
frame.render_widget(p, area);
}
return;
}
};
@@ -250,6 +308,32 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
.unwrap_or(0);
let top = v.top as usize;
// Strip nroff backspace-overstrike sequences if present. The
// cleaned text may have fewer bytes than the source, so the
// goto line-offset table (built from the raw bytes) is no
// longer authoritative — we recompute per-line starts from the
// cleaned text and pass `nroff_ranges` through to the per-line
// renderer so it can apply Bold/Underline modifiers.
let (text, nroff_ranges): (String, Vec<NroffRange>) = if has_nroff_sequences(&text) {
let (clean, ranges) = process_nroff(&text);
(clean, ranges)
} else {
(text.into_owned(), Vec::new())
};
// Local line-offset table for the cleaned text: line_idx -> byte
// start. We compute this here rather than reusing `v.goto` so the
// gutter and per-line `line_start` arguments line up with the
// nroff-cleaned text the renderer is about to draw.
let line_offsets: Vec<usize> = {
let mut offsets = vec![0usize];
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
offsets.push(i + 1);
}
}
offsets
};
// Pull the current match list once. The slice is borrowed
// immutably for the rest of the loop; we do not mutate the
// viewer's search state in `render`.
@@ -287,17 +371,28 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
Style::default().fg(theme.hidden).bg(body_bg)
};
gutter_spans.push(Span::styled(g_text, g_style));
// Content. The line's absolute byte offset is the entry
// `line_idx` of the goto line-offset table (1-based line
// numbering: line 1 starts at offset 0).
let line_start = v.goto.offset_for_line((line_idx + 1) as u64).unwrap_or(0) as usize;
// Content. The line's absolute byte offset in the cleaned
// text comes from the local `line_offsets` table — goto's
// table is built from the raw source bytes which differ
// after nroff processing.
let line_start = line_offsets.get(line_idx).copied().unwrap_or(0);
let cursor_col = if line_idx as u64 == cursor_line {
Some(cursor_col_in_line(v, &lines, line_idx))
} else {
None
};
let spans =
render_line_with_highlight(line, line_start, matches, cursor_col, usable_w, v.wrap, theme);
let spans = render_line_with_highlight(
line,
line_start,
matches,
cursor_col,
usable_w,
v.wrap,
theme,
&nroff_ranges,
#[cfg(feature = "syntect")]
v.highlighter.as_mut(),
);
content_lines.push(Line::from(spans));
}
// Render side-by-side via Paragraph with a tab-like layout.
@@ -324,6 +419,10 @@ pub fn render(v: &mut Viewer, frame: &mut Frame, area: Rect, theme: &Theme) {
/// source text. `matches` is the global list of match byte ranges
/// in the source text; only matches whose start falls inside
/// `[line_start, line_start + line.len())` are rendered here.
/// `nroff_ranges` are byte ranges in the cleaned (post-nroff) text
/// that should receive bold / underline modifiers from the
/// backspace-overstrike stripper.
#[allow(clippy::too_many_arguments)]
fn render_line_with_highlight(
line: &str,
line_start: usize,
@@ -332,6 +431,8 @@ fn render_line_with_highlight(
usable_w: usize,
wrap: bool,
theme: &Theme,
nroff_ranges: &[NroffRange],
#[cfg(feature = "syntect")] highlighter: Option<&mut crate::editor::syntax::Highlighter>,
) -> Vec<Span<'static>> {
let _ = wrap; // TODO: word-wrap
let line_len = line.len();
@@ -350,36 +451,54 @@ fn render_line_with_highlight(
}
}
// Find nroff ranges that overlap this line, expressed as relative
// offsets. We will apply their modifier on top of any syntax/match
// style.
let mut nroff_in_line: Vec<(usize, usize, super::nroff::NroffStyle)> = Vec::new();
for r in nroff_ranges {
if r.end <= line_start || r.start >= line_end {
continue;
}
let s = r.start.saturating_sub(line_start).min(line_len);
let e = (r.end - line_start).min(line_len);
if e > s {
nroff_in_line.push((s, e, r.style));
}
}
// Build the base spans. With a syntax highlighter, we feed the
// line through it to get per-token colors. Without one, we
// fall back to a single foreground-colored span.
let mut spans: Vec<Span<'static>> = Vec::new();
if matches_in_line.is_empty() {
#[cfg(feature = "syntect")]
{
if let Some(h) = highlighter {
let highlighted = h.highlight_line(line);
spans = highlighted
.into_iter()
.map(|mut s| {
let trimmed = fit_owned(s.content.to_string(), usable_w);
s.content = trimmed.into();
s
})
.collect();
}
}
if spans.is_empty() {
spans.push(Span::styled(
fit(line, usable_w).to_string(),
Style::default().fg(theme.foreground),
));
} else {
}
// Apply search match highlight ON TOP of syntax colors.
if !matches_in_line.is_empty() {
matches_in_line.sort_by_key(|m| m.0);
let mut cursor = 0usize;
for (s, e) in matches_in_line.iter() {
let s = *s;
let e = (*e).min(line_len);
if s > cursor {
spans.push(Span::styled(
fit(&line[cursor..s.min(line_len)], usable_w).to_string(),
Style::default().fg(theme.foreground),
));
}
spans.push(Span::styled(
fit(&line[s..e], usable_w).to_string(),
match_style(theme),
));
cursor = e;
}
if cursor < line_len {
spans.push(Span::styled(
fit(&line[cursor..], usable_w).to_string(),
Style::default().fg(theme.foreground),
));
}
spans = overlay_match_highlight(spans, &matches_in_line, theme);
}
// Apply nroff Bold/Underline modifiers on top of everything else.
if !nroff_in_line.is_empty() {
nroff_in_line.sort_by_key(|r| r.0);
spans = overlay_nroff_styles(spans, &nroff_in_line);
}
// Cursor marker.
if let Some(col) = cursor_col {
@@ -417,6 +536,168 @@ fn fit(s: &str, w: usize) -> &str {
}
}
/// Truncate `s` to `w` characters, returning an owned `String`.
fn fit_owned(s: String, w: usize) -> String {
if w == 0 {
return String::new();
}
if s.chars().count() <= w {
return s;
}
match s.char_indices().nth(w) {
Some((idx, _)) => s[..idx].to_string(),
None => s,
}
}
/// Apply search-match highlighting as an overlay on top of existing
/// syntax-highlighted spans. Bytes inside any `match_range` receive
/// the warning background; the syntax foreground color is preserved.
/// Spans that straddle a match boundary are split at UTF-8 char
/// boundaries.
fn overlay_match_highlight(
base_spans: Vec<Span<'static>>,
matches_in_line: &[(usize, usize)],
theme: &Theme,
) -> Vec<Span<'static>> {
let ms = match_style(theme);
let mut out: Vec<Span<'static>> = Vec::with_capacity(base_spans.len() + 2);
let mut pos: usize = 0;
for span in base_spans {
let span_start = pos;
let span_text = span.content.to_string();
let span_end = pos + span_text.len();
pos = span_end;
// Walk the match ranges that overlap this span, emitting
// pre-match / inside-match pieces in order, then a final
// post-match piece for any leftover tail.
let mut last_cut = span_start;
for &(ms_start, ms_end) in matches_in_line {
if ms_end <= span_start || ms_start >= span_end {
continue;
}
let cut_a = ms_start.max(span_start).min(span_end);
let cut_b = ms_end.min(span_end);
// Pre-match chunk.
if cut_a > last_cut {
let local_from = last_cut - span_start;
let local_to = cut_a - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
out.push(Span::styled(piece.to_string(), span.style));
}
}
// Match chunk — apply match background, keep foreground.
if cut_b > cut_a {
let local_from = cut_a - span_start;
let local_to = cut_b - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
let mut s = span.style;
if let Some(bg) = ms.bg {
s = s.bg(bg);
}
if let Some(fg) = ms.fg {
s = s.fg(fg);
}
s = s.add_modifier(ms.add_modifier);
out.push(Span::styled(piece.to_string(), s));
}
}
last_cut = cut_b;
}
if last_cut < span_end {
let local_from = last_cut - span_start;
let local_to = span_end - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
out.push(Span::styled(piece.to_string(), span.style));
}
}
}
out
}
/// Apply nroff Bold/Underline modifiers on top of existing
/// syntax/match-styled spans. Each nroff range maps a relative
/// byte range inside the line to a style; the foreground and
/// background colors of the underlying span are preserved — only
/// the `Modifier` bits are extended with the nroff style.
fn overlay_nroff_styles(
base_spans: Vec<Span<'static>>,
nroff_in_line: &[(usize, usize, super::nroff::NroffStyle)],
) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::with_capacity(base_spans.len() + 2);
let mut pos: usize = 0;
for span in base_spans {
let span_start = pos;
let span_text = span.content.to_string();
let span_end = pos + span_text.len();
pos = span_end;
let mut last_cut = span_start;
for &(rs, re, style) in nroff_in_line {
if re <= span_start || rs >= span_end {
continue;
}
let cut_a = rs.max(span_start).min(span_end);
let cut_b = re.min(span_end);
if cut_a > last_cut {
let local_from = last_cut - span_start;
let local_to = cut_a - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
out.push(Span::styled(piece.to_string(), span.style));
}
}
if cut_b > cut_a {
let local_from = cut_a - span_start;
let local_to = cut_b - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
let mut s = span.style;
s = s.add_modifier(modifier_for(style));
out.push(Span::styled(piece.to_string(), s));
}
}
last_cut = cut_b;
}
if last_cut < span_end {
let local_from = last_cut - span_start;
let local_to = span_end - span_start;
let piece = slice_char_boundary(&span_text, local_from, local_to);
if !piece.is_empty() {
out.push(Span::styled(piece.to_string(), span.style));
}
}
}
out
}
/// Slice `s` from `from` to `to` in byte indices, clamped to UTF-8
/// char boundaries.
fn slice_char_boundary(s: &str, from: usize, to: usize) -> &str {
let len = s.len();
let lo = round_up_to_char_boundary(s, from.min(len));
let hi = round_up_to_char_boundary(s, to.min(len));
if lo >= hi {
""
} else {
&s[lo..hi]
}
}
/// Round `idx` up to the next UTF-8 char boundary in `s`.
fn round_up_to_char_boundary(s: &str, idx: usize) -> usize {
if idx >= s.len() {
return s.len();
}
let mut i = idx;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
@@ -585,4 +866,56 @@ mod tests {
"the 'f' before the match must not be highlighted"
);
}
#[test]
fn fit_owned_truncates_by_chars() {
assert_eq!(fit_owned("hello".to_string(), 10), "hello");
assert_eq!(fit_owned("hello".to_string(), 5), "hello");
assert_eq!(fit_owned("hello".to_string(), 3), "hel");
assert_eq!(fit_owned("hello".to_string(), 0), "");
// CJK — each char is one visual unit regardless of byte length.
assert_eq!(fit_owned("日本語".to_string(), 2), "日本");
}
#[test]
fn overlay_match_highlight_splits_spans_at_match_boundaries() {
use ratatui::style::{Color as RC, Style};
let red = RC::Rgb(255, 0, 0);
let base = Style::default().fg(red);
let base_spans = vec![
Span::styled("foo".to_string(), base),
Span::styled(" ".to_string(), base),
Span::styled("bar".to_string(), base),
];
// Match "o b" at byte offsets 2..5 (crosses two spans).
let theme = crate::terminal::color::DEFAULT_THEME;
let out = overlay_match_highlight(base_spans, &[(2, 5)], &theme);
let total: String = out.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(total, "foo bar");
}
#[test]
fn overlay_match_highlight_no_match_returns_base_spans() {
use ratatui::style::{Color as RC, Style};
let base = Style::default().fg(RC::Rgb(255, 0, 0));
let spans = vec![Span::styled("abc".to_string(), base)];
let theme = crate::terminal::color::DEFAULT_THEME;
let out = overlay_match_highlight(spans.clone(), &[], &theme);
assert_eq!(out.len(), 1);
assert_eq!(out[0].content, "abc");
}
#[test]
fn slice_char_boundary_handles_mid_codepoint_indices() {
// "é" = 0xC3 0xA9 (2 bytes). `round_up_to_char_boundary`
// rounds mid-codepoint indices UP to the end of the
// codepoint, so slice(0, 1) expands to the whole "é".
assert_eq!(slice_char_boundary("é", 0, 1), "é");
assert_eq!(slice_char_boundary("é", 0, 2), "é");
// slice(1, 2): from rounds up to 2 → empty range.
assert_eq!(slice_char_boundary("é", 1, 2), "");
assert_eq!(slice_char_boundary("abc", 1, 3), "bc");
assert_eq!(slice_char_boundary("abc", 0, 99), "abc");
assert_eq!(slice_char_boundary("abc", 5, 10), "");
}
}