🎉 Add overtype mode to text editor

This commit is contained in:
Aitor Moreno 2026-04-21 14:52:49 +02:00
parent 35f8e1b084
commit ca228e7d34
7 changed files with 183 additions and 30 deletions

View File

@ -189,6 +189,13 @@
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
;; Insert
(= key "Insert")
(do
(dom/prevent-default event)
(text-editor/text-editor-toggle-overtype-mode)
(wasm.api/request-render "text-overtype-mode"))
;; Arrow keys
(= key "ArrowLeft")
(do

View File

@ -204,6 +204,7 @@
(def text-editor-blur text-editor/text-editor-blur)
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode)
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up)

View File

@ -143,6 +143,12 @@
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-toggle-overtype-mode
"Toggles overtype mode"
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_toggle_overtype_mode")))
(defn text-editor-pointer-down
[{:keys [x y]}]
(when wasm/context-initialized?

View File

@ -3,7 +3,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextEditorState, TextSelection};
use crate::view::Viewbox;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{BlendMode, Canvas, Paint, Rect};
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
pub fn render_overlay(
canvas: &Canvas,
@ -48,11 +48,26 @@ fn render_cursor(
};
let mut cursor_rect = Rect::new_empty();
cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height());
cursor_rect.set_xywh(
rect.x(),
rect.y(),
if editor_state.is_overtype_mode {
rect.width()
} else {
editor_state.theme.cursor_width / zoom
},
rect.height(),
);
let mut paint = Paint::default();
paint.set_color(editor_state.theme.cursor_color);
paint.set_anti_alias(true);
paint.set_anti_alias(false);
if editor_state.is_overtype_mode {
paint.set_blend_mode(BlendMode::Exclusion);
paint.set_color(Color::WHITE);
} else {
paint.set_blend_mode(BlendMode::SrcOver);
paint.set_color(editor_state.theme.cursor_color);
}
let shape_matrix = shape.get_matrix();
canvas.save();
@ -132,9 +147,9 @@ fn calculate_cursor_rect(
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 {
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, 0.0, laid_out_para.height())
(0.0, 0.0, 1.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
@ -143,9 +158,9 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(r.left(), r.top(), r.height())
(r.left(), r.top(), r.width(), r.height())
} else {
(0.0, 0.0, laid_out_para.height())
(0.0, 0.0, 1.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
@ -155,9 +170,14 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(r.right(), r.top(), r.height())
(r.right(), r.top(), r.width(), r.height())
} else {
(laid_out_para.longest_line(), 0.0, laid_out_para.height())
(
laid_out_para.longest_line(),
0.0,
1.0,
laid_out_para.height(),
)
}
} else {
let rects = laid_out_para.get_rects_for_range(
@ -167,18 +187,18 @@ fn calculate_cursor_rect(
);
if !rects.is_empty() {
let r = &rects[0].rect;
(r.left(), r.top(), r.height())
(r.left(), r.top(), r.width(), r.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, 0.0, laid_out_para.height())
(pos.position as f32, 0.0, 1.0, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
cursor_x,
y_offset + cursor_y,
1.0, // cursor_width
cursor_width, // cursor_width
cursor_height,
));
}

View File

@ -101,7 +101,8 @@ pub enum TextEditorEvent {
/// FIXME: It should be better to get these constants from the frontend through the API.
const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184);
const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
const CURSOR_WIDTH: f32 = 1.0;
const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0;
#[derive(Debug)]
pub struct TextEditorStyles {
@ -257,6 +258,7 @@ impl TextEditorStyles {
pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_color: Color,
pub cursor_width: f32,
}
pub struct TextComposition {
@ -326,9 +328,10 @@ pub struct TextEditorState {
// selecting something with the pointer.
pub is_pointer_selection_active: bool,
pub is_click_event_skipped: bool,
pub is_overtype_mode: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pub last_blink_time_ms: f32,
pub current_styles: TextEditorStyles,
pending_events: Vec<TextEditorEvent>,
}
@ -339,15 +342,17 @@ impl TextEditorState {
theme: TextEditorTheme {
selection_color: SELECTION_COLOR,
cursor_color: CURSOR_COLOR,
cursor_width: CURSOR_WIDTH,
},
selection: TextSelection::new(),
composition: TextComposition::new(),
has_focus: false,
is_pointer_selection_active: false,
is_click_event_skipped: false,
is_overtype_mode: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
last_blink_time_ms: 0.0,
pending_events: Vec::new(),
current_styles: TextEditorStyles::new(),
}
@ -357,9 +362,10 @@ impl TextEditorState {
self.has_focus = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.last_blink_time_ms = 0.0;
self.selection.reset();
self.is_pointer_selection_active = false;
self.is_overtype_mode = false;
self.pending_events.clear();
}
@ -367,9 +373,10 @@ impl TextEditorState {
self.has_focus = false;
// self.active_shape_id = None;
self.cursor_visible = false;
self.last_blink_time = 0.0;
self.last_blink_time_ms = 0.0;
// self.selection.reset();
self.is_pointer_selection_active = false;
self.is_overtype_mode = false;
self.pending_events.clear();
}
@ -377,9 +384,10 @@ impl TextEditorState {
self.has_focus = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.last_blink_time = 0.0;
self.last_blink_time_ms = 0.0;
self.selection.reset();
self.is_pointer_selection_active = false;
self.is_overtype_mode = false;
self.pending_events.clear();
}
@ -516,6 +524,14 @@ impl TextEditorState {
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn set_overtype_mode(&mut self, overtype_mode: bool) {
self.is_overtype_mode = overtype_mode;
}
pub fn toggle_overtype_mode(&mut self) {
self.set_overtype_mode(!self.is_overtype_mode);
}
fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool {
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
@ -687,27 +703,27 @@ impl TextEditorState {
styles_were_updated
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
pub fn update_blink(&mut self, timestamp_ms: f32) {
if !self.has_focus {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
if self.last_blink_time_ms == 0.0 {
self.last_blink_time_ms = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
let elapsed = timestamp_ms - self.last_blink_time_ms;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
self.last_blink_time_ms = timestamp_ms;
}
}
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.last_blink_time_ms = 0.0;
}
pub fn push_event(&mut self, event: TextEditorEvent) {

View File

@ -276,6 +276,43 @@ pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<
None
}
pub fn replace_text_with_newlines(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<TextPositionWithAffinity> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() {
return None;
}
let mut current_cursor = *cursor;
if let Some(new_offset) = replace_text_at_cursor(text_content, &current_cursor, lines[0]) {
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset);
} else {
return None;
}
for line in lines.iter().skip(1) {
if !split_paragraph_at_cursor(text_content, &current_cursor) {
break;
}
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0);
if let Some(new_offset) = replace_text_at_cursor(text_content, &current_cursor, line) {
current_cursor = TextPositionWithAffinity::new_without_affinity(
current_cursor.paragraph,
new_offset,
);
}
}
Some(current_cursor)
}
/// Insert text at a cursor position, splitting on newlines into multiple paragraphs.
/// Returns the final cursor position after insertion.
pub fn insert_text_with_newlines(
@ -356,6 +393,58 @@ pub fn insert_text_at_cursor(
Some(cursor.offset + text.chars().count())
}
/// Replace text at cursor position (overtype mode). Replaces N characters where N is the
/// length of the input text, returning the new cursor offset.
pub fn replace_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let text_len = text.chars().count();
if text_len == 0 {
return Some(cursor.offset);
}
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let para = &mut paragraphs[cursor.paragraph];
let children = para.children_mut();
if children.is_empty() {
return None;
}
if children.len() == 1 && children[0].text.is_empty() {
children[0].set_text(text.to_string());
return Some(text_len);
}
let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
let mut new_text = span.text.clone();
let byte_offset = new_text
.char_indices()
.nth(offset_in_span)
.map(|(i, _)| i)
.unwrap_or(new_text.len());
let end_byte_offset = new_text
.char_indices()
.nth(offset_in_span + text_len)
.map(|(i, _)| i)
.unwrap_or(new_text.len());
new_text.replace_range(byte_offset..end_byte_offset, text);
span.set_text(new_text);
Some(cursor.offset + text_len)
}
/// Delete a range of text specified by a selection.
pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
let start = selection.start();

View File

@ -455,6 +455,15 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
with_state_mut!(state, {
state.text_editor_state.toggle_overtype_mode();
Ok(())
})
}
// FIXME: Review if all the return Ok(()) should be Err instead.
#[no_mangle]
#[wasm_error]
@ -491,11 +500,16 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
}
let cursor = state.text_editor_state.selection.focus;
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
if !state.text_editor_state.is_overtype_mode {
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
}
} else if let Some(new_cursor) =
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
state.text_editor_state.selection.set_caret(new_cursor);
}
text_content.layout.paragraphs.clear();
@ -876,7 +890,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
}
#[no_mangle]
pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) {
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
with_state_mut!(state, {
state.text_editor_state.update_blink(timestamp_ms);
});