diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index b2fe908759..38ebd1cce3 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -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; diff --git a/local/recipes/tui/tlc/source/src/editor/buffer.rs b/local/recipes/tui/tlc/source/src/editor/buffer.rs index 73a42852e3..f6c2ba88a8 100644 --- a/local/recipes/tui/tlc/source/src/editor/buffer.rs +++ b/local/recipes/tui/tlc/source/src/editor/buffer.rs @@ -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`]. diff --git a/local/recipes/tui/tlc/source/src/editor/clipboard_osc52.rs b/local/recipes/tui/tlc/source/src/editor/clipboard_osc52.rs new file mode 100644 index 0000000000..d2a052a432 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/clipboard_osc52.rs @@ -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> { + 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;\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 { + 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> { + 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 { + 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 { + 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 = (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()); + } +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/editor/completion.rs b/local/recipes/tui/tlc/source/src/editor/completion.rs index c5ba0e2fca..f876114b5f 100644 --- a/local/recipes/tui/tlc/source/src/editor/completion.rs +++ b/local/recipes/tui/tlc/source/src/editor/completion.rs @@ -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 { diff --git a/local/recipes/tui/tlc/source/src/editor/cursor_shape.rs b/local/recipes/tui/tlc/source/src/editor/cursor_shape.rs new file mode 100644 index 0000000000..6f369efff5 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/cursor_shape.rs @@ -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, +} \ No newline at end of file diff --git a/local/recipes/tui/tlc/source/src/editor/folding.rs b/local/recipes/tui/tlc/source/src/editor/folding.rs new file mode 100644 index 0000000000..7279aa3f5a --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/folding.rs @@ -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, +} + +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 { + 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 { + 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); + } +} diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 0d639f9f3f..e3b7a31587 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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 { + 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 { + 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() +} diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index f91ae91c75..fe7f0d4dee 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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, /// Internal clipboard for F5/F6 copy/cut and Ctrl-V paste. clipboard: Option, + /// 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, + /// 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, + /// 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, +} + +/// 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 { + 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 { + 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"); diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index b46de72fab..2f319b1449 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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 = (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 = (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 = 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 = 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 = 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 = (0..chunks[1].height as usize) + let margin_lines: Vec = (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 = 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 = 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>, 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 +} diff --git a/local/recipes/tui/tlc/source/src/editor/search.rs b/local/recipes/tui/tlc/source/src/editor/search.rs index 686f66158a..a93e23b715 100644 --- a/local/recipes/tui/tlc/source/src/editor/search.rs +++ b/local/recipes/tui/tlc/source/src/editor/search.rs @@ -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] diff --git a/local/recipes/tui/tlc/source/src/editor/tags.rs b/local/recipes/tui/tlc/source/src/editor/tags.rs new file mode 100644 index 0000000000..70005bb1a9 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/tags.rs @@ -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 +//! `\t\t` 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 +//! namefileexcmd +//! ``` +//! +//! 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>, +} + +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) -> Result { + 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 {{ fn push/;\n").unwrap(); + writeln!(f, "push\tdeque.rs\t/^impl Deque {{ 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(""), ""); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 90793bb156..d4b84374a5 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -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() diff --git a/local/recipes/tui/tlc/source/src/filemanager/render.rs b/local/recipes/tui/tlc/source/src/filemanager/render.rs index dd6e9119c1..c47788d8af 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/render.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/render.rs @@ -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), } } } diff --git a/local/recipes/tui/tlc/source/src/skin/mod.rs b/local/recipes/tui/tlc/source/src/skin/mod.rs index 84c28f8eba..60defe0cb1 100644 --- a/local/recipes/tui/tlc/source/src/skin/mod.rs +++ b/local/recipes/tui/tlc/source/src/skin/mod.rs @@ -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(); } diff --git a/local/recipes/tui/tlc/source/src/terminal/color.rs b/local/recipes/tui/tlc/source/src/terminal/color.rs index a543b357e7..5019ff14cf 100644 --- a/local/recipes/tui/tlc/source/src/terminal/color.rs +++ b/local/recipes/tui/tlc/source/src/terminal/color.rs @@ -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. diff --git a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs index 73db12cfd5..33cd069423 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs @@ -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), diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 57a3141c79..486da9c302 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -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, + /// 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, + /// 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::() { + 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. diff --git a/local/recipes/tui/tlc/source/src/viewer/text.rs b/local/recipes/tui/tlc/source/src/viewer/text.rs index 7b97ed595d..2deb0b7f87 100644 --- a/local/recipes/tui/tlc/source/src/viewer/text.rs +++ b/local/recipes/tui/tlc/source/src/viewer/text.rs @@ -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) = 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 = { + 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> { 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> = 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>, + matches_in_line: &[(usize, usize)], + theme: &Theme, +) -> Vec> { + let ms = match_style(theme); + let mut out: Vec> = 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>, + nroff_in_line: &[(usize, usize, super::nroff::NroffStyle)], +) -> Vec> { + let mut out: Vec> = 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), ""); + } }