use super::indicator::Indicator;
use super::scrollbar::ScrollBar;
use super::syntax::SyntaxHighlighter;
use super::view::{View, write_line_to_terminal};
use crate::core::clipboard;
use crate::core::draw::DrawBuffer;
use crate::core::event::{
Event, EventType, KB_BACKSPACE, KB_DEL, KB_DOWN, KB_END, KB_ENTER, KB_HOME, KB_LEFT, KB_PGDN,
KB_PGUP, KB_RIGHT, KB_TAB, KB_UP, MB_LEFT_BUTTON,
};
use crate::core::geometry::{Point, Rect};
use crate::core::state::StateFlags;
use crate::terminal::Terminal;
use std::cell::RefCell;
use std::cmp::min;
use std::rc::Rc;
const KB_CTRL_A: u16 = 0x0001; const KB_CTRL_C: u16 = 0x0003; #[expect(dead_code, reason = "Reserved for future find/replace functionality")]
const KB_CTRL_F: u16 = 0x0006; #[expect(dead_code, reason = "Reserved for future find/replace functionality")]
const KB_CTRL_H: u16 = 0x0008; const KB_CTRL_V: u16 = 0x0016; const KB_CTRL_X: u16 = 0x0018; const KB_CTRL_Y: u16 = 0x0019; const KB_CTRL_Z: u16 = 0x001A;
const MAX_UNDO_HISTORY: usize = 100;
#[derive(Clone, Copy, Debug)]
pub struct SearchOptions {
pub case_sensitive: bool,
pub whole_words_only: bool,
pub backwards: bool,
}
impl SearchOptions {
pub fn new() -> Self {
Self {
case_sensitive: false,
whole_words_only: false,
backwards: false,
}
}
}
impl Default for SearchOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
enum EditAction {
InsertChar {
pos: Point,
ch: char,
},
DeleteChar {
pos: Point,
ch: char,
},
InsertText {
pos: Point,
text: String,
},
DeleteText {
pos: Point,
text: String,
},
InsertLine {
line: usize,
text: String,
},
DeleteLine {
line: usize,
text: String,
},
Compound(Vec<EditAction>),
}
impl EditAction {
fn inverse(&self) -> Self {
match self {
EditAction::InsertChar { pos, ch } => EditAction::DeleteChar { pos: *pos, ch: *ch },
EditAction::DeleteChar { pos, ch } => EditAction::InsertChar { pos: *pos, ch: *ch },
EditAction::InsertText { pos, text } => EditAction::DeleteText {
pos: *pos,
text: text.clone(),
},
EditAction::DeleteText { pos, text } => EditAction::InsertText {
pos: *pos,
text: text.clone(),
},
EditAction::InsertLine { line, text } => EditAction::DeleteLine {
line: *line,
text: text.clone(),
},
EditAction::DeleteLine { line, text } => EditAction::InsertLine {
line: *line,
text: text.clone(),
},
EditAction::Compound(actions) => {
EditAction::Compound(actions.iter().rev().map(|a| a.inverse()).collect())
}
}
}
}
pub struct EditorWindow {
bounds: Rect,
lines: Vec<String>,
cursor: Point,
delta: Point,
selection_start: Option<Point>,
state: StateFlags,
v_scrollbar: Option<Rc<RefCell<ScrollBar>>>,
h_scrollbar: Option<Rc<RefCell<ScrollBar>>>,
indicator: Option<Rc<RefCell<Indicator>>>,
read_only: bool,
modified: bool,
tab_size: usize,
undo_stack: Vec<EditAction>,
redo_stack: Vec<EditAction>,
insert_mode: bool, auto_indent: bool,
last_search: String,
last_search_options: SearchOptions,
filename: Option<String>,
trailing_newline: bool,
crlf_line_endings: bool,
backup_files: bool,
highlighter: Option<Box<dyn SyntaxHighlighter>>,
palette_chain: Option<crate::core::palette_chain::PaletteChainNode>,
}
impl EditorWindow {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
lines: vec![String::new()],
cursor: Point::zero(),
delta: Point::zero(),
selection_start: None,
state: 0,
v_scrollbar: None,
h_scrollbar: None,
indicator: None,
read_only: false,
modified: false,
tab_size: 4,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
insert_mode: true,
auto_indent: false,
last_search: String::new(),
last_search_options: SearchOptions::new(),
filename: None,
trailing_newline: true,
crlf_line_endings: false,
backup_files: false,
highlighter: None,
palette_chain: None,
}
}
pub fn with_scrollbars(
bounds: Rect,
h_scrollbar: Option<Rc<RefCell<ScrollBar>>>,
v_scrollbar: Option<Rc<RefCell<ScrollBar>>>,
indicator: Option<Rc<RefCell<Indicator>>>,
) -> Self {
let mut editor = Self::new(bounds);
editor.h_scrollbar = h_scrollbar;
editor.v_scrollbar = v_scrollbar;
editor.indicator = indicator;
editor.update_scrollbars();
editor.update_indicator();
editor
}
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
pub fn set_tab_size(&mut self, tab_size: usize) {
self.tab_size = tab_size.max(1);
}
pub fn set_auto_indent(&mut self, auto_indent: bool) {
self.auto_indent = auto_indent;
}
pub fn set_highlighter(&mut self, highlighter: Box<dyn SyntaxHighlighter>) {
self.highlighter = Some(highlighter);
}
pub fn clear_highlighter(&mut self) {
self.highlighter = None;
}
pub fn has_highlighter(&self) -> bool {
self.highlighter.is_some()
}
pub fn toggle_insert_mode(&mut self) {
self.insert_mode = !self.insert_mode;
self.update_indicator();
}
pub fn get_text(&self) -> String {
self.lines.join("\n")
}
pub fn set_text(&mut self, text: &str) {
self.lines = text.lines().map(|s| s.to_string()).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.cursor = Point::zero();
self.delta = Point::zero();
self.selection_start = None;
self.modified = false;
self.undo_stack.clear();
self.redo_stack.clear();
self.update_scrollbars();
self.update_indicator();
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn clear_modified(&mut self) {
self.modified = false;
self.update_indicator();
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn get_delta(&self) -> Point {
self.delta
}
pub fn cursor(&self) -> Point {
self.cursor
}
pub fn scroll_to_line(&mut self, line: usize) {
self.cursor.y = line as i16;
self.cursor.x = 0;
self.clamp_cursor();
self.ensure_cursor_visible();
}
pub fn max_line_width(&self) -> usize {
self.lines.iter().map(|line| line.len()).max().unwrap_or(0)
}
pub fn needs_vertical_scrollbar(&self) -> bool {
let visible_height = self.bounds.height_clamped() as usize;
self.line_count() > visible_height
}
pub fn needs_horizontal_scrollbar(&self) -> bool {
let visible_width = self.bounds.width_clamped() as usize;
self.max_line_width() > visible_width
}
pub fn load_file(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let path_ref = path.as_ref();
let content = std::fs::read_to_string(path_ref)?;
self.trailing_newline = content.is_empty() || content.ends_with('\n');
self.crlf_line_endings = content.contains("\r\n");
self.set_text(&content);
self.filename = Some(path_ref.to_string_lossy().to_string());
self.modified = false;
self.undo_stack.clear();
self.redo_stack.clear();
self.update_indicator();
Ok(())
}
pub fn save_file(&mut self) -> std::io::Result<()> {
if let Some(path) = self.filename.clone() {
self.save_as(&path)
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"No filename set - use save_as() first",
))
}
}
pub fn save_as(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let path_ref = path.as_ref();
if self.backup_files && path_ref.exists() {
let mut backup = path_ref.as_os_str().to_owned();
backup.push(".bak");
let _ = std::fs::rename(path_ref, std::path::Path::new(&backup));
}
let newline = if self.crlf_line_endings { "\r\n" } else { "\n" };
let mut content = self.lines.join(newline);
if self.trailing_newline && !self.lines.is_empty() {
content.push_str(newline);
}
std::fs::write(path_ref, content)?;
self.filename = Some(path_ref.to_string_lossy().to_string());
self.modified = false;
self.update_indicator();
Ok(())
}
pub fn set_backup_files(&mut self, backup: bool) {
self.backup_files = backup;
}
pub fn get_filename(&self) -> Option<&str> {
self.filename.as_deref()
}
pub fn undo(&mut self) {
if let Some(action) = self.undo_stack.pop() {
self.apply_action_inverse(&action);
self.redo_stack.push(action);
}
}
pub fn redo(&mut self) {
if let Some(action) = self.redo_stack.pop() {
self.apply_action(&action);
self.undo_stack.push(action);
}
}
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
pub fn find(&mut self, text: &str, options: SearchOptions) -> Option<Point> {
if text.is_empty() {
return None;
}
self.last_search = text.to_string();
self.last_search_options = options;
self.find_from_cursor(text, options)
}
pub fn find_next(&mut self) -> Option<Point> {
if self.last_search.is_empty() {
return None;
}
if self.selection_start.is_some() {
self.selection_start = None;
}
self.find_from_cursor(&self.last_search.clone(), self.last_search_options)
}
fn find_from_cursor(&mut self, text: &str, options: SearchOptions) -> Option<Point> {
let case_sensitive = options.case_sensitive;
let fold = move |ch: char| {
if case_sensitive {
ch
} else {
ch.to_lowercase().next().unwrap_or(ch)
}
};
let needle: Vec<char> = text.chars().map(fold).collect();
if needle.is_empty() {
return None;
}
let is_word_char = |ch: char| ch.is_alphanumeric() || ch == '_';
let start_line = self.cursor.y as usize;
let start_col = self.cursor.x as usize;
for (line_idx, line) in self.lines.iter().enumerate().skip(start_line) {
let chars: Vec<char> = line.chars().collect();
let from = if line_idx == start_line { start_col } else { 0 };
let mut col = from;
while col + needle.len() <= chars.len() {
let matched = chars[col..col + needle.len()]
.iter()
.map(|&c| fold(c))
.eq(needle.iter().copied());
if matched {
let after = col + needle.len();
let whole_ok = !options.whole_words_only
|| ((col == 0 || !is_word_char(chars[col - 1]))
&& (after >= chars.len() || !is_word_char(chars[after])));
if whole_ok {
let pos = Point::new(col as i16, line_idx as i16);
self.selection_start = Some(pos);
self.cursor = Point::new(after as i16, line_idx as i16);
self.make_cursor_visible();
return Some(pos);
}
}
col += 1;
}
}
None
}
pub fn replace_selection(&mut self, replace_text: &str) -> bool {
if self.selection_start.is_some() {
self.delete_selection();
self.insert_text(replace_text);
true
} else {
false
}
}
pub fn replace_next(
&mut self,
find_text: &str,
replace_text: &str,
options: SearchOptions,
) -> bool {
if let Some(_pos) = self.find(find_text, options) {
self.delete_selection();
self.insert_text(replace_text);
true
} else {
false
}
}
pub fn replace_all(
&mut self,
find_text: &str,
replace_text: &str,
options: SearchOptions,
) -> usize {
let mut count = 0;
self.cursor = Point::zero();
self.selection_start = None;
self.last_search = find_text.to_string();
self.last_search_options = options;
loop {
if let Some(_pos) = self.find_from_cursor(find_text, options) {
self.delete_selection();
self.insert_text(replace_text);
count += 1;
} else {
break;
}
}
count
}
fn get_content_area(&self) -> Rect {
self.bounds
}
fn mouse_pos_to_cursor(&self, mouse_pos: Point) -> Point {
let content_area = self.get_content_area();
let mut relative_x = mouse_pos.x - content_area.a.x;
let mut relative_y = mouse_pos.y - content_area.a.y;
relative_x = relative_x.max(0).min(content_area.width() - 1);
relative_y = relative_y.max(0).min(content_area.height() - 1);
let doc_y = (relative_y + self.delta.y) as usize;
let doc_x = (relative_x + self.delta.x) as usize;
let line_idx = doc_y.min(self.lines.len().saturating_sub(1));
let line_char_len = self.lines[line_idx].chars().count();
let col = doc_x.min(line_char_len);
Point::new(col as i16, line_idx as i16)
}
fn set_cursor_with_selection(&mut self, pos: Point, extend_selection: bool) {
if !extend_selection {
self.selection_start = None;
self.cursor = pos;
} else {
if self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
self.cursor = pos;
}
self.clamp_cursor();
self.ensure_cursor_visible();
}
fn max_line_length(&self) -> i16 {
self.lines
.iter()
.map(|line| line.chars().count() as i16)
.max()
.unwrap_or(0)
}
fn update_scrollbars(&mut self) {
let max_x = self.max_line_length();
let max_y = self.lines.len() as i16;
if let Some(ref h_bar) = self.h_scrollbar {
h_bar
.borrow_mut()
.set_params(self.cursor.x as i32, 0, (max_x - 1).max(0) as i32, 1, 1);
h_bar.borrow_mut().set_total(max_x as i32);
}
if let Some(ref v_bar) = self.v_scrollbar {
v_bar
.borrow_mut()
.set_params(self.cursor.y as i32, 0, (max_y - 1).max(0) as i32, 1, 1);
v_bar.borrow_mut().set_total(max_y as i32);
}
}
pub fn sync_from_scrollbars(&mut self) {
if let Some(ref h_bar) = self.h_scrollbar {
self.cursor.x = h_bar.borrow().get_value() as i16;
}
if let Some(ref v_bar) = self.v_scrollbar {
self.cursor.y = v_bar.borrow().get_value() as i16;
}
self.clamp_cursor();
self.ensure_cursor_visible();
}
fn update_indicator(&mut self) {
if let Some(ref indicator) = self.indicator {
indicator.borrow_mut().set_value(
Point::new(self.cursor.x + 1, self.cursor.y + 1),
self.modified,
);
}
}
fn make_cursor_visible(&mut self) {
self.ensure_cursor_visible();
}
fn ensure_cursor_visible(&mut self) {
let content_area = self.get_content_area();
let width = content_area.width();
let height = content_area.height();
if self.cursor.y < self.delta.y {
self.delta.y = self.cursor.y;
} else if self.cursor.y >= self.delta.y + height {
self.delta.y = self.cursor.y - height + 1;
}
if self.cursor.x < self.delta.x {
self.delta.x = self.cursor.x;
} else if self.cursor.x >= self.delta.x + width {
self.delta.x = self.cursor.x - width + 1;
}
self.delta.x = self.delta.x.max(0);
self.delta.y = self.delta.y.max(0);
self.update_scrollbars();
self.update_indicator();
}
fn clamp_cursor(&mut self) {
if self.cursor.y < 0 {
self.cursor.y = 0;
}
if self.cursor.y >= self.lines.len() as i16 {
self.cursor.y = (self.lines.len() - 1) as i16;
}
let line_char_len = self.lines[self.cursor.y as usize].chars().count() as i16;
if self.cursor.x > line_char_len {
self.cursor.x = line_char_len;
}
if self.cursor.x < 0 {
self.cursor.x = 0;
}
}
fn char_to_byte_idx(&self, line_idx: usize, char_idx: usize) -> usize {
self.lines[line_idx]
.char_indices()
.nth(char_idx)
.map(|(byte_idx, _)| byte_idx)
.unwrap_or_else(|| self.lines[line_idx].len())
}
fn push_undo(&mut self, action: EditAction) {
self.undo_stack.push(action);
if self.undo_stack.len() > MAX_UNDO_HISTORY {
self.undo_stack.remove(0);
}
self.redo_stack.clear();
self.modified = true;
self.update_indicator();
}
fn apply_action(&mut self, action: &EditAction) {
match action {
EditAction::InsertChar { pos, ch } => {
self.cursor = *pos;
let line_idx = pos.y as usize;
let col = pos.x as usize;
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].insert(byte_idx, *ch);
self.cursor.x += 1;
}
EditAction::DeleteChar { pos, .. } => {
self.cursor = *pos;
let line_idx = pos.y as usize;
let col = pos.x as usize;
let line_char_len = self.lines[line_idx].chars().count();
if col < line_char_len {
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].remove(byte_idx);
}
}
EditAction::InsertText { pos, text } => {
self.cursor = *pos;
self.insert_text_internal(text);
}
EditAction::DeleteText { pos, text } => {
self.selection_start = Some(*pos);
let mut end = *pos;
for ch in text.chars() {
if ch == '\n' {
end.y += 1;
end.x = 0;
} else {
end.x += 1;
}
}
self.cursor = end;
self.delete_selection_internal();
}
EditAction::Compound(actions) => {
for inner in actions {
self.apply_action(inner);
}
}
_ => {}
}
self.ensure_cursor_visible();
}
fn apply_action_inverse(&mut self, action: &EditAction) {
let inverse = action.inverse();
self.apply_action(&inverse);
}
fn insert_char(&mut self, ch: char) {
if self.read_only {
return;
}
let line_idx = self.cursor.y as usize;
let col = self.cursor.x as usize;
if self.insert_mode {
let action = EditAction::InsertChar {
pos: self.cursor,
ch,
};
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].insert(byte_idx, ch);
self.cursor.x += 1;
self.push_undo(action);
} else {
let mut steps = Vec::with_capacity(2);
let line_char_len = self.lines[line_idx].chars().count();
if col < line_char_len {
let old_ch = self.lines[line_idx].chars().nth(col).unwrap();
steps.push(EditAction::DeleteChar {
pos: self.cursor,
ch: old_ch,
});
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].remove(byte_idx);
}
steps.push(EditAction::InsertChar {
pos: self.cursor,
ch,
});
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].insert(byte_idx, ch);
self.cursor.x += 1;
self.push_undo(EditAction::Compound(steps));
}
self.selection_start = None;
self.ensure_cursor_visible();
}
fn insert_newline(&mut self) {
if self.read_only {
return;
}
let line_idx = self.cursor.y as usize;
let col_char = self.cursor.x as usize;
let col_byte = self.char_to_byte_idx(line_idx, col_char);
let current_line = &self.lines[line_idx];
let before = current_line[..col_byte].to_string();
let after = current_line[col_byte..].to_string();
let indent = if self.auto_indent {
current_line
.chars()
.take_while(|&c| c == ' ' || c == '\t')
.collect::<String>()
} else {
String::new()
};
let action = EditAction::InsertText {
pos: self.cursor,
text: format!("\n{indent}"),
};
self.lines[line_idx] = before;
self.lines.insert(line_idx + 1, indent.clone() + &after);
self.cursor.y += 1;
self.cursor.x = indent.chars().count() as i16;
self.push_undo(action);
self.selection_start = None;
self.ensure_cursor_visible();
self.update_indicator();
}
fn delete_char(&mut self) {
if self.read_only {
return;
}
let line_idx = self.cursor.y as usize;
if line_idx >= self.lines.len() {
return; }
let col = self.cursor.x as usize;
let line_char_len = self.lines[line_idx].chars().count();
if col < line_char_len {
let ch = self.lines[line_idx].chars().nth(col).unwrap();
let action = EditAction::DeleteChar {
pos: self.cursor,
ch,
};
let byte_idx = self.char_to_byte_idx(line_idx, col);
self.lines[line_idx].remove(byte_idx);
self.push_undo(action);
} else if line_idx + 1 < self.lines.len() {
let next_line = self.lines.remove(line_idx + 1);
self.lines[line_idx].push_str(&next_line);
self.push_undo(EditAction::DeleteText {
pos: self.cursor,
text: "\n".to_string(),
});
}
self.selection_start = None;
self.ensure_cursor_visible();
}
fn backspace(&mut self) {
if self.read_only {
return;
}
let line_idx = self.cursor.y as usize;
if line_idx >= self.lines.len() {
return; }
let col = self.cursor.x as usize;
if col > 0 {
let ch = self.lines[line_idx].chars().nth(col - 1).unwrap();
self.cursor.x -= 1;
let action = EditAction::DeleteChar {
pos: self.cursor,
ch,
};
let byte_idx = self.char_to_byte_idx(line_idx, col - 1);
self.lines[line_idx].remove(byte_idx);
self.push_undo(action);
} else if line_idx > 0 {
let current_line = self.lines.remove(line_idx);
self.cursor.y -= 1;
let prev_line_char_len = self.lines[line_idx - 1].chars().count();
self.lines[line_idx - 1].push_str(¤t_line);
self.cursor.x = prev_line_char_len as i16;
self.push_undo(EditAction::DeleteText {
pos: self.cursor,
text: "\n".to_string(),
});
}
self.selection_start = None;
self.ensure_cursor_visible();
}
fn insert_tab(&mut self) {
if self.read_only {
return;
}
for _ in 0..self.tab_size {
self.insert_char(' ');
}
}
fn word_left_pos(&self) -> Point {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let (mut x, mut y) = (self.cursor.x as i32, self.cursor.y as i32);
let char_at = |lines: &Vec<String>, y: i32, x: i32| -> Option<char> {
lines[y as usize].chars().nth(x as usize)
};
let step_left = |x: &mut i32, y: &mut i32| -> bool {
if *x > 0 {
*x -= 1;
true
} else if *y > 0 {
*y -= 1;
*x = self.lines[*y as usize].chars().count() as i32;
true
} else {
false
}
};
loop {
if !step_left(&mut x, &mut y) {
return Point::new(x as i16, y as i16);
}
if char_at(&self.lines, y, x).is_some_and(is_word) {
break;
}
if x == 0 && y == 0 {
return Point::new(0, 0);
}
}
while x > 0 {
if char_at(&self.lines, y, x - 1).is_some_and(is_word) {
x -= 1;
} else {
break;
}
}
Point::new(x as i16, y as i16)
}
fn word_right_pos(&self) -> Point {
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let (mut x, mut y) = (self.cursor.x as i32, self.cursor.y as i32);
let last_line = self.lines.len() as i32 - 1;
let line_len = |lines: &Vec<String>, y: i32| lines[y as usize].chars().count() as i32;
let char_at = |lines: &Vec<String>, y: i32, x: i32| -> Option<char> {
lines[y as usize].chars().nth(x as usize)
};
while x < line_len(&self.lines, y) && char_at(&self.lines, y, x).is_some_and(is_word) {
x += 1;
}
loop {
if x >= line_len(&self.lines, y) {
if y >= last_line {
return Point::new(line_len(&self.lines, y) as i16, y as i16);
}
y += 1;
x = 0;
} else if char_at(&self.lines, y, x).is_some_and(is_word) {
return Point::new(x as i16, y as i16);
} else {
x += 1;
}
}
}
fn move_word(&mut self, right: bool, extend_selection: bool) {
if extend_selection {
if self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
} else {
self.selection_start = None;
}
self.cursor = if right {
self.word_right_pos()
} else {
self.word_left_pos()
};
self.ensure_cursor_visible();
}
fn text_between(&self, from: Point, to: Point) -> String {
let mut out = String::new();
let (mut x, mut y) = (from.x as usize, from.y as usize);
while (y as i16, x as i16) < (to.y, to.x) {
let line = &self.lines[y];
if x >= line.chars().count() {
out.push('\n');
y += 1;
x = 0;
} else {
out.push(line.chars().nth(x).expect("in range"));
x += 1;
}
}
out
}
fn delete_word(&mut self, right: bool) {
if self.read_only {
return;
}
let (from, to) = if right {
(self.cursor, self.word_right_pos())
} else {
(self.word_left_pos(), self.cursor)
};
if from == to {
return;
}
let text = self.text_between(from, to);
self.selection_start = Some(from);
self.cursor = to;
self.delete_selection_internal();
self.cursor = from;
self.selection_start = None;
self.push_undo(EditAction::DeleteText { pos: from, text });
self.ensure_cursor_visible();
}
fn move_cursor(&mut self, dx: i16, dy: i16, extend_selection: bool) {
if !extend_selection {
self.selection_start = None;
} else if self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
self.cursor.x += dx;
self.cursor.y += dy;
self.clamp_cursor();
self.ensure_cursor_visible();
}
fn move_cursor_left(&mut self, extend_selection: bool) {
if !extend_selection {
self.selection_start = None;
} else if self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
if self.cursor.x > 0 {
self.cursor.x -= 1;
} else if self.cursor.y > 0 {
self.cursor.y -= 1;
let line_char_len = self.lines[self.cursor.y as usize].chars().count() as i16;
self.cursor.x = line_char_len;
}
self.ensure_cursor_visible();
}
fn move_cursor_right(&mut self, extend_selection: bool) {
if !extend_selection {
self.selection_start = None;
} else if self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
}
let line_char_len = self.lines[self.cursor.y as usize].chars().count() as i16;
if self.cursor.x < line_char_len {
self.cursor.x += 1;
} else if self.cursor.y < (self.lines.len() - 1) as i16 {
self.cursor.y += 1;
self.cursor.x = 0;
}
self.ensure_cursor_visible();
}
pub fn has_selection(&self) -> bool {
self.selection_start.is_some()
}
fn is_position_selected(&self, line: i16, col: i16) -> bool {
if let Some(start) = self.selection_start {
let end = self.cursor;
let (start, end) = if start.y < end.y || (start.y == end.y && start.x < end.x) {
(start, end)
} else {
(end, start)
};
if line < start.y || line > end.y {
return false;
}
if line == start.y && line == end.y {
return col >= start.x && col < end.x;
} else if line == start.y {
return col >= start.x;
} else if line == end.y {
return col < end.x;
} else {
return true;
}
}
false
}
fn get_selection(&self) -> Option<String> {
let start = self.selection_start?;
let end = self.cursor;
let (start, end) = if start.y < end.y || (start.y == end.y && start.x < end.x) {
(start, end)
} else {
(end, start)
};
if start == end {
return None;
}
let mut result = String::new();
for y in start.y..=end.y {
if y < 0 || y >= self.lines.len() as i16 {
continue;
}
let line_idx = y as usize;
let line = &self.lines[line_idx];
let line_char_len = line.chars().count();
if y == start.y && y == end.y {
let s_char = start.x.max(0) as usize;
let e_char = (end.x as usize).min(line_char_len);
if s_char < e_char {
let s_byte = self.char_to_byte_idx(line_idx, s_char);
let e_byte = self.char_to_byte_idx(line_idx, e_char);
result.push_str(&line[s_byte..e_byte]);
}
} else if y == start.y {
let s_char = start.x.max(0) as usize;
let s_byte = self.char_to_byte_idx(line_idx, s_char);
result.push_str(&line[s_byte..]);
result.push('\n');
} else if y == end.y {
let e_char = (end.x as usize).min(line_char_len);
let e_byte = self.char_to_byte_idx(line_idx, e_char);
result.push_str(&line[..e_byte]);
} else {
result.push_str(line);
result.push('\n');
}
}
Some(result)
}
pub fn select_all(&mut self) {
self.selection_start = Some(Point::zero());
self.cursor = Point::new(
self.lines.last().map(|l| l.chars().count()).unwrap_or(0) as i16,
(self.lines.len() - 1) as i16,
);
self.ensure_cursor_visible();
}
fn delete_selection_internal(&mut self) {
if !self.has_selection() || self.read_only {
return;
}
let start = self.selection_start.unwrap();
let end = self.cursor;
let (start, end) = if start.y < end.y || (start.y == end.y && start.x < end.x) {
(start, end)
} else {
(end, start)
};
let start_line = start.y.max(0) as usize;
let end_line = end.y.min((self.lines.len() - 1) as i16) as usize;
if start_line == end_line {
let start_col_char = start.x.max(0) as usize;
let end_col_char = (end.x as usize).min(self.lines[start_line].chars().count());
if start_col_char < end_col_char {
let start_col_byte = self.char_to_byte_idx(start_line, start_col_char);
let end_col_byte = self.char_to_byte_idx(start_line, end_col_char);
self.lines[start_line].drain(start_col_byte..end_col_byte);
}
} else {
let start_col_char = start.x.max(0) as usize;
let end_col_char = (end.x as usize).min(self.lines[end_line].chars().count());
let start_col_byte = self.char_to_byte_idx(start_line, start_col_char);
let end_col_byte = self.char_to_byte_idx(end_line, end_col_char);
let before = self.lines[start_line][..start_col_byte].to_string();
let after = self.lines[end_line][end_col_byte..].to_string();
self.lines.drain(start_line..=end_line);
self.lines.insert(start_line, before + &after);
}
self.cursor = start;
self.selection_start = None;
self.modified = true;
self.ensure_cursor_visible();
}
pub fn delete_selection(&mut self) {
if !self.has_selection() {
return;
}
if let Some(text) = self.get_selection() {
let action = EditAction::DeleteText {
pos: self.selection_start.unwrap(),
text,
};
self.delete_selection_internal();
self.push_undo(action);
}
}
pub fn clip_copy(&mut self) -> bool {
if let Some(text) = self.get_selection() {
clipboard::set_clipboard(&text);
true
} else {
false
}
}
pub fn clip_cut(&mut self) -> bool {
if self.read_only || !self.has_selection() {
return false;
}
if let Some(text) = self.get_selection() {
clipboard::set_clipboard(&text);
self.delete_selection();
true
} else {
false
}
}
pub fn clip_paste(&mut self) -> bool {
if self.read_only {
return false;
}
let text = clipboard::get_clipboard();
if text.is_empty() {
return false;
}
let mut actions: Vec<EditAction> = Vec::new();
if self.has_selection() {
if let Some(selected) = self.get_selection() {
let start = self.selection_start.unwrap();
let end = self.cursor;
let pos = if start.y < end.y || (start.y == end.y && start.x < end.x) {
start
} else {
end
};
actions.push(EditAction::DeleteText {
pos,
text: selected,
});
self.delete_selection_internal();
}
}
let insert_pos = self.cursor;
actions.push(EditAction::InsertText {
pos: insert_pos,
text: text.clone(),
});
self.insert_text_internal(&text);
let entry = if actions.len() == 1 {
actions.into_iter().next().unwrap()
} else {
EditAction::Compound(actions)
};
self.push_undo(entry);
true
}
fn insert_text_internal(&mut self, text: &str) {
if self.read_only {
return;
}
let lines_to_insert: Vec<&str> = text.split('\n').collect();
if lines_to_insert.is_empty() {
return;
}
let line_idx = self.cursor.y as usize;
let col_char = self.cursor.x as usize;
let col_byte = self.char_to_byte_idx(line_idx, col_char);
if lines_to_insert.len() == 1 {
self.lines[line_idx].insert_str(col_byte, lines_to_insert[0]);
self.cursor.x += lines_to_insert[0].chars().count() as i16;
} else {
let current_line = &self.lines[line_idx];
let before = current_line[..col_byte].to_string();
let after = current_line[col_byte..].to_string();
self.lines[line_idx] = before + lines_to_insert[0];
for (i, line) in lines_to_insert.iter().enumerate().skip(1) {
self.lines.insert(line_idx + i, line.to_string());
}
let last_line_idx = line_idx + lines_to_insert.len() - 1;
let last_inserted = lines_to_insert.last().unwrap();
self.lines[last_line_idx].push_str(&after);
self.cursor.y = last_line_idx as i16;
self.cursor.x = last_inserted.chars().count() as i16;
}
self.modified = true;
self.selection_start = None;
self.ensure_cursor_visible();
}
fn insert_text(&mut self, text: &str) {
if self.has_selection() {
self.delete_selection();
}
let action = EditAction::InsertText {
pos: self.cursor,
text: text.to_string(),
};
self.insert_text_internal(text);
self.push_undo(action);
}
}
impl View for EditorWindow {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
self.update_scrollbars();
}
fn draw(&mut self, terminal: &mut Terminal) {
use crate::core::palette::{EDITOR_CURSOR, EDITOR_NORMAL, EDITOR_SELECTED};
let content_area = self.get_content_area();
let width = content_area.width_clamped() as usize;
let height = content_area.height_clamped() as usize;
let default_color = self.map_color(EDITOR_NORMAL);
let selected_color = self.map_color(EDITOR_SELECTED);
let cursor_color = self.map_color(EDITOR_CURSOR);
for y in 0..height {
let line_idx = (self.delta.y + y as i16) as usize;
let mut buf = DrawBuffer::new(width);
buf.move_char(0, ' ', default_color, width);
if line_idx < self.lines.len() {
let line = &self.lines[line_idx];
let start_col = self.delta.x as usize;
let line_char_count = line.chars().count();
if start_col < line_char_count {
let end_col_char = min(start_col + width, line_char_count);
let visible_text: String = line
.chars()
.skip(start_col)
.take(end_col_char - start_col)
.collect();
if let Some(ref highlighter) = self.highlighter {
let tokens = highlighter.highlight_line(line, line_idx);
let mut current_col = 0;
for token in tokens {
if token.end <= start_col {
continue;
}
if token.start >= end_col_char {
break;
}
let token_start = token.start.max(start_col) - start_col;
let token_end = token.end.min(end_col_char) - start_col;
if current_col < token_start {
}
let token_text: String = line
.chars()
.skip(start_col + token_start)
.take(token_end - token_start)
.collect();
if !token_text.is_empty() {
buf.move_str(
token_start,
&token_text,
self.map_color(token.token_type.palette_index()),
);
}
current_col = token_end;
}
} else {
buf.move_str(0, &visible_text, default_color);
}
}
}
if self.has_selection() {
let line_y = (self.delta.y + y as i16) as i16;
let start_col = self.delta.x;
for x in 0..width {
let col = (start_col + x as i16) as i16;
if self.is_position_selected(line_y, col) {
if x < buf.data.len() {
buf.data[x].attr = selected_color;
}
}
}
}
write_line_to_terminal(
terminal,
content_area.a.x,
content_area.a.y + y as i16,
&buf,
);
}
if self.is_focused() {
let cursor_screen_x = content_area.a.x + (self.cursor.x - self.delta.x);
let cursor_screen_y = content_area.a.y + (self.cursor.y - self.delta.y);
if cursor_screen_x >= content_area.a.x
&& cursor_screen_x < content_area.b.x
&& cursor_screen_y >= content_area.a.y
&& cursor_screen_y < content_area.b.y
{
let line_idx = self.cursor.y as usize;
let col = self.cursor.x as usize;
let ch = if line_idx < self.lines.len() {
self.lines[line_idx].chars().nth(col).unwrap_or(' ')
} else {
' '
};
let cursor_attr = cursor_color;
terminal.write_cell(
cursor_screen_x as u16,
cursor_screen_y as u16,
crate::core::draw::Cell::new(ch, cursor_attr),
);
}
}
}
fn handle_event(&mut self, event: &mut Event) {
if event.what == EventType::MouseDown {
if !self.is_focused() {
return;
}
let mouse_pos = event.mouse.pos;
let content_area = self.get_content_area();
if !content_area.contains(mouse_pos) {
return;
}
let cursor_pos = self.mouse_pos_to_cursor(mouse_pos);
let extend_selection = false;
self.set_cursor_with_selection(cursor_pos, extend_selection);
event.clear();
return;
}
if event.what == EventType::MouseMove {
if !self.is_focused() || (event.mouse.buttons & MB_LEFT_BUTTON == 0) {
return;
}
let mouse_pos = event.mouse.pos;
let content_area = self.get_content_area();
let mut scroll_delta = self.delta;
let mut needs_scroll = false;
if mouse_pos.x < content_area.a.x {
scroll_delta.x = scroll_delta.x.saturating_sub(1);
needs_scroll = true;
} else if mouse_pos.x >= content_area.b.x {
scroll_delta.x += 1;
needs_scroll = true;
}
if mouse_pos.y < content_area.a.y {
scroll_delta.y = scroll_delta.y.saturating_sub(1);
needs_scroll = true;
} else if mouse_pos.y >= content_area.b.y {
scroll_delta.y += 1;
needs_scroll = true;
}
if needs_scroll {
let max_x = self.max_line_length().saturating_sub(content_area.width());
let max_y = (self.lines.len() as i16).saturating_sub(content_area.height());
scroll_delta.x = scroll_delta.x.max(0).min(max_x);
scroll_delta.y = scroll_delta.y.max(0).min(max_y);
self.delta = scroll_delta;
self.update_scrollbars();
}
let cursor_pos = self.mouse_pos_to_cursor(mouse_pos);
self.set_cursor_with_selection(cursor_pos, true);
event.clear();
return;
}
if event.what == EventType::MouseWheelUp {
let content_area = self.get_content_area();
if content_area.contains(event.mouse.pos) {
self.delta.y = (self.delta.y - 3).max(0);
self.update_scrollbars();
event.clear();
return;
}
}
if event.what == EventType::MouseWheelDown {
let content_area = self.get_content_area();
if content_area.contains(event.mouse.pos) {
let max_y = (self.lines.len() as i16).saturating_sub(content_area.height());
self.delta.y = (self.delta.y + 3).min(max_y).max(0);
self.update_scrollbars();
event.clear();
return;
}
}
if event.what == EventType::Keyboard {
if !self.is_focused() {
return;
}
use crossterm::event::KeyModifiers;
let shift_pressed = event.key_modifiers.contains(KeyModifiers::SHIFT);
match event.key_code {
KB_UP => {
self.move_cursor(0, -1, shift_pressed);
event.clear();
}
KB_DOWN => {
self.move_cursor(0, 1, shift_pressed);
event.clear();
}
KB_LEFT => {
if event.key_modifiers.contains(KeyModifiers::CONTROL) {
self.move_word(false, shift_pressed);
} else {
self.move_cursor_left(shift_pressed);
}
event.clear();
}
KB_RIGHT => {
if event.key_modifiers.contains(KeyModifiers::CONTROL) {
self.move_word(true, shift_pressed);
} else {
self.move_cursor_right(shift_pressed);
}
event.clear();
}
crate::core::event::KB_INS => {
self.toggle_insert_mode();
event.clear();
}
KB_HOME => {
if shift_pressed && self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
} else if !shift_pressed {
self.selection_start = None;
}
self.cursor.x = 0;
self.ensure_cursor_visible();
event.clear();
}
KB_END => {
if shift_pressed && self.selection_start.is_none() {
self.selection_start = Some(self.cursor);
} else if !shift_pressed {
self.selection_start = None;
}
let line_idx = self.cursor.y as usize;
if line_idx < self.lines.len() {
let line_char_len = self.lines[line_idx].chars().count() as i16;
self.cursor.x = line_char_len;
}
self.ensure_cursor_visible();
event.clear();
}
KB_PGUP => {
let height = self.get_content_area().height();
self.move_cursor(0, -height, shift_pressed);
event.clear();
}
KB_PGDN => {
let height = self.get_content_area().height();
self.move_cursor(0, height, shift_pressed);
event.clear();
}
KB_ENTER => {
self.insert_newline();
event.clear();
}
KB_BACKSPACE => {
if event.key_modifiers.contains(KeyModifiers::CONTROL) {
self.delete_word(false);
} else if self.has_selection() {
self.delete_selection();
} else {
self.backspace();
}
event.clear();
}
KB_DEL => {
if event.key_modifiers.contains(KeyModifiers::CONTROL) {
self.delete_word(true);
} else if self.has_selection() {
self.delete_selection();
} else {
self.delete_char();
}
event.clear();
}
KB_TAB => {
self.insert_tab();
event.clear();
}
KB_CTRL_A => {
self.select_all();
event.clear();
}
KB_CTRL_C => {
self.clip_copy();
event.clear();
}
KB_CTRL_X => {
self.clip_cut();
event.clear();
}
KB_CTRL_V => {
self.clip_paste();
event.clear();
}
KB_CTRL_Z => {
self.undo();
event.clear();
}
KB_CTRL_Y => {
self.redo();
event.clear();
}
key_code => {
let is_special = key_code > 0xFF && (key_code & 0xFF) == 0;
if !is_special {
if let Some(ch) = char::from_u32(key_code as u32) {
if !ch.is_control() {
self.insert_char(ch);
event.clear();
}
}
}
}
}
}
}
fn can_focus(&self) -> bool {
true
}
fn state(&self) -> StateFlags {
self.state
}
fn set_state(&mut self, state: StateFlags) {
self.state = state;
}
fn update_cursor(&self, terminal: &mut Terminal) {
if self.is_focused() {
let content_area = self.get_content_area();
let cursor_x = content_area.a.x + (self.cursor.x - self.delta.x);
let cursor_y = content_area.a.y + (self.cursor.y - self.delta.y);
let _ = terminal.show_cursor(cursor_x as u16, cursor_y as u16);
}
}
fn set_palette_chain(&mut self, node: Option<crate::core::palette_chain::PaletteChainNode>) {
self.palette_chain = node;
}
fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
self.palette_chain.as_ref()
}
fn get_palette(&self) -> Option<crate::core::palette::Palette> {
use crate::core::palette::{Palette, palettes};
Some(Palette::from_slice(palettes::CP_EDITOR))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_editor_load_file() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "Line 1").unwrap();
writeln!(file, "Line 2").unwrap();
writeln!(file, "Line 3").unwrap();
file.flush().unwrap();
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.load_file(file.path().to_str().unwrap()).unwrap();
assert_eq!(editor.line_count(), 3);
assert_eq!(editor.get_text(), "Line 1\nLine 2\nLine 3");
assert_eq!(editor.get_filename(), file.path().to_str());
assert!(!editor.is_modified());
}
#[test]
fn test_editor_save_as() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.set_text("Hello\nWorld");
let file = NamedTempFile::new().unwrap();
let path = file.path().to_str().unwrap();
editor.save_as(path).unwrap();
let content = std::fs::read_to_string(path).unwrap();
assert_eq!(content, "Hello\nWorld\n");
assert_eq!(editor.get_filename(), Some(path));
assert!(!editor.is_modified());
}
#[test]
fn test_editor_save_file() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
assert!(editor.save_file().is_err());
let file = NamedTempFile::new().unwrap();
let path = file.path().to_str().unwrap();
editor.set_text("Test content");
editor.save_as(path).unwrap();
assert!(!editor.is_modified());
editor.set_text("Modified content");
editor.save_file().unwrap();
let content = std::fs::read_to_string(path).unwrap();
assert_eq!(content, "Modified content\n");
assert!(!editor.is_modified());
}
#[test]
fn test_editor_modified_flag() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
assert!(!editor.is_modified());
editor.set_text("Some text");
assert!(!editor.is_modified());
let file = NamedTempFile::new().unwrap();
editor.save_as(file.path().to_str().unwrap()).unwrap();
assert!(!editor.is_modified());
}
#[test]
fn paste_over_selection_undoes_in_one_step() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.set_text("hello world");
editor.cursor = Point::new(6, 0);
editor.selection_start = Some(Point::new(6, 0));
editor.cursor.x = 11;
clipboard::set_clipboard("RUST");
assert!(editor.clip_paste());
assert_eq!(editor.get_text(), "hello RUST");
editor.undo();
assert_eq!(editor.get_text(), "hello world");
editor.redo();
assert_eq!(editor.get_text(), "hello RUST");
}
#[test]
fn paste_without_selection_is_single_undo_entry() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.set_text("abc");
editor.cursor = Point::new(3, 0);
clipboard::set_clipboard("XYZ");
assert!(editor.clip_paste());
assert_eq!(editor.get_text(), "abcXYZ");
editor.undo();
assert_eq!(editor.get_text(), "abc");
}
#[test]
fn two_stacks_preserve_undo_history_through_redo_cycle() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.set_text("");
assert!(!editor.can_undo());
assert!(!editor.can_redo());
editor.cursor = Point::new(0, 0);
editor.insert_char('A');
editor.insert_char('B');
assert_eq!(editor.get_text(), "AB");
assert!(editor.can_undo());
assert!(!editor.can_redo());
editor.undo();
assert_eq!(editor.get_text(), "A");
assert!(editor.can_undo());
assert!(editor.can_redo());
editor.undo();
assert_eq!(editor.get_text(), "");
assert!(!editor.can_undo());
assert!(editor.can_redo());
editor.redo();
assert_eq!(editor.get_text(), "A");
assert!(editor.can_undo());
assert!(editor.can_redo());
editor.redo();
assert_eq!(editor.get_text(), "AB");
assert!(editor.can_undo());
assert!(!editor.can_redo());
editor.undo();
assert!(editor.can_redo());
editor.insert_char('C');
assert_eq!(editor.get_text(), "AC");
assert!(editor.can_undo());
assert!(!editor.can_redo());
}
#[test]
fn cut_undoes_and_redoes_atomically() {
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.set_text("foo bar");
editor.cursor = Point::new(4, 0);
editor.selection_start = Some(Point::new(4, 0));
editor.cursor.x = 7;
assert!(editor.clip_cut());
assert_eq!(editor.get_text(), "foo ");
editor.undo();
assert_eq!(editor.get_text(), "foo bar");
editor.redo();
assert_eq!(editor.get_text(), "foo ");
}
#[test]
fn test_editor_load_empty_file() {
let file = NamedTempFile::new().unwrap();
let bounds = Rect::new(0, 0, 80, 25);
let mut editor = EditorWindow::new(bounds);
editor.load_file(file.path().to_str().unwrap()).unwrap();
assert_eq!(editor.line_count(), 1); assert_eq!(editor.get_text(), "");
assert!(!editor.is_modified());
}
#[test]
fn newline_and_line_joins_are_undoable() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("abc");
editor.cursor = Point::new(3, 0);
editor.insert_newline();
editor.insert_char('d');
editor.insert_char('e');
editor.insert_char('f');
assert_eq!(editor.get_text(), "abc\ndef");
editor.undo();
editor.undo();
editor.undo();
assert_eq!(editor.get_text(), "abc\n");
editor.undo();
assert_eq!(editor.get_text(), "abc");
for _ in 0..4 {
editor.redo();
}
assert_eq!(editor.get_text(), "abc\ndef");
editor.cursor = Point::new(0, 1);
editor.backspace();
assert_eq!(editor.get_text(), "abcdef");
editor.undo();
assert_eq!(editor.get_text(), "abc\ndef");
editor.cursor = Point::new(3, 0);
editor.delete_char();
assert_eq!(editor.get_text(), "abcdef");
editor.undo();
assert_eq!(editor.get_text(), "abc\ndef");
}
#[test]
fn overwrite_mode_is_single_undo_step() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("xyz");
editor.cursor = Point::new(0, 0);
editor.toggle_insert_mode();
editor.insert_char('A');
assert_eq!(editor.get_text(), "Ayz");
editor.undo();
assert_eq!(editor.get_text(), "xyz");
}
#[test]
fn replace_all_terminates_when_replacement_contains_pattern() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("a b a");
let count = editor.replace_all("a", "aa", SearchOptions::default());
assert_eq!(count, 2);
assert_eq!(editor.get_text(), "aa b aa");
}
#[test]
fn search_handles_multibyte_lines() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("héllo wörld\nsécond ligne");
let pos = editor.find("wörld", SearchOptions::default()).unwrap();
assert_eq!((pos.x, pos.y), (6, 0));
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("héllo wörld\nsécond ligne");
let pos = editor.find(
"SÉCOND",
SearchOptions {
case_sensitive: false,
..SearchOptions::default()
},
);
assert_eq!(pos.map(|p| (p.x, p.y)), Some((0, 1)));
}
#[test]
fn find_next_finds_adjacent_matches() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("abab");
let first = editor.find("ab", SearchOptions::default()).unwrap();
assert_eq!((first.x, first.y), (0, 0));
let second = editor.find_next().unwrap();
assert_eq!((second.x, second.y), (2, 0));
assert!(editor.find_next().is_none());
}
#[test]
fn word_movement_crosses_lines() {
use crossterm::event::KeyModifiers;
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("foo bar\nbaz");
editor.set_focus(true);
editor.cursor = Point::new(0, 0);
let mut ev = Event::keyboard(KB_RIGHT);
ev.key_modifiers = KeyModifiers::CONTROL;
editor.handle_event(&mut ev);
assert_eq!((editor.cursor.x, editor.cursor.y), (4, 0));
let mut ev = Event::keyboard(KB_RIGHT);
ev.key_modifiers = KeyModifiers::CONTROL;
editor.handle_event(&mut ev);
assert_eq!((editor.cursor.x, editor.cursor.y), (0, 1));
let mut ev = Event::keyboard(KB_LEFT);
ev.key_modifiers = KeyModifiers::CONTROL;
editor.handle_event(&mut ev);
assert_eq!((editor.cursor.x, editor.cursor.y), (4, 0));
}
#[test]
fn delete_word_is_single_undo_step() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("foo bar baz");
editor.cursor = Point::new(4, 0);
editor.delete_word(true); assert_eq!(editor.get_text(), "foo baz");
editor.undo();
assert_eq!(editor.get_text(), "foo bar baz");
editor.cursor = Point::new(8, 0); editor.delete_word(false);
assert_eq!(editor.get_text(), "foo baz");
editor.undo();
assert_eq!(editor.get_text(), "foo bar baz");
}
#[test]
fn ins_key_toggles_overwrite() {
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.set_text("abc");
editor.set_focus(true);
editor.cursor = Point::new(0, 0);
let mut ev = Event::keyboard(crate::core::event::KB_INS);
editor.handle_event(&mut ev);
let mut ev = Event::keyboard('X' as u16);
editor.handle_event(&mut ev);
assert_eq!(editor.get_text(), "Xbc");
}
#[test]
fn save_preserves_line_ending_style() {
use std::io::Write;
let mut file = NamedTempFile::new().unwrap();
write!(file, "one\r\ntwo").unwrap();
file.flush().unwrap();
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.load_file(file.path().to_str().unwrap()).unwrap();
assert_eq!(editor.get_text(), "one\ntwo");
let out = NamedTempFile::new().unwrap();
editor.save_as(out.path().to_str().unwrap()).unwrap();
let round = std::fs::read_to_string(out.path()).unwrap();
assert_eq!(round, "one\r\ntwo");
}
#[test]
fn backup_files_creates_bak() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.txt");
std::fs::write(&path, "old contents\n").unwrap();
let mut editor = EditorWindow::new(Rect::new(0, 0, 80, 25));
editor.load_file(path.to_str().unwrap()).unwrap();
editor.set_backup_files(true);
editor.insert_char('x');
editor.save_file().unwrap();
let bak = dir.path().join("data.txt.bak");
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "old contents\n");
assert!(std::fs::read_to_string(&path).unwrap().starts_with('x'));
}
}