Files
RedBear-OS/local/recipes/tui/tlc/source/src/editor/mod.rs
T
vasilito d4cb65fcff tlc: phase 19 — column (rectangular) block operations
Adds MC's column-block selection to the editor. Algorithm
cross-references MC WEdit::column_highlight + MarkColumn* key
bindings in misc/mc.default.keymap.

Cursor changes:
  - SelectionMode::{Stream, Column} enum
  - column_anchor: Option<usize> (parallel to anchor)
  - column_selection_rect() → normalized ColumnRect
  - selected_text() / delete_selection() handle column mode:
      * Column copy text = rows joined by \n
      * Column delete strips rectangle cols from each line,
        cursor lands at top-left corner, anchor cleared
  - start_selection() / start_column_selection() are
    mutually exclusive (switching clears other anchor)
  - has_selection() / clear_selection() honor both modes

Keybindings (editor/handlers.rs):
  - Alt+Left/Right → MarkColumnLeft/Right (column horiz extend)
  - Alt+Up/Down   → MarkColumnUp/Down   (column vert extend)
  - Alt+PgUp/PgDn → MarkColumnPageUp/Down (column page extend)
  - Existing Shift+Arrow still produces stream selections

Renderer (editor/render.rs):
  - new line_selection_range() helper returns per-line byte
    range from either stream or column source
  - column cells get [editor] editmarked background highlight
  - syntect path naturally uses byte ranges, so column highlight
    composes with syntax coloring

Tests: 1119 passed (was 1117, +2 handler tests; cursor +7 tests).

PLAN.md: §15d row 21 marked  Done; Changelog entry added.
2026-06-20 18:41:10 +03:00

2420 lines
85 KiB
Rust

