use std::cell::{Cell, RefCell};
use cosmic_text::Scroll;
use iced::advanced::graphics::text::cosmic_text;
use iced::advanced::input_method;
use iced::mouse::ScrollDelta;
use iced::widget::text_editor;
use super::highlight::{self, FileHighlights, HighlightLanguage};
use super::text_rendering::{
GUTTER_FONT_SIZE, byte_line_and_start, compute_total_height, cursor_to_buffer_coords,
draw_background, draw_buffer_text, draw_run_highlights, fill_rich_spans, font_metrics,
gutter_clip_rect, iced_color_to_cosmic, reshape_and_shape, text_area_rect, with_font_system,
};
use crate::util::UnwrapPoison;
use crate::util::is_word_char;
const PAGE_SCROLL_LINES: usize = 40;
#[derive(Debug, Clone)]
pub struct CursorState {
pub line: usize,
pub column: usize,
pub selection: Option<Box<CursorState>>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CursorMove {
Left,
Right,
Up,
Down,
Home,
End,
WordLeft,
WordRight,
DocStart,
DocEnd,
PageUp,
PageDown,
}
#[derive(Debug, Clone)]
pub enum EditorAction {
Insert(char),
Enter,
Backspace,
Delete,
Paste(String),
MoveTo {
line: usize,
col: usize,
},
SelectTo {
line: usize,
col: usize,
},
Move {
direction: CursorMove,
select: bool,
},
DeleteWordBack,
DeleteWordForward,
SelectAll,
SelectWordAt {
line: usize,
col: usize,
},
Indent,
Unindent,
ToggleLineComment,
JumpToMatchingBracket,
DeleteLine,
DuplicateLine,
MoveLineUp,
MoveLineDown,
}
impl EditorAction {
#[must_use]
pub const fn is_edit_action(&self) -> bool {
matches!(
self,
Self::Insert(_)
| Self::Enter
| Self::Backspace
| Self::Delete
| Self::Paste(_)
| Self::Indent
| Self::Unindent
| Self::DeleteWordBack
| Self::DeleteWordForward
| Self::ToggleLineComment
| Self::DeleteLine
| Self::DuplicateLine
| Self::MoveLineUp
| Self::MoveLineDown
)
}
#[must_use]
pub const fn is_cursor_movement(&self) -> bool {
matches!(
self,
Self::Move { .. }
| Self::SelectWordAt { .. }
| Self::JumpToMatchingBracket
| Self::MoveLineUp
| Self::MoveLineDown
)
}
}
pub struct EditorBuffer {
buffer: RefCell<cosmic_text::Buffer>,
cursor_line: Cell<usize>,
cursor_col: Cell<usize>,
sel_line: Cell<usize>,
sel_col: Cell<usize>,
has_selection: Cell<bool>,
language: Option<HighlightLanguage>,
file_extension: RefCell<Option<String>>,
}
impl EditorBuffer {
#[must_use]
pub fn with_text(text: &str, language: Option<HighlightLanguage>) -> Self {
let buffer = with_font_system(|font_sys| {
let mut buffer = cosmic_text::Buffer::new(font_sys, font_metrics());
Self::set_buffer_text_highlighted(&mut buffer, font_sys, text, language);
buffer
});
Self {
buffer: RefCell::new(buffer),
cursor_line: Cell::new(0),
cursor_col: Cell::new(0),
sel_line: Cell::new(0),
sel_col: Cell::new(0),
has_selection: Cell::new(false),
language,
file_extension: RefCell::new(None),
}
}
pub fn from_file(text: &str, path: impl AsRef<std::path::Path>) -> Self {
let path_ref = path.as_ref();
let language = path_ref.to_str().and_then(HighlightLanguage::from_path);
let content = Self::with_text(text, language);
content.set_file_extension(path_ref.extension().and_then(|e| e.to_str()));
content
}
pub fn set_file_extension(&self, ext: Option<&str>) {
*self.file_extension.borrow_mut() = ext.map(String::from);
}
#[must_use]
pub fn file_extension(&self) -> Option<String> {
self.file_extension.borrow().clone()
}
pub fn text(&self) -> String {
buffer_text(&self.buffer.borrow())
}
pub fn line_count(&self) -> usize {
self.buffer.borrow().lines.len()
}
fn line_char_len(&self, line: usize) -> usize {
self.buffer
.borrow()
.lines
.get(line)
.map_or(0, |l| l.text().chars().count())
}
pub fn cursor(&self) -> CursorState {
let has_real_selection = self.has_selection.get()
&& (self.sel_line.get() != self.cursor_line.get()
|| self.sel_col.get() != self.cursor_col.get());
let selection = if has_real_selection {
Some(Box::new(CursorState {
line: self.sel_line.get(),
column: self.sel_col.get(),
selection: None,
}))
} else {
None
};
CursorState {
line: self.cursor_line.get(),
column: self.cursor_col.get(),
selection,
}
}
pub fn move_to(&self, line: usize, col: usize) {
self.set_cursor_pos(line, col);
self.has_selection.set(false);
}
fn set_cursor_pos(&self, line: usize, col: usize) {
let max_line = self.line_count().saturating_sub(1);
let line = line.min(max_line);
let col = self.clamp_col_to_line(line, col);
self.cursor_line.set(line);
self.cursor_col.set(col);
}
pub fn selection(&self) -> Option<String> {
if !self.has_selection.get() {
return None;
}
let (start_line, start_col, end_line, end_col) = self.selection_range();
let text_buf = self.text();
let start_offset = line_col_to_byte_offset(&text_buf, start_line, start_col);
let end_offset = line_col_to_byte_offset(&text_buf, end_line, end_col);
match start_offset.cmp(&end_offset) {
std::cmp::Ordering::Less => Some(text_buf[start_offset..end_offset].to_string()),
std::cmp::Ordering::Greater => Some(text_buf[end_offset..start_offset].to_string()),
std::cmp::Ordering::Equal => None,
}
}
pub fn select_all(&self) {
let line_count = self.line_count();
if line_count == 0 {
return;
}
let last_line = line_count - 1;
let last_line_len = self.line_char_len(last_line);
self.cursor_line.set(last_line);
self.cursor_col.set(last_line_len);
self.sel_line.set(0);
self.sel_col.set(0);
self.has_selection.set(true);
}
pub fn perform_action(&self, action: EditorAction) {
match action {
EditorAction::Insert(c) => self.do_insert(c),
EditorAction::Enter => self.do_enter(),
EditorAction::Backspace => self.do_backspace(),
EditorAction::Delete => self.do_delete(),
EditorAction::Paste(s) => self.do_paste(&s),
EditorAction::MoveTo { line, col } => self.move_to(line, col),
EditorAction::SelectTo { line, col } => {
if !self.has_selection.get() {
self.sel_line.set(self.cursor_line.get());
self.sel_col.set(self.cursor_col.get());
}
let max_line = self.line_count().saturating_sub(1);
let line = line.min(max_line);
let col = self.clamp_col_to_line(line, col);
if line == self.cursor_line.get() && col == self.cursor_col.get() {
if self.has_selection.get()
&& (self.sel_line.get() != self.cursor_line.get()
|| self.sel_col.get() != self.cursor_col.get())
{
return;
}
self.has_selection.set(false);
return;
}
self.cursor_line.set(line);
self.cursor_col.set(col);
self.has_selection.set(true);
self.normalize_selection();
}
EditorAction::SelectAll => self.select_all(),
EditorAction::SelectWordAt { line, col } => {
let text_buf = self.text();
let byte_offset = line_col_to_byte_offset(&text_buf, line, col);
let (word_start, word_end) = word_bounds_at(&text_buf, byte_offset);
if word_start == word_end {
self.move_to(line, col);
} else {
let (anchor_line, anchor_col) = byte_offset_to_line_col(&text_buf, word_start);
let (cursor_line, cursor_col) = byte_offset_to_line_col(&text_buf, word_end);
self.sel_line.set(anchor_line);
self.sel_col.set(anchor_col);
self.cursor_line.set(cursor_line);
self.cursor_col.set(cursor_col);
self.has_selection.set(true);
self.normalize_selection();
}
}
EditorAction::Indent => self.do_indent(),
EditorAction::Unindent => self.do_unindent(),
EditorAction::Move { direction, select } => match direction {
CursorMove::Left => self.do_move_left(select),
CursorMove::Right => self.do_move_right(select),
CursorMove::Up => self.do_move_up(select),
CursorMove::Down => self.do_move_down(select),
CursorMove::Home => self.do_move_home(select),
CursorMove::End => self.do_move_end(select),
CursorMove::WordLeft => self.do_move_word_left(select),
CursorMove::WordRight => self.do_move_word_right(select),
CursorMove::DocStart => self.do_move_doc_start(select),
CursorMove::DocEnd => self.do_move_doc_end(select),
CursorMove::PageUp => self.do_move_page_up(select),
CursorMove::PageDown => self.do_move_page_down(select),
},
EditorAction::DeleteWordBack => self.do_delete_word_back(),
EditorAction::DeleteWordForward => self.do_delete_word_forward(),
EditorAction::ToggleLineComment => self.do_toggle_line_comment(),
EditorAction::JumpToMatchingBracket => self.do_jump_to_matching_bracket(),
EditorAction::DeleteLine => self.do_delete_line(),
EditorAction::DuplicateLine => self.do_duplicate_line(),
EditorAction::MoveLineUp => self.do_move_line(true),
EditorAction::MoveLineDown => self.do_move_line(false),
}
}
pub fn borrow_buffer(&self) -> std::cell::Ref<'_, cosmic_text::Buffer> {
self.buffer.borrow()
}
pub fn borrow_buffer_mut(&self) -> std::cell::RefMut<'_, cosmic_text::Buffer> {
self.buffer.borrow_mut()
}
fn clamp_col_to_line(&self, line: usize, col: usize) -> usize {
self.line_char_len(line).min(col)
}
fn set_buffer_text_highlighted(
buffer: &mut cosmic_text::Buffer,
font_sys: &mut cosmic_text::FontSystem,
text: &str,
language: Option<HighlightLanguage>,
) {
if let Some(lang) = language {
if let Some(highlights) = highlight::parse_highlights(text, lang) {
let base_attrs =
cosmic_text::Attrs::new().family(cosmic_text::Family::Name("JetBrains Mono"));
let spans = build_rich_spans(text, &highlights, &base_attrs);
buffer.set_rich_text(
font_sys,
spans,
&base_attrs,
cosmic_text::Shaping::Advanced,
None,
);
if text.ends_with('\n')
&& buffer
.lines
.last()
.is_some_and(|l| l.ending() != cosmic_text::LineEnding::None)
{
buffer.lines.push(cosmic_text::BufferLine::new(
"",
cosmic_text::LineEnding::None,
cosmic_text::AttrsList::new(&base_attrs),
cosmic_text::Shaping::Advanced,
));
}
return;
}
}
buffer.set_text(
font_sys,
text,
&cosmic_text::Attrs::new().family(cosmic_text::Family::Name("JetBrains Mono")),
cosmic_text::Shaping::Advanced,
None,
);
}
const fn selection_range(&self) -> (usize, usize, usize, usize) {
let cl = self.cursor_line.get();
let cc = self.cursor_col.get();
let sl = self.sel_line.get();
let sc = self.sel_col.get();
if cl < sl || (cl == sl && cc < sc) {
(cl, cc, sl, sc)
} else {
(sl, sc, cl, cc)
}
}
fn clamped_selection_range(&self) -> Option<(usize, usize, usize, usize)> {
let (sl, sc, el, ec) = self.selection_range();
let line_count = self.line_count();
if sl >= line_count {
return None;
}
let el = el.min(line_count.saturating_sub(1));
Some((sl, sc, el, ec))
}
fn selected_line_range(&self) -> Option<(usize, usize)> {
if self.line_count() == 0 {
return None;
}
if self.has_selection.get() {
let (sl, _sc, el, _ec) = self.clamped_selection_range()?;
Some((sl, el))
} else {
let line = self.cursor_line.get();
Some((line, line))
}
}
fn delete_selection_get_range(&self) -> Option<(usize, usize)> {
if !self.has_selection.get() {
return None;
}
let text_buf = self.text();
let (sl, sc, el, ec) = self.selection_range();
let start_off = line_col_to_byte_offset(&text_buf, sl, sc);
let end_off = line_col_to_byte_offset(&text_buf, el, ec);
self.has_selection.set(false);
self.cursor_line.set(sl);
self.cursor_col.set(sc);
Some((start_off, end_off))
}
fn delete_if_selected(&self) -> bool {
if let Some((start, end)) = self.delete_selection_get_range() {
self.edit_text(|text| {
let mut new_text = text.to_string();
new_text.replace_range(start..end, "");
let (line, col) = byte_offset_to_line_col(&new_text, start);
(new_text, Some((line, col)))
});
true
} else {
false
}
}
fn edit_text(&self, f: impl FnOnce(&str) -> (String, Option<(usize, usize)>)) {
let text_buf = self.text();
let (new_text, new_cursor) = f(&text_buf);
if new_text == text_buf {
return;
}
let saved_line = self.cursor_line.get();
let saved_col = self.cursor_col.get();
let saved_has_sel = self.has_selection.get();
let saved_sel_line = self.sel_line.get();
let saved_sel_col = self.sel_col.get();
let language = self.language;
with_font_system(|font_sys| {
let mut buffer = self.buffer.borrow_mut();
Self::set_buffer_text_highlighted(&mut buffer, font_sys, &new_text, language);
});
self.cursor_line.set(saved_line);
self.cursor_col.set(saved_col);
self.has_selection.set(saved_has_sel);
self.sel_line.set(saved_sel_line);
self.sel_col.set(saved_sel_col);
if let Some((line, col)) = new_cursor {
let max_line = self.line_count().saturating_sub(1);
let line = line.min(max_line);
let col = self.clamp_col_to_line(line, col);
self.cursor_line.set(line);
self.cursor_col.set(col);
self.has_selection.set(false);
}
}
fn do_insert(&self, c: char) {
let sel_range = self.delete_selection_get_range();
self.edit_text(|text| {
let mut new_text = text.to_string();
if let Some((start, end)) = sel_range {
new_text.replace_range(start..end, &c.to_string());
} else {
let offset =
line_col_to_byte_offset(text, self.cursor_line.get(), self.cursor_col.get());
new_text.insert(offset, c);
}
let (new_line, new_col) = if c == '\n' {
(self.cursor_line.get() + 1, 0)
} else {
(self.cursor_line.get(), self.cursor_col.get() + 1)
};
(new_text, Some((new_line, new_col)))
});
}
fn do_enter(&self) {
let sel_range = self.delete_selection_get_range();
let current_line = self.cursor_line.get();
let leading_ws = self
.buffer
.borrow()
.lines
.get(current_line)
.map(|l| {
let line_text = l.text();
let ws_len = line_text
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
line_text[..ws_len].to_string()
})
.unwrap_or_default();
let ws_len = leading_ws.chars().count();
self.edit_text(|text| {
let mut new_text = text.to_string();
if let Some((start, end)) = sel_range {
new_text.replace_range(start..end, &format!("\n{leading_ws}"));
} else {
let offset = line_col_to_byte_offset(text, current_line, self.cursor_col.get());
new_text.insert_str(offset, &format!("\n{leading_ws}"));
}
(new_text, Some((current_line + 1, ws_len)))
});
}
fn do_backspace(&self) {
if self.delete_if_selected() {
return;
}
let (cl, cc) = (self.cursor_line.get(), self.cursor_col.get());
if cl == 0 && cc == 0 {
return; }
self.edit_text(|text| {
let offset = line_col_to_byte_offset(text, cl, cc);
if offset == 0 {
return (text.to_string(), None);
}
let prev_boundary = text.floor_char_boundary(offset.saturating_sub(1));
let deleted = &text[prev_boundary..offset];
let mut new_text = text.to_string();
new_text.replace_range(prev_boundary..offset, "");
let (new_line, new_col) = if deleted == "\n" {
let new_cl = cl.saturating_sub(1);
let prev_line_text = self.line_char_len(new_cl);
(new_cl, prev_line_text)
} else {
(cl, cc.saturating_sub(1))
};
(new_text, Some((new_line, new_col)))
});
}
fn do_delete(&self) {
if self.delete_if_selected() {
return;
}
let (cl, cc) = (self.cursor_line.get(), self.cursor_col.get());
self.edit_text(|text| {
let offset = line_col_to_byte_offset(text, cl, cc);
if offset >= text.len() {
return (text.to_string(), None);
}
let next_boundary = text[offset..]
.chars()
.next()
.map_or(offset, |c| offset + c.len_utf8());
let mut new_text = text.to_string();
new_text.replace_range(offset..next_boundary, "");
(new_text, None) });
}
fn do_paste(&self, s: &str) {
let sel_range = self.delete_selection_get_range();
let s = s.to_string();
self.edit_text(move |text| {
let mut new_text = text.to_string();
let new_offset = if let Some((start, end)) = sel_range {
new_text.replace_range(start..end, &s);
start + s.len()
} else {
let offset =
line_col_to_byte_offset(text, self.cursor_line.get(), self.cursor_col.get());
new_text.insert_str(offset, &s);
offset + s.len()
};
let (line, col) = byte_offset_to_line_col(&new_text, new_offset);
(new_text, Some((line, col)))
});
}
fn do_indent(&self) {
if self.has_selection.get() {
let Some((sl, _sc, el, _ec)) = self.clamped_selection_range() else {
return;
};
self.edit_text(|text| {
let mut new_text = text.to_string();
for line_idx in sl..=el {
let offset = line_col_to_byte_offset(&new_text, line_idx, 0);
new_text.insert(offset, '\t');
}
(new_text, None)
});
let cl = self.cursor_line.get();
let cc = self.cursor_col.get();
if (sl..=el).contains(&cl) && cc > 0 {
self.cursor_col.set(cc + 1);
}
if self.has_selection.get() {
let anchor_line = self.sel_line.get();
let anchor_col = self.sel_col.get();
if (sl..=el).contains(&anchor_line) && anchor_col > 0 {
self.sel_col.set(anchor_col + 1);
}
}
} else {
let offset = line_col_to_byte_offset(
&self.text(),
self.cursor_line.get(),
self.cursor_col.get(),
);
self.edit_text(|text| {
let mut new_text = text.to_string();
new_text.insert(offset, '\t');
(
new_text,
Some((self.cursor_line.get(), self.cursor_col.get() + 1)),
)
});
}
}
fn do_unindent(&self) {
if self.has_selection.get() {
let Some((sl, _sc, el, _ec)) = self.clamped_selection_range() else {
return;
};
let mut modified_lines: Vec<usize> = Vec::new();
self.edit_text(|text| {
let mut new_text = text.to_string();
for line_idx in (sl..=el).rev() {
let line_text = new_text.lines().nth(line_idx).unwrap_or("").to_string();
let leading_count = line_text
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
if leading_count == 0 {
continue;
}
modified_lines.push(line_idx);
let remove_count = 1.min(leading_count);
let remove_bytes = line_text[..remove_count].len();
let line_start = line_col_to_byte_offset(&new_text, line_idx, 0);
new_text.replace_range(line_start..line_start + remove_bytes, "");
}
(new_text, None)
});
let adjust_col = |line: usize, col: usize| {
if modified_lines.contains(&line) && col > 0 {
col - 1
} else {
col
}
};
let cl = self.cursor_line.get();
self.cursor_col.set(adjust_col(cl, self.cursor_col.get()));
if self.has_selection.get() {
let anchor_line = self.sel_line.get();
self.sel_col
.set(adjust_col(anchor_line, self.sel_col.get()));
}
} else {
let cl = self.cursor_line.get();
let line_text = self
.buffer
.borrow()
.lines
.get(cl)
.map(|l| l.text().to_string())
.unwrap_or_default();
let leading_spaces = line_text
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
if leading_spaces == 0 {
return;
}
let remove_count = 1.min(leading_spaces);
let remove_bytes = line_text[..remove_count].len();
self.edit_text(|text| {
let mut new_text = text.to_string();
let line_start = line_col_to_byte_offset(text, cl, 0);
new_text.replace_range(line_start..line_start + remove_bytes, "");
let new_col = self.cursor_col.get().saturating_sub(1);
(new_text, Some((cl, new_col)))
});
}
}
fn with_cursor_movement(
&self,
extend_selection: bool,
compute: impl FnOnce() -> Option<(usize, usize)>,
) {
if extend_selection {
let anchor = (self.cursor_line.get(), self.cursor_col.get());
if let Some((line, col)) = compute() {
if !self.has_selection.get() {
self.sel_line.set(anchor.0);
self.sel_col.set(anchor.1);
self.has_selection.set(true);
}
self.set_cursor_pos(line, col);
self.normalize_selection();
}
} else if let Some((line, col)) = compute() {
self.move_to(line, col);
}
}
fn do_move_left(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let (line, col) = (self.cursor_line.get(), self.cursor_col.get());
if col > 0 {
Some((line, col - 1))
} else if line > 0 {
let prev_line = line - 1;
let prev_len = self.line_char_len(prev_line);
Some((prev_line, prev_len))
} else {
None
}
});
}
fn do_move_right(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let (line, col) = (self.cursor_line.get(), self.cursor_col.get());
let max_line = self.line_count().saturating_sub(1);
let line_len = self.line_char_len(line);
if col < line_len {
Some((line, col + 1))
} else if line < max_line {
Some((line + 1, 0))
} else {
None
}
});
}
fn do_move_up(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let (line, col) = (self.cursor_line.get(), self.cursor_col.get());
if line > 0 {
Some((line - 1, col))
} else {
None
}
});
}
fn do_move_down(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let (line, col) = (self.cursor_line.get(), self.cursor_col.get());
let max_line = self.line_count().saturating_sub(1);
if line < max_line {
Some((line + 1, col))
} else {
None
}
});
}
fn do_move_home(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let line = self.cursor_line.get();
Some((line, 0))
});
}
fn do_move_end(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let line = self.cursor_line.get();
let line_len = self.line_char_len(line);
Some((line, line_len))
});
}
fn do_move_word_left(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let text = self.text();
let offset =
line_col_to_byte_offset(&text, self.cursor_line.get(), self.cursor_col.get());
if offset == 0 {
None
} else {
Some(byte_offset_to_line_col(
&text,
find_word_start(&text, offset),
))
}
});
}
fn do_move_word_right(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let text = self.text();
let offset =
line_col_to_byte_offset(&text, self.cursor_line.get(), self.cursor_col.get());
if offset >= text.len() {
None
} else {
Some(byte_offset_to_line_col(&text, find_word_end(&text, offset)))
}
});
}
fn do_move_doc_start(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || Some((0, 0)));
}
fn do_move_doc_end(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let max_line = self.line_count().saturating_sub(1);
let line_len = self.line_char_len(max_line);
Some((max_line, line_len))
});
}
fn do_move_page_up(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let line = self.cursor_line.get();
let col = self.cursor_col.get();
let page_lines = PAGE_SCROLL_LINES;
Some(if line > page_lines {
(line - page_lines, col)
} else {
(0, col)
})
});
}
fn do_move_page_down(&self, extend_selection: bool) {
self.with_cursor_movement(extend_selection, || {
let line = self.cursor_line.get();
let max_line = self.line_count().saturating_sub(1);
let col = self.cursor_col.get();
let page_lines = PAGE_SCROLL_LINES;
Some((line.saturating_add(page_lines).min(max_line), col))
});
}
fn normalize_selection(&self) {
if self.has_selection.get()
&& self.sel_line.get() == self.cursor_line.get()
&& self.sel_col.get() == self.cursor_col.get()
{
self.has_selection.set(false);
}
}
fn do_delete_word_back(&self) {
if self.delete_if_selected() {
return;
}
if self.cursor_line.get() == 0 && self.cursor_col.get() == 0 {
return;
}
let (cl, cc) = (self.cursor_line.get(), self.cursor_col.get());
self.edit_text(|text| {
let offset = line_col_to_byte_offset(text, cl, cc);
let word_start = find_word_start(text, offset);
let mut new_text = text.to_string();
new_text.replace_range(word_start..offset, "");
let (line, col) = byte_offset_to_line_col(&new_text, word_start);
(new_text, Some((line, col)))
});
}
fn do_delete_word_forward(&self) {
if self.delete_if_selected() {
return;
}
let (cl, cc) = (self.cursor_line.get(), self.cursor_col.get());
self.edit_text(|text| {
let offset = line_col_to_byte_offset(text, cl, cc);
if offset >= text.len() {
return (text.to_string(), None);
}
let word_end = find_word_end(text, offset);
let mut new_text = text.to_string();
new_text.replace_range(offset..word_end, "");
(new_text, None)
});
}
fn do_toggle_line_comment(&self) {
let ext = self.file_extension();
let Some(prefix) = line_comment_prefix(self.language, ext.as_deref()) else {
return; };
let Some((start_line, end_line)) = self.selected_line_range() else {
return;
};
let text = self.text();
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
let mut first_toggled_col = None;
for line_idx in start_line..=end_line {
let Some((ls, le)) = line_byte_range(&text, line_idx) else {
continue;
};
let line_slice = &text[ls..le];
let (body, ending) = split_line_body_and_ending(line_slice);
let trimmed = body.trim_start();
let leading_ws_len = body.len() - trimmed.len();
let leading_ws = &body[..leading_ws_len];
let (new_body, toggled_col) = if let Some(stripped) = trimmed.strip_prefix(prefix) {
let after_comment = stripped.strip_prefix(' ').unwrap_or(stripped);
(format!("{leading_ws}{after_comment}"), Some(leading_ws_len))
} else {
(
format!("{leading_ws}{prefix} {trimmed}"),
Some(leading_ws_len + prefix.len() + 1),
)
};
if line_idx == start_line {
first_toggled_col = toggled_col;
}
replacements.push((ls, le, format!("{new_body}{ending}")));
}
let target_line = start_line;
let target_col = first_toggled_col.unwrap_or(0);
self.edit_text(|text| {
let mut new_text = text.to_string();
for (ls, le, replacement) in replacements.into_iter().rev() {
new_text.replace_range(ls..le, &replacement);
}
(new_text, Some((target_line, target_col)))
});
}
fn do_jump_to_matching_bracket(&self) {
let text = self.text();
let (cl, cc) = (self.cursor_line.get(), self.cursor_col.get());
if let Some(pair) = find_matching_bracket(&text, cl, cc) {
let ((ol, oc), (close_l, close_c)) = pair;
let at_open = cl == ol && (cc == oc || cc == oc + 1 || (cc > 0 && cc - 1 == oc));
let at_close = cl == close_l && cc == close_c;
if at_open {
self.move_to(close_l, close_c);
} else if at_close {
self.move_to(ol, oc + 1);
}
else {
let open_dist = cl.abs_diff(ol) + cc.abs_diff(oc);
let close_dist = cl.abs_diff(close_l) + cc.abs_diff(close_c);
if open_dist <= close_dist {
self.move_to(close_l, close_c);
} else {
self.move_to(ol, oc + 1);
}
}
}
}
fn do_delete_line(&self) {
let line_count = self.line_count();
if line_count == 0 {
return;
}
let Some((start_line, end_line)) = self.selected_line_range() else {
return;
};
self.edit_text(|text| {
let mut new_text = text.to_string();
let start_off = line_col_to_byte_offset(text, start_line, 0);
let end_off = if end_line + 1 < line_count {
line_col_to_byte_offset(text, end_line + 1, 0)
} else {
text.len()
};
let adjusted_end = if end_line + 1 >= line_count && start_line > 0 {
let prev_line_end = line_col_to_byte_offset(text, start_line, 0).saturating_sub(1);
if prev_line_end > 0
&& text.as_bytes().get(prev_line_end) == Some(&b'\n')
&& text.as_bytes().get(prev_line_end.saturating_sub(1)) == Some(&b'\r')
{
start_off - 2
} else if text.as_bytes().get(prev_line_end) == Some(&b'\n') {
start_off.saturating_sub(1)
} else {
start_off
}
} else {
start_off
};
if line_count == 1 {
new_text.clear();
return (new_text, Some((0, 0)));
}
new_text.replace_range(adjusted_end..end_off, "");
let new_line_count = new_text.lines().count().max(1);
let target_line = start_line.min(new_line_count.saturating_sub(1));
(new_text, Some((target_line, 0)))
});
}
fn do_duplicate_line(&self) {
let line_count = self.line_count();
if line_count == 0 {
return;
}
let Some((start_line, end_line)) = self.selected_line_range() else {
return;
};
self.edit_text(|text| {
let mut new_text = text.to_string();
let line_ending = detect_line_ending(text).as_str();
let duplicated: String = if end_line + 1 < line_count {
let start_off = line_col_to_byte_offset(text, start_line, 0);
let end_off = line_col_to_byte_offset(text, end_line + 1, 0);
text[start_off..end_off].to_string()
} else {
let start_off = line_col_to_byte_offset(text, start_line, 0);
let dup_text = text[start_off..].to_string();
if !text.ends_with(line_ending) {
format!("{line_ending}{dup_text}")
} else {
dup_text
}
};
let insert_off = if end_line + 1 < line_count {
line_col_to_byte_offset(text, end_line + 1, 0)
} else {
text.len()
};
new_text.insert_str(insert_off, &duplicated);
let target_line = end_line + 1;
(new_text, Some((target_line, 0)))
});
}
fn do_move_line(&self, up: bool) {
let line_count = self.line_count();
if line_count <= 1 {
return;
}
let Some((start_line, end_line)) = self.selected_line_range() else {
return;
};
let (swap_line, insert_at) = if up {
if start_line == 0 {
return; }
let above = start_line.saturating_sub(1);
(above, above)
} else {
if end_line + 1 >= line_count {
return; }
(end_line + 1, start_line + 1)
};
self.edit_text(|text| {
let default_ending = detect_line_ending(text);
let had_trailing = has_trailing_newline(text);
let mut lines = logical_lines(text);
if swap_line >= lines.len() || end_line >= lines.len() {
return (text.to_string(), None);
}
if start_line == end_line {
swap_lines_with_endings(&mut lines, start_line, swap_line);
} else {
let block: Vec<_> = lines.drain(start_line..=end_line).collect();
lines.splice(insert_at..insert_at, block);
}
fix_line_endings(&mut lines, had_trailing, default_ending);
(reassemble_lines(&lines), Some((insert_at, 0)))
});
}
}
impl super::common::UndoableText for EditorBuffer {
fn text(&self) -> String {
EditorBuffer::text(self)
}
fn cursor(&self) -> text_editor::Cursor {
let c = EditorBuffer::cursor(self);
text_editor::Cursor {
position: text_editor::Position {
line: c.line,
column: c.column,
},
selection: None,
}
}
}
const BRACKET_SCAN_LIMIT: usize = 20_000;
pub type BracketPair = ((usize, usize), (usize, usize));
#[must_use]
pub fn find_matching_bracket(
text: &str,
cursor_line: usize,
cursor_col: usize,
) -> Option<BracketPair> {
let offset = line_col_to_byte_offset(text, cursor_line, cursor_col);
let bytes = text.as_bytes();
if offset > 0 {
let c = bytes[offset - 1];
let (open, close) = match c {
b'(' => (b'(', b')'),
b'[' => (b'[', b']'),
b'{' => (b'{', b'}'),
_ => (0, 0),
};
if open != 0 {
let mut depth = 1u32;
let search_end = (offset + BRACKET_SCAN_LIMIT).min(bytes.len());
for (i, &b) in bytes[offset..search_end].iter().enumerate() {
let abs_i = offset + i;
if b == open {
depth += 1;
} else if b == close {
depth -= 1;
if depth == 0 {
let (line, col) = byte_offset_to_line_col(text, abs_i);
return Some(((cursor_line, cursor_col.saturating_sub(1)), (line, col)));
}
}
}
return None;
}
}
if let Some(&c) = bytes.get(offset) {
let (open, close) = match c {
b')' => (b'(', b')'),
b']' => (b'[', b']'),
b'}' => (b'{', b'}'),
_ => return None,
};
let mut depth = 1u32;
let search_start = offset.saturating_sub(BRACKET_SCAN_LIMIT);
for (rev_i, &b) in bytes[search_start..offset].iter().rev().enumerate() {
let abs_i = offset - 1 - rev_i;
if b == close {
depth += 1;
} else if b == open {
depth -= 1;
if depth == 0 {
let (line, col) = byte_offset_to_line_col(text, abs_i);
return Some(((line, col), (cursor_line, cursor_col)));
}
}
}
}
None
}
#[must_use]
pub fn line_comment_prefix(
lang: Option<HighlightLanguage>,
ext: Option<&str>,
) -> Option<&'static str> {
if let Some(lang) = lang {
return match lang {
HighlightLanguage::Rust
| HighlightLanguage::JavaScript
| HighlightLanguage::TypeScript
| HighlightLanguage::TSX
| HighlightLanguage::Go
| HighlightLanguage::C
| HighlightLanguage::Css => Some("//"),
HighlightLanguage::Python
| HighlightLanguage::Ruby
| HighlightLanguage::Bash
| HighlightLanguage::Toml => Some("#"),
HighlightLanguage::Sql => Some("--"),
HighlightLanguage::Json | HighlightLanguage::Html | HighlightLanguage::Markdown => {
return None;
}
};
}
if let Some(ext) = ext {
return match ext {
"yaml" | "yml" | "dockerfile" | "makefile" | "mak" | "cmake" => Some("#"),
_ => None,
};
}
None
}
fn build_rich_spans<'a>(
text: &'a str,
highlights: &FileHighlights,
base_attrs: &cosmic_text::Attrs<'a>,
) -> Vec<(&'a str, cosmic_text::Attrs<'a>)> {
let mut spans = Vec::new();
let mut byte_pos = 0usize;
for line_spans in &highlights.spans {
let line_start = byte_pos;
let line_end = text[byte_pos..]
.find('\n')
.map_or(text.len(), |nl| byte_pos + nl + 1);
for span in line_spans {
let s = line_start + span.start;
let e = (line_start + span.end).min(line_end);
if e > s {
let color = iced_color_to_cosmic(span.highlight_class.color());
spans.push((s, e, base_attrs.clone().color(color)));
}
}
byte_pos = line_end;
}
fill_rich_spans(text, spans, base_attrs)
}
fn buffer_text(buffer: &cosmic_text::Buffer) -> String {
let mut result = String::with_capacity(buffer.lines.iter().map(|l| l.text().len() + 1).sum());
for (i, line) in buffer.lines.iter().enumerate() {
if i > 0 {
result.push('\n');
}
result.push_str(line.text());
}
result
}
fn line_col_to_byte_offset(text: &str, line: usize, col: usize) -> usize {
let mut current_line = 0;
let mut byte_offset = 0;
for c in text.chars() {
if current_line == line {
break;
}
if c == '\n' {
current_line += 1;
}
byte_offset += c.len_utf8();
}
for (current_col, c) in text[byte_offset..].chars().enumerate() {
if current_col == col || c == '\n' {
break;
}
byte_offset += c.len_utf8();
}
byte_offset
}
fn line_byte_range(text: &str, line_idx: usize) -> Option<(usize, usize)> {
if text.is_empty() {
return (line_idx == 0).then_some((0, 0));
}
let mut current = 0usize;
let mut start = 0usize;
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
if current == line_idx {
return Some((start, i + 1));
}
current += 1;
start = i + 1;
}
}
if current == line_idx {
return Some((start, text.len()));
}
None
}
fn split_line_body_and_ending(line: &str) -> (&str, &str) {
if let Some(body) = line.strip_suffix("\r\n") {
(body, "\r\n")
} else if let Some(body) = line.strip_suffix('\n') {
(body, "\n")
} else {
(line, "")
}
}
fn logical_lines(text: &str) -> Vec<(String, String)> {
let mut lines = Vec::new();
let mut idx = 0;
while let Some((ls, le)) = line_byte_range(text, idx) {
let slice = &text[ls..le];
let (body, ending) = split_line_body_and_ending(slice);
lines.push((body.to_string(), ending.to_string()));
idx += 1;
}
if lines.is_empty() {
lines.push((String::new(), String::new()));
}
lines
}
fn reassemble_lines(lines: &[(String, String)]) -> String {
let mut out = String::new();
for (body, ending) in lines {
out.push_str(body);
out.push_str(ending);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LineEnding {
Lf,
Crlf,
}
impl LineEnding {
#[must_use]
pub(crate) fn as_str(self) -> &'static str {
match self {
LineEnding::Lf => "\n",
LineEnding::Crlf => "\r\n",
}
}
}
#[must_use]
pub(crate) fn has_trailing_newline(text: &str) -> bool {
text.ends_with('\n')
}
#[must_use]
pub(crate) fn detect_line_ending(text: &str) -> LineEnding {
let bytes = text.as_bytes();
let limit = bytes.len().min(65536);
let has_crlf = bytes[..limit].windows(2).any(|w| w == b"\r\n");
if has_crlf {
LineEnding::Crlf
} else {
LineEnding::Lf
}
}
fn swap_lines_with_endings(lines: &mut [(String, String)], i: usize, j: usize) {
let end_i = lines[i].1.clone();
let end_j = lines[j].1.clone();
lines.swap(i, j);
lines[i].1 = end_i;
lines[j].1 = end_j;
}
fn fix_line_endings(
lines: &mut [(String, String)],
had_trailing: bool,
default_ending: LineEnding,
) {
if lines.is_empty() {
return;
}
let default_str = default_ending.as_str();
let last_idx = lines.len() - 1;
for line in &mut lines[..last_idx] {
if line.1.is_empty() {
line.1 = default_str.to_string();
}
}
if had_trailing {
if lines[last_idx].1.is_empty() {
lines[last_idx].1 = default_str.to_string();
}
} else {
lines[last_idx].1.clear();
}
}
pub(crate) fn byte_offset_to_line_col(text: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(text.len());
let (line, line_start) = byte_line_and_start(text, offset);
(line, text[line_start..offset].chars().count())
}
pub(crate) fn char_col_to_byte_offset_in_line(line_text: &str, char_col: usize) -> usize {
line_text.chars().take(char_col).map(char::len_utf8).sum()
}
pub(crate) fn byte_col_to_char_col_in_line(line_text: &str, byte_col: usize) -> usize {
line_text[..byte_col.min(line_text.len())].chars().count()
}
pub(crate) fn char_col_to_byte_range_in_line(line_text: &str, char_col: usize) -> (usize, usize) {
let start = char_col_to_byte_offset_in_line(line_text, char_col);
let end = line_text[start..]
.chars()
.next()
.map_or(start, |c| start + c.len_utf8());
(start, end)
}
fn find_word_start(text: &str, offset: usize) -> usize {
let offset = offset.min(text.len());
let mut pos = offset;
while pos > 0 {
let c = text[..pos].chars().last();
match c {
Some(ch) if !is_word_char(ch) && !ch.is_whitespace() => {
pos -= ch.len_utf8();
}
Some(ch) if ch.is_whitespace() => {
pos -= ch.len_utf8();
}
_ => break,
}
}
while pos > 0 {
let c = text[..pos].chars().last();
match c {
Some(ch) if is_word_char(ch) => {
pos -= ch.len_utf8();
}
_ => break,
}
}
pos
}
fn find_word_end(text: &str, offset: usize) -> usize {
let len = text.len();
if offset >= len {
return len;
}
let mut pos = offset;
let char_at = |p: usize| text[p..].chars().next().map(|c| (c, c.len_utf8()));
while let Some((ch, ch_len)) = char_at(pos) {
if !is_word_char(ch) {
break;
}
pos += ch_len;
if pos >= len {
return len;
}
}
while let Some((ch, ch_len)) = char_at(pos) {
if is_word_char(ch) || ch.is_whitespace() {
break;
}
pos += ch_len;
if pos >= len {
return len;
}
}
while let Some((ch, ch_len)) = char_at(pos) {
if !ch.is_whitespace() {
break;
}
pos += ch_len;
if pos >= len {
return len;
}
}
pos
}
fn word_bounds_at(text: &str, byte_offset: usize) -> (usize, usize) {
let len = text.len();
if byte_offset >= len {
return (len, len);
}
let first_char = text[byte_offset..].chars().next().unwrap();
if first_char == '\n' {
return (byte_offset, byte_offset);
}
if is_word_char(first_char) {
let mut start = byte_offset;
loop {
if start == 0 {
break;
}
let c = text[..start].chars().last().unwrap();
if c == '\n' || !is_word_char(c) {
break;
}
start -= c.len_utf8();
}
let mut end = byte_offset + first_char.len_utf8();
while end < len {
let c = text[end..].chars().next().unwrap();
if c == '\n' || !is_word_char(c) {
break;
}
end += c.len_utf8();
}
(start, end)
} else if first_char.is_whitespace() {
(byte_offset, byte_offset)
} else {
(byte_offset, byte_offset + first_char.len_utf8())
}
}
use std::sync::Arc;
use iced::advanced::graphics::text::{self as graphics_text};
use iced::advanced::layout::{self, Layout};
use iced::advanced::mouse;
use iced::advanced::renderer;
use iced::advanced::widget::{self, Tree, Widget};
use iced::advanced::{Shell, graphics};
use iced::keyboard::{self, key};
use iced::window;
use iced::{Event, Length, Point, Rectangle, Size};
use std::time::Duration;
use super::theme;
fn hit_test(
buffer: &EditorBuffer,
layout: Layout<'_>,
cursor: mouse::Cursor,
gutter_width: f32,
padding: f32,
) -> Option<(usize, usize)> {
let (buf_x, buf_y) = cursor_to_buffer_coords(layout, cursor, gutter_width, padding)?;
let buf = buffer.borrow_buffer();
let hit = buf.hit(buf_x, buf_y)?;
let line_text = buf.lines.get(hit.line).map_or("", |l| l.text());
let col = byte_col_to_char_col_in_line(line_text, hit.index);
Some((hit.line, col))
}
fn find_cursor_run<'a>(
runs: impl Iterator<Item = cosmic_text::LayoutRun<'a>>,
cursor_line: usize,
cursor_col: usize,
) -> Option<cosmic_text::LayoutRun<'a>> {
let mut last_for_line: Option<cosmic_text::LayoutRun<'a>> = None;
for run in runs {
if run.line_i != cursor_line {
if last_for_line.is_some() {
break; }
continue;
}
if let (Some(first), Some(last)) = (run.glyphs.first(), run.glyphs.last()) {
let first_char = byte_col_to_char_col_in_line(run.text, first.start);
let last_char = byte_col_to_char_col_in_line(run.text, last.end);
if cursor_col >= first_char && cursor_col <= last_char {
return Some(run);
}
}
last_for_line = Some(run);
}
last_for_line
}
struct EditorWidgetState {
buffer_for_render: Option<Arc<cosmic_text::Buffer>>,
last_blink: std::time::Instant,
scroll_y: f32,
max_scroll_y: f32,
gutter_width: f32,
mouse_held: bool,
auto_scroll_enabled: bool,
last_click_time: Option<std::time::Instant>,
last_click_pos: Option<(usize, usize)>,
ime_commit_suppress: Option<String>,
last_buffer_key: Option<String>,
modifiers: keyboard::Modifiers,
}
impl Default for EditorWidgetState {
fn default() -> Self {
Self {
buffer_for_render: None,
last_blink: std::time::Instant::now(),
scroll_y: 0.0,
max_scroll_y: 0.0,
gutter_width: 0.0,
mouse_held: false,
auto_scroll_enabled: true,
last_click_time: None,
last_click_pos: None,
ime_commit_suppress: None,
last_buffer_key: None,
modifiers: keyboard::Modifiers::empty(),
}
}
}
pub struct EditorWidget<'a> {
buffer: &'a EditorBuffer,
padding: f32,
ignore_keyboard: bool,
matches: Option<Vec<(usize, usize, usize)>>,
match_current_idx: usize,
bracket_pair: Option<((usize, usize), (usize, usize))>,
buffer_key: Option<&'a str>,
}
impl<'a> EditorWidget<'a> {
pub const fn new(buffer: &'a EditorBuffer) -> Self {
Self {
buffer,
padding: 8.0,
ignore_keyboard: false,
matches: None,
match_current_idx: 0,
bracket_pair: None,
buffer_key: None,
}
}
#[must_use]
pub const fn padding(mut self, padding: f32) -> Self {
self.padding = padding;
self
}
#[must_use]
pub const fn ignore_keyboard(mut self, ignore: bool) -> Self {
self.ignore_keyboard = ignore;
self
}
#[must_use]
pub fn matches(mut self, matches: Vec<(usize, usize, usize)>, current_idx: usize) -> Self {
self.matches = if matches.is_empty() {
None
} else {
Some(matches)
};
self.match_current_idx = current_idx;
self
}
#[must_use]
pub const fn bracket_pair(mut self, pair: Option<((usize, usize), (usize, usize))>) -> Self {
self.bracket_pair = pair;
self
}
#[must_use]
pub const fn buffer_key(mut self, key: Option<&'a str>) -> Self {
self.buffer_key = key;
self
}
}
impl<Theme, Renderer> Widget<EditorAction, Theme, Renderer> for EditorWidget<'_>
where
Renderer: iced::advanced::Renderer + graphics::text::Renderer + iced::advanced::text::Renderer,
{
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn state(&self) -> widget::tree::State {
widget::tree::State::Some(Box::new(EditorWidgetState::default()))
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<EditorWidgetState>()
}
#[expect(clippy::cast_precision_loss)]
fn layout(
&mut self,
tree: &mut Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let bounds = limits.max();
let state = tree.state.downcast_mut::<EditorWidgetState>();
let current_key = self.buffer_key.map(str::to_string);
if state.last_buffer_key != current_key {
state.scroll_y = 0.0;
state.auto_scroll_enabled = true;
state.mouse_held = false;
state.last_click_time = None;
state.last_click_pos = None;
state.last_buffer_key = current_key;
}
let line_count = self.buffer.line_count();
let gutter_width = {
let digits = (line_count.max(1).ilog10() + 1).min(6) as f32;
digits * 5.0 + 10.0
};
state.gutter_width = gutter_width;
let text_rect = text_area_rect(
Rectangle::new(Point::ORIGIN, bounds),
self.padding,
gutter_width,
);
let text_area_width = text_rect.width;
let text_area_height = text_rect.height;
let mut guard = graphics_text::font_system().write().unwrap_poison();
let font_sys = guard.raw();
let mut buffer = self.buffer.borrow_buffer_mut();
reshape_and_shape(
&mut buffer,
font_sys,
Some(state.scroll_y),
text_area_width,
text_area_height,
);
let cursor = self.buffer.cursor();
let old_scroll_y = state.scroll_y;
let metrics = font_metrics();
if state.auto_scroll_enabled {
let mut cursor_in_view = false;
if let Some(run) = find_cursor_run(buffer.layout_runs(), cursor.line, cursor.column) {
let cursor_top = run.line_top;
let cursor_bottom = run.line_top + run.line_height;
if cursor_top < 0.0 {
state.scroll_y = (state.scroll_y + cursor_top).max(0.0);
}
if cursor_bottom > text_area_height {
state.scroll_y =
(state.scroll_y + cursor_bottom - text_area_height).min(state.max_scroll_y);
}
cursor_in_view = true;
}
if !cursor_in_view {
let est_y = cursor.line as f32 * metrics.line_height;
if est_y < state.scroll_y {
state.scroll_y = est_y;
} else if est_y >= state.scroll_y + text_area_height {
state.scroll_y = (est_y - text_area_height + metrics.line_height).max(0.0);
}
}
}
let total_height = compute_total_height(&mut buffer, font_sys, metrics);
state.max_scroll_y = (total_height - text_area_height).max(0.0);
state.scroll_y = state.scroll_y.clamp(0.0, state.max_scroll_y);
if (state.scroll_y - old_scroll_y).abs() > f32::EPSILON {
buffer.set_scroll(Scroll {
line: 0,
vertical: state.scroll_y,
horizontal: 0.0,
});
buffer.shape_until_scroll(font_sys, false);
}
let arc = Arc::new(buffer.clone());
state.buffer_for_render = Some(arc);
drop(buffer);
drop(guard);
layout::Node::new(bounds)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_ref::<EditorWidgetState>();
let bounds = layout.bounds();
let gutter_width = state.gutter_width;
let text_rect = text_area_rect(bounds, self.padding, gutter_width);
let text_x = text_rect.x;
let text_y = text_rect.y;
let text_area_width = text_rect.width;
let text_area_height = text_rect.height;
let buffer_for_draw = state.buffer_for_render.clone().unwrap_or_else(|| {
with_font_system(|font_sys| {
let mut buffer = self.buffer.borrow_buffer_mut();
reshape_and_shape(
&mut buffer,
font_sys,
None,
text_area_width,
text_area_height,
);
Arc::new(buffer.clone())
})
});
let text_geo = TextGeometry {
clip: text_rect,
x: text_x,
y: text_y,
};
draw_background(renderer, bounds);
draw_line_numbers(
renderer,
&buffer_for_draw,
bounds,
self.padding,
text_y,
gutter_width,
text_area_height,
);
draw_find_match_highlights(
renderer,
&buffer_for_draw,
&text_geo,
self.matches.as_ref(),
self.match_current_idx,
);
draw_bracket_match_highlights(renderer, &buffer_for_draw, &text_geo, self.bracket_pair);
draw_selection(renderer, &buffer_for_draw, &text_geo, self.buffer);
draw_buffer_text(
renderer,
&buffer_for_draw,
Point::new(text_geo.x, text_geo.y),
text_geo.clip,
);
draw_cursor(renderer, &buffer_for_draw, &text_geo, state, self.buffer);
}
#[expect(clippy::too_many_lines)]
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn iced::advanced::Clipboard,
shell: &mut Shell<'_, EditorAction>,
_viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<EditorWidgetState>();
match event {
Event::Mouse(iced::mouse::Event::WheelScrolled { delta }) => {
if cursor.position_in(layout.bounds()).is_none() {
return;
}
let line_height = font_metrics().line_height;
let pixel_delta = match delta {
ScrollDelta::Lines { y, .. } => y * line_height,
ScrollDelta::Pixels { y, .. } => *y,
};
state.scroll_y = (state.scroll_y - pixel_delta).clamp(0.0, state.max_scroll_y);
state.auto_scroll_enabled = false;
shell.invalidate_layout();
shell.request_redraw();
}
Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) => {
{
let bounds = layout.bounds();
let text_rect = text_area_rect(bounds, self.padding, state.gutter_width);
let text_area_width = text_rect.width;
let text_area_height = text_rect.height;
let scroll_y = state.scroll_y;
with_font_system(|font_sys| {
let mut buffer = self.buffer.borrow_buffer_mut();
reshape_and_shape(
&mut buffer,
font_sys,
Some(scroll_y),
text_area_width,
text_area_height,
);
});
}
if let Some((line, col)) = hit_test(
self.buffer,
layout,
cursor,
state.gutter_width,
self.padding,
) {
state.mouse_held = true;
state.last_blink = std::time::Instant::now();
state.auto_scroll_enabled = true;
let now = state.last_blink;
let is_double_click = match (state.last_click_time, state.last_click_pos) {
(Some(last_time), Some((last_line, last_col))) => {
now.duration_since(last_time).as_millis() < 500
&& line == last_line
&& col.abs_diff(last_col) <= 2
}
_ => false,
};
state.last_click_time = Some(now);
state.last_click_pos = Some((line, col));
if is_double_click {
if state.modifiers.shift() {
let text_buf = self.buffer.text();
let byte_offset = line_col_to_byte_offset(&text_buf, line, col);
let (word_start, word_end) = word_bounds_at(&text_buf, byte_offset);
if word_start != word_end {
let (start_line, start_col) =
byte_offset_to_line_col(&text_buf, word_start);
let (end_line, end_col) =
byte_offset_to_line_col(&text_buf, word_end);
let cur = self.buffer.cursor();
let anchor_byte = cur.selection.as_ref().map_or_else(
|| line_col_to_byte_offset(&text_buf, cur.line, cur.column),
|a| line_col_to_byte_offset(&text_buf, a.line, a.column),
);
if anchor_byte < word_start {
shell.publish(EditorAction::SelectTo {
line: end_line,
col: end_col,
});
} else if anchor_byte >= word_end {
shell.publish(EditorAction::SelectTo {
line: start_line,
col: start_col,
});
} else {
shell.publish(EditorAction::MoveTo {
line: start_line,
col: start_col,
});
shell.publish(EditorAction::SelectTo {
line: end_line,
col: end_col,
});
}
} else {
shell.publish(EditorAction::SelectTo { line, col });
}
} else {
shell.publish(EditorAction::SelectWordAt { line, col });
}
state.mouse_held = false;
} else if state.modifiers.shift() {
shell.publish(EditorAction::SelectTo { line, col });
} else {
shell.publish(EditorAction::MoveTo { line, col });
}
} else {
state.mouse_held = false;
state.last_click_time = None;
state.last_click_pos = None;
}
shell.request_redraw();
}
Event::Mouse(iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left)) => {
state.mouse_held = false;
let cursor_state = self.buffer.cursor();
if let Some(ref anchor) = cursor_state.selection {
if anchor.line == cursor_state.line && anchor.column == cursor_state.column {
shell.publish(EditorAction::MoveTo {
line: cursor_state.line,
col: cursor_state.column,
});
}
}
shell.request_redraw();
}
Event::Mouse(iced::mouse::Event::CursorMoved { .. }) if state.mouse_held => {
if let Some((line, col)) = hit_test(
self.buffer,
layout,
cursor,
state.gutter_width,
self.padding,
) {
shell.publish(EditorAction::SelectTo { line, col });
shell.request_redraw();
}
}
Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
state.modifiers = *modifiers;
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: key_press,
modifiers,
physical_key,
text,
..
}) => {
if self.ignore_keyboard {
return;
}
if is_cursor_movement_key(key_press) {
state.auto_scroll_enabled = true;
state.last_blink = std::time::Instant::now();
}
if super::detect_keyboard_mods(*modifiers).is_text_platform_mod() {
if let Some(latin) = key_press.to_latin(*physical_key) {
match latin {
'c' | 'x' => {
if let Some(text) = self.buffer.selection() {
clipboard
.write(iced::advanced::clipboard::Kind::Standard, text);
if latin == 'x' {
shell.publish(EditorAction::Delete);
}
}
return;
}
'v' => {
if let Some(text) =
clipboard.read(iced::advanced::clipboard::Kind::Standard)
{
shell.publish(EditorAction::Paste(text));
shell.invalidate_layout();
shell.request_redraw();
}
return;
}
_ => {}
}
}
}
{
let platform_mod =
super::detect_keyboard_mods(*modifiers).is_nav_platform_mod();
let alt = modifiers.alt();
let shift = modifiers.shift();
let is_arrow_up = matches!(key_press, key::Key::Named(key::Named::ArrowUp));
let is_arrow_down = matches!(key_press, key::Key::Named(key::Named::ArrowDown));
if (is_arrow_up || is_arrow_down) && !platform_mod && !alt {
let bounds = layout.bounds();
let text_rect = text_area_rect(bounds, self.padding, state.gutter_width);
let text_area_width = text_rect.width;
let text_area_height = text_rect.height;
let scroll_y = state.scroll_y;
let result = with_font_system(|font_sys| {
let mut buffer = self.buffer.borrow_buffer_mut();
reshape_and_shape(
&mut buffer,
font_sys,
Some(scroll_y),
text_area_width,
text_area_height,
);
let cursor = self.buffer.cursor();
let metrics = font_metrics();
let cursor_run =
find_cursor_run(buffer.layout_runs(), cursor.line, cursor.column);
if let Some(run) = cursor_run {
let cursor_x = run
.glyphs
.iter()
.find(|g| {
cursor.column
< byte_col_to_char_col_in_line(run.text, g.end)
})
.map_or_else(
|| run.glyphs.last().map_or(0.0, |last| last.x + last.w),
|g| g.x,
);
let target_y = if is_arrow_up {
run.line_top - 1.0
} else {
run.line_top + run.line_height + 1.0
};
buffer.hit(cursor_x, target_y).map(|hit| {
let line_text =
buffer.lines.get(hit.line).map_or("", |l| l.text());
let col = byte_col_to_char_col_in_line(line_text, hit.index);
(hit.line, col)
})
} else {
#[expect(clippy::cast_precision_loss)]
let est_y =
cursor.line as f32 * metrics.line_height - state.scroll_y;
let run_h = metrics.line_height;
let target_y = if is_arrow_up {
est_y - 1.0
} else {
est_y + run_h + 1.0
};
buffer.hit(0.0, target_y).map(|hit| {
let line_text =
buffer.lines.get(hit.line).map_or("", |l| l.text());
let col = byte_col_to_char_col_in_line(line_text, hit.index);
(hit.line, col)
})
}
});
if let Some((target_line, target_col)) = result {
publish_move_or_select(shell, shift, target_line, target_col);
return;
}
}
}
{
let platform_mod =
super::detect_keyboard_mods(*modifiers).is_nav_platform_mod();
let alt = modifiers.alt();
let shift = modifiers.shift();
let is_cmd_left = platform_mod
&& !alt
&& matches!(key_press, key::Key::Named(key::Named::ArrowLeft));
let is_cmd_right = platform_mod
&& !alt
&& matches!(key_press, key::Key::Named(key::Named::ArrowRight));
if is_cmd_left || is_cmd_right {
let bounds = layout.bounds();
let text_rect = text_area_rect(bounds, self.padding, state.gutter_width);
let text_area_width = text_rect.width;
let text_area_height = text_rect.height;
let scroll_y = state.scroll_y;
let result = with_font_system(|font_sys| {
let mut buffer = self.buffer.borrow_buffer_mut();
reshape_and_shape(
&mut buffer,
font_sys,
Some(scroll_y),
text_area_width,
text_area_height,
);
let cursor = self.buffer.cursor();
let cursor_run =
find_cursor_run(buffer.layout_runs(), cursor.line, cursor.column);
cursor_run.map(|run| {
let first = run.glyphs.first();
let last = run.glyphs.last();
let line_text =
buffer.lines.get(cursor.line).map_or("", |l| l.text());
let line_len = line_text.chars().count();
let visual_start: usize = first.map_or(0, |g| {
byte_col_to_char_col_in_line(line_text, g.start)
});
let visual_end: usize = last.map_or(line_len, |g| {
byte_col_to_char_col_in_line(line_text, g.end)
});
if is_cmd_left {
if cursor.column == visual_start {
(cursor.line, 0)
} else {
(cursor.line, visual_start)
}
} else {
if cursor.column == visual_end {
(cursor.line, line_len)
} else {
(cursor.line, visual_end)
}
}
})
});
if let Some((target_line, target_col)) = result {
publish_move_or_select(shell, shift, target_line, target_col);
return;
}
}
}
if !super::detect_keyboard_mods(*modifiers).is_text_platform_mod() {
if let Some(committed) = text {
if !committed.is_empty() {
if let Some(ref suppress) = state.ime_commit_suppress {
if committed.as_ref() == suppress {
state.ime_commit_suppress = None;
return;
}
state.ime_commit_suppress = None;
}
let committed: &str = committed.as_ref();
if committed.chars().count() == 1 {
let c = committed.chars().next().unwrap();
if !c.is_control() {
shell.publish(EditorAction::Insert(c));
shell.invalidate_layout();
shell.request_redraw();
return;
}
} else {
shell.publish(EditorAction::Paste(committed.to_string()));
shell.invalidate_layout();
shell.request_redraw();
return;
}
}
}
}
let action =
map_key_to_action(key_press, *modifiers, *physical_key, text.is_some());
if let Some(ref action) = action {
let is_cursor_move = action.is_cursor_movement();
if is_cursor_move {
state.auto_scroll_enabled = true;
state.last_blink = std::time::Instant::now();
}
shell.publish(action.clone());
shell.invalidate_layout();
shell.request_redraw();
}
}
Event::InputMethod(ime_event) => {
if self.ignore_keyboard {
return;
}
match ime_event {
input_method::Event::Commit(committed) => {
if committed.is_empty() {
return;
}
state.ime_commit_suppress = Some(committed.clone());
if committed.chars().count() == 1 {
let c = committed.chars().next().unwrap();
if !c.is_control() {
shell.publish(EditorAction::Insert(c));
}
} else {
shell.publish(EditorAction::Paste(committed.clone()));
}
shell.invalidate_layout();
shell.request_redraw();
}
input_method::Event::Preedit(_, _)
| input_method::Event::Opened
| input_method::Event::Closed => {}
}
}
Event::Window(window::Event::RedrawRequested(_)) => {
let now = std::time::Instant::now();
let elapsed_ms =
u64::try_from(now.duration_since(state.last_blink).as_millis()).unwrap_or(0);
let ms_into_cycle = elapsed_ms % 1000;
let ms_until_toggle = if ms_into_cycle < 500 {
500 - ms_into_cycle
} else {
1000 - ms_into_cycle
};
let next = now + Duration::from_millis(ms_until_toggle + 1);
shell.request_redraw_at(window::RedrawRequest::At(next));
shell.request_redraw();
}
_ => {}
}
}
fn mouse_interaction(
&self,
_tree: &Tree,
_layout: Layout<'_>,
_cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
mouse::Interaction::Text
}
}
fn publish_move_or_select(
shell: &mut Shell<'_, EditorAction>,
shift: bool,
line: usize,
col: usize,
) {
if shift {
shell.publish(EditorAction::SelectTo { line, col });
} else {
shell.publish(EditorAction::MoveTo { line, col });
}
shell.invalidate_layout();
shell.request_redraw();
}
struct TextGeometry {
clip: Rectangle,
x: f32,
y: f32,
}
fn draw_line_numbers<Renderer>(
renderer: &mut Renderer,
buffer: &cosmic_text::Buffer,
bounds: Rectangle,
padding: f32,
text_y: f32,
gutter_width: f32,
text_area_height: f32,
) where
Renderer: iced::advanced::text::Renderer,
{
let number_color = theme::TEXT_MUTED;
let number_clip = gutter_clip_rect(bounds, padding, gutter_width, text_area_height);
let mut last_line_i = usize::MAX;
for run in buffer.layout_runs() {
if run.line_i == last_line_i {
continue;
}
last_line_i = run.line_i;
let num = run.line_i + 1;
let num_str = num.to_string();
let num_text = iced::advanced::text::Text {
content: num_str,
bounds: Size::new(gutter_width, run.line_height),
size: iced::Pixels(GUTTER_FONT_SIZE),
line_height: iced::advanced::text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: iced::alignment::Horizontal::Right.into(),
align_y: iced::alignment::Vertical::Center,
shaping: iced::advanced::text::Shaping::Advanced,
wrapping: iced::advanced::text::Wrapping::None,
};
renderer.fill_text(
num_text,
Point::new(
bounds.x + padding + gutter_width,
text_y + run.line_top + run.line_height / 2.0,
),
number_color,
number_clip,
);
}
}
fn draw_find_match_highlights<Renderer>(
renderer: &mut Renderer,
buffer: &cosmic_text::Buffer,
geo: &TextGeometry,
matches: Option<&Vec<(usize, usize, usize)>>,
match_current_idx: usize,
) where
Renderer: iced::advanced::Renderer,
{
if let Some(matches) = matches {
for (i, &(match_line, col_start, col_end)) in matches.iter().enumerate() {
let color = if i == match_current_idx {
theme::FIND_MATCH_CURRENT
} else {
theme::FIND_MATCH_DIM
};
let filter = move |run: &cosmic_text::LayoutRun| {
(run.line_i == match_line)
.then_some(((match_line, col_start), (match_line, col_end)))
};
draw_run_highlights(
renderer, buffer, geo.clip, geo.x, geo.y, color, false, None, filter,
);
}
}
}
fn draw_bracket_match_highlights<Renderer>(
renderer: &mut Renderer,
buffer: &cosmic_text::Buffer,
geo: &TextGeometry,
bracket_pair: Option<((usize, usize), (usize, usize))>,
) where
Renderer: iced::advanced::Renderer,
{
if let Some(((open_line, open_col), (close_line, close_col))) = bracket_pair {
for &(b_line, b_col) in &[(open_line, open_col), (close_line, close_col)] {
let line_text = buffer.lines.get(b_line).map_or("", |l| l.text());
let (byte_start, byte_end) = char_col_to_byte_range_in_line(line_text, b_col);
let filter = move |run: &cosmic_text::LayoutRun| {
(run.line_i == b_line).then_some(((b_line, byte_start), (b_line, byte_end)))
};
draw_run_highlights(
renderer,
buffer,
geo.clip,
geo.x,
geo.y,
theme::BRACKET_MATCH,
true,
None,
filter,
);
}
}
}
fn draw_selection<Renderer>(
renderer: &mut Renderer,
buffer: &cosmic_text::Buffer,
geo: &TextGeometry,
editor_buffer: &EditorBuffer,
) where
Renderer: iced::advanced::Renderer,
{
let cursor_state = editor_buffer.cursor();
if let Some(anchor) = cursor_state.selection.as_ref() {
let start = (cursor_state.line, cursor_state.column);
let end = (anchor.line, anchor.column);
let (sel_start, sel_end) = if start < end {
(start, end)
} else {
(end, start)
};
let sel_start_byte = buffer.lines.get(sel_start.0).map_or(0, |l| {
char_col_to_byte_offset_in_line(l.text(), sel_start.1)
});
let sel_end_byte = buffer
.lines
.get(sel_end.0)
.map_or(0, |l| char_col_to_byte_offset_in_line(l.text(), sel_end.1));
let filter = move |_run: &cosmic_text::LayoutRun| {
Some(((sel_start.0, sel_start_byte), (sel_end.0, sel_end_byte)))
};
draw_run_highlights(
renderer,
buffer,
geo.clip,
geo.x,
geo.y,
theme::ACCENT_DIM,
false,
None,
filter,
);
}
}
fn draw_cursor<Renderer>(
renderer: &mut Renderer,
buffer: &cosmic_text::Buffer,
geo: &TextGeometry,
state: &EditorWidgetState,
editor_buffer: &EditorBuffer,
) where
Renderer: iced::advanced::Renderer,
{
let now = std::time::Instant::now();
let blink_on = now.duration_since(state.last_blink).as_millis() % 1000 < 500;
let cursor_state = editor_buffer.cursor();
let has_selection = cursor_state.selection.is_some();
if blink_on && !has_selection {
let cursor_x;
let cursor_y;
let cursor_height;
if let Some(run) =
find_cursor_run(buffer.layout_runs(), cursor_state.line, cursor_state.column)
{
cursor_y = geo.y + run.line_top;
cursor_height = run.line_height;
let found_x = run
.glyphs
.iter()
.find(|g| cursor_state.column < byte_col_to_char_col_in_line(run.text, g.end))
.map(|g| g.x);
cursor_x = geo.x
+ found_x.unwrap_or_else(|| run.glyphs.last().map_or(0.0, |last| last.x + last.w));
} else {
cursor_x = 0.0;
cursor_y = geo.y;
cursor_height = font_metrics().line_height;
}
let cursor_rect = Rectangle {
x: cursor_x,
y: cursor_y,
width: 1.5,
height: cursor_height,
};
if let Some(clipped) = geo.clip.intersection(&cursor_rect) {
renderer.fill_quad(
renderer::Quad {
bounds: clipped,
border: iced::Border::default(),
..renderer::Quad::default()
},
theme::TEXT_PRIMARY,
);
}
}
}
const fn is_cursor_movement_key(key: &key::Key) -> bool {
matches!(
key,
key::Key::Named(
key::Named::ArrowLeft
| key::Named::ArrowRight
| key::Named::ArrowUp
| key::Named::ArrowDown
| key::Named::Home
| key::Named::End
| key::Named::PageUp
| key::Named::PageDown
)
)
}
fn map_key_to_action(
key: &key::Key,
modifiers: keyboard::Modifiers,
physical_key: key::Physical,
_has_text: bool,
) -> Option<EditorAction> {
let platform_mod = super::detect_keyboard_mods(modifiers).is_nav_platform_mod();
let shift = modifiers.shift();
let alt = modifiers.alt();
#[cfg(not(target_os = "macos"))]
let altgr_active = alt && modifiers.control() && _has_text;
#[cfg(target_os = "macos")]
let altgr_active = false;
let mv = |dir, sel| {
Some(EditorAction::Move {
direction: dir,
select: sel,
})
};
match key {
key::Key::Named(named) => {
match (named, platform_mod, shift, alt) {
(key::Named::ArrowUp | key::Named::Home, true, s, false) => {
mv(CursorMove::DocStart, s)
}
(key::Named::ArrowDown | key::Named::End, true, s, false) => {
mv(CursorMove::DocEnd, s)
}
(key::Named::ArrowLeft, false, s, false) => mv(CursorMove::Left, s),
(key::Named::ArrowRight, false, s, false) => mv(CursorMove::Right, s),
(key::Named::ArrowUp, false, s, false) => mv(CursorMove::Up, s),
(key::Named::ArrowDown, false, s, false) => mv(CursorMove::Down, s),
(key::Named::Home, false, s, false) | (key::Named::ArrowLeft, true, s, false) => {
mv(CursorMove::Home, s)
}
(key::Named::End, false, s, false) | (key::Named::ArrowRight, true, s, false) => {
mv(CursorMove::End, s)
}
(key::Named::PageUp, false, s, false) => mv(CursorMove::PageUp, s),
(key::Named::PageDown, false, s, false) => mv(CursorMove::PageDown, s),
(key::Named::ArrowLeft, false, s, true) => mv(CursorMove::WordLeft, s),
(key::Named::ArrowRight, false, s, true) => mv(CursorMove::WordRight, s),
(key::Named::ArrowUp, false, false, true) => Some(EditorAction::MoveLineUp),
(key::Named::ArrowDown, false, false, true) => Some(EditorAction::MoveLineDown),
(key::Named::Backspace, false, false, false) => Some(EditorAction::Backspace),
(key::Named::Delete, false, false, false) => Some(EditorAction::Delete),
(key::Named::Backspace, true, false, false) => Some(EditorAction::DeleteWordBack),
(key::Named::Delete, true, false, false) => Some(EditorAction::DeleteWordForward),
(key::Named::Enter, false, _, false) => Some(EditorAction::Enter),
(key::Named::Space, false, false, false) => Some(EditorAction::Insert(' ')),
(key::Named::Tab, false, false, false) if !modifiers.control() => {
Some(EditorAction::Indent)
}
(key::Named::Tab, false, true, false) if !modifiers.control() => {
Some(EditorAction::Unindent)
}
_ => None,
}
}
key::Key::Unidentified => None,
key::Key::Character(ch) => {
let latin = key.to_latin(physical_key);
#[cfg(target_os = "macos")]
{
let ctrl = modifiers.control();
if ctrl && !modifiers.command() {
match latin {
Some('f') => return mv(CursorMove::Right, false),
Some('b') => return mv(CursorMove::Left, false),
Some('a') => return mv(CursorMove::Home, false),
Some('e') => return mv(CursorMove::End, false),
Some('h') => return Some(EditorAction::Backspace),
Some('d') => return Some(EditorAction::Delete),
Some('n') => return mv(CursorMove::Down, false),
Some('p') => return mv(CursorMove::Up, false),
_ => {}
}
}
}
if !altgr_active && latin == Some('a') && platform_mod && !shift {
return Some(EditorAction::SelectAll);
}
if !altgr_active && latin == Some('/') && platform_mod && !shift {
return Some(EditorAction::ToggleLineComment);
}
if !altgr_active && latin == Some('\\') && platform_mod && shift {
return Some(EditorAction::JumpToMatchingBracket);
}
if !altgr_active && latin == Some('k') && platform_mod && shift {
return Some(EditorAction::DeleteLine);
}
if !altgr_active && latin == Some('d') && platform_mod && shift {
return Some(EditorAction::DuplicateLine);
}
if !platform_mod && !modifiers.control() {
if let Some(c) = ch.chars().next() {
if !c.is_control() {
return Some(EditorAction::Insert(c));
}
}
}
None
}
}
}
#[cfg(test)]
#[path = "editor_widget_tests.rs"]
mod tests;