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:
2026-06-19 20:35:47 +03:00
parent 477b7efc33
commit 3d4fe13001
4 changed files with 1226 additions and 1151 deletions
@@ -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
}