Compare commits

...

15 Commits

Author SHA1 Message Date
Alejandro Alonso
782adec4e6 🐛 Fix stroke artifacts 2026-02-23 09:34:47 +01:00
Aitor Moreno
c58054d19c Merge pull request #8408 from penpot/ladybenko-always-mock-config-playwright
🔧 Always mock config.js in Playwright
2026-02-20 14:26:45 +01:00
Alejandro Alonso
c7f644ab2a Merge pull request #8420 from penpot/elenatorro-13426-improve-pan-and-zoom-for-blur
🔧 Improve performance on shapes with blur
2026-02-20 12:49:24 +01:00
Elena Torró
cb5cacbcee Merge pull request #8413 from penpot/alotor-fix-error-emtpy-text
🐛 Fix problem with empty text
2026-02-19 17:09:03 +01:00
Elena Torro
337cfc2d3e 🔧 Improve performance on shapes with blur 2026-02-19 16:50:42 +01:00
Belén Albeza
c2ee31e791 Fix some flaky text editor v2 tests 2026-02-19 15:46:16 +01:00
alonso.torres
360937f613 🐛 Fix problem with empty text 2026-02-19 12:00:05 +01:00
Belén Albeza
f6d0414449 🔧 Use config flag for variants tests 2026-02-19 09:56:28 +01:00
Belén Albeza
4d05827fa9 🔧 Use disable-onboarding config flag instead of mocking get-profile in playwright 2026-02-19 09:56:27 +01:00
Belén Albeza
48fb9fa6ea Fix broken playwright tests 2026-02-19 09:56:27 +01:00
Belén Albeza
c74cf3fa37 🔧 Make config.js automatically mocked in playwright tests 2026-02-18 16:54:03 +01:00
Aitor Moreno
7c1ddd3d7d Merge pull request #8382 from penpot/alotor-fix-components-propagation
🐛 Fix problem with text change component propagation and undo
2026-02-18 10:37:06 +01:00
Alejandro Alonso
4965f6d859 Merge pull request #8394 from penpot/elenatorro-fix-watch
🔧 Fix watch script
2026-02-18 10:11:44 +01:00
Elena Torro
a3cd90da7f 🔧 Fix watch script 2026-02-18 09:57:25 +01:00
alonso.torres
30106f8524 🐛 Fix problem with text change component propagation and undo 2026-02-17 11:54:33 +01:00
37 changed files with 495 additions and 234 deletions

View File

@@ -605,31 +605,31 @@
add-undo-change-shape
(fn [change-set id]
(let [shape (get objects id)]
(conj
change-set
{:type :add-obj
:id id
:page-id page-id
:parent-id (:parent-id shape)
:frame-id (:frame-id shape)
:index (cfh/get-position-on-parent objects id)
:obj (cond-> shape
(contains? shape :shapes)
(assoc :shapes []))})))
(cond-> change-set
(some? shape)
(conj {:type :add-obj
:id id
:page-id page-id
:parent-id (:parent-id shape)
:frame-id (:frame-id shape)
:index (cfh/get-position-on-parent objects id)
:obj (cond-> shape
(contains? shape :shapes)
(assoc :shapes []))}))))
add-undo-change-parent
(fn [change-set id]
(let [shape (get objects id)
prev-sibling (cfh/get-prev-sibling objects (:id shape))]
(conj
change-set
{:type :mov-objects
:page-id page-id
:parent-id (:parent-id shape)
:shapes [id]
:after-shape prev-sibling
:index 0
:ignore-touched true})))]
(cond-> change-set
(some? shape)
(conj {:type :mov-objects
:page-id page-id
:parent-id (:parent-id shape)
:shapes [id]
:after-shape prev-sibling
:index 0
:ignore-touched true}))))]
(-> changes
(update :redo-changes #(reduce add-redo-change % ids))
@@ -1150,3 +1150,24 @@
[changes]
(::page-id (meta changes)))
(defn set-text-content
[changes id content prev-content]
(assert-page-id! changes)
(let [page-id (::page-id (meta changes))
redo-change
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set :attr :content :val content}]}
undo-change
{:type :mod-obj
:page-id page-id
:id id
:operations [{:type :set :attr :content :val prev-content}]}]
(-> changes
(update :redo-changes conj redo-change)
(update :undo-changes conj undo-change))))

View File

