diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 84e786d8cf..fb65be820e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -113,6 +113,11 @@ ;; `input`. See on-before-input / on-input. pending-replace-ref (mf/use-ref 0) + ;; Tracks an in-flight pointer drag-selection so `on-pointer-move` only + ;; repaints the selection overlay while a drag is active (mirrors the + ;; WASM `is_pointer_selection_active` guard), not on every hover move. + dragging-ref (mf/use-ref false) + fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false) fallback-families (map (fn [font] (font-family-from-font-id (:font-id font))) fallback-fonts) @@ -157,7 +162,7 @@ (when (some? data) (text-editor/text-editor-composition-update data) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) + (wasm.api/request-render-preserving-target "text-composition")) (reset-input-node (mf/ref-val contenteditable-ref))))) on-composition-end @@ -166,7 +171,7 @@ (let [data (or (.-data event) "")] (text-editor/text-editor-composition-end data) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-composition")) + (wasm.api/request-render-preserving-target "text-composition")) (reset-input-node (mf/ref-val contenteditable-ref)))) on-paste @@ -178,7 +183,7 @@ (when (and text (seq text)) (text-editor/text-editor-insert-text text) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paste")) + (wasm.api/request-render-preserving-target "text-paste")) (reset-input-node (mf/ref-val contenteditable-ref))))) on-copy @@ -201,7 +206,7 @@ (when (and text (seq text)) (text-editor/text-editor-delete-backward) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-cut")))) + (wasm.api/request-render-preserving-target "text-cut")))) (reset-input-node (mf/ref-val contenteditable-ref))))) on-key-down @@ -225,7 +230,7 @@ (do (dom/prevent-default event) (text-editor/text-editor-select-all) - (wasm.api/request-render "text-select-all")) + (wasm.api/render-text-editor-overlay!)) ;; Enter (= key "Enter") @@ -233,7 +238,7 @@ (dom/prevent-default event) (text-editor/text-editor-insert-paragraph) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-paragraph")) + (wasm.api/request-render-preserving-target "text-paragraph")) ;; Backspace (= key "Backspace") @@ -241,7 +246,7 @@ (dom/prevent-default event) (text-editor/text-editor-delete-backward ctrl?) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-backward")) + (wasm.api/request-render-preserving-target "text-delete-backward")) ;; Delete (= key "Delete") @@ -249,51 +254,51 @@ (dom/prevent-default event) (text-editor/text-editor-delete-forward ctrl?) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-delete-forward")) + (wasm.api/request-render-preserving-target "text-delete-forward")) ;; Insert (= key "Insert") (do (dom/prevent-default event) (text-editor/text-editor-toggle-overtype-mode) - (wasm.api/request-render "text-overtype-mode")) + (wasm.api/render-text-editor-overlay!)) ;; Arrow keys (= key "ArrowLeft") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 0 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) (= key "ArrowRight") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 1 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) (= key "ArrowUp") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 2 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) (= key "ArrowDown") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 3 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) (= key "Home") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 4 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) (= key "End") (do (dom/prevent-default event) (text-editor/text-editor-move-cursor 5 ctrl? shift?) - (wasm.api/request-render "text-cursor-move")) + (wasm.api/render-text-editor-overlay!)) ;; Let contenteditable handle text input via on-input :else nil))))) @@ -341,7 +346,7 @@ (text-editor/text-editor-delete-backward))) (text-editor/text-editor-insert-text data) (sync-wasm-text-editor-content!) - (wasm.api/request-render "text-input")) + (wasm.api/request-render-preserving-target "text-input")) (mf/set-ref-val! pending-replace-ref 0) ;; IMPORTANT: do NOT clear the surface here (see keep-input-alive): ;; the browser must retain the just-typed character so the macOS @@ -353,35 +358,47 @@ (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-down off-pt)))) + (mf/set-ref-val! dragging-ref true) + (wasm.api/text-editor-pointer-down off-pt) + ;; Repaint the caret over the cached tiles instead of a full render, + ;; which flashes at high zoom (see `render-text-editor-overlay!`). + (wasm.api/render-text-editor-overlay!)))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-move off-pt)))) + (wasm.api/text-editor-pointer-move off-pt) + ;; Only while dragging: `text-editor-pointer-move` is a no-op + ;; otherwise, so avoid repainting on plain hover. + (when (mf/ref-val dragging-ref) + (wasm.api/render-text-editor-overlay!))))) on-pointer-up (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-up off-pt)))) + (mf/set-ref-val! dragging-ref false) + (wasm.api/text-editor-pointer-up off-pt) + (wasm.api/render-text-editor-overlay!)))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-set-cursor-from-offset off-pt)))) + (wasm.api/text-editor-set-cursor-from-offset off-pt) + (wasm.api/render-text-editor-overlay!)))) on-double-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-select-word-boundary off-pt)))) + (wasm.api/text-editor-select-word-boundary off-pt) + (wasm.api/render-text-editor-overlay!)))) on-focus (mf/use-fn @@ -420,21 +437,30 @@ ;; Focus and select all text on mount (this will trigger on-focus) (.focus node) (text-editor/text-editor-select-all) - (wasm.api/request-render "text-editor-select-all-on-mount")) + (wasm.api/request-render-preserving-target "text-editor-select-all-on-mount")) ;; On unmount, finalize the editor content and then dispose the WASM editor. ;; We finalize on unmount instead of relying on the browser blur event, because ;; it was not being reliable (timing issues, Firefox issues…) (fn [] (on-blur) (text-editor/text-editor-dispose) - (wasm.api/request-render "text-editor-dispose")))) + (wasm.api/request-render-preserving-target "text-editor-dispose")))) (mf/use-effect + (mf/deps) (fn [] (let [timeout-id (atom nil) schedule-blink (fn schedule-blink [] - (when (text-editor/text-editor-has-focus?) - (wasm.api/request-render "cursor-blink")) + ;; The caret only blinks for a collapsed cursor. With an active + ;; selection there is nothing to animate, so skip the repaint: + ;; re-compositing every interval would otherwise redraw the + ;; selection over and over (a visible flicker at high zoom). + (when (and (text-editor/text-editor-has-focus?) + (not (text-editor/text-editor-has-selection?))) + ;; Redraw only the caret (cached frame + overlay) instead of a + ;; full `request-render`, which flashes on zoomed-in views by + ;; kicking off a progressive tile-by-tile shape re-render. + (wasm.api/render-text-editor-overlay!)) (reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))] (schedule-blink) (fn [] diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 4a0c4a80b2..906f68eb2a 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -586,19 +586,22 @@ (wasm.api/text-editor-apply-theme) (wasm.api/request-render "text-editor-colors-theme"))))) - ;; Ruler overlay updates below only change the UI surface, not the shapes. - ;; They use `render-from-cache!` (cached tiles + UI, atomic) instead of a full - ;; `request-render`, which would kick off a progressive tile-by-tile shape - ;; re-render that flashes on zoomed-in views (see penpot ruler-selection flash). + ;; Ruler overlay updates below only change the UI surface, not the shapes, + ;; and they fire on a stable viewbox (toggles / selection changes, not pan). + ;; They re-present via `render-from-backbuffer!` — reusing the crisp last + ;; frame + fresh UI — instead of a full `request-render` (which would kick + ;; off a progressive tile-by-tile re-render that flashes) or a cached-atlas + ;; blit (whose scale-capped atlas flashes crisp->blurry on zoomed-in views, + ;; e.g. when the text editor opens at high zoom). (mf/with-effect [@canvas-init? frame-visible?] (when @canvas-init? (wasm.api/set-rulers-frame-visible! frame-visible?) - (wasm.api/render-from-cache!))) + (wasm.api/render-from-backbuffer!))) (mf/with-effect [@canvas-init? show-rulers?] (when @canvas-init? (wasm.api/set-rulers-visible! show-rulers?) - (wasm.api/render-from-cache!))) + (wasm.api/render-from-backbuffer!))) (mf/with-effect [@canvas-init? show-rulers? offset-x offset-y] (when (and @canvas-init? show-rulers?) @@ -609,7 +612,7 @@ (some-> ruler-selection :width) (some-> ruler-selection :height)] (when (and @canvas-init? show-rulers?) (wasm.api/set-rulers-selection! ruler-selection) - (wasm.api/render-from-cache!))) + (wasm.api/render-from-backbuffer!))) ;; Paint background + rulers instantly, before shapes finish loading. Runs ;; after the ruler push effects so the WASM ruler state is already set. diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 82cf07cc8f..3a6938423f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -351,6 +351,8 @@ (declare request-render) (declare set-shape-vertical-align fonts-from-text-content) (declare reload-renderer!) +(declare request-render-preserving-target) +(declare render-pending?) ;; These are the type of frames we have in our ;; render pipeline. @@ -419,32 +421,52 @@ (when (initialized?) (h/call wasm/internal-module "_free_gpu_resources"))) +;; When set, the next render keeps the previously presented frame on screen +;; while the new tiles are rasterized, instead of clearing the canvas first. +;; See `request-render-preserving-target`. +(defonce ^:private preserve-target-render? (atom false)) + +(defn- drain-text-editor-events! + "Pop and handle every pending text-editor event. + + StylesChanged syncs the caret's current styles to the toolbar. Returns true + when some event needs a full shape re-render (content or layout changed)." + [] + (loop [needs-render? false] + (let [ev (text-editor/text-editor-poll-event)] + (if (or (nil? ev) (= ev TEXT_EDITOR_EVENT_NONE)) + needs-render? + (do + (when (= ev TEXT_EDITOR_EVENT_STYLES_CHANGED) + (let [current-styles (text-editor/text-editor-get-current-styles) + shape-id (text-editor/text-editor-get-active-shape-id)] + (st/emit! (texts/v3-update-text-editor-styles shape-id current-styles)))) + (recur (or needs-render? + (= ev TEXT_EDITOR_EVENT_CONTENT_CHANGED) + (= ev TEXT_EDITOR_EVENT_NEEDS_LAYOUT)))))))) + ;; This should never be called from the outside. (defn- render [timestamp] (when (and wasm/context-initialized? (not @wasm/context-lost?)) - (internal-render timestamp) + ;; SYNC-TILES makes WASM keep the last presented frame while the new tiles + ;; are rasterized, rather than clearing to the background first. The flag is + ;; one-shot on both sides: WASM clears it when the render loop starts, so + ;; the progressive continuation frames behave normally. + (if (compare-and-set! preserve-target-render? true false) + (internal-render timestamp (bit-or wasm/internal-frame-type RENDER-FLAG-SYNC-TILES)) + (internal-render timestamp)) ;; Update text editor blink (so cursor toggles) using the same timestamp (try (when (is-text-editor-wasm-enabled @st/state) (text-editor/text-editor-update-blink timestamp) (text-editor/text-editor-render-overlay) - ;; Poll for editor events; if any event occurs, trigger a re-render - (let [ev (text-editor/text-editor-poll-event)] - (when (and ev (not= ev TEXT_EDITOR_EVENT_NONE)) - ;; When StylesChanged, get the current styles. - (case ev - ;; StylesChanged Event - TEXT_EDITOR_EVENT_STYLES_CHANGED - (let [current-styles (text-editor/text-editor-get-current-styles) - shape-id (text-editor/text-editor-get-active-shape-id)] - (st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))) - - ;; Default case - nil) - - (request-render "text-editor-event")))) + ;; Drain editor events. Only content/layout changes need a full shape + ;; re-render; selection/style changes are already reflected by the + ;; overlay redrawn just above. + (when (drain-text-editor-events!) + (request-render-preserving-target "text-editor-content"))) (catch :default e (js/console.error "text-editor overlay/update failed:" e))) @@ -459,19 +481,51 @@ (when (and wasm/context-initialized? (not @wasm/context-lost?)) (h/call wasm/internal-module "_render_ui_only"))) -(defn render-from-cache! - "Blit the shapes from the cached tile atlas and redraw the UI overlay - (rulers, selection band) fresh on top, in a single atomic frame. The - *shapes* are the cached part (already-rasterized tiles, not rebuilt); the UI - is re-rendered every call, which is what lets it reflect a new selection. +(defn render-from-backbuffer! + "Re-present the last fully rendered frame with the UI overlay (rulers, + selection band) redrawn on top, reusing the crisp Backbuffer instead of + rebuilding it from the scale-capped document atlas. - Use for UI-only updates that don't change shapes (e.g. the ruler selection - band): unlike `request-render`, it never kicks off a progressive, - tile-by-tile shape re-render, so it does not flash on zoomed-in views where - the scene spans multiple tiles." + For UI-only updates that must not kick off a progressive shape re-render. The + alternative — blitting shapes from the cached tile atlas (WASM + `_render_from_cache`, what pan/zoom uses in `set-view-box`) — is fine at + normal zoom, but on a zoomed-in view (>1000%) it is a heavy upscale that + flashes crisp->blurry->crisp. Reusing the Backbuffer is pixel-identical at + any zoom. Only valid on a stable viewbox; pan/zoom must keep using the atlas + blit, whose preview tracks the moving viewport." [] (when (and wasm/context-initialized? (not @wasm/context-lost?)) - (h/call wasm/internal-module "_render_from_cache" 0))) + (h/call wasm/internal-module "_render_from_backbuffer"))) + +(defn render-text-editor-overlay! + "Repaint the text-editor caret and selection over the last fully rendered + frame, without a full (tile-rebuilding) render. + + Caret/selection changes — the blink, clicks, drag-selection, arrow-key + navigation — never alter the shapes. `text-editor-render-caret` re-composes + the frame from the Backbuffer (which still holds the last complete render) + and draws the caret/selection overlay on top, so the shapes are pixel + identical to the last full render at any zoom level. A full `request-render` + would instead blank the canvas and re-rasterize tiles progressively, which + flashes on zoomed-in views; blitting from the cached tile atlas + (`_render_from_cache`) would instead look softer when upscaled (the atlas is + scale-capped), so the blink would alternate crisp/soft — a subtler flash. + Pending editor events are still drained so the style toolbar stays in sync; a + full render is only requested when content or layout actually changed." + [] + (when (and wasm/context-initialized? + (not @wasm/context-lost?) + ;; Skip when a render is already pending: `text-editor-render-caret` + ;; composes from the Backbuffer, but a progressive render (multiple + ;; rAF frames at high zoom) is still rebuilding it. Compositing then + ;; would show a half-built frame — a sparse, timing-dependent flash. + ;; The in-flight render draws the overlay itself when it completes. + (not (render-pending?))) + (when (is-text-editor-wasm-enabled @st/state) + (text-editor/text-editor-update-blink (js/performance.now)) + (text-editor/text-editor-render-caret) + (when (drain-text-editor-events!) + (request-render-preserving-target "text-editor-content"))))) ;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM). (def ^:private TRANSITION_BLUR_RADIUS 4.0) @@ -512,6 +566,12 @@ (defonce shapes-loading? (atom false)) (defonce deferred-render? (atom false)) +(defn render-pending? + "True while a render has been scheduled but not yet completed — including the + frames of an in-progress progressive render." + [] + @pending-render) + (defn- register-deferred-render! [] (reset! deferred-render? true)) @@ -538,6 +598,23 @@ (throw e)))))] (set! wasm/internal-frame-id frame-id)))))) +(defn request-render-preserving-target + "Like `request-render`, but keeps the previously presented frame on screen + while the new tiles are rasterized instead of blanking the canvas first. + + A plain `request-render` goes through WASM's `reset_canvas`, which clears to + the background colour and then fills the viewport tile by tile. When that + rasterization spans more than one frame — as it does on zoomed-in views, + where glyphs are expensive to raster — the cleared canvas is visible as a + flash. Preserving the target is what the renderer already does after a + pan/zoom gesture for exactly this reason. + + Use for shape edits on a stable viewbox (typing in the text editor), where + the previous frame is a good stand-in until the new one is ready." + [requester] + (reset! preserve-target-render? true) + (request-render requester)) + (defn- begin-shapes-loading! [] (reset! shapes-loading? true) @@ -1271,7 +1348,16 @@ "Ends the view interaction and triggers a full-quality render." [] (view-interaction-end!) - (internal-render 0 0)) + ;; Preserve the last presented frame while the new one renders. A plain render + ;; goes through WASM `reset_canvas`, which clears to the background and + ;; re-rasterizes the viewport tile by tile — a visible flash on zoomed-in + ;; views. The content is unchanged across a view-interaction end (only the + ;; view moved), so there is nothing to clear; SYNC-TILES sets `preserve_target` + ;; and lets the new tiles replace the old frame in place. Zoom-end already did + ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered + ;; ends (e.g. selecting a shape opens the options panel and resizes the + ;; viewport), which previously blanked. + (internal-render 0 RENDER-FLAG-SYNC-TILES)) (def render-finish (letfn [(do-render [] diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index d630346159..7b5e15cc0b 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -206,6 +206,14 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_render_overlay"))) +(defn text-editor-render-caret + "Re-compose the frame from the Backbuffer (the last full render) and draw the + caret/selection overlay on top, submitting one atomic frame. Pixel identical + to the last full render at any zoom, so the blink does not flash." + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_render_caret"))) + (defn text-editor-poll-event [] (when wasm/context-initialized? diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 06ad3caaad..1bf7a130ad 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -236,6 +236,23 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render_from_backbuffer() -> Result<()> { + with_state!(state, { + // Re-present the last fully rendered frame from the Backbuffer with the + // UI overlay (rulers) redrawn on top. Unlike `render_from_cache`, it does + // NOT rebuild the Backbuffer from the document tile atlas — that atlas is + // capped at scale <= 1.0, so on a zoomed-in view its blit is a blurry + // upscale and swapping it in reads as a flash. Reusing the Backbuffer is + // pixel-identical at any zoom. Only valid on a stable viewbox (the + // Backbuffer must still match the current view); pan/zoom keeps using + // `render_from_cache`. + state.present_frame(); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index c0786cadfb..b522d5c4f0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -942,6 +942,18 @@ impl RenderState { /// on top of Target, then present. Backbuffer is left clean so it can be reused /// as-is across interactive-transform frames without stale overlay pixels. pub fn present_frame(&mut self, tree: ShapesPoolRef) { + self.compose_frame(tree); + self.surfaces.flush_and_submit(SurfaceId::Target); + } + + /// Compose the frame on Target — the already-rendered Backbuffer plus the + /// UI/debug overlays — *without* submitting it, so a caller can draw extra + /// overlays into the same frame and present them atomically. + /// + /// Unlike `render_from_cache`, this reuses the Backbuffer as-is instead of + /// rebuilding it from the document atlas, so the result is pixel-identical + /// to the last full render at any zoom level. + pub fn compose_frame(&mut self, tree: ShapesPoolRef) { // Viewer masked passes render a partial scene onto a transparent backbuffer. // SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent. if self.viewer_masked_pass() { @@ -959,7 +971,6 @@ impl RenderState { ui::render(self, tree); } debug::render_wasm_label(self); - self.surfaces.flush_and_submit(SurfaceId::Target); } /// Renders only the canvas background and UI surface (rulers/frame), without diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 361b6db165..e469305ee5 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -26,10 +26,11 @@ pub fn render_overlay( canvas.translate((-viewbox.area.left, -viewbox.area.top)); if editor_state.selection.is_selection() { + // With an active selection there is no blinking caret (the caret is one + // end of the selection); drawing it would make it toggle on top of the + // highlight while the selection is held. render_selection(canvas, editor_state, text_content, shape); - } - - if editor_state.cursor_visible { + } else if editor_state.cursor_visible { render_cursor(canvas, zoom, options.dpr, editor_state, text_content, shape); } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 136f5b52ba..f9cd814548 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -68,6 +68,10 @@ impl State { get_render_state().render_from_cache(&self.shapes); } + pub fn present_frame(&mut self) { + get_render_state().present_frame(&self.shapes); + } + pub fn render_ui_only(&mut self) { get_render_state().render_ui_only(&self.shapes); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 3216586998..4531d55816 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -535,11 +535,17 @@ impl TextEditorState { pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); + // Restart the blink so the caret is solid right after it is placed, + // instead of keeping whatever phase it had (which can toggle off at the + // moment of the click and read as a flash). Mirrors the keyboard paths + // (`move_cursor`, `select_all`) which already reset the blink. + 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); } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 1ee391b40b..7df8c6e10d 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -6,9 +6,10 @@ use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::{TextEditorEvent, TextSelection}; +use crate::state::{State, TextEditorEvent, TextSelection}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; +use crate::uuid::Uuid; use crate::wasm::fills::RawFillData; use crate::wasm::text::{ helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, @@ -849,6 +850,63 @@ pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { get_text_editor_state().update_blink(timestamp_ms); } +/// Refresh a text shape's layout if the editor marked it dirty, so the +/// caret/selection overlay is measured against up-to-date glyph geometry. +fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) { + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return; + }; + + let selrect = shape.selrect(); + + let Type::Text(text_content) = &mut shape.shape_type else { + return; + }; + + if text_content.needs_update_layout() { + text_content.update_layout(selrect); + } +} + +/// Repaint the caret/selection over the last fully rendered frame. +/// +/// Re-composes Target from the Backbuffer (which still holds the last complete +/// render) and draws the editor overlay on top, in a single submitted frame. +/// +/// This exists because the caret blink must erase the previous caret, which +/// means restoring the pixels underneath it. Doing that via `render_from_cache` +/// rebuilds the frame from the document atlas, and that atlas is capped at +/// scale <= 1.0 — on a zoomed-in view it gets blitted heavily upscaled, so the +/// blink alternates between the crisp render and a softer approximation, which +/// reads as a flash. Reusing the Backbuffer is pixel-identical at any zoom. +#[no_mangle] +pub extern "C" fn text_editor_render_caret() { + with_state!(state, { + let Some(shape_id) = get_text_editor_state().active_shape_id else { + return; + }; + + update_text_layout_if_needed(state, shape_id); + + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + get_render_state().compose_frame(&state.shapes); + + let canvas = get_render_state().surfaces.canvas(SurfaceId::Target); + let viewbox = get_render_state().viewbox; + text_editor_render::render_overlay( + canvas, + &viewbox, + &get_render_state().options, + get_text_editor_state(), + shape, + ); + get_render_state().flush_and_submit(); + }); +} + #[no_mangle] pub extern "C" fn text_editor_render_overlay() { with_state!(state, { @@ -856,18 +914,7 @@ pub extern "C" fn text_editor_render_overlay() { return; }; - if let Some(shape) = state.shapes.get(&shape_id) { - if let Type::Text(text_content) = &shape.shape_type { - if text_content.needs_update_layout() { - let selrect = shape.selrect(); - if let Some(shape) = state.shapes.get_mut(&shape_id) { - if let Type::Text(text_content) = &mut shape.shape_type { - text_content.update_layout(selrect); - } - } - } - } - } + update_text_layout_if_needed(state, shape_id); let Some(shape) = state.shapes.get(&shape_id) else { return;