mirror of
https://github.com/penpot/penpot.git
synced 2026-07-03 12:55:04 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
5212e2202b
@ -92,6 +92,10 @@ IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
* **Linting:** `pnpm run lint` from the repository root.
|
||||
* **Formatting:** `pnpm run check-fmt`. Use `pnpm run fmt` to fix. Avoid unrelated whitespace diffs.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits), run
|
||||
`tools/paren-repair.bb` on the affected files first. Delimiter errors produce
|
||||
misleading linter/compiler output. See `mem:tools/paren-repair`.
|
||||
|
||||
## Testing
|
||||
|
||||
IMPORTANT: all CLI commands must be executed from the `backend/` subdirectory.
|
||||
|
||||
@ -48,7 +48,7 @@ Components, variants, and debugging:
|
||||
|
||||
Text and tests:
|
||||
- Shared text data conversion, DraftJS compatibility, modern text content, and derived position data: `mem:common/text-subtleties`.
|
||||
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/test-setup`.
|
||||
- Common test commands, helper conventions, production-path test mutations, and runtime coverage choices: `mem:common/testing`.
|
||||
|
||||
## Areas without focused memories
|
||||
|
||||
|
||||
@ -1,24 +1,27 @@
|
||||
# Common Module Test Setup
|
||||
# Common Testing and Verification
|
||||
|
||||
`common/` is CLJC shared code. Tests should cover the relevant runtime(s): JVM for backend/common logic and JS for frontend/exporter behavior. For geometry, component, and file-model changes, JVM tests are common and fast, but JS/browser behavior can differ when WASM modifier math or CLJS-specific state is involved.
|
||||
|
||||
## Running tests
|
||||
## Unit tests
|
||||
|
||||
Common tests live under `common/test/common_tests/` and use `clojure.test`.
|
||||
They are CLJC and run on both JVM and JS.
|
||||
|
||||
From `common/`:
|
||||
- Full JVM test run: `clojure -M:dev:test`
|
||||
- Full JS test run: `pnpm run test:quiet`
|
||||
- Focus a JVM test namespace: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test`
|
||||
- Focus a JVM test var: `clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch`
|
||||
- Focus a JS test namespace: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test`
|
||||
- Focus a JS test var: `pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute`
|
||||
- Quiet logging during a JS run: append `--log-level warn` (or `trace|debug|info|warn|error`)
|
||||
- Build JS test target only: `pnpm run build:test`
|
||||
- After `pnpm run build:test`, direct compiled runner: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`
|
||||
- Watch tests: `pnpm run watch:test`
|
||||
|
||||
```bash
|
||||
pnpm run test:jvm
|
||||
clojure -M:dev:test
|
||||
pnpm run test:jvm --focus common-tests.logic.variants-switch-test
|
||||
clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch
|
||||
pnpm run test:js
|
||||
pnpm run test:quiet
|
||||
pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test
|
||||
pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn
|
||||
pnpm run watch:test
|
||||
```
|
||||
|
||||
Use `test:quiet` for non-interactive JS runs; it buffers `build:test` output and forwards runner args. Common JS runner args support `--focus <namespace-or-var>` and `--log-level trace|debug|info|warn|error`. After `pnpm run build:test`, direct compiled runner focus is faster: `node target/tests/test.js --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute --log-level warn`. New common JS test namespaces must be required/listed in `common_tests/runner.cljc`; new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags compose as a union.
|
||||
New common JS test namespaces must be required/listed in `common_tests/runner.cljc`;
|
||||
new vars in existing namespaces need no runner change. Multiple JVM `--focus` flags
|
||||
compose as a union.
|
||||
|
||||
## Test helpers
|
||||
|
||||
@ -45,4 +48,4 @@ For geometry-sensitive tests, read `mem:common/geometry-invariants` before posit
|
||||
|
||||
## Debugging
|
||||
|
||||
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
|
||||
Use `mem:common/component-debugging-recipes` for shape-tree dumps, undo/change inspection, and temporary live instrumentation recipes.
|
||||
@ -14,6 +14,11 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
- Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool.
|
||||
- Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps.
|
||||
*After making changes, run the applicable lint and format checks for the affected module before considering the work done (per example `mem:backend/core` or `mem:frontend/core`).
|
||||
- Align `let` binding values: when a `let` form has multiple bindings spanning
|
||||
several lines, align the value forms to the same column with spaces.
|
||||
- If you introduce delimiter errors (mismatched parens/brackets) in Clojure/CLJS files,
|
||||
fix them with `tools/paren-repair.bb` BEFORE running lint/format checks.
|
||||
See `mem:tools/paren-repair` for usage.
|
||||
- Never run anything that destroys data without explicit permission, including `drop-devenv`, `docker compose down -v`, `docker volume rm ...`. The user's real work lives in the volumes of the shared infra.
|
||||
|
||||
# Project modules
|
||||
@ -39,6 +44,9 @@ module. You can read it from `mem:<MODULE>/core`
|
||||
When working on devenv startup, compose layout, instance config (`defaults.env`),
|
||||
tmux session lifecycle, MinIO provisioning, or anything in `manage.sh`'s
|
||||
`*-devenv` commands, read `mem:devenv/core`.
|
||||
- `tools/` contains standalone dev utilities: `nrepl-eval.mjs` (backend REPL eval),
|
||||
`paren-repair.bb` (delimiter-error fixer, see `mem:tools/paren-repair`), and
|
||||
`taiga.py` / `gh.py` (issue management helpers).
|
||||
- `experiments/` contains standalone experimental HTML/JS/scripts; treat it as non-core unless the user explicitly asks about it.
|
||||
- `sample_media/` contains sample image/icon media and config used as fixtures/demo material; do not infer app behavior from it.
|
||||
|
||||
|
||||
@ -26,6 +26,11 @@ From `frontend/`:
|
||||
- Format fix: `pnpm run fmt`, or targeted `fmt:clj` / `fmt:js` / `fmt:scss`.
|
||||
- Translation formatting after i18n edits: `pnpm run translations`.
|
||||
|
||||
**Before linting:** if delimiter errors are suspected (after LLM edits, or
|
||||
lint/compiler reports syntax errors), run `tools/paren-repair.bb` on the
|
||||
affected files first. Delimiter errors produce misleading linter output.
|
||||
See `mem:tools/paren-repair`.
|
||||
|
||||
## Focused memory routing
|
||||
|
||||
UI and packages:
|
||||
|
||||
@ -10,6 +10,12 @@ You have access to two tools for finding errors in Clojure source code (which yo
|
||||
The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second
|
||||
tool can often find the exact location of such errors.
|
||||
|
||||
When delimiter errors are detected (typically from lint or compiler output),
|
||||
fix the affected files with `tools/paren-repair.bb`. The `clj_check_parentheses`
|
||||
MCP tool can also pinpoint the error location when available, but it is not
|
||||
required — standard build errors are usually enough.
|
||||
See `mem:tools/paren-repair`.
|
||||
|
||||
## Runtime patching with `set!`
|
||||
|
||||
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching.
|
||||
|
||||
29
.serena/memories/tools/paren-repair.md
Normal file
29
.serena/memories/tools/paren-repair.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Paren-Repair
|
||||
|
||||
`tools/paren-repair.bb` fixes mismatched parentheses, brackets, and braces in
|
||||
Clojure/ClojureScript files, then reformats them with cljfmt.
|
||||
|
||||
## When to use
|
||||
|
||||
- After LLM edits introduce broken delimiters — proactively run it on files
|
||||
you just touched.
|
||||
- When lint (clj-kondo), the Clojure compiler, or shadow-cljs report syntax
|
||||
errors mentioning mismatched/unclosed delimiters, reader errors, or
|
||||
unexpected EOF.
|
||||
- Before running lint/format checks — delimiter errors make linter output
|
||||
misleading. Fix them first, then lint.
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
# File mode (in-place fix + format)
|
||||
bb tools/paren-repair.bb path/to/file.clj
|
||||
|
||||
# Pipe mode (stdin → fixed code to stdout)
|
||||
echo '(def x 1' | bb tools/paren-repair.bb
|
||||
|
||||
# Help
|
||||
bb tools/paren-repair.bb --help
|
||||
```
|
||||
|
||||
`bb` must be invoked from the repo root so the path `tools/paren-repair.bb` resolves.
|
||||
@ -127,6 +127,13 @@
|
||||
- Fix open overlay board mispositioned with repeated image artifacts in viewer WebGL [#10180](https://github.com/penpot/penpot/issues/10180) (PR: [#10191](https://github.com/penpot/penpot/pull/10191))
|
||||
- Fix color picker preview when browser has non-100% zoom level [#10265](https://github.com/penpot/penpot/issues/10265) (PR: [#10305](https://github.com/penpot/penpot/pull/10305))
|
||||
|
||||
## 2.16.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix error 500 when submitting the contact form [#10178](https://github.com/penpot/penpot/issues/10178) (PR: [#10419](https://github.com/penpot/penpot/pull/10419))
|
||||
- Fix text editor modifying content and detaching applied typography tokens [#10389](https://github.com/penpot/penpot/issues/10389) (PR: [#10402](https://github.com/penpot/penpot/pull/10402))
|
||||
|
||||
## 2.16.1
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
@ -284,6 +284,53 @@ test.describe("Inspect tab - Styles", () => {
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.blur);
|
||||
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Blur");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape - Blur on not svg compatible shape", async ({ page }) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.blur);
|
||||
|
||||
await workspacePage.page.getByTestId("add-stroke").click();
|
||||
await workspacePage.page.getByTestId("stroke.alignment").click();
|
||||
await workspacePage.page.getByRole("option", { name: "Center" }).click();
|
||||
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Blur");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape - Background Blur on not svg compatible shape", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WasmWorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.blur);
|
||||
|
||||
await workspacePage.page.getByRole('combobox', { name: 'Blur type select' }).click();
|
||||
await workspacePage.page.getByRole('option', { name: 'Background blur' }).click();
|
||||
await workspacePage.page.getByTestId("add-stroke").click();
|
||||
await workspacePage.page.getByTestId("stroke.alignment").click();
|
||||
await workspacePage.page.getByRole("option", { name: "Center" }).click();
|
||||
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Blur");
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.types.shape.layout :as ctl]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.viewport-wasm :as dwvw]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn hover-grid-cell
|
||||
@ -104,7 +105,11 @@
|
||||
y (+ y (/ height 2) (- (/ (:height vport) 2 zoom)))
|
||||
srect (grc/make-rect x y width height)]
|
||||
(-> local
|
||||
(update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2])))))))))))
|
||||
(update :vbox merge (select-keys srect [:x :y :x1 :x2 :y1 :y2])))))))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))
|
||||
|
||||
(defn select-track-cells
|
||||
[grid-id type index]
|
||||
|
||||
@ -27,4 +27,4 @@
|
||||
(defn maybe-view-interaction-end!
|
||||
[state]
|
||||
(when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state)))
|
||||
(wasm.api/view-interaction-end!)))
|
||||
(wasm.api/finalize-view-interaction!)))
|
||||
@ -8,6 +8,7 @@
|
||||
"WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)."
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.wasm :as wasm]
|
||||
[app.util.dom :as dom]
|
||||
@ -43,15 +44,31 @@
|
||||
:dpr 1}))
|
||||
|
||||
(defn- draw-bitmap!
|
||||
"Blit the rendered OffscreenCanvas onto the visible 2D `canvas`. Firefox+NVIDIA
|
||||
can't `drawImage` a managed OffscreenCanvas directly (yields transparent
|
||||
pixels), but `drawImage` of an `ImageBitmap` works — so go through
|
||||
`createImageBitmap` (GPU-side, correct orientation, no CPU readback)."
|
||||
[canvas os-canvas object-id vis-w vis-h finish]
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [ctx2d (.getContext canvas "2d")]
|
||||
(.clearRect ctx2d 0 0 vis-w vis-h)
|
||||
;; Draw directly from OffscreenCanvas so it can be reused across passes.
|
||||
(.drawImage ctx2d os-canvas 0 0 vis-w vis-h)
|
||||
(dom/set-attribute! canvas "id" (str "screenshot-" object-id))
|
||||
(finish)))))
|
||||
(-> (js/createImageBitmap os-canvas)
|
||||
(p/then
|
||||
(fn [bitmap]
|
||||
(ts/raf
|
||||
(fn []
|
||||
(let [ctx2d (.getContext canvas "2d")]
|
||||
(.clearRect ctx2d 0 0 vis-w vis-h)
|
||||
(.drawImage ctx2d bitmap 0 0 vis-w vis-h)
|
||||
(.close bitmap)
|
||||
(dom/set-attribute! canvas "id" (str "screenshot-" object-id))
|
||||
(finish))))))
|
||||
(p/catch
|
||||
(fn [e]
|
||||
(finish)
|
||||
(ts/schedule
|
||||
(fn []
|
||||
(ex/raise :type :wasm-error
|
||||
:code :viewer-canvas-failed
|
||||
:hint "Viewer canvas failed"
|
||||
:cause e)))))))
|
||||
|
||||
(defn- viewer-disable-wasm-ui-overlay!
|
||||
"Workspace WASM UI (rulers + rounded viewport frame) is composited in
|
||||
|
||||
@ -86,4 +86,5 @@
|
||||
|
||||
.token-name-tooltip {
|
||||
color: var(--color-foreground-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@ -12,15 +12,15 @@
|
||||
[app.common.types.components-list :as ctkl]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.inspect.annotation :refer [annotation*]]
|
||||
[app.main.ui.inspect.attributes.blur :refer [blur-panel]]
|
||||
[app.main.ui.inspect.attributes.blur :refer [blur-panel*]]
|
||||
[app.main.ui.inspect.attributes.fill :refer [fill-panel*]]
|
||||
[app.main.ui.inspect.attributes.geometry :refer [geometry-panel]]
|
||||
[app.main.ui.inspect.attributes.layout :refer [layout-panel]]
|
||||
[app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel]]
|
||||
[app.main.ui.inspect.attributes.shadow :refer [shadow-panel]]
|
||||
[app.main.ui.inspect.attributes.geometry :refer [geometry-panel*]]
|
||||
[app.main.ui.inspect.attributes.layout :refer [layout-panel*]]
|
||||
[app.main.ui.inspect.attributes.layout-element :refer [layout-element-panel*]]
|
||||
[app.main.ui.inspect.attributes.shadow :refer [shadow-panel*]]
|
||||
[app.main.ui.inspect.attributes.stroke :refer [stroke-panel*]]
|
||||
[app.main.ui.inspect.attributes.svg :refer [svg-panel]]
|
||||
[app.main.ui.inspect.attributes.text :refer [text-panel]]
|
||||
[app.main.ui.inspect.attributes.svg :refer [svg-panel*]]
|
||||
[app.main.ui.inspect.attributes.text :refer [text-panel*]]
|
||||
[app.main.ui.inspect.attributes.variant :refer [variant-panel*]]
|
||||
[app.main.ui.inspect.attributes.visibility :refer [visibility-panel*]]
|
||||
[app.main.ui.inspect.exports :refer [exports]]
|
||||
@ -61,16 +61,16 @@
|
||||
:workspace-element-options (= from :workspace))}
|
||||
(for [[idx option] (map-indexed vector options)]
|
||||
[:> (case option
|
||||
:geometry geometry-panel
|
||||
:layout layout-panel
|
||||
:layout-element layout-element-panel
|
||||
:geometry geometry-panel*
|
||||
:layout layout-panel*
|
||||
:layout-element layout-element-panel*
|
||||
:fill fill-panel*
|
||||
:stroke stroke-panel*
|
||||
:shadow shadow-panel
|
||||
:blur blur-panel
|
||||
:shadow shadow-panel*
|
||||
:blur blur-panel*
|
||||
:visibility visibility-panel*
|
||||
:text text-panel
|
||||
:svg svg-panel
|
||||
:text text-panel*
|
||||
:svg svg-panel*
|
||||
:variant variant-panel*)
|
||||
{:key idx
|
||||
:shapes shapes
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.main.ui.components.copy-button :refer [copy-button*]]
|
||||
[app.main.ui.components.title-bar :refer [inspect-title-bar*]]
|
||||
[app.util.code-gen.style-css :as css]
|
||||
[app.util.code-gen.style-css-formats :refer [format-blur]]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@ -20,8 +21,8 @@
|
||||
(or (:blur shape)
|
||||
(:background-blur shape)))
|
||||
|
||||
(mf/defc blur-panel
|
||||
[{:keys [objects shapes]}]
|
||||
(mf/defc blur-panel*
|
||||
[{:keys [shapes]}]
|
||||
(let [render-wasm? (features/use-feature "render-wasm/v1")
|
||||
bg-blur? (and render-wasm?
|
||||
(contains? cf/flags :background-blur))
|
||||
@ -36,34 +37,51 @@
|
||||
:class (stl/css :title-wrapper)
|
||||
:title-class (stl/css :blur-attr-title)}
|
||||
(when (= (count shapes) 1)
|
||||
(let [background-blur (:background-blur (first shapes))
|
||||
layer-blur (:blur (first shapes))]
|
||||
(let [layer-blur (:blur (first shapes))
|
||||
blur-property :filter
|
||||
blue-value-raw (get-in (first shapes) [:blur :value])
|
||||
blur-property-value (css/format-css-property [blur-property blue-value-raw] {})
|
||||
|
||||
background-blur (:background-blur (first shapes))
|
||||
background-blur-property :backdrop-filter
|
||||
background-blur-value-raw (get-in (first shapes) [:background-blur :value])
|
||||
background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})]
|
||||
(when background-blur
|
||||
[:> copy-button* {:data (css/get-css-property objects (first shapes) :backdrop-filter)
|
||||
[:> copy-button* {:data (dm/str background-blur-property-value)
|
||||
:class (stl/css :copy-btn-title)}])
|
||||
(when layer-blur
|
||||
[:> copy-button* {:data (css/get-css-property objects (first shapes) :filter)
|
||||
[:> copy-button* {:data (dm/str blur-property-value)
|
||||
:class (stl/css :copy-btn-title)}])))]
|
||||
|
||||
[:div {:class (stl/css :attributes-content)}
|
||||
(for [shape shapes]
|
||||
(let [background-blur (:background-blur (first shapes))
|
||||
layer-blur (:blur (first shapes))]
|
||||
[:div {:class (stl/css :blur-row)
|
||||
:key (dm/str "block-" (:id shape) "-blur")}
|
||||
(let [layer-blur (:blur (first shapes))
|
||||
blur-property :filter
|
||||
blue-value-raw (get-in (first shapes) [:blur :value])
|
||||
blur-property-value (css/format-css-property [blur-property blue-value-raw] {})
|
||||
blur-value-detail (format-blur blue-value-raw)
|
||||
|
||||
background-blur (:background-blur (first shapes))
|
||||
background-blur-property :backdrop-filter
|
||||
background-blur-value-raw (get-in (first shapes) [:background-blur :value])
|
||||
background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})
|
||||
background-blur-value-detail (format-blur background-blur-value-raw)]
|
||||
[:*
|
||||
(when background-blur
|
||||
[:div {:key (dm/str "block-" (:id shape) "-background-blur")}
|
||||
[:div {:class (stl/css :blur-row)
|
||||
:key (dm/str "block-" (:id shape) "-background-blur")}
|
||||
[:div {:class (stl/css :global/attr-label)}
|
||||
"Backdrop Filter"]
|
||||
[:div {:class (stl/css :global/attr-value)}
|
||||
[:> copy-button* {:data (css/get-css-property objects shape :backdrop-filter)}
|
||||
[:> copy-button* {:data (dm/str background-blur-property-value)}
|
||||
[:div {:class (stl/css :button-children)}
|
||||
(css/get-css-value objects shape :backdrop-filter)]]]])
|
||||
(dm/str background-blur-value-detail)]]]])
|
||||
(when layer-blur
|
||||
[:div {:key (dm/str "block-" (:id shape) "-layer-blur")}
|
||||
[:div {:class (stl/css :blur-row)
|
||||
:key (dm/str "block-" (:id shape) "-layer-blur")}
|
||||
[:div {:class (stl/css :global/attr-label)}
|
||||
"Filter"]
|
||||
[:div {:class (stl/css :global/attr-value)}
|
||||
[:> copy-button* {:data (css/get-css-property objects shape :filter)}
|
||||
[:> copy-button* {:data (dm/str blur-property-value)}
|
||||
[:div {:class (stl/css :button-children)}
|
||||
(css/get-css-value objects shape :filter)]]]])]))]])))
|
||||
(dm/str blur-value-detail)]]]])]))]])))
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
[:div {:class (stl/css :button-children)} value]]]])))])
|
||||
|
||||
|
||||
(mf/defc geometry-panel
|
||||
(mf/defc geometry-panel*
|
||||
[{:keys [objects shapes]}]
|
||||
[:div {:class (stl/css :attributes-block)}
|
||||
[:> inspect-title-bar*
|
||||
|
||||
@ -49,7 +49,7 @@
|
||||
[:> copy-button* {:data (css/get-css-property objects shape property)}
|
||||
[:div {:class (stl/css :button-children)} value]]]]))))
|
||||
|
||||
(mf/defc layout-panel
|
||||
(mf/defc layout-panel*
|
||||
[{:keys [objects shapes]}]
|
||||
(let [shapes (->> shapes (filter ctl/any-layout?))]
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
[:> copy-button* {:data (css/get-css-property objects shape property)}
|
||||
[:div {:class (stl/css :button-children)} value]]]]))))
|
||||
|
||||
(mf/defc layout-element-panel
|
||||
(mf/defc layout-element-panel*
|
||||
[{:keys [objects shapes]}]
|
||||
(let [shapes (->> shapes (filter #(ctl/any-layout-immediate-child? objects %)))
|
||||
only-flex? (every? #(ctl/flex-layout-immediate-child? objects %) shapes)
|
||||
|
||||
@ -56,7 +56,8 @@
|
||||
:copy-data (copy-color-data (:color shadow) color-format*)
|
||||
:on-change-format on-change-format}]]))
|
||||
|
||||
(mf/defc shadow-panel [{:keys [shapes]}]
|
||||
(mf/defc shadow-panel*
|
||||
[{:keys [shapes]}]
|
||||
(let [shapes (->> shapes (filter has-shadow?))]
|
||||
|
||||
(when (and (seq shapes) (> (count shapes) 0))
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
(for [[attr-key attr-value] (:svg-attrs shape)]
|
||||
[:& svg-attr {:attr attr-key :value attr-value :key (str/join "svg-block-key-" (d/name attr-key))}])])
|
||||
|
||||
(mf/defc svg-panel
|
||||
(mf/defc svg-panel*
|
||||
[{:keys [shapes]}]
|
||||
(let [shape (first shapes)]
|
||||
(when (seq (:svg-attrs shape))
|
||||
|
||||
@ -151,7 +151,7 @@
|
||||
:style full-style
|
||||
:text text}])))
|
||||
|
||||
(mf/defc text-panel
|
||||
(mf/defc text-panel*
|
||||
[{:keys [shapes]}]
|
||||
(when-let [shapes (seq (filter has-text? shapes))]
|
||||
[:div {:class (stl/css :attributes-block)}
|
||||
|
||||
@ -246,8 +246,7 @@
|
||||
(let [shapes (->> shapes (filter has-blur?))]
|
||||
(when (seq shapes)
|
||||
[:> style-box* {:panel :blur}
|
||||
[:> blur-panel* {:shapes shapes
|
||||
:objects objects}]]))
|
||||
[:> blur-panel* {:shapes shapes}]]))
|
||||
;; TEXT PANEL
|
||||
:text
|
||||
(let [shapes (filter has-text? shapes)]
|
||||
|
||||
@ -11,31 +11,35 @@
|
||||
[app.main.ui.inspect.attributes.common :as cmm]
|
||||
[app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]]
|
||||
[app.util.code-gen.style-css :as css]
|
||||
[app.util.code-gen.style-css-formats :refer [format-blur]]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc blur-panel*
|
||||
[{:keys [shapes objects]}]
|
||||
[{:keys [shapes]}]
|
||||
[:div {:class (stl/css :blur-panel)}
|
||||
(for [shape shapes]
|
||||
[:div {:key (:id shape) :class (stl/css :blur-shape)}
|
||||
(let [blur-property :filter
|
||||
blur-value (css/get-css-value objects shape blur-property)
|
||||
background-blur-property :backdrop-filter
|
||||
blue-value-raw (get-in shape [:blur :value])
|
||||
blur-value-detail (format-blur blue-value-raw)
|
||||
blur-property-name (cmm/get-css-rule-humanized blur-property)
|
||||
blur-property-value (css/get-css-property objects shape blur-property)
|
||||
background-blur-value (css/get-css-value objects shape background-blur-property)
|
||||
blur-property-value (css/format-css-property [blur-property blue-value-raw] {})
|
||||
|
||||
background-blur-property :backdrop-filter
|
||||
background-blur-value-raw (get-in shape [:background-blur :value])
|
||||
background-blur-value-detail (format-blur background-blur-value-raw)
|
||||
background-blur-property-name (cmm/get-css-rule-humanized background-blur-property)
|
||||
background-blur-property-value (css/get-css-property objects shape background-blur-property)]
|
||||
background-blur-property-value (css/format-css-property [background-blur-property background-blur-value-raw] {})]
|
||||
[:div
|
||||
(when blur-property-value
|
||||
(when blue-value-raw
|
||||
[:> properties-row* {:key (dm/str "blur-property-" blur-property)
|
||||
:term blur-property-name
|
||||
:detail (dm/str blur-value)
|
||||
:detail blur-value-detail
|
||||
:property blur-property-value
|
||||
:copiable true}])
|
||||
(when background-blur-property-value
|
||||
(when background-blur-value-raw
|
||||
[:> properties-row* {:key (dm/str "blur-property-" background-blur-property)
|
||||
:term background-blur-property-name
|
||||
:detail (dm/str background-blur-value)
|
||||
:detail background-blur-value-detail
|
||||
:property background-blur-property-value
|
||||
:copiable true}])])])])
|
||||
|
||||
@ -195,7 +195,7 @@
|
||||
selected (deref selected*)
|
||||
layout (mf/deref refs/workspace-layout)
|
||||
|
||||
view-mode* (mf/use-state :grid)
|
||||
view-mode* (h/use-persisted-state ::view-mode :grid)
|
||||
view-mode (deref view-mode*)
|
||||
|
||||
file-id (mf/use-ctx ctx/current-file-id)
|
||||
|
||||
@ -4,19 +4,21 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
|
||||
.assets-list {
|
||||
padding: 0 0 0 deprecated.$s-4;
|
||||
padding: 0 0 0 var(--sp-xs);
|
||||
}
|
||||
|
||||
.drop-space {
|
||||
height: deprecated.$s-12;
|
||||
block-size: $sz-12;
|
||||
}
|
||||
|
||||
.grid-placeholder {
|
||||
height: deprecated.$s-2;
|
||||
width: 100%;
|
||||
block-size: px2rem(2);
|
||||
inline-size: 100%;
|
||||
background-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
@ -24,18 +26,19 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: deprecated.$s-4;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--assets-item-background-color);
|
||||
margin-block-end: var(--sp-xs);
|
||||
border-radius: $br-8;
|
||||
background-color: var(--color-background-tertiary);
|
||||
inline-size: var(--options-width);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: deprecated.$s-2 solid var(--assets-item-border-color-drag);
|
||||
border-radius: deprecated.$s-8;
|
||||
background-color: var(--assets-item-background-color-drag);
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
border: $b-2 solid var(--color-accent-primary-muted);
|
||||
border-radius: $br-8;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
[app.common.types.shape-tree :as ctst]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.refs :as refs]
|
||||
[app.main.ui.workspace.viewport.rulers :as rulers]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc square-grid* [{:keys [frame zoom grid]}]
|
||||
@ -165,12 +166,15 @@
|
||||
|
||||
(mf/defc frame-grid*
|
||||
{::mf/wrap [mf/memo]}
|
||||
[{:keys [zoom transform selected focus]}]
|
||||
[{:keys [zoom transform selected focus vbox clip-rulers] :or {clip-rulers false}}]
|
||||
(let [frames (->> (mf/deref refs/workspace-frames)
|
||||
(filter has-grid?))
|
||||
transforming (when (some? transform) selected)]
|
||||
|
||||
[:g.grid-display {:style {:pointer-events "none"}}
|
||||
[:g.grid-display {:style {:pointer-events "none"}
|
||||
:clip-path (when clip-rulers "url(#clip-frame-grid)")}
|
||||
(when clip-rulers
|
||||
[:> rulers/rulers-clip-path* {:id "clip-frame-grid" :vbox vbox :zoom zoom}])
|
||||
(for [frame frames]
|
||||
(when (and #_(not (is-transform? frame))
|
||||
(not (ctst/rotated-frame? frame))
|
||||
|
||||
@ -35,6 +35,19 @@
|
||||
;; RULERS
|
||||
;; ----------------
|
||||
|
||||
(mf/defc rulers-clip-path*
|
||||
"Defines a clip path (referenced by `id`) that excludes the ruler bars from the
|
||||
given `vbox`. Used to keep SVG overlays from painting over the rulers that are
|
||||
drawn by the wasm render engine on the canvas."
|
||||
[{:keys [id vbox zoom]}]
|
||||
(let [ruler-size (/ ruler-area-size zoom)]
|
||||
[:defs
|
||||
[:clipPath {:id id}
|
||||
[:rect {:x (+ (:x vbox) ruler-size)
|
||||
:y (+ (:y vbox) ruler-size)
|
||||
:width (max 0 (- (:width vbox) ruler-size))
|
||||
:height (max 0 (- (:height vbox) ruler-size))}]]]))
|
||||
|
||||
(defn- calculate-step-size
|
||||
[zoom]
|
||||
(cond
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
[app.main.ui.context :as ctx]
|
||||
[app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]]
|
||||
[app.main.ui.hooks :as hooks]
|
||||
[app.main.ui.workspace.viewport.rulers :as rulers]
|
||||
[app.main.ui.workspace.viewport.utils :as vwu]
|
||||
[app.util.debug :as dbg]
|
||||
[app.util.dom :as dom]
|
||||
@ -32,7 +33,7 @@
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(mf/defc pixel-grid*
|
||||
[{:keys [vbox zoom]}]
|
||||
[{:keys [vbox zoom clip-rulers] :or {clip-rulers false}}]
|
||||
(let [page (mf/deref refs/workspace-page)
|
||||
custom-color (:pixel-grid-color page)
|
||||
custom-alpha (:pixel-grid-opacity page)
|
||||
@ -57,11 +58,14 @@
|
||||
:stroke stroke
|
||||
:stroke-opacity opacity
|
||||
:stroke-width (str (/ 1 zoom))}}]]]
|
||||
(when clip-rulers
|
||||
[:> rulers/rulers-clip-path* {:id "clip-pixel-grid" :vbox vbox :zoom zoom}])
|
||||
[:rect {:x (:x vbox)
|
||||
:y (:y vbox)
|
||||
:width (:width vbox)
|
||||
:height (:height vbox)
|
||||
:fill (str "url(#pixel-grid)")
|
||||
:clip-path (when clip-rulers "url(#clip-pixel-grid)")
|
||||
:style {:pointer-events "none"}}]]))
|
||||
|
||||
(mf/defc cursor-tooltip*
|
||||
|
||||
@ -853,11 +853,14 @@
|
||||
{:zoom zoom
|
||||
:selected selected
|
||||
:transform transform
|
||||
:focus focus}])
|
||||
:focus focus
|
||||
:vbox vbox
|
||||
:clip-rulers show-rulers?}])
|
||||
|
||||
(when show-pixel-grid?
|
||||
[:> widgets/pixel-grid* {:vbox vbox
|
||||
:zoom zoom}])
|
||||
:zoom zoom
|
||||
:clip-rulers show-rulers?}])
|
||||
|
||||
(when show-snap-points?
|
||||
[:> snap-points/snap-points*
|
||||
|
||||
@ -356,12 +356,15 @@
|
||||
(def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak".
|
||||
(def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end.
|
||||
(def ^:const FRAME_TYPE_FULL 2) ;; A frame was full.
|
||||
(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause).
|
||||
|
||||
(defn- internal-render
|
||||
([]
|
||||
(internal-render 0))
|
||||
([timestamp]
|
||||
(set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp wasm/internal-frame-type))
|
||||
(internal-render timestamp wasm/internal-frame-type))
|
||||
([timestamp flags]
|
||||
(set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags))
|
||||
(when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
(request-render "frame-type-partial"))))
|
||||
|
||||
@ -1310,20 +1313,27 @@
|
||||
(perf/end-measure "render-finish")
|
||||
(reset! view-interaction-active? false)))
|
||||
|
||||
(defn- view-gesture-active?
|
||||
"True while a pointer-driven pan or zoom gesture is in progress."
|
||||
[]
|
||||
(let [local (get @st/state :workspace-local)]
|
||||
(or (:panning local) (:zooming local))))
|
||||
|
||||
(defn finalize-view-interaction!
|
||||
"Ends the view interaction and triggers a full-quality render."
|
||||
[]
|
||||
(view-interaction-end!)
|
||||
(internal-render 0 0))
|
||||
|
||||
(def render-finish
|
||||
(letfn [(do-render []
|
||||
;; Check if context is still initialized before executing
|
||||
;; to prevent errors when navigating quickly
|
||||
(when (initialized?)
|
||||
(view-interaction-end!)
|
||||
;; Use async _render: visible tiles render synchronously
|
||||
;; (no yield), interest-area tiles render progressively
|
||||
;; via rAF. _set_view_end already rebuilt the tile
|
||||
;; index. For pan, most tiles are cached so the render
|
||||
;; completes in the first frame. For zoom, interest-
|
||||
;; area tiles (~3 tile margin) don't block the main
|
||||
;; thread.
|
||||
(internal-render)))]
|
||||
(if (view-gesture-active?)
|
||||
;; Pan/zoom pause: render without ending the interaction.
|
||||
(internal-render 0 RENDER-FLAG-SYNC-TILES)
|
||||
(finalize-view-interaction!))))]
|
||||
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
|
||||
|
||||
(defn set-view-box
|
||||
|
||||
@ -174,7 +174,7 @@
|
||||
(map #(format-shadow->css % options))
|
||||
(str/join ", ")))
|
||||
|
||||
(defn- format-blur
|
||||
(defn format-blur
|
||||
[value]
|
||||
(dm/fmt "blur(%)" (fmt/format-pixels value)))
|
||||
|
||||
|
||||
@ -123,6 +123,9 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result<FrameType> {
|
||||
}
|
||||
}
|
||||
let is_partial = flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8;
|
||||
if flags & RenderFlag::SyncTiles as u8 != 0 {
|
||||
render_state.preserve_target_during_render = true;
|
||||
}
|
||||
let frame_type = if is_partial && !render_state.preserve_target_during_render {
|
||||
state
|
||||
.continue_render_loop(timestamp)
|
||||
@ -355,6 +358,10 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
// instead of re-drawing every visible tile from scratch.
|
||||
render_state.rebuild_tile_index(&state.shapes);
|
||||
}
|
||||
// Avoid `reset_canvas` on the post-gesture render (pan at stable zoom).
|
||||
if !render_state.options.is_profile_rebuild_tiles() {
|
||||
render_state.preserve_target_during_render = true;
|
||||
}
|
||||
performance::end_measure!("set_view_end");
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@ -54,7 +54,8 @@ pub enum FrameType {
|
||||
pub enum RenderFlag {
|
||||
None = 0,
|
||||
Partial = 1,
|
||||
Full = 2,
|
||||
/// Rebuilds the tile index without leaving fast mode.
|
||||
SyncTiles = 4,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@ -2092,6 +2093,10 @@ impl RenderState {
|
||||
let preserve_target = self.preserve_target_during_render;
|
||||
self.preserve_target_during_render = false;
|
||||
|
||||
if preserve_target && self.options.is_fast_mode() {
|
||||
self.rebuild_tile_index(tree);
|
||||
}
|
||||
|
||||
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
|
||||
@ -3899,7 +3904,11 @@ impl RenderState {
|
||||
let mut all_tiles = HashSet::<tiles::Tile>::new();
|
||||
|
||||
let ids = std::mem::take(&mut self.touched_ids);
|
||||
self.preserve_target_during_render = !ids.is_empty();
|
||||
// Pan release sets `preserve_target` in `set_view_end`; don't reset it
|
||||
// here when no shapes changed, or the next render clears the canvas.
|
||||
if !ids.is_empty() {
|
||||
self.preserve_target_during_render = true;
|
||||
}
|
||||
|
||||
for shape_id in ids.iter() {
|
||||
if let Some(shape) = tree.get(shape_id) {
|
||||
|
||||
@ -13,7 +13,7 @@ use super::fonts::FontStore;
|
||||
use crate::state::RulerState;
|
||||
use crate::view::Viewbox;
|
||||
|
||||
const RULER_AREA_SIZE: f32 = 22.0;
|
||||
pub const RULER_AREA_SIZE: f32 = 22.0;
|
||||
const RULER_TICK_OFFSET: f32 = 15.0;
|
||||
const RULER_TICK_LEN: f32 = 4.0;
|
||||
const RULER_TICK_GAP: f32 = 2.0;
|
||||
|
||||
@ -65,13 +65,19 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
||||
let viewbox = render_state.viewbox;
|
||||
let ruler_state = render_state.rulers;
|
||||
rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state);
|
||||
// TODO: pass guides data here
|
||||
|
||||
// Width of the ruler bars in document coordinates.
|
||||
// Note that when rulers are hidden, guides are not shown either, so we
|
||||
// can use a fixed value here.
|
||||
let ruler_width = rulers::RULER_AREA_SIZE / viewbox.zoom;
|
||||
|
||||
let (horizontal, vertical) = get_ui_state().guides();
|
||||
guides::render(
|
||||
canvas,
|
||||
zoom,
|
||||
render_state.options.dpr,
|
||||
viewbox.area,
|
||||
ruler_width,
|
||||
horizontal,
|
||||
vertical,
|
||||
);
|
||||
|
||||
@ -3,22 +3,43 @@ use skia_safe::{self as skia};
|
||||
use crate::math::Rect;
|
||||
use crate::ui::{Guide, GuideKind};
|
||||
|
||||
/// Renders the ruler guides overlay using the guides provided by the host
|
||||
/// (ClojureScript) and stored in the render state.
|
||||
/// Renders the ruler guides, clipped out of the ruler bars.
|
||||
pub fn render(
|
||||
canvas: &skia::Canvas,
|
||||
zoom: f32,
|
||||
dpr: f32,
|
||||
area: Rect,
|
||||
ruler_width: f32,
|
||||
horizontal: &[Guide],
|
||||
vertical: &[Guide],
|
||||
) {
|
||||
// Horizontal guides: clip out the top strip (horizontal ruler)
|
||||
canvas.save();
|
||||
canvas.clip_rect(
|
||||
Rect::from_ltrb(area.left, area.top + ruler_width, area.right, area.bottom),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
for guide in horizontal {
|
||||
render_guide(canvas, zoom, dpr, area, *guide);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
// Vertical guides: clip out the left strip (vertical ruler)
|
||||
canvas.save();
|
||||
canvas.clip_rect(
|
||||
Rect::from_ltrb(area.left + ruler_width, area.top, area.right, area.bottom),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
for guide in vertical {
|
||||
render_guide(canvas, zoom, dpr, area, *guide);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
pub fn render_guide(canvas: &skia::Canvas, zoom: f32, dpr: f32, area: Rect, guide: Guide) {
|
||||
|
||||
@ -312,102 +312,13 @@ impl TileHashMap {
|
||||
}
|
||||
|
||||
const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12;
|
||||
const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = VIEWPORT_DEFAULT_CAPACITY;
|
||||
|
||||
/// Cached spiral of tile offsets for a given grid size.
|
||||
///
|
||||
/// Offsets are centered at (0,0) and must be translated by the desired origin/center tile.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TileSpiral {
|
||||
offsets: Vec<Tile>,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl TileSpiral {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
offsets: Vec::with_capacity(VIEWPORT_SPIRAL_DEFAULT_CAPACITY),
|
||||
columns: 0,
|
||||
rows: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn iter(&self) -> std::slice::Iter<'_, Tile> {
|
||||
self.offsets.iter()
|
||||
}
|
||||
|
||||
/// Ensure the spiral offsets match the given grid size.
|
||||
///
|
||||
/// This regenerates offsets whenever the size changes (grow or shrink) so callers
|
||||
/// don't accidentally reuse a spiral built for a previous viewport.
|
||||
pub fn ensure(&mut self, columns: usize, rows: usize) {
|
||||
if self.columns == columns && self.rows == rows {
|
||||
return;
|
||||
}
|
||||
self.columns = columns;
|
||||
self.rows = rows;
|
||||
|
||||
let total = columns.saturating_mul(rows);
|
||||
self.offsets.clear();
|
||||
self.offsets.reserve(total);
|
||||
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate tiles in spiral order from center (same algorithm as before).
|
||||
let mut cx = 0;
|
||||
let mut cy = 0;
|
||||
|
||||
let ratio = (columns as f32 / rows as f32).ceil() as i32;
|
||||
|
||||
let mut direction_current = 0;
|
||||
let mut direction_total_x = ratio;
|
||||
let mut direction_total_y = 1;
|
||||
let mut direction = 0;
|
||||
|
||||
self.offsets.push(Tile(cx, cy));
|
||||
while self.offsets.len() < total {
|
||||
match direction {
|
||||
0 => cx += 1,
|
||||
1 => cy += 1,
|
||||
2 => cx -= 1,
|
||||
3 => cy -= 1,
|
||||
_ => unreachable!("Invalid direction"),
|
||||
}
|
||||
|
||||
self.offsets.push(Tile(cx, cy));
|
||||
|
||||
direction_current += 1;
|
||||
let direction_total = if direction % 2 == 0 {
|
||||
direction_total_x
|
||||
} else {
|
||||
direction_total_y
|
||||
};
|
||||
|
||||
if direction_current == direction_total {
|
||||
if direction % 2 == 0 {
|
||||
direction_total_x += 1;
|
||||
} else {
|
||||
direction_total_y += 1;
|
||||
}
|
||||
direction = (direction + 1) % 4;
|
||||
direction_current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.offsets.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
// This structure keeps the list of tiles that are in the pending list, the
|
||||
// ones that are going to be rendered.
|
||||
pub struct PendingTiles {
|
||||
pub list: Vec<Tile>,
|
||||
pub spiral: TileSpiral,
|
||||
pub spiral_rect: TileRect,
|
||||
pub tile_order: Vec<(i32, Tile)>,
|
||||
pub tile_rect: TileRect,
|
||||
pub visible_cached: Vec<Tile>,
|
||||
pub visible_uncached: Vec<Tile>,
|
||||
pub interest_cached: Vec<Tile>,
|
||||
@ -418,8 +329,8 @@ impl PendingTiles {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
spiral: TileSpiral::new(),
|
||||
spiral_rect: TileRect::empty(),
|
||||
tile_order: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
tile_rect: TileRect::empty(),
|
||||
visible_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
@ -435,22 +346,13 @@ impl PendingTiles {
|
||||
// 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 {
|
||||
let tile_rect = if only_visible {
|
||||
&tile_viewbox.visible_rect
|
||||
} else {
|
||||
&tile_viewbox.interest_rect
|
||||
};
|
||||
|
||||
self.spiral_rect = *spiral_rect;
|
||||
|
||||
// We do not regenerate spiral if the spiral_rect
|
||||
// doesn't change. The spiral_rect is based on the
|
||||
// viewbox so, if the viewbox doesn't change
|
||||
// the spiral should not change.
|
||||
let columns = spiral_rect.columns();
|
||||
let rows = spiral_rect.rows();
|
||||
|
||||
self.spiral.ensure(columns as usize, rows as usize);
|
||||
self.tile_rect = *tile_rect;
|
||||
|
||||
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
|
||||
// 1. visible + cached (fastest - just blit from cache)
|
||||
@ -462,15 +364,25 @@ impl PendingTiles {
|
||||
self.interest_cached.clear();
|
||||
self.interest_uncached.clear();
|
||||
|
||||
// Compute the scheduling center explicitly (inclusive range).
|
||||
// This avoids relying on `TileRect::center_x/center_y` semantics, which may be used
|
||||
// elsewhere with different expectations.
|
||||
let center_tile = Tile(
|
||||
(spiral_rect.x1() + spiral_rect.x2()) / 2,
|
||||
(spiral_rect.y1() + spiral_rect.y2()) / 2,
|
||||
);
|
||||
for spiral_tile in self.spiral.iter() {
|
||||
let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);
|
||||
// Enumerate every tile in `tile_rect`, ordered by distance from the
|
||||
// rect center.
|
||||
let center_x = (tile_rect.x1() + tile_rect.x2()) / 2;
|
||||
let center_y = (tile_rect.y1() + tile_rect.y2()) / 2;
|
||||
|
||||
self.tile_order.clear();
|
||||
|
||||
for tile in tile_rect.iter(true) {
|
||||
let dx = tile.x() - center_x;
|
||||
let dy = tile.y() - center_y;
|
||||
self.tile_order.push((dx * dx + dy * dy, tile));
|
||||
}
|
||||
|
||||
// Farthest first, since we use pop() to process the tiles
|
||||
// in order of priority (closest first)
|
||||
self.tile_order.sort_unstable_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
for (_, tile) in self.tile_order.iter() {
|
||||
let tile = *tile;
|
||||
let is_visible = tile_viewbox.visible_rect.contains(&tile);
|
||||
let is_cached = surfaces.has_cached_tile_surface(tile);
|
||||
|
||||
@ -482,8 +394,6 @@ impl PendingTiles {
|
||||
}
|
||||
}
|
||||
|
||||
// Build final list with lowest priority first (they get popped last)
|
||||
// Order: interest_uncached, interest_cached, visible_uncached, visible_cached
|
||||
self.list.extend(self.interest_uncached.iter());
|
||||
self.list.extend(self.interest_cached.iter());
|
||||
self.list.extend(self.visible_uncached.iter());
|
||||
|
||||
361
tools/paren-repair.bb
Executable file
361
tools/paren-repair.bb
Executable file
@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env bb
|
||||
|
||||
;; ── Dependencies (resolved once, cached forever by Babashka) ──
|
||||
(babashka.deps/add-deps
|
||||
'{:deps {dev.weavejester/cljfmt {:mvn/version "0.15.5"}
|
||||
parinferish/parinferish {:mvn/version "0.8.0"}}})
|
||||
|
||||
(ns paren-repair
|
||||
"Standalone CLI tool for fixing delimiter errors and formatting Clojure files.
|
||||
|
||||
Single-file consolidation of:
|
||||
clojure-mcp-light.delimiter-repair (detection + repair engine)
|
||||
clojure-mcp-light.hook (file detection + combine repair+format)
|
||||
clojure-mcp-light.paren-repair (CLI / main entry point)
|
||||
|
||||
Stripped: stats logging, timbre, tools.cli, apply-patch, tmp sessions.
|
||||
Includes a fix for the process-stdin destructuring bug in the original."
|
||||
|
||||
(:require [babashka.fs :as fs]
|
||||
[cheshire.core :as json]
|
||||
[cljfmt.core :as cljfmt]
|
||||
[cljfmt.main]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.java.shell :as shell]
|
||||
[clojure.string :as string]
|
||||
[edamame.core :as e]
|
||||
[parinferish.core :as parinferish]))
|
||||
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
;; Section 1: Delimiter Detection & Repair
|
||||
;; (from delimiter_repair.clj — stats calls removed)
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
(def ^:dynamic *signal-on-bad-parse*
|
||||
"When true, non-delimiter parse errors still trigger parinfer as a safety net.
|
||||
Running parinfer on valid code is generally benign."
|
||||
true)
|
||||
|
||||
(defn delimiter-error?
|
||||
"Returns true if the string has a delimiter error specifically.
|
||||
Checks that it's an :edamame/error with :edamame/opened-delimiter info.
|
||||
Uses :all true to enable all standard Clojure reader features:
|
||||
function literals, regex, quotes, syntax-quote, deref, var, etc.
|
||||
Also enables :read-cond :allow to support reader conditionals.
|
||||
Handles unknown data readers gracefully with a default reader fn."
|
||||
[s]
|
||||
(try
|
||||
(e/parse-string-all s {:all true
|
||||
:features #{:bb :clj :cljs :cljr :default}
|
||||
:read-cond :allow
|
||||
:readers (fn [_tag] (fn [data] data))
|
||||
:auto-resolve name})
|
||||
false ;; No error = no delimiter error
|
||||
(catch clojure.lang.ExceptionInfo ex
|
||||
(let [data (ex-data ex)]
|
||||
(and (= :edamame/error (:type data))
|
||||
(contains? data :edamame/opened-delimiter))))
|
||||
(catch Exception _
|
||||
;; Non-edamame parse error — run parinfer as a safety net
|
||||
;; (parinfer on valid code is generally benign)
|
||||
*signal-on-bad-parse*)))
|
||||
|
||||
(defn actual-delimiter-error?
|
||||
"Like delimiter-error? but never falls back to parinfer on unknown parse errors."
|
||||
[s]
|
||||
(binding [*signal-on-bad-parse* false]
|
||||
(delimiter-error? s)))
|
||||
|
||||
(defn parinferish-repair
|
||||
"Attempts to repair delimiter errors using parinferish (pure Clojure).
|
||||
Returns a map with :success, :text, and :error."
|
||||
[s]
|
||||
(try
|
||||
(let [repaired (parinferish/flatten
|
||||
(parinferish/parse s {:mode :indent}))]
|
||||
{:success true
|
||||
:text repaired
|
||||
:error nil})
|
||||
(catch Exception e
|
||||
{:success false
|
||||
:error (.getMessage e)})))
|
||||
|
||||
(def parinfer-rust-available?
|
||||
"Check if parinfer-rust binary is available on PATH. Result is memoized."
|
||||
(memoize
|
||||
(fn []
|
||||
(try
|
||||
(let [result (shell/sh "which" "parinfer-rust")]
|
||||
(zero? (:exit result)))
|
||||
(catch Exception _
|
||||
false)))))
|
||||
|
||||
(defn parinfer-repair
|
||||
"Attempts to repair delimiter errors using parinfer-rust (external binary).
|
||||
Returns a map with :success, :text, and :error.
|
||||
Uses JSON output format from parinfer-rust."
|
||||
[s]
|
||||
(let [result (shell/sh "parinfer-rust"
|
||||
"--mode" "indent"
|
||||
"--language" "clojure"
|
||||
"--output-format" "json"
|
||||
:in s)
|
||||
exit-code (:exit result)]
|
||||
(if (zero? exit-code)
|
||||
(try
|
||||
(json/parse-string (:out result) true)
|
||||
(catch Exception _
|
||||
{:success false}))
|
||||
{:success false})))
|
||||
|
||||
(defn repair-delimiters
|
||||
"Unified delimiter repair: prefers parinfer-rust when available,
|
||||
falls back to parinferish (pure Clojure).
|
||||
Returns a map with :success, :text, and :error."
|
||||
[s]
|
||||
(if (parinfer-rust-available?)
|
||||
(parinfer-repair s)
|
||||
(parinferish-repair s)))
|
||||
|
||||
(defn fix-delimiters
|
||||
"Takes a Clojure string and attempts to fix delimiter errors.
|
||||
Returns the repaired string if successful, or nil if unfixable.
|
||||
If no delimiter errors exist, returns the original string unchanged."
|
||||
[s]
|
||||
(if (delimiter-error? s)
|
||||
(let [{:keys [text success]} (repair-delimiters s)]
|
||||
(when (and success text (not (delimiter-error? text)))
|
||||
text))
|
||||
s))
|
||||
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
;; Section 2: File Processing
|
||||
;; (from hook.clj — stripped of stats, timbre, backup/restore, hook dispatch)
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
(def ^:dynamic *enable-cljfmt*
|
||||
"When true, files are reformatted with cljfmt after delimiter repair."
|
||||
false)
|
||||
|
||||
(defn- babashka-shebang?
|
||||
"Checks if a file starts with a Babashka shebang line."
|
||||
[file-path]
|
||||
(when (fs/exists? file-path)
|
||||
(try
|
||||
(with-open [r (io/reader file-path)]
|
||||
(let [line (-> r line-seq first)]
|
||||
(and line
|
||||
(re-matches #"^#!/[^\s]+/(bb|env\s{1,3}bb)(\s.*)?$" line))))
|
||||
(catch Exception _ false))))
|
||||
|
||||
(defn clojure-file?
|
||||
"Checks if a file path has a Clojure-related extension or Babashka shebang.
|
||||
Supported extensions: .clj .cljs .cljc .cljd .bb .edn .lpy
|
||||
Also detects files with a Babashka shebang (#!/.../bb)."
|
||||
[file-path]
|
||||
(when file-path
|
||||
(let [lower-path (string/lower-case file-path)]
|
||||
(or (string/ends-with? lower-path ".clj")
|
||||
(string/ends-with? lower-path ".cljs")
|
||||
(string/ends-with? lower-path ".cljc")
|
||||
(string/ends-with? lower-path ".cljd")
|
||||
(string/ends-with? lower-path ".bb")
|
||||
(string/ends-with? lower-path ".lpy")
|
||||
(string/ends-with? lower-path ".edn")
|
||||
(babashka-shebang? file-path)))))
|
||||
|
||||
(defn run-cljfmt
|
||||
"Check if file needs formatting (via cljfmt.core), then reformat in-place
|
||||
(via cljfmt.main to respect user cljfmt config). Returns true if file was
|
||||
reformatted, false otherwise."
|
||||
[file-path]
|
||||
(when *enable-cljfmt*
|
||||
(try
|
||||
(let [original (slurp file-path :encoding "UTF-8")
|
||||
formatted (cljfmt/reformat-string original)]
|
||||
(if (not= original formatted)
|
||||
(do
|
||||
(cljfmt.main/-main "fix" file-path)
|
||||
true)
|
||||
false))
|
||||
(catch Exception _
|
||||
false))))
|
||||
|
||||
(defn fix-and-format-file!
|
||||
"Core logic for fixing delimiters and (optionally) formatting a Clojure file
|
||||
in-place. Returns a map with :success, :delimiter-fixed, :formatted, and
|
||||
:message."
|
||||
[file-path enable-cljfmt]
|
||||
(try
|
||||
(let [file-content (slurp file-path :encoding "UTF-8")
|
||||
has-delimiter-error? (delimiter-error? file-content)]
|
||||
(if has-delimiter-error?
|
||||
;; Has delimiter error — try to fix
|
||||
(if-let [fixed-content (fix-delimiters file-content)]
|
||||
(do
|
||||
(spit file-path fixed-content :encoding "UTF-8")
|
||||
(let [formatted? (binding [*enable-cljfmt* enable-cljfmt]
|
||||
(run-cljfmt file-path))]
|
||||
{:success true
|
||||
:delimiter-fixed true
|
||||
:formatted (boolean formatted?)
|
||||
:message "Delimiter errors fixed and formatted"}))
|
||||
{:success false
|
||||
:delimiter-fixed false
|
||||
:formatted false
|
||||
:message "Could not fix delimiter errors"})
|
||||
;; No delimiter error — just format if enabled
|
||||
(let [formatted? (binding [*enable-cljfmt* enable-cljfmt]
|
||||
(run-cljfmt file-path))]
|
||||
{:success true
|
||||
:delimiter-fixed false
|
||||
:formatted (boolean formatted?)
|
||||
:message (if formatted? "Formatted" "No changes needed")})))
|
||||
(catch Exception e
|
||||
{:success false
|
||||
:delimiter-fixed false
|
||||
:formatted false
|
||||
:message (str "Error: " (.getMessage e))})))
|
||||
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
;; Section 3: CLI
|
||||
;; (from paren_repair.clj — with process-stdin bug fix, no timbre)
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
(defn has-stdin-data?
|
||||
"Check if stdin has data available (not a TTY).
|
||||
Returns true if stdin is ready to be read (e.g., piped input or heredoc)."
|
||||
[]
|
||||
(try
|
||||
(.ready *in*)
|
||||
(catch Exception _ false)))
|
||||
|
||||
(defn process-stdin
|
||||
"Process code from stdin: fix delimiters and format.
|
||||
Outputs result to stdout.
|
||||
Returns a map with :success and :changed."
|
||||
[]
|
||||
(let [input (slurp *in*)
|
||||
fixed (fix-delimiters input)]
|
||||
(if fixed
|
||||
;; fix-delimiters succeeded (or no errors) — format and print
|
||||
(let [formatted (try
|
||||
(cljfmt/reformat-string fixed)
|
||||
(catch Exception _
|
||||
fixed))
|
||||
changed? (not= input formatted)]
|
||||
(print formatted)
|
||||
(flush)
|
||||
{:success true
|
||||
:changed changed?})
|
||||
;; fix-delimiters returned nil (unfixable)
|
||||
(do
|
||||
(binding [*out* *err*]
|
||||
(println "Error: Could not fix delimiter errors"))
|
||||
{:success false
|
||||
:changed false}))))
|
||||
|
||||
(defn process-file
|
||||
"Process a single file: fix delimiters and format in-place.
|
||||
Returns a map with :success, :file-path, :message, :delimiter-fixed,
|
||||
and :formatted."
|
||||
[file-path]
|
||||
(cond
|
||||
(not (fs/exists? file-path))
|
||||
{:success false
|
||||
:file-path file-path
|
||||
:message "File does not exist"
|
||||
:delimiter-fixed false
|
||||
:formatted false}
|
||||
|
||||
(not (clojure-file? file-path))
|
||||
{:success false
|
||||
:file-path file-path
|
||||
:message "Not a Clojure file (skipping)"
|
||||
:delimiter-fixed false
|
||||
:formatted false}
|
||||
|
||||
:else
|
||||
(assoc (fix-and-format-file! file-path true)
|
||||
:file-path file-path)))
|
||||
|
||||
(defn show-help []
|
||||
(println "Usage: paren-repair [FILE ...]")
|
||||
(println " echo CODE | paren-repair")
|
||||
(println " paren-repair <<'EOF' ... EOF")
|
||||
(println)
|
||||
(println "Fix delimiter errors and format Clojure code.")
|
||||
(println)
|
||||
(println "When no files are provided, reads from stdin and writes to stdout.")
|
||||
(println "If no changes are needed, echoes the input unchanged.")
|
||||
(println)
|
||||
(println "Options:")
|
||||
(println " -h, --help Show this help message"))
|
||||
|
||||
(defn -main [& args]
|
||||
(let [show-help? (some #{"--help" "-h"} args)
|
||||
file-args (remove #{"--help" "-h"} args)]
|
||||
|
||||
(cond
|
||||
;; Help requested
|
||||
show-help?
|
||||
(do
|
||||
(show-help)
|
||||
(System/exit 0))
|
||||
|
||||
;; No file args — check for stdin
|
||||
(empty? file-args)
|
||||
(if (has-stdin-data?)
|
||||
;; Stdin mode: read, process, output to stdout
|
||||
(let [result (process-stdin)]
|
||||
(System/exit (if (:success result) 0 1)))
|
||||
;; No stdin and no files — show help
|
||||
(do
|
||||
(show-help)
|
||||
(System/exit 1)))
|
||||
|
||||
;; File mode
|
||||
:else
|
||||
(try
|
||||
(let [results (doall (map process-file file-args))
|
||||
successes (filter :success results)
|
||||
failures (filter (complement :success) results)
|
||||
success-count (count successes)
|
||||
failure-count (count failures)]
|
||||
|
||||
;; Print results
|
||||
(println)
|
||||
(println "paren-repair Results")
|
||||
(println "========================")
|
||||
(println)
|
||||
|
||||
(doseq [{:keys [file-path message delimiter-fixed formatted]} results]
|
||||
(let [tags (when (or delimiter-fixed formatted)
|
||||
(str " ["
|
||||
(string/join ", "
|
||||
(filter some?
|
||||
[(when delimiter-fixed "delimiter-fixed")
|
||||
(when formatted "formatted")]))
|
||||
"]"))]
|
||||
(println (str " " file-path ": " message tags))))
|
||||
|
||||
(println)
|
||||
(println "Summary:")
|
||||
(println " Success:" success-count)
|
||||
(println " Failed: " failure-count)
|
||||
(println)
|
||||
|
||||
(if (zero? failure-count)
|
||||
(System/exit 0)
|
||||
(System/exit 1)))
|
||||
(catch Exception e
|
||||
(binding [*out* *err*]
|
||||
(println "Fatal error:" (.getMessage e)))
|
||||
(System/exit 1))))))
|
||||
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
;; Entry point — only run -main when executed directly (not loaded as lib)
|
||||
;; ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
(when (= *file* (System/getProperty "babashka.file"))
|
||||
(apply -main *command-line-args*))
|
||||
Loading…
x
Reference in New Issue
Block a user