Compare commits

...

3 Commits

Author SHA1 Message Date
Aitor Moreno
b2e689a645 ♻️ Refactor TextCursor and TextPositionWithAffinity 2026-02-24 15:56:54 +01:00
Elena Torró
32d4026641 Merge pull request #8338 from penpot/azazeln28-11826-compute-selection-rects-from-pointer-events
🎉 Add compute selection rects from pointer events
2026-02-24 12:53:08 +01:00
Aitor Moreno
4477b2b4a0 🎉 Compute selection rects from pointer events 2026-02-24 11:09:45 +01:00
12 changed files with 475 additions and 403 deletions

View File

@@ -19,7 +19,6 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.data.workspace.texts :as dwt]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -50,41 +49,42 @@
(mf/deps id blocked hidden type selected edition drawing-tool text-editing?
node-editing? grid-editing? drawing-path? create-comment? @z? @space?
panning read-only?)
(fn [bevent]
(fn [event]
;; We need to handle editor related stuff here because
;; handling on editor dom node does not works properly.
(let [target (dom/get-target bevent)
(let [target (dom/get-target event)
editor (txu/closest-text-editor-content target)]
;; Capture mouse pointer to detect the movements even if cursor
;; leaves the viewport or the browser itself
;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture
(if editor
(.setPointerCapture editor (.-pointerId bevent))
(.setPointerCapture target (.-pointerId bevent))))
(.setPointerCapture editor (.-pointerId event))
(.setPointerCapture target (.-pointerId event))))
(when (or (dom/class? (dom/get-target bevent) "viewport-controls")
(dom/class? (dom/get-target bevent) "viewport-selrect")
(dom/child? (dom/get-target bevent) (dom/query ".grid-layout-editor")))
(when (or (dom/class? (dom/get-target event) "viewport-controls")
(dom/class? (dom/get-target event) "viewport-selrect")
(dom/child? (dom/get-target event) (dom/query ".grid-layout-editor")))
(dom/stop-propagation bevent)
(dom/stop-propagation event)
(when-not @z?
(let [event (dom/event->native-event bevent)
ctrl? (kbd/ctrl? event)
meta? (kbd/meta? event)
shift? (kbd/shift? event)
alt? (kbd/alt? event)
mod? (kbd/mod? event)
(let [native-event (dom/event->native-event event)
ctrl? (kbd/ctrl? native-event)
meta? (kbd/meta? native-event)
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
mod? (kbd/mod? native-event)
off-pt (dom/get-offset-position native-event)
left-click? (and (not panning) (dom/left-mouse? bevent))
middle-click? (and (not panning) (dom/middle-mouse? bevent))]
left-click? (and (not panning) (dom/left-mouse? event))
middle-click? (and (not panning) (dom/middle-mouse? event))]
(cond
(or middle-click? (and left-click? @space?))
(do
(dom/prevent-default bevent)
(dom/prevent-default event)
(if mod?
(let [raw-pt (dom/get-client-position event)
(let [raw-pt (dom/get-client-position native-event)
pt (uwvv/point->viewport raw-pt)]
(st/emit! (dw/start-zooming pt)))
(st/emit! (dw/start-panning))))
@@ -94,18 +94,23 @@
(st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?)
::dwsp/interrupt)
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt)))
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode))
;; FIXME: I think this is not completely correct because this
;; is going to happen even when clicking or selecting text.
;; Sync and stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
#_(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:finalize? true)))
(wasm.api/text-editor-stop)))
(when (and (not text-editing?)
(not blocked)
@@ -187,10 +192,14 @@
alt? (kbd/alt? event)
meta? (kbd/meta? event)
hovering? (some? @hover)
native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)]
(st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?))
;; FIXME: Maybe we can transform this into a cond instead
;; of multiple (when)s.
(when (and hovering?
(not @space?)
(not edition)
@@ -198,6 +207,8 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
;; FIXME: Maybe we can move into a function of the kind
;; "text-editor-on-click"
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
@@ -208,9 +219,7 @@
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
;; FIXME
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
(wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt)))))
(when (and @z?
(not @space?)
@@ -261,6 +270,12 @@
wasm.wasm/context-initialized?)
(wasm.api/text-editor-start id)))
(and editable? (= id edition) (not read-only?)
(= type :text)
(features/active-feature? @st/state "text-editor-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-select-all)
(some? selected-shape)
(do
(reset! hover selected-shape)
@@ -310,20 +325,24 @@
;; Release pointer on mouse up
(.releasePointerCapture target (.-pointerId event)))
(let [event (dom/event->native-event event)
ctrl? (kbd/ctrl? event)
shift? (kbd/shift? event)
alt? (kbd/alt? event)
meta? (kbd/meta? event)
(let [native-event (dom/event->native-event event)
off-pt (dom/get-offset-position native-event)
ctrl? (kbd/ctrl? native-event)
shift? (kbd/shift? native-event)
alt? (kbd/alt? native-event)
meta? (kbd/meta? native-event)
left-click? (= 1 (.-which event))
middle-click? (= 2 (.-which event))]
left-click? (= 1 (.-which native-event))
middle-click? (= 2 (.-which native-event))]
(when left-click?
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)))
(st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))
(when (wasm.api/text-editor-is-active?)
(wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))
(when middle-click?
(dom/prevent-default event)
(dom/prevent-default native-event)
;; We store this so in Firefox the middle button won't do a paste of the content
(mf/set-ref-val! disable-paste-ref true)
@@ -381,7 +400,9 @@
(let [last-position (mf/use-var nil)]
(mf/use-fn
(fn [event]
(let [raw-pt (dom/get-client-position event)
(let [native-event (unchecked-get event "nativeEvent")
off-pt (dom/get-offset-position native-event)
raw-pt (dom/get-client-position event)
pt (uwvv/point->viewport raw-pt)
;; We calculate the delta because Safari's MouseEvent.movementX/Y drop
@@ -390,6 +411,12 @@
(gpt/subtract raw-pt @last-position)
(gpt/point 0 0))]
;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think
;; in the future (when we handle the UI in the render) should be better to
;; have a "wasm.api/pointer-move" function that works as an entry point for
;; all the pointer-move events.
(wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))
(rx/push! move-stream pt)
(reset! last-position raw-pt)
(st/emit! (mse/->PointerEvent :delta delta

View File

@@ -87,7 +87,11 @@
(def text-editor-start text-editor/text-editor-start)
(def text-editor-stop text-editor/text-editor-stop)
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
(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)
(def text-editor-is-active? text-editor/text-editor-is-active?)
(def text-editor-select-all text-editor/text-editor-select-all)
(def text-editor-sync-content text-editor/text-editor-sync-content)
(def dpr
@@ -263,22 +267,6 @@
[attrs]
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -996,6 +984,22 @@
(render-finish)
(perf/end-measure "set-view-box::zoom")))))
(defn update-text-rect!
[id]
(when wasm/context-initialized?
(mw/emit!
{:cmd :index/update-text-rect
:page-id (:current-page-id @st/state)
:shape-id id
:dimensions (get-text-dimensions id)})))
(defn- ensure-text-content
"Guarantee that the shape always sends a valid text tree to WASM. When the
content is nil (freshly created text) we fall back to
tc/default-text-content so the renderer receives typography information."
[content]
(or content (tc/v2-default-text-content)))
(defn set-object
[shape]
(perf/begin-measure "set-object")

View File

@@ -27,6 +27,21 @@
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-pointer-down
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when wasm/context-initialized?
@@ -83,9 +98,12 @@
(h/call wasm/internal-module "_text_editor_stop")))
(defn text-editor-is-active?
[]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
([id]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active_with_id" id)))))
([]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active"))))))
(defn text-editor-export-content
[]

View File

@@ -76,3 +76,4 @@ export function getFills(fillStyle) {
const [color, opacity] = getColor(fillStyle);
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
}

View File

@@ -162,12 +162,15 @@ class TextEditorPlayground {
}
this.#module.call("use_shape", ...textShape.id);
// FIXME: This function doesn't exists anymore.
/*
const caretPosition = this.#module.call(
"get_caret_position_at",
e.offsetX,
e.offsetY,
);
console.log("caretPosition", caretPosition);
*/
};
#onResize = (_entries) => {

View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- esbuild

View File

@@ -45,7 +45,11 @@ fn render_cursor(
paint.set_color(editor_state.theme.cursor_color);
paint.set_anti_alias(true);
let shape_matrix = shape.get_matrix();
canvas.save();
canvas.concat(&shape_matrix);
canvas.draw_rect(rect, &paint);
canvas.restore();
}
fn render_selection(
@@ -65,9 +69,14 @@ fn render_selection(
paint.set_blend_mode(BlendMode::Multiply);
paint.set_color(editor_state.theme.selection_color);
paint.set_anti_alias(true);
let shape_matrix = shape.get_matrix();
canvas.save();
canvas.concat(&shape_matrix);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
canvas.restore();
}
fn vertical_align_offset(
@@ -99,12 +108,10 @@ fn calculate_cursor_rect(
return None;
}
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
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
// We need to handle edge cases:
// - At start of paragraph: use position 0
@@ -157,8 +164,8 @@ fn calculate_cursor_rect(
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
cursor_x,
y_offset,
editor_state.theme.cursor_width,
cursor_height,
));
@@ -182,7 +189,6 @@ fn calculate_selection_rects(
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
@@ -203,13 +209,13 @@ fn calculate_selection_rects(
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -225,8 +231,8 @@ fn calculate_selection_rects(
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
selrect.x() + r.left(),
selrect.y() + y_offset + r.top(),
r.left(),
y_offset + r.top(),
r.width(),
r.height(),
));

View File

@@ -258,6 +258,18 @@ pub fn all_with_ancestors(
}
impl Shape {
pub fn get_relative_point(
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
) -> Option<Point> {
let inv_view_matrix = view_matrix.invert()?;
let inv_shape_matrix = shape_matrix.invert()?;
let transform_matrix: Matrix = Matrix::concat(&inv_shape_matrix, &inv_view_matrix);
let shape_relative_point = transform_matrix.map_point(*point);
Some(shape_relative_point)
}
pub fn new(id: Uuid) -> Self {
Self {
id,

View File

@@ -14,6 +14,7 @@ use skia_safe::{
textlayout::ParagraphBuilder,
textlayout::ParagraphStyle,
textlayout::PositionWithAffinity,
textlayout::Affinity,
Contains,
};
@@ -112,29 +113,55 @@ impl TextContentSize {
}
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Default)]
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
pub offset: i32,
pub position_with_affinity: PositionWithAffinity,
pub paragraph: usize,
pub offset: usize,
}
impl PartialEq for TextPositionWithAffinity {
fn eq(&self, other: &Self) -> bool {
self.paragraph == other.paragraph
&& self.offset == other.offset
}
}
impl TextPositionWithAffinity {
pub fn new(
position_with_affinity: PositionWithAffinity,
paragraph: i32,
span: i32,
offset: i32,
paragraph: usize,
offset: usize,
) -> Self {
Self {
position_with_affinity,
paragraph,
span,
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
}
}
}
#[derive(Debug)]
@@ -421,14 +448,19 @@ impl TextContent {
self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y);
}
pub fn get_caret_position_at(&self, point: &Point) -> Option<TextPositionWithAffinity> {
pub fn get_caret_position_from_shape_coords(
&self,
point: &Point,
) -> Option<TextPositionWithAffinity> {
let mut offset_y = 0.0;
let layout_paragraphs = self.layout.paragraphs.iter().flatten();
let mut paragraph_index: i32 = -1;
let mut span_index: i32 = -1;
let mut paragraph_index: usize = 0;
// IMPORTANT! I'm keeping this because I think it should be better to have the span index
// cached the same way we keep the paragraph index.
#[allow(dead_code)]
let mut _span_index: usize = 0;
for layout_paragraph in layout_paragraphs {
paragraph_index += 1;
let start_y = offset_y;
let end_y = offset_y + layout_paragraph.height();
@@ -449,16 +481,15 @@ impl TextContent {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
let mut computed_position: usize = 0;
let mut span_offset: usize = 0;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
_span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
@@ -475,22 +506,23 @@ impl TextContent {
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
position_with_affinity.position as usize - start_position;
break;
}
computed_position += length;
_span_index += 1;
}
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
span_index,
span_offset,
position_with_affinity.position,
));
}
}
offset_y += layout_paragraph.height();
paragraph_index += 1;
}
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
@@ -507,7 +539,6 @@ impl TextContent {
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
@@ -515,6 +546,16 @@ impl TextContent {
None
}
pub fn get_caret_position_from_screen_coords(
&self,
point: &Point,
view_matrix: &Matrix,
shape_matrix: &Matrix,
) -> Option<TextPositionWithAffinity> {
let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?;
self.get_caret_position_from_shape_coords(&shape_rel_point)
}
/// Builds the ParagraphBuilders necessary to render
/// this text.
pub fn paragraph_builder_group_from_text(

View File

@@ -1,37 +1,16 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::shapes::{TextContent, TextPositionWithAffinity};
use crate::uuid::Uuid;
use skia_safe::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,
}
}
}
use skia_safe::{
textlayout::{Affinity, PositionWithAffinity},
Color,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
pub anchor: TextPositionWithAffinity,
pub focus: TextPositionWithAffinity,
}
impl TextSelection {
@@ -39,10 +18,10 @@ impl TextSelection {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
Self {
anchor: cursor,
focus: cursor,
anchor: position,
focus: position,
}
}
@@ -54,12 +33,12 @@ impl TextSelection {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
self.focus = cursor;
}
@@ -71,24 +50,24 @@ impl TextSelection {
self.focus = self.anchor;
}
pub fn start(&self) -> TextCursor {
pub fn start(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.char_offset <= self.focus.char_offset {
} else if self.anchor.offset <= self.focus.offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
pub fn end(&self) -> TextPositionWithAffinity {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.char_offset >= self.focus.char_offset {
} else if self.anchor.offset >= self.focus.offset {
self.anchor
} else {
self.focus
@@ -99,7 +78,7 @@ impl TextSelection {
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
pub enum TextEditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
@@ -122,10 +101,13 @@ pub struct TextEditorState {
pub theme: TextEditorTheme,
pub selection: TextSelection,
pub is_active: bool,
// This property indicates that we've started
// selecting something with the pointer.
pub is_pointer_selection_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<EditorEvent>,
pending_events: Vec<TextEditorEvent>,
}
impl TextEditorState {
@@ -138,6 +120,7 @@ impl TextEditorState {
},
selection: TextSelection::new(),
is_active: false,
is_pointer_selection_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
@@ -151,6 +134,7 @@ impl TextEditorState {
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.is_pointer_selection_active = false;
self.pending_events.clear();
}
@@ -158,21 +142,77 @@ impl TextEditorState {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.is_pointer_selection_active = false;
self.pending_events.clear();
self.reset_blink();
}
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(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
pub fn start_pointer_selection(&mut self) -> bool {
if self.is_pointer_selection_active {
return false;
}
self.is_pointer_selection_active = true;
true
}
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(cursor);
pub fn stop_pointer_selection(&mut self) -> bool {
if !self.is_pointer_selection_active {
return false;
}
self.is_pointer_selection_active = false;
true
}
pub fn select_all(&mut self, content: &TextContent) -> bool {
self.is_pointer_selection_active = false;
self.set_caret_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
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 {
return false;
};
let num_spans = (last_paragraph.children().len() - 1) as i32;
let Some(last_text_span) = last_paragraph.children().last() else {
return false;
};
let mut offset = 0;
for span in last_paragraph.children() {
offset += span.text.len();
}
self.extend_selection_from_position(TextPositionWithAffinity::new(
PositionWithAffinity {
position: offset as i32,
affinity: Affinity::Upstream,
},
num_paragraphs,
num_spans,
last_text_span.text.len() as i32,
offset as i32,
));
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
self.push_event(crate::state::EditorEvent::SelectionChanged);
true
}
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.set_caret(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
self.selection.extend_to(*position);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
@@ -198,41 +238,17 @@ impl TextEditorState {
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) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
pub fn poll_event(&mut self) -> TextEditorEvent {
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!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,16 +1,13 @@
use macros::ToJs;
use super::{fills::RawFillData, fonts::RawFontStyle};
use crate::math::{Matrix, Point};
use crate::mem::{self, SerializableResult};
use crate::shapes::{
self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type,
};
use crate::utils::{uuid_from_u32, uuid_from_u32_quartet};
use crate::{
with_current_shape, with_current_shape_mut, with_state, with_state_mut,
with_state_mut_current_shape, STATE,
};
use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE};
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
@@ -388,32 +385,6 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
});
}
#[no_mangle]
pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 {
with_state_mut_current_shape!(state, |shape: &Shape| {
if let Type::Text(text_content) = &shape.shape_type {
let mut matrix = Matrix::new_identity();
let shape_matrix = shape.get_concatenated_matrix(&state.shapes);
let view_matrix = state.render_state.viewbox.get_matrix();
if let Some(inv_view_matrix) = view_matrix.invert() {
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
if let Some(position_with_affinity) =
text_content.get_caret_position_at(&mapped_point)
{
return position_with_affinity.position_with_affinity.position;
}
}
} else {
panic!("Trying to get caret position of a shape that it's not a text shape");
}
});
-1
}
const RAW_POSITION_DATA_SIZE: usize = size_of::<shapes::PositionData>();
impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData {

View File

@@ -1,12 +1,11 @@
use macros::ToJs;
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextCursor, TextSelection};
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextSelection};
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::{with_state, with_state_mut, STATE};
use macros::ToJs;
#[derive(PartialEq, ToJs)]
#[repr(u8)]
@@ -54,6 +53,17 @@ pub extern "C" fn text_editor_is_active() -> bool {
with_state!(state, { state.text_editor_state.is_active })
}
#[no_mangle]
pub extern "C" fn text_editor_is_active_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
let Some(active_shape_id) = state.text_editor_state.active_shape_id else {
return false;
};
state.text_editor_state.is_active && active_shape_id == shape_id
})
}
#[no_mangle]
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
with_state!(state, {
@@ -70,45 +80,25 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
}
#[no_mangle]
pub extern "C" fn text_editor_select_all() {
pub extern "C" fn text_editor_select_all() -> bool {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
return false;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
return false;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
return false;
};
let Type::Text(text_content) = &shape.shape_type else {
return;
return false;
};
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
return;
}
let last_para_idx = paragraphs.len() - 1;
let last_para = &paragraphs[last_para_idx];
let total_chars: usize = last_para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
use crate::state::TextCursor;
state.text_editor_state.selection.anchor = TextCursor::new(0, 0);
state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars);
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged);
});
state.text_editor_state.select_all(text_content)
})
}
#[no_mangle]
@@ -121,146 +111,127 @@ pub extern "C" fn text_editor_poll_event() -> u8 {
// ============================================================================
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let (shape_matrix, view_matrix, selrect, vertical_align) = {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
(
shape.get_concatenated_matrix(&state.shapes),
state.render_state.viewbox.get_matrix(),
shape.selrect(),
shape.vertical_align(),
)
};
let Some(inv_view_matrix) = view_matrix.invert() else {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Some(inv_shape_matrix) = shape_matrix.invert() else {
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let mut matrix = Matrix::new_identity();
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&inv_shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
let Some(shape) = state.shapes.get_mut(&shape_id) else {
return;
};
let Type::Text(text_content) = &mut shape.shape_type else {
return;
};
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
let bounds = text_content.bounds;
text_content.update_layout(bounds);
}
// Calculate vertical alignment offset (same as in render/text_editor.rs)
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
let vertical_offset = match vertical_align {
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
_ => 0.0,
};
// Adjust point: subtract selrect offset and vertical alignment
// The text layout expects coordinates where (0, 0) is the top-left of the text content
let adjusted_point = Point::new(
mapped_point.x - selrect.x(),
mapped_point.y - selrect.y() - vertical_offset,
);
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
let point = Point::new(x, y);
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let shape_matrix = shape.get_matrix();
state.text_editor_state.start_pointer_selection();
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) {
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state
.text_editor_state
.extend_selection_from_position(position);
}
});
}
#[no_mangle]
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let shape_matrix = shape.get_matrix();
let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix)
else {
return;
};
if !state.text_editor_state.is_pointer_selection_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state
.text_editor_state
.extend_selection_from_position(&position);
}
state.text_editor_state.stop_pointer_selection();
});
}
#[no_mangle]
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
}
let view_matrix: Matrix = state.render_state.viewbox.get_matrix();
let point = Point::new(x, y);
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return;
};
let (shape_matrix, view_matrix, selrect, vertical_align) = {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
(
shape.get_concatenated_matrix(&state.shapes),
state.render_state.viewbox.get_matrix(),
shape.selrect(),
shape.vertical_align(),
)
};
let Some(inv_view_matrix) = view_matrix.invert() else {
let Some(shape) = state.shapes.get(&shape_id) else {
return;
};
let Some(inv_shape_matrix) = shape_matrix.invert() else {
let shape_matrix = shape.get_matrix();
let Type::Text(text_content) = &shape.shape_type else {
return;
};
let mut matrix = Matrix::new_identity();
matrix.post_concat(&inv_view_matrix);
matrix.post_concat(&inv_shape_matrix);
let mapped_point = matrix.map_point(Point::new(x, y));
let Some(shape) = state.shapes.get_mut(&shape_id) else {
return;
};
let Type::Text(text_content) = &mut shape.shape_type else {
return;
};
if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
let bounds = text_content.bounds;
text_content.update_layout(bounds);
}
// Calculate vertical alignment offset (same as in render/text_editor.rs)
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
let vertical_offset = match vertical_align {
crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
_ => 0.0,
};
// Adjust point: subtract selrect offset and vertical alignment
let adjusted_point = Point::new(
mapped_point.x - selrect.x(),
mapped_point.y - selrect.y() - vertical_offset,
);
if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
state
.text_editor_state
.extend_selection_from_position(position);
if let Some(position) =
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
{
state.text_editor_state.set_caret_from_position(&position);
}
});
}
@@ -305,7 +276,7 @@ pub extern "C" fn text_editor_insert_text() {
let cursor = state.text_editor_state.selection.focus;
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);
}
@@ -315,10 +286,10 @@ pub extern "C" fn text_editor_insert_text() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -365,10 +336,10 @@ pub extern "C" fn text_editor_delete_backward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -413,10 +384,10 @@ pub extern "C" fn text_editor_delete_forward() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -452,7 +423,7 @@ pub extern "C" fn text_editor_insert_paragraph() {
let cursor = state.text_editor_state.selection.focus;
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);
}
@@ -462,10 +433,10 @@ pub extern "C" fn text_editor_insert_paragraph() {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::ContentChanged);
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::EditorEvent::NeedsLayout);
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@@ -523,7 +494,7 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::EditorEvent::SelectionChanged);
.push_event(crate::state::TextEditorEvent::SelectionChanged);
});
}
@@ -740,12 +711,12 @@ pub extern "C" fn text_editor_export_selection() -> *mut u8 {
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -793,9 +764,9 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
let sel = &state.text_editor_state.selection;
unsafe {
*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(3) = sel.focus.char_offset as u32;
*buffer_ptr.add(3) = sel.focus.offset as u32;
}
1
})
@@ -805,7 +776,7 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
// 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();
if cursor.paragraph >= paragraphs.len() {
return None;
@@ -823,7 +794,7 @@ fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shap
let mut y_offset = valign_offset;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
let char_pos = cursor.offset;
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let rects = laid_out_para.get_rects_for_range(
@@ -898,13 +869,13 @@ fn get_selection_rects(
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
start.offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
end.offset
} else {
para_char_count
};
@@ -943,40 +914,40 @@ fn paragraph_char_count(para: &Paragraph) -> usize {
}
/// 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() {
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 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.
fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
if cursor.char_offset > 0 {
TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
fn move_cursor_backward(cursor: &TextPositionWithAffinity, paragraphs: &[Paragraph]) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
} else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextCursor::new(prev_para, char_count)
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
} else {
*cursor
}
}
/// 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 char_count = paragraph_char_count(para);
if cursor.char_offset < char_count {
TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
if cursor.offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 {
TextCursor::new(cursor.paragraph + 1, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
} else {
*cursor
}
@@ -984,52 +955,52 @@ fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCur
/// Move cursor up by one line.
fn move_cursor_up(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(prev_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else {
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
fn move_cursor_down(
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextCursor {
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.char_offset.min(char_count);
TextCursor::new(next_para, new_offset)
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else {
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.
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
TextCursor::new(cursor.paragraph, 0)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
/// 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
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextCursor::new(cursor.paragraph, char_count)
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
// ============================================================================
@@ -1057,7 +1028,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.
fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextCursor,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
@@ -1077,7 +1048,7 @@ fn insert_text_at_cursor(
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 span = &mut children[span_idx];
@@ -1092,7 +1063,7 @@ fn insert_text_at_cursor(
new_text.insert_str(byte_offset, 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.
@@ -1108,18 +1079,18 @@ fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelect
if start.paragraph == end.paragraph {
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
end.char_offset,
start.offset,
end.offset,
);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.char_offset,
start.offset,
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() {
let end_para_children: Vec<_> =
@@ -1218,13 +1189,13 @@ fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offs
}
/// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option<TextCursor> {
if cursor.char_offset > 0 {
fn delete_char_before(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.char_offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
Some(TextCursor::new(cursor.paragraph, delete_pos))
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(cursor.paragraph, delete_pos))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
@@ -1240,14 +1211,14 @@ fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Op
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 {
None
}
}
/// 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();
if cursor.paragraph >= paragraphs.len() {
return;
@@ -1255,9 +1226,9 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
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];
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 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
@@ -1270,7 +1241,7 @@ fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
}
/// 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();
if cursor.paragraph >= paragraphs.len() {
return false;
@@ -1278,7 +1249,7 @@ fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor
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;
};