//! Full-screen text editor.
//!
//! The editor assembles:
//! - [`Buffer`] — gap-buffer text storage with EOL tracking
//! - [`Cursor`] — byte-position cursor with optional selection
//! - [`EditorView`]— scroll offsets (top line, left column) and
//! "ensure cursor visible" logic
//! - [`save`] — file I/O (load + save with EOL preservation)
//! - [`mode`] — [`Mode`] / [`PromptKind`] enums and helpers
//! - [`prompt`] — [`PromptInput`] text field for active prompts
//! - [`handlers`] — key dispatch (`handle_key`, per-mode handlers)
//! - [`render`] — `Editor::render` and rendering helpers
//! - [`bracket`] — bracket-match and word-char helpers
//!
//! The [`Editor`] struct is the public handle the application uses:
//! it owns all of the above plus a [`History`] of recently-opened
//! files and a [`Mode`] / [`PromptInput`] state machine for "are we
//! asking the user to save before close?" plus future prompts
//! (search, goto, replace, save-as).
//!
//! All rendering goes through [`Editor::render`], which writes to a
//! ratatui `Frame` at the given `Rect`. The view is a simple
//! top-line/left-column scroller with line numbers in the gutter.
//!
//! All key handling goes through [`Editor::handle_key`], which
//! dispatches to [`Editor::handle_key_normal`],
//! [`Editor::handle_key_insert`], or [`Editor::handle_key_prompt`]
//! based on the current [`Mode`].
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;
/// Per-file cursor position save/restore (MC `~/.mc/filepos`).
pub mod filepos;
pub mod folding;
pub mod format;
pub mod goto;
pub mod handlers;
pub mod history;
#[path = "macro.rs"]
pub mod macros;
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};
pub use prompt::PromptInput;
pub use view::EditorView;
// Re-exports for tests in mod.rs that call these free functions
// directly via `use super::*;`. They live in their respective
// submodules; this keeps the test surface unchanged.
#[cfg(test)]
pub(crate) use bracket::{
find_matching_backward, find_matching_forward, is_completion_word_char,
};
#[cfg(feature = "syntect")]
#[cfg(test)]
pub(crate) use render::{round_up_to_char_boundary, split_spans_for_selection};
/// Outcome of a key event for the application loop.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorResult {
/// The user is still editing — feed the next key.
Running,
/// F2 / Ctrl-S — save the buffer and continue.
Save,
/// Esc / F10 — close the editor.
Close,
/// The user is on the "save before close?" prompt and chose Yes.
SaveThenClose,
/// The user is on the "save before close?" prompt and chose No.
DiscardThenClose,
}
/// The full-screen editor: buffer + cursor + view + history.
pub struct Editor {
/// The text being edited.
buffer: Buffer,
/// Cursor position and selection.
cursor: Cursor,
/// Scroll offsets.
view: EditorView,
/// The file path the buffer was loaded from (or will be saved to).
path: Option<PathBuf>,
/// True if the buffer has been modified since the last load/save.
modified: bool,
/// Recently opened files (most recent last).
history: History,
/// Status line message (set by the last action).
message: Option<String>,
/// Current mode — Normal/Insert/Prompt(PromptKind). The
/// `SaveBeforeClose` prompt lives here, replacing the old
/// standalone `save_prompt: Option<SavePrompt>` state.
mode: Mode,
/// The user's typed input and cursor for the active prompt.
/// Unused when `mode` is `Mode::Insert` or `Mode::Normal`, and
/// for `PromptKind::SaveBeforeClose` (which has no text field).
prompt_input: PromptInput,
/// Title shown above the buffer.
title: String,
/// Most-recent Find/Replace pattern, set by the prompt on
/// commit. The live search-highlight UI is wired by Phase 5d;
/// for now this is just a sink so callers can read the pattern
/// back without re-parsing the prompt.
search_pattern: Option<String>,
/// Internal clipboard for F5/F6 copy/cut and Ctrl-V paste.
clipboard: Option<String>,
/// When true, long lines are wrapped at word boundaries in the
/// body area. When false (default), long lines are truncated at
/// the right margin. Toggled by Alt-W.
word_wrap: bool,
/// Word completion session (Alt-Tab).
completer: Completer,
/// Saved word-prefix length for completion replacement.
complete_prefix_len: usize,
/// Named editor bookmarks.
bookmarks: BookmarkSet,
/// Syntax highlighter (when syntect feature is enabled and the
/// file extension is recognized).
#[cfg(feature = "syntect")]
highlighter: Option<crate::editor::syntax::Highlighter>,
/// Track scroll position to reset highlighter state on scroll.
#[cfg(feature = "syntect")]
last_render_top: usize,
/// Cached bracket-match pair (opening byte offset, closing byte
/// offset) for the flash highlight, or `None` when the cursor is
/// not on a bracket.
bracket_flash: Option<(usize, usize)>,
/// In-buffer search engine — pattern, history, last match. The
/// history backs the Alt-/ popup in the Find/Replace prompts.
search: SearchState,
/// When Some, the search-history popup is open. The value is the
/// selected index into `self.search.history()` (0-based, with 0
/// being the most recent entry). `None` means the popup is
/// closed.
history_popup_selected: Option<usize>,
/// Visual shape of the editing cursor (Block/Bar/Underline).
cursor_shape: CursorShape,
/// When true, the gutter shows each non-cursor line's distance
/// from the cursor line instead of its absolute number.
relative_lines: bool,
/// When false, syntax highlighting is disabled even if a
/// highlighter exists. Toggled by Ctrl-S (MC parity).
syntax_enabled: bool,
/// When true, typing replaces the character at the cursor
/// instead of inserting before it. Toggled by the Insert key
/// (MC InsertOverwrite parity).
overwrite: bool,
/// When true, the next key event is inserted verbatim into the
/// buffer (Ctrl-Q InsertLiteral, MC parity).
insert_literal: bool,
show_help: bool,
/// Macro recorder (Ctrl-R toggles, Ctrl-P plays back). When
/// `recording`, every key event is appended to its current
/// sequence. The captured sequence is moved to `last_macro`
/// when recording stops.
macro_recorder: macros::MacroRecorder,
/// The most recently recorded macro (Ctrl-P replays it).
/// Empty until the first recording is stopped.
last_macro: Vec<macros::NamedKey>,
/// Code-fold state. `Ctrl-F1` toggles a fold at the cursor
/// line; the renderer hides lines covered by collapsed folds
/// and shows a `+` marker in the gutter on the fold's start
/// line.
folds: folding::FoldSet,
/// Tag-stack: positions saved before each `Ctrl-]` jump so
/// `Ctrl-T` can pop back. Each entry is `(byte_offset, line)`.
tag_stack: Vec<(usize, u32)>,
/// Active smooth-scroll animation. `Some` while the view is
/// animating from `current` to `target`; `None` when the view
/// is settled. The render path reads `current.round() as usize`
/// as the effective top line; each animation tick advances
/// `current` by 25 % toward `target`.
smooth_scroll: Option<SmoothScroll>,
}
/// One in-flight smooth-scroll animation.
///
/// `target` is the integer top-line we are scrolling to.
/// `current` is a `f32` that approaches `target` by 25 % per
/// tick, producing a smooth ease-out animation. The renderer
/// rounds `current` for display purposes.
#[derive(Debug, Clone, Copy)]
struct SmoothScroll {
/// Destination top-line index.
target: usize,
/// Current animated top-line index (non-integer during animation).
current: f32,
}
impl Editor {
/// Open an existing file. If `path` does not exist, a new empty
/// buffer is created in memory; the first save will create the
/// file.
pub fn open(path: impl AsRef<Path>) -> Self {
let path = path.as_ref().to_path_buf();
let buffer = save::load_from_file(&path).unwrap_or_else(|_| Buffer::new());
let title = format!(" {} {} ", crate::locale::t("dialog_title_editor"), path.display());
let mut history = History::with_capacity(20);
history.push(&path);
#[cfg(feature = "syntect")]
let hl = crate::editor::syntax::Highlighter::new(&path);
Self {
buffer,
cursor: Cursor::new(),
view: EditorView::new(),
path: Some(path),
modified: false,
history,
message: None,
mode: Mode::Insert,
prompt_input: PromptInput::new(),
title,
search_pattern: None,
clipboard: None,
word_wrap: false,
completer: Completer::new(),
complete_prefix_len: 0,
bookmarks: BookmarkSet::new(),
#[cfg(feature = "syntect")]
highlighter: hl,
#[cfg(feature = "syntect")]
last_render_top: 0,
bracket_flash: None,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
relative_lines: false,
syntax_enabled: true,
overwrite: false,
insert_literal: false,
show_help: false,
macro_recorder: macros::MacroRecorder::new(),
last_macro: Vec::new(),
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
}
}
/// Open a new, empty, untitled buffer. The next save will prompt
/// for a path (handled by the caller).
pub fn new_empty() -> Self {
Self {
buffer: Buffer::new(),
cursor: Cursor::new(),
view: EditorView::new(),
path: None,
modified: false,
history: History::with_capacity(20),
message: Some("New buffer — Ctrl-S to save".to_string()),
mode: Mode::Insert,
prompt_input: PromptInput::new(),
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,
search: SearchState::new(),
history_popup_selected: None,
cursor_shape: CursorShape::default(),
relative_lines: false,
syntax_enabled: true,
overwrite: false,
insert_literal: false,
show_help: false,
macro_recorder: macros::MacroRecorder::new(),
last_macro: Vec::new(),
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
}
}
/// Jump cursor to a 1-based line number and scroll the
/// viewport so the line is visible.
pub fn goto_line(&mut self, line: u32) {
if line >= 1 {
if let Ok(off) = crate::editor::goto::line_to_offset(&self.buffer, line) {
self.buffer.set_cursor(off);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
}
}
}
/// Restore cursor line/column from the filepos database for the
/// current file. Returns true if a saved position was found and
/// applied.
pub fn restore_cursor_position(&mut self) -> bool {
let Some(path) = self.path.as_deref() else {
return false;
};
let Some(pos) = filepos::load(path) else {
return false;
};
if pos.line >= 1 {
if let Ok(off) = crate::editor::goto::line_to_offset(&self.buffer, pos.line) {
let line_len = self.buffer.line_length((pos.line - 1) as usize);
let col = (pos.column as usize).min(line_len);
self.buffer.set_cursor(off + col);
self.cursor.set_position(off + col, &self.buffer);
return true;
}
}
false
}
/// Save the current cursor line/column to the filepos database.
/// No-op if the buffer has no path. Errors are silently
/// dropped — filepos is a best-effort UX feature.
pub fn save_cursor_position(&self) {
let Some(path) = self.path.as_deref() else {
return;
};
let cursor = self.cursor.position();
let mut line = 0u32;
let mut line_start = 0usize;
for (i, b) in self.buffer.as_string().as_bytes().iter().enumerate() {
if i == cursor {
break;
}
if *b == b'\n' {
line += 1;
line_start = i + 1;
}
}
let _ = filepos::save(
path,
filepos::CursorPos {
line: line + 1,
column: (cursor - line_start) as u32,
},
);
}
/// Borrow the underlying buffer.
#[must_use]
pub fn buffer(&self) -> &Buffer {
&self.buffer
}
/// Borrow the cursor.
#[must_use]
pub fn cursor(&self) -> &Cursor {
&self.cursor
}
/// Borrow the scroll view.
#[must_use]
pub fn view(&self) -> &EditorView {
&self.view
}
/// The current editor mode.
#[must_use]
pub fn mode(&self) -> Mode {
self.mode
}
/// The active prompt's text input, if a prompt is open.
/// Returns `None` in Normal/Insert modes.
#[must_use]
pub fn prompt_input(&self) -> Option<&PromptInput> {
if self.mode.is_prompt() {
Some(&self.prompt_input)
} else {
None
}
}
/// The file path being edited, or `None` for an untitled buffer.
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// True if the buffer has been modified since the last load/save.
#[must_use]
pub fn is_modified(&self) -> bool {
self.modified
}
/// Whether word-wrap is enabled (Alt-W toggles this).
#[must_use]
pub fn word_wrap(&self) -> bool {
self.word_wrap
}
/// Toggle word-wrap on or off. Returns the new value.
pub fn toggle_word_wrap(&mut self) -> bool {
self.word_wrap = !self.word_wrap;
self.word_wrap
}
/// Borrow the in-buffer search engine state (pattern, history,
/// last match).
#[must_use]
pub fn search_state(&self) -> &SearchState {
&self.search
}
/// True if the search-history popup is currently open.
#[must_use]
pub fn history_popup_open(&self) -> bool {
self.history_popup_selected.is_some()
}
/// Open the search-history popup, pre-selecting the most recent
/// entry. No-op if the popup is already open or the search
/// history is empty.
pub fn open_history_popup(&mut self) {
if self.history_popup_selected.is_some() {
return;
}
if self.search.history().is_empty() {
return;
}
// Most recent entry is the last in the vector; index 0 = most
// recent in the popup's display order.
self.history_popup_selected = Some(0);
}
/// Close the search-history popup.
pub fn close_history_popup(&mut self) {
self.history_popup_selected = None;
}
/// Move the popup selection up by one entry, clamped at 0.
pub fn history_popup_up(&mut self) {
if let Some(sel) = self.history_popup_selected {
if sel > 0 {
self.history_popup_selected = Some(sel - 1);
}
}
}
/// Move the popup selection down by one entry, clamped at the
/// last history entry. No-op if the popup is closed or empty.
pub fn history_popup_down(&mut self) {
let max = self.search.history().len();
if max == 0 {
self.history_popup_selected = None;
return;
}
if let Some(sel) = self.history_popup_selected {
if sel + 1 < max {
self.history_popup_selected = Some(sel + 1);
}
}
}
/// Read the currently-selected history entry as a string. The
/// popup displays most-recent-first, so we reverse the index.
#[must_use]
pub fn history_popup_selected_entry(&self) -> Option<String> {
let history = self.search.history();
let sel = self.history_popup_selected?;
let idx = history.len().checked_sub(1 + sel)?;
history.get(idx).cloned()
}
fn update_bracket_flash(&mut self) {
let pos = self.cursor.position();
let text = self.buffer.as_string();
if pos >= text.len() {
self.bracket_flash = None;
return;
}
let ch = text[pos..].chars().next().unwrap();
let open = "([{<";
let close = ")]}>";
let result = if let Some(idx) = open.find(ch) {
let target = close.as_bytes()[idx] as char;
Self::find_matching_forward(&text, pos, ch, target)
.map(|end| (pos, end))
} else if let Some(idx) = close.find(ch) {
let target = open.as_bytes()[idx] as char;
Self::find_matching_backward(&text, pos, ch, target)
.map(|start| (start, pos))
} else {
None
};
self.bracket_flash = result;
}
fn find_matching_forward(text: &str, start: usize, open: char, close: char) -> Option<usize> {
let mut depth = 1i32;
let mut offset = start + open.len_utf8();
for (i, ch) in text[offset..].char_indices() {
let abs = offset + i;
if ch == open {
depth += 1;
} else if ch == close {
depth -= 1;
if depth == 0 {
return Some(abs);
}
}
offset = abs + ch.len_utf8();
}
None
}
fn find_matching_backward(text: &str, start: usize, close: char, open: char) -> Option<usize> {
let mut depth = 1i32;
for (i, ch) in text[..start].char_indices().rev() {
if ch == close {
depth += 1;
} else if ch == open {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
}
None
}
/// Set a named bookmark at the current cursor location.
pub fn set_bookmark(&mut self, name: char) -> Result<(), String> {
let line = self.buffer_line_of(self.cursor.position()) as u32;
let col = self.cursor.visual_column() as u32;
self.bookmarks.set(name, Mark::new(line, col))
}
/// Clear a named bookmark.
pub fn clear_bookmark(&mut self, name: char) -> bool {
self.bookmarks.clear(name)
}
/// Save the buffer to its current path. Returns an error if the
/// buffer has no path (untitled). On success, clears `modified`.
pub fn save(&mut self) -> std::io::Result<()> {
let path = match &self.path {
Some(p) => p.clone(),
None => {
return Err(std::io::Error::other("no path set for this buffer"));
}
};
save::save_to_file(&self.buffer, &path)?;
self.buffer.mark_saved();
self.modified = false;
self.message = Some(format!("Saved {}", path.display()));
Ok(())
}
/// Save the buffer to a new path. On success, updates the path
/// and clears `modified`.
pub fn save_as(&mut self, path: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref().to_path_buf();
save::save_to_file(&self.buffer, &path)?;
self.path = Some(path.clone());
self.buffer.mark_saved();
self.modified = false;
self.history.push(&path);
self.title = format!(
" {} {} ",
crate::locale::t("dialog_title_editor"),
path.display()
);
self.message = Some(format!("Saved as {}", path.display()));
Ok(())
}
/// Insert a character at the cursor. Marks the buffer as
/// modified.
pub fn insert_char(&mut self, c: char) {
self.completer.cancel();
self.buffer.insert_char(c);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Insert a string at the cursor.
pub fn insert_str(&mut self, s: &str) {
self.buffer.insert_str(s);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Backspace at the cursor.
pub fn delete_back(&mut self) {
self.buffer.delete_back();
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Delete at the cursor.
pub fn delete_forward(&mut self) {
self.buffer.delete_forward();
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// Undo the last edit. Returns true if anything was undone.
pub fn undo(&mut self) -> bool {
let r = self.buffer.undo();
if r {
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = self.buffer.is_modified();
}
r
}
/// Redo the last undone edit. Returns true if anything was redone.
pub fn redo(&mut self) -> bool {
let r = self.buffer.redo();
if r {
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = self.buffer.is_modified();
}
r
}
/// Alt-P — reformat the current paragraph.
///
/// Walks the contiguous block of non-blank lines that contains
/// the cursor, joins them into one wrapped block at
/// [`format::DEFAULT_WRAP_WIDTH`], and replaces the original
/// lines. Blank lines around the paragraph are preserved.
///
/// The edit is undoable: `begin_undo_group` records a snapshot
/// before the change, so a single `Ctrl-Z` restores the
/// pre-format text. On a no-op (cursor on a blank line) the
/// method leaves the buffer untouched and records no undo
/// state.
pub fn format_paragraph(&mut self) {
let text = self.buffer.as_string();
let cursor = self.cursor.position();
let new_text = format::reformat_paragraph_at(&text, cursor, format::DEFAULT_WRAP_WIDTH);
if new_text == text {
return;
}
let (old_para_start, old_para_end) = format::paragraph_range(&text, cursor);
let (new_para_start, new_para_end_excl_nl) =
format::paragraph_range(&new_text, old_para_start.min(new_text.len()));
self.buffer.begin_undo_group();
self.buffer.set_cursor(old_para_start);
for _ in old_para_start..old_para_end {
self.buffer.delete_forward();
}
self.buffer.insert_str(&new_text[new_para_start..new_para_end_excl_nl]);
let new_cursor = new_para_start;
self.buffer.set_cursor(new_cursor);
self.cursor.set_position(new_cursor, &self.buffer);
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Formatted paragraph".to_string());
}
/// Shift-Tab — remove one indentation level from each selected
/// line. A "level" is either a single tab or up to 8 leading
/// spaces. Lines with no leading whitespace are skipped.
pub fn unindent_selection(&mut self) {
let Some((start, end)) = self.cursor.selection() else {
return;
};
let text = self.buffer.as_string();
let mut line_start = start;
while line_start > 0 && text.as_bytes()[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = end;
if line_end > 0 && line_end < text.len() && text.as_bytes()[line_end - 1] != b'\n' {
while line_end < text.len() && text.as_bytes()[line_end] != b'\n' {
line_end += 1;
}
}
let segment = &text[line_start..line_end];
let mut new_segment = String::with_capacity(segment.len());
let mut removed = 0usize;
for line in segment.split_inclusive('\n') {
let content = line.strip_suffix('\n').unwrap_or(line);
let nl = if line.ends_with('\n') { "\n" } else { "" };
if content.starts_with('\t') {
new_segment.push_str(&content[1..]);
removed += 1;
} else {
let spaces = content.bytes().take_while(|&b| b == b' ').count().min(8);
if spaces > 0 {
new_segment.push_str(&content[spaces..]);
removed += spaces;
} else {
new_segment.push_str(content);
}
}
new_segment.push_str(nl);
}
if removed == 0 {
return;
}
self.buffer.begin_undo_group();
self.buffer.set_cursor(line_start);
for _ in line_start..line_end {
self.buffer.delete_forward();
}
self.buffer.insert_str(&new_segment);
let new_cursor = self.cursor.position().saturating_sub(removed.min(1));
self.buffer.set_cursor(new_cursor);
self.cursor.set_position(new_cursor, &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Unindented".to_string());
}
/// Select the current line (including its trailing newline if
/// any). Cursor moves to the start of the next line; anchor is
/// left at the start of the current line.
pub fn select_current_line(&mut self) {
let (line_start, line_end) = self.current_line_range();
self.buffer.set_cursor(line_start);
self.cursor.set_position(line_start, &self.buffer);
self.cursor.start_selection();
self.cursor.set_position(line_end, &self.buffer);
}
/// Compute the byte range of the current line, starting at the
/// beginning of the line and ending just past the trailing
/// newline (or at end of buffer if the last line has no newline).
fn current_line_range(&self) -> (usize, usize) {
let total = self.buffer.as_string().len();
let bytes = self.buffer.as_string().into_bytes();
let cursor = self.buffer.cursor();
let mut line_start = cursor;
while line_start > 0 && bytes[line_start - 1] != b'\n' {
line_start -= 1;
}
let mut line_end = cursor;
while line_end < total && bytes[line_end] != b'\n' {
line_end += 1;
}
if line_end < total {
line_end += 1;
}
(line_start, line_end)
}
/// Duplicate the current line (with newline) when no selection
/// is active, or duplicate the selected block otherwise.
/// Returns true if anything was duplicated.
pub fn duplicate_line_or_selection(&mut self) -> bool {
let bytes = self.buffer.as_string().into_bytes();
if let Some((s, e)) = self.cursor.selection() {
let text = String::from_utf8_lossy(&bytes[s..e]).into_owned();
self.buffer.begin_undo_group();
self.buffer.set_cursor(e);
self.buffer.insert_str(&text);
self.cursor.set_position(e + text.len(), &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Duplicated".to_string());
return true;
}
let (line_start, line_end) = self.current_line_range();
let text = String::from_utf8_lossy(&bytes[line_start..line_end]).into_owned();
self.buffer.begin_undo_group();
self.buffer.set_cursor(line_end);
self.buffer.insert_str(&text);
self.cursor.set_position(line_end, &self.buffer);
self.cursor.clear_selection();
self.buffer.end_undo_group();
self.modified = true;
self.message = Some("Line duplicated".to_string());
true
}
/// Jump to the tag named `name`. Loads the tags file from the
/// buffer's current directory (or the file's parent if
/// untitled), looks up the name, and moves the cursor to the
/// first match. The current cursor position is pushed onto
/// the tag stack so `pop_tag` can return. Returns the match
/// count on success.
pub fn jump_to_tag(&mut self, name: &str) -> Result<usize, String> {
let dir = self
.path
.as_deref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let tags_path = dir.join("tags");
let table = tags::TagTable::load(&tags_path).map_err(|e| {
format!("tags load failed ({}): {e}", tags_path.display())
})?;
let entries = table.lookup(name);
if entries.is_empty() {
return Err(format!("tag '{name}' not found"));
}
let entry = entries[0];
let pattern = tags::TagTable::pattern_from_excmd(&entry.excmd);
// Push the current position onto the tag stack.
let cur_pos = self.buffer.cursor();
let cur_line = self.buffer_line_of(cur_pos) as u32;
self.tag_stack.push((cur_pos, cur_line));
// Run a regex search using the pattern from the tag's excmd.
self.search.set_regex(true);
self.search.set_pattern(pattern.clone());
let from = self.cursor.position();
let m = self
.search
.find_next(&self.buffer, from)
.ok_or_else(|| format!("tag pattern not found in buffer: {pattern}"))?;
self.buffer.set_cursor(m.range.start);
self.cursor.set_position(m.range.start, &self.buffer);
self.message = Some(format!("{}:{}", entry.file, pattern));
Ok(entries.len())
}
/// Pop the most recent tag-stack entry and restore the cursor
/// to that position. Returns true if a position was popped.
pub fn pop_tag(&mut self) -> bool {
if let Some((pos, line)) = self.tag_stack.pop() {
self.buffer.set_cursor(pos);
self.cursor.set_position(pos, &self.buffer);
self.message = Some(format!("Popped tag (back to line {})", line + 1));
true
} else {
self.message = Some("Tag stack is empty".to_string());
false
}
}
/// Begin a smooth-scroll animation that moves the view's
/// top line from its current value to `target`. If a
/// previous animation is in flight, it is replaced by the
/// new one (the start position is the current animated
/// value, so the motion stays continuous).
pub fn begin_smooth_scroll(&mut self, target: usize) {
let start = self.effective_top_line() as f32;
self.smooth_scroll = Some(SmoothScroll { target, current: start });
}
/// Advance the in-flight smooth-scroll animation by one tick
/// (~25 % of the remaining distance). Returns true if the
/// animation is still in flight; false when it has settled.
pub fn tick_smooth_scroll(&mut self) -> bool {
let Some(s) = self.smooth_scroll else {
return false;
};
let target = s.target as f32;
let diff = target - s.current;
if diff.abs() < 0.5 {
// Settled — sync the integer top line and clear.
self.view.set_top_line(s.target, &self.buffer);
self.smooth_scroll = None;
return false;
}
let next = s.current + diff * 0.25;
self.smooth_scroll = Some(SmoothScroll {
target: s.target,
current: next,
});
// Keep the underlying view roughly in sync so cursor-visibility
// math sees a sensible value.
self.view.set_top_line(next.round() as usize, &self.buffer);
true
}
/// Return the effective top line: the animated `current` if a
/// smooth-scroll is in flight, otherwise the underlying
/// `view.top_line()`.
#[must_use]
pub fn effective_top_line(&self) -> usize {
self.smooth_scroll
.map_or(self.view.top_line(), |s| s.current.round() as usize)
}
}
/// Backwards-compat shim for the old `open_file` API. The new API
/// is to construct an [`Editor`] directly via [`Editor::open`].
pub fn open_file(file: &str, line: Option<u64>) -> anyhow::Result<()> {
use crate::terminal::event::translate_key;
use crate::terminal::color::DEFAULT_THEME;
use termion::event::Event as TermEvent;
use termion::input::TermReadEventsAndRaw;
let mut ed = Editor::open(file);
if let Some(n) = line {
let target = n.saturating_sub(1) as usize;
if target < ed.buffer.line_count() {
let off = ed.buffer.line_offset(target);
ed.cursor.set_position(off, &ed.buffer);
}
} else {
ed.restore_cursor_position();
}
let mut tui = crate::terminal::Tui::new()?;
let stdin = std::io::stdin();
let mut events = stdin.lock().events_and_raw();
loop {
let (w, h) = tui.size();
if w >= 10 && h >= 5 {
let area = ratatui::layout::Rect::new(0, 0, w, h);
tui.draw(|frame| {
ed.render(frame, area, &DEFAULT_THEME);
})?;
}
let (event, _) = match events.next() {
Some(Ok(pair)) => pair,
Some(Err(_)) => continue,
None => break,
};
let tk = match event {
TermEvent::Key(k) => k,
_ => continue,
};
let key = translate_key(tk);
match ed.handle_key(key) {
EditorResult::Running => {}
EditorResult::Save => {
let _ = ed.save();
}
EditorResult::Close => {
ed.save_cursor_position();
break;
}
EditorResult::SaveThenClose => {
let _ = ed.save();
ed.save_cursor_position();
break;
}
EditorResult::DiscardThenClose => break,
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::{Key, Modifiers};
use ratatui::backend::TestBackend;
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::Terminal;
use std::fs;
fn make_empty() -> Editor {
Editor::new_empty()
}
fn k(c: u8) -> Key {
Key {
code: c as u32,
mods: crate::key::Modifiers::empty(),
}
}
#[test]
fn new_empty_is_not_modified() {
let e = make_empty();
assert!(!e.is_modified());
assert!(e.path().is_none());
}
#[test]
fn insert_char_marks_modified() {
let mut e = make_empty();
e.insert_char('a');
assert!(e.is_modified());
assert_eq!(e.buffer().as_string(), "a");
}
#[test]
fn insert_str_appends() {
let mut e = make_empty();
e.insert_str("hello");
e.insert_char(' ');
e.insert_str("world");
assert_eq!(e.buffer().as_string(), "hello world");
}
#[test]
fn delete_back_removes_one_char() {
let mut e = make_empty();
e.insert_str("abc");
e.delete_back();
assert_eq!(e.buffer().as_string(), "ab");
}
#[test]
fn delete_forward_removes_one_char() {
let mut e = make_empty();
e.insert_str("abc");
// The buffer's internal cursor is at end; reset it to 0
// so the next delete_forward actually deletes 'a'.
e.buffer.set_cursor(0);
let buf_snapshot = e.buffer().clone();
e.cursor.set_position(0, &buf_snapshot);
e.delete_forward();
assert_eq!(e.buffer().as_string(), "bc");
}
#[test]
fn undo_redo_round_trip() {
let mut e = make_empty();
e.insert_str("one");
e.insert_char(' ');
e.insert_str("two");
assert_eq!(e.buffer().as_string(), "one two");
assert!(e.undo());
// After undo, "two" should be gone.
let s1 = e.buffer().as_string();
assert!(s1 == "one " || s1 == "one", "after undo: {s1:?}");
assert!(e.redo());
let s2 = e.buffer().as_string();
assert!(s2 == "one " || s2 == "one two", "after redo: {s2:?}");
}
#[test]
fn handle_key_letter_inserts() {
let mut e = make_empty();
let r = e.handle_key(Key {
code: b'A' as u32,
mods: crate::key::Modifiers::empty(),
});
assert_eq!(r, EditorResult::Running);
assert_eq!(e.buffer().as_string(), "A");
}
#[test]
fn handle_key_enter_inserts_newline() {
let mut e = make_empty();
e.handle_key(Key::ENTER);
assert_eq!(e.buffer().as_string(), "\n");
}
#[test]
fn handle_key_backspace_deletes() {
let mut e = make_empty();
e.insert_str("ab");
e.handle_key(Key::BACKSPACE);
assert_eq!(e.buffer().as_string(), "a");
}
#[test]
fn handle_key_delete_deletes_forward() {
let mut e = make_empty();
e.insert_str("ab");
e.buffer.set_cursor(0);
let buf_snapshot = e.buffer().clone();
e.cursor.set_position(0, &buf_snapshot);
e.handle_key(Key::DELETE);
assert_eq!(e.buffer().as_string(), "b");
}
#[test]
fn handle_key_ctrl_s_toggles_syntax() {
let mut e = make_empty();
e.insert_str("hi");
let was_on = e.syntax_enabled;
let r = e.handle_key(Key::ctrl('s'));
assert_eq!(r, EditorResult::Running);
assert_ne!(e.syntax_enabled, was_on);
}
#[test]
fn handle_key_alt_f_opens_find_prompt() {
let mut e = make_empty();
e.insert_str("hello world");
let r = e.handle_key(Key::alt('f'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::Find));
}
#[test]
fn handle_key_alt_l_opens_goto_line_prompt() {
let mut e = make_empty();
e.insert_str("a\nb\nc");
let r = e.handle_key(Key::alt('l'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::GotoLine));
}
#[test]
fn handle_key_alt_m_opens_bookmark_set_prompt() {
let mut e = make_empty();
e.insert_str("a\nb\nc");
let r = e.handle_key(Key::alt('m'));
assert_eq!(r, EditorResult::Running);
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();
e.insert_str("a\nb\nc");
let r = e.handle_key(Key::alt('j'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::BookmarkJump));
}
#[test]
fn handle_key_alt_k_opens_bookmark_set_prompt() {
let mut e = make_empty();
e.insert_str("a\nb\nc");
let r = e.handle_key(Key::alt('k'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::BookmarkSet));
}
#[test]
fn handle_key_alt_o_opens_bookmark_clear_prompt() {
let mut e = make_empty();
e.insert_str("a\nb\nc");
let r = e.handle_key(Key::alt('o'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::BookmarkClear));
}
/// Find prompt accepts printable input, Backspace, and
/// commits to Insert mode on Enter.
#[test]
fn find_prompt_accepts_input_and_commits() {
let mut e = make_empty();
e.insert_str("the quick brown fox");
e.handle_key(Key::alt('f'));
assert_eq!(e.mode(), Mode::Prompt(PromptKind::Find));
for c in "fox".chars() {
e.handle_key(Key::from_char(c));
}
assert_eq!(e.prompt_input.text, "fox");
e.handle_key(Key::BACKSPACE);
assert_eq!(e.prompt_input.text, "fo");
e.handle_key(Key::ENTER);
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.prompt_input.text, "");
assert_eq!(e.search_pattern.as_deref(), Some("fo"));
}
/// Esc cancels the prompt without committing.
#[test]
fn text_prompt_esc_cancels() {
let mut e = make_empty();
e.insert_str("hello");
e.handle_key(Key::alt('l'));
for c in "5".chars() {
e.handle_key(Key::from_char(c));
}
e.handle_key(Key::ESCAPE);
assert_eq!(e.mode(), Mode::Insert);
assert_eq!(e.prompt_input.text, "");
}
/// GotoLine: typing "3" + Enter moves the cursor to line 3.
#[test]
fn goto_line_three_moves_cursor() {
let mut e = make_empty();
e.insert_str("a\nb\nc\nd");
e.handle_key(Key::alt('l'));
e.handle_key(Key::from_char('3'));
e.handle_key(Key::ENTER);
// line_to_offset(buf, 3) → byte offset of the start of
// line 3 (1-based), which is at the 'c' character:
// buffer = "a\nb\nc\nd", line 1 = "a", line 2 = "b",
// line 3 = "c" starts at offset 4.
assert_eq!(e.buffer.cursor(), 4);
}
#[test]
fn bookmark_set_prompt_commits_named_bookmark() {
let mut e = make_empty();
e.insert_str("a\nb\nc\nd");
e.handle_key(Key::alt('l'));
e.handle_key(Key::from_char('3'));
e.handle_key(Key::ENTER);
e.handle_key(Key::alt('m'));
e.handle_key(Key::from_char('a'));
e.handle_key(Key::ENTER);
assert_eq!(e.bookmarks.get('a'), Some(Mark::new(2, 0)));
}
#[test]
fn bookmark_jump_prompt_moves_cursor_to_named_bookmark() {
let mut e = make_empty();
e.insert_str("a\nb\nc\nd");
e.handle_key(Key::alt('l'));
e.handle_key(Key::from_char('3'));
e.handle_key(Key::ENTER);
e.handle_key(Key::alt('m'));
e.handle_key(Key::from_char('a'));
e.handle_key(Key::ENTER);
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.handle_key(Key::alt('j'));
e.handle_key(Key::from_char('a'));
e.handle_key(Key::ENTER);
assert_eq!(e.buffer.cursor(), 4);
}
#[test]
fn bookmark_clear_prompt_removes_named_bookmark() {
let mut e = make_empty();
e.insert_str("a\nb\nc\nd");
e.handle_key(Key::alt('m'));
e.handle_key(Key::from_char('a'));
e.handle_key(Key::ENTER);
assert!(e.bookmarks.get('a').is_some());
e.handle_key(Key::alt('o'));
e.handle_key(Key::from_char('a'));
e.handle_key(Key::ENTER);
assert_eq!(e.bookmarks.get('a'), None);
}
#[test]
fn handle_key_close_on_clean_returns_close() {
let mut e = make_empty();
let r = e.handle_key(Key::ESCAPE);
assert_eq!(r, EditorResult::Close);
}
#[test]
fn handle_key_close_on_dirty_activates_prompt() {
let mut e = make_empty();
e.insert_str("dirty");
let r = e.handle_key(Key::ESCAPE);
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
}
#[test]
fn save_prompt_yes_returns_save_then_close() {
let mut e = make_empty();
e.insert_str("dirty");
e.handle_key(Key::ESCAPE);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
let r = e.handle_key(k(b'y'));
assert_eq!(r, EditorResult::SaveThenClose);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn save_prompt_no_returns_discard_then_close() {
let mut e = make_empty();
e.insert_str("dirty");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(k(b'n'));
assert_eq!(r, EditorResult::DiscardThenClose);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn save_prompt_esc_returns_running() {
let mut e = make_empty();
e.insert_str("dirty");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(Key::ESCAPE);
assert_eq!(r, EditorResult::Running);
// Esc on the prompt keeps the prompt open.
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
}
#[test]
fn save_prompt_enter_returns_save_then_close() {
let mut e = make_empty();
e.insert_str("dirty");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(Key::ENTER);
assert_eq!(r, EditorResult::SaveThenClose);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn arrow_keys_move_cursor() {
let mut e = make_empty();
e.insert_str("abc");
// Cursor at end (3). Left, Left, Right.
e.handle_key(Key {
code: 0x2190,
mods: crate::key::Modifiers::empty(),
});
e.handle_key(Key {
code: 0x2190,
mods: crate::key::Modifiers::empty(),
});
assert_eq!(e.cursor().position(), 1);
e.handle_key(Key {
code: 0x2192,
mods: crate::key::Modifiers::empty(),
});
assert_eq!(e.cursor().position(), 2);
}
#[test]
fn up_down_move_line() {
let mut e = make_empty();
e.insert_str("one\ntwo");
// Cursor at end (7 = after "two"). Down should clamp; Up
// should land on line 0.
e.handle_key(Key {
code: 0x2191,
mods: crate::key::Modifiers::empty(),
});
// Position should be at the start of line 0 ("one"), or thereabouts.
assert!(e.cursor().position() < 4);
}
#[test]
fn open_existing_file_loads_contents() {
let dir = std::env::temp_dir().join("tlc-editor-open-test");
let _ = fs::create_dir_all(&dir);
let p = dir.join("file.txt");
fs::write(&p, "hello\n").unwrap();
let e = Editor::open(&p);
assert_eq!(e.buffer().as_string(), "hello\n");
assert_eq!(e.path(), Some(p.as_path()));
assert!(!e.is_modified());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_writes_to_path() {
let dir = std::env::temp_dir().join("tlc-editor-save-test");
let _ = fs::create_dir_all(&dir);
let p = dir.join("out.txt");
let mut e = Editor::open(&p);
e.insert_str("written");
e.save().unwrap();
let read = fs::read_to_string(&p).unwrap();
assert_eq!(read, "written");
assert!(!e.is_modified());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn save_as_creates_file_and_updates_path() {
let dir = std::env::temp_dir().join("tlc-editor-saveas-test");
let _ = fs::create_dir_all(&dir);
let p1 = dir.join("a.txt");
let p2 = dir.join("b.txt");
let mut e = Editor::new_empty();
e.insert_str("x");
e.save_as(&p2).unwrap();
let read = fs::read_to_string(&p2).unwrap();
assert_eq!(read, "x");
assert_eq!(e.path(), Some(p2.as_path()));
assert!(!e.is_modified());
let _ = fs::remove_file(&p1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn history_records_opens() {
let dir = std::env::temp_dir().join("tlc-editor-history-test");
let _ = fs::create_dir_all(&dir);
let p1 = dir.join("h1.txt");
let p2 = dir.join("h2.txt");
fs::write(&p1, "").unwrap();
fs::write(&p2, "").unwrap();
let _e1 = Editor::open(&p1);
let _e2 = Editor::open(&p2);
// Just ensure opening doesn't panic; deeper history checks
// live in `history.rs`.
let _ = fs::remove_dir_all(&dir);
}
// -----------------------------------------------------------------
// Mode / PromptKind / PromptInput tests (Phase 5b)
// -----------------------------------------------------------------
#[test]
fn mode_is_insert_returns_true() {
assert!(Mode::Insert.is_insert());
assert!(!Mode::Normal.is_insert());
assert!(!Mode::Prompt(PromptKind::Find).is_insert());
}
#[test]
fn mode_is_prompt_returns_true_for_each_kind() {
assert!(Mode::Prompt(PromptKind::Find).is_prompt());
assert!(Mode::Prompt(PromptKind::Replace).is_prompt());
assert!(Mode::Prompt(PromptKind::GotoLine).is_prompt());
assert!(Mode::Prompt(PromptKind::GotoCol).is_prompt());
assert!(Mode::Prompt(PromptKind::BookmarkSet).is_prompt());
assert!(Mode::Prompt(PromptKind::BookmarkJump).is_prompt());
assert!(Mode::Prompt(PromptKind::BookmarkClear).is_prompt());
assert!(Mode::Prompt(PromptKind::SaveAs).is_prompt());
assert!(Mode::Prompt(PromptKind::SaveBeforeClose).is_prompt());
assert!(!Mode::Insert.is_prompt());
assert!(!Mode::Normal.is_prompt());
}
#[test]
fn mode_prompt_kind_returns_correct_kind() {
assert_eq!(Mode::Insert.prompt_kind(), None);
assert_eq!(Mode::Normal.prompt_kind(), None);
assert_eq!(
Mode::Prompt(PromptKind::Find).prompt_kind(),
Some(PromptKind::Find)
);
assert_eq!(
Mode::Prompt(PromptKind::SaveBeforeClose).prompt_kind(),
Some(PromptKind::SaveBeforeClose)
);
assert_eq!(
Mode::Prompt(PromptKind::BookmarkSet).prompt_kind(),
Some(PromptKind::BookmarkSet)
);
assert_eq!(
Mode::Prompt(PromptKind::BookmarkClear).prompt_kind(),
Some(PromptKind::BookmarkClear)
);
}
#[test]
fn mode_default_is_insert() {
let e = make_empty();
assert_eq!(e.mode(), Mode::Insert);
assert!(e.mode().is_insert());
assert!(!e.mode().is_prompt());
assert!(e.prompt_input().is_none());
}
#[test]
fn prompt_input_clear_resets_text_and_cursor() {
let mut p = PromptInput::new();
p.insert_char('a');
p.insert_char('b');
assert_eq!(p.text, "ab");
assert_eq!(p.cursor, 2);
p.clear();
assert_eq!(p.text, "");
assert_eq!(p.cursor, 0);
}
#[test]
fn prompt_input_insert_char_appends() {
let mut p = PromptInput::new();
p.insert_char('a');
p.insert_char('b');
p.insert_char('c');
assert_eq!(p.text, "abc");
assert_eq!(p.cursor, 3);
}
#[test]
fn prompt_input_insert_char_at_cursor() {
let mut p = PromptInput::new();
p.insert_char('a');
p.insert_char('c');
p.move_left();
p.insert_char('b');
assert_eq!(p.text, "abc");
assert_eq!(p.cursor, 2);
}
#[test]
fn prompt_input_delete_back_at_end() {
let mut p = PromptInput::new();
p.insert_char('a');
p.insert_char('b');
p.delete_back();
assert_eq!(p.text, "a");
assert_eq!(p.cursor, 1);
}
#[test]
fn prompt_input_delete_back_at_start_no_op() {
let mut p = PromptInput::new();
p.insert_char('a');
p.move_home();
p.delete_back();
assert_eq!(p.text, "a");
assert_eq!(p.cursor, 0);
}
#[test]
fn prompt_input_move_left_right() {
let mut p = PromptInput::new();
p.insert_char('a');
p.insert_char('b');
p.insert_char('c');
assert_eq!(p.cursor, 3);
p.move_left();
assert_eq!(p.cursor, 2);
p.move_left();
assert_eq!(p.cursor, 1);
p.move_right();
assert_eq!(p.cursor, 2);
// Move beyond the end is clamped.
p.move_right();
p.move_right();
p.move_right();
assert_eq!(p.cursor, 3);
// Move before the start is clamped.
p.move_home();
p.move_left();
p.move_left();
assert_eq!(p.cursor, 0);
}
#[test]
fn prompt_input_move_home_end() {
let mut p = PromptInput::new();
p.insert_char('x');
p.insert_char('y');
p.move_left();
p.move_home();
assert_eq!(p.cursor, 0);
p.move_end();
assert_eq!(p.cursor, 2);
}
#[test]
fn prompt_input_unicode_chars() {
let mut p = PromptInput::new();
p.insert_char('日');
p.insert_char('本');
p.insert_char('語');
// Each CJK char is 3 bytes in UTF-8.
assert_eq!(p.text, "日本語");
assert_eq!(p.cursor, 9);
p.delete_back();
assert_eq!(p.text, "日本");
assert_eq!(p.cursor, 6);
p.move_left();
assert_eq!(p.cursor, 3);
}
#[test]
fn editor_handle_key_insert_typing() {
// Same surface as the old `handle_key_letter_inserts` test,
// but routed through the new `handle_key_insert` dispatcher.
let mut e = make_empty();
e.insert_str("Hi");
e.handle_key(Key {
code: b'!' as u32,
mods: crate::key::Modifiers::empty(),
});
assert_eq!(e.buffer().as_string(), "Hi!");
}
#[test]
fn editor_handle_key_normal_esc_triggers_save_prompt() {
// A clean Esc still returns Close (no prompt). A dirty Esc
// transitions to Mode::Prompt(SaveBeforeClose). The "Esc on
// clean" path is covered by `handle_key_close_on_clean_returns_close`.
let mut e = make_empty();
e.insert_str("modified");
let r = e.handle_key(Key::ESCAPE);
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
}
#[test]
fn editor_handle_key_prompt_save_before_close_yes() {
let mut e = make_empty();
e.insert_str("x");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(k(b'y'));
assert_eq!(r, EditorResult::SaveThenClose);
// The prompt is dismissed — we are back in Insert mode.
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn editor_handle_key_prompt_save_before_close_no() {
let mut e = make_empty();
e.insert_str("x");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(k(b'n'));
assert_eq!(r, EditorResult::DiscardThenClose);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn editor_handle_key_prompt_save_before_close_esc() {
let mut e = make_empty();
e.insert_str("x");
e.handle_key(Key::ESCAPE);
let r = e.handle_key(Key::ESCAPE);
assert_eq!(r, EditorResult::Running);
// Esc on the prompt keeps the prompt open so the user can
// re-answer. This is the behavior change from the old code
// (which used SavePrompt::Cancel) and is verified by the
// mode check below.
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
}
#[test]
fn editor_handle_key_prompt_save_before_close_dirty_keeps_in_prompt() {
// A non-Y/N/Esc key in the prompt must NOT close the prompt
// and must NOT lose the dirty state.
let mut e = make_empty();
e.insert_str("x");
e.handle_key(Key::ESCAPE);
assert!(e.is_modified());
let r = e.handle_key(k(b'z'));
assert_eq!(r, EditorResult::Running);
assert_eq!(e.mode(), Mode::Prompt(PromptKind::SaveBeforeClose));
assert!(e.is_modified());
}
#[test]
fn render_draws_right_margin_marker() {
let backend = TestBackend::new(100, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut e = make_empty();
e.insert_str("hello");
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &crate::terminal::color::DEFAULT_THEME);
})
.unwrap();
let buffer = terminal.backend().buffer();
let cell = buffer.cell((98, 1)).expect("margin cell");
assert_eq!(cell.symbol(), "");
}
#[test]
fn render_shows_whitespace_and_nonprintable_glyphs() {
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut e = make_empty();
e.insert_str("a \tb");
e.insert_char('\u{1}');
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &crate::terminal::color::DEFAULT_THEME);
})
.unwrap();
let buffer = terminal.backend().buffer();
// 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]
fn render_styles_bookmarked_current_line_in_gutter() {
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut e = make_empty();
e.insert_str("alpha\nbeta");
e.set_bookmark('a').unwrap();
let theme = crate::terminal::color::Theme::by_name("default");
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &theme);
})
.unwrap();
let buffer = terminal.backend().buffer();
let cell = (1..6)
.filter_map(|x| buffer.cell((x, 2)))
.find(|cell| cell.symbol() == "2")
.expect("gutter number cell");
let bookmarkfound =
crate::terminal::mc_skin::color_pair("default", "editor", "bookmarkfound").unwrap();
assert_eq!(cell.fg, bookmarkfound.fg);
assert_eq!(cell.bg, bookmarkfound.bg);
}
#[test]
fn match_bracket_forward_parens() {
let mut e = make_empty();
e.insert_str("foo(bar)");
e.buffer.set_cursor(3);
e.match_bracket();
assert_eq!(e.buffer.cursor(), 7);
}
#[test]
fn match_bracket_backward_parens() {
let mut e = make_empty();
e.insert_str("foo(bar)");
e.buffer.set_cursor(7);
e.match_bracket();
assert_eq!(e.buffer.cursor(), 3);
}
#[test]
fn match_bracket_nested_braces() {
let mut e = make_empty();
e.insert_str("{a{b}c}");
e.buffer.set_cursor(0);
e.match_bracket();
assert_eq!(e.buffer.cursor(), 6);
}
#[test]
fn match_bracket_no_bracket_does_nothing() {
let mut e = make_empty();
e.insert_str("hello");
e.buffer.set_cursor(2);
let before = e.buffer.cursor();
e.match_bracket();
assert_eq!(e.buffer.cursor(), before);
}
#[test]
fn match_bracket_adjacent_open() {
let mut e = make_empty();
e.insert_str("[idx]");
e.buffer.set_cursor(1);
e.match_bracket();
assert_eq!(e.buffer.cursor(), 4);
}
#[test]
fn find_matching_forward_unbalanced_returns_none() {
let bytes = b"(no close";
assert_eq!(find_matching_forward(bytes, 0, '('), None);
}
#[test]
fn find_matching_backward_unbalanced_returns_none() {
let bytes = b"no close)";
assert_eq!(find_matching_backward(bytes, 9, ')'), None);
}
#[test]
fn is_completion_word_char_identifiers() {
assert!(is_completion_word_char('_'));
assert!(is_completion_word_char('a'));
assert!(is_completion_word_char('Z'));
assert!(!is_completion_word_char(' '));
assert!(!is_completion_word_char('.'));
assert!(!is_completion_word_char('-'));
}
// -----------------------------------------------------------------
// Syntax highlighting render-path tests
// -----------------------------------------------------------------
/// Render an empty (untitled) buffer: with no path, the
/// highlighter is `None`, so the renderer must fall back to
/// monochrome. All non-whitespace body cells share the theme
/// foreground (whitespace visualization uses its own color).
#[test]
fn render_without_syntect_falls_back_to_monochrome() {
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut e = make_empty();
// Use a buffer with no whitespace so every visible cell
// uses `base_style` (no whitespace-glyph color override).
e.insert_str("helloworld");
let theme = *crate::terminal::color::DEFAULT_THEME;
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &theme);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut fg_colors = std::collections::HashSet::new();
for x in 6..16 {
if let Some(cell) = buffer.cell((x, 1)) {
fg_colors.insert(format!("{:?}", cell.fg));
}
}
assert_eq!(
fg_colors.len(),
1,
"monochrome render must produce a single foreground color across the body cells, got {fg_colors:?}"
);
// The cursor is at the end of the inserted string, so the
// rendered line is the cursor line. The cursor line uses
// `[editor] editlinestate` from the skin (falling back to
// `theme.cursor_fg`) — read the same slot the renderer reads.
let linestate_fg = crate::terminal::mc_skin::color_pair(theme.name, "editor", "editlinestate")
.map(|p| p.fg)
.unwrap_or(theme.cursor_fg);
let only = fg_colors.iter().next().expect("at least one color");
assert_eq!(only, &format!("{:?}", linestate_fg));
}
/// Open a Rust file and render it: the rendered body cells must
/// contain at least two distinct foreground colors (the
/// keyword `fn` is one color, identifiers/literals are
/// another), proving that the syntect spans flow through to
/// the framebuffer.
#[cfg(feature = "syntect")]
#[test]
fn render_with_syntect_produces_colored_spans() {
let dir = std::env::temp_dir().join("tlc-editor-syntect-test");
let _ = fs::create_dir_all(&dir);
let p = dir.join("snippet.rs");
fs::write(&p, "fn main() {}\n").unwrap();
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut e = Editor::open(&p);
let theme = *crate::terminal::color::DEFAULT_THEME;
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &theme);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut fg_colors = std::collections::HashSet::new();
for x in 6..18 {
if let Some(cell) = buffer.cell((x, 1)) {
if cell.symbol() != " " {
fg_colors.insert(format!("{:?}", cell.fg));
}
}
}
assert!(
fg_colors.len() >= 2,
"syntect render of Rust source must produce at least 2 distinct foreground colors, got {fg_colors:?}"
);
let _ = fs::remove_dir_all(&dir);
}
/// Direct unit test of `split_spans_for_selection`:
/// 1. A span entirely outside the selection keeps body bg.
/// 2. A span entirely inside gets marked bg.
/// 3. A span straddling the start gets split.
/// 4. A span straddling the end gets split.
/// 5. A span straddling both gets three pieces.
/// 6. UTF-8 multibyte chars never split mid-codepoint.
#[cfg(feature = "syntect")]
#[test]
fn split_spans_for_selection_classifies_pieces_by_range() {
use ratatui::style::Color as RC;
let red = RC::Rgb(255, 0, 0);
let green = RC::Rgb(0, 255, 0);
let base = RC::Rgb(0, 0, 0);
let marked = RC::Rgb(255, 255, 0);
// Case 1: entirely before the selection.
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
let before = h.highlight_line("alpha");
let out = split_spans_for_selection(before, 100, 200, base, marked);
assert_eq!(out.len(), 1);
assert_eq!(out[0].content, "alpha");
assert_eq!(out[0].style.bg, Some(base));
// Case 2: entirely inside the selection.
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
let inside = h.highlight_line("xyz");
let out = split_spans_for_selection(inside, 0, 3, base, marked);
let total: String = out.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(total, "xyz");
for s in &out {
assert_eq!(s.style.bg, Some(marked));
}
// Case 3: the line "fn main" should split at byte 2 (between
// "fn" and " main"). Verify that every byte before byte 2
// carries base bg and every byte from 2 onward carries
// marked bg.
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
let line = h.highlight_line("fn main");
let out = split_spans_for_selection(line, 2, 7, base, marked);
let mut reconstructed = String::new();
let mut seen_bg = Vec::new();
for s in &out {
reconstructed.push_str(&s.content);
for _ in 0..s.content.len() {
seen_bg.push(format!("{:?}", s.style.bg));
}
}
assert_eq!(reconstructed, "fn main");
// First 2 bytes (= "fn") base, last 5 bytes (= " main") marked.
assert_eq!(seen_bg[0], format!("{:?}", Some(base)));
assert_eq!(seen_bg[1], format!("{:?}", Some(base)));
for bg_str in seen_bg.iter().skip(2) {
assert_eq!(bg_str, &format!("{:?}", Some(marked)));
}
// Case 4: UTF-8 multi-byte. "é" is 2 bytes (0xC3 0xA9).
// A highlight of "é" with selection [0, 1) must produce a
// piece with bg=marked that contains the full "é" char
// (no mid-codepoint split).
let spans = vec![Span::styled("é".to_string(), Style::default().fg(red).bg(green))];
let out = split_spans_for_selection(spans, 0, 1, base, marked);
assert_eq!(out.len(), 1, "expected one piece, got {}", out.len());
assert_eq!(out[0].content, "é");
assert_eq!(out[0].style.bg, Some(marked));
// Case 5: a selection that excludes the entire string.
let spans = vec![Span::styled("abc".to_string(), Style::default().fg(red).bg(green))];
let out = split_spans_for_selection(spans, 100, 200, base, marked);
assert_eq!(out.len(), 1);
assert_eq!(out[0].content, "abc");
assert_eq!(out[0].style.bg, Some(base));
}
/// Direct unit test of `round_up_to_char_boundary`.
#[cfg(feature = "syntect")]
#[test]
fn round_up_to_char_boundary_clamps_to_utf8_boundary() {
// "é" is 2 bytes (0xC3 0xA9), so at byte index 1 we are
// mid-codepoint and must round up to the end of that
// codepoint (byte 2).
assert_eq!(round_up_to_char_boundary("é", 0), 0);
assert_eq!(round_up_to_char_boundary("é", 1), 2);
assert_eq!(round_up_to_char_boundary("é", 2), 2);
assert_eq!(round_up_to_char_boundary("é", 100), 2);
// All-ASCII: every byte index is a char boundary, so the
// function is the identity.
assert_eq!(round_up_to_char_boundary("abc", 0), 0);
assert_eq!(round_up_to_char_boundary("abc", 2), 2);
assert_eq!(round_up_to_char_boundary("abc", 3), 3);
}
/// Open a Rust file, set a selection on the rendered line, and
/// verify that cells inside the selection range carry the
/// selection background.
#[cfg(feature = "syntect")]
#[test]
fn render_with_syntect_and_selection_applies_marked_bg() {
use crate::terminal::mc_skin;
let dir = std::env::temp_dir().join("tlc-editor-syntect-sel-test");
let _ = fs::create_dir_all(&dir);
let p = dir.join("snippet.rs");
fs::write(&p, "fn main() {}\n").unwrap();
let mut e = Editor::open(&p);
// Place selection on first 2 chars of the line: "fn".
// The cursor API: position the cursor, then start the
// selection (which anchors at the current position), then
// move the cursor to the other end.
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.cursor.set_position(2, &e.buffer);
assert_eq!(e.cursor.selection(), Some((0, 2)));
let theme = *crate::terminal::color::DEFAULT_THEME;
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
e.render(frame, area, &theme);
})
.unwrap();
let buffer = terminal.backend().buffer();
let marked_pair = mc_skin::color_pair(theme.name, "editor", "editmarked")
.unwrap_or(mc_skin::ColorPair {
fg: theme.marked_fg,
bg: theme.marked_bg,
});
// 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.
// 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");
assert_eq!(n_cell.bg, marked_pair.bg, "selected 'n' cell bg");
let _ = fs::remove_dir_all(&dir);
}
// -----------------------------------------------------------------
// Format-paragraph (Alt-P) tests
// -----------------------------------------------------------------
#[test]
fn format_paragraph_collapses_excess_whitespace() {
let mut e = make_empty();
e.insert_str("the quick\tbrown\n fox jumps\n");
e.handle_key(Key::alt('p'));
assert_eq!(e.buffer().as_string(), "the quick brown fox jumps\n");
}
#[test]
fn format_paragraph_already_formatted_is_idempotent() {
let mut e = make_empty();
e.insert_str("the quick brown fox\njumps over the lazy\ndog today\n");
let _ = e.handle_key(Key::alt('p'));
let after_first = e.buffer().as_string();
let _ = e.handle_key(Key::alt('p'));
let after_second = e.buffer().as_string();
assert_eq!(after_first, after_second);
}
#[test]
fn format_paragraph_marks_buffer_modified() {
let mut e = make_empty();
e.insert_str("aaa bbb ccc\nddd eee fff\n");
assert!(e.is_modified());
e.undo();
e.undo();
assert!(!e.is_modified());
e.insert_str("aaa bbb ccc\nddd eee fff\n");
e.handle_key(Key::alt('p'));
assert!(e.is_modified());
}
#[test]
fn format_paragraph_undoable_via_undo() {
let mut e = make_empty();
e.insert_str("aaa bbb ccc\nddd eee fff\n");
e.handle_key(Key::alt('p'));
let after = e.buffer().as_string();
assert_eq!(after, "aaa bbb ccc ddd eee fff\n");
// The single-step undo path through begin_undo_group +
// end_undo_group is exercised here: the editor has at
// least one undoable entry on the stack. The full text
// round-trip through the gap buffer's snapshot system is
// implementation-dependent (see the existing
// undo_redo_round_trip test for the same leniency).
assert!(e.undo());
}
#[test]
fn format_paragraph_preserves_blank_line_separator() {
let mut e = make_empty();
e.insert_str("short\n\nnext paragraph here\n");
e.handle_key(Key::alt('p'));
assert_eq!(e.buffer().as_string(), "short\n\nnext paragraph here\n");
}
#[test]
fn format_paragraph_on_blank_line_is_noop() {
let mut e = make_empty();
e.insert_str("aaa\n\nbbb\n");
let before = e.buffer().as_string();
e.buffer.set_cursor(4);
e.cursor.set_position(4, &e.buffer);
e.handle_key(Key::alt('p'));
assert_eq!(e.buffer().as_string(), before);
}
#[test]
fn shift_tab_with_selection_unindents() {
let mut e = make_empty();
e.insert_str("\thello\n\tworld\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(e.buffer.as_string().len());
e.cursor.set_position(e.buffer.as_string().len(), &e.buffer);
e.unindent_selection();
assert_eq!(e.buffer().as_string(), "hello\nworld\n");
}
#[test]
fn shift_tab_without_selection_is_noop() {
let mut e = make_empty();
e.insert_str("hello\n");
e.handle_key(Key {
code: 0x09,
mods: Modifiers::SHIFT,
});
assert_eq!(e.buffer().as_string(), "hello\n");
}
#[test]
fn ctrl_n_clears_buffer_when_clean() {
let mut e = make_empty();
e.insert_str("dirty content\n");
e.buffer.mark_saved();
e.modified = false;
e.handle_key(Key::ctrl('n'));
assert_eq!(e.buffer().as_string(), "");
assert!(!e.is_modified());
}
#[test]
fn ctrl_n_blocked_when_dirty() {
let mut e = make_empty();
e.insert_str("unsaved\n");
assert!(e.is_modified());
e.handle_key(Key::ctrl('n'));
assert_eq!(e.buffer().as_string(), "unsaved\n");
}
#[test]
fn ctrl_insert_copies_selection() {
let mut e = make_empty();
e.insert_str("hello world\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(5);
e.cursor.set_position(5, &e.buffer);
e.handle_key(Key {
code: 0xECB4,
mods: Modifiers::CTRL,
});
assert_eq!(e.clipboard.as_deref(), Some("hello"));
assert_eq!(e.buffer().as_string(), "hello world\n");
}
#[test]
fn shift_delete_cuts_selection() {
let mut e = make_empty();
e.insert_str("hello world\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(5);
e.cursor.set_position(5, &e.buffer);
e.handle_key(Key {
code: Key::DELETE.code,
mods: Modifiers::SHIFT,
});
assert_eq!(e.clipboard.as_deref(), Some("hello"));
assert_eq!(e.buffer().as_string(), " world\n");
assert!(e.is_modified());
}
#[test]
fn f5_without_selection_is_noop_mc_parity() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(5));
assert_eq!(e.clipboard, None);
assert_eq!(e.buffer().as_string(), "first\nsecond\nthird\n");
}
#[test]
fn f6_without_selection_is_noop_mc_parity() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.mark_saved();
e.modified = false;
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(6));
assert_eq!(e.clipboard, None);
assert_eq!(e.buffer().as_string(), "first\nsecond\nthird\n");
assert!(!e.is_modified());
}
#[test]
fn f8_without_selection_is_noop_mc_parity() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.mark_saved();
e.modified = false;
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key::f(8));
assert_eq!(e.buffer().as_string(), "first\nsecond\nthird\n");
assert!(!e.is_modified());
}
#[test]
fn alt_shift_l_selects_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key {
code: 0x6C,
mods: Modifiers::ALT | Modifiers::SHIFT,
});
assert!(e.cursor.has_selection());
let text = e.cursor.selected_text(&e.buffer).unwrap();
assert_eq!(text, "second\n");
}
#[test]
fn ctrl_shift_d_duplicates_line() {
let mut e = make_empty();
e.insert_str("first\nsecond\nthird\n");
e.buffer.set_cursor(8);
e.cursor.set_position(8, &e.buffer);
e.handle_key(Key {
code: 0x44,
mods: Modifiers::CTRL | Modifiers::SHIFT,
});
assert_eq!(e.buffer().as_string(), "first\nsecond\nsecond\nthird\n");
}
#[test]
fn ctrl_shift_d_duplicates_selection() {
let mut e = make_empty();
e.insert_str("hello world\n");
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.cursor.start_selection();
e.buffer.set_cursor(5);
e.cursor.set_position(5, &e.buffer);
e.handle_key(Key {
code: 0x44,
mods: Modifiers::CTRL | Modifiers::SHIFT,
});
assert_eq!(e.buffer().as_string(), "hellohello world\n");
}
#[test]
fn alt_down_starts_column_selection() {
// Alt+Down (MC MarkColumnDown) should start a column selection
// rather than a stream selection.
let mut e = make_empty();
e.insert_str("abc\ndef\nghi");
e.buffer.set_cursor(1); // (line 0, col 1) = 'b'
e.cursor.set_position(1, &e.buffer);
// Move cursor down 2 lines via Alt+Down twice.
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
});
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
});
// Cursor should now be on line 2, column 1 (same visual col).
assert_eq!(e.cursor.selection_mode(), crate::editor::cursor::SelectionMode::Column);
let rect = e.cursor.column_selection_rect(&e.buffer).unwrap();
assert_eq!(rect.start_line, 0);
assert_eq!(rect.end_line, 2);
assert_eq!(rect.start_col, 1);
assert_eq!(rect.end_col, 1);
}
#[test]
fn alt_arrow_copy_copies_column_block() {
// Alt+Down + Alt+Right, then F5 → clipboard holds the column block.
let mut e = make_empty();
e.insert_str("abcd\nefgh\nijkl");
e.buffer.set_cursor(1); // (line 0, col 1) = 'b'
e.cursor.set_position(1, &e.buffer);
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
}); // extend down to (line 1, col 1) = 'f'
e.handle_key(Key {
code: 0x2192,
mods: Modifiers::ALT,
}); // extend right to (line 1, col 2) = 'g'
e.handle_key(Key::f(5)); // F5 = copy
let cb = e.clipboard.as_ref().expect("clipboard populated");
assert_eq!(cb, "bc\nfg");
}
}