@@ -1,26 +0,0 @@
[
{
"~:features": {
"~#set": [
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true
},
"~:name": "Default",
"~:modified-at": "~m1713533116375",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f7920a",
"~:created-at": "~m1713533116375",
"~:is-default": true
}
]

View File

@@ -1,26 +0,0 @@
{
"~:email": "foo@example.com",
"~:is-demo": false,
"~:auth-backend": "penpot",
"~:fullname": "Princesa Leia",
"~:modified-at": "~m1713533116365",
"~:is-active": true,
"~:default-project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
"~:id": "~uc7ce0794-0992-8105-8004-38e630f29a9b",
"~:is-muted": false,
"~:default-team-id": "~uc7ce0794-0992-8105-8004-38e630f40f6d",
"~:created-at": "~m1713533116365",
"~:is-blocked": false,
"~:props": {
"~:nudge": {
"~:big": 10,
"~:small": 1
},
"~:v2-info-shown": true,
"~:viewed-tutorial?": false,
"~:viewed-walkthrough?": false,
"~:onboarding-viewed": true,
"~:builtin-templates-collapsed-status":
true
}
}

View File

@@ -1,4 +1,8 @@
export class BasePage {
static async init(page) {
await BasePage.mockConfigFlags(page, []);
}
/**
* Mocks multiple RPC calls in a single call.
*

View File

@@ -2,13 +2,8 @@ import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper";
import BasePage from "./BasePage";
export class BaseWebSocketPage extends BasePage {
/**
* This should be called on `test.beforeEach`.
*
* @param {Page} page
* @returns
*/
static async initWebSockets(page) {
static async init(page) {
await super.init(page);
await MockWebSocketHelper.init(page);
}

View File

@@ -3,54 +3,62 @@ import { BaseWebSocketPage } from "./BaseWebSocketPage";
export class DashboardPage extends BaseWebSocketPage {
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await super.init(page);
await BaseWebSocketPage.mockRPC(
await super.mockConfigFlags(page, ["disable-onboarding"]);
await super.mockRPC(
page,
"get-teams",
"logged-in-user/get-teams-default.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-font-variants?team-id=*",
"workspace/get-font-variants-empty.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-projects?team-id=*",
"logged-in-user/get-projects-default.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-members?team-id=*",
"logged-in-user/get-team-members-your-penpot.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-users?team-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-unread-comment-threads?team-id=*",
"logged-in-user/get-team-users-single-user.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-team-recent-files?team-id=*",
"logged-in-user/get-team-recent-files-empty.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-profiles-for-file-comments",
"workspace/get-profile-for-file-comments.json",
);
await BaseWebSocketPage.mockRPC(
await super.mockRPC(
page,
"get-builtin-templates",
"logged-in-user/get-built-in-templates-empty.json",
);
await super.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in.json",
);
}
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f40f6d";

View File

@@ -1,6 +1,10 @@
import { BasePage } from "./BasePage";
export class LoginPage extends BasePage {
static async init(page) {
await super.init(page);
}
constructor(page) {
super(page);
this.loginButton = page.getByRole("button", { name: "Continue" });

View File

@@ -29,8 +29,13 @@ export class RegisterPage extends BasePage {
);
}
static async init(page) {
await BasePage.init(page);
}
static async initWithLoggedOutUser(page) {
await this.mockRPC(page, "get-profile", "get-profile-anonymous.json");
await BasePage.init(page);
await BasePage.mockRPC(page, "get-profile", "get-profile-anonymous.json");
}
}

View File

@@ -3,9 +3,9 @@ import { DashboardPage } from "./DashboardPage";
export class SubscriptionProfilePage extends DashboardPage {
static async init(page) {
await DashboardPage.initWebSockets(page);
await super.init(page);
await DashboardPage.mockRPC(
await super.mockRPC(
page,
"get-subscription-usage",
"subscription/get-subscription-usage.json",

View File

@@ -4,16 +4,6 @@ export class ViewerPage extends BaseWebSocketPage {
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
/**
* This should be called on `test.beforeEach`.
*
* @param {Page} page
* @returns
*/
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
}
async setupLoggedInUser() {
await this.mockRPC(
"get-profile",

View File

@@ -45,24 +45,27 @@ export class WorkspacePage extends BaseWebSocketPage {
return this.waitForEditor();
}
stopEditing() {
return this.page.keyboard.press("Escape");
async stopEditing() {
await this.page.keyboard.press("Escape");
}
async moveToLeft(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowLeft");
}
await this.waitForIdle();
}
async moveToRight(amount = 0) {
for (let i = 0; i < amount; i++) {
await this.page.keyboard.press("ArrowRight");
}
await this.waitForIdle();
}
async moveFromStart(offset = 0) {
await this.page.keyboard.press("Home");
await this.waitForIdle();
await this.moveToRight(offset);
}
@@ -103,6 +106,10 @@ export class WorkspacePage extends BaseWebSocketPage {
changeLetterSpacing(newValue) {
return this.changeNumericInput(this.letterSpacing, newValue);
}
async waitForIdle() {
await this.page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
}
};
/**
@@ -112,9 +119,9 @@ export class WorkspacePage extends BaseWebSocketPage {
* @returns
*/
static async init(page) {
await BaseWebSocketPage.initWebSockets(page);
await super.init(page);
await BaseWebSocketPage.mockRPCs(page, {
await super.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-team-users?file-id=*":
"logged-in-user/get-team-users-single-user.json",

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test.describe("Dashboard Deleted Page", () => {

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("BUG 10421 - Fix libraries context menu", async ({ page }) => {

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("BUG 12359 - Selected invitations count is not pluralized", async ({

View File

@@ -3,11 +3,7 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
await DashboardPage.mockRPC(
page,
"get-teams",

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("Dashboad page has title ", async ({ page }) => {

View File

@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
await LoginPage.init(page);
const login = new LoginPage(page);
await login.initWithLoggedOutUser();

View File

@@ -4,6 +4,7 @@ import OnboardingPage from "../pages/OnboardingPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockConfigFlags(page, ["enable-onboarding"]);
await DashboardPage.mockRPC(
page,
"get-profile",

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("Navigate to penpot changelog from profile menu", async ({ page }) => {

View File

@@ -2,8 +2,6 @@ import { test, expect } from "@playwright/test";
import { Clipboard } from "../../helpers/Clipboard";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
const timeToWait = 100;
test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
@@ -37,11 +35,13 @@ test("Create a new text shape from pasting text", async ({ page, context }) => {
await workspace.setupEmptyFile();
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
await workspace.goToWorkspace();
await workspace.moveButton.click();
await Clipboard.writeText(page, textToPaste);
await workspace.clickAt(190, 150);
await workspace.paste("keyboard");
await workspace.textEditor.stopEditing();
await expect(workspace.layers.getByText(textToPaste)).toBeVisible();
@@ -57,6 +57,7 @@ test("Create a new text shape from pasting text using context menu", async ({
});
await workspace.setupEmptyFile();
await workspace.goToWorkspace();
await workspace.moveButton.click();
await Clipboard.writeText(page, textToPaste);
@@ -138,7 +139,7 @@ test("Update a new text shape appending text by pasting text", async ({
await workspace.paste("keyboard");
await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Lorem ipsum dolor sit amet");
});
});
test.skip("Update a new text shape prepending text by pasting text", async ({
page,

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { BaseWebSocketPage } from "../pages/BaseWebSocketPage";
import { Clipboard } from "../../helpers/Clipboard";
@@ -7,7 +7,7 @@ test.beforeEach(async ({ page, context }) => {
await Clipboard.enable(context, Clipboard.Permission.ALL);
await WasmWorkspacePage.init(page);
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-variants.json");
await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-variants"]);
});
test.afterEach(async ({ context }) => {

View File

@@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { WasmWorkspacePage } from "../pages/WasmWorkspacePage";
import { presenceFixture } from "../../data/workspace/ws-notifications";
test.beforeEach(async ({ page }) => {
await WasmWorkspacePage.init(page);
@@ -106,7 +105,7 @@ test("BUG 11006 - Fix history panel shortcut", async ({ page }) => {
await workspacePage.goToWorkspace();
await page.keyboard.press("Control+Alt+h");
await page.keyboard.press("ControlOrMeta+Alt+h");
await expect(
workspacePage.rightSidebar.getByText("There are no versions yet"),

View File

@@ -3,11 +3,6 @@ import DashboardPage from "../pages/DashboardPage";
test.beforeEach(async ({ page }) => {
await DashboardPage.init(page);
await DashboardPage.mockRPC(
page,
"get-profile",
"logged-in-user/get-profile-logged-in-no-onboarding.json",
);
});
test("User goes to an empty dashboard", async ({ page }) => {

View File

@@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test.beforeEach(async ({ page }) => {
await LoginPage.init(page);
const login = new LoginPage(page);
await login.initWithLoggedOutUser();
await login.page.goto("/#/auth/login");

View File

@@ -197,11 +197,12 @@
objects (:objects page)
undo-id (or (:undo-id options) (js/Symbol))
[all-parents changes] (-> (pcb/empty-changes it (:id page))
(cls/generate-delete-shapes fdata page objects ids
{:ignore-touched (:allow-altering-copies options)
:undo-group (:undo-group options)
:undo-id undo-id}))]
[all-parents changes]
(-> (pcb/empty-changes it (:id page))
(cls/generate-delete-shapes fdata page objects ids
{:ignore-touched (:allow-altering-copies options)
:undo-group (:undo-group options)
:undo-id undo-id}))]
(rx/of (dwu/start-undo-transaction undo-id)
(dc/detach-comment-thread ids)

View File

@@ -10,6 +10,7 @@
[app.common.attrs :as attrs]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
@@ -19,6 +20,7 @@
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.data.changes :as dch]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.common :as dwc]
@@ -916,11 +918,11 @@
(update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))
(defn v2-update-text-shape-content
[id content & {:keys [update-name? name finalize? save-undo?]
:or {update-name? false name nil finalize? false save-undo? true}}]
[id content & {:keys [update-name? name finalize? save-undo? original-content]
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
(ptk/reify ::v2-update-text-shape-content
ptk/WatchEvent
(watch [_ state _]
(watch [it state _]
(if (features/active-feature? state "render-wasm/v1")
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)
@@ -950,11 +952,11 @@
new-shape))
{:save-undo? save-undo? :undo-group (when new-shape? id)})
(let [modifiers (dwwt/resize-wasm-text-modifiers shape content)
options {:undo-group (when new-shape? id)}]
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers modifiers options)
(dwm/set-wasm-modifiers modifiers options))))
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
(let [options {:undo-group (when new-shape? id)}]
(if (and (not= :fixed (:grow-type shape)) finalize?)
(dwm/apply-wasm-modifiers modifiers options)
(dwm/set-wasm-modifiers modifiers options)))))
(when finalize?
(rx/concat
@@ -970,7 +972,13 @@
{:save-undo? false}))
(dws/deselect-shape id)
(dwsh/delete-shapes #{id})))
(rx/of (dwt/finish-transform))))))
(rx/of
;; This commit is necesary for undo and component propagation
;; on finalization
(dch/commit-changes
(-> (pcb/empty-changes it (:current-page-id state))
(pcb/set-text-content id content original-content)))
(dwt/finish-transform))))))
(let [objects (dsh/lookup-page-objects state)
shape (get objects id)

View File

@@ -27,27 +27,28 @@
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(when id
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
origin (first points)]
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}})))
{id
{:modifiers
(ctm/resize-modifiers
resize-v
origin
(:transform shape (gmt/matrix))
(:transform-inverse shape (gmt/matrix)))}}))))
(defn resize-wasm-text
"Resize a single text shape (auto-width/auto-height) by id.

View File

@@ -118,7 +118,8 @@
:update-name? update-name?
:name generated-name
:finalize? true
:save-undo? false))))
:save-undo? false
:original-content original-content))))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))

View File

@@ -540,7 +540,7 @@
[:values schema:layout-item-props-schema]
[:applied-tokens [:maybe [:map-of :keyword :string]]]
[:ids [::sm/vec ::sm/uuid]]
[:v-sizing {:optional true} [:maybe [:= :fill]]]])
[:v-sizing {:optional true} [:maybe [:enum :fill :fix :auto]]]])
(mf/defc layout-size-constraints*
{::mf/private true

View File

@@ -224,8 +224,9 @@
show-gradient-handlers? (= (count selected) 1)
show-grids? (contains? layout :display-guides)
show-frame-outline? (= transform :move)
show-frame-outline? (and (= transform :move) (not panning))
show-outlines? (and (nil? transform)
(not panning)
(not edition)
(not drawing-obj)
(not (#{:comments :path :curve} drawing-tool)))
@@ -561,7 +562,7 @@
:shift? @shift?}])
[:> widgets/frame-titles*
{:objects (with-meta objects-modified nil)
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?

View File

@@ -39,6 +39,7 @@ pub use images::*;
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
@@ -641,6 +642,7 @@ impl RenderState {
apply_to_current_surface: bool,
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
outset: Option<f32>,
) {
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
@@ -699,7 +701,7 @@ impl RenderState {
canvas.translate(translation);
});
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current, outset);
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
@@ -709,6 +711,7 @@ impl RenderState {
&visible_strokes,
Some(SurfaceId::Current),
antialias,
outset,
);
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
@@ -787,6 +790,25 @@ impl RenderState {
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
&& apply_to_current_surface
&& fills_surface_id == SurfaceId::Fills
&& !matches!(shape.shape_type, Type::Text(_))
&& !matches!(shape.shape_type, Type::SVGRaw(_))
{
if let Some(blur) = shape.blur.filter(|b| !b.hidden) {
shape.to_mut().set_blur(None);
Some(blur.value)
} else {
None
}
} else {
None
};
let center = shape.center();
let mut matrix = shape.transform;
matrix.post_translate(center);
@@ -1005,6 +1027,24 @@ impl RenderState {
s.canvas().concat(&matrix);
});
// Wrap ALL fill/stroke/shadow rendering so a single GPU blur pass calls
let blur_filter_for_layers: Option<skia::ImageFilter> = blur_sigma_for_layers
.and_then(|sigma| skia::image_filters::blur((sigma, sigma), None, None, None));
if let Some(ref filter) = blur_filter_for_layers {
let mut layer_paint = skia::Paint::default();
layer_paint.set_image_filter(filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&layer_paint);
self.surfaces
.canvas(fills_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(strokes_surface_id)
.save_layer(&layer_rec);
self.surfaces
.canvas(innershadows_surface_id)
.save_layer(&layer_rec);
}
let shape = &shape;
if shape.fills.is_empty()
@@ -1017,10 +1057,24 @@ impl RenderState {
{
if let Some(fills_to_render) = self.nested_fills.last() {
let fills_to_render = fills_to_render.clone();
fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
fills::render(
self,
shape,
&fills_to_render,
antialias,
fills_surface_id,
outset,
);
}
} else {
fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
fills::render(
self,
shape,
&shape.fills,
antialias,
fills_surface_id,
outset,
);
}
// Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
@@ -1035,6 +1089,7 @@ impl RenderState {
&visible_strokes,
Some(strokes_surface_id),
antialias,
outset,
);
if !fast_mode {
for stroke in &visible_strokes {
@@ -1057,7 +1112,12 @@ impl RenderState {
innershadows_surface_id,
);
}
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
if blur_filter_for_layers.is_some() {
self.surfaces.canvas(innershadows_surface_id).restore();
self.surfaces.canvas(strokes_surface_id).restore();
self.surfaces.canvas(fills_surface_id).restore();
}
}
};
@@ -1149,6 +1209,11 @@ impl RenderState {
let _start = performance::begin_timed_log!("render_preview");
performance::begin_measure!("render_preview");
// Enable fast_mode during preview to skip expensive effects (blur, shadows).
// Restore the previous state afterward so the final render is full quality.
let current_fast_mode = self.options.is_fast_mode();
self.options.set_fast_mode(true);
// Skip tile rebuilding during preview - we'll do it at the end
// Just rebuild tiles for touched shapes and render synchronously
self.rebuild_touched_tiles(tree);
@@ -1156,6 +1221,8 @@ impl RenderState {
// Use the sync render path
self.start_render_loop(None, tree, timestamp, true)?;
self.options.set_fast_mode(current_fast_mode);
performance::end_measure!("render_preview");
performance::end_timed_log!("render_preview", _start);
@@ -1326,11 +1393,16 @@ impl RenderState {
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
// 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();
let sigma = frame_blur.value * scale;
if let Some(filter) =
skia::image_filters::blur((sigma, sigma), None, None, None)
{
paint.set_image_filter(filter);
}
}
}
@@ -1417,6 +1489,7 @@ impl RenderState {
true,
None,
None,
None,
);
}
@@ -1517,9 +1590,7 @@ impl RenderState {
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
let blur_filter = combined_blur
.and_then(|blur| skia::image_filters::blur((blur.value, blur.value), None, None, None));
// Legacy path is only stable up to 1.0 zoom: the canvas is scaled and the shadow
// filter is evaluated in that scaled space, so for scale > 1 it over-inflates blur/spread.
// We also disable it when combined layer blur is present to avoid incorrect composition.
let use_low_zoom_path = scale <= 1.0 && combined_blur.is_none();
if use_low_zoom_path {
@@ -1595,6 +1666,7 @@ impl RenderState {
false,
Some(shadow.offset),
None,
None,
);
});
@@ -1602,13 +1674,27 @@ impl RenderState {
return;
}
let filter_result =
filters::render_into_filter_surface(self, bounds, |state, temp_surface| {
// Adaptive downscale for large blur values (lossless GPU optimization).
// Bounds above were computed from the original sigma so filter surface coverage is correct.
// Maximum downscale is 1/BLUR_DOWNSCALE_THRESHOLD (i.e. 8×): beyond that the
// filter surface becomes too small and quality degrades noticeably.
const MIN_BLUR_DOWNSCALE: f32 = 1.0 / BLUR_DOWNSCALE_THRESHOLD;
let blur_downscale = if shadow.blur > BLUR_DOWNSCALE_THRESHOLD {
(BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)
} else {
1.0
};
let filter_result = filters::render_into_filter_surface(
self,
bounds,
blur_downscale,
|state, temp_surface| {
{
let canvas = state.surfaces.canvas(temp_surface);
let mut shadow_paint = skia::Paint::default();
shadow_paint.set_image_filter(drop_filter.clone());
shadow_paint.set_image_filter(drop_filter);
shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&shadow_paint);
@@ -1626,6 +1712,7 @@ impl RenderState {
false,
Some(shadow.offset),
None,
None,
);
});
@@ -1633,7 +1720,8 @@ impl RenderState {
let canvas = state.surfaces.canvas(temp_surface);
canvas.restore();
}
});
},
);
if let Some((mut surface, filter_scale)) = filter_result {
let drop_canvas = self.surfaces.canvas(SurfaceId::DropShadows);
@@ -1720,6 +1808,7 @@ impl RenderState {
if shadow_shape.hidden {
continue;
}
let nested_clip_bounds =
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
@@ -1767,6 +1856,7 @@ impl RenderState {
true,
None,
Some(vec![new_shadow_paint.clone()]),
None,
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
@@ -1780,7 +1870,6 @@ impl RenderState {
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
@@ -1979,6 +2068,7 @@ impl RenderState {
true,
None,
None,
None,
);
self.surfaces

View File

@@ -2,7 +2,15 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, StrokeKind, Type};
/// True when the shape has at least one visible inner stroke.
fn has_inner_stroke(shape: &Shape) -> bool {
let is_open = shape.is_open();
shape
.visible_strokes()
.any(|s| s.render_kind(is_open) == StrokeKind::Inner)
}
fn draw_image_fill(
render_state: &mut RenderState,
@@ -97,6 +105,7 @@ pub fn render(
fills: &[Fill],
antialias: bool,
surface_id: SurfaceId,
outset: Option<f32>,
) {
if fills.is_empty() {
return;
@@ -106,8 +115,14 @@ pub fn render(
// and sampling options that get_fill_shader (used by merge_fills) lacks.
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
} else {
None
};
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id);
render_single_fill(render_state, shape, fill, antialias, surface_id, outset, inset);
}
return;
}
@@ -115,6 +130,13 @@ pub fn render(
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(antialias);
let scale = render_state.get_scale().max(1e-6);
let inset = if has_inner_stroke(shape) {
Some(1.0 / scale)
} else {
None
};
if let Some(image_filter) = shape.image_filter(1.) {
let bounds = image_filter.compute_fast_bounds(shape.selrect);
if filters::render_with_filter_surface(
@@ -124,7 +146,7 @@ pub fn render(
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint);
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, outset, inset);
},
) {
return;
@@ -133,28 +155,35 @@ pub fn render(
}
}
draw_fill_to_surface(render_state, shape, surface_id, &paint);
draw_fill_to_surface(render_state, shape, surface_id, &paint, outset, inset);
}
/// Draws a single paint (with a merged shader) to the appropriate surface
/// based on the shape type.
/// When `inset` is Some(eps), the fill is inset by eps (e.g. to avoid seam with inner strokes).
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
surface_id: SurfaceId,
paint: &Paint,
outset: Option<f32>,
inset: Option<f32>,
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, outset, inset);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, outset, inset);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, outset, inset);
}
Type::Group(_) => {}
_ => unreachable!("This shape should not have fills"),
@@ -167,6 +196,8 @@ fn render_single_fill(
fill: &Fill,
antialias: bool,
surface_id: SurfaceId,
outset: Option<f32>,
inset: Option<f32>,
) {
let mut paint = fill.to_paint(&shape.selrect, antialias);
if let Some(image_filter) = shape.image_filter(1.) {
@@ -185,6 +216,8 @@ fn render_single_fill(
antialias,
temp_surface,
&filtered_paint,
outset,
inset,
);
},
) {
@@ -194,7 +227,16 @@ fn render_single_fill(
}
}
draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
draw_single_fill_to_surface(
render_state,
shape,
fill,
antialias,
surface_id,
&paint,
outset,
inset,
);
}
fn draw_single_fill_to_surface(
@@ -204,6 +246,8 @@ fn draw_single_fill_to_surface(
antialias: bool,
surface_id: SurfaceId,
paint: &Paint,
outset: Option<f32>,
inset: Option<f32>,
) {
match (fill, &shape.shape_type) {
(Fill::Image(image_fill), _) => {
@@ -217,15 +261,19 @@ fn draw_single_fill_to_surface(
);
}
(_, Type::Rect(_) | Type::Frame(_)) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Circle) => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Path(_)) | (_, Type::Bool(_)) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, outset, inset);
}
(_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children

View File

@@ -40,7 +40,9 @@ pub fn render_with_filter_surface<F>(
where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((mut surface, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
if let Some((mut surface, scale)) =
render_into_filter_surface(render_state, bounds, 1.0, draw_fn)
{
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
@@ -69,9 +71,15 @@ where
/// down so that everything fits; the returned `scale` tells the caller how much the
/// content was reduced so it can be re-scaled on compositing. The `draw_fn` should
/// render the untransformed shape (i.e. in document coordinates) onto `SurfaceId::Filter`.
///
/// `extra_downscale` is an additional scale factor applied on top of the overflow-fit scale.
/// Use values < 1.0 to pre-downscale before applying Gaussian blur filters, which dramatically
/// reduces GPU kernel work for large blur sigmas (Gaussian blur is scale-equivariant, so the
/// caller must also reduce the sigma proportionally). Pass 1.0 for no extra downscale.
pub fn render_into_filter_surface<F>(
render_state: &mut RenderState,
bounds: Rect,
extra_downscale: f32,
draw_fn: F,
) -> Option<(skia::Surface, f32)>
where
@@ -86,16 +94,28 @@ where
let bounds_width = bounds.width().ceil().max(1.0) as i32;
let bounds_height = bounds.height().ceil().max(1.0) as i32;
// Minimum scale floor for fit_scale alone; prevents extreme downscaling when
// the shape is much larger than the filter surface.
const MIN_FIT_SCALE: f32 = 0.1;
// Absolute minimum for the combined scale (fit × extra_downscale). Below this
// the offscreen surface would have sub-pixel dimensions and produce artifacts or
// crashes. At 0.03 a shape must be at least ~34 px wide to render as a single
// pixel, which is a safe lower bound in practice.
const MIN_COMBINED_SCALE: f32 = 0.03;
// Calculate scale factor if bounds exceed filter surface size
let scale = if bounds_width > filter_width || bounds_height > filter_height {
let fit_scale = if bounds_width > filter_width || bounds_height > filter_height {
let scale_x = filter_width as f32 / bounds_width as f32;
let scale_y = filter_height as f32 / bounds_height as f32;
// Use the smaller scale to ensure everything fits
scale_x.min(scale_y).max(0.1) // Clamp to minimum 0.1 to avoid extreme scaling
scale_x.min(scale_y).max(MIN_FIT_SCALE)
} else {
1.0
};
// Combine overflow-fit scale with caller-requested extra downscale
let scale = (fit_scale * extra_downscale).max(MIN_COMBINED_SCALE);
{
let canvas = render_state.surfaces.canvas(filter_id);
canvas.clear(skia::Color::TRANSPARENT);

View File

@@ -47,6 +47,7 @@ pub fn render_stroke_inner_shadows(
Some(surface_id),
filter.as_ref(),
antialias,
None,
)
}
}
@@ -106,15 +107,19 @@ fn render_shadow_paint(
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
render_state
.surfaces
.draw_rect_to(surface_id, shape, paint, None, None);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
.draw_circle_to(surface_id, shape, paint, None, None);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
render_state
.surfaces
.draw_path_to(surface_id, shape, paint, None, None);
}
_ => {}
}

View File

@@ -526,6 +526,7 @@ pub fn render(
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
outset: Option<f32>,
) {
if strokes.is_empty() {
return;
@@ -540,6 +541,10 @@ pub fn render(
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
@@ -583,6 +588,7 @@ pub fn render(
antialias,
true,
true,
outset,
);
}
@@ -595,12 +601,28 @@ pub fn render(
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(render_state, shape, stroke, surface_id, None, antialias);
render_single(
render_state,
shape,
stroke,
surface_id,
None,
antialias,
outset,
);
}
return;
}
render_merged(render_state, shape, strokes, surface_id, antialias, false);
render_merged(
render_state,
shape,
strokes,
surface_id,
antialias,
false,
outset,
);
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
@@ -620,6 +642,7 @@ fn render_merged(
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
outset: Option<f32>,
) {
let representative = *strokes
.last()
@@ -635,6 +658,10 @@ fn render_merged(
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
@@ -660,7 +687,15 @@ fn render_merged(
canvas.save_layer(&layer_rec);
});
render_merged(state, shape, strokes, Some(temp_surface), antialias, true);
render_merged(
state,
shape,
strokes,
Some(temp_surface),
antialias,
true,
outset,
);
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
@@ -676,7 +711,16 @@ fn render_merged(
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
let merged = merge_fills(&fills, shape.selrect);
// Expand selrect if outset is provided
let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
let merged = merge_fills(&fills, selrect);
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
@@ -747,6 +791,7 @@ pub fn render_single(
surface_id: Option<SurfaceId>,
shadow: Option<&ImageFilter>,
antialias: bool,
outset: Option<f32>,
) {
render_single_internal(
render_state,
@@ -757,6 +802,7 @@ pub fn render_single(
antialias,
false,
false,
outset,
);
}
@@ -770,10 +816,15 @@ fn render_single_internal(
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
outset: Option<f32>,
) {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
// Expand for outset if provided
if let Some(s) = outset.filter(|&s| s > 0.0) {
content_bounds.outset((s, s));
}
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
@@ -799,6 +850,7 @@ fn render_single_internal(
antialias,
true,
true,
outset,
);
},
) {
@@ -867,7 +919,21 @@ fn render_single_internal(
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
let mut paint =
stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
// Apply outset by increasing stroke width
if let Some(s) = outset.filter(|&s| s > 0.0) {
let current_width = paint.stroke_width();
// Path stroke kinds are built differently:
// - Center uses the stroke width directly.
// - Inner/Outer use a doubled width plus clipping/clearing logic.
// Compensate outset so visual growth is comparable across kinds.
let outset_growth = match stroke.render_kind(is_open) {
StrokeKind::Center => s * 2.0,
StrokeKind::Inner | StrokeKind::Outer => s * 4.0,
};
paint.set_stroke_width(current_width + outset_growth);
}
draw_stroke_on_path(
canvas,
stroke,

View File

@@ -355,24 +355,91 @@ impl Surfaces {
));
}
pub fn draw_rect_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
pub fn draw_rect_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
outset: Option<f32>,
inset: Option<f32>,
) {
let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
if let Some(eps) = inset.filter(|&e| e > 0.0) {
rect.inset((eps, eps));
}
if let Some(corners) = shape.shape_type.corners() {
let rrect = RRect::new_rect_radii(shape.selrect, &corners);
let corners = if let Some(eps) = inset.filter(|&e| e > 0.0) {
let mut c = corners;
for r in c.iter_mut() {
r.x = (r.x - eps).max(0.0);
r.y = (r.y - eps).max(0.0);
}
c
} else {
corners
};
let rrect = RRect::new_rect_radii(rect, &corners);
self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint);
} else {
self.canvas_and_mark_dirty(id)
.draw_rect(shape.selrect, paint);
self.canvas_and_mark_dirty(id).draw_rect(rect, paint);
}
}
pub fn draw_circle_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
self.canvas_and_mark_dirty(id)
.draw_oval(shape.selrect, paint);
pub fn draw_circle_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
outset: Option<f32>,
inset: Option<f32>,
) {
let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) {
let mut r = shape.selrect;
r.outset((s, s));
r
} else {
shape.selrect
};
if let Some(eps) = inset.filter(|&e| e > 0.0) {
rect.inset((eps, eps));
}
self.canvas_and_mark_dirty(id).draw_oval(rect, paint);
}
pub fn draw_path_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
pub fn draw_path_to(
&mut self,
id: SurfaceId,
shape: &Shape,
paint: &Paint,
outset: Option<f32>,
inset: Option<f32>,
) {
if let Some(path) = shape.get_skia_path() {
self.canvas_and_mark_dirty(id).draw_path(&path, paint);
let canvas = self.canvas_and_mark_dirty(id);
if let Some(s) = outset.filter(|&s| s > 0.0) {
// Draw path as a thick stroke to get outset (expanded) silhouette
let mut stroke_paint = paint.clone();
stroke_paint.set_style(skia::PaintStyle::Stroke);
stroke_paint.set_stroke_width(s * 2.0);
canvas.draw_path(&path, &stroke_paint);
} else {
canvas.draw_path(&path, paint);
// Inset: avoid seam with inner strokes by clearing a thin border from the fill
if let Some(eps) = inset.filter(|&e| e > 0.0) {
let mut clear_paint = skia::Paint::default();
clear_paint.set_style(skia::PaintStyle::Stroke);
clear_paint.set_stroke_width(eps * 2.0);
clear_paint.set_blend_mode(skia::BlendMode::Clear);
clear_paint.set_anti_alias(paint.is_anti_alias());
canvas.draw_path(&path, &clear_paint);
}
}
}
}

View File

@@ -14,7 +14,7 @@ pushd $_SCRIPT_DIR;
cargo watch \
--why \
-i "_tmp*"
-i "_tmp*" \
-x "build $CARGO_PARAMS" \
-s "./build" \
-s "echo 'DONE\n'";