From 3d4fe1300138286e9250b5a2525e417f253e89c2 Mon Sep 17 00:00:00 2001 From: vasilito Date: Fri, 19 Jun 2026 20:35:47 +0300 Subject: [PATCH] =?UTF-8?q?Split=20editor=20module:=20mod.rs=202646?= =?UTF-8?q?=E2=86=92434=20non-test=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../tui/tlc/source/src/editor/bracket.rs | 84 ++ .../tui/tlc/source/src/editor/handlers.rs | 551 ++++++++ .../recipes/tui/tlc/source/src/editor/mod.rs | 1171 +---------------- .../tui/tlc/source/src/editor/render.rs | 571 ++++++++ 4 files changed, 1226 insertions(+), 1151 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/editor/bracket.rs create mode 100644 local/recipes/tui/tlc/source/src/editor/handlers.rs create mode 100644 local/recipes/tui/tlc/source/src/editor/render.rs diff --git a/local/recipes/tui/tlc/source/src/editor/bracket.rs b/local/recipes/tui/tlc/source/src/editor/bracket.rs new file mode 100644 index 0000000000..d06ac5a056 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/bracket.rs @@ -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 { + 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 { + 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 { + 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 { + 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() +} diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs new file mode 100644 index 0000000000..0d639f9f3f --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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 { + // 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::() { + 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::() { + 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; + } + } +} diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 6becc2175b..3bb5115208 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -8,6 +8,9 @@ //! - [`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 @@ -26,29 +29,22 @@ use std::path::{Path, PathBuf}; -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::bookmark::{BookmarkSet, Mark}; -use crate::key::{Key, Modifiers}; -use crate::terminal::color::Theme; -use crate::terminal::mc_skin; -use crate::terminal::popup::{centered_percent_rect, render_popup}; pub mod bookmark; +pub mod bracket; pub mod buffer; pub mod completion; pub mod cursor; 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; #[cfg(feature = "syntect")] pub mod syntax; @@ -63,6 +59,17 @@ 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 { @@ -330,72 +337,6 @@ impl Editor { r } - /// Alt-B — jump to the matching bracket for the character at - /// or adjacent to the cursor. Supports `()[]{}`. - 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. - 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; - } - } - /// Alt-P — reformat the current paragraph. /// /// Walks the contiguous block of non-blank lines that contains @@ -431,904 +372,6 @@ impl Editor { self.modified = true; self.message = Some("Formatted paragraph".to_string()); } - - /// 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 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 { - // 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::() { - 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::() { - 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 - } - - /// 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 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 = (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 = 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 = 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 = (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)); - } - } - - 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 - } - - 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(|| "".to_string()), - mode_tag, - ) - } - - 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) - } } /// Backwards-compat shim for the old `open_file` API. The new API @@ -1389,187 +432,13 @@ pub fn open_file(file: &str, line: Option) -> anyhow::Result<()> { Ok(()) } -fn is_open_bracket(c: char) -> bool { - matches!(c, '(' | '[' | '{') -} - -fn is_close_bracket(c: char) -> bool { - matches!(c, ')' | ']' | '}') -} - -fn matching_open(c: char) -> Option { - match c { - ')' => Some('('), - ']' => Some('['), - '}' => Some('{'), - _ => None, - } -} - -fn matching_close(c: char) -> Option { - match c { - '(' => Some(')'), - '[' => Some(']'), - '{' => Some('}'), - _ => None, - } -} - -fn find_matching_forward(bytes: &[u8], start: usize, open: char) -> Option { - 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 -} - -fn find_matching_backward(bytes: &[u8], start: usize, close: char) -> Option { - 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 -} - -fn is_completion_word_char(c: char) -> bool { - c == '_' || c.is_alphanumeric() || c.is_alphabetic() -} - -fn push_rendered_text<'a>( - spans: &mut Vec>, - 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")] -fn split_spans_for_selection( - highlighted: Vec>, - rs: usize, - re: usize, - base_bg: Color, - marked_bg: Color, -) -> Vec> { - let mut out: Vec> = 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")] -fn round_up_to_char_boundary(s: &str, idx: usize) -> usize { - if idx >= s.len() { - return s.len(); - } - let mut i = idx; - while i < s.len() && !s.is_char_boundary(i) { - i += 1; - } - i -} - #[cfg(test)] mod tests { use super::*; + use crate::key::{Key, Modifiers}; use ratatui::backend::TestBackend; + use ratatui::style::Style; + use ratatui::text::Span; use ratatui::Terminal; use std::fs; diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs new file mode 100644 index 0000000000..c756f96fc3 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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 = (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 = 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 = 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 = (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(|| "".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>, + 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>, + rs: usize, + re: usize, + base_bg: Color, + marked_bg: Color, +) -> Vec> { + let mut out: Vec> = 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 +}