🐛 Fix caret not mimicking text color (#10866)

This commit is contained in:
Belén Albeza 2026-07-28 16:25:47 +02:00 committed by GitHub
parent cb21f6401a
commit 704cd40182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 139 additions and 13 deletions

View File

@ -440,6 +440,8 @@
(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)]
;; Keep the caret color matching the text at the caret position.
(text-editor/text-editor-apply-caret-color (:fills current-styles))
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles))))
(recur (or needs-render?
(= ev TEXT_EDITOR_EVENT_CONTENT_CHANGED)
@ -531,9 +533,13 @@
(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")))))
;; Drain before painting so the caret color (updated on StylesChanged when
;; the caret crosses into differently-colored text) is applied before the
;; overlay is drawn, instead of lagging a frame behind.
(let [needs-render? (drain-text-editor-events!)]
(text-editor/text-editor-render-caret)
(when needs-render?
(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)

View File

@ -128,7 +128,6 @@
nil)))
(def ^:private selection-color-css-var "--text-editor-selection-background-color")
(def ^:private caret-color-css-var "--text-editor-caret-color")
(defn- resolve-theme-color
"Resolve a themed CSS color variable (read from the document body) into a
@ -140,17 +139,54 @@
(dom/get-css-variable css-var js/document.body))]
(sr-clr/hex->u32argb color opacity)))
;; ARGB u32 for opaque white, painted with a Difference blend mode so the caret
;; always shows the inverted color of the background.
(def ^:private caret-invert-color 0xffffffff)
(defn text-editor-apply-theme
"Push the current theme's selection and caret colors (read from the CSS
custom properties on the document body) into the WASM text editor. The
editor theme is a persistent singleton, so call once after init and again
on every color-scheme change."
"Push the current theme's selection color (read from the CSS custom properties
on the document body) into the WASM text editor, together with the default
caret: white with invert, so it shows the inverted color of the background.
The caret only switches to a solid text color (invert off) via
`text-editor-apply-caret-color`. The editor theme is a persistent singleton,
so call once after init and again on every color-scheme change."
[]
(when wasm/context-initialized?
(let [selection (resolve-theme-color selection-color-css-var)]
(when selection
(h/call wasm/internal-module "_text_editor_apply_theme" selection caret-invert-color true)))))
(defn- solid-fill?
[fill]
(some? (:fill-color fill)))
(defn resolve-caret-color
"Compute the caret color from the text `fills` at the caret (as returned by
`text-editor-get-current-styles`), as `{:color <argb-u32> :invert? bool}`:
- when there is at least one solid fill, match the topmost (visible) one,
painted normally (`:invert? false`);
- otherwise (no fill, gradient, image fills, mixed selection, …) use white
with `:invert? true`, which the renderer paints with a Difference blend so
the caret is the inverted color of whatever is behind it."
[fills]
(if-let [solid (and (sequential? fills)
(some #(when (solid-fill? %) %) fills))]
{:color (sr-clr/hex->u32argb (:fill-color solid) (:fill-opacity solid))
:invert? false}
{:color caret-invert-color
:invert? true}))
(defn text-editor-apply-caret-color
"Update the WASM text-editor caret color so it matches the text at the caret
(see `resolve-caret-color`). Re-applies the current theme selection color
unchanged, since the WASM theme is a singleton holding both."
[fills]
(when wasm/context-initialized?
(let [selection (resolve-theme-color selection-color-css-var)
caret (resolve-theme-color caret-color-css-var)]
(when (and selection caret)
(h/call wasm/internal-module "_text_editor_apply_theme" selection caret)))))
{:keys [color invert?]} (resolve-caret-color fills)]
(when selection
(h/call wasm/internal-module "_text_editor_apply_theme" selection color invert?)))))
(defn text-editor-focus
[id]

View File

@ -0,0 +1,67 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.render-wasm.text-editor-caret-color-test
"Unit tests for the text-editor caret color resolution.
The caret matches the topmost solid fill of the text at the caret. For
anything else (no fill, gradient, image fills, mixed selection) it falls back
to an inverted caret (white painted with a Difference blend)."
(:require
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true]))
(def ^:private white 0xffffffff)
(defn- solid [color opacity]
{:fill-color color :fill-opacity opacity})
(defn- gradient [stops]
{:fill-color-gradient {:type :linear :stops stops}})
(t/deftest resolve-caret-color-solid
(t/testing "a single solid fill is matched, painted normally"
(t/is (= {:color (sr-clr/hex->u32argb "#ff0000" 1) :invert? false}
(text-editor/resolve-caret-color [(solid "#ff0000" 1)]))))
(t/testing "the topmost (first, visible) solid fill wins over the ones below"
(t/is (= {:color (sr-clr/hex->u32argb "#ff0000" 1) :invert? false}
(text-editor/resolve-caret-color [(solid "#ff0000" 1) (solid "#00ff00" 1)]))))
(t/testing "the solid fill opacity is preserved"
(t/is (= {:color (sr-clr/hex->u32argb "#00ff00" 0.5) :invert? false}
(text-editor/resolve-caret-color [{:fill-image {:id "x"}} (solid "#00ff00" 0.5)]))))
(t/testing "a solid fill takes precedence over a gradient"
(t/is (= {:color (sr-clr/hex->u32argb "#ff0000" 1) :invert? false}
(text-editor/resolve-caret-color
[(solid "#ff0000" 1)
(gradient [{:color "#000000" :opacity 1 :offset 0}
{:color "#ffffff" :opacity 1 :offset 1}])])))))
(t/deftest resolve-caret-color-inverted
(t/testing "a gradient falls back to the inverted caret"
(t/is (= {:color white :invert? true}
(text-editor/resolve-caret-color
[(gradient [{:color "#ff0000" :opacity 1 :offset 0}
{:color "#0000ff" :opacity 1 :offset 1}])]))))
(t/testing "an image-only fill falls back to the inverted caret"
(t/is (= {:color white :invert? true}
(text-editor/resolve-caret-color [{:fill-image {:id "x"}}]))))
(t/testing "no fills fall back to the inverted caret"
(t/is (= {:color white :invert? true}
(text-editor/resolve-caret-color []))))
(t/testing "nil falls back to the inverted caret"
(t/is (= {:color white :invert? true}
(text-editor/resolve-caret-color nil))))
(t/testing "a mixed selection (:multiple) falls back to the inverted caret"
(t/is (= {:color white :invert? true}
(text-editor/resolve-caret-color :multiple)))))

View File

@ -49,6 +49,7 @@
[frontend-tests.plugins.utils-test]
[frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test]
[frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.copy-paste-props-test]
@ -136,6 +137,7 @@
'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-objects-test
'frontend-tests.render-wasm.text-editor-caret-color-test
'frontend-tests.svg-fills-test
'frontend-tests.tokens.copy-paste-props-test
'frontend-tests.tokens.import-export-test

View File

@ -66,6 +66,11 @@ fn render_cursor(
if editor_state.is_overtype_mode {
paint.set_blend_mode(BlendMode::Exclusion);
paint.set_color(Color::WHITE);
} else if editor_state.theme.cursor_invert {
// Default (no solid fill to match): a white caret with a Difference
// blend renders the inverted color of whatever is behind it.
paint.set_blend_mode(BlendMode::Difference);
paint.set_color(editor_state.theme.cursor_color);
} else {
paint.set_blend_mode(BlendMode::SrcOver);
paint.set_color(editor_state.theme.cursor_color);

View File

@ -101,7 +101,7 @@ pub enum TextEditorEvent {
/// FIXME: It should be better to get these constants from the frontend through the API.
const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184);
const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_COLOR: Color = Color::WHITE;
const CURSOR_WIDTH: f32 = 1.0;
const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0;
@ -274,6 +274,10 @@ pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_color: Color,
pub cursor_width: f32,
/// When true the caret is painted with a Difference blend mode, so it shows
/// as the inverted color of whatever is behind it. Used as the default when
/// the text has no solid fill whose color the caret can match.
pub cursor_invert: bool,
}
pub struct TextComposition {
@ -364,6 +368,7 @@ impl TextEditorState {
selection_color: SELECTION_COLOR,
cursor_color: CURSOR_COLOR,
cursor_width: CURSOR_WIDTH,
cursor_invert: true,
},
selection: TextSelection::new(),
composition: TextComposition::new(),

View File

@ -33,12 +33,17 @@ pub enum CursorDirection {
// STATE MANAGEMENT
// ============================================================================
/// Apply the editor theme. When `invert` is true the caret is painted with a
/// Difference blend mode (pass white as `cursor_color` to always show the
/// inverted color of the background); otherwise it is painted with the given
/// solid `cursor_color`.
#[no_mangle]
pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32) {
pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32, invert: bool) {
// NOTE: In the future could be interesting to fill al this data from
// a structure pointer.
get_text_editor_state().theme.selection_color = Color::new(selection_color);
get_text_editor_state().theme.cursor_color = Color::new(cursor_color);
get_text_editor_state().theme.cursor_invert = invert;
}
#[no_mangle]