use super::buffer::{join_lines, split_lines, EditorModel};
use super::grid::{LineGrid, RowLayout};
use super::multicursor::{self, MultiCursor, OffsetEdit};
use super::pairs::{self, PairAction};
use super::range_command::TextRangeCommand;
use super::syntax::{BuiltinHighlighter, LanguageId, LineState, SyntaxHighlighter};
use super::types::{
CodeEditorConfig, CompletionSource, CompletionState, ContextMenuState, Cursor, CursorGoal,
DiagnosticMarker, DocumentCompletions, DocumentScale, EditRejection, EditorBuffer, FindState,
FoldRegion, InlineDiagnostic, MarkerSeverity, MenuItem, ReadOnlySpan, SearchMatch,
SearchOptions, SyntaxPalette, TextPosition, TokenKind, TokenSpan, ViewportSnapshot, VisualLine,
MAX_COMPLETIONS,
};
use crate::compat::MiniVec;
use crate::core::{Color, Font, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::signal::Signal1;
use crate::undo::{CommandDescription, CommandId, UndoCommand, UndoStack, ValueCommand};
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::text_utils::floor_char_boundary;
use crate::widget::{BaseWidget, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use alloc::boxed::Box;
use alloc::format;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use core::cell::RefCell;
use core::sync::atomic::{AtomicU64, Ordering};
mod caret;
mod clipboard;
mod completion;
mod diagnostics;
mod document;
mod editing;
mod events;
mod find;
mod folding;
mod rows;
mod tabs;
mod tokens;
mod viewport;
mod widget_impl;
pub(crate) type SharedModel = Rc<RefCell<EditorModel>>;
struct CaretEdit {
start: TextPosition,
end: TextPosition,
insert: String,
caret_in_insert: Option<usize>,
}
impl CaretEdit {
fn new(start: TextPosition, end: TextPosition, insert: impl Into<String>) -> Self {
Self { start, end, insert: insert.into(), caret_in_insert: None }
}
fn with_caret(mut self, index: usize) -> Self {
self.caret_in_insert = Some(index);
self
}
}
struct TabSwitchCommand {
editor: SharedModel,
before: usize,
after: usize,
}
impl TabSwitchCommand {
fn next_id() -> CommandId {
static NEXT: AtomicU64 = AtomicU64::new(0x434F_4445);
CommandId(NEXT.fetch_add(1, Ordering::Relaxed))
}
}
impl UndoCommand for TabSwitchCommand {
fn id(&self) -> CommandId {
Self::next_id()
}
fn description(&self) -> CommandDescription {
CommandDescription {
text: "Switch tab".to_string(),
timestamp_ms: 0,
command_type: "code_editor_tab",
}
}
fn execute(&mut self) -> Result<(), String> {
if let Ok(mut model) = self.editor.try_borrow_mut() {
model.apply_tab_switch(self.after);
}
Ok(())
}
fn undo(&mut self) -> Result<(), String> {
if let Ok(mut model) = self.editor.try_borrow_mut() {
model.apply_tab_switch(self.before);
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub(crate) struct OccurrenceCache {
pub(crate) bounds: (TextPosition, TextPosition),
pub(crate) revision: u64,
pub(crate) matches: Vec<SearchMatch>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BracketCache {
pub(crate) revision: u64,
pub(crate) caret: TextPosition,
pub(crate) pair: Option<(TextPosition, TextPosition)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct Preedit {
pub(crate) text: String,
pub(crate) cursor: usize,
}
pub struct CodeEditor {
pub(crate) base: BaseWidget,
pub(crate) model: SharedModel,
pub(crate) config: CodeEditorConfig,
highlighter: Option<Box<dyn SyntaxHighlighter>>,
language_highlighters: Vec<(LanguageId, Box<dyn SyntaxHighlighter>)>,
completion_source: Box<dyn CompletionSource>,
pub(crate) palette: SyntaxPalette,
pub(crate) cursor: Cursor,
extra_cursors: Vec<Cursor>,
goal: CursorGoal,
scroll_line: usize,
pub(crate) scroll_column: usize,
pub(crate) scroll_visual_row: usize,
pub(crate) visible_rows: usize,
pub(crate) measured_cell_width: core::cell::Cell<Option<f32>>,
pub(crate) measured_geometry: Option<Rect>,
pub(crate) find: FindState,
pub(crate) completion: CompletionState,
pub(crate) context_menu: ContextMenuState,
pub(crate) markers: Vec<DiagnosticMarker>,
clipboard_has_text: Option<bool>,
pub(crate) read_only_spans: Vec<ReadOnlySpan>,
folded_lines: MiniVec<usize>,
visual_row_prefix: Vec<usize>,
line_states: Vec<LineState>,
walked_lines: usize,
line_tokens: Vec<Option<Vec<TokenSpan>>>,
pub(crate) occurrence_cache: Option<OccurrenceCache>,
pub(crate) bracket_cache: RefCell<Option<BracketCache>>,
pub(crate) row_layouts: RefCell<Vec<(usize, RowLayout)>>,
pub(crate) row_layout_key: core::cell::Cell<(
u64,
u64,
u32,
usize,
u32,
u32,
crate::widget::special_widgets::code_editor::syntax::LanguageId,
u64,
)>,
pub(crate) layout_lexer_generation: u64,
pub(crate) minimap_buckets: Vec<MinimapBucket>,
minimap_line_count: usize,
pub(crate) scale: DocumentScale,
pub(crate) minimap_dragging: bool,
pub(crate) preedit: Option<Preedit>,
pub(crate) tab_hits: RefCell<Vec<super::render::TabSlot>>,
pub(crate) hovered_tab: Option<usize>,
pub(crate) mouse_selecting: bool,
pub(crate) last_pointer_position: Option<Point>,
pub(crate) caret_blink: crate::style::CursorBlink,
undo_stack: UndoStack,
restoring_history: bool,
last_checkpoint_bytes: usize,
revision: u64,
pub text_changed: Signal1<String>,
pub cursor_moved: Signal1<(usize, usize)>,
pub selection_changed: Signal1<Option<Rect>>,
pub tab_changed: Signal1<usize>,
pub search_changed: Signal1<usize>,
pub fold_changed: Signal1<usize>,
pub completion_changed: Signal1<bool>,
}
impl CodeEditor {
pub fn new(geometry: Rect) -> Self {
let config = CodeEditorConfig::default();
debug_assert!(config.validate().is_ok(), "default configuration must be valid");
Self::build(geometry, config)
}
pub fn with_config(geometry: Rect, config: CodeEditorConfig) -> Result<Self, &'static str> {
config.validate()?;
Ok(Self::build(geometry, config))
}
fn build(geometry: Rect, config: CodeEditorConfig) -> Self {
let mut editor = Self {
base: BaseWidget::new(WidgetKind::RichEdit, geometry, "CodeEditor"),
model: Rc::new(RefCell::new(EditorModel::new())),
config,
highlighter: None,
language_highlighters: Vec::new(),
completion_source: Box::new(DocumentCompletions),
palette: SyntaxPalette::for_active_appearance(),
cursor: Cursor::default(),
extra_cursors: Vec::new(),
goal: CursorGoal::default(),
scroll_line: 0,
scroll_column: 0,
scroll_visual_row: 0,
visible_rows: 0,
measured_cell_width: core::cell::Cell::new(None),
measured_geometry: None,
find: FindState::default(),
completion: CompletionState::default(),
context_menu: ContextMenuState::default(),
markers: Vec::new(),
clipboard_has_text: None,
read_only_spans: Vec::new(),
folded_lines: MiniVec::new(),
visual_row_prefix: Vec::new(),
line_states: Vec::new(),
line_tokens: Vec::new(),
occurrence_cache: None,
bracket_cache: RefCell::new(None),
row_layouts: RefCell::new(Vec::new()),
row_layout_key: core::cell::Cell::new((
0,
0,
0,
0,
0,
0,
crate::widget::special_widgets::code_editor::syntax::LanguageId::default(),
0,
)),
layout_lexer_generation: 0,
walked_lines: 0,
minimap_buckets: Vec::new(),
minimap_line_count: 0,
scale: DocumentScale::Full,
mouse_selecting: false,
minimap_dragging: false,
preedit: None,
tab_hits: RefCell::new(Vec::new()),
hovered_tab: None,
last_pointer_position: None,
caret_blink: crate::style::CursorBlink::new(),
undo_stack: UndoStack::new(),
restoring_history: false,
last_checkpoint_bytes: 0,
revision: 0,
text_changed: Signal1::new(),
cursor_moved: Signal1::new(),
selection_changed: Signal1::new(),
tab_changed: Signal1::new(),
search_changed: Signal1::new(),
fold_changed: Signal1::new(),
completion_changed: Signal1::new(),
};
if !editor.config.read_only {
editor.caret_blink.start();
}
editor.refresh_derived_state();
editor
}
pub fn config(&self) -> &CodeEditorConfig {
&self.config
}
pub fn is_read_only(&self) -> bool {
!self.is_editable()
}
pub fn set_read_only(&mut self, read_only: bool) {
self.config.read_only = read_only;
if read_only {
self.caret_blink.stop();
} else {
self.caret_blink.start();
}
self.base.request_redraw();
}
pub fn tick(&mut self, delta_ms: u32) -> bool {
if !self.is_editable() {
return false;
}
let running = self.caret_blink.tick(delta_ms);
if running {
self.base.request_redraw();
}
running
}
pub fn is_caret_visible(&self) -> bool {
self.caret_blink.is_visible()
}
pub fn tab_width(&self) -> usize {
self.config.tab_width
}
pub fn set_tab_width(&mut self, width: usize) {
if width == 0 || width > 16 {
return;
}
self.config.tab_width = width;
self.base.request_layout();
self.base.request_redraw();
}
pub fn font(&self) -> Font {
Font::simple(self.config.font_family.clone(), self.config.font_size)
}
pub fn palette(&self) -> &SyntaxPalette {
&self.palette
}
pub fn set_palette(&mut self, palette: SyntaxPalette) {
self.palette = palette;
self.base.request_redraw();
}
pub fn set_highlighter(&mut self, highlighter: Box<dyn SyntaxHighlighter>) {
self.highlighter = Some(highlighter);
self.invalidate_line_states();
self.base.request_redraw();
}
pub fn clear_highlighter(&mut self) {
self.highlighter = None;
self.invalidate_line_states();
self.base.request_redraw();
}
pub fn register_language_highlighter(
&mut self,
language: LanguageId,
highlighter: Box<dyn SyntaxHighlighter>,
) {
match self.language_highlighters.iter_mut().find(|(registered, _)| *registered == language)
{
Some(slot) => slot.1 = highlighter,
None => self.language_highlighters.push((language, highlighter)),
}
if self.active_language() == language {
self.invalidate_line_states();
}
self.base.request_redraw();
}
pub fn unregister_language_highlighter(&mut self, language: LanguageId) -> bool {
let before = self.language_highlighters.len();
self.language_highlighters.retain(|(registered, _)| *registered != language);
let removed = self.language_highlighters.len() != before;
if removed && self.active_language() == language {
self.invalidate_line_states();
self.base.request_redraw();
}
removed
}
pub fn registered_languages(&self) -> Vec<LanguageId> {
self.language_highlighters.iter().map(|(language, _)| *language).collect()
}
pub fn highlighter_name_for(&self, language: LanguageId) -> String {
if let Some((_, highlighter)) =
self.language_highlighters.iter().find(|(registered, _)| *registered == language)
{
return highlighter.language_name().to_string();
}
if let Some(highlighter) = &self.highlighter {
return highlighter.language_name().to_string();
}
language.name().to_string()
}
pub fn set_completion_source(&mut self, source: Box<dyn CompletionSource>) {
self.completion_source = source;
}
pub fn document_scale(&self) -> DocumentScale {
self.scale
}
pub fn is_editable(&self) -> bool {
!self.config.read_only && self.scale.allows_editing()
}
pub fn read_only_spans(&self) -> &[ReadOnlySpan] {
&self.read_only_spans
}
pub fn set_read_only_spans(&mut self, spans: Vec<ReadOnlySpan>) {
self.read_only_spans = spans.into_iter().filter(|span| !span.is_empty()).collect();
self.base.request_redraw();
}
pub fn add_read_only_span(&mut self, span: ReadOnlySpan) {
if !span.is_empty() {
self.read_only_spans.push(span);
self.base.request_redraw();
}
}
pub fn clear_read_only_spans(&mut self) {
if !self.read_only_spans.is_empty() {
self.read_only_spans.clear();
self.base.request_redraw();
}
}
pub fn is_position_editable(&self, position: TextPosition) -> bool {
self.is_editable() && !self.read_only_spans.iter().any(|span| span.contains(position))
}
pub fn is_range_editable(&self, start: TextPosition, end: TextPosition) -> bool {
self.is_editable() && !self.read_only_spans.iter().any(|span| span.overlaps(start, end))
}
pub fn shift_read_only_spans_after(&mut self, first_line: usize, lines_delta: isize) {
if self.read_only_spans.is_empty() || lines_delta == 0 {
return;
}
for span in &mut self.read_only_spans {
if span.start_line >= first_line {
span.start_line = shift_index(span.start_line, lines_delta);
}
if span.end_line >= first_line {
span.end_line = shift_index(span.end_line, lines_delta);
}
}
self.read_only_spans.retain(|span| !span.is_empty());
self.base.request_redraw();
}
fn shift_read_only_spans_for_edit(
&mut self,
first_line: usize,
lines_now: usize,
lines_before: usize,
) {
let delta = lines_now as isize - lines_before as isize;
if delta == 0 || self.read_only_spans.is_empty() {
return;
}
let edit_end = first_line + lines_before;
for span in &mut self.read_only_spans {
if span.start_line >= edit_end {
span.start_line = shift_index(span.start_line, delta);
span.end_line = shift_index(span.end_line, delta);
} else if span.end_line >= edit_end {
span.end_line = shift_index(span.end_line, delta);
}
}
self.read_only_spans.retain(|span| !span.is_empty());
}
pub fn language_name(&self) -> String {
self.highlighter_name_for(self.active_language())
}
pub fn active_language(&self) -> LanguageId {
self.model
.borrow()
.all_buffers
.get(self.model.borrow().active_tab)
.and_then(|buffer| buffer.language)
.unwrap_or(self.config.language)
}
pub fn set_language(&mut self, language: LanguageId) {
self.config.language = language;
self.config.tab_width = language.default_tab_width();
let active = self.model.borrow().active_tab;
if let Some(buffer) = self.model.borrow_mut().all_buffers.get_mut(active) {
buffer.language = Some(language);
}
self.invalidate_line_states();
self.base.request_redraw();
}
pub fn set_buffer_language(&mut self, index: usize, language: Option<LanguageId>) -> bool {
let active = self.model.borrow().active_tab;
let changed = {
let mut model = self.model.borrow_mut();
match model.all_buffers.get_mut(index) {
Some(buffer) => {
buffer.language = language;
true
}
None => false,
}
};
if changed && index == active {
self.invalidate_line_states();
self.base.request_redraw();
}
changed
}
pub fn buffer_language(&self, index: usize) -> Option<LanguageId> {
self.model.borrow().all_buffers.get(index).and_then(|buffer| buffer.language)
}
pub fn keywords(&self) -> &'static [&'static str] {
self.active_language().keywords()
}
pub(crate) const MODIFIER_CONTROL: u32 = 2;
}
fn closes_bracket(ch: char, candidate: char) -> bool {
matches!((ch, candidate), ('(', ')') | ('[', ']') | ('{', '}'))
}
fn opens_bracket(ch: char, candidate: char) -> bool {
matches!((ch, candidate), (')', '(') | (']', '[') | ('}', '{'))
}
fn is_skipped(byte: usize, spans: &[(usize, usize)]) -> bool {
spans.iter().any(|(start, end)| byte >= *start && byte < *end)
}
fn byte_and_char_at(text: &str, column: usize) -> Option<(usize, char)> {
text.char_indices().nth(column)
}
pub(crate) fn decimal_digits(value: usize) -> usize {
let mut digits = 1usize;
let mut rest = value;
while rest >= 10 {
rest /= 10;
digits += 1;
}
digits
}
fn shift_index(index: usize, delta: isize) -> usize {
if delta >= 0 {
index.saturating_add(delta as usize)
} else {
index.saturating_sub(delta.unsigned_abs())
}
}
fn position_in_text(text: &str, offset: usize) -> TextPosition {
let offset = floor_char_boundary(text, offset.min(text.len()));
let mut line = 0usize;
let mut column = 0usize;
for ch in text[..offset].chars() {
if ch == '\n' {
line += 1;
column = 0;
} else {
column += 1;
}
}
TextPosition::new(line, column)
}
fn char_len_of(text: &str) -> usize {
text.chars().count()
}
fn trailing_line_len(payload: &str) -> usize {
payload.rsplit('\n').next().map(|line| line.chars().count()).unwrap_or(0)
}
fn whole_word_ok(
haystack: &[char],
start: usize,
length: usize,
options: SearchOptions,
language: LanguageId,
) -> bool {
if !options.whole_word {
return true;
}
let before_ok = start == 0 || !language.is_word_char(haystack[start - 1]);
let after = start + length;
let after_ok = after >= haystack.len() || !language.is_word_char(haystack[after]);
before_ok && after_ok
}
pub(crate) const MAX_MINIMAP_ROWS: usize = 256;
struct Checkpoint {
range: Option<(usize, usize, String)>,
removed: Option<String>,
before: Option<String>,
}
fn diff_range(before: &str, after: &str) -> Option<(usize, usize, String)> {
if before == after {
return None;
}
let before_bytes = before.as_bytes();
let after_bytes = after.as_bytes();
let max_prefix = before_bytes.len().min(after_bytes.len());
let mut prefix = 0usize;
while prefix < max_prefix && before_bytes[prefix] == after_bytes[prefix] {
prefix += 1;
}
while prefix > 0 && !after.is_char_boundary(prefix) {
prefix -= 1;
}
let max_suffix = (before_bytes.len() - prefix).min(after_bytes.len() - prefix);
let mut suffix = 0usize;
while suffix < max_suffix
&& before_bytes[before_bytes.len() - 1 - suffix]
== after_bytes[after_bytes.len() - 1 - suffix]
{
suffix += 1;
}
while suffix > 0 && !after.is_char_boundary(after.len() - suffix) {
suffix -= 1;
}
while suffix > 0 && !before.is_char_boundary(before.len() - suffix) {
suffix -= 1;
}
let removed_len = before_bytes.len().saturating_sub(prefix + suffix);
let inserted_end = after_bytes.len().saturating_sub(suffix);
Some((prefix, removed_len, after[prefix..inserted_end].to_string()))
}
fn clamp_and_merge(text: &str, raw: Vec<TokenSpan>) -> Vec<TokenSpan> {
let length = text.len();
let mut spans: Vec<TokenSpan> = Vec::with_capacity(raw.len());
for mut span in raw {
span.start = floor_char_boundary(text, span.start.min(length));
span.end = floor_char_boundary(text, span.end.min(length));
if span.end > span.start {
spans.push(span);
}
}
spans.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.end.cmp(&b.end)));
super::syntax::merge_adjacent(spans)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum MiniTone {
#[default]
Plain,
Comment,
String,
}
pub(crate) fn common_leading_whitespace(text: &str) -> String {
let mut common: Option<&str> = None;
for line in text.split('\n') {
let line = line.trim_end_matches(['\r']);
if line.trim().is_empty() {
continue;
}
let indent = &line[..line.len() - line.trim_start_matches([' ', '\t']).len()];
common = Some(match common {
None => indent,
Some(previous) => {
let shared = previous
.bytes()
.zip(indent.bytes())
.take_while(|(left, right)| left == right)
.count();
&previous[..shared]
}
});
}
common.unwrap_or("").to_string()
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct MinimapBucket {
pub(crate) indent: usize,
pub(crate) length: usize,
pub(crate) tone: MiniTone,
pub(crate) blank: bool,
}
fn minimap_partition(count: usize, rows: usize) -> (usize, usize) {
let per_bucket = count.div_ceil(rows.max(1)).max(1);
let buckets = count.div_ceil(per_bucket).max(1);
(buckets, per_bucket)
}
impl Default for MinimapBucket {
fn default() -> Self {
Self { indent: 0, length: 0, tone: MiniTone::Plain, blank: true }
}
}
impl MinimapBucket {
fn accumulate(slot: &mut Self, text: &str) {
let indent = text.chars().take_while(|ch| ch.is_whitespace()).count();
let length = text.trim_end().chars().count().saturating_sub(indent);
if length == 0 {
return;
}
if slot.blank || length > slot.length {
slot.indent = indent;
slot.length = length;
let trimmed = text.trim_start();
slot.tone = if trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| trimmed.starts_with('#')
{
MiniTone::Comment
} else if trimmed.contains('"') || trimmed.contains('\'') {
MiniTone::String
} else {
MiniTone::Plain
};
}
slot.blank = false;
}
}
#[cfg(test)]
mod diff_range_tests {
use super::diff_range;
#[test]
fn a_single_substitution_is_one_range() {
assert_eq!(diff_range("a-b-c", "a+b+c"), Some((1, 3, "+b+".to_string())));
}
#[test]
fn pure_insertion_and_deletion() {
assert_eq!(diff_range("ac", "abc"), Some((1, 0, "b".to_string())));
assert_eq!(diff_range("abc", "ac"), Some((1, 1, String::new())));
}
#[test]
fn identical_texts_produce_no_edit() {
assert_eq!(diff_range("same", "same"), None);
assert_eq!(diff_range("", ""), None);
}
#[test]
fn multibyte_boundaries_are_respected() {
let (start, removed, inserted) = diff_range("héllo", "hello").unwrap();
assert!("héllo".is_char_boundary(start));
assert!("héllo".is_char_boundary(start + removed));
assert_eq!(inserted, "e");
}
#[test]
fn replacing_the_whole_text() {
assert_eq!(diff_range("abc", "xyz"), Some((0, 3, "xyz".to_string())));
}
}