Split editor module: mod.rs 2646→434 non-test lines
Extract impl Editor blocks into submodules following the filemanager split pattern: - handlers.rs (551 lines): all key handling methods - render.rs (571 lines): render method + rendering helpers - bracket.rs (84 lines): bracket matching free functions mod.rs retains: struct definition, basic impl, open_file, all tests. Pure code-move refactoring — no logic changes. 1022 tests pass, 0 new warnings.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
//! Bracket matching and word-completion character helpers.
|
||||
//!
|
||||
//! Free functions used by [`crate::editor::Editor`] for the
|
||||
//! Alt-B bracket-match command and Alt-Tab word completion.
|
||||
//! They are pure byte/char-level helpers; no editor state is
|
||||
//! touched here.
|
||||
|
||||
/// True if `c` is an opening bracket character `(`/`[`/`{`.
|
||||
pub(crate) fn is_open_bracket(c: char) -> bool {
|
||||
matches!(c, '(' | '[' | '{')
|
||||
}
|
||||
|
||||
/// True if `c` is a closing bracket character `)`/`]`/`}`.
|
||||
pub(crate) fn is_close_bracket(c: char) -> bool {
|
||||
matches!(c, ')' | ']' | '}')
|
||||
}
|
||||
|
||||
/// Returns the matching opening bracket for a closing bracket
|
||||
/// character, or `None` if `c` is not a closing bracket.
|
||||
pub(crate) fn matching_open(c: char) -> Option<char> {
|
||||
match c {
|
||||
')' => Some('('),
|
||||
']' => Some('['),
|
||||
'}' => Some('{'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the matching closing bracket for an opening bracket
|
||||
/// character, or `None` if `c` is not an opening bracket.
|
||||
pub(crate) fn matching_close(c: char) -> Option<char> {
|
||||
match c {
|
||||
'(' => Some(')'),
|
||||
'[' => Some(']'),
|
||||
'{' => Some('}'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the byte offset of the bracket matching `open` in
|
||||
/// `bytes[start..]`. Scans forward, counting nesting depth.
|
||||
/// Returns `None` if the match is unbalanced.
|
||||
pub(crate) fn find_matching_forward(bytes: &[u8], start: usize, open: char) -> Option<usize> {
|
||||
let close = matching_close(open)?;
|
||||
let mut depth = 1i32;
|
||||
for (i, &b) in bytes.iter().enumerate().skip(start + 1) {
|
||||
let c = b as char;
|
||||
if c == open {
|
||||
depth += 1;
|
||||
} else if c == close {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the byte offset of the bracket matching `close` in
|
||||
/// `bytes[..start]`. Scans backward, counting nesting depth.
|
||||
/// Returns `None` if the match is unbalanced.
|
||||
pub(crate) fn find_matching_backward(bytes: &[u8], start: usize, close: char) -> Option<usize> {
|
||||
let open = matching_open(close)?;
|
||||
let mut depth = 1;
|
||||
for i in (0..start).rev() {
|
||||
let c = bytes[i] as char;
|
||||
if c == close {
|
||||
depth += 1;
|
||||
} else if c == open {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True if `c` is part of a "word" for completion purposes
|
||||
/// (ASCII letter, digit, or underscore).
|
||||
pub(crate) fn is_completion_word_char(c: char) -> bool {
|
||||
c == '_' || c.is_alphanumeric() || c.is_alphabetic()
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
//! Key dispatch and event handling for the editor.
|
||||
//!
|
||||
//! [`crate::editor::Editor::handle_key`] is the public entry point:
|
||||
//! it dispatches to the per-mode handler (`handle_key_normal`,
|
||||
//! `handle_key_insert`, or `handle_key_prompt`) based on the current
|
||||
//! [`Mode`]. Esc / F10 / Ctrl-Q (close) and Ctrl-S / F2 (save) are
|
||||
//! intercepted at the dispatcher so they work in BOTH Normal and
|
||||
//! Insert modes — matching the original behavior where Esc closes
|
||||
//! the editor from any non-prompt state. Prompt mode handles its
|
||||
//! own keys (Y / N / Esc / Enter depending on the active prompt).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::editor::bracket::{
|
||||
find_matching_backward, find_matching_forward, is_close_bracket, is_completion_word_char,
|
||||
is_open_bracket,
|
||||
};
|
||||
use crate::editor::EditorResult;
|
||||
use crate::editor::{completion::CompletionMode, Mode, PromptKind};
|
||||
use crate::key::{Key, Modifiers};
|
||||
|
||||
use super::Editor;
|
||||
|
||||
impl Editor {
|
||||
/// Handle a key event. Returns the [`EditorResult`] for the
|
||||
/// application loop. Dispatches to Normal/Insert/Prompt
|
||||
/// handlers based on the current [`Mode`].
|
||||
///
|
||||
/// Esc / F10 / Ctrl-Q (close) and Ctrl-S / F2 (save) are
|
||||
/// intercepted at the dispatcher so they work in BOTH Normal
|
||||
/// and Insert modes — matching the original behavior where
|
||||
/// Esc closes the editor from any non-prompt state. Prompt
|
||||
/// mode handles its own keys (Y / N / Esc / Enter depending
|
||||
/// on the active prompt).
|
||||
pub(crate) fn handle_key(&mut self, key: Key) -> EditorResult {
|
||||
// Prompt mode owns its own keymap (Y/N/Esc/Enter for
|
||||
// SaveBeforeClose; reserved for other kinds).
|
||||
if self.mode.is_prompt() {
|
||||
return self.handle_key_prompt(key);
|
||||
}
|
||||
// Close shortcuts (Esc / F10 / Ctrl-Q): if the buffer is
|
||||
// dirty, intercept with a "Save before close?" prompt;
|
||||
// otherwise return `Close` directly.
|
||||
if key == Key::ESCAPE || key == Key::f(10) || key == Key::ctrl('q') {
|
||||
if self.modified {
|
||||
self.open_save_before_close_prompt();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
return EditorResult::Close;
|
||||
}
|
||||
// Save shortcut (Ctrl-S / F2): ask the application to
|
||||
// save. The caller calls `Editor::save` (or, for an
|
||||
// untitled buffer, opens a SaveAs prompt — not yet
|
||||
// implemented here).
|
||||
if key == Key::ctrl('s') || key == Key::f(2) {
|
||||
return EditorResult::Save;
|
||||
}
|
||||
// Alt-letter prompt shortcuts work from any non-prompt mode.
|
||||
if let Some(r) = self.try_global_shortcut(key) {
|
||||
return r;
|
||||
}
|
||||
match self.mode {
|
||||
Mode::Normal => self.handle_key_normal(key),
|
||||
Mode::Insert => self.handle_key_insert(key),
|
||||
// Prompt is handled above; the match is exhaustive.
|
||||
Mode::Prompt(_) => EditorResult::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Normal-mode key handler.
|
||||
///
|
||||
/// This is the "commands" surface: open a prompt, save, close,
|
||||
/// or move the cursor without inserting text. The Ctrl-S / F2 /
|
||||
/// Esc / F10 / Ctrl-Q shortcuts are handled at the dispatcher
|
||||
/// so a future "vim-like" Normal mode can keep them.
|
||||
fn handle_key_normal(&mut self, key: Key) -> EditorResult {
|
||||
// M-f / M-% / M-l / M-g open the four modal prompts.
|
||||
if key.mods == Modifiers::ALT {
|
||||
match key.code {
|
||||
0x66 => return self.open_prompt(PromptKind::Find),
|
||||
0x25 => return self.open_prompt(PromptKind::Replace),
|
||||
0x6C => return self.open_prompt(PromptKind::GotoLine),
|
||||
0x67 => return self.open_prompt(PromptKind::GotoCol),
|
||||
0x6D => return self.open_prompt(PromptKind::BookmarkSet),
|
||||
0x6A => return self.open_prompt(PromptKind::BookmarkJump),
|
||||
0x6B => return self.open_prompt(PromptKind::BookmarkClear),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
EditorResult::Running
|
||||
}
|
||||
|
||||
/// Handle a Ctrl-S / F2 / Esc / F10 / Ctrl-Q / Alt-letter shortcut
|
||||
/// from any non-prompt mode. Returns Some(result) if the key was
|
||||
/// consumed by a shortcut, None if the caller should continue to
|
||||
/// the per-mode handler.
|
||||
fn try_global_shortcut(&mut self, key: Key) -> Option<EditorResult> {
|
||||
// M-f / M-% / M-l / M-g open the four modal prompts from any
|
||||
// non-prompt mode (the editor is normally in Insert mode).
|
||||
if key.mods == Modifiers::ALT {
|
||||
match key.code {
|
||||
0x66 => return Some(self.open_prompt(PromptKind::Find)),
|
||||
0x25 => return Some(self.open_prompt(PromptKind::Replace)),
|
||||
0x6C => return Some(self.open_prompt(PromptKind::GotoLine)),
|
||||
0x67 => return Some(self.open_prompt(PromptKind::GotoCol)),
|
||||
0x6D => return Some(self.open_prompt(PromptKind::BookmarkSet)),
|
||||
0x6A => return Some(self.open_prompt(PromptKind::BookmarkJump)),
|
||||
0x6B => return Some(self.open_prompt(PromptKind::BookmarkClear)),
|
||||
0x62 => {
|
||||
self.match_bracket();
|
||||
return Some(EditorResult::Running);
|
||||
}
|
||||
0x70 => {
|
||||
self.format_paragraph();
|
||||
return Some(EditorResult::Running);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert-mode key handler. The bulk of the previous
|
||||
/// `handle_key` body — typing, arrow movement, backspace, undo,
|
||||
/// etc.
|
||||
fn handle_key_insert(&mut self, key: Key) -> EditorResult {
|
||||
if key == Key::ctrl('z') {
|
||||
self.undo();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key == Key::ctrl('y') {
|
||||
self.redo();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key == Key::BACKSPACE {
|
||||
self.delete_back();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key == Key::DELETE {
|
||||
self.delete_forward();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key == Key::ENTER {
|
||||
self.insert_char('\n');
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key == Key::TAB {
|
||||
self.insert_char('\t');
|
||||
return EditorResult::Running;
|
||||
}
|
||||
if key.code == 0x09 && key.mods.contains(Modifiers::ALT) {
|
||||
self.word_complete();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// F3 — Toggle selection mark.
|
||||
if key == Key::f(3) {
|
||||
if self.cursor.has_selection() {
|
||||
self.cursor.clear_selection();
|
||||
} else {
|
||||
self.cursor.start_selection();
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// F5 — Copy block.
|
||||
if key == Key::f(5) {
|
||||
if let Some(text) = self.cursor.selected_text(&self.buffer) {
|
||||
self.clipboard = Some(text.to_string());
|
||||
self.message = Some("Block copied".to_string());
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// F6 — Move (cut) block.
|
||||
if key == Key::f(6) {
|
||||
if let Some(text) = self.cursor.selected_text(&self.buffer) {
|
||||
self.clipboard = Some(text.to_string());
|
||||
self.cursor.delete_selection(&mut self.buffer);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.modified = true;
|
||||
self.message = Some("Block moved".to_string());
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// F8 — Delete block.
|
||||
if key == Key::f(8) {
|
||||
if self.cursor.has_selection() {
|
||||
self.cursor.delete_selection(&mut self.buffer);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.modified = true;
|
||||
self.message = Some("Block deleted".to_string());
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// Ctrl-V — Paste from internal clipboard.
|
||||
if key == Key::ctrl('v') {
|
||||
if let Some(text) = self.clipboard.clone() {
|
||||
self.insert_str(&text);
|
||||
self.message = Some("Pasted".to_string());
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
match key {
|
||||
Key { code: 0x2190, mods } if mods.is_empty() => {
|
||||
self.cursor.move_left(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2190, mods } if mods.contains(crate::key::Modifiers::CTRL) => {
|
||||
self.cursor.move_word_backward(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2192, mods } if mods.is_empty() => {
|
||||
self.cursor.move_right(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2192, mods } if mods.contains(crate::key::Modifiers::CTRL) => {
|
||||
self.cursor.move_word_forward(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2191, .. } => {
|
||||
self.cursor.move_up(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2193, .. } => {
|
||||
self.cursor.move_down(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21A1, .. } => {
|
||||
self.cursor.move_home(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21A0, .. } => {
|
||||
self.cursor.move_end(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21DE, .. } => {
|
||||
self.cursor.move_page_up(&mut self.buffer, 20);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21DF, .. } => {
|
||||
self.cursor.move_page_down(&mut self.buffer, 20);
|
||||
EditorResult::Running
|
||||
}
|
||||
// Printable ASCII.
|
||||
Key { code: c, mods } if mods.is_empty() && (0x20..0x7f).contains(&c) => {
|
||||
if let Some(ch) = char::from_u32(c) {
|
||||
self.insert_char(ch);
|
||||
}
|
||||
EditorResult::Running
|
||||
}
|
||||
// Unicode insert (non-ASCII printable — best-effort single char).
|
||||
Key { code: c, mods } if mods.is_empty() && c > 0x7f && c < 0x11_0000 => {
|
||||
if let Some(ch) = char::from_u32(c) {
|
||||
self.insert_char(ch);
|
||||
}
|
||||
EditorResult::Running
|
||||
}
|
||||
_ => EditorResult::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt-mode key handler. The kind of prompt currently open
|
||||
/// (carried in `self.mode`) determines which keys are accepted
|
||||
/// and what they do.
|
||||
///
|
||||
/// `SaveBeforeClose` is a yes/no prompt and stays separate
|
||||
/// from the text-input prompts (`Find`, `Replace`, `GotoLine`,
|
||||
/// `GotoCol`, `SaveAs`) — those route through
|
||||
/// [`Self::handle_text_prompt`].
|
||||
fn handle_key_prompt(&mut self, key: Key) -> EditorResult {
|
||||
match self.mode {
|
||||
Mode::Prompt(PromptKind::SaveBeforeClose) => self.handle_save_before_close(key),
|
||||
Mode::Prompt(_) => self.handle_text_prompt(key),
|
||||
_ => EditorResult::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key while one of the text-input prompts is open
|
||||
/// (Find, Replace, GotoLine, GotoCol, SaveAs). The active
|
||||
/// prompt kind decides what happens on Enter; the text input
|
||||
/// itself accepts printable characters, Backspace, and the
|
||||
/// usual cursor / home / end keys.
|
||||
fn handle_text_prompt(&mut self, key: Key) -> EditorResult {
|
||||
// Esc cancels the prompt without committing.
|
||||
if key == Key::ESCAPE {
|
||||
self.prompt_input.clear();
|
||||
self.mode = Mode::Insert;
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// Enter commits the prompt's text and runs the prompt's
|
||||
// action (jump, search, save-as).
|
||||
if key == Key::ENTER {
|
||||
let text = self.prompt_input.text.clone();
|
||||
let kind = match self.mode {
|
||||
Mode::Prompt(k) => k,
|
||||
_ => return EditorResult::Running,
|
||||
};
|
||||
self.prompt_input.clear();
|
||||
self.mode = Mode::Insert;
|
||||
self.commit_prompt(kind, &text);
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// Backspace deletes the character before the cursor.
|
||||
if key == Key::BACKSPACE {
|
||||
self.prompt_input.delete_back();
|
||||
return EditorResult::Running;
|
||||
}
|
||||
// Cursor / home / end (no modifiers).
|
||||
if key.mods.is_empty() {
|
||||
match key.code {
|
||||
0x2190 => {
|
||||
self.prompt_input.move_left();
|
||||
return EditorResult::Running;
|
||||
} // Left
|
||||
0x2192 => {
|
||||
self.prompt_input.move_right();
|
||||
return EditorResult::Running;
|
||||
} // Right
|
||||
0x2196 => {
|
||||
self.prompt_input.move_home();
|
||||
return EditorResult::Running;
|
||||
} // Home
|
||||
0x2198 => {
|
||||
self.prompt_input.move_end();
|
||||
return EditorResult::Running;
|
||||
} // End
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Printable ASCII: feed into the prompt buffer. Other
|
||||
// keys (function keys, Alt-letter shortcuts, etc.) are
|
||||
// ignored to keep the prompt simple.
|
||||
if key.mods.is_empty() && (0x20..=0x7E).contains(&key.code) {
|
||||
if let Some(c) = char::from_u32(key.code) {
|
||||
self.prompt_input.insert_char(c);
|
||||
}
|
||||
return EditorResult::Running;
|
||||
}
|
||||
EditorResult::Running
|
||||
}
|
||||
|
||||
/// Apply a committed prompt value to the editor. Called by
|
||||
/// `handle_text_prompt` on Enter; the prompt kind drives the
|
||||
/// action (GotoLine → move cursor; Find → start search; etc.).
|
||||
fn commit_prompt(&mut self, kind: PromptKind, text: &str) {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
match kind {
|
||||
PromptKind::GotoLine => {
|
||||
// 1-based line number; the editor exposes an
|
||||
// offset helper that maps line → byte offset.
|
||||
if let Ok(n) = text.parse::<u32>() {
|
||||
if n >= 1 {
|
||||
if let Ok(off) = crate::editor::goto::line_to_offset(&self.buffer, n) {
|
||||
self.buffer.set_cursor(off);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PromptKind::GotoCol => {
|
||||
// 1-based column on the **current** line. We read
|
||||
// the current line from the cursor's byte offset
|
||||
// and map (line, col) to an offset.
|
||||
if let Ok(n) = text.parse::<u32>() {
|
||||
if n >= 1 {
|
||||
// The line number is not known here; the
|
||||
// user wanted "column N from the start of
|
||||
// the current line", so we anchor at line 1
|
||||
// (the first line). For column N in an
|
||||
// arbitrary line, the user would re-issue
|
||||
// the prompt from that line.
|
||||
let line_num = 1u32;
|
||||
if let Ok(off) = crate::editor::goto::col_to_offset(&self.buffer, line_num, n) {
|
||||
self.buffer.set_cursor(off);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PromptKind::BookmarkSet => {
|
||||
if let Some(name) = text.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||
match self.set_bookmark(name) {
|
||||
Ok(()) => self.message = Some(format!("Bookmark '{}' set", name)),
|
||||
Err(err) => self.message = Some(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
PromptKind::BookmarkJump => {
|
||||
if let Some(name) = text.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||
if let Some(mark) = self.bookmarks.get(name) {
|
||||
if let Ok(off) = crate::editor::goto::col_to_offset(
|
||||
&self.buffer,
|
||||
mark.line + 1,
|
||||
mark.col + 1,
|
||||
) {
|
||||
self.buffer.set_cursor(off);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.message = Some(format!("Jumped to bookmark '{}'", name));
|
||||
}
|
||||
} else {
|
||||
self.message = Some(format!("Bookmark '{}' is not set", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
PromptKind::BookmarkClear => {
|
||||
if let Some(name) = text.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||
if self.clear_bookmark(name) {
|
||||
self.message = Some(format!("Bookmark '{}' cleared", name));
|
||||
} else {
|
||||
self.message = Some(format!("Bookmark '{}' is not set", name));
|
||||
}
|
||||
}
|
||||
}
|
||||
PromptKind::Find | PromptKind::Replace => {
|
||||
// Find/Replace store the pattern; the live
|
||||
// highlight UI is Phase 5d.
|
||||
self.search_pattern = Some(text.to_string());
|
||||
}
|
||||
PromptKind::SaveAs => {
|
||||
// SaveAs is a two-step: the user types a path in
|
||||
// the prompt; on commit we either write the
|
||||
// buffer to that path (full save) or just record
|
||||
// the path (deferred save). For v1 we record the
|
||||
// path: the next `Save` action uses it. We do
|
||||
// NOT touch the filesystem here.
|
||||
self.path = Some(std::path::PathBuf::from(text));
|
||||
}
|
||||
PromptKind::SaveBeforeClose => {
|
||||
// The save-before-close prompt routes through
|
||||
// `handle_save_before_close`, not this function.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key while the "Save before close?" prompt is open.
|
||||
/// Y / Enter → `SaveThenClose`, N → `DiscardThenClose`,
|
||||
/// Esc → stay in the prompt (Running).
|
||||
fn handle_save_before_close(&mut self, key: Key) -> EditorResult {
|
||||
// Enter is accepted as an alias for Y so the prompt works
|
||||
// for both keyboard users (Enter) and quick answerers (Y/N).
|
||||
if key == Key::ENTER {
|
||||
self.mode = Mode::Insert;
|
||||
return EditorResult::SaveThenClose;
|
||||
}
|
||||
// Plain ASCII y / n (no modifiers).
|
||||
if key.mods.is_empty() {
|
||||
match key.code {
|
||||
c if c == b'y' as u32 => {
|
||||
self.mode = Mode::Insert;
|
||||
return EditorResult::SaveThenClose;
|
||||
}
|
||||
c if c == b'n' as u32 => {
|
||||
self.mode = Mode::Insert;
|
||||
return EditorResult::DiscardThenClose;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if key == Key::ESCAPE {
|
||||
// Cancel: stay in the prompt so the user can re-answer.
|
||||
// We do NOT close the prompt here — the caller should
|
||||
// not see Running as a "go ahead and close" signal.
|
||||
return EditorResult::Running;
|
||||
}
|
||||
EditorResult::Running
|
||||
}
|
||||
|
||||
/// Open the "Save before close?" prompt.
|
||||
fn open_save_before_close_prompt(&mut self) {
|
||||
self.prompt_input.clear();
|
||||
self.mode = Mode::Prompt(PromptKind::SaveBeforeClose);
|
||||
}
|
||||
|
||||
/// Open the named prompt (Find, Replace, GotoLine, GotoCol, SaveAs).
|
||||
///
|
||||
/// Resets the prompt's text input and switches the editor into
|
||||
/// [`Mode::Prompt`] with the given kind. The caller's keymap is
|
||||
/// responsible for routing the trigger key to Normal mode first.
|
||||
fn open_prompt(&mut self, kind: PromptKind) -> EditorResult {
|
||||
self.prompt_input.clear();
|
||||
self.mode = Mode::Prompt(kind);
|
||||
EditorResult::Running
|
||||
}
|
||||
|
||||
/// Alt-B — jump to the matching bracket for the character at
|
||||
/// or adjacent to the cursor. Supports `()[]{}`.
|
||||
pub(crate) fn match_bracket(&mut self) {
|
||||
let text = self.buffer.as_string();
|
||||
let pos = self.buffer.cursor();
|
||||
let bytes = text.as_bytes();
|
||||
if pos >= bytes.len() {
|
||||
return;
|
||||
}
|
||||
let ch = bytes[pos] as char;
|
||||
let target = if is_open_bracket(ch) {
|
||||
find_matching_forward(bytes, pos, ch)
|
||||
} else if is_close_bracket(ch) {
|
||||
find_matching_backward(bytes, pos, ch)
|
||||
} else if pos > 0 && is_open_bracket(bytes[pos - 1] as char) {
|
||||
find_matching_forward(bytes, pos - 1, bytes[pos - 1] as char)
|
||||
} else if pos > 0 && is_close_bracket(bytes[pos - 1] as char) {
|
||||
find_matching_backward(bytes, pos - 1, bytes[pos - 1] as char)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(match_pos) = target {
|
||||
self.buffer.set_cursor(match_pos);
|
||||
self.cursor.set_position(match_pos, &self.buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt-Tab — complete the word before the cursor. First press
|
||||
/// collects candidates; subsequent presses cycle through them.
|
||||
pub(crate) fn word_complete(&mut self) {
|
||||
let text = self.buffer.as_string();
|
||||
let pos = self.cursor.position();
|
||||
let prefix_end = pos;
|
||||
let prefix_start = text[..prefix_end]
|
||||
.rfind(|c: char| !is_completion_word_char(c))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let prefix = &text[prefix_start..prefix_end];
|
||||
if prefix.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.completer.is_empty() {
|
||||
self.completer
|
||||
.start(CompletionMode::Word, prefix, &self.buffer, Path::new("."));
|
||||
self.complete_prefix_len = prefix.len();
|
||||
if self.completer.is_empty() {
|
||||
self.message = Some("No completions".to_string());
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
self.completer.next();
|
||||
}
|
||||
|
||||
if let Some(c) = self.completer.current() {
|
||||
let del = prefix_start + self.complete_prefix_len;
|
||||
self.buffer.set_cursor(prefix_start);
|
||||
for _ in 0..del {
|
||||
self.buffer.delete_back();
|
||||
}
|
||||
self.buffer.insert_str(&c.text);
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
self.modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
//! Rendering for the editor.
|
||||
//!
|
||||
//! [`Editor::render`] writes the editor into a ratatui `Frame` at
|
||||
//! the given `Rect`. The view is a simple top-line/left-column
|
||||
//! scroller with line numbers in the gutter. The status line shows
|
||||
//! position, modification state, and active mode.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::editor::{Mode, PromptKind};
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::mc_skin;
|
||||
use crate::terminal::popup::{centered_percent_rect, render_popup};
|
||||
|
||||
use super::Editor;
|
||||
|
||||
impl Editor {
|
||||
/// Render the editor into a ratatui frame at the given area.
|
||||
///
|
||||
/// `theme` supplies the title, gutter, body, prompt-overlay, and
|
||||
/// status-line colours so the editor follows the active skin.
|
||||
pub(crate) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
// Re-sync cursor struct from buffer in case a mutator
|
||||
// (e.g. commit_prompt's GotoLine/GotoCol) changed the
|
||||
// buffer cursor without calling cursor.set_position().
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
// Syntect is stateful: when the user scrolls the viewport
|
||||
// we have to rebuild the parser state by replaying every
|
||||
// line from the top of the file down to the new first
|
||||
// visible line. Without this, the highlighter's notion of
|
||||
// "what context are we in?" (block comments, string
|
||||
// literals, etc.) drifts away from reality as the user
|
||||
// scrolls, and the colors stop matching the source.
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
let current_top = self.view.top_line();
|
||||
if current_top != self.last_render_top {
|
||||
self.last_render_top = current_top;
|
||||
if let Some(ref path) = self.path {
|
||||
self.highlighter =
|
||||
crate::editor::syntax::Highlighter::new(path);
|
||||
}
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
let full_text = self.buffer.as_string();
|
||||
for i in 0..current_top {
|
||||
let off = self.buffer.line_offset(i);
|
||||
let len = self.buffer.line_length(i);
|
||||
let end = (off + len).min(full_text.len());
|
||||
let line_text = full_text.get(off..end).unwrap_or("");
|
||||
let _ = h.highlight_line(line_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let editor_default = mc_skin::color_pair(theme.name, "editor", "_default_");
|
||||
let editor_marked = mc_skin::color_pair(theme.name, "editor", "editmarked");
|
||||
let editor_linestate = mc_skin::color_pair(theme.name, "editor", "editlinestate");
|
||||
let editor_frameactive = mc_skin::color_pair(theme.name, "editor", "editframeactive");
|
||||
let editor_rightmargin = mc_skin::color_pair(theme.name, "editor", "editrightmargin");
|
||||
let editor_whitespace = mc_skin::color_pair(theme.name, "editor", "editwhitespace");
|
||||
let editor_nonprintable = mc_skin::color_pair(theme.name, "editor", "editnonprintable");
|
||||
let editor_bookmark = mc_skin::color_pair(theme.name, "editor", "bookmark");
|
||||
let editor_bookmarkfound = mc_skin::color_pair(theme.name, "editor", "bookmarkfound");
|
||||
let body_fg = editor_default.map(|p| p.fg).unwrap_or(theme.foreground);
|
||||
let body_bg = editor_default.map(|p| p.bg).unwrap_or(theme.background);
|
||||
let marked_fg = editor_marked.map(|p| p.fg).unwrap_or(theme.marked_fg);
|
||||
let marked_bg = editor_marked.map(|p| p.bg).unwrap_or(theme.marked_bg);
|
||||
let linestate_fg = editor_linestate.map(|p| p.fg).unwrap_or(theme.cursor_fg);
|
||||
let linestate_bg = editor_linestate.map(|p| p.bg).unwrap_or(theme.title_bg);
|
||||
let frame_fg = editor_frameactive.map(|p| p.fg).unwrap_or(theme.title_fg);
|
||||
let margin_fg = editor_rightmargin.map(|p| p.fg).unwrap_or(frame_fg);
|
||||
let margin_bg = editor_rightmargin.map(|p| p.bg).unwrap_or(body_bg);
|
||||
let whitespace_fg = editor_whitespace.map(|p| p.fg).unwrap_or(frame_fg);
|
||||
let whitespace_bg = editor_whitespace.map(|p| p.bg).unwrap_or(body_bg);
|
||||
let nonprintable_fg = editor_nonprintable.map(|p| p.fg).unwrap_or(frame_fg);
|
||||
let nonprintable_bg = editor_nonprintable.map(|p| p.bg).unwrap_or(body_bg);
|
||||
let bookmark_fg = editor_bookmark.map(|p| p.fg).unwrap_or(frame_fg);
|
||||
let bookmark_bg = editor_bookmark.map(|p| p.bg).unwrap_or(body_bg);
|
||||
let bookmarkfound_fg = editor_bookmarkfound.map(|p| p.fg).unwrap_or(bookmark_fg);
|
||||
let bookmarkfound_bg = editor_bookmarkfound.map(|p| p.bg).unwrap_or(bookmark_bg);
|
||||
|
||||
// Block + title.
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(frame_fg).bg(body_bg))
|
||||
.style(Style::default().fg(body_fg).bg(body_bg))
|
||||
.title(Span::styled(
|
||||
self.title.clone(),
|
||||
Style::default()
|
||||
.fg(frame_fg)
|
||||
.bg(body_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// Split: gutter (line numbers) + body.
|
||||
let line_count = self.buffer.line_count();
|
||||
let gutter_w = (line_count.max(1).to_string().len() as u16 + 1).max(4);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(gutter_w), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
|
||||
// Ensure cursor is visible.
|
||||
self.view.ensure_cursor_visible(
|
||||
&self.buffer,
|
||||
self.cursor.position(),
|
||||
chunks[1].height as usize,
|
||||
);
|
||||
|
||||
// Gutter: line numbers, 1-based, top..top+height.
|
||||
let top = self.view.top_line();
|
||||
let height = chunks[1].height as usize;
|
||||
let cursor_line = self.buffer_line_of(self.cursor.position());
|
||||
let gutter_lines: Vec<Line> = (0..height)
|
||||
.map(|row| {
|
||||
let line_idx = top + row;
|
||||
let n = line_idx + 1;
|
||||
let gutter_style = if self.has_bookmark_on_line(line_idx) {
|
||||
if cursor_line == line_idx {
|
||||
Style::default()
|
||||
.fg(bookmarkfound_fg)
|
||||
.bg(bookmarkfound_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(bookmark_fg).bg(bookmark_bg)
|
||||
}
|
||||
} else {
|
||||
Style::default().fg(frame_fg).bg(body_bg)
|
||||
};
|
||||
Line::from(Span::styled(
|
||||
format!("{:>w$} ", n, w = (gutter_w - 1) as usize),
|
||||
gutter_style,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(gutter_lines), chunks[0]);
|
||||
|
||||
// Body: render the visible slice of the buffer.
|
||||
let mut body_lines: Vec<Line> = Vec::with_capacity(height);
|
||||
let full_text = self.buffer.as_string();
|
||||
let sel = self.cursor.selection();
|
||||
for row in 0..height {
|
||||
let line_idx = top + row;
|
||||
if line_idx >= line_count {
|
||||
body_lines.push(Line::from(Span::styled(
|
||||
"~",
|
||||
Style::default().fg(frame_fg).bg(body_bg),
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
let off = self.buffer.line_offset(line_idx);
|
||||
let len = self.buffer.line_length(line_idx);
|
||||
let line_end = (off + len).min(full_text.len());
|
||||
let line_text = full_text.get(off..line_end).unwrap_or("");
|
||||
let base_style = if cursor_line == line_idx {
|
||||
Style::default().fg(linestate_fg).bg(body_bg)
|
||||
} else {
|
||||
Style::default().fg(body_fg).bg(body_bg)
|
||||
};
|
||||
if let Some((ss, se)) = sel {
|
||||
if ss < line_end && se > off {
|
||||
let rs = ss.saturating_sub(off);
|
||||
let re = (se - off).min(len);
|
||||
let sel_style = Style::default()
|
||||
.fg(marked_fg)
|
||||
.bg(marked_bg);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
let highlighted = h.highlight_line(line_text);
|
||||
spans = split_spans_for_selection(
|
||||
highlighted,
|
||||
rs,
|
||||
re,
|
||||
body_bg,
|
||||
marked_bg,
|
||||
);
|
||||
body_lines.push(Line::from(spans));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if rs > 0 {
|
||||
if let Some(b) = line_text.get(..rs) {
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
b,
|
||||
base_style,
|
||||
whitespace_fg,
|
||||
whitespace_bg,
|
||||
nonprintable_fg,
|
||||
nonprintable_bg,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(m) = line_text.get(rs..re) {
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
m,
|
||||
sel_style,
|
||||
marked_fg,
|
||||
marked_bg,
|
||||
marked_fg,
|
||||
marked_bg,
|
||||
);
|
||||
}
|
||||
if re < line_text.len() {
|
||||
if let Some(a) = line_text.get(re..) {
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
a,
|
||||
base_style,
|
||||
whitespace_fg,
|
||||
whitespace_bg,
|
||||
nonprintable_fg,
|
||||
nonprintable_bg,
|
||||
);
|
||||
}
|
||||
}
|
||||
body_lines.push(Line::from(spans));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut spans = Vec::new();
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
spans = h.highlight_line(line_text);
|
||||
}
|
||||
}
|
||||
if spans.is_empty() {
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
line_text,
|
||||
base_style,
|
||||
whitespace_fg,
|
||||
whitespace_bg,
|
||||
nonprintable_fg,
|
||||
nonprintable_bg,
|
||||
);
|
||||
}
|
||||
body_lines.push(Line::from(spans));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(body_lines), chunks[1]);
|
||||
|
||||
if chunks[1].width > 0 {
|
||||
let margin_area = Rect::new(
|
||||
chunks[1].x + chunks[1].width - 1,
|
||||
chunks[1].y,
|
||||
1,
|
||||
chunks[1].height,
|
||||
);
|
||||
let margin_lines: Vec<Line> = (0..chunks[1].height as usize)
|
||||
.map(|_| {
|
||||
Line::from(Span::styled(
|
||||
"│",
|
||||
Style::default().fg(margin_fg).bg(margin_bg),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
frame.render_widget(
|
||||
Paragraph::new(margin_lines).style(Style::default().fg(margin_fg).bg(margin_bg)),
|
||||
margin_area,
|
||||
);
|
||||
}
|
||||
|
||||
// Position the terminal cursor at the editing point.
|
||||
if !matches!(self.mode, Mode::Prompt(_)) {
|
||||
let cursor_line = self.buffer_line_of(self.cursor.position());
|
||||
let cursor_col = self.cursor.visual_column() as u16;
|
||||
let row = cursor_line.saturating_sub(top) as u16;
|
||||
let x = chunks[1].x + cursor_col.min(chunks[1].width.saturating_sub(1));
|
||||
let y = chunks[1].y + row;
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
|
||||
// Save-before-close prompt overlay.
|
||||
if matches!(self.mode, Mode::Prompt(PromptKind::SaveBeforeClose)) {
|
||||
let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_")
|
||||
.unwrap_or(mc_skin::ColorPair {
|
||||
fg: theme.foreground,
|
||||
bg: body_bg,
|
||||
});
|
||||
let dialog_hot = mc_skin::color_pair(theme.name, "dialog", "dhotnormal")
|
||||
.unwrap_or(dialog_default);
|
||||
let popup = centered_percent_rect(area, 0.5, 0.25);
|
||||
let inner = render_popup(frame, popup, crate::locale::t("dialog_save_changes"), theme);
|
||||
let p = Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
"Press ",
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
),
|
||||
Span::styled(
|
||||
"Y",
|
||||
Style::default()
|
||||
.fg(dialog_hot.fg)
|
||||
.bg(dialog_hot.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" to save, ",
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
),
|
||||
Span::styled(
|
||||
"N",
|
||||
Style::default()
|
||||
.fg(dialog_hot.fg)
|
||||
.bg(dialog_hot.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" to discard, ",
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
),
|
||||
Span::styled(
|
||||
"Esc",
|
||||
Style::default()
|
||||
.fg(dialog_hot.fg)
|
||||
.bg(dialog_hot.bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
" to cancel.",
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
),
|
||||
]))
|
||||
.style(Style::default().fg(dialog_default.fg).bg(dialog_default.bg));
|
||||
frame.render_widget(p, inner);
|
||||
} else if let Mode::Prompt(kind) = self.mode {
|
||||
let dialog_default = mc_skin::color_pair(theme.name, "dialog", "_default_")
|
||||
.unwrap_or(mc_skin::ColorPair {
|
||||
fg: theme.foreground,
|
||||
bg: body_bg,
|
||||
});
|
||||
let popup = centered_percent_rect(area, 0.5, 0.25);
|
||||
let title = match kind {
|
||||
PromptKind::Find => crate::locale::t("dialog_title_find"),
|
||||
PromptKind::Replace => crate::locale::t("dialog_title_replace"),
|
||||
PromptKind::GotoLine => crate::locale::t("dialog_title_goto_line"),
|
||||
PromptKind::GotoCol => crate::locale::t("dialog_title_goto_col"),
|
||||
PromptKind::BookmarkSet => crate::locale::t("dialog_title_bookmark_set"),
|
||||
PromptKind::BookmarkJump => crate::locale::t("dialog_title_bookmark_jump"),
|
||||
PromptKind::BookmarkClear => crate::locale::t("dialog_title_bookmark_clear"),
|
||||
PromptKind::SaveAs => crate::locale::t("dialog_title_save_as"),
|
||||
PromptKind::SaveBeforeClose => unreachable!(),
|
||||
};
|
||||
let label = match kind {
|
||||
PromptKind::Find => crate::locale::t("dialog_label_find"),
|
||||
PromptKind::Replace => crate::locale::t("dialog_label_replace_with"),
|
||||
PromptKind::GotoLine => crate::locale::t("dialog_title_goto_line"),
|
||||
PromptKind::GotoCol => crate::locale::t("dialog_title_goto_col"),
|
||||
PromptKind::BookmarkSet
|
||||
| PromptKind::BookmarkJump
|
||||
| PromptKind::BookmarkClear => crate::locale::t("dialog_label_bookmark"),
|
||||
PromptKind::SaveAs => crate::locale::t("dialog_label_path"),
|
||||
PromptKind::SaveBeforeClose => unreachable!(),
|
||||
};
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Length(1)])
|
||||
.split(inner);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
))),
|
||||
rows[0],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
self.prompt_input.text.clone(),
|
||||
Style::default().fg(dialog_default.fg).bg(dialog_default.bg),
|
||||
)))
|
||||
.style(Style::default().fg(dialog_default.fg).bg(dialog_default.bg)),
|
||||
rows[1],
|
||||
);
|
||||
let cursor_x =
|
||||
rows[1].x + (self.prompt_input.cursor as u16).min(rows[1].width.saturating_sub(1));
|
||||
frame.set_cursor_position((cursor_x, rows[1].y));
|
||||
}
|
||||
|
||||
// Status line (last line of the editor area).
|
||||
if area.height >= 2 {
|
||||
let status_y = area.y + area.height - 1;
|
||||
let status = Paragraph::new(Line::from(Span::styled(
|
||||
self.status_string(),
|
||||
Style::default().fg(linestate_fg).bg(linestate_bg),
|
||||
)));
|
||||
frame.render_widget(status, Rect::new(area.x, status_y, area.width, 1));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn buffer_line_of(&self, byte_pos: usize) -> usize {
|
||||
// Linear walk — fine for typical edit-buffer sizes.
|
||||
let text = self.buffer.as_string();
|
||||
let mut line = 0;
|
||||
for (i, ch) in text.char_indices() {
|
||||
if i >= byte_pos {
|
||||
break;
|
||||
}
|
||||
if ch == '\n' {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
pub(crate) fn status_string(&self) -> String {
|
||||
let line = self.buffer_line_of(self.cursor.position()) + 1;
|
||||
let col = self.cursor.visual_column() + 1;
|
||||
let modified = if self.modified { "[+]" } else { " " };
|
||||
let eol = self.buffer.eol();
|
||||
let mode_tag = match self.mode {
|
||||
Mode::Insert => "",
|
||||
Mode::Normal => " [NORMAL]",
|
||||
Mode::Prompt(k) => match k {
|
||||
PromptKind::SaveBeforeClose => " [Save?]",
|
||||
PromptKind::Find => " [Find]",
|
||||
PromptKind::Replace => " [Replace]",
|
||||
PromptKind::GotoLine => " [GotoLine]",
|
||||
PromptKind::GotoCol => " [GotoCol]",
|
||||
PromptKind::BookmarkSet => " [BookmarkSet]",
|
||||
PromptKind::BookmarkJump => " [BookmarkJump]",
|
||||
PromptKind::BookmarkClear => " [BookmarkClear]",
|
||||
PromptKind::SaveAs => " [SaveAs]",
|
||||
},
|
||||
};
|
||||
format!(
|
||||
" {} Ln {}/{} Col {} EOL: {} Bytes: {} Path: {}{}",
|
||||
modified,
|
||||
line,
|
||||
self.buffer.line_count(),
|
||||
col,
|
||||
eol,
|
||||
self.buffer.len(),
|
||||
self.path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "<untitled>".to_string()),
|
||||
mode_tag,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn has_bookmark_on_line(&self, line: usize) -> bool {
|
||||
self.bookmarks
|
||||
.names()
|
||||
.into_iter()
|
||||
.filter_map(|name| self.bookmarks.get(name))
|
||||
.any(|mark| mark.line as usize == line)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_rendered_text<'a>(
|
||||
spans: &mut Vec<Span<'a>>,
|
||||
text: &str,
|
||||
base_style: Style,
|
||||
whitespace_fg: Color,
|
||||
whitespace_bg: Color,
|
||||
nonprintable_fg: Color,
|
||||
nonprintable_bg: Color,
|
||||
) {
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
' ' => spans.push(Span::styled(
|
||||
"·".to_string(),
|
||||
Style::default().fg(whitespace_fg).bg(whitespace_bg),
|
||||
)),
|
||||
'\t' => spans.push(Span::styled(
|
||||
"→".to_string(),
|
||||
Style::default().fg(whitespace_fg).bg(whitespace_bg),
|
||||
)),
|
||||
ch if ch.is_control() => {
|
||||
let rendered = if ch == '\u{7f}' {
|
||||
"^?".to_string()
|
||||
} else {
|
||||
format!("^{}", ((ch as u32) + 0x40) as u8 as char)
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
rendered,
|
||||
Style::default()
|
||||
.fg(nonprintable_fg)
|
||||
.bg(nonprintable_bg),
|
||||
));
|
||||
}
|
||||
_ => spans.push(Span::styled(ch.to_string(), base_style)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a sequence of syntax-highlighted spans for a single line
|
||||
/// so that bytes falling inside the selection range `[rs, re)`
|
||||
/// receive the selection background, while bytes outside keep the
|
||||
/// body background. Foreground colors and font modifiers from the
|
||||
/// highlighted spans are preserved everywhere — the selection
|
||||
/// only changes the background.
|
||||
#[cfg(feature = "syntect")]
|
||||
pub(crate) fn split_spans_for_selection(
|
||||
highlighted: Vec<Span<'static>>,
|
||||
rs: usize,
|
||||
re: usize,
|
||||
base_bg: Color,
|
||||
marked_bg: Color,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut out: Vec<Span<'static>> = Vec::with_capacity(highlighted.len() + 2);
|
||||
let mut pos: usize = 0;
|
||||
for span in highlighted {
|
||||
let span_start = pos;
|
||||
let span_end = pos + span.content.len();
|
||||
pos = span_end;
|
||||
// Three candidate cut points in line-relative byte
|
||||
// coordinates: the selection's start and end, clamped to
|
||||
// this span. We then take sub-slices at those cuts.
|
||||
let cut_a = rs.min(span_end).max(span_start);
|
||||
let cut_b = re.min(span_end).max(span_start);
|
||||
// Always emit up to three pieces: [span_start..cut_a],
|
||||
// [cut_a..cut_b], [cut_b..span_end]. Each piece is
|
||||
// classified by whether its midpoint falls inside the
|
||||
// selection range.
|
||||
let pieces: [(usize, usize, bool); 3] = [
|
||||
(span_start, cut_a, false),
|
||||
(cut_a, cut_b, true),
|
||||
(cut_b, span_end, false),
|
||||
];
|
||||
for (from, to, in_sel) in pieces {
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
// Translate line-byte coordinates to local char-boundary
|
||||
// coordinates inside `span.content`. UTF-8 forces us to
|
||||
// walk `char_indices`; clamping `from`/`to` to the
|
||||
// nearest valid char boundary guarantees a safe slice.
|
||||
let local_from = (from - span_start).min(span.content.len());
|
||||
let local_to = (to - span_start).min(span.content.len());
|
||||
let lo = round_up_to_char_boundary(&span.content, local_from);
|
||||
let hi = round_up_to_char_boundary(&span.content, local_to);
|
||||
if lo >= hi {
|
||||
continue;
|
||||
}
|
||||
let piece = &span.content[lo..hi];
|
||||
let bg = if in_sel { marked_bg } else { base_bg };
|
||||
let mut s = span.style;
|
||||
s = s.bg(bg);
|
||||
out.push(Span::styled(piece.to_string(), s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Round `idx` up to the next UTF-8 char boundary in `s`. If `idx`
|
||||
/// is already at a boundary (or is `>= s.len()`), returns `idx`
|
||||
/// unchanged. If `idx` falls mid-codepoint, advances to the END of
|
||||
/// that codepoint so that any multi-byte character straddling the
|
||||
/// cut is included entirely on the cut's "greater" side.
|
||||
#[cfg(feature = "syntect")]
|
||||
pub(crate) fn round_up_to_char_boundary(s: &str, idx: usize) -> usize {
|
||||
if idx >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = idx;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
Reference in New Issue
Block a user