#![warn(clippy::pedantic)]
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ratatui::layout::Rect;
use ratatui::style::Style;
#[cfg(feature = "image")]
pub use ratatui_image::protocol::StatefulProtocol as ImageProtocol;
#[cfg(not(feature = "image"))]
pub enum ImageProtocol {}
use vix_editor_core::actions::{
Delete, Duplicate, InsertText, MoveDown, MoveRight, MoveUp, SelectAll,
};
use vix_editor_core::code::Code;
pub use vix_editor_core::editor::Editor as CodeEditor;
use vix_editor_core::utils::get_lang;
use vix_textops::sentence_starts;
use vix_theme as theme;
fn line_is_blank(code: &Code, row: usize) -> bool {
if row >= code.len_lines() {
return true;
}
let start = code.line_to_char(row);
let len = code.line_len(row);
code.char_slice(start, start + len)
.chars()
.all(|c: char| c.is_whitespace())
}
fn is_section_break(code: &Code, row: usize, n: usize) -> bool {
line_is_blank(code, row)
&& ((row > 0 && line_is_blank(code, row - 1))
|| (row + 1 < n && line_is_blank(code, row + 1)))
}
fn word_starts(text: &str) -> Vec<usize> {
let mut starts = Vec::new();
let mut prev_word = false;
for (i, c) in text.chars().enumerate() {
let word = is_word_char(c);
if word && !prev_word {
starts.push(i);
}
prev_word = word;
}
starts
}
#[derive(Clone, Copy)]
pub struct SaveOptions {
pub trim_trailing_whitespace: bool,
pub ensure_final_newline: bool,
}
fn trim_trailing_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut lines = text.split('\n');
if let Some(first) = lines.next() {
out.push_str(first.trim_end_matches([' ', '\t']));
}
for line in lines {
out.push('\n');
out.push_str(line.trim_end_matches([' ', '\t']));
}
out
}
#[must_use]
pub fn is_image_path(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "ico" | "tiff" | "tif")
)
}
pub const SEARCH_MARK: &str = "search";
fn apply_theme(ed: &mut CodeEditor) {
ed.set_text_style(
Style::default()
.fg(theme::region_fg(theme::Region::Editor))
.add_modifier(theme::region_modifiers(theme::Region::Editor)),
);
ed.set_line_number_style(theme::dim());
ed.set_selection_style(theme::selected());
ed.set_whitespace_style(theme::dim());
ed.set_bracket_style(theme::selected());
let syntax = theme::syntax_theme();
let pairs: Vec<(&str, &str)> = syntax.iter().map(|(t, hex)| (*t, hex.as_str())).collect();
ed.set_syntax_theme(&pairs);
let cursor = match theme::editor_cursor() {
Some(color) => Style::default()
.bg(color)
.fg(theme::region_bg(theme::Region::Editor)),
None => theme::selected(),
};
ed.set_cursor_style(Some(cursor));
}
fn make_editor(path: Option<&Path>, text: &str, flags: Flags, indent: &str) -> CodeEditor {
let name = path
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let lang = if name.is_empty() {
"text".to_string()
} else {
get_lang(&name)
};
let mut ed = CodeEditor::new(&lang, text, Vec::new())
.or_else(|_| CodeEditor::new("text", text, Vec::new()))
.expect("code editor init for plain text never fails");
ed.show_line_numbers(flags.contains(Flags::LINE_NUMBERS));
ed.show_whitespace(flags.contains(Flags::SHOW_WHITESPACE));
ed.set_soft_wrap(flags.contains(Flags::SOFT_WRAP));
ed.set_indent(Some(indent.to_string()));
apply_theme(&mut ed);
ed
}
#[must_use]
pub fn text_field(content: &str) -> CodeEditor {
let mut ed = CodeEditor::new("text", content, Vec::new())
.expect("text field init for plain text never fails");
ed.show_line_numbers(false);
apply_theme(&mut ed);
ed
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FsAccess {
#[default]
Writable,
ReadOnly,
}
pub struct Tab {
pub editor: CodeEditor,
pub path: Option<PathBuf>,
pub dirty: bool,
pub preview: bool,
pub image: Option<ImageProtocol>,
pub read_only: bool,
pub fs_access: FsAccess,
}
impl Tab {
pub fn is_image(&self) -> bool {
self.image.is_some()
}
pub fn text(&self) -> String {
self.editor.get_content()
}
pub fn lines(&self) -> Vec<String> {
self.text()
.lines()
.map(std::string::ToString::to_string)
.collect()
}
pub fn title(&self) -> String {
let name = self.path.as_ref().and_then(|p| p.file_name()).map_or_else(
|| "untitled".to_string(),
|s| s.to_string_lossy().into_owned(),
);
let icon = theme::file_icon(&name);
let flag = if self.dirty {
format!(" {}", theme::icon::FILE_DIRTY)
} else {
String::new()
};
let lock = if self.fs_access == FsAccess::ReadOnly {
" 🔒"
} else {
""
};
format!("{icon} {name}{flag}{lock}")
}
pub fn display_path(&self) -> String {
self.path
.as_ref()
.map_or_else(|| "untitled".to_string(), |p| p.display().to_string())
}
pub fn cursor_1based(&self) -> (usize, usize) {
let cur = self.editor.get_cursor();
let code = self.editor.code_ref();
let row = code.char_to_line(cur);
let col = cur - code.line_to_char(row);
(row + 1, col + 1)
}
#[must_use]
pub fn line_count(&self) -> usize {
self.editor.code_ref().len_lines()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SplitDir {
Vertical,
Horizontal,
}
bitflags::bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Flags: u8 {
const LINE_NUMBERS = 1 << 0;
const RELATIVE_LINE_NUMBERS = 1 << 1;
const SHOW_WHITESPACE = 1 << 2;
const SOFT_WRAP = 1 << 3;
}
}
pub struct Editor {
pub tabs: Vec<Tab>,
pub active: usize,
pub flags: Flags,
pub indent: String,
pub split_root: Option<crate::pane_tree::Pane>,
pub focused_leaf: usize,
pub max_text_bytes: u64,
}
impl Default for Editor {
fn default() -> Self {
Editor::new(Flags::LINE_NUMBERS, " ".to_string())
}
}
impl Editor {
#[must_use]
pub fn new(flags: Flags, indent: String) -> Self {
let mut e = Editor {
tabs: Vec::new(),
active: 0,
flags,
indent,
split_root: None,
focused_leaf: 0,
max_text_bytes: vix_fileops::MAX_TEXT_BYTES,
};
e.new_tab();
e
}
#[must_use]
pub fn active_tab(&self) -> Option<&Tab> {
self.tabs.get(self.active)
}
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
self.tabs.get_mut(self.active)
}
#[must_use]
pub fn is_split(&self) -> bool {
self.split_root.is_some()
}
pub fn set_split(&mut self, dir: SplitDir) {
let other = (0..self.tabs.len())
.find(|&i| i != self.active)
.unwrap_or(self.active);
match self.split_root.as_mut() {
None => {
self.split_root = Some(crate::pane_tree::Pane::Split {
dir,
ratio: 50,
first: Box::new(crate::pane_tree::Pane::Leaf(self.active)),
second: Box::new(crate::pane_tree::Pane::Leaf(other)),
});
self.focused_leaf = 0;
}
Some(root) => {
root.set_leaf_tab(self.focused_leaf, self.active);
root.split_leaf(self.focused_leaf, dir, other);
}
}
}
pub fn unsplit(&mut self) {
let Some(root) = self.split_root.take() else {
return;
};
match root.remove_leaf(self.focused_leaf) {
Some(tree) if tree.leaf_count() > 1 => {
self.focused_leaf = self.focused_leaf.min(tree.leaf_count() - 1);
if let Some(tab) = tree.leaf_tab(self.focused_leaf) {
self.active = tab.min(self.tabs.len().saturating_sub(1));
}
self.split_root = Some(tree);
}
Some(tree) => {
if let Some(tab) = tree.leaf_tab(0) {
self.active = tab.min(self.tabs.len().saturating_sub(1));
}
self.split_root = None;
self.focused_leaf = 0;
}
None => {
self.split_root = None;
self.focused_leaf = 0;
}
}
}
fn sync_split(&mut self) {
let count = self.tabs.len();
if let Some(root) = self.split_root.as_mut() {
root.set_leaf_tab(self.focused_leaf, self.active);
root.clamp_tabs(count);
self.focused_leaf = self.focused_leaf.min(root.leaf_count().saturating_sub(1));
}
}
pub fn focus_leaf(&mut self, i: usize) {
self.sync_split();
let Some(root) = self.split_root.as_ref() else {
return;
};
if let Some(tab) = root.leaf_tab(i) {
self.focused_leaf = i;
self.active = tab.min(self.tabs.len().saturating_sub(1));
}
}
pub fn focus_other_pane(&mut self) {
self.sync_split();
let Some(root) = self.split_root.as_ref() else {
return;
};
let n = root.leaf_count();
if n > 0 {
self.focus_leaf((self.focused_leaf + 1) % n);
}
}
#[must_use]
pub fn focused_leaf(&self) -> usize {
self.focused_leaf
}
pub fn split_layout(&mut self, area: Rect) -> Vec<crate::pane_tree::LeafBox> {
self.sync_split();
self.split_root
.as_ref()
.map(|r| r.layout(area))
.unwrap_or_default()
}
#[must_use]
pub fn split_dividers(&self, area: Rect) -> Vec<(SplitDir, Rect)> {
self.split_root
.as_ref()
.map(|r| r.dividers(area))
.unwrap_or_default()
}
pub fn focus_pane_at(&mut self, area: Rect, col: u16, row: u16) -> bool {
self.sync_split();
let Some(leaf) = self
.split_root
.as_ref()
.and_then(|r| r.leaf_at(area, col, row))
else {
return false;
};
self.focus_leaf(leaf);
true
}
pub fn resize_split_at(&mut self, area: Rect, col: u16, row: u16) -> bool {
self.split_root
.as_mut()
.is_some_and(|r| r.resize_at(area, col, row))
}
pub fn new_tab(&mut self) {
let editor = make_editor(None, "", self.flags, &self.indent);
self.tabs.push(Tab {
editor,
path: None,
dirty: false,
preview: false,
image: None,
read_only: false,
fs_access: FsAccess::Writable,
});
self.active = self.tabs.len() - 1;
}
pub fn new_tab_with_content(&mut self, content: &str) {
let editor = make_editor(None, content, self.flags, &self.indent);
self.tabs.push(Tab {
editor,
path: None,
dirty: true,
preview: false,
image: None,
read_only: false,
fs_access: FsAccess::Writable,
});
self.active = self.tabs.len() - 1;
}
#[cfg(feature = "image")]
pub fn open_image(&mut self, path: &Path, proto: ImageProtocol) {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Some(i) = self
.tabs
.iter()
.position(|t| t.path.as_deref() == Some(canon.as_path()))
{
self.tabs[i].image = Some(proto);
self.active = i;
return;
}
let editor = make_editor(Some(&canon), "", self.flags, &self.indent);
self.tabs.push(Tab {
editor,
path: Some(canon),
dirty: false,
preview: false,
image: Some(proto),
read_only: false,
fs_access: FsAccess::Writable,
});
self.active = self.tabs.len() - 1;
}
pub fn refresh_line_numbers(&mut self) {
for tab in &mut self.tabs {
tab.editor
.show_line_numbers(self.flags.contains(Flags::LINE_NUMBERS));
tab.editor
.set_relative_line_numbers(self.flags.contains(Flags::RELATIVE_LINE_NUMBERS));
}
}
pub fn refresh_whitespace(&mut self) {
for tab in &mut self.tabs {
tab.editor
.show_whitespace(self.flags.contains(Flags::SHOW_WHITESPACE));
}
}
pub fn refresh_soft_wrap(&mut self) {
for tab in &mut self.tabs {
tab.editor
.set_soft_wrap(self.flags.contains(Flags::SOFT_WRAP));
}
}
pub fn refresh_theme(&mut self) {
for tab in &mut self.tabs {
apply_theme(&mut tab.editor);
}
}
pub fn open(&mut self, path: &Path, preview: bool) -> io::Result<()> {
self.open_with_limit(path, preview, self.max_text_bytes)
}
pub fn open_unbounded(&mut self, path: &Path, preview: bool) -> io::Result<()> {
self.open_with_limit(path, preview, vix_fileops::HARD_MAX_TEXT_BYTES)
}
fn open_with_limit(&mut self, path: &Path, preview: bool, max_bytes: u64) -> io::Result<()> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Some(i) = self
.tabs
.iter()
.position(|t| t.path.as_deref() == Some(canon.as_path()))
{
self.active = i;
if !preview {
self.tabs[i].preview = false;
}
return Ok(());
}
let fs_read_only = vix_fileops::is_unwritable(&canon);
let (content, lossy) = vix_fileops::read_text_guarded(&canon, max_bytes)?;
let editor = make_editor(Some(&canon), &content, self.flags, &self.indent);
let tab = Tab {
editor,
path: Some(canon),
dirty: false,
preview,
image: None,
read_only: lossy,
fs_access: if fs_read_only {
FsAccess::ReadOnly
} else {
FsAccess::Writable
},
};
if preview && let Some(i) = self.tabs.iter().position(|t| t.preview) {
self.tabs[i] = tab;
self.active = i;
return Ok(());
}
self.tabs.push(tab);
self.active = self.tabs.len() - 1;
Ok(())
}
pub fn reload_clean_from_disk(&mut self) -> usize {
let mut reloaded = 0;
for tab in &mut self.tabs {
if tab.dirty || tab.image.is_some() {
continue;
}
let Some(path) = tab.path.clone() else {
continue;
};
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
if content == tab.editor.get_content() {
continue;
}
let cursor = tab.editor.get_cursor();
tab.editor.set_content(&content);
tab.editor.set_cursor(cursor.min(content.chars().count()));
tab.dirty = false;
reloaded += 1;
}
reloaded
}
pub fn promote_active(&mut self) {
if let Some(t) = self.active_tab_mut() {
t.preview = false;
}
}
pub fn save_active(&mut self, opts: SaveOptions) -> io::Result<PathBuf> {
let idx = self.active;
let path = self.tabs[idx]
.path
.clone()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "buffer has no path"))?;
self.write_to(idx, &path, opts)?;
Ok(path)
}
pub fn save_active_as(&mut self, path: PathBuf, opts: SaveOptions) -> io::Result<PathBuf> {
let idx = self.active;
self.write_to(idx, &path, opts)?;
self.tabs[idx].path = Some(path.clone());
Ok(path)
}
fn write_to(&mut self, idx: usize, path: &Path, opts: SaveOptions) -> io::Result<()> {
let mut data = self.tabs[idx].text();
if opts.trim_trailing_whitespace {
data = trim_trailing_whitespace(&data);
}
if opts.ensure_final_newline && !data.is_empty() && !data.ends_with('\n') {
data.push('\n');
}
vix_fileops::write_atomic(path, data.as_bytes())?;
self.tabs[idx].dirty = false;
self.tabs[idx].preview = false;
Ok(())
}
pub fn close_active(&mut self) -> Option<PathBuf> {
if self.tabs.is_empty() {
return None;
}
let closed = self.tabs.remove(self.active).path;
if self.tabs.is_empty() {
self.new_tab();
} else if self.active >= self.tabs.len() {
self.active = self.tabs.len() - 1;
}
closed
}
pub fn close_all(&mut self) -> Vec<PathBuf> {
let closed: Vec<PathBuf> = self.tabs.drain(..).filter_map(|t| t.path).collect();
self.active = 0;
self.new_tab();
closed
}
pub fn next_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = (self.active + 1) % self.tabs.len();
}
}
pub fn prev_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
}
}
#[must_use]
pub fn cursor_1based(&self) -> (usize, usize) {
self.active_tab().map_or((1, 1), Tab::cursor_1based)
}
#[must_use]
pub fn active_line_count(&self) -> usize {
self.active_tab()
.map_or(1, |t| t.editor.code_ref().len_lines())
}
pub fn goto(&mut self, line: usize, col: Option<usize>, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let last = t.editor.code_ref().len_lines().max(1);
let line = line.clamp(1, last);
let off = t
.editor
.code_ref()
.offset(line - 1, col.unwrap_or(1).saturating_sub(1));
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_line_home(&mut self) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let code = t.editor.code_ref();
let row = code.char_to_line(cur);
let line_start = code.line_to_char(row);
let line_len = code.line_len(row);
let cur_col = cur - line_start;
let indent = code
.char_slice(line_start, line_start + line_len)
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
let indent = if indent >= line_len { 0 } else { indent };
let target = if cur_col == indent { 0 } else { indent };
t.editor.set_cursor(line_start + target);
}
}
pub fn cursor_document_start(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
t.editor.set_cursor(0);
t.editor.focus(&area);
}
}
pub fn cursor_document_end(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let code = t.editor.code_ref();
let last = code.len_lines().saturating_sub(1);
let off = code.line_to_char(last) + code.line_len(last);
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_line_end(&mut self) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let code = t.editor.code_ref();
let row = code.char_to_line(cur);
let off = code.line_to_char(row) + code.line_len(row);
t.editor.set_cursor(off);
}
}
pub fn cursor_line_start(&mut self) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let code = t.editor.code_ref();
let row = code.char_to_line(cur);
let off = code.line_to_char(row);
t.editor.set_cursor(off);
}
}
pub fn cursor_paragraph_start(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let off = {
let code = t.editor.code_ref();
let is_blank = |r: usize| line_is_blank(code, r);
let mut r = code.char_to_line(cur);
while r > 0 && is_blank(r) {
r -= 1;
}
while r > 0 && !is_blank(r - 1) {
r -= 1;
}
code.line_to_char(r)
};
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_paragraph_end(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let off = {
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
let is_blank = |r: usize| line_is_blank(code, r);
let mut r = code.char_to_line(cur);
while r + 1 < n && is_blank(r) {
r += 1;
}
while r + 1 < n && !is_blank(r + 1) {
r += 1;
}
code.line_to_char(r) + code.line_len(r)
};
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_section_start(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let off = {
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
let sep = |r: usize| is_section_break(code, r, n);
let mut r = code.char_to_line(cur);
while r > 0 && !sep(r - 1) {
r -= 1;
}
code.line_to_char(r)
};
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_section_end(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let cur = t.editor.get_cursor();
let off = {
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
let sep = |r: usize| is_section_break(code, r, n);
let mut r = code.char_to_line(cur);
while r + 1 < n && !sep(r + 1) {
r += 1;
}
code.line_to_char(r) + code.line_len(r)
};
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_line_down(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
t.editor.cursor_down();
t.editor.focus(&area);
}
}
pub fn cursor_line_up(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
t.editor.cursor_up();
t.editor.focus(&area);
}
}
fn cursor_row(&self) -> usize {
self.active_tab().map_or(0, |t| {
let code = t.editor.code_ref();
code.char_to_line(t.editor.get_cursor())
})
}
#[must_use]
pub fn cursor_line(&self) -> usize {
self.cursor_row()
}
pub fn poll_parse(&mut self) -> bool {
self.active_tab_mut().is_some_and(|t| t.editor.poll_parse())
}
#[must_use]
pub fn parse_pending(&self) -> bool {
self.active_tab().is_some_and(|t| t.editor.parse_pending())
}
pub fn switch_undo_branch(&mut self) -> bool {
self.active_tab_mut()
.is_some_and(|t| t.editor.switch_undo_branch())
}
#[must_use]
pub fn top_visible_line(&self) -> usize {
self.active_tab().map_or(0, |t| t.editor.top_line())
}
pub fn set_word_marks(&mut self, marks: Vec<(usize, usize)>) {
if let Some(t) = self.active_tab_mut() {
t.editor.set_word_marks(marks);
}
}
fn block_starts(&self, section: bool) -> Vec<usize> {
let Some(t) = self.active_tab() else {
return Vec::new();
};
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
(0..n)
.filter(|&r| {
if section {
!is_section_break(code, r, n) && (r == 0 || is_section_break(code, r - 1, n))
} else {
!line_is_blank(code, r) && (r == 0 || line_is_blank(code, r - 1))
}
})
.collect()
}
fn goto_row(&mut self, row: usize, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let off = t.editor.code_ref().line_to_char(row);
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_paragraph_next(&mut self, area: Rect) {
let row = self.cursor_row();
if let Some(&next) = self.block_starts(false).iter().find(|&&r| r > row) {
self.goto_row(next, area);
}
}
pub fn cursor_paragraph_prev(&mut self, area: Rect) {
let row = self.cursor_row();
if let Some(&prev) = self.block_starts(false).iter().rev().find(|&&r| r < row) {
self.goto_row(prev, area);
}
}
pub fn goto_paragraph(&mut self, n: usize, area: Rect) {
if let Some(&row) = self.block_starts(false).get(n.saturating_sub(1)) {
self.goto_row(row, area);
}
}
pub fn cursor_section_next(&mut self, area: Rect) {
let row = self.cursor_row();
if let Some(&next) = self.block_starts(true).iter().find(|&&r| r > row) {
self.goto_row(next, area);
}
}
pub fn cursor_section_prev(&mut self, area: Rect) {
let row = self.cursor_row();
if let Some(&prev) = self.block_starts(true).iter().rev().find(|&&r| r < row) {
self.goto_row(prev, area);
}
}
pub fn goto_section(&mut self, n: usize, area: Rect) {
if let Some(&row) = self.block_starts(true).get(n.saturating_sub(1)) {
self.goto_row(row, area);
}
}
pub fn goto_offset(&mut self, off: usize, area: Rect) {
if let Some(t) = self.active_tab_mut() {
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn cursor_sentence_start(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
let off = sentence_starts(&t.editor.get_content())
.into_iter()
.rev()
.find(|&s| s <= cur)
.unwrap_or(0);
self.goto_offset(off, area);
}
pub fn cursor_sentence_end(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
let text = t.editor.get_content();
let chars: Vec<char> = text.chars().collect();
let off = match sentence_starts(&text).into_iter().find(|&s| s > cur) {
Some(mut e) => {
while e > 0 && chars.get(e - 1).is_some_and(|c| c.is_whitespace()) {
e -= 1;
}
e
}
None => chars.len(),
};
self.goto_offset(off, area);
}
pub fn cursor_sentence_next(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
if let Some(next) = sentence_starts(&t.editor.get_content())
.into_iter()
.find(|&s| s > cur)
{
self.goto_offset(next, area);
}
}
pub fn cursor_sentence_prev(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
if let Some(prev) = sentence_starts(&t.editor.get_content())
.into_iter()
.rev()
.find(|&s| s < cur)
{
self.goto_offset(prev, area);
}
}
pub fn goto_sentence(&mut self, n: usize, area: Rect) {
let Some(t) = self.active_tab() else { return };
let starts = sentence_starts(&t.editor.get_content());
if let Some(&off) = starts.get(n.saturating_sub(1)) {
self.goto_offset(off, area);
}
}
pub fn cursor_word_start(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
let off = word_starts(&t.editor.get_content())
.into_iter()
.rev()
.find(|&s| s <= cur)
.unwrap_or(0);
self.goto_offset(off, area);
}
pub fn cursor_word_end(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
let chars: Vec<char> = t.editor.get_content().chars().collect();
let mut e = cur;
while e < chars.len() && !is_word_char(chars[e]) {
e += 1;
}
while e < chars.len() && is_word_char(chars[e]) {
e += 1;
}
self.goto_offset(e, area);
}
pub fn cursor_word_next(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
if let Some(next) = word_starts(&t.editor.get_content())
.into_iter()
.find(|&s| s > cur)
{
self.goto_offset(next, area);
}
}
pub fn cursor_word_prev(&mut self, area: Rect) {
let Some(t) = self.active_tab() else { return };
let cur = t.editor.get_cursor();
if let Some(prev) = word_starts(&t.editor.get_content())
.into_iter()
.rev()
.find(|&s| s < cur)
{
self.goto_offset(prev, area);
}
}
pub fn goto_word(&mut self, n: usize, area: Rect) {
let Some(t) = self.active_tab() else { return };
let starts = word_starts(&t.editor.get_content());
if let Some(&off) = starts.get(n.saturating_sub(1)) {
self.goto_offset(off, area);
}
}
pub fn goto_percent(&mut self, pct: usize, area: Rect) {
let Some(t) = self.active_tab() else { return };
let total = t.editor.code_ref().len_lines().max(1);
let line = pct.min(100) * total.saturating_sub(1) / 100;
self.goto(line + 1, None, area); }
pub fn goto_byte(&mut self, byte: usize, area: Rect) {
let Some(t) = self.active_tab() else { return };
let code = t.editor.code_ref();
let off = code.byte_to_char(byte.min(code.len_bytes()));
self.goto_offset(off, area);
}
pub fn select_line(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let (start, end) = {
let code = t.editor.code_ref();
let row = code.char_to_line(t.editor.get_cursor());
let start = code.line_to_char(row);
let end = if row + 1 < code.len_lines() {
code.line_to_char(row + 1)
} else {
start + code.line_len(row)
};
(start, end)
};
t.editor.set_selection_range(start, end);
t.editor.focus(&area);
}
}
pub fn select_paragraph(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let (start, end) = {
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
let is_blank = |r: usize| line_is_blank(code, r);
let row = code.char_to_line(t.editor.get_cursor());
let mut s = row;
while s > 0 && is_blank(s) {
s -= 1;
}
while s > 0 && !is_blank(s - 1) {
s -= 1;
}
let mut e = row;
while e + 1 < n && is_blank(e) {
e += 1;
}
while e + 1 < n && !is_blank(e + 1) {
e += 1;
}
(
code.line_to_char(s),
code.line_to_char(e) + code.line_len(e),
)
};
t.editor.set_selection_range(start, end);
t.editor.focus(&area);
}
}
pub fn select_section(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut() {
let (start, end) = {
let code = t.editor.code_ref();
let n = code.len_lines().max(1);
let sep = |r: usize| is_section_break(code, r, n);
let row = code.char_to_line(t.editor.get_cursor());
let mut s = row;
while s > 0 && !sep(s - 1) {
s -= 1;
}
let mut e = row;
while e + 1 < n && !sep(e + 1) {
e += 1;
}
(
code.line_to_char(s),
code.line_to_char(e) + code.line_len(e),
)
};
t.editor.set_selection_range(start, end);
t.editor.focus(&area);
}
}
pub fn insert_str(&mut self, text: &str, area: Rect) -> bool {
if let Some(t) = self.active_tab_mut() {
if t.is_image() || t.read_only {
return false;
}
t.editor.apply(InsertText {
text: text.to_string(),
});
t.editor.focus(&area);
t.dirty = true;
t.preview = false;
return true;
}
false
}
pub fn paste_str(&mut self, text: &str, area: Rect) -> bool {
if let Some(t) = self.active_tab_mut() {
if t.is_image() || t.read_only {
return false;
}
t.editor.paste_text(text);
t.editor.focus(&area);
t.dirty = true;
t.preview = false;
return true;
}
false
}
pub fn select_all(&mut self) {
if let Some(t) = self.active_tab_mut() {
t.editor.apply(SelectAll {});
}
}
pub fn duplicate_line(&mut self) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
t.editor.apply(Duplicate {});
t.dirty = true;
t.preview = false;
}
}
pub fn join_lines(&mut self) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
t.editor.join_lines();
t.dirty = true;
t.preview = false;
}
}
pub fn sort_lines(&mut self) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
t.editor.sort_lines();
t.dirty = true;
t.preview = false;
}
}
pub fn trim_trailing_whitespace(&mut self) {
self.edit_active_lines(CodeEditor::trim_trailing_whitespace);
}
pub fn remove_duplicate_lines(&mut self) {
self.edit_active_lines(CodeEditor::remove_duplicate_lines);
}
pub fn reverse_lines(&mut self) {
self.edit_active_lines(CodeEditor::reverse_lines);
}
pub fn sort_unique(&mut self) {
self.edit_active_lines(CodeEditor::sort_unique);
}
pub fn shuffle_lines(&mut self) {
self.edit_active_lines(CodeEditor::shuffle_lines);
}
fn edit_active_lines(&mut self, f: impl FnOnce(&mut CodeEditor)) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
f(&mut t.editor);
t.dirty = true;
t.preview = false;
}
}
pub fn move_line(&mut self, down: bool, area: Rect) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
if down {
t.editor.move_line_down();
} else {
t.editor.move_line_up();
}
t.editor.focus(&area);
t.dirty = true;
t.preview = false;
}
}
pub fn select_word(&mut self, forward: bool, area: Rect) {
if let Some(t) = self.active_tab_mut() {
if t.is_image() {
return;
}
let text: Vec<char> = t.text().chars().collect();
let cur = t.editor.get_cursor();
let target = if forward {
next_word(&text, cur)
} else {
prev_word(&text, cur)
};
if target != cur {
t.editor.extend_selection(target);
t.editor.set_cursor(target);
t.editor.focus(&area);
}
}
}
pub fn jump_matching_bracket(&mut self, area: Rect) {
if let Some(t) = self.active_tab_mut()
&& let Some(off) = t.editor.matching_bracket_offset()
{
t.editor.set_cursor(off);
t.editor.focus(&area);
}
}
pub fn delete_forward(&mut self) {
if let Some(t) = self.active_tab_mut() {
let at_end = t.editor.get_cursor() >= t.editor.code_ref().len_chars();
let selecting = t.editor.get_selection().is_some_and(|s| !s.is_empty());
if at_end && !selecting {
return;
}
t.editor.apply(MoveRight { shift: false });
t.editor.apply(Delete {});
}
}
pub fn page_up(&mut self, lines: usize) {
if let Some(t) = self.active_tab_mut() {
for _ in 0..lines.max(1) {
t.editor.apply(MoveUp { shift: false });
}
}
}
pub fn page_down(&mut self, lines: usize) {
if let Some(t) = self.active_tab_mut() {
for _ in 0..lines.max(1) {
t.editor.apply(MoveDown { shift: false });
}
}
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn next_word(text: &[char], pos: usize) -> usize {
let n = text.len();
let mut i = pos.min(n);
while i < n && !is_word_char(text[i]) {
i += 1;
}
while i < n && is_word_char(text[i]) {
i += 1;
}
i
}
fn prev_word(text: &[char], pos: usize) -> usize {
let mut i = pos.min(text.len());
while i > 0 && !is_word_char(text[i - 1]) {
i -= 1;
}
while i > 0 && is_word_char(text[i - 1]) {
i -= 1;
}
i
}
#[cfg(test)]
mod nav_tests {
use super::{sentence_starts, word_starts};
#[test]
fn word_starts_finds_each_word() {
assert_eq!(word_starts("foo bar_baz qux"), vec![0, 5, 13]);
assert_eq!(word_starts(" leading"), vec![3]);
assert_eq!(word_starts("..."), [] as [usize; 0]);
}
#[test]
fn sentence_starts_splits_on_terminators() {
let text = "One. Two! Three? Four.";
assert_eq!(sentence_starts(text), vec![0, 5, 10, 18]);
assert_eq!(sentence_starts("pi is 3.14 today"), vec![0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_shows_a_lock_only_for_filesystem_read_only_tabs() {
let mut e = Editor::new(Flags::LINE_NUMBERS, " ".into());
let tab = e.active_tab_mut().unwrap();
assert!(!tab.title().contains('🔒'));
tab.fs_access = FsAccess::ReadOnly;
assert!(tab.title().contains('🔒'));
tab.read_only = true;
tab.read_only = false;
assert_eq!(tab.fs_access, FsAccess::ReadOnly);
}
}