use std::any::Any;
use std::sync::RwLock;
use streaming_iterator::StreamingIterator;
use super::context::{BasicContext, Context};
use super::{Element, FocusRequest, ViewLimits, ViewStretch};
use crate::support::color::Color;
use crate::support::point::Point;
use crate::support::rect::Rect;
use crate::support::theme::get_theme;
use crate::view::{CursorTracking, KeyCode, KeyInfo, MouseButton, MouseButtonKind, TextInfo};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
struct CursorPos {
line: usize,
column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum EditorState {
#[default]
Idle,
Hover,
Focused,
}
#[derive(Debug, Clone, Copy)]
struct Highlight {
start: CursorPos,
end: CursorPos,
color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub line: usize,
pub severity: DiagnosticSeverity,
pub message: String,
}
pub type TextChangeCallback = Box<dyn Fn(&str) + Send + Sync>;
pub struct CodeEditor {
lines: RwLock<Vec<String>>,
cursor: RwLock<CursorPos>,
selection_anchor: RwLock<Option<CursorPos>>,
scroll_offset: RwLock<Point>,
content_width: RwLock<f32>,
content_width_dirty: RwLock<bool>,
state: RwLock<EditorState>,
undo_stack: RwLock<Vec<Vec<String>>>,
redo_stack: RwLock<Vec<Vec<String>>>,
highlights: RwLock<Vec<Highlight>>,
parser: RwLock<tree_sitter::Parser>,
query: Option<tree_sitter::Query>,
diagnostics: RwLock<Vec<Diagnostic>>,
read_only: RwLock<bool>,
find_query: RwLock<String>,
find_matches: RwLock<Vec<CursorPos>>,
background_color: Color,
gutter_color: Color,
gutter_text_color: Color,
text_color: Color,
highlight_select_color: Color,
find_match_color: Color,
error_color: Color,
warning_color: Color,
info_color: Color,
caret_color: Color,
scrollbar_color: Color,
scrollbar_hover_color: Color,
scrollbar_width: f32,
font_size: f32,
line_height: f32,
gutter_width: f32,
width: f32,
height: RwLock<f32>,
stretch_y: f32,
enabled: bool,
on_change: Option<TextChangeCallback>,
dragging_v: RwLock<bool>,
dragging_h: RwLock<bool>,
drag_start: RwLock<Point>,
drag_start_scroll: RwLock<Point>,
}
impl CodeEditor {
pub fn new() -> Self {
let theme = get_theme();
let mut parser = tree_sitter::Parser::new();
let query = parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.ok()
.and_then(|_| {
tree_sitter::Query::new(&tree_sitter_rust::LANGUAGE.into(), RUST_HIGHLIGHT_QUERY)
.map_err(|err| {
log::warn!("code_editor: highlight query failed to compile: {err}")
})
.ok()
});
let editor = Self {
lines: RwLock::new(vec![String::new()]),
cursor: RwLock::new(CursorPos::default()),
selection_anchor: RwLock::new(None),
scroll_offset: RwLock::new(Point::zero()),
content_width: RwLock::new(0.0),
content_width_dirty: RwLock::new(true),
state: RwLock::new(EditorState::Idle),
undo_stack: RwLock::new(Vec::new()),
redo_stack: RwLock::new(Vec::new()),
highlights: RwLock::new(Vec::new()),
parser: RwLock::new(parser),
query,
diagnostics: RwLock::new(Vec::new()),
read_only: RwLock::new(false),
find_query: RwLock::new(String::new()),
find_matches: RwLock::new(Vec::new()),
background_color: theme.input_box_color,
gutter_color: theme.input_box_color.level(0.9),
gutter_text_color: theme.text_box_idle_color,
text_color: theme.text_box_font_color,
highlight_select_color: theme.text_box_hilite_color,
find_match_color: Color::from_rgb_u32(0xffd54a).with_alpha(0.45),
error_color: Color::from_rgb_u32(0xe5484d),
warning_color: Color::from_rgb_u32(0xf5a623),
info_color: Color::from_rgb_u32(0x4a9fe5),
caret_color: theme.text_box_caret_color,
scrollbar_color: theme.scrollbar_color,
scrollbar_hover_color: theme.scrollbar_color.level(1.3),
scrollbar_width: theme.scrollbar_width,
font_size: theme.text_box_font_size,
line_height: theme.text_box_font_size * 1.4,
gutter_width: 48.0,
width: 600.0,
height: RwLock::new(400.0),
stretch_y: 1.0,
enabled: true,
on_change: None,
dragging_v: RwLock::new(false),
dragging_h: RwLock::new(false),
drag_start: RwLock::new(Point::zero()),
drag_start_scroll: RwLock::new(Point::zero()),
};
editor.reparse();
editor
}
pub fn text(mut self, text: impl Into<String>) -> Self {
self.set_text_inner(text.into());
*self.undo_stack.get_mut().unwrap() = Vec::new();
*self.redo_stack.get_mut().unwrap() = Vec::new();
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = RwLock::new(height);
self
}
pub fn get_height(&self) -> f32 {
*self.height.read().unwrap()
}
pub fn set_height(&self, height: f32) {
*self.height.write().unwrap() = height.max(40.0);
}
pub fn stretch_y(mut self, stretch_y: f32) -> Self {
self.stretch_y = stretch_y;
self
}
pub fn on_change<F: Fn(&str) + Send + Sync + 'static>(mut self, callback: F) -> Self {
self.on_change = Some(Box::new(callback));
self
}
pub fn read_only(mut self, read_only: bool) -> Self {
*self.read_only.get_mut().unwrap() = read_only;
self
}
pub fn set_read_only(&self, read_only: bool) {
*self.read_only.write().unwrap() = read_only;
}
pub fn is_read_only(&self) -> bool {
*self.read_only.read().unwrap()
}
pub fn set_diagnostics(&self, diagnostics: Vec<Diagnostic>) {
*self.diagnostics.write().unwrap() = diagnostics;
}
pub fn clear_diagnostics(&self) {
self.diagnostics.write().unwrap().clear();
}
pub fn find(&self, query: &str) -> bool {
*self.find_query.write().unwrap() = query.to_string();
self.recompute_find_matches();
if query.is_empty() {
return false;
}
let cursor = *self.cursor.read().unwrap();
self.find_next_from(cursor, true)
}
pub fn find_next(&self) -> bool {
let cursor = *self.cursor.read().unwrap();
self.find_next_from(cursor, true)
}
pub fn find_prev(&self) -> bool {
let cursor = *self.cursor.read().unwrap();
self.find_next_from(cursor, false)
}
fn find_next_from(&self, from: CursorPos, forward: bool) -> bool {
let matches = self.find_matches.read().unwrap();
if matches.is_empty() {
return false;
}
let next = if forward {
matches
.iter()
.find(|m| **m > from)
.or_else(|| matches.first())
} else {
matches
.iter()
.rev()
.find(|m| **m < from)
.or_else(|| matches.last())
};
let Some(&pos) = next else {
return false;
};
drop(matches);
*self.cursor.write().unwrap() = pos;
*self.selection_anchor.write().unwrap() = None;
true
}
fn recompute_find_matches(&self) {
let query = self.find_query.read().unwrap().clone();
let mut matches = Vec::new();
if !query.is_empty() {
let lines = self.lines.read().unwrap();
for (line_index, line) in lines.iter().enumerate() {
let mut start = 0;
while let Some(byte_offset) = line[start..].find(&query) {
let byte_pos = start + byte_offset;
let column = line[..byte_pos].chars().count();
matches.push(CursorPos {
line: line_index,
column,
});
start = byte_pos + query.len().max(1);
if start >= line.len() {
break;
}
}
}
}
*self.find_matches.write().unwrap() = matches;
}
pub fn get_text(&self) -> String {
self.lines.read().unwrap().join("\n")
}
pub fn set_text(&self, text: impl Into<String>) {
self.push_undo_snapshot();
self.set_text_inner(text.into());
}
fn set_text_inner(&self, text: String) {
let lines: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(str::to_string).collect()
};
*self.lines.write().unwrap() = lines;
*self.cursor.write().unwrap() = CursorPos::default();
*self.selection_anchor.write().unwrap() = None;
*self.scroll_offset.write().unwrap() = Point::zero();
self.reparse();
}
fn push_undo_snapshot(&self) {
let snapshot = self.lines.read().unwrap().clone();
self.undo_stack.write().unwrap().push(snapshot);
self.redo_stack.write().unwrap().clear();
}
pub fn append_line(&self, line: &str) {
{
let mut lines = self.lines.write().unwrap();
if lines.len() == 1 && lines[0].is_empty() {
lines[0] = line.to_string();
} else {
lines.push(line.to_string());
}
}
self.reparse();
self.scroll_to_bottom();
}
pub fn scroll_to_bottom(&self) {
let content_height = self.content_height();
let viewport_height = *self.height.read().unwrap();
let max_y = (content_height - viewport_height).max(0.0);
self.scroll_offset.write().unwrap().y = max_y;
}
fn undo(&self) {
if *self.read_only.read().unwrap() {
return;
}
let Some(snapshot) = self.undo_stack.write().unwrap().pop() else {
return;
};
let current = self.lines.read().unwrap().clone();
self.redo_stack.write().unwrap().push(current);
*self.lines.write().unwrap() = snapshot;
self.clamp_cursor();
self.reparse();
self.notify_change();
}
fn redo(&self) {
if *self.read_only.read().unwrap() {
return;
}
let Some(snapshot) = self.redo_stack.write().unwrap().pop() else {
return;
};
let current = self.lines.read().unwrap().clone();
self.undo_stack.write().unwrap().push(current);
*self.lines.write().unwrap() = snapshot;
self.clamp_cursor();
self.reparse();
self.notify_change();
}
fn clamp_cursor(&self) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
cursor.line = cursor.line.min(lines.len().saturating_sub(1));
cursor.column = cursor.column.min(lines[cursor.line].chars().count());
}
fn notify_change(&self) {
if let Some(ref callback) = self.on_change {
callback(&self.get_text());
}
}
fn reparse(&self) {
self.recompute_find_matches();
*self.content_width_dirty.write().unwrap() = true;
let Some(query) = &self.query else {
return;
};
let text = self.get_text();
let mut parser = self.parser.write().unwrap();
let Some(tree) = parser.parse(&text, None) else {
return;
};
drop(parser);
let line_starts = line_start_byte_offsets(&text);
let mut cursor = tree_sitter::QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), text.as_bytes());
let theme_colors = HighlightColors::from_theme();
let mut spans = Vec::new();
while let Some(m) = matches.next() {
for capture in m.captures {
let name = &query.capture_names()[capture.index as usize];
let Some(color) = theme_colors.for_capture(name) else {
continue;
};
let node = capture.node;
let start = byte_to_cursor_pos(node.start_byte(), &line_starts);
let end = byte_to_cursor_pos(node.end_byte(), &line_starts);
spans.push(Highlight { start, end, color });
}
}
*self.highlights.write().unwrap() = spans;
}
fn insert_text(&self, s: &str) {
if *self.read_only.read().unwrap() {
return;
}
self.push_undo_snapshot();
let mut lines = self.lines.write().unwrap();
let mut cursor = self.cursor.write().unwrap();
let mut anchor = self.selection_anchor.write().unwrap();
if let Some(sel) = *anchor {
delete_range_inner(&mut lines, sel, *cursor, &mut cursor);
*anchor = None;
}
if s == "\n" {
let line = lines[cursor.line].clone();
let byte = char_to_byte(&line, cursor.column);
let (before, after) = line.split_at(byte);
lines[cursor.line] = before.to_string();
lines.insert(cursor.line + 1, after.to_string());
cursor.line += 1;
cursor.column = 0;
} else {
let line = &mut lines[cursor.line];
let byte = char_to_byte(line, cursor.column);
line.insert_str(byte, s);
cursor.column += s.chars().count();
}
drop(lines);
drop(cursor);
drop(anchor);
self.reparse();
self.notify_change();
}
fn delete_backward(&self) {
if *self.read_only.read().unwrap() {
return;
}
let mut lines = self.lines.write().unwrap();
let mut cursor = self.cursor.write().unwrap();
let mut anchor = self.selection_anchor.write().unwrap();
if let Some(sel) = anchor.take() {
self.push_undo_snapshot_locked(&lines);
delete_range_inner(&mut lines, sel, *cursor, &mut cursor);
} else if cursor.column > 0 {
self.push_undo_snapshot_locked(&lines);
let line = &mut lines[cursor.line];
let start = char_to_byte(line, cursor.column - 1);
let end = char_to_byte(line, cursor.column);
line.replace_range(start..end, "");
cursor.column -= 1;
} else if cursor.line > 0 {
self.push_undo_snapshot_locked(&lines);
let current = lines.remove(cursor.line);
let prev_len = lines[cursor.line - 1].chars().count();
lines[cursor.line - 1].push_str(¤t);
cursor.line -= 1;
cursor.column = prev_len;
}
drop(lines);
drop(cursor);
drop(anchor);
self.reparse();
self.notify_change();
}
fn delete_forward(&self) {
if *self.read_only.read().unwrap() {
return;
}
let mut lines = self.lines.write().unwrap();
let mut cursor = self.cursor.write().unwrap();
let mut anchor = self.selection_anchor.write().unwrap();
if let Some(sel) = anchor.take() {
self.push_undo_snapshot_locked(&lines);
delete_range_inner(&mut lines, sel, *cursor, &mut cursor);
} else {
let line_char_count = lines[cursor.line].chars().count();
if cursor.column < line_char_count {
self.push_undo_snapshot_locked(&lines);
let line = &mut lines[cursor.line];
let start = char_to_byte(line, cursor.column);
let end = char_to_byte(line, cursor.column + 1);
line.replace_range(start..end, "");
} else if cursor.line + 1 < lines.len() {
self.push_undo_snapshot_locked(&lines);
let next = lines.remove(cursor.line + 1);
lines[cursor.line].push_str(&next);
}
}
drop(lines);
drop(cursor);
drop(anchor);
self.reparse();
self.notify_change();
}
fn push_undo_snapshot_locked(&self, lines: &[String]) {
self.undo_stack.write().unwrap().push(lines.to_vec());
self.redo_stack.write().unwrap().clear();
}
fn move_left(&self, select: bool) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
if cursor.column > 0 {
cursor.column -= 1;
} else if cursor.line > 0 {
cursor.line -= 1;
cursor.column = lines[cursor.line].chars().count();
}
}
fn move_right(&self, select: bool) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
let line_len = lines[cursor.line].chars().count();
if cursor.column < line_len {
cursor.column += 1;
} else if cursor.line + 1 < lines.len() {
cursor.line += 1;
cursor.column = 0;
}
}
fn move_up(&self, select: bool) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
if cursor.line > 0 {
cursor.line -= 1;
cursor.column = cursor.column.min(lines[cursor.line].chars().count());
}
}
fn move_down(&self, select: bool) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
if cursor.line + 1 < lines.len() {
cursor.line += 1;
cursor.column = cursor.column.min(lines[cursor.line].chars().count());
}
}
fn move_home(&self, select: bool) {
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
cursor.column = 0;
}
fn move_end(&self, select: bool) {
let lines = self.lines.read().unwrap();
let mut cursor = self.cursor.write().unwrap();
self.update_selection_anchor(select);
cursor.column = lines[cursor.line].chars().count();
}
fn select_all(&self) {
let lines = self.lines.read().unwrap();
let last_line = lines.len() - 1;
let last_col = lines[last_line].chars().count();
*self.selection_anchor.write().unwrap() = Some(CursorPos { line: 0, column: 0 });
*self.cursor.write().unwrap() = CursorPos {
line: last_line,
column: last_col,
};
}
fn update_selection_anchor(&self, select: bool) {
let mut anchor = self.selection_anchor.write().unwrap();
if select {
if anchor.is_none() {
*anchor = Some(*self.cursor.read().unwrap());
}
} else {
*anchor = None;
}
}
fn diagnostic_severity_for_line(&self, line: usize) -> Option<DiagnosticSeverity> {
self.diagnostics
.read()
.unwrap()
.iter()
.filter(|d| d.line == line)
.map(|d| d.severity)
.max_by_key(|s| match s {
DiagnosticSeverity::Error => 2,
DiagnosticSeverity::Warning => 1,
DiagnosticSeverity::Info => 0,
})
}
fn severity_color(&self, severity: DiagnosticSeverity) -> Color {
match severity {
DiagnosticSeverity::Error => self.error_color,
DiagnosticSeverity::Warning => self.warning_color,
DiagnosticSeverity::Info => self.info_color,
}
}
fn measure_content_width(&self, ctx: &Context) -> f32 {
let mut canvas = ctx.canvas.borrow_mut();
let theme = get_theme();
canvas.font(theme.text_box_font);
canvas.font_size(self.font_size);
self.lines
.read()
.unwrap()
.iter()
.map(|l| canvas.text_width(l))
.fold(0.0, f32::max)
}
fn content_height(&self) -> f32 {
self.lines.read().unwrap().len() as f32 * self.line_height
}
fn needs_v_scrollbar(&self, ctx: &Context) -> bool {
self.content_height() > ctx.bounds.height()
}
fn needs_h_scrollbar(&self, ctx: &Context) -> bool {
*self.content_width.read().unwrap() > ctx.bounds.width() - self.gutter_width
}
fn viewport_rect(&self, ctx: &Context) -> Rect {
let has_v = self.needs_v_scrollbar(ctx);
let has_h = self.needs_h_scrollbar(ctx);
Rect::new(
ctx.bounds.left,
ctx.bounds.top,
ctx.bounds.right - if has_v { self.scrollbar_width } else { 0.0 },
ctx.bounds.bottom - if has_h { self.scrollbar_width } else { 0.0 },
)
}
fn text_viewport(&self, ctx: &Context) -> Rect {
let viewport = self.viewport_rect(ctx);
Rect::new(
viewport.left + self.gutter_width,
viewport.top,
viewport.right,
viewport.bottom,
)
}
fn v_scrollbar_rect(&self, ctx: &Context) -> Rect {
if !self.needs_v_scrollbar(ctx) {
return Rect::zero();
}
let has_h = self.needs_h_scrollbar(ctx);
Rect::new(
ctx.bounds.right - self.scrollbar_width,
ctx.bounds.top,
ctx.bounds.right,
ctx.bounds.bottom - if has_h { self.scrollbar_width } else { 0.0 },
)
}
fn h_scrollbar_rect(&self, ctx: &Context) -> Rect {
if !self.needs_h_scrollbar(ctx) {
return Rect::zero();
}
let has_v = self.needs_v_scrollbar(ctx);
Rect::new(
ctx.bounds.left + self.gutter_width,
ctx.bounds.bottom - self.scrollbar_width,
ctx.bounds.right - if has_v { self.scrollbar_width } else { 0.0 },
ctx.bounds.bottom,
)
}
fn v_thumb_rect(&self, ctx: &Context) -> Rect {
let track = self.v_scrollbar_rect(ctx);
if track.is_empty() {
return Rect::zero();
}
let content_height = self.content_height();
let viewport = self.text_viewport(ctx);
let scroll_y = self.scroll_offset.read().unwrap().y;
let visible_ratio = (viewport.height() / content_height).min(1.0);
let thumb_height = (track.height() * visible_ratio).max(20.0);
let scroll_range = (content_height - viewport.height()).max(0.0);
let scroll_ratio = if scroll_range > 0.0 {
scroll_y / scroll_range
} else {
0.0
};
let thumb_y = track.top + scroll_ratio * (track.height() - thumb_height);
Rect::new(
track.left + 2.0,
thumb_y,
track.right - 2.0,
thumb_y + thumb_height,
)
}
fn h_thumb_rect(&self, ctx: &Context) -> Rect {
let track = self.h_scrollbar_rect(ctx);
if track.is_empty() {
return Rect::zero();
}
let content_width = *self.content_width.read().unwrap();
let viewport = self.text_viewport(ctx);
let scroll_x = self.scroll_offset.read().unwrap().x;
let visible_ratio = (viewport.width() / content_width).min(1.0);
let thumb_width = (track.width() * visible_ratio).max(20.0);
let scroll_range = (content_width - viewport.width()).max(0.0);
let scroll_ratio = if scroll_range > 0.0 {
scroll_x / scroll_range
} else {
0.0
};
let thumb_x = track.left + scroll_ratio * (track.width() - thumb_width);
Rect::new(
thumb_x,
track.top + 2.0,
thumb_x + thumb_width,
track.bottom - 2.0,
)
}
fn draw_scrollbars(&self, ctx: &Context) {
let mut canvas = ctx.canvas.borrow_mut();
if self.needs_v_scrollbar(ctx) {
let track = self.v_scrollbar_rect(ctx);
let thumb = self.v_thumb_rect(ctx);
canvas.fill_style(self.scrollbar_color.with_alpha(0.2));
canvas.fill_rect(track);
let color = if *self.dragging_v.read().unwrap() {
self.scrollbar_hover_color
} else {
self.scrollbar_color
};
canvas.fill_style(color);
canvas.fill_round_rect(thumb, 3.0);
}
if self.needs_h_scrollbar(ctx) {
let track = self.h_scrollbar_rect(ctx);
let thumb = self.h_thumb_rect(ctx);
canvas.fill_style(self.scrollbar_color.with_alpha(0.2));
canvas.fill_rect(track);
let color = if *self.dragging_h.read().unwrap() {
self.scrollbar_hover_color
} else {
self.scrollbar_color
};
canvas.fill_style(color);
canvas.fill_round_rect(thumb, 3.0);
}
if self.needs_v_scrollbar(ctx) && self.needs_h_scrollbar(ctx) {
let corner = Rect::new(
ctx.bounds.right - self.scrollbar_width,
ctx.bounds.bottom - self.scrollbar_width,
ctx.bounds.right,
ctx.bounds.bottom,
);
canvas.fill_style(self.scrollbar_color.with_alpha(0.3));
canvas.fill_rect(corner);
}
}
fn set_scroll(&self, ctx: &Context, x: f32, y: f32) {
let content_width = *self.content_width.read().unwrap();
let content_height = self.content_height();
let viewport = self.text_viewport(ctx);
let max_x = (content_width - viewport.width()).max(0.0);
let max_y = (content_height - viewport.height()).max(0.0);
*self.scroll_offset.write().unwrap() = Point::new(x.clamp(0.0, max_x), y.clamp(0.0, max_y));
}
fn scroll_cursor_into_view(&self, ctx: &Context) {
let cursor = *self.cursor.read().unwrap();
let scroll = *self.scroll_offset.read().unwrap();
let viewport = self.text_viewport(ctx);
let cursor_top = cursor.line as f32 * self.line_height;
let cursor_bottom = cursor_top + self.line_height;
let new_y = if cursor_top < scroll.y {
cursor_top
} else if cursor_bottom > scroll.y + viewport.height() {
cursor_bottom - viewport.height()
} else {
scroll.y
};
let cursor_x = {
let lines = self.lines.read().unwrap();
let mut canvas = ctx.canvas.borrow_mut();
let theme = get_theme();
canvas.font(theme.text_box_font);
canvas.font_size(self.font_size);
canvas.text_width_to_position(&lines[cursor.line], cursor.column)
};
let new_x = if cursor_x < scroll.x {
cursor_x
} else if cursor_x > scroll.x + viewport.width() {
cursor_x - viewport.width()
} else {
scroll.x
};
self.set_scroll(ctx, new_x, new_y);
}
fn draw_gutter(
&self,
ctx: &Context,
first_visible_line: usize,
visible_lines: usize,
line_offset: f32,
) {
let mut canvas = ctx.canvas.borrow_mut();
let gutter_rect = Rect::new(
ctx.bounds.left,
ctx.bounds.top,
ctx.bounds.left + self.gutter_width,
self.viewport_rect(ctx).bottom,
);
canvas.fill_style(self.gutter_color);
canvas.fill_rect(gutter_rect);
let theme = get_theme();
canvas.font(theme.text_box_font);
canvas.font_size(self.font_size);
let lines = self.lines.read().unwrap();
for row in 0..visible_lines {
let line_index = first_visible_line + row;
if line_index >= lines.len() {
break;
}
let y = ctx.bounds.top + (row as f32 + 1.0) * self.line_height
- self.line_height * 0.3
- line_offset;
if let Some(severity) = self.diagnostic_severity_for_line(line_index) {
canvas.fill_style(self.severity_color(severity));
let dot_y = y - self.font_size * 0.35;
canvas.fill_round_rect(
Rect::new(
ctx.bounds.left + 4.0,
dot_y - 3.0,
ctx.bounds.left + 10.0,
dot_y + 3.0,
),
3.0,
);
}
let label = (line_index + 1).to_string();
canvas.fill_style(self.gutter_text_color);
let x = ctx.bounds.left + self.gutter_width - 8.0 - canvas.text_width(&label);
canvas.fill_text(&label, Point::new(x, y));
}
}
fn draw_selection_and_text(
&self,
ctx: &Context,
first_visible_line: usize,
visible_lines: usize,
line_offset: f32,
) {
let text_viewport = self.text_viewport(ctx);
let mut canvas = ctx.canvas.borrow_mut();
let theme = get_theme();
canvas.font(theme.text_box_font);
canvas.font_size(self.font_size);
canvas.save();
canvas.clip(text_viewport);
let lines = self.lines.read().unwrap();
let cursor = *self.cursor.read().unwrap();
let anchor = *self.selection_anchor.read().unwrap();
let highlights = self.highlights.read().unwrap();
let find_matches = self.find_matches.read().unwrap();
let query_len = self.find_query.read().unwrap().chars().count();
let scroll_x = self.scroll_offset.read().unwrap().x;
let text_left = ctx.bounds.left + self.gutter_width + 6.0 - scroll_x;
for row in 0..visible_lines {
let line_index = first_visible_line + row;
if line_index >= lines.len() {
break;
}
let line = &lines[line_index];
let y_top = ctx.bounds.top + row as f32 * self.line_height - line_offset;
let y_baseline = y_top + self.line_height - self.font_size * 0.3;
if let Some(severity) = self.diagnostic_severity_for_line(line_index) {
canvas.fill_style(self.severity_color(severity).with_alpha(0.12));
canvas.fill_rect(Rect::new(
ctx.bounds.left + self.gutter_width,
y_top,
ctx.bounds.right,
y_top + self.line_height,
));
}
if query_len > 0 {
for m in find_matches.iter().filter(|m| m.line == line_index) {
let x1 = text_left + canvas.text_width_to_position(line, m.column);
let x2 = text_left
+ canvas.text_width_to_position(
line,
(m.column + query_len).min(line.chars().count()),
);
canvas.fill_style(self.find_match_color);
canvas.fill_rect(Rect::new(
x1,
y_top,
x2.max(x1 + 2.0),
y_top + self.line_height,
));
}
}
if let Some(sel) = anchor {
if let Some((start_col, end_col)) = selection_on_line(sel, cursor, line_index) {
let x1 = text_left + canvas.text_width_to_position(line, start_col);
let x2 = text_left
+ canvas.text_width_to_position(line, end_col.min(line.chars().count()));
canvas.fill_style(self.highlight_select_color);
canvas.fill_rect(Rect::new(
x1,
y_top,
x2.max(x1 + 2.0),
y_top + self.line_height,
));
}
}
let segments = line_color_segments(line, line_index, &highlights, self.text_color);
for (start_col, end_col, color) in segments {
let start_byte = char_to_byte(line, start_col);
let end_byte = char_to_byte(line, end_col);
if start_byte >= end_byte {
continue;
}
canvas.fill_style(color);
let x = text_left + canvas.text_width_to_position(line, start_col);
canvas.fill_text(&line[start_byte..end_byte], Point::new(x, y_baseline));
}
if line_index == cursor.line && *self.state.read().unwrap() == EditorState::Focused {
let x = text_left + canvas.text_width_to_position(line, cursor.column);
canvas.stroke_style(self.caret_color);
canvas.line_width(1.5);
canvas.begin_path();
canvas.move_to(Point::new(x, y_top + 2.0));
canvas.line_to(Point::new(x, y_top + self.line_height - 2.0));
canvas.stroke();
}
}
canvas.restore();
}
fn visible_line_window(&self, ctx: &Context) -> (usize, usize, f32) {
let scroll_y = self.scroll_offset.read().unwrap().y;
let first = (scroll_y / self.line_height).floor().max(0.0) as usize;
let line_offset = scroll_y - first as f32 * self.line_height;
let visible = (self.text_viewport(ctx).height() / self.line_height).ceil() as usize + 1;
(first, visible, line_offset)
}
fn cursor_pos_from_click(&self, ctx: &Context, p: Point) -> CursorPos {
let lines = self.lines.read().unwrap();
let scroll = *self.scroll_offset.read().unwrap();
let row = (((p.y - ctx.bounds.top + scroll.y) / self.line_height)
.floor()
.max(0.0)) as usize;
let line = row.min(lines.len().saturating_sub(1));
let line_text = &lines[line];
let mut canvas = ctx.canvas.borrow_mut();
let theme = get_theme();
canvas.font(theme.text_box_font);
canvas.font_size(self.font_size);
let text_left = ctx.bounds.left + self.gutter_width + 6.0 - scroll.x;
let rel_x = p.x - text_left;
let char_count = line_text.chars().count();
let mut column = char_count;
for i in 0..=char_count {
if canvas.text_width_to_position(line_text, i) >= rel_x {
column = i;
break;
}
}
CursorPos { line, column }
}
}
impl Default for CodeEditor {
fn default() -> Self {
Self::new()
}
}
impl Element for CodeEditor {
fn limits(&self, _ctx: &BasicContext) -> ViewLimits {
ViewLimits::min_size(self.width, *self.height.read().unwrap())
}
fn stretch(&self) -> ViewStretch {
ViewStretch::new(1.0, self.stretch_y)
}
fn draw(&self, ctx: &Context) {
{
let mut canvas = ctx.canvas.borrow_mut();
canvas.fill_style(self.background_color);
canvas.fill_rect(ctx.bounds);
}
if *self.content_width_dirty.read().unwrap() {
*self.content_width.write().unwrap() = self.measure_content_width(ctx);
*self.content_width_dirty.write().unwrap() = false;
}
let (first, visible, line_offset) = self.visible_line_window(ctx);
self.draw_selection_and_text(ctx, first, visible, line_offset);
self.draw_gutter(ctx, first, visible, line_offset);
self.draw_scrollbars(ctx);
}
fn hit_test(
&self,
ctx: &Context,
p: Point,
_leaf: bool,
_control: bool,
) -> Option<&dyn Element> {
if ctx.bounds.contains(p) && self.enabled {
Some(self)
} else {
None
}
}
fn wants_control(&self) -> bool {
self.enabled
}
fn wants_focus(&self) -> bool {
self.enabled
}
fn begin_focus(&mut self, _req: FocusRequest) {
*self.state.write().unwrap() = EditorState::Focused;
}
fn end_focus(&mut self) -> bool {
*self.state.write().unwrap() = EditorState::Idle;
true
}
fn clear_focus(&self) {
let mut state = self.state.write().unwrap();
if *state == EditorState::Focused {
*state = EditorState::Idle;
}
}
fn handle_click(&self, ctx: &Context, btn: MouseButton) -> bool {
if !self.enabled || btn.button != MouseButtonKind::Left {
return false;
}
if btn.down {
if self.v_thumb_rect(ctx).contains(btn.pos) {
*self.dragging_v.write().unwrap() = true;
*self.drag_start.write().unwrap() = btn.pos;
*self.drag_start_scroll.write().unwrap() = *self.scroll_offset.read().unwrap();
return true;
}
if self.h_thumb_rect(ctx).contains(btn.pos) {
*self.dragging_h.write().unwrap() = true;
*self.drag_start.write().unwrap() = btn.pos;
*self.drag_start_scroll.write().unwrap() = *self.scroll_offset.read().unwrap();
return true;
}
*self.state.write().unwrap() = EditorState::Focused;
let pos = self.cursor_pos_from_click(ctx, btn.pos);
*self.cursor.write().unwrap() = pos;
*self.selection_anchor.write().unwrap() = None;
} else {
*self.dragging_v.write().unwrap() = false;
*self.dragging_h.write().unwrap() = false;
}
true
}
fn drag(&mut self, ctx: &Context, btn: MouseButton) {
self.handle_drag(ctx, btn);
}
fn handle_drag(&self, ctx: &Context, btn: MouseButton) {
let drag_start = *self.drag_start.read().unwrap();
let start_scroll = *self.drag_start_scroll.read().unwrap();
if *self.dragging_v.read().unwrap() {
let track = self.v_scrollbar_rect(ctx);
let thumb = self.v_thumb_rect(ctx);
let viewport = self.text_viewport(ctx);
let delta_y = btn.pos.y - drag_start.y;
let track_range = track.height() - thumb.height();
let scroll_range = (self.content_height() - viewport.height()).max(0.0);
if track_range > 0.0 {
let new_y = start_scroll.y + delta_y * scroll_range / track_range;
self.set_scroll(ctx, start_scroll.x, new_y);
}
}
if *self.dragging_h.read().unwrap() {
let track = self.h_scrollbar_rect(ctx);
let thumb = self.h_thumb_rect(ctx);
let viewport = self.text_viewport(ctx);
let content_width = *self.content_width.read().unwrap();
let delta_x = btn.pos.x - drag_start.x;
let track_range = track.width() - thumb.width();
let scroll_range = (content_width - viewport.width()).max(0.0);
if track_range > 0.0 {
let new_x = start_scroll.x + delta_x * scroll_range / track_range;
self.set_scroll(ctx, new_x, start_scroll.y);
}
}
}
fn cursor(&mut self, _ctx: &Context, _p: Point, status: CursorTracking) -> bool {
if !self.enabled {
return false;
}
let mut state = self.state.write().unwrap();
if *state == EditorState::Focused {
return true;
}
match status {
CursorTracking::Entering | CursorTracking::Hovering => *state = EditorState::Hover,
CursorTracking::Leaving => *state = EditorState::Idle,
}
true
}
fn handle_scroll(&self, ctx: &Context, dir: Point, _p: Point) -> bool {
if !self.enabled {
return false;
}
let scroll = *self.scroll_offset.read().unwrap();
self.set_scroll(ctx, scroll.x - dir.x, scroll.y - dir.y);
true
}
fn key(&mut self, ctx: &Context, k: KeyInfo) -> bool {
self.handle_key(ctx, k)
}
fn handle_key(&self, ctx: &Context, k: KeyInfo) -> bool {
if !self.enabled || *self.state.read().unwrap() != EditorState::Focused {
return false;
}
if k.action != crate::view::KeyAction::Press && k.action != crate::view::KeyAction::Repeat {
return true;
}
let shift = k.modifiers & crate::view::modifiers::SHIFT != 0;
let ctrl =
k.modifiers & (crate::view::modifiers::CONTROL | crate::view::modifiers::SUPER) != 0;
match k.key {
KeyCode::Left => self.move_left(shift),
KeyCode::Right => self.move_right(shift),
KeyCode::Up => self.move_up(shift),
KeyCode::Down => self.move_down(shift),
KeyCode::Home => self.move_home(shift),
KeyCode::End => self.move_end(shift),
KeyCode::Backspace => self.delete_backward(),
KeyCode::Delete => self.delete_forward(),
KeyCode::Enter => self.insert_text("\n"),
KeyCode::Tab => self.insert_text(" "),
KeyCode::A if ctrl => self.select_all(),
KeyCode::Z if ctrl && shift => self.redo(),
KeyCode::Z if ctrl => self.undo(),
KeyCode::Y if ctrl => self.redo(),
_ => return false,
}
self.scroll_cursor_into_view(ctx);
true
}
fn text(&mut self, ctx: &Context, info: TextInfo) -> bool {
self.handle_text(ctx, info)
}
fn handle_text(&self, ctx: &Context, info: TextInfo) -> bool {
if !self.enabled || *self.state.read().unwrap() != EditorState::Focused {
return false;
}
let c = info.codepoint;
if !c.is_control() {
self.insert_text(&c.to_string());
self.scroll_cursor_into_view(ctx);
}
true
}
fn enable(&mut self, state: bool) {
self.enabled = state;
}
fn is_enabled(&self) -> bool {
self.enabled
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
pub fn code_editor() -> CodeEditor {
CodeEditor::new()
}
fn char_to_byte(line: &str, column: usize) -> usize {
line.char_indices()
.nth(column)
.map(|(i, _)| i)
.unwrap_or(line.len())
}
fn delete_range_inner(lines: &mut Vec<String>, a: CursorPos, b: CursorPos, cursor: &mut CursorPos) {
let (start, end) = if (a.line, a.column) <= (b.line, b.column) {
(a, b)
} else {
(b, a)
};
if start.line == end.line {
let line = &mut lines[start.line];
let sb = char_to_byte(line, start.column);
let eb = char_to_byte(line, end.column);
line.replace_range(sb..eb, "");
} else {
let start_byte = char_to_byte(&lines[start.line], start.column);
let end_byte = char_to_byte(&lines[end.line], end.column);
let remainder = lines[end.line][end_byte..].to_string();
lines[start.line].truncate(start_byte);
lines[start.line].push_str(&remainder);
lines.drain(start.line + 1..=end.line);
}
*cursor = start;
}
fn selection_on_line(
anchor: CursorPos,
cursor: CursorPos,
line_index: usize,
) -> Option<(usize, usize)> {
let (start, end) = if (anchor.line, anchor.column) <= (cursor.line, cursor.column) {
(anchor, cursor)
} else {
(cursor, anchor)
};
if line_index < start.line || line_index > end.line {
return None;
}
let start_col = if line_index == start.line {
start.column
} else {
0
};
let end_col = if line_index == end.line {
end.column
} else {
usize::MAX
};
Some((start_col, end_col))
}
fn line_start_byte_offsets(text: &str) -> Vec<usize> {
let mut offsets = vec![0usize];
for (i, b) in text.bytes().enumerate() {
if b == b'\n' {
offsets.push(i + 1);
}
}
offsets
}
fn byte_to_cursor_pos(byte: usize, line_starts: &[usize]) -> CursorPos {
let line = match line_starts.binary_search(&byte) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
let column_bytes = byte - line_starts[line];
CursorPos {
line,
column: column_bytes,
}
}
fn line_color_segments(
line: &str,
line_index: usize,
highlights: &[Highlight],
default_color: Color,
) -> Vec<(usize, usize, Color)> {
let char_count = line.chars().count();
let mut boundaries: Vec<usize> = vec![0, char_count];
let mut applicable: Vec<(usize, usize, Color)> = Vec::new();
for h in highlights {
if line_index < h.start.line || line_index > h.end.line {
continue;
}
let start_col = if line_index == h.start.line {
byte_column_to_char_column(line, h.start.column)
} else {
0
};
let end_col = if line_index == h.end.line {
byte_column_to_char_column(line, h.end.column)
} else {
char_count
};
if start_col >= end_col {
continue;
}
boundaries.push(start_col);
boundaries.push(end_col.min(char_count));
applicable.push((start_col, end_col.min(char_count), h.color));
}
boundaries.sort_unstable();
boundaries.dedup();
let mut segments = Vec::new();
for window in boundaries.windows(2) {
let (a, b) = (window[0], window[1]);
if a >= b {
continue;
}
let color = applicable
.iter()
.rev()
.find(|(s, e, _)| *s <= a && b <= *e)
.map(|(_, _, c)| *c)
.unwrap_or(default_color);
segments.push((a, b, color));
}
segments
}
fn byte_column_to_char_column(line: &str, byte_column: usize) -> usize {
line.char_indices()
.position(|(i, _)| i >= byte_column)
.unwrap_or(line.chars().count())
}
struct HighlightColors {
keyword: Color,
string: Color,
comment: Color,
number: Color,
ty: Color,
function: Color,
property: Color,
}
impl HighlightColors {
fn from_theme() -> Self {
Self {
keyword: Color::from_rgb_u8(198, 120, 221),
string: Color::from_rgb_u8(152, 195, 121),
comment: Color::from_rgb_u8(110, 118, 129),
number: Color::from_rgb_u8(209, 154, 102),
ty: Color::from_rgb_u8(224, 175, 104),
function: Color::from_rgb_u8(97, 175, 239),
property: Color::from_rgb_u8(224, 108, 117),
}
}
fn for_capture(&self, name: &str) -> Option<Color> {
match name {
"keyword" => Some(self.keyword),
"string" => Some(self.string),
"comment" => Some(self.comment),
"number" => Some(self.number),
"type" => Some(self.ty),
"function" => Some(self.function),
"property" => Some(self.property),
_ => None,
}
}
}
const RUST_HIGHLIGHT_QUERY: &str = r#"
(line_comment) @comment
(block_comment) @comment
(string_literal) @string
(char_literal) @string
(integer_literal) @number
(float_literal) @number
(type_identifier) @type
(primitive_type) @type
(field_identifier) @property
(function_item name: (identifier) @function)
(call_expression function: (identifier) @function)
(call_expression function: (field_expression field: (field_identifier) @function))
[
"fn" "let" "pub" "struct" "impl" "use" "mod" "if" "else" "match" "for"
"while" "loop" "return" "mut" "const" "static" "trait" "enum" "async"
"await" "move" "in" "as" "ref" "where" "unsafe" "extern" "crate" "super"
"dyn" "break" "continue" "true" "false" "self" "Self"
] @keyword
"#;
#[cfg(test)]
mod editor_interaction_tests {
use super::*;
use crate::support::canvas::Canvas;
use crate::view::{MouseButtonKind, TextInfo};
use std::cell::RefCell;
fn click_and_type(editor: &CodeEditor, click_pos: Point, text: &str) {
let view = crate::view::View::new(crate::support::point::Extent::new(700.0, 400.0));
let canvas = RefCell::new(Canvas::new(700, 400).unwrap());
let bounds = Rect::new(0.0, 0.0, 700.0, 400.0);
let ctx = Context::new(&view, &canvas, bounds);
assert!(
editor.hit_test(&ctx, click_pos, false, false).is_some(),
"hit_test should find the editor at {click_pos:?}"
);
let down = MouseButton {
down: true,
click_count: 1,
button: MouseButtonKind::Left,
modifiers: 0,
pos: click_pos,
};
assert!(
editor.handle_click(&ctx, down),
"mouse-down should be handled"
);
let up = MouseButton {
down: false,
..down
};
editor.handle_click(&ctx, up);
for c in text.chars() {
let handled = editor.handle_text(
&ctx,
TextInfo {
codepoint: c,
modifiers: 0,
},
);
assert!(handled, "handle_text should accept '{c}' once focused");
}
}
#[test]
fn click_then_type_inserts_text() {
let editor = CodeEditor::new().text("");
click_and_type(&editor, Point::new(60.0, 10.0), "hi");
assert_eq!(editor.get_text(), "hi");
}
#[test]
fn click_then_arrow_keys_move_cursor() {
let editor = CodeEditor::new().text("hello");
let view = crate::view::View::new(crate::support::point::Extent::new(700.0, 400.0));
let canvas = RefCell::new(Canvas::new(700, 400).unwrap());
let bounds = Rect::new(0.0, 0.0, 700.0, 400.0);
let ctx = Context::new(&view, &canvas, bounds);
let down = MouseButton {
down: true,
click_count: 1,
button: MouseButtonKind::Left,
modifiers: 0,
pos: Point::new(60.0, 10.0),
};
editor.handle_click(&ctx, down);
let before = *editor.cursor.read().unwrap();
let key = KeyInfo {
key: KeyCode::Left,
action: crate::view::KeyAction::Press,
modifiers: 0,
};
assert!(
editor.handle_key(&ctx, key),
"Left arrow should be handled once focused"
);
let after = *editor.cursor.read().unwrap();
assert_ne!(before, after, "cursor should move after pressing Left");
}
#[test]
fn stretch_y_zero_makes_rendered_height_track_set_height_exactly() {
use crate::element::composite::CompositeBase;
use crate::element::tile::VTile;
use crate::support::point::Extent;
struct StretchySibling;
impl Element for StretchySibling {
fn limits(&self, _ctx: &BasicContext) -> ViewLimits {
ViewLimits::min_size(200.0, 300.0)
}
fn stretch(&self) -> ViewStretch {
ViewStretch::new(1.0, 1.0)
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
let output =
std::sync::Arc::new(CodeEditor::new().width(200.0).height(90.0).stretch_y(0.0));
let vtile = VTile::from_vec(vec![
crate::element::share(StretchySibling),
output.clone() as crate::element::ElementPtr,
]);
let view = crate::view::View::new(Extent::new(200.0, 600.0));
let canvas = RefCell::new(Canvas::new(200, 600).unwrap());
let bounds = Rect::new(0.0, 0.0, 200.0, 600.0);
let ctx = Context::new(&view, &canvas, bounds);
let initial = vtile.bounds_of(&ctx, 1);
assert_eq!(
initial.height(),
90.0,
"output should render at exactly its own min, claiming none of the extra"
);
output.set_height(190.0);
let after = vtile.bounds_of(&ctx, 1);
assert_eq!(
after.height(),
190.0,
"output's rendered height should track set_height exactly (1:1), not half of the delta"
);
}
}