d4cb65fcff
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.
2420 lines
85 KiB
Rust
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");
|
|
}
|
|
}
|
|
|