mirror of
https://github.com/penpot/penpot.git
synced 2026-05-01 06:08:09 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
a004219405
90
.opencode/skills/backport-commit/SKILL.md
Normal file
90
.opencode/skills/backport-commit/SKILL.md
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
name: backport-commit
|
||||
description: Port changes from a specific Git commit to the current branch by manually applying the diff, avoiding cherry-pick when it would introduce complex conflicts.
|
||||
---
|
||||
|
||||
# Backport Commit
|
||||
|
||||
Port changes from a specific Git commit to the current branch by manually
|
||||
applying the diff, avoiding `git cherry-pick` when it would introduce
|
||||
complex conflicts.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill whenever the user asks to backport a commit, especially when:
|
||||
|
||||
- The commit touches multiple modules or files with significant divergence
|
||||
- `git cherry-pick` is explicitly ruled out ("do not use cherry-pick")
|
||||
- The target commit is old enough that conflicts are likely
|
||||
- The commit introduces both source changes AND new files (tests, etc.)
|
||||
- You need full control over how each hunk is applied
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Identify the target commit
|
||||
|
||||
```bash
|
||||
# Verify the commit exists and understand what it does
|
||||
git log --oneline -1 <commit-sha>
|
||||
|
||||
# Get the full diff (including new/deleted files)
|
||||
git show <commit-sha>
|
||||
|
||||
# Capture the original commit message for later reuse
|
||||
git log --format='%B' -1 <commit-sha>
|
||||
```
|
||||
|
||||
### 2. Identify affected modules
|
||||
|
||||
From the file paths in the diff, determine which Penpot modules are affected
|
||||
(frontend, backend, common, render-wasm, etc.) and read their `AGENTS.md`
|
||||
files **before** making any changes. If a module has no `AGENTS.md`, skip
|
||||
that step — verify with `ls <module>/AGENTS.md` first.
|
||||
|
||||
### 3. Read the current state of each affected file
|
||||
|
||||
For every file the diff touches, read the current version on disk to understand
|
||||
context and ensure correct placement before editing.
|
||||
|
||||
### 4. Apply changes manually (the core of this approach)
|
||||
|
||||
Process every hunk in the diff using the appropriate tool:
|
||||
|
||||
| Diff action | Tool to use |
|
||||
|-------------|-------------|
|
||||
| Modify existing file | `edit` — use enough surrounding context in `oldString` to uniquely match the location |
|
||||
| Add new file | `write` — include proper license header and namespace conventions matching project style |
|
||||
| Delete file | `bash rm <path>` |
|
||||
| Rename/move file | `bash mv <old> <new>`, then apply any content changes with `edit` |
|
||||
|
||||
> **Tip:** Group nearby hunks from the same file into a single `edit` call.
|
||||
> Use separate calls when hunks are far apart to keep `oldString` short and
|
||||
> unambiguous.
|
||||
|
||||
Repeat until **all** hunks in the diff are ported.
|
||||
|
||||
### 5. Validate
|
||||
|
||||
Run **lint**, **check-fmt**, and **tests** for every affected module (see each
|
||||
module's `AGENTS.md` for the exact commands). If the formatter auto-fixes
|
||||
indentation, verify the logic is still semantically correct. All checks must
|
||||
pass before moving on.
|
||||
|
||||
### 6. Port the changelog entry (if any)
|
||||
|
||||
If the original commit added or modified a `CHANGES.md` entry, port that entry
|
||||
too — adapting wording and version references for the target branch.
|
||||
|
||||
### 7. Commit
|
||||
|
||||
Ask the `commiter` sub-agent to create a commit. Stage all relevant files
|
||||
(exclude unrelated untracked files) and provide the original commit message as
|
||||
a reference, adapting it as needed for the target branch context.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Context matters** — always read files before editing; never guess
|
||||
indentation or surrounding code
|
||||
- **Lint + format + test** — never skip validation before committing
|
||||
- **Preserve intent** — keep the original commit message meaning; the
|
||||
`commiter` agent handles formatting
|
||||
@ -134,7 +134,9 @@
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
|
||||
|
||||
## 2.14.4
|
||||
|
||||
@ -497,7 +497,7 @@
|
||||
|
||||
(def ^:private schema:create-team
|
||||
[:map {:title "create-team"}
|
||||
[:name [:string {:max 250}]]
|
||||
[:name types.team/schema:team-name]
|
||||
[:features {:optional true} ::cfeat/features]
|
||||
[:id {:optional true} ::sm/uuid]
|
||||
[:organization-id {:optional true} ::sm/uuid]
|
||||
@ -667,7 +667,7 @@
|
||||
|
||||
(def ^:private schema:update-team
|
||||
[:map {:title "update-team"}
|
||||
[:name [:string {:max 250}]]
|
||||
[:name types.team/schema:team-name]
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::update-team
|
||||
|
||||
@ -767,3 +767,82 @@
|
||||
(t/is (th/success? (th/command! data)))
|
||||
(t/is (= 1 (:call-count @mock))))))
|
||||
|
||||
(t/deftest create-team-with-invalid-name
|
||||
(let [profile (th/create-profile* 1 {:is-active true})]
|
||||
|
||||
;; name with a dot should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo.bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a colon should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo:bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a slash should fail
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "foo/bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; valid name should succeed
|
||||
(let [data {::th/type :create-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
(t/deftest update-team-with-invalid-name
|
||||
(let [profile (th/create-profile* 1 {:is-active true})
|
||||
team (th/create-team* 1 {:profile-id (:id profile)})]
|
||||
|
||||
;; name with a dot should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo.bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a colon should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo:bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; name with a slash should fail
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "foo/bar"}
|
||||
out (th/command! data)]
|
||||
(t/is (not (th/success? out)))
|
||||
(t/is (th/ex-of-type? (:error out) :validation))
|
||||
(t/is (th/ex-of-code? (:error out) :params-validation)))
|
||||
|
||||
;; valid name should succeed
|
||||
(let [data {::th/type :update-team
|
||||
::rpc/profile-id (:id profile)
|
||||
:id (:id team)
|
||||
:name "My Valid Team"}
|
||||
out (th/command! data)]
|
||||
(t/is (th/success? out)))))
|
||||
|
||||
|
||||
@ -20,6 +20,12 @@
|
||||
(def schema:role
|
||||
[::sm/one-of {:title "TeamRole"} valid-roles])
|
||||
|
||||
(def schema:team-name
|
||||
[:and
|
||||
[::sm/text {:max 250}]
|
||||
[:fn {:error/code "errors.team-name-invalid-chars"}
|
||||
(fn [s] (not (re-find #"[.:/]" s)))]])
|
||||
|
||||
;; FIXME: specify more fields
|
||||
(def schema:team
|
||||
[:map {:title "Team"}
|
||||
|
||||
BIN
frontend/resources/images/features/2.15-mcp-01.gif
Normal file
BIN
frontend/resources/images/features/2.15-mcp-01.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 MiB |
BIN
frontend/resources/images/features/2.15-mcp-02.gif
Normal file
BIN
frontend/resources/images/features/2.15-mcp-02.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 MiB |
BIN
frontend/resources/images/features/2.15-mcp-03.gif
Normal file
BIN
frontend/resources/images/features/2.15-mcp-03.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
BIN
frontend/resources/images/features/2.15-slide-0.jpg
Normal file
BIN
frontend/resources/images/features/2.15-slide-0.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@ -960,6 +960,12 @@
|
||||
(dwt.wasm/render-thumbnail file-id page-id root-id)
|
||||
(dwt/update-thumbnail file-id page-id root-id tag "update-component-thumbnail-sync"))))
|
||||
|
||||
(defn- render-component-thumbnail-event
|
||||
[[component-id file-id]]
|
||||
(let [file-id (or file-id (:current-file-id @st/state))]
|
||||
(update-component-thumbnail-sync
|
||||
@st/state component-id file-id "component")))
|
||||
|
||||
(defn update-component-sync
|
||||
([shape-id file-id] (update-component-sync shape-id file-id nil))
|
||||
([shape-id file-id undo-group]
|
||||
@ -1358,15 +1364,14 @@
|
||||
(->> (rx/from-atom refs/workspace-data {:emit-current-value? true})
|
||||
(rx/share))
|
||||
|
||||
;; Pair every commit with the file data right before it
|
||||
;; (so deleted shapes still exist when we inspect the change).
|
||||
workspace-buffer-s
|
||||
(->> (rx/concat
|
||||
(rx/take 1 workspace-data-s)
|
||||
(rx/take 1 workspace-data-s)
|
||||
workspace-data-s)
|
||||
;; Need to get the file data before the change, so deleted shapes
|
||||
;; still exist, for example. We initialize the buffer with three
|
||||
;; copies of the initial state
|
||||
(rx/buffer 3 1))
|
||||
(rx/buffer 2 1)
|
||||
(rx/map first))
|
||||
|
||||
changes-s
|
||||
(->> stream
|
||||
@ -1376,7 +1381,7 @@
|
||||
(rx/observe-on :async))
|
||||
|
||||
check-changes
|
||||
(fn [[event [old-data _mid_data _new-data]]]
|
||||
(fn [[event old-data]]
|
||||
(if (nil? old-data)
|
||||
(rx/empty)
|
||||
(let [{:keys [file-id changes save-undo? undo-group]} event
|
||||
@ -1392,11 +1397,9 @@
|
||||
(do (log/info :hint "detected component changes"
|
||||
:ids (map str changed-components)
|
||||
:undo-group undo-group)
|
||||
|
||||
(->> (rx/from changed-components)
|
||||
(rx/map #(component-changed % (:id old-data) undo-group))))
|
||||
;; even if save-undo? is false, we need to update the :modified-date of the component
|
||||
;; (for example, for undos)
|
||||
;; save-undo? false (undos): just bump :modified-at
|
||||
(->> (rx/from changed-components)
|
||||
(rx/map touch-component)))
|
||||
|
||||
@ -1418,8 +1421,19 @@
|
||||
(->> (rx/merge
|
||||
changes-s
|
||||
|
||||
;; Persist thumbnails to the server in batches after user
|
||||
;; becomes inactive for 5 seconds.
|
||||
;; WASM only: render the thumbnail on every component
|
||||
;; change so single edits (fill, etc.) update instantly.
|
||||
;; Non-WASM persists on every render, so it stays on the
|
||||
;; debounced path below to avoid per-edit backend posts.
|
||||
(if (features/active-feature? @st/state "render-wasm/v1")
|
||||
(->> changes-s
|
||||
(rx/filter (ptk/type? ::component-changed))
|
||||
(rx/map deref)
|
||||
(rx/map render-component-thumbnail-event))
|
||||
(rx/empty))
|
||||
|
||||
;; Persist to the server in batches, 5s after the user
|
||||
;; goes idle.
|
||||
(->> changes-s
|
||||
(rx/filter (ptk/type? ::component-changed))
|
||||
(rx/map deref)
|
||||
@ -1428,15 +1442,11 @@
|
||||
(rx/map (fn [[component-id file-id]]
|
||||
(update-component-thumbnail component-id file-id))))
|
||||
|
||||
;; Immediately update the component thumbnail on undos,
|
||||
;; which emit touch-component instead of component-changed.
|
||||
;; Undo/redo emit touch-component instead.
|
||||
(->> changes-s
|
||||
(rx/filter (ptk/type? ::touch-component))
|
||||
(rx/map deref)
|
||||
(rx/map (fn [[component-id file-id]]
|
||||
(let [file-id (or file-id (:current-file-id @st/state))]
|
||||
(update-component-thumbnail-sync
|
||||
@st/state component-id file-id "component"))))))
|
||||
(rx/map render-component-thumbnail-event)))
|
||||
|
||||
(rx/take-until stopper-s)))))))
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.team :as ctt]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.modal :as modal]
|
||||
@ -24,7 +25,7 @@
|
||||
|
||||
(def ^:private schema:team-form
|
||||
[:map {:title "TeamForm"}
|
||||
[:name [::sm/text {:max 250}]]
|
||||
[:name ctt/schema:team-name]
|
||||
[:organization-id {:optional true} [:maybe ::sm/uuid]]])
|
||||
|
||||
(defn- on-create-success
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.common.types.team :as ctt]
|
||||
[app.main.data.common :as dcm]
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.profile :as du]
|
||||
@ -59,7 +60,7 @@
|
||||
|
||||
(def ^:private schema:team-form
|
||||
[:map {:title "TeamForm"}
|
||||
[:name [::sm/text {:max 250}]]
|
||||
[:name ctt/schema:team-name]
|
||||
[:role :keyword]
|
||||
[:emails {:optional true} [::sm/set ::sm/email]]])
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
[app.main.ui.releases.v2-12]
|
||||
[app.main.ui.releases.v2-13]
|
||||
[app.main.ui.releases.v2-14]
|
||||
[app.main.ui.releases.v2-15]
|
||||
[app.main.ui.releases.v2-2]
|
||||
[app.main.ui.releases.v2-3]
|
||||
[app.main.ui.releases.v2-4]
|
||||
@ -105,4 +106,4 @@
|
||||
|
||||
(defmethod rc/render-release-notes "0.0"
|
||||
[params]
|
||||
(rc/render-release-notes (assoc params :version "2.14")))
|
||||
(rc/render-release-notes (assoc params :version "2.15")))
|
||||
|
||||
159
frontend/src/app/main/ui/releases/v2_15.cljs
Normal file
159
frontend/src/app/main/ui/releases/v2_15.cljs
Normal file
@ -0,0 +1,159 @@
|
||||
;; 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
|
||||
|
||||
(ns app.main.ui.releases.v2-15
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defmethod c/render-release-notes "2.15"
|
||||
[{:keys [slide klass next finish navigate version]}]
|
||||
(mf/html
|
||||
(case slide
|
||||
:start
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.15-slide-0.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Penpot 2.15 is here!"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"What’s new in Penpot?"]
|
||||
|
||||
[:div {:class (stl/css :version-tag)}
|
||||
(dm/str "Version " version)]]
|
||||
|
||||
[:div {:class (stl/css :features-block)}
|
||||
[:span {:class (stl/css :feature-title)}
|
||||
"One major feature: the Penpot MCP Server, with infinite workflow possibilities"]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"This release marks a major MCP milestone: Penpot MCP moves from an early technical setup to an accessible in-app experience via hosted remote setup. Whether you already know MCP or are new to it, it's now zero-friction to connect your AI client and turn prompts into real actions on real design data."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"With 2.15, we are opening the door to truly multi-directional workflows between design and code, while staying faithful to Penpot values: openness, freedom of choice, and respect for your data."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.15-mcp-01.gif"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Penpot MCP Server: AI connected to real design context"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Penpot MCP Server: AI connected to real design context"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Penpot MCP Server is the bridge between your AI client and your Penpot file. You describe what you need in natural language, your agent picks the right operation, and MCP translates that into real actions through Penpot APIs."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"This is not a generic 'describe and generate' flow. It is context-aware work with components, tokens, pages, layers, and structure. In short: design expressed as code, now usable through your preferred AI assistant."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:& c/navigation-bullets
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.15-mcp-02.gif"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Multi-directional workflows, from design to code and back"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Multi-directional workflows, from design to code and back"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"The biggest unlock in 2.15 is multi-directionality. You can move from design to code, and from code back to design, without losing intent or structure in the process."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Generate semantic HTML/CSS from real layouts."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Translate tokens and styles into code variables."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Export only assets in use."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Validate design-code consistency."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Reorganize layers, apply naming rules, and automate repetitive design system maintenance."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:& c/navigation-bullets
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.15-mcp-03.gif"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Your stack, your model, your control"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Your stack, your model, your control"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"With MCP, you connect Penpot to the AI client and model you already trust. Cursor, Claude, VS Code, Codex, or another MCP-compatible setup: the workflow adapts to your stack, not the other way around."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"You can run it hosted for a faster setup, or locally when you need tighter infrastructure control. The same applies to data boundaries: Penpot provides the bridge to your design context, while your team decides how and where AI runs."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"In practice, this means teams can automate design and code workflows without giving up tool freedom, deployment control, or ownership of their process.
|
||||
"]]
|
||||
[:div {:class (stl/css :navigation)}
|
||||
|
||||
[:& c/navigation-bullets
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
102
frontend/src/app/main/ui/releases/v2_15.scss
Normal file
102
frontend/src/app/main/ui/releases/v2_15.scss
Normal file
@ -0,0 +1,102 @@
|
||||
// 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
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend .modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flexCenter;
|
||||
@include deprecated.headlineSmallTypography;
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headlineLargeTypography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.bodyLargeTypography;
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.bodyMediumTypography;
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.bodyMediumTypography;
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend .button-primary;
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@ -425,8 +425,8 @@
|
||||
|
||||
(not render-wasm?)
|
||||
(obj/merge!
|
||||
#js {"--editor-container-width" (dm/str width "px")
|
||||
"--editor-container-height" (dm/str height "px")})
|
||||
#js {"--editor-container-width" (dm/str (max 1 width) "px")
|
||||
"--editor-container-height" (dm/str (max 1 height) "px")})
|
||||
|
||||
;; Transform is necessary when there is a text overflow and the vertical
|
||||
;; aligment is center or bottom.
|
||||
|
||||
@ -189,6 +189,13 @@
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "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"))
|
||||
|
||||
;; Arrow keys
|
||||
(= key "ArrowLeft")
|
||||
(do
|
||||
|
||||
@ -204,6 +204,7 @@
|
||||
(def text-editor-blur text-editor/text-editor-blur)
|
||||
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
|
||||
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
|
||||
(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode)
|
||||
(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)
|
||||
|
||||
@ -143,6 +143,12 @@
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
|
||||
|
||||
(defn text-editor-toggle-overtype-mode
|
||||
"Toggles overtype mode"
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_toggle_overtype_mode")))
|
||||
|
||||
(defn text-editor-pointer-down
|
||||
[{:keys [x y]}]
|
||||
(when wasm/context-initialized?
|
||||
|
||||
@ -135,6 +135,14 @@
|
||||
(wasm.mem/free)
|
||||
text)))
|
||||
|
||||
(defn ^:export wasmRenderStats
|
||||
[]
|
||||
(let [module wasm/internal-module
|
||||
f (when module (unchecked-get module "_render_stats"))]
|
||||
(if (fn? f)
|
||||
(wasm.h/call module "_render_stats")
|
||||
(js/console.warn "[debug] render-wasm module not ready or missing _render_stats"))))
|
||||
|
||||
(defn ^:export wasmAtlasConsole
|
||||
"Logs the current render-wasm atlas as an image in the JS console (if present)."
|
||||
[]
|
||||
|
||||
@ -1537,6 +1537,10 @@ msgstr "The recovery token is invalid."
|
||||
msgid "errors.invalid-text"
|
||||
msgstr "Invalid text"
|
||||
|
||||
#: common/src/app/common/types/team.cljc:26
|
||||
msgid "errors.team-name-invalid-chars"
|
||||
msgstr "The team name can't contain any of the following characters:'.', ':' or '/'"
|
||||
|
||||
#: src/app/main/ui/static.cljs:74
|
||||
msgid "errors.invite-invalid"
|
||||
msgstr "Invite invalid"
|
||||
|
||||
@ -10,6 +10,7 @@ build = "build.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
stats = []
|
||||
profile = ["profile-macros", "profile-raf"]
|
||||
profile-macros = []
|
||||
profile-raf = []
|
||||
@ -36,4 +37,6 @@ thiserror = "2.0.18"
|
||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
strip = true
|
||||
|
||||
@ -31,8 +31,8 @@ export EM_MALLOC="dlmalloc"
|
||||
|
||||
export EMCC_CFLAGS="--no-entry \
|
||||
--js-library src/js/wapi.js \
|
||||
-sASSERTIONS=1 \
|
||||
-sALLOW_TABLE_GROWTH=1 \
|
||||
-sMALLOC=$EM_MALLOC \
|
||||
-sALLOW_TABLE_GROWTH=0 \
|
||||
-sALLOW_MEMORY_GROWTH=1 \
|
||||
-sINITIAL_HEAP=$EM_INITIAL_HEAP \
|
||||
-sMEMORY_GROWTH_GEOMETRIC_STEP=$EM_MEMORY_GROWTH_GEOMETRIC_STEP \
|
||||
@ -50,13 +50,13 @@ export CARGO_PARAMS="${@:2}";
|
||||
|
||||
if [ "$BUILD_MODE" = "release" ]; then
|
||||
export CARGO_PARAMS="--release $CARGO_PARAMS"
|
||||
export EMCC_CFLAGS="-Os $EMCC_CFLAGS"
|
||||
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 $EMCC_CFLAGS"
|
||||
else
|
||||
# TODO: Extra parameters that could be good to look into:
|
||||
# -gseparate-dwarf
|
||||
# -gsplit-dwarf
|
||||
# -gsource-map
|
||||
export EMCC_CFLAGS="-g $EMCC_CFLAGS -sVERBOSE=1 -sMALLOC=$EM_MALLOC"
|
||||
export EMCC_CFLAGS="-g -sASSERTIONS=1 -sVERBOSE=1 $EMCC_CFLAGS"
|
||||
fi
|
||||
|
||||
function clean {
|
||||
|
||||
@ -225,6 +225,17 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> {
|
||||
pub extern "C" fn render(_: i32) -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.rebuild_touched_tiles();
|
||||
// Drain the throttled modifier-tile invalidation accumulated
|
||||
// since the previous rAF. set_modifiers skips this work during
|
||||
// interactive_transform; we do it once here, with the current
|
||||
// modifier set, so the cost is paid once per rAF rather than
|
||||
// once per pointer move.
|
||||
if state.render_state.options.is_interactive_transform() {
|
||||
let ids = state.shapes.modifier_ids();
|
||||
if !ids.is_empty() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
}
|
||||
state
|
||||
.start_render_loop(performance::get_time())
|
||||
.map_err(|_| Error::RecoverableError("Error rendering".to_string()))?;
|
||||
@ -344,11 +355,9 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
|
||||
#[wasm_error]
|
||||
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
|
||||
let result = with_state_mut!(state, { state.process_animation_frame(timestamp) });
|
||||
|
||||
if let Err(err) = result {
|
||||
eprintln!("process_animation_frame error: {}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -453,9 +462,11 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
let opts = &mut state.render_state.options;
|
||||
opts.set_fast_mode(true);
|
||||
opts.set_interactive_transform(true);
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
state.render_state.options.set_interactive_transform(true);
|
||||
// Capture the last fully-rendered frame as a stable backdrop for the drag.
|
||||
// This avoids relying on atlas/cache correctness during fast_mode.
|
||||
state.render_state.surfaces.copy_target_to_backbuffer();
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
});
|
||||
Ok(())
|
||||
@ -470,9 +481,8 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
performance::begin_measure!("set_modifiers_end");
|
||||
let opts = &mut state.render_state.options;
|
||||
opts.set_fast_mode(false);
|
||||
opts.set_interactive_transform(false);
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
state.render_state.options.set_interactive_transform(false);
|
||||
state.render_state.cancel_animation_frame();
|
||||
performance::end_measure!("set_modifiers_end");
|
||||
});
|
||||
@ -945,7 +955,11 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> {
|
||||
pub extern "C" fn clean_modifiers() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
let prev_modifier_ids = state.shapes.clean_all();
|
||||
if !prev_modifier_ids.is_empty() {
|
||||
// Skip the tile-cache cleanup during interactive transform: the
|
||||
// per-rAF `rebuild_modifier_tiles` in `render()` already evicts
|
||||
// the same tiles for the active modifier set, so the eviction
|
||||
// here is redundant and doubles the per-emission cost.
|
||||
if !prev_modifier_ids.is_empty() && !state.render_state.options.is_interactive_transform() {
|
||||
state
|
||||
.render_state
|
||||
.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?;
|
||||
@ -973,7 +987,10 @@ pub extern "C" fn set_modifiers() -> Result<()> {
|
||||
|
||||
with_state_mut!(state, {
|
||||
state.set_modifiers(modifiers);
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
// TO CHECK
|
||||
if !state.render_state.options.is_interactive_transform() {
|
||||
state.rebuild_modifier_tiles(ids)?;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@ -1035,6 +1052,13 @@ pub extern "C" fn render_shape_pixels(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn render_stats() {
|
||||
with_state!(state, {
|
||||
state.render_state.print_stats();
|
||||
})
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
init_gl!();
|
||||
|
||||
@ -15,6 +15,7 @@ mod ui;
|
||||
|
||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use gpu_state::GpuState;
|
||||
@ -289,9 +290,47 @@ fn sort_z_index(tree: ShapesPoolRef, element: &Shape, children_ids: Vec<Uuid>) -
|
||||
}
|
||||
}
|
||||
|
||||
struct RenderStats {
|
||||
pub counts: HashMap<Uuid, i32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl RenderStats {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
counts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn count(&mut self, id: Uuid) -> i32 {
|
||||
let counter = self.counts.entry(id).or_insert(0);
|
||||
*counter += 1;
|
||||
*counter
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.counts.clear();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get(&self, id: &Uuid) -> Option<&i32> {
|
||||
self.counts.get(id)
|
||||
}
|
||||
|
||||
fn print(&self) {
|
||||
let mut sum: i32 = 0;
|
||||
for (&id, &count) in self.counts.iter() {
|
||||
println!("{}: {}", id, count);
|
||||
sum += count;
|
||||
}
|
||||
println!("{}: {}", self.counts.len(), sum);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderState {
|
||||
gpu_state: GpuState,
|
||||
pub options: RenderOptions,
|
||||
stats: RenderStats,
|
||||
pub surfaces: Surfaces,
|
||||
pub fonts: FontStore,
|
||||
pub viewbox: Viewbox,
|
||||
@ -334,6 +373,17 @@ pub(crate) struct RenderState {
|
||||
/// Cleared at the beginning of a render pass; set to true after we clear Cache the first
|
||||
/// time we are about to blit a tile into Cache for this pass.
|
||||
pub cache_cleared_this_render: bool,
|
||||
/// True iff the current tile had shapes assigned to it when we
|
||||
/// started rendering it. Lets us distinguish a genuinely empty
|
||||
/// tile (skip composite, just clear) from a tile whose walker
|
||||
/// finished its work in a previous PAF and is now being resumed
|
||||
/// (must composite to present the work). Reset when current_tile
|
||||
/// changes.
|
||||
pub current_tile_had_shapes: bool,
|
||||
/// During interactive transforms we keep `Target` between rAFs. Seed the
|
||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
||||
/// repeatedly overwrite tiles that have already been updated.
|
||||
pub interactive_target_seeded: bool,
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
@ -377,6 +427,7 @@ impl RenderState {
|
||||
Ok(RenderState {
|
||||
gpu_state: gpu_state.clone(),
|
||||
options,
|
||||
stats: RenderStats::new(),
|
||||
surfaces,
|
||||
fonts,
|
||||
viewbox,
|
||||
@ -407,6 +458,8 @@ impl RenderState {
|
||||
preview_mode: false,
|
||||
export_context: None,
|
||||
cache_cleared_this_render: false,
|
||||
current_tile_had_shapes: false,
|
||||
interactive_target_seeded: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -624,6 +677,10 @@ impl RenderState {
|
||||
|
||||
pub fn set_viewport_interest_area_threshold(&mut self, value: i32) {
|
||||
self.options.set_viewport_interest_area_threshold(value);
|
||||
// The TileViewbox stores its own copy of `interest` (set at
|
||||
// construction). Without propagating, options change wouldn't
|
||||
// affect pending_tiles generation.
|
||||
self.tile_viewbox.set_interest(value);
|
||||
}
|
||||
|
||||
pub fn set_node_batch_threshold(&mut self, value: i32) {
|
||||
@ -712,6 +769,16 @@ impl RenderState {
|
||||
}
|
||||
|
||||
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> {
|
||||
// During interactive transforms we render tiles directly into Target; updating the cache
|
||||
// (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until
|
||||
// the interaction ends.
|
||||
if self.options.is_interactive_transform() {
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
self.surfaces
|
||||
.draw_current_tile_direct_target_only(&tile_rect, self.background_color);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
|
||||
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
|
||||
@ -849,6 +916,9 @@ impl RenderState {
|
||||
outset: Option<f32>,
|
||||
target_surface: SurfaceId,
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "stats")]
|
||||
self.stats.count(shape.id);
|
||||
|
||||
let surface_ids = fills_surface_id as u32
|
||||
| strokes_surface_id as u32
|
||||
| innershadows_surface_id as u32
|
||||
@ -864,10 +934,14 @@ impl RenderState {
|
||||
s.canvas().save();
|
||||
});
|
||||
}
|
||||
|
||||
let antialias =
|
||||
shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
||||
let fast_mode = self.options.is_fast_mode();
|
||||
// Skip anti-aliasing entirely during fast_mode (interactive
|
||||
// gestures + pan/zoom). AA edge sampling is per-pixel and adds
|
||||
// up across many shapes; reverts to full quality on commit.
|
||||
let antialias = !fast_mode
|
||||
&& shape.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
||||
let skip_effects = fast_mode;
|
||||
|
||||
let has_nested_fills = self
|
||||
.nested_fills
|
||||
.last()
|
||||
@ -910,7 +984,6 @@ impl RenderState {
|
||||
});
|
||||
|
||||
fills::render(self, shape, &shape.fills, antialias, target_surface, None)?;
|
||||
|
||||
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
|
||||
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
||||
strokes::render(
|
||||
@ -995,7 +1068,7 @@ impl RenderState {
|
||||
// Remove background blur from the shape so it doesn't get processed
|
||||
// as a layer blur. The actual rendering is done before the save_layer
|
||||
// in render_background_blur() so it's independent of shape opacity.
|
||||
if !fast_mode
|
||||
if !skip_effects
|
||||
&& apply_to_current_surface
|
||||
&& fills_surface_id == SurfaceId::Fills
|
||||
&& !matches!(shape.shape_type, Type::Text(_))
|
||||
@ -1021,14 +1094,14 @@ impl RenderState {
|
||||
} else if shape_has_blur {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
if fast_mode {
|
||||
if skip_effects {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
|
||||
// For non-text, non-SVG shapes in the normal rendering path, apply blur
|
||||
// via a single save_layer on each render surface
|
||||
// Clip correctness is preserved
|
||||
let blur_sigma_for_layers: Option<f32> = if !fast_mode
|
||||
let blur_sigma_for_layers: Option<f32> = if !skip_effects
|
||||
&& apply_to_current_surface
|
||||
&& fills_surface_id == SurfaceId::Fills
|
||||
&& !matches!(shape.shape_type, Type::Text(_))
|
||||
@ -1107,7 +1180,7 @@ impl RenderState {
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
if fast_mode {
|
||||
if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
@ -1396,7 +1469,7 @@ impl RenderState {
|
||||
antialias,
|
||||
outset,
|
||||
)?;
|
||||
if !fast_mode {
|
||||
if !skip_effects {
|
||||
for stroke in &visible_strokes {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
@ -1409,7 +1482,7 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if !fast_mode {
|
||||
if !skip_effects {
|
||||
shadows::render_fill_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
@ -1660,6 +1733,9 @@ impl RenderState {
|
||||
timestamp: i32,
|
||||
sync_render: bool,
|
||||
) -> Result<()> {
|
||||
#[cfg(feature = "stats")]
|
||||
self.stats.clear();
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
let scale = self.get_scale();
|
||||
|
||||
@ -1669,30 +1745,28 @@ impl RenderState {
|
||||
performance::begin_measure!("render");
|
||||
performance::begin_measure!("start_render_loop");
|
||||
|
||||
self.cache_cleared_this_render = false;
|
||||
self.reset_canvas();
|
||||
|
||||
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
|
||||
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
|
||||
// growth far beyond real content.
|
||||
let doc_bounds = self.compute_document_bounds(base_object, tree);
|
||||
self.surfaces.set_atlas_doc_bounds(doc_bounds);
|
||||
|
||||
// During an interactive shape transform (drag/resize/rotate) the
|
||||
// Target is repainted tile-by-tile. If only a subset of the
|
||||
// invalidated tiles finishes in this rAF the remaining area
|
||||
// would either show stale content from the previous frame or,
|
||||
// on buffer swaps, show blank pixels — either way the user
|
||||
// perceives tiles appearing sequentially. Paint the persistent
|
||||
// 1:1 atlas as a stable backdrop so every flush presents a
|
||||
// coherent picture: unchanged tiles come from the atlas and
|
||||
// invalidated tiles are overwritten on top as they finish.
|
||||
if self.options.is_interactive_transform() && self.surfaces.has_atlas() {
|
||||
self.surfaces.draw_atlas_to_target(
|
||||
self.viewbox,
|
||||
self.options.dpr(),
|
||||
self.background_color,
|
||||
);
|
||||
self.cache_cleared_this_render = false;
|
||||
if self.options.is_interactive_transform() {
|
||||
// Keep `Target` as the previous frame and overwrite only the tiles
|
||||
// that changed. This avoids clearing + redrawing an atlas backdrop
|
||||
// every rAF during drag (a common source of GPU work/stalls).
|
||||
self.surfaces
|
||||
.reset_interactive_transform(self.background_color);
|
||||
if !self.interactive_target_seeded {
|
||||
// Seed from the last presented frame; this is stable even when
|
||||
// fast_mode skips cache updates and regardless of atlas coverage.
|
||||
self.surfaces.seed_target_from_backbuffer();
|
||||
self.interactive_target_seeded = true;
|
||||
}
|
||||
} else {
|
||||
self.reset_canvas();
|
||||
self.interactive_target_seeded = false;
|
||||
}
|
||||
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
@ -1729,8 +1803,9 @@ impl RenderState {
|
||||
|
||||
let _tile_start = performance::begin_timed_log!("tile_cache_update");
|
||||
performance::begin_measure!("tile_cache");
|
||||
let only_visible = self.options.is_interactive_transform();
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces);
|
||||
.update(&self.tile_viewbox, &self.surfaces, only_visible);
|
||||
performance::end_measure!("tile_cache");
|
||||
performance::end_timed_log!("tile_cache_update", _tile_start);
|
||||
|
||||
@ -1815,20 +1890,16 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// In a pure viewport interaction (pan/zoom), render_from_cache
|
||||
// owns the Target surface — don't flush Target so we don't
|
||||
// present stale tile positions. We still drain the GPU command
|
||||
// queue with a non-Target `flush_and_submit` so the backlog
|
||||
// of tile-render commands executes incrementally instead of
|
||||
// piling up for hundreds of milliseconds and blowing up the
|
||||
// next `render_from_cache` call into a multi-frame hitch.
|
||||
// owns the Target surface — skip flush so we don't present
|
||||
// stale tile positions. The rAF still populates the Cache
|
||||
// surface and tile HashMap so render_from_cache progressively
|
||||
// shows more complete content.
|
||||
//
|
||||
// During interactive shape transforms (drag/resize/rotate) we
|
||||
// still need to flush every rAF so the user sees the updated
|
||||
// shape position — render_from_cache is not in the loop here.
|
||||
if !self.options.is_viewport_interaction() {
|
||||
self.flush_and_submit();
|
||||
} else {
|
||||
self.gpu_state.context.flush_and_submit();
|
||||
}
|
||||
|
||||
if self.render_in_progress {
|
||||
@ -2030,7 +2101,7 @@ impl RenderState {
|
||||
paint.set_blend_mode(element.blend_mode().into());
|
||||
paint.set_alpha_f(element.opacity());
|
||||
|
||||
// Skip frame-level blur in fast mode (pan/zoom)
|
||||
// Skip frame-level blur in fast mode (pan/zoom).
|
||||
if !self.options.is_fast_mode() {
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
@ -2549,7 +2620,8 @@ impl RenderState {
|
||||
}
|
||||
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
let antialias = element.should_use_antialias(scale, self.options.antialias_threshold);
|
||||
let antialias = !self.options.is_fast_mode()
|
||||
&& element.should_use_antialias(scale, self.options.antialias_threshold);
|
||||
self.surfaces.canvas(target_surface).save();
|
||||
for (bounds, corners, transform) in clips.iter() {
|
||||
if target_surface == SurfaceId::Export {
|
||||
@ -2753,7 +2825,7 @@ impl RenderState {
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom).
|
||||
let skip_shadows = self.options.is_fast_mode();
|
||||
|
||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
||||
@ -2893,12 +2965,17 @@ impl RenderState {
|
||||
if let Some(current_tile) = self.current_tile {
|
||||
if self.surfaces.has_cached_tile_surface(current_tile) {
|
||||
performance::begin_measure!("render_shape_tree::cached");
|
||||
// During interactive transforms, `Target` is preserved and seeded once
|
||||
// from Backbuffer. Cached tiles are therefore already visible and
|
||||
// re-blitting them costs extra GPU work.
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
self.surfaces.draw_cached_tile_surface(
|
||||
current_tile,
|
||||
tile_rect,
|
||||
self.background_color,
|
||||
);
|
||||
if !self.options.is_interactive_transform() {
|
||||
self.surfaces.draw_cached_tile_surface(
|
||||
current_tile,
|
||||
tile_rect,
|
||||
self.background_color,
|
||||
);
|
||||
}
|
||||
|
||||
// Also draw the cached tile to the Cache surface so
|
||||
// render_from_cache (used during pan) has the full scene.
|
||||
@ -2936,8 +3013,19 @@ impl RenderState {
|
||||
}
|
||||
performance::end_measure!("render_shape_tree::uncached");
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
if !is_empty {
|
||||
self.apply_render_to_final_canvas(tile_rect)?;
|
||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
||||
// the tile has unfinished work from a previous PAF
|
||||
// (`current_tile_had_shapes` was set when we populated pending_nodes
|
||||
// for this tile).
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
if self.options.is_interactive_transform() {
|
||||
// During drag, avoid snapshot-based caching. Draw Current directly
|
||||
// into Target (and Cache) to reduce stalls.
|
||||
self.surfaces
|
||||
.draw_current_tile_direct(&tile_rect, self.background_color);
|
||||
} else {
|
||||
self.apply_render_to_final_canvas(tile_rect)?;
|
||||
}
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render_workspace_current_tile(
|
||||
@ -2978,6 +3066,11 @@ impl RenderState {
|
||||
// let's check if there are more pending nodes
|
||||
if let Some(next_tile) = self.pending_tiles.pop() {
|
||||
self.update_render_context(next_tile);
|
||||
// Reset for the new tile. We'll flip it to true if the
|
||||
// tile has shapes, so a later "is_empty=true" reflects
|
||||
// a resumed-from-yield case rather than a genuinely
|
||||
// empty tile.
|
||||
self.current_tile_had_shapes = false;
|
||||
|
||||
if !self.surfaces.has_cached_tile_surface(next_tile) {
|
||||
if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
|
||||
@ -2993,14 +3086,28 @@ impl RenderState {
|
||||
})
|
||||
});
|
||||
|
||||
// We only need first level shapes, in the same order as the parent node
|
||||
// We only need first level shapes, in the same order as the parent node.
|
||||
//
|
||||
// During interactive transforms we may invalidate only the modified shapes
|
||||
// (to avoid massive ancestor eviction). However, we still composite full
|
||||
// tiles (we clear the tile rect before drawing Current), so we must render
|
||||
// all root shapes that can contribute to this tile; otherwise, unchanged
|
||||
// siblings inside the same tile would disappear.
|
||||
let mut valid_ids = Vec::with_capacity(ids.len());
|
||||
for root_id in root_ids.iter() {
|
||||
if tile_has_bg_blur || ids.contains(root_id) {
|
||||
valid_ids.push(*root_id);
|
||||
if self.options.is_interactive_transform() || tile_has_bg_blur {
|
||||
valid_ids.extend(root_ids.iter().copied());
|
||||
} else {
|
||||
for root_id in root_ids.iter() {
|
||||
if ids.contains(root_id) {
|
||||
valid_ids.push(*root_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !valid_ids.is_empty() {
|
||||
self.current_tile_had_shapes = true;
|
||||
}
|
||||
|
||||
self.pending_nodes.extend(valid_ids.into_iter().map(|id| {
|
||||
NodeRenderState {
|
||||
id,
|
||||
@ -3336,8 +3443,17 @@ impl RenderState {
|
||||
tree: ShapesPoolMutRef<'_>,
|
||||
ids: Vec<Uuid>,
|
||||
) -> Result<()> {
|
||||
let ancestors = all_with_ancestors(&ids, tree, false);
|
||||
self.update_tiles_shapes(&ancestors, tree)?;
|
||||
// During interactive transform, skip ancestor invalidation: walking up to the
|
||||
// parent frame evicts every tile the frame covers, including dense tiles with
|
||||
// many siblings. Ancestor extrect caches are already invalidated by
|
||||
// `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by
|
||||
// the committing code path (rebuild_touched_tiles).
|
||||
if self.options.is_interactive_transform() {
|
||||
self.update_tiles_shapes(&ids, tree)?;
|
||||
} else {
|
||||
let ancestors = all_with_ancestors(&ids, tree, false);
|
||||
self.update_tiles_shapes(&ancestors, tree)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -3373,6 +3489,10 @@ impl RenderState {
|
||||
pub fn set_view(&mut self, zoom: f32, x: f32, y: f32) {
|
||||
self.viewbox.set_all(zoom, x, y);
|
||||
}
|
||||
|
||||
pub fn print_stats(&self) {
|
||||
self.stats.print();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderState {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
use crate::error::{Error, Result};
|
||||
use skia_safe::gpu::{self, gl::FramebufferInfo, gl::TextureInfo, DirectContext};
|
||||
use skia_safe::gpu::{
|
||||
self, ganesh::context_options::Enable, gl::FramebufferInfo, gl::TextureInfo, ContextOptions,
|
||||
DirectContext,
|
||||
};
|
||||
use skia_safe::{self as skia, ISize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -13,7 +16,16 @@ impl GpuState {
|
||||
let interface = gpu::gl::Interface::new_native().ok_or(Error::CriticalError(
|
||||
"Failed to create GL interface".to_string(),
|
||||
))?;
|
||||
let context = gpu::direct_contexts::make_gl(interface, None).ok_or(
|
||||
|
||||
// We tweak some options to enhance performance.
|
||||
let mut context_options = ContextOptions::default();
|
||||
// context_options.reduce_ops_task_splitting = Enable::Yes;
|
||||
context_options.skip_gl_error_checks = Enable::Yes;
|
||||
// context_options.runtime_program_cache_size = 1024;
|
||||
// context_options.allow_multiple_glyph_cache_textures = Enable::Yes;
|
||||
// context_options.allow_path_mask_caching = false;
|
||||
|
||||
let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or(
|
||||
Error::CriticalError("Failed to create GL context".to_string()),
|
||||
)?;
|
||||
let framebuffer_info = {
|
||||
|
||||
@ -231,8 +231,8 @@ fn draw_stroke_on_path(
|
||||
if let Some(pt) = path_transform {
|
||||
canvas.concat(pt);
|
||||
}
|
||||
let skia_path = path.to_skia_path(svg_attrs);
|
||||
|
||||
let skia_path = path.to_skia_path(svg_attrs);
|
||||
match stroke.render_kind(is_open) {
|
||||
StrokeKind::Inner => {
|
||||
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
|
||||
|
||||
@ -42,6 +42,7 @@ pub enum SurfaceId {
|
||||
UI = 0b100_0000_0000,
|
||||
Debug = 0b100_0000_0001,
|
||||
Atlas = 0b100_0000_0010,
|
||||
Backbuffer = 0b100_0000_0100,
|
||||
}
|
||||
|
||||
pub struct Surfaces {
|
||||
@ -67,6 +68,8 @@ pub struct Surfaces {
|
||||
debug: skia::Surface,
|
||||
// for drawing tiles.
|
||||
export: skia::Surface,
|
||||
// Persistent viewport-sized surface used to keep the last presented frame.
|
||||
backbuffer: skia::Surface,
|
||||
|
||||
tiles: TileTextureCache,
|
||||
// Persistent 1:1 document-space atlas that gets incrementally updated as tiles render.
|
||||
@ -112,6 +115,8 @@ impl Surfaces {
|
||||
let target = gpu_state.create_target_surface(width, height)?;
|
||||
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?;
|
||||
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?;
|
||||
let backbuffer =
|
||||
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?;
|
||||
let current =
|
||||
gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?;
|
||||
|
||||
@ -148,6 +153,7 @@ impl Surfaces {
|
||||
ui,
|
||||
debug,
|
||||
export,
|
||||
backbuffer,
|
||||
tiles,
|
||||
atlas,
|
||||
atlas_origin: skia::Point::new(0.0, 0.0),
|
||||
@ -399,10 +405,12 @@ impl Surfaces {
|
||||
|
||||
/// Draw the persistent atlas onto the target using the current viewbox transform.
|
||||
/// Intended for fast pan/zoom-out previews (avoids per-tile composition).
|
||||
/// Clears Target to `background` first so atlas-uncovered regions don't
|
||||
/// show stale content when the atlas only partially covers the viewport.
|
||||
pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) {
|
||||
if !self.has_atlas() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
let canvas = self.target.canvas();
|
||||
canvas.save();
|
||||
@ -413,11 +421,10 @@ impl Surfaces {
|
||||
None,
|
||||
true,
|
||||
);
|
||||
canvas.clear(background);
|
||||
|
||||
let s = viewbox.zoom * dpr;
|
||||
let atlas_scale = self.atlas_scale.max(0.01);
|
||||
|
||||
canvas.clear(background);
|
||||
canvas.translate((
|
||||
(self.atlas_origin.x + viewbox.pan_x) * s,
|
||||
(self.atlas_origin.y + viewbox.pan_y) * s,
|
||||
@ -581,6 +588,9 @@ impl Surfaces {
|
||||
if ids & SurfaceId::Cache as u32 != 0 {
|
||||
f(self.get_mut(SurfaceId::Cache));
|
||||
}
|
||||
if ids & SurfaceId::Backbuffer as u32 != 0 {
|
||||
f(self.get_mut(SurfaceId::Backbuffer));
|
||||
}
|
||||
if ids & SurfaceId::Fills as u32 != 0 {
|
||||
f(self.get_mut(SurfaceId::Fills));
|
||||
}
|
||||
@ -655,6 +665,7 @@ impl Surfaces {
|
||||
SurfaceId::Target => &mut self.target,
|
||||
SurfaceId::Filter => &mut self.filter,
|
||||
SurfaceId::Cache => &mut self.cache,
|
||||
SurfaceId::Backbuffer => &mut self.backbuffer,
|
||||
SurfaceId::Current => &mut self.current,
|
||||
SurfaceId::DropShadows => &mut self.drop_shadows,
|
||||
SurfaceId::InnerShadows => &mut self.inner_shadows,
|
||||
@ -673,6 +684,7 @@ impl Surfaces {
|
||||
SurfaceId::Target => &self.target,
|
||||
SurfaceId::Filter => &self.filter,
|
||||
SurfaceId::Cache => &self.cache,
|
||||
SurfaceId::Backbuffer => &self.backbuffer,
|
||||
SurfaceId::Current => &self.current,
|
||||
SurfaceId::DropShadows => &self.drop_shadows,
|
||||
SurfaceId::InnerShadows => &self.inner_shadows,
|
||||
@ -686,6 +698,29 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the current `Target` contents into the persistent `Backbuffer`.
|
||||
/// This is a GPU→GPU copy via Skia (no ReadPixels).
|
||||
pub fn copy_target_to_backbuffer(&mut self) {
|
||||
let sampling_options = self.sampling_options;
|
||||
self.target.clone().draw(
|
||||
self.backbuffer.canvas(),
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed `Target` from `Backbuffer` (last presented frame).
|
||||
pub fn seed_target_from_backbuffer(&mut self) {
|
||||
let sampling_options = self.sampling_options;
|
||||
self.backbuffer.clone().draw(
|
||||
self.target.canvas(),
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
}
|
||||
|
||||
fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> {
|
||||
let dim = (target.width(), target.height());
|
||||
self.target = target;
|
||||
@ -693,6 +728,10 @@ impl Surfaces {
|
||||
.target
|
||||
.new_surface_with_dimensions(dim)
|
||||
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
|
||||
self.backbuffer = self
|
||||
.target
|
||||
.new_surface_with_dimensions(dim)
|
||||
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
|
||||
self.debug = self
|
||||
.target
|
||||
.new_surface_with_dimensions(dim)
|
||||
@ -849,6 +888,43 @@ impl Surfaces {
|
||||
self.clear_all_dirty();
|
||||
}
|
||||
|
||||
/// Reset render surfaces for interactive transforms without clearing `Target`.
|
||||
/// Keeping `Target` avoids having to redraw an atlas backdrop each frame; we
|
||||
/// then overwrite only the tiles that changed.
|
||||
pub fn reset_interactive_transform(&mut self, color: skia::Color) {
|
||||
self.canvas(SurfaceId::Fills).restore_to_count(1);
|
||||
self.canvas(SurfaceId::InnerShadows).restore_to_count(1);
|
||||
self.canvas(SurfaceId::TextDropShadows).restore_to_count(1);
|
||||
self.canvas(SurfaceId::DropShadows).restore_to_count(1);
|
||||
self.canvas(SurfaceId::Strokes).restore_to_count(1);
|
||||
self.canvas(SurfaceId::Current).restore_to_count(1);
|
||||
self.canvas(SurfaceId::Export).restore_to_count(1);
|
||||
|
||||
// Clear tile-sized/intermediate surfaces; leave Target intact.
|
||||
self.apply_mut(
|
||||
SurfaceId::Fills as u32
|
||||
| SurfaceId::Strokes as u32
|
||||
| SurfaceId::Current as u32
|
||||
| SurfaceId::InnerShadows as u32
|
||||
| SurfaceId::TextDropShadows as u32
|
||||
| SurfaceId::DropShadows as u32
|
||||
| SurfaceId::Export as u32,
|
||||
|s| {
|
||||
s.canvas().clear(color).reset_matrix();
|
||||
},
|
||||
);
|
||||
|
||||
// UI/debug can be redrawn; clearing them is fine.
|
||||
self.canvas(SurfaceId::Debug)
|
||||
.clear(skia::Color::TRANSPARENT)
|
||||
.reset_matrix();
|
||||
self.canvas(SurfaceId::UI)
|
||||
.clear(skia::Color::TRANSPARENT)
|
||||
.reset_matrix();
|
||||
|
||||
self.clear_all_dirty();
|
||||
}
|
||||
|
||||
/// Clears the whole cache surface without disturbing its configured transform.
|
||||
pub fn clear_cache(&mut self, color: skia::Color) {
|
||||
let canvas = self.cache.canvas();
|
||||
@ -986,6 +1062,37 @@ impl Surfaces {
|
||||
);
|
||||
}
|
||||
|
||||
/// Same as `draw_current_tile_direct` but draws only into Target.
|
||||
/// Useful during interactive transforms to reduce extra GPU work.
|
||||
pub fn draw_current_tile_direct_target_only(
|
||||
&mut self,
|
||||
tile_rect: &skia::Rect,
|
||||
color: skia::Color,
|
||||
) {
|
||||
let sampling_options = self.sampling_options;
|
||||
let src_rect = IRect::from_xywh(
|
||||
self.margins.width,
|
||||
self.margins.height,
|
||||
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
|
||||
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
self.target.canvas().draw_rect(tile_rect, &paint);
|
||||
|
||||
self.current.clone().draw(
|
||||
self.target.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Full cache reset: clears both the tile texture cache and the cache canvas.
|
||||
/// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve
|
||||
/// the cache canvas for scaled previews, use `invalidate_tile_cache` instead.
|
||||
|
||||
@ -3,7 +3,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
|
||||
use crate::state::{TextEditorState, TextSelection};
|
||||
use crate::view::Viewbox;
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
use skia_safe::{BlendMode, Canvas, Paint, Rect};
|
||||
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
|
||||
|
||||
pub fn render_overlay(
|
||||
canvas: &Canvas,
|
||||
@ -48,11 +48,26 @@ fn render_cursor(
|
||||
};
|
||||
|
||||
let mut cursor_rect = Rect::new_empty();
|
||||
cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height());
|
||||
cursor_rect.set_xywh(
|
||||
rect.x(),
|
||||
rect.y(),
|
||||
if editor_state.is_overtype_mode {
|
||||
rect.width()
|
||||
} else {
|
||||
editor_state.theme.cursor_width / zoom
|
||||
},
|
||||
rect.height(),
|
||||
);
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_anti_alias(false);
|
||||
if editor_state.is_overtype_mode {
|
||||
paint.set_blend_mode(BlendMode::Exclusion);
|
||||
paint.set_color(Color::WHITE);
|
||||
} else {
|
||||
paint.set_blend_mode(BlendMode::SrcOver);
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
}
|
||||
|
||||
let shape_matrix = shape.get_matrix();
|
||||
canvas.save();
|
||||
@ -132,9 +147,9 @@ fn calculate_cursor_rect(
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 {
|
||||
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, 0.0, laid_out_para.height())
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..1,
|
||||
@ -143,9 +158,9 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.left(), r.top(), r.height())
|
||||
(r.left(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
(0.0, 0.0, laid_out_para.height())
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
@ -155,9 +170,14 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.right(), r.top(), r.height())
|
||||
(r.right(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
(laid_out_para.longest_line(), 0.0, laid_out_para.height())
|
||||
(
|
||||
laid_out_para.longest_line(),
|
||||
0.0,
|
||||
1.0,
|
||||
laid_out_para.height(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
@ -167,18 +187,18 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.left(), r.top(), r.height())
|
||||
(r.left(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
// Fallback: use glyph position
|
||||
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
|
||||
(pos.position as f32, 0.0, laid_out_para.height())
|
||||
(pos.position as f32, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
};
|
||||
|
||||
return Some(Rect::from_xywh(
|
||||
cursor_x,
|
||||
y_offset + cursor_y,
|
||||
1.0, // cursor_width
|
||||
cursor_width, // cursor_width
|
||||
cursor_height,
|
||||
));
|
||||
}
|
||||
|
||||
@ -195,7 +195,7 @@ pub struct Shape {
|
||||
pub shadows: Vec<Shadow>,
|
||||
pub layout_item: Option<LayoutItem>,
|
||||
pub bounds: OnceCell<math::Bounds>,
|
||||
pub extrect_cache: RefCell<Option<(math::Rect, u32)>>,
|
||||
pub extrect_cache: RefCell<Option<math::Rect>>,
|
||||
pub svg_transform: Option<Matrix>,
|
||||
pub ignore_constraints: bool,
|
||||
deleted: bool,
|
||||
@ -1015,17 +1015,13 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn calculate_extrect(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
|
||||
let scale_key = (scale * 1000.0).round() as u32;
|
||||
|
||||
if let Some((cached_extrect, cached_scale)) = *self.extrect_cache.borrow() {
|
||||
if cached_scale == scale_key {
|
||||
return cached_extrect;
|
||||
}
|
||||
if let Some(cached_extrect) = *self.extrect_cache.borrow() {
|
||||
return cached_extrect;
|
||||
}
|
||||
|
||||
let extrect = self.calculate_extrect_uncached(shapes_pool, scale);
|
||||
|
||||
*self.extrect_cache.borrow_mut() = Some((extrect, scale_key));
|
||||
*self.extrect_cache.borrow_mut() = Some(extrect);
|
||||
extrect
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
use skia_safe::{self as skia, Matrix};
|
||||
|
||||
use crate::math;
|
||||
use crate::shapes::svg_attrs::{FillRule, SvgAttrs};
|
||||
use skia_safe::{self as skia, Matrix};
|
||||
|
||||
mod subpaths;
|
||||
|
||||
|
||||
@ -309,6 +309,24 @@ impl ShapesPoolImpl {
|
||||
modified_uuids
|
||||
}
|
||||
|
||||
/// UUIDs of all shapes that currently have a transform modifier.
|
||||
/// Used by the throttled drag path so per-rAF tile invalidation can
|
||||
/// be done once with the current modifier set instead of once per
|
||||
/// pointer move.
|
||||
pub fn modifier_ids(&self) -> Vec<Uuid> {
|
||||
if self.modifiers.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idx_to_uuid: HashMap<usize, Uuid> = HashMap::with_capacity(self.uuid_to_idx.len());
|
||||
for (uuid, idx) in self.uuid_to_idx.iter() {
|
||||
idx_to_uuid.insert(*idx, *uuid);
|
||||
}
|
||||
self.modifiers
|
||||
.keys()
|
||||
.filter_map(|idx| idx_to_uuid.get(idx).copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl {
|
||||
let Some(shape) = self.get(id) else {
|
||||
panic!("Subtree not found");
|
||||
|
||||
@ -101,7 +101,8 @@ 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_BLINK_INTERVAL_MS: f64 = 530.0;
|
||||
const CURSOR_WIDTH: f32 = 1.0;
|
||||
const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TextEditorStyles {
|
||||
@ -257,6 +258,7 @@ impl TextEditorStyles {
|
||||
pub struct TextEditorTheme {
|
||||
pub selection_color: Color,
|
||||
pub cursor_color: Color,
|
||||
pub cursor_width: f32,
|
||||
}
|
||||
|
||||
pub struct TextComposition {
|
||||
@ -326,9 +328,10 @@ pub struct TextEditorState {
|
||||
// selecting something with the pointer.
|
||||
pub is_pointer_selection_active: bool,
|
||||
pub is_click_event_skipped: bool,
|
||||
pub is_overtype_mode: bool,
|
||||
pub active_shape_id: Option<Uuid>,
|
||||
pub cursor_visible: bool,
|
||||
pub last_blink_time: f64,
|
||||
pub last_blink_time_ms: f32,
|
||||
pub current_styles: TextEditorStyles,
|
||||
pending_events: Vec<TextEditorEvent>,
|
||||
}
|
||||
@ -339,15 +342,17 @@ impl TextEditorState {
|
||||
theme: TextEditorTheme {
|
||||
selection_color: SELECTION_COLOR,
|
||||
cursor_color: CURSOR_COLOR,
|
||||
cursor_width: CURSOR_WIDTH,
|
||||
},
|
||||
selection: TextSelection::new(),
|
||||
composition: TextComposition::new(),
|
||||
has_focus: false,
|
||||
is_pointer_selection_active: false,
|
||||
is_click_event_skipped: false,
|
||||
is_overtype_mode: false,
|
||||
active_shape_id: None,
|
||||
cursor_visible: true,
|
||||
last_blink_time: 0.0,
|
||||
last_blink_time_ms: 0.0,
|
||||
pending_events: Vec::new(),
|
||||
current_styles: TextEditorStyles::new(),
|
||||
}
|
||||
@ -357,9 +362,10 @@ impl TextEditorState {
|
||||
self.has_focus = true;
|
||||
self.active_shape_id = Some(shape_id);
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -367,9 +373,10 @@ impl TextEditorState {
|
||||
self.has_focus = false;
|
||||
// self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
// self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -377,9 +384,10 @@ impl TextEditorState {
|
||||
self.has_focus = false;
|
||||
self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -516,6 +524,14 @@ impl TextEditorState {
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn set_overtype_mode(&mut self, overtype_mode: bool) {
|
||||
self.is_overtype_mode = overtype_mode;
|
||||
}
|
||||
|
||||
pub fn toggle_overtype_mode(&mut self) {
|
||||
self.set_overtype_mode(!self.is_overtype_mode);
|
||||
}
|
||||
|
||||
fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool {
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if paragraphs.is_empty() {
|
||||
@ -687,27 +703,27 @@ impl TextEditorState {
|
||||
styles_were_updated
|
||||
}
|
||||
|
||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||
pub fn update_blink(&mut self, timestamp_ms: f32) {
|
||||
if !self.has_focus {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.last_blink_time == 0.0 {
|
||||
self.last_blink_time = timestamp_ms;
|
||||
if self.last_blink_time_ms == 0.0 {
|
||||
self.last_blink_time_ms = timestamp_ms;
|
||||
self.cursor_visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed = timestamp_ms - self.last_blink_time;
|
||||
let elapsed = timestamp_ms - self.last_blink_time_ms;
|
||||
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
|
||||
self.cursor_visible = !self.cursor_visible;
|
||||
self.last_blink_time = timestamp_ms;
|
||||
self.last_blink_time_ms = timestamp_ms;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_event(&mut self, event: TextEditorEvent) {
|
||||
|
||||
@ -86,6 +86,10 @@ impl TileViewbox {
|
||||
self.center = get_tile_center_for_viewbox(viewbox, scale);
|
||||
}
|
||||
|
||||
pub fn set_interest(&mut self, interest: i32) {
|
||||
self.interest = interest;
|
||||
}
|
||||
|
||||
pub fn is_visible(&self, tile: &Tile) -> bool {
|
||||
// TO CHECK self.interest_rect.contains(tile)
|
||||
self.visible_rect.contains(tile)
|
||||
@ -261,11 +265,20 @@ impl PendingTiles {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
|
||||
self.list.clear();
|
||||
|
||||
// Generate spiral for the interest area (viewport + margin)
|
||||
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
|
||||
// During interactive transform, skip the interest-area ring
|
||||
// entirely — the user is dragging, every rAF is on the critical
|
||||
// path, and pre-rendering tiles outside the viewport is wasted
|
||||
// work that just gets evicted on the next pointer move. The ring
|
||||
// is repopulated naturally on gesture end / on idle rAFs.
|
||||
let spiral_rect = if only_visible {
|
||||
&tile_viewbox.visible_rect
|
||||
} else {
|
||||
&tile_viewbox.interest_rect
|
||||
};
|
||||
let spiral = Self::generate_spiral(spiral_rect);
|
||||
|
||||
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
|
||||
// 1. visible + cached (fastest - just blit from cache)
|
||||
|
||||
@ -276,6 +276,43 @@ pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<
|
||||
None
|
||||
}
|
||||
|
||||
pub fn replace_text_with_newlines(
|
||||
text_content: &mut TextContent,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
text: &str,
|
||||
) -> Option<TextPositionWithAffinity> {
|
||||
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let lines: Vec<&str> = normalized.split('\n').collect();
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut current_cursor = *cursor;
|
||||
|
||||
if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, lines[0]) {
|
||||
current_cursor =
|
||||
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
for line in lines.iter().skip(1) {
|
||||
if !split_paragraph_at_cursor(text_content, ¤t_cursor) {
|
||||
break;
|
||||
}
|
||||
current_cursor =
|
||||
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0);
|
||||
if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, line) {
|
||||
current_cursor = TextPositionWithAffinity::new_without_affinity(
|
||||
current_cursor.paragraph,
|
||||
new_offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(current_cursor)
|
||||
}
|
||||
|
||||
/// Insert text at a cursor position, splitting on newlines into multiple paragraphs.
|
||||
/// Returns the final cursor position after insertion.
|
||||
pub fn insert_text_with_newlines(
|
||||
@ -356,6 +393,58 @@ pub fn insert_text_at_cursor(
|
||||
Some(cursor.offset + text.chars().count())
|
||||
}
|
||||
|
||||
/// Replace text at cursor position (overtype mode). Replaces N characters where N is the
|
||||
/// length of the input text, returning the new cursor offset.
|
||||
pub fn replace_text_at_cursor(
|
||||
text_content: &mut TextContent,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
text: &str,
|
||||
) -> Option<usize> {
|
||||
let text_len = text.chars().count();
|
||||
if text_len == 0 {
|
||||
return Some(cursor.offset);
|
||||
}
|
||||
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
if cursor.paragraph >= paragraphs.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let para = &mut paragraphs[cursor.paragraph];
|
||||
let children = para.children_mut();
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if children.len() == 1 && children[0].text.is_empty() {
|
||||
children[0].set_text(text.to_string());
|
||||
return Some(text_len);
|
||||
}
|
||||
|
||||
let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.offset)?;
|
||||
|
||||
let children = para.children_mut();
|
||||
let span = &mut children[span_idx];
|
||||
let mut new_text = span.text.clone();
|
||||
|
||||
let byte_offset = new_text
|
||||
.char_indices()
|
||||
.nth(offset_in_span)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(new_text.len());
|
||||
|
||||
let end_byte_offset = new_text
|
||||
.char_indices()
|
||||
.nth(offset_in_span + text_len)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(new_text.len());
|
||||
|
||||
new_text.replace_range(byte_offset..end_byte_offset, text);
|
||||
span.set_text(new_text);
|
||||
|
||||
Some(cursor.offset + text_len)
|
||||
}
|
||||
|
||||
/// Delete a range of text specified by a selection.
|
||||
pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
|
||||
let start = selection.start();
|
||||
|
||||
@ -455,6 +455,15 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.text_editor_state.toggle_overtype_mode();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME: Review if all the return Ok(()) should be Err instead.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
@ -491,9 +500,14 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
|
||||
}
|
||||
|
||||
let cursor = state.text_editor_state.selection.focus;
|
||||
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
if !state.text_editor_state.is_overtype_mode {
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
} else if let Some(new_cursor) =
|
||||
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
@ -876,7 +890,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) {
|
||||
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
|
||||
with_state_mut!(state, {
|
||||
state.text_editor_state.update_blink(timestamp_ms);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user