Compare commits

...

2 Commits

Author SHA1 Message Date
Elena Torro
0463db5f96 wip basic cursor 2026-01-27 10:32:22 +01:00
Elena Torro
29247c75e7 wip basis: text editor files 2026-01-26 17:02:45 +01:00
11 changed files with 1982 additions and 88 deletions

View File

@@ -21,6 +21,9 @@
[app.main.data.workspace.specialized-panel :as-alias dwsp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.features :as features]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
[app.util.dom :as dom]
@@ -91,7 +94,12 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
(st/emit! (dw/clear-edition-mode)))
(st/emit! (dw/clear-edition-mode))
;; Stop WASM text editor when exiting edit mode
(when (and text-editing?
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(wasm.api/text-editor-stop)))
(when (and (not text-editing?)
(not blocked)
@@ -184,6 +192,19 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
;; If clicking on a text shape and wasm render is enabled, forward cursor position
(when (and hovering?
(not @space?)
edition ;; Only when already in edit mode
(not drawing-path?)
(not drawing-tool))
(let [hover-shape @hover]
(when (and (= :text (:type hover-shape))
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
(when (and @z?
(not @space?)
(not edition)
@@ -223,8 +244,17 @@
(when (and (not drawing-path?) shape)
(cond
(and editable? (not= id edition) (not read-only?))
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
(do
(st/emit! (dw/select-shape id)
(dw/start-editing-selected))
;; If using wasm text-editor, notify WASM to start editing this shape
;; and set cursor position from the double-click location
(when (and (= type :text)
(features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?)
(let [raw-pt (dom/get-client-position event)]
(wasm.api/text-editor-start id)
(wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt)))))
(some? selected-shape)
(do

View File

@@ -31,6 +31,7 @@
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.worker :as mw]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.wasm :as wasm.wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.globals :as globals]
@@ -165,6 +166,55 @@
(when-not ^boolean value
(reset! z* false))))
;; Route keyboard events to WASM text-editor when it's active
(hooks/use-stream ms/keyboard
(fn [kevent]
(when (and (features/active-feature? @st/state "render-wasm/v1")
wasm.wasm/context-initialized?
(wasm.api/text-editor-is-active?))
(let [key (:key kevent)
t (:type kevent)
native (:native-event kevent)
ctrl? (kbd/mod? native)
shift? (kbd/shift? native)]
(when (= t :down)
;; Prevent default to stop other handlers from processing
(dom/prevent-default native)
(dom/stop-propagation native)
(let [handled?
(cond
;; Escape to exit text editing
(kbd/esc? kevent)
(do (wasm.api/text-editor-stop) true)
;; Ctrl+A for select all
(and ctrl? (= key "a"))
(do (wasm.api/text-editor-select-all) true)
(kbd/backspace? kevent)
(do (wasm.api/text-editor-delete-backward) true)
(kbd/delete? kevent)
(do (wasm.api/text-editor-delete-forward) true)
(kbd/enter? kevent)
(do (wasm.api/text-editor-insert-paragraph) true)
(kbd/left-arrow? kevent)
(do (wasm.api/text-editor-move-cursor 0 shift?) true)
(kbd/right-arrow? kevent)
(do (wasm.api/text-editor-move-cursor 1 shift?) true)
(kbd/up-arrow? kevent)
(do (wasm.api/text-editor-move-cursor 2 shift?) true)
(kbd/down-arrow? kevent)
(do (wasm.api/text-editor-move-cursor 3 shift?) true)
(kbd/home? kevent)
(do (wasm.api/text-editor-move-cursor 4 shift?) true)
(= key "End")
(do (wasm.api/text-editor-move-cursor 5 shift?) true)
;; printable character insertion
(and (string? key) (= (count key) 1) (not ctrl?))
(do (wasm.api/text-editor-insert-text key) true)
:else false)]
;; Request immediate render after any text editing action
(when handled?
(wasm.api/request-render "text-editor-keystroke"))))))))
(hooks/use-stream kbd-zoom-s
(fn [kevent]
(dom/prevent-default kevent)

View File

@@ -452,7 +452,9 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
(when show-text-editor?
;; When render-wasm/v1 is active, text editing is handled by WASM - no DOM editor needed
(when (and show-text-editor?
(not (features/active-feature? @st/state "render-wasm/v1")))
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref

View File

@@ -109,11 +109,28 @@
(mf/element object-svg #js {:shape shape})
(rds/renderToStaticMarkup)))
;; forward declare editor helpers so render can call them
(declare text-editor-update-blink text-editor-render-overlay text-editor-poll-event request-render)
;; This should never be called from the outside.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp)
;; Update text editor blink (so cursor toggles) using the same timestamp
(try
(when wasm/context-initialized?
(text-editor-update-blink timestamp)
;; Render text editor overlay on top of main canvas
(text-editor-render-overlay)
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor-poll-event)]
(when (and ev (not= ev 0))
(request-render "text-editor-event"))))
(catch :default e
(js/console.error "text-editor overlay/update failed:" e)))
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -187,6 +204,128 @@
(declare get-text-dimensions)
;; --------------------------------------------------------------------------
;; Text editor WASM bindings
;; --------------------------------------------------------------------------
(defn text-editor-start
"Activate the WASM text editor for a shape id (uuid). Returns boolean-like result."
[id]
(when wasm/context-initialized?
(let [buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_text_editor_start"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)))))
(defn text-editor-set-cursor-from-point
"Tell WASM to set caret from screen point (client coords)."
[x y]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-update-blink
"Update editor blink state (timestamp_ms float)."
[timestamp-ms]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
"Render editor overlay (cursor & selection) using WASM internal render path."
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-poll-event
"Poll for pending editor events. Returns integer code (0=none,1=content_changed,2=selection_changed,3=needs_layout)."
[]
(when wasm/context-initialized?
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
(defn text-editor-insert-text
"Insert UTF-8 text bytes into editor (writes to wasm memory and calls wasm)."
[text]
(when wasm/context-initialized?
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
size (mem/size buf)
offset (mem/alloc size)]
(mem/write-buffer offset heapu8 buf)
(h/call wasm/internal-module "_text_editor_insert_text")
(mem/free))))
(defn text-editor-delete-backward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward")))
(defn text-editor-delete-forward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward")))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
"Move cursor in direction (u8) and extend-selection bool.
direction: 0=Left,1=Right,2=Up,3=Down,4=LineStart,5=LineEnd"
[direction extend-selection]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
(defn text-editor-select-all
"Select all text in the current text shape."
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-stop
"Stop the text editor and deactivate it."
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_stop")))
(defn text-editor-is-active?
"Check if text editor is currently active."
[]
(when wasm/context-initialized?
(not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
(defn text-editor-export-content
"Export text content from WASM as JSON array of paragraphs with span texts.
Returns a vector of vectors: [[\"span1\" \"span2\"] [\"para2span1\"]]"
[]
(when wasm/context-initialized?
(let [len (h/call wasm/internal-module "_text_editor_export_content")]
(when (pos? len)
(let [json-str (mem/read-string len)]
(mem/free-bytes)
(js/JSON.parse json-str))))))
(defn text-editor-get-active-shape-id
"Get the ID of the shape currently being edited."
[]
(when wasm/context-initialized?
(let [buffer (js/Uint32Array. 4)]
(h/call wasm/internal-module "_text_editor_get_active_shape_id"
(.-byteOffset buffer))
(uuid/from-u32 buffer))))
(defn text-editor-sync-content
"Sync text content from WASM back to the frontend shape.
Updates the shape's :content with the new text strings from WASM.
Returns the shape-id and new-texts if sync was successful, nil otherwise."
[]
(when (and wasm/context-initialized? (text-editor-is-active?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
{:shape-id shape-id
:texts (js->clj new-texts)}))))
(defn update-text-rect!
[id]
(when wasm/context-initialized?

View File

@@ -10,6 +10,7 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
pub mod text_editor;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};

View File

@@ -0,0 +1,243 @@
//! Rendering for the integrated text editor.
//!
//! This module handles rendering cursor (caret) and selection highlighting
//! as overlays on top of text shapes being edited.
use skia_safe::{self as skia, Canvas, Color, Matrix, Paint, Rect};
use crate::shapes::{Shape, TextContent, Type};
use crate::state::{TextCursor, TextEditorState, TextSelection};
/// Default cursor width in pixels
const CURSOR_WIDTH: f32 = 1.5;
/// Selection highlight color (semi-transparent blue)
const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244);
/// Cursor color (black)
const CURSOR_COLOR: Color = Color::BLACK;
/// Render the text editor overlay (cursor and selection) for the active shape.
pub fn render_overlay(
canvas: &Canvas,
editor_state: &TextEditorState,
shape: &Shape,
transform: &Matrix,
) {
if !editor_state.is_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
canvas.save();
canvas.concat(transform);
// Render selection first (behind cursor)
if editor_state.selection.is_selection() {
render_selection(canvas, &editor_state.selection, text_content, shape);
}
// Render cursor if visible (blinking)
if editor_state.cursor_visible {
render_cursor(canvas, &editor_state.selection.focus, text_content, shape);
}
canvas.restore();
}
/// Render the cursor (caret) at the focus position.
fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) {
let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else {
return;
};
let mut paint = Paint::default();
paint.set_color(CURSOR_COLOR);
paint.set_anti_alias(true);
canvas.draw_rect(rect, &paint);
}
/// Render selection highlighting for the selected text range.
fn render_selection(
canvas: &Canvas,
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) {
let rects = calculate_selection_rects(selection, text_content, shape);
if rects.is_empty() {
return;
}
let mut paint = Paint::default();
paint.set_color(SELECTION_COLOR);
paint.set_anti_alias(true);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
}
/// Calculate the cursor rectangle for a given cursor position.
fn calculate_cursor_rect(
cursor: &TextCursor,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
}
// Get laid out paragraphs from layout cache
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
return None;
}
let selrect = shape.selrect();
// Calculate y offset to the target paragraph
let mut y_offset = 0.0;
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
// Use Skia's get_rects_for_range to get cursor position
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
let para_char_count: usize = para.children().iter().map(|span| span.text.len()).sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
} else if char_pos == 0 {
// At start - get rect for first character
let rects = laid_out_para.get_rects_for_range(
0..1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
(0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
// At end - get rect for last character and use right edge
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
}
} else {
// In middle - get rect at position
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
CURSOR_WIDTH,
cursor_height,
));
}
y_offset += laid_out_para.height();
}
None
}
/// Calculate selection rectangles for a given selection.
fn calculate_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = 0.0;
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();
// Check if this paragraph is in selection range
if para_idx < start.paragraph || para_idx > end.paragraph {
y_offset += para_height;
continue;
}
// Calculate character range for this paragraph
let para = &paragraphs[para_idx];
let para_char_count: usize = para.children().iter().map(|span| span.text.len()).sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
} else {
para_char_count
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
RectHeightStyle::Tight,
RectWidthStyle::Tight,
);
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.width(),
r.height(),
));
}
}
y_offset += para_height;
}
rects
}

View File

@@ -316,6 +316,10 @@ impl TextContent {
&self.paragraphs
}
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
&mut self.paragraphs
}
pub fn width(&self) -> f32 {
self.size.width
}
@@ -838,6 +842,10 @@ impl Paragraph {
&self.children
}
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
&mut self.children
}
#[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) {
self.children.push(span);
@@ -847,6 +855,26 @@ impl Paragraph {
self.line_height
}
pub fn letter_spacing(&self) -> f32 {
self.letter_spacing
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn text_direction(&self) -> TextDirection {
self.text_direction
}
pub fn text_decoration(&self) -> Option<TextDecoration> {
self.text_decoration
}
pub fn text_transform(&self) -> Option<TextTransform> {
self.text_transform
}
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();

View File

@@ -1,9 +1,251 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::uuid::Uuid;
/// TODO: Now this is just a tuple with 2 i32 working
/// as indices (paragraph and span).
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
/// Index of the paragraph (0-based)
pub paragraph: usize,
/// Character offset within the paragraph (0-based, counting from paragraph start)
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,
}
}
}
/// Represents a text selection with anchor (start) and focus (end) cursors.
/// When anchor == focus, it's a caret (collapsed selection).
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
/// The fixed end of the selection (where selection started)
pub anchor: TextCursor,
/// The moving end of the selection (where cursor displays)
pub focus: TextCursor,
}
impl TextSelection {
pub fn new() -> Self {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
/// Returns true if the selection is collapsed (just a caret)
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
/// Returns true if there's an actual selection (not collapsed)
pub fn is_selection(&self) -> bool {
!self.is_collapsed()
}
/// Set both anchor and focus to the same position (caret)
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
/// Move focus while keeping anchor fixed (extend selection)
pub fn extend_to(&mut self, cursor: TextCursor) {
self.focus = cursor;
}
/// Collapse selection to the focus position
pub fn collapse_to_focus(&mut self) {
self.anchor = self.focus;
}
/// Collapse selection to the anchor position
pub fn collapse_to_anchor(&mut self) {
self.focus = self.anchor;
}
/// Get the start cursor (leftmost/topmost position)
pub fn start(&self) -> TextCursor {
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 {
self.anchor
} else {
self.focus
}
}
/// Get the end cursor (rightmost/bottommost position)
pub fn end(&self) -> TextCursor {
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 {
self.anchor
} else {
self.focus
}
}
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
}
/// Main text editor state
pub struct TextEditorState {
/// Current text selection (includes cursor position)
pub selection: TextSelection,
/// Whether the editor is currently active/focused
pub is_active: bool,
/// The shape being edited (if any)
pub active_shape_id: Option<Uuid>,
/// Whether the cursor should be visible (for blinking)
pub cursor_visible: bool,
/// Timestamp of last blink toggle (milliseconds)
pub last_blink_time: f64,
/// Preferred x position for vertical cursor movement
pub x_affinity: Option<f32>,
/// Queue of pending events for frontend
pending_events: Vec<EditorEvent>,
}
// Blink interval in milliseconds
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
is_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
x_affinity: None,
pending_events: Vec::new(),
}
}
/// Start editing a text shape
pub fn start(&mut self, shape_id: Uuid) {
self.is_active = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.x_affinity = None;
self.pending_events.clear();
}
/// Stop editing
pub fn stop(&mut self) {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.x_affinity = None;
self.pending_events.clear();
}
/// Set caret position from hit test result
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.clear_x_affinity();
self.push_event(EditorEvent::SelectionChanged);
}
/// Extend selection from hit test result
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);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
/// Update cursor blink state
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.is_active {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
}
}
/// Reset blink to visible (called on user action)
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
}
/// Clear x affinity (called when horizontal movement occurs)
pub fn clear_x_affinity(&mut self) {
self.x_affinity = None;
}
/// Push an event to the queue
pub fn push_event(&mut self, event: EditorEvent) {
// Avoid duplicate consecutive events
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
/// Poll and remove the next pending event
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
}
/// Check if there are pending events
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
// Legacy compatibility method
pub fn set_caret_position_from(&mut self, text_position_with_affinity: TextPositionWithAffinity) {
self.set_caret_from_position(text_position_with_affinity);
}
}
// Keep the old types for backward compatibility during migration
/// Legacy type - use TextCursor instead
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
@@ -15,89 +257,7 @@ impl TextNodePosition {
Self { paragraph, span }
}
#[allow(dead_code)]
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}
pub struct TextPosition {
node: Option<TextNodePosition>,
offset: i32,
}
impl TextPosition {
pub fn new() -> Self {
Self {
node: None,
offset: -1,
}
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.node = node;
self.offset = offset;
}
}
pub struct TextSelection {
focus: TextPosition,
anchor: TextPosition,
}
impl TextSelection {
pub fn new() -> Self {
Self {
focus: TextPosition::new(),
anchor: TextPosition::new(),
}
}
#[allow(dead_code)]
pub fn is_caret(&self) -> bool {
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
}
#[allow(dead_code)]
pub fn is_selection(&self) -> bool {
!self.is_caret()
}
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.focus.set(node, offset);
}
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.anchor.set(node, offset);
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.set_focus(node, offset);
self.set_anchor(node, offset);
}
}
pub struct TextEditorState {
selection: TextSelection,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
}
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.selection.set(
Some(TextNodePosition::new(
text_position_with_affinity.paragraph,
text_position_with_affinity.span,
)),
text_position_with_affinity.offset,
);
}
}

View File

@@ -24,6 +24,11 @@ pub fn uuid_from_u32(id: [u32; 4]) -> Uuid {
uuid_from_u32_quartet(id[0], id[1], id[2], id[3])
}
// pub fn uuid_to_u32(id: &Uuid) -> [u32; 4] {
// let (a, b, c, d) = uuid_to_u32_quartet(id);
// [a, b, c, d]
// }
pub fn get_image(image_id: &Uuid) -> Option<&Image> {
with_state_mut!(state, { state.render_state_mut().images.get(image_id) })
}

View File

@@ -9,3 +9,4 @@ pub mod shapes;
pub mod strokes;
pub mod svg_attrs;
pub mod text;
pub mod text_editor;

View File

File diff suppressed because it is too large Load Diff