diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 575a61bb0a..7e4e4c2734 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 7b5e15cc0b..0b346b4dd4 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -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 :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] diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs new file mode 100644 index 0000000000..2594e84995 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/text_editor_caret_color_test.cljs @@ -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))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e26c36ade9..2f4bb3d591 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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 diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index e469305ee5..91d1093d85 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -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); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 4531d55816..e112d49732 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -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(), diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 7df8c6e10d..ca54edc39a 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -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]