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:
@@ -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-V — Paste from internal clipboard.
|
||||
// Ctrl-F1 — toggle 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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user