Compare commits

...

1 Commits

Author SHA1 Message Date
Aitor Moreno
a12b59d101 ♻️ Refactor TextCursor and TextPositionWithAffinity 2026-02-25 09:59:02 +01:00
4 changed files with 187 additions and 197 deletions

View File

@@ -111,7 +111,7 @@ fn calculate_cursor_rect(
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph { if idx == cursor.paragraph {
let char_pos = cursor.char_offset; let char_pos = cursor.offset;
// For cursor, we get a zero-width range at the position // For cursor, we get a zero-width range at the position
// We need to handle edge cases: // We need to handle edge cases:
// - At start of paragraph: use position 0 // - At start of paragraph: use position 0
@@ -209,13 +209,13 @@ fn calculate_selection_rects(
.sum(); .sum();
let range_start = if para_idx == start.paragraph { let range_start = if para_idx == start.paragraph {
start.char_offset start.offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.char_offset end.offset
} else { } else {
para_char_count para_char_count
}; };

View File

@@ -11,6 +11,7 @@ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{ use skia_safe::{
self as skia, self as skia,
paint::{self, Paint}, paint::{self, Paint},
textlayout::Affinity,
textlayout::ParagraphBuilder, textlayout::ParagraphBuilder,
textlayout::ParagraphStyle, textlayout::ParagraphStyle,
textlayout::PositionWithAffinity, textlayout::PositionWithAffinity,
@@ -112,31 +113,51 @@ impl TextContentSize {
} }
} }
#[derive(Debug, Copy, Clone)] #[derive(Debug, Clone, Copy, Default)]
pub struct TextPositionWithAffinity { pub struct TextPositionWithAffinity {
#[allow(dead_code)] #[allow(dead_code)]
pub position_with_affinity: PositionWithAffinity, pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32, pub paragraph: usize,
#[allow(dead_code)] pub offset: usize,
pub span: i32, }
#[allow(dead_code)]
pub span_relative_offset: i32, impl PartialEq for TextPositionWithAffinity {
pub offset: i32, fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph && self.offset == other.offset
}
} }
impl TextPositionWithAffinity { impl TextPositionWithAffinity {
pub fn new( pub fn new(
position_with_affinity: PositionWithAffinity, position_with_affinity: PositionWithAffinity,
paragraph: i32, paragraph: usize,
span: i32, offset: usize,
span_relative_offset: i32,
offset: i32,
) -> Self { ) -> Self {
Self { Self {
position_with_affinity, position_with_affinity,
paragraph, paragraph,
span, offset,
span_relative_offset, }
}
pub fn empty() -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
},
paragraph: 0,
offset: 0,
}
}
pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self {
Self {
position_with_affinity: PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Downstream,
},
paragraph,
offset, offset,
} }
} }
@@ -433,10 +454,11 @@ impl TextContent {
let mut offset_y = 0.0; let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten(); let layout_paragraphs = self.layout.paragraphs.iter().flatten();
let mut paragraph_index: i32 = -1; // IMPORTANT! I'm keeping this because I think it should be better to have the span index
let mut span_index: i32 = -1; // cached the same way we keep the paragraph index.
for layout_paragraph in layout_paragraphs { #[allow(dead_code)]
paragraph_index += 1; let mut _span_index: usize = 0;
for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() {
let start_y = offset_y; let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height(); let end_y = offset_y + layout_paragraph.height();
@@ -453,20 +475,22 @@ impl TextContent {
if matches { if matches {
let position_with_affinity = let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point); layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) { if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
// Computed position keeps the current position in terms // Computed position keeps the current position in terms
// of number of characters of text. This is used to know // of number of characters of text. This is used to know
// in which span we are. // in which span we are.
let mut computed_position = 0; let mut computed_position: usize = 0;
let mut span_offset = 0;
// This could be useful in the future as part of the TextPositionWithAffinity.
#[allow(dead_code)]
let mut _span_offset: usize = 0;
// If paragraph has no spans, default to span 0, offset 0 // If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() { if paragraph.children().is_empty() {
span_index = 0; _span_index = 0;
span_offset = 0; _span_offset = 0;
} else { } else {
for span in paragraph.children() { for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count(); let length = span.text.chars().count();
let start_position = computed_position; let start_position = computed_position;
let end_position = computed_position + length; let end_position = computed_position + length;
@@ -475,27 +499,26 @@ impl TextContent {
// Handle empty spans: if the span is empty and current position // Handle empty spans: if the span is empty and current position
// matches the start, this is the right span // matches the start, this is the right span
if length == 0 && current_position == start_position { if length == 0 && current_position == start_position {
span_offset = 0; _span_offset = 0;
break; break;
} }
if start_position <= current_position if start_position <= current_position
&& end_position >= current_position && end_position >= current_position
{ {
span_offset = _span_offset =
position_with_affinity.position - start_position as i32; position_with_affinity.position as usize - start_position;
break; break;
} }
computed_position += length; computed_position += length;
_span_index += 1;
} }
} }
return Some(TextPositionWithAffinity::new( return Some(TextPositionWithAffinity::new(
position_with_affinity, position_with_affinity,
paragraph_index, paragraph_index,
span_index, position_with_affinity.position as usize,
span_offset,
position_with_affinity.position,
)); ));
} }
} }
@@ -516,9 +539,7 @@ impl TextContent {
return Some(TextPositionWithAffinity::new( return Some(TextPositionWithAffinity::new(
default_position, default_position,
0, // paragraph 0 0, // paragraph 0
0, // span 0
0, // offset 0 0, // offset 0
0,
)); ));
} }

View File

@@ -7,34 +7,10 @@ use skia_safe::{
Color, Color,
}; };
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection { pub struct TextSelection {
pub anchor: TextCursor, pub anchor: TextPositionWithAffinity,
pub focus: TextCursor, pub focus: TextPositionWithAffinity,
} }
impl TextSelection { impl TextSelection {
@@ -42,10 +18,10 @@ impl TextSelection {
Self::default() Self::default()
} }
pub fn from_cursor(cursor: TextCursor) -> Self { pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
Self { Self {
anchor: cursor, anchor: position,
focus: cursor, focus: position,
} }
} }
@@ -57,12 +33,12 @@ impl TextSelection {
!self.is_collapsed() !self.is_collapsed()
} }
pub fn set_caret(&mut self, cursor: TextCursor) { pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = cursor; self.anchor = cursor;
self.focus = cursor; self.focus = cursor;
} }
pub fn extend_to(&mut self, cursor: TextCursor) { pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
self.focus = cursor; self.focus = cursor;
} }
@@ -74,24 +50,24 @@ impl TextSelection {
self.focus = self.anchor; self.focus = self.anchor;
} }
pub fn start(&self) -> TextCursor { pub fn start(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph < self.focus.paragraph { if self.anchor.paragraph < self.focus.paragraph {
self.anchor self.anchor
} else if self.anchor.paragraph > self.focus.paragraph { } else if self.anchor.paragraph > self.focus.paragraph {
self.focus self.focus
} else if self.anchor.char_offset <= self.focus.char_offset { } else if self.anchor.offset <= self.focus.offset {
self.anchor self.anchor
} else { } else {
self.focus self.focus
} }
} }
pub fn end(&self) -> TextCursor { pub fn end(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph > self.focus.paragraph { if self.anchor.paragraph > self.focus.paragraph {
self.anchor self.anchor
} else if self.anchor.paragraph < self.focus.paragraph { } else if self.anchor.paragraph < self.focus.paragraph {
self.focus self.focus
} else if self.anchor.char_offset >= self.focus.char_offset { } else if self.anchor.offset >= self.focus.offset {
self.anchor self.anchor
} else { } else {
self.focus self.focus
@@ -102,7 +78,7 @@ impl TextSelection {
/// Events that the text editor can emit for frontend synchronization /// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)] #[repr(u8)]
pub enum EditorEvent { pub enum TextEditorEvent {
None = 0, None = 0,
ContentChanged = 1, ContentChanged = 1,
SelectionChanged = 2, SelectionChanged = 2,
@@ -131,7 +107,7 @@ pub struct TextEditorState {
pub active_shape_id: Option<Uuid>, pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool, pub cursor_visible: bool,
pub last_blink_time: f64, pub last_blink_time: f64,
pending_events: Vec<EditorEvent>, pending_events: Vec<TextEditorEvent>,
} }
impl TextEditorState { impl TextEditorState {
@@ -189,56 +165,44 @@ impl TextEditorState {
pub fn select_all(&mut self, content: &TextContent) -> bool { pub fn select_all(&mut self, content: &TextContent) -> bool {
self.is_pointer_selection_active = false; self.is_pointer_selection_active = false;
self.set_caret_from_position(TextPositionWithAffinity::new( self.set_caret_from_position(&TextPositionWithAffinity::empty());
PositionWithAffinity { let num_paragraphs = content.paragraphs().len() - 1;
position: 0,
affinity: Affinity::Downstream,
},
0,
0,
0,
0,
));
let num_paragraphs = (content.paragraphs().len() - 1) as i32;
let Some(last_paragraph) = content.paragraphs().last() else { let Some(last_paragraph) = content.paragraphs().last() else {
return false; return false;
}; };
let num_spans = (last_paragraph.children().len() - 1) as i32; #[allow(dead_code)]
let Some(last_text_span) = last_paragraph.children().last() else { let _num_spans = last_paragraph.children().len() - 1;
let Some(_last_text_span) = last_paragraph.children().last() else {
return false; return false;
}; };
let mut offset = 0; let mut offset = 0;
for span in last_paragraph.children() { for span in last_paragraph.children() {
offset += span.text.len(); offset += span.text.len();
} }
self.extend_selection_from_position(TextPositionWithAffinity::new( self.extend_selection_from_position(&TextPositionWithAffinity::new(
PositionWithAffinity { PositionWithAffinity {
position: offset as i32, position: offset as i32,
affinity: Affinity::Upstream, affinity: Affinity::Upstream,
}, },
num_paragraphs, num_paragraphs,
num_spans, offset,
last_text_span.text.len() as i32,
offset as i32,
)); ));
self.reset_blink(); self.reset_blink();
self.push_event(crate::state::EditorEvent::SelectionChanged); self.push_event(crate::state::TextEditorEvent::SelectionChanged);
true true
} }
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize); self.selection.set_caret(*position);
self.selection.set_caret(cursor);
self.reset_blink(); self.reset_blink();
self.push_event(EditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) { pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize); self.selection.extend_to(*position);
self.selection.extend_to(cursor);
self.reset_blink(); self.reset_blink();
self.push_event(EditorEvent::SelectionChanged); self.push_event(TextEditorEvent::SelectionChanged);
} }
pub fn update_blink(&mut self, timestamp_ms: f64) { pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -264,41 +228,17 @@ impl TextEditorState {
self.last_blink_time = 0.0; self.last_blink_time = 0.0;
} }
pub fn push_event(&mut self, event: EditorEvent) { pub fn push_event(&mut self, event: TextEditorEvent) {
if self.pending_events.last() != Some(&event) { if self.pending_events.last() != Some(&event) {
self.pending_events.push(event); self.pending_events.push(event);
} }
} }
pub fn poll_event(&mut self) -> EditorEvent { pub fn poll_event(&mut self) -> TextEditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None) self.pending_events.pop().unwrap_or(TextEditorEvent::None)
} }
pub fn has_pending_events(&self) -> bool { pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty() !self.pending_events.is_empty()
} }
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
pub span: i32,
}
impl TextNodePosition {
pub fn new(paragraph: i32, span: i32) -> Self {
Self { paragraph, span }
}
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
} }

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect}; use crate::math::{Matrix, Point, Rect};
use crate::mem; use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign}; use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextCursor, TextSelection}; use crate::state::TextSelection;
use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet; use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE}; use crate::{with_state, with_state_mut, STATE};
@@ -132,7 +132,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
if let Some(position) = if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{ {
state.text_editor_state.set_caret_from_position(position); state.text_editor_state.set_caret_from_position(&position);
} }
}); });
} }
@@ -168,7 +168,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
{ {
state state
.text_editor_state .text_editor_state
.extend_selection_from_position(position); .extend_selection_from_position(&position);
} }
}); });
} }
@@ -203,7 +203,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
{ {
state state
.text_editor_state .text_editor_state
.extend_selection_from_position(position); .extend_selection_from_position(&position);
} }
state.text_editor_state.stop_pointer_selection(); state.text_editor_state.stop_pointer_selection();
}); });
@@ -231,7 +231,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
if let Some(position) = if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{ {
state.text_editor_state.set_caret_from_position(position); state.text_editor_state.set_caret_from_position(&position);
} }
}); });
} }
@@ -276,7 +276,8 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus; let cursor = state.text_editor_state.selection.focus;
if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) { if let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
let new_cursor = TextCursor::new(cursor.paragraph, new_offset); let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, new_offset);
state.text_editor_state.selection.set_caret(new_cursor); state.text_editor_state.selection.set_caret(new_cursor);
} }
@@ -286,10 +287,10 @@ pub extern "C" fn text_editor_insert_text() {
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged); .push_event(crate::state::TextEditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout); .push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -336,10 +337,10 @@ pub extern "C" fn text_editor_delete_backward() {
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged); .push_event(crate::state::TextEditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout); .push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -384,10 +385,10 @@ pub extern "C" fn text_editor_delete_forward() {
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged); .push_event(crate::state::TextEditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout); .push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -423,7 +424,8 @@ pub extern "C" fn text_editor_insert_paragraph() {
let cursor = state.text_editor_state.selection.focus; let cursor = state.text_editor_state.selection.focus;
if split_paragraph_at_cursor(text_content, &cursor) { if split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor = TextCursor::new(cursor.paragraph + 1, 0); let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor); state.text_editor_state.selection.set_caret(new_cursor);
} }
@@ -433,10 +435,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged); .push_event(crate::state::TextEditorEvent::ContentChanged);
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout); .push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id); state.render_state.mark_touched(shape_id);
}); });
@@ -494,7 +496,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink(); state.text_editor_state.reset_blink();
state state
.text_editor_state .text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged); .push_event(crate::state::TextEditorEvent::SelectionChanged);
}); });
} }
@@ -711,12 +713,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
.map(|span| span.text.chars().count()) .map(|span| span.text.chars().count())
.sum(); .sum();
let range_start = if para_idx == start.paragraph { let range_start = if para_idx == start.paragraph {
start.char_offset start.offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.char_offset end.offset
} else { } else {
para_char_count para_char_count
}; };
@@ -764,9 +766,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
let sel = &state.text_editor_state.selection; let sel = &state.text_editor_state.selection;
unsafe { unsafe {
*buffer_ptr = sel.anchor.paragraph as u32; *buffer_ptr = sel.anchor.paragraph as u32;
*buffer_ptr.add(1) = sel.anchor.char_offset as u32; *buffer_ptr.add(1) = sel.anchor.offset as u32;
*buffer_ptr.add(2) = sel.focus.paragraph as u32; *buffer_ptr.add(2) = sel.focus.paragraph as u32;
*buffer_ptr.add(3) = sel.focus.char_offset as u32; *buffer_ptr.add(3) = sel.focus.offset as u32;
} }
1 1
}) })
@@ -776,7 +778,11 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// HELPERS: Cursor & Selection // HELPERS: Cursor & Selection
// ============================================================================ // ============================================================================
fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option<Rect> { fn get_cursor_rect(
text_content: &TextContent,
cursor: &TextPositionWithAffinity,
shape: &Shape,
) -> Option<Rect> {
let paragraphs = text_content.paragraphs(); let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() { if cursor.paragraph >= paragraphs.len() {
return None; return None;
@@ -794,7 +800,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
let mut y_offset = valign_offset; let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph { if idx == cursor.paragraph {
let char_pos = cursor.char_offset; let char_pos = cursor.offset;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range( let rects = laid_out_para.get_rects_for_range(
@@ -869,13 +875,13 @@ fn get_selection_rects(
.map(|span| span.text.chars().count()) .map(|span| span.text.chars().count())
.sum(); .sum();
let range_start = if para_idx == start.paragraph { let range_start = if para_idx == start.paragraph {
start.char_offset start.offset
} else { } else {
0 0
}; };
let range_end = if para_idx == end.paragraph { let range_end = if para_idx == end.paragraph {
end.char_offset end.offset
} else { } else {
para_char_count para_char_count
}; };
@@ -914,40 +920,49 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
} }
/// Clamp a cursor position to valid bounds within the text content. /// Clamp a cursor position to valid bounds within the text content.
fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor { fn clamp_cursor(
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if paragraphs.is_empty() { if paragraphs.is_empty() {
return TextCursor::new(0, 0); return TextPositionWithAffinity::new_without_affinity(0, 0);
} }
let para_idx = cursor.paragraph.min(paragraphs.len() - 1); let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]); let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = cursor.char_offset.min(para_len); let char_offset = position.offset.min(para_len);
TextCursor::new(para_idx, char_offset) TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
} }
/// Move cursor left by one character. /// Move cursor left by one character.
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { fn move_cursor_backward(
if cursor.char_offset > 0 { cursor: &TextPositionWithAffinity,
TextCursor::new(cursor.paragraph, cursor.char_offset - 1) paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
} else if cursor.paragraph > 0 { } else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1; let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]); let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextCursor::new(prev_para, char_count) TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
} else { } else {
*cursor *cursor
} }
} }
/// Move cursor right by one character. /// Move cursor right by one character.
fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { fn move_cursor_forward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
let para = &paragraphs[cursor.paragraph]; let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para); let char_count = paragraph_char_count(para);
if cursor.char_offset < char_count { if cursor.offset < char_count {
TextCursor::new(cursor.paragraph, cursor.char_offset + 1) TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 { } else if cursor.paragraph < paragraphs.len() - 1 {
TextCursor::new(cursor.paragraph + 1, 0) TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
} else { } else {
*cursor *cursor
} }
@@ -955,52 +970,58 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
/// Move cursor up by one line. /// Move cursor up by one line.
fn move_cursor_up( fn move_cursor_up(
cursor: &TextCursor, cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph], paragraphs: &[Paragraph],
_text_content: &TextContent, _text_content: &TextContent,
_shape: &Shape, _shape: &Shape,
) -> TextCursor { ) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics // TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 { if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1; let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]); let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.char_offset.min(char_count); let new_offset = cursor.offset.min(char_count);
TextCursor::new(prev_para, new_offset) TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else { } else {
TextCursor::new(cursor.paragraph, 0) TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
} }
} }
/// Move cursor down by one line. /// Move cursor down by one line.
fn move_cursor_down( fn move_cursor_down(
cursor: &TextCursor, cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph], paragraphs: &[Paragraph],
_text_content: &TextContent, _text_content: &TextContent,
_shape: &Shape, _shape: &Shape,
) -> TextCursor { ) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics // TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 { if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1; let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]); let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.char_offset.min(char_count); let new_offset = cursor.offset.min(char_count);
TextCursor::new(next_para, new_offset) TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else { } else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]); let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count) TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
} }
} }
/// Move cursor to start of current line. /// Move cursor to start of current line.
fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor { fn move_cursor_line_start(
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics // TODO: Implement proper line-start using line metrics
TextCursor::new(cursor.paragraph, 0) TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
} }
/// Move cursor to end of current line. /// Move cursor to end of current line.
fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { fn move_cursor_line_end(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-end using line metrics // TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]); let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count) TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
} }
// ============================================================================ // ============================================================================
@@ -1028,7 +1049,7 @@ fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, u
/// Insert text at a cursor position. Returns the new character offset after insertion. /// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor( fn insert_text_at_cursor(
text_content: &mut TextContent, text_content: &mut TextContent,
cursor: &TextCursor, cursor: &TextPositionWithAffinity,
text: &str, text: &str,
) -> Option<usize> { ) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
@@ -1048,7 +1069,7 @@ fn insert_text_at_cursor(
return Some(text.chars().count()); return Some(text.chars().count());
} }
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?; let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let children = para.children_mut(); let children = para.children_mut();
let span = &mut children[span_idx]; let span = &mut children[span_idx];
@@ -1063,7 +1084,7 @@ fn insert_text_at_cursor(
new_text.insert_str(byte_offset, text); new_text.insert_str(byte_offset, text);
span.set_text(new_text); span.set_text(new_text);
Some(cursor.char_offset + text.chars().count()) Some(cursor.offset + text.chars().count())
} }
/// Delete a range of text specified by a selection. /// Delete a range of text specified by a selection.
@@ -1077,20 +1098,16 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
} }
if start.paragraph == end.paragraph { if start.paragraph == end.paragraph {
delete_range_in_paragraph( delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
);
} else { } else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]); let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph( delete_range_in_paragraph(
&mut paragraphs[start.paragraph], &mut paragraphs[start.paragraph],
start.char_offset, start.offset,
start_para_len, start_para_len,
); );
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset); delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
if end.paragraph < paragraphs.len() { if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> = let end_para_children: Vec<_> =
@@ -1189,13 +1206,19 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
} }
/// Delete the character before the cursor. Returns the new cursor position. /// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> { fn delete_char_before(
if cursor.char_offset > 0 { text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph]; let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.char_offset - 1; let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.char_offset); delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextCursor::new(cursor.paragraph, delete_pos)) Some(TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
delete_pos,
))
} else if cursor.paragraph > 0 { } else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1; let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
@@ -1211,14 +1234,17 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
paragraphs.remove(cursor.paragraph); paragraphs.remove(cursor.paragraph);
Some(TextCursor::new(prev_para_idx, prev_para_len)) Some(TextPositionWithAffinity::new_without_affinity(
prev_para_idx,
prev_para_len,
))
} else { } else {
None None
} }
} }
/// Delete the character after the cursor. /// Delete the character after the cursor.
fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) { fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() { if cursor.paragraph >= paragraphs.len() {
return; return;
@@ -1226,9 +1252,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]); let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.char_offset < para_len { if cursor.offset < para_len {
let para = &mut paragraphs[cursor.paragraph]; let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1); delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 { } else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1; let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect(); let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
@@ -1241,7 +1267,10 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
} }
/// Split a paragraph at the cursor position. Returns true if split was successful. /// Split a paragraph at the cursor position. Returns true if split was successful.
fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool { fn split_paragraph_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> bool {
let paragraphs = text_content.paragraphs_mut(); let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() { if cursor.paragraph >= paragraphs.len() {
return false; return false;
@@ -1249,7 +1278,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
let para = &paragraphs[cursor.paragraph]; let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else { let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
return false; return false;
}; };