Merge remote-tracking branch 'origin/staging-render' into develop

This commit is contained in:
Andrey Antukh
2026-03-16 09:35:12 +01:00
19 changed files with 1253 additions and 950 deletions

View File

@@ -146,11 +146,18 @@ jobs:
name: "Frontend Tests"
runs-on: penpot-runner-02
container: penpotapp/devenv:latest
needs: test-render-wasm
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Restore shared.js
uses: actions/cache/restore@v4
with:
key: "render-wasm-shared-js-${{ github.sha }}"
path: frontend/src/app/render_wasm/api/shared.js
- name: Unit Tests
working-directory: ./frontend
run: |
@@ -187,6 +194,19 @@ jobs:
run: |
./test
- name: Copy shared.js artifact
working-directory: ./render-wasm
run: |
SHARED_FILE=$(find target -name render_wasm_shared.js | head -n 1);
mkdir -p ../frontend/src/app/render_wasm/api;
cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js;
- name: Cache shared.js
uses: actions/cache@v4
with:
key: "render-wasm-shared-js-${{ github.sha }}"
path: frontend/src/app/render_wasm/api/shared.js
test-backend:
name: "Backend Tests"
runs-on: penpot-runner-02

321
AGENTS.md
View File

@@ -1,8 +1,44 @@
# Penpot Instructions
# IA Agent Guide for Penpot
## Architecture Overview
This document provides comprehensive context and guidelines for AI agents working on this repository.
Penpot is a full-stack design tool composed of several distinct components:
## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS
Before responding to ANY user request, you MUST:
1. **READ** the CONTRIBUTING.md file
2. **READ** this file and has special focus on your ROLE.
## ROLE: SENIOR SOFTWARE ENGINEER
You are a high-autonomy Senior Software Engineer. You have full
permission to navigate the codebase, modify files, and execute
commands to fulfill your tasks. Your goal is to solve complex
technical tasks with high precision, focusing on maintainability and
performance.
### OPERATIONAL GUIDELINES
1. Always begin by analyzing this document and understand the architecture and "Golden Rules".
2. Before writing code, describe your plan. If the task is complex, break it down into atomic steps.
3. Be concise and autonomous as possible in your task.
### SEARCH STANDARDS
When searching code, always use `ripgrep` (rg) instead of grep if
available, as it respects `.gitignore` by default.
If using grep, try to exclude node_modules and .shadow-cljs directories
## ARCHITECTURE
### Overview
Penpot is a full-stack design tool composed of several distinct
components separated in modules and subdirectories:
| Component | Language | Role |
|-----------|----------|------|
@@ -18,119 +54,6 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh`
orchestrates cross-component builds. `run-ci.sh` defines the CI
pipeline.
## Search Standards
When searching code, always use `ripgrep` (rg) instead of grep if
available, as it respects `.gitignore` by default.
If using grep, try to exclude node_modules and .shadow-cljs directories
## Build, Test & Lint Commands
### Frontend (`cd frontend`)
Run `./scripts/setup` for setup all dependencies.
```bash
# Build (Producution)
./scripts/build
# Tests
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
# Lint
pnpm run lint:js # Linter for JS/TS
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
pnpm run lint:scss # Linter for SCSS
# Check Code Formart
pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
pnpm run check-fmt:js # Format JS/TS
pnpm run check-fmt:scss # Format SCSS
# Code Format (Automatic Formating)
pnpm run fmt:clj # Format CLJ/CLJS/CLJC
pnpm run fmt:js # Format JS/TS
pnpm run fmt:scss # Format SCSS
```
To run a focused ClojureScript unit test: edit
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
### Backend (`cd backend`)
Run `pnpm install` for install all dependencies.
```bash
# Run full test suite
pnpm run test
# Run single namespace
pnpm run test --focus backend-tests.rpc-doc-test
# Check Code Format
pnpm run check-fmt
# Code Format (Automatic Formatting)
pnpm run fmt
# Code Linter
pnpm run lint
```
Test config is in `backend/tests.edn`; test namespaces match
`.*-test$` under `test/` directory. You should not touch this file,
just use it for reference.
### Common (`cd common`)
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
separarated for each runtime.
```bash
clojure -M:dev:test # Run full test suite under JVM
clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
# Run full test suite under JS or JVM runtimes
pnpm run test:js
pnpm run test:jvm
# Run single namespace (only on JVM)
pnpm run test:jvm --focus common-tests.my-ns-test
# Lint
pnpm run lint:clj # Lint CLJ/CLJS/CLJC code
# Check Format
pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run check-fmt:js # Check JS/TS code
# Code Format (Automatic Formatting)
pnpm run fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run fmt:js # Check JS/TS code
```
To run a focused ClojureScript unit test: edit
`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
### Render-WASM (`cd render-wasm`)
```bash
./test # Rust unit tests (cargo test)
./build # Compile to WASM (requires Emscripten)
cargo fmt --check
./lint --debug
```
## Key Conventions
### Namespace Structure
The backend, frontend and exporter are developed using clojure and
@@ -162,7 +85,9 @@ overview of the available namespaces.
- `app.common.data.macros` Performance macros used everywhere
### Backend RPC Commands
## Key Conventions
### Backend RPC
The PRC methods are implement in a some kind of multimethod structure using
`app.util.serivices` namespace. All RPC methods are collected under `app.rpc`
@@ -240,26 +165,7 @@ available, prefer adding a new helper for handling it and the use the
new helper.
### CSS (Modules Pattern)
Styles are co-located with components. Each `.cljs` file has a corresponding
`.scss` file:
```clojure
;; In the component namespace:
(require '[app.main.style :as stl])
;; In the render function:
[:div {:class (stl/css :container :active)}]
;; Conditional:
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
;; When you need concat an existing class:
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
```
### Integration tests (Playwright)
### Integration Tests (Playwright)
Integration tests are developed under `frontend/playwright` directory, we use
mocks for remove communication with backend.
@@ -286,7 +192,7 @@ Always prefer these macros over their `clojure.core` equivalents — they compil
(dm/str "a" "b" "c") ;; string concatenation
```
### Shared Code under Common (CLJC)
### Shared Code
Files in `common/src/app/common/` use reader conditionals to target both runtimes:
@@ -300,7 +206,7 @@ Both frontend and backend depend on `common` as a local library (`penpot/common
### Component Standards & Syntax (React & Rumext: mf/defc)
### UI Component Standards & Syntax (React & Rumext: mf/defc)
The codebase contains various component patterns. When creating or refactoring
components, follow the Modern Syntax rules outlined below.
@@ -417,7 +323,113 @@ Examples:
- [ ] Does the component name end with *?
## Commit Format Guidelines
### Build, Test & Lint commands
#### Frontend (`cd frontend`)
Run `./scripts/setup` for setup all dependencies.
```bash
# Build (Producution)
./scripts/build
# Tests
pnpm run test # Build ClojureScript tests + run node target/tests/test.js
# Lint
pnpm run lint:js # Linter for JS/TS
pnpm run lint:clj # Linter for CLJ/CLJS/CLJC
pnpm run lint:scss # Linter for SCSS
# Check Code Formart
pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC
pnpm run check-fmt:js # Format JS/TS
pnpm run check-fmt:scss # Format SCSS
# Code Format (Automatic Formating)
pnpm run fmt:clj # Format CLJ/CLJS/CLJC
pnpm run fmt:js # Format JS/TS
pnpm run fmt:scss # Format SCSS
```
To run a focused ClojureScript unit test: edit
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
#### Backend (`cd backend`)
Run `pnpm install` for install all dependencies.
```bash
# Run full test suite
pnpm run test
# Run single namespace
pnpm run test --focus backend-tests.rpc-doc-test
# Check Code Format
pnpm run check-fmt
# Code Format (Automatic Formatting)
pnpm run fmt
# Code Linter
pnpm run lint
```
Test config is in `backend/tests.edn`; test namespaces match
`.*-test$` under `test/` directory. You should not touch this file,
just use it for reference.
#### Common (`cd common`)
This contains code that should compile and run under different runtimes: JVM & JS so the commands are
separarated for each runtime.
```bash
clojure -M:dev:test # Run full test suite under JVM
clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM
# Run full test suite under JS or JVM runtimes
pnpm run test:js
pnpm run test:jvm
# Run single namespace (only on JVM)
pnpm run test:jvm --focus common-tests.my-ns-test
# Lint
pnpm run lint:clj # Lint CLJ/CLJS/CLJC code
# Check Format
pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run check-fmt:js # Check JS/TS code
# Code Format (Automatic Formatting)
pnpm run fmt:clj # Check CLJ/CLJS/CLJS code
pnpm run fmt:js # Check JS/TS code
```
To run a focused ClojureScript unit test: edit
`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm
run build:test && node target/tests/test.js`.
#### Render-WASM (`cd render-wasm`)
```bash
./test # Rust unit tests (cargo test)
./build # Compile to WASM (requires Emscripten)
cargo fmt --check
./lint --debug
```
### Commit Format Guidelines
Format: `<emoji-code> <subject>`
@@ -456,9 +468,28 @@ applicable.
| 🌐 | `:globe_with_meridians:` | Translations |
## SCSS Rules & Migration
### CSS
#### Usage convention for components
### General rules
Styles are co-located with components. Each `.cljs` file has a corresponding
`.scss` file:
```clojure
;; In the component namespace:
(require '[app.main.style :as stl])
;; In the render function:
[:div {:class (stl/css :container :active)}]
;; Conditional:
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
;; When you need concat an existing class:
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
```
#### Styles rules & migration
##### General
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
variables and get the already defined properties from `_sizes.scss`. The SCSS
@@ -478,7 +509,7 @@ applicable.
- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
### Syntax & Structure
##### Syntax & Structure
- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file,
try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as
@@ -489,9 +520,9 @@ applicable.
- Leverage component-level CSS variables for state changes (hover/focus) instead
of rewriting properties.
### Checklist
##### Checklist
- [ ] No references to `common/refactor/`
- [ ] No references to `common/refactor/`
- [ ] All `@import` converted to `@use` (only if refactoring)
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
- [ ] Typography implemented via `use-typography()` mixin.

View File

@@ -206,3 +206,6 @@ Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Please, use your real name (sorry, no pseudonyms or anonymous
contributions are allowed).
The commit Signed-off-by is mandatory and should match the commit author.

View File

@@ -0,0 +1,20 @@
{
"~:file-id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770",
"~:id": "~u3ac58b88-38b3-80c9-8007-b4f791c7c36b",
"~:created-at": "~m1773398778658",
"~:modified-at": "~m1773398778658",
"~:type": "fragment",
"~:backend": "db",
"~:data": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\"]]]",
"~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",76,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^<\",[\"^ \",\"~:x\",76,\"~:y\",0]],[\"^<\",[\"^ \",\"~:x\",76,\"~:y\",59]],[\"^<\",[\"^ \",\"~:x\",0,\"~:y\",59]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a\",\"~:parent-id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^8\",76,\"~:height\",59,\"~:x1\",0,\"~:y1\",0,\"~:x2\",76,\"~:y2\",59]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",59,\"~:flip-y\",null]]",
"~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74": "[\"~#shape\",[\"^ \",\"~:y\",74,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",95,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",57,\"~:y\",74]],[\"^<\",[\"^ \",\"~:x\",152,\"~:y\",74]],[\"^<\",[\"^ \",\"~:x\",152,\"~:y\",123]],[\"^<\",[\"^ \",\"~:x\",57,\"~:y\",123]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74\",\"~:parent-id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",57,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",57,\"~:y\",74,\"^8\",95,\"~:height\",49,\"~:x1\",57,\"~:y1\",74,\"~:x2\",152,\"~:y2\",123]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",49,\"~:flip-y\",null]]",
"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",2,\"~:name\",\"Group\",\"~:width\",152,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",152,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",152,\"~:y\",123]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",123]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",152,\"~:height\",123,\"~:x1\",0,\"~:y1\",0,\"~:x2\",152,\"~:y2\",123]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",123,\"~:flip-y\",null,\"~:shapes\",[\"~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74\"]]]"
}
},
"~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771",
"~:name": "Page 1"
}
}

View File

@@ -0,0 +1,135 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~ud715d0a5-a44e-8056-8005-a79999e18b64",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "New File 13",
"~:revn": 79,
"~:modified-at": "~m1773398778654",
"~:vern": 0,
"~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0015-clean-shadow-color",
"0016-copy-fills-from-position-data-to-text-node",
"0017-fix-layout-flex-dir"
]
},
"~:version": 67,
"~:project-id": "~u76eab896-accf-81a5-8007-2b264ebe7817",
"~:created-at": "~m1773332008622",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771"
],
"~:pages-index": {
"~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771": {
"~#penpot/pointer": [
"~u3ac58b88-38b3-80c9-8007-b4f791c7c36b",
{
"~:created-at": "~m1773398778656"
}
]
}
},
"~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

@@ -72,7 +72,7 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => {
fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9",
pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd",
});
0
await workspacePage.clickToggableLayer("Parent");
await workspacePage.clickToggableLayer("Container");
@@ -82,4 +82,33 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => {
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76");
});
test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockGetFile("workspace/get-file-13272.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=*",
"workspace/get-file-13272-fragment.json",
);
await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json");
await workspacePage.goToWorkspace({
fileId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba770",
pageId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba771",
});
await workspacePage.clickToggableLayer("Group");
await workspacePage.clickLeafLayer("Group");
await workspacePage.page.locator('g:nth-child(11) > .cursor-resize-nesw-0').hover();
await workspacePage.page.mouse.down();
await workspacePage.page.mouse.move(1200, 800);
await workspacePage.page.mouse.up();
await workspacePage.clickLeafLayer("Rectangle");
await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("197.5");
await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("128.28");
});

View File

@@ -628,12 +628,13 @@
(let [prev-wasm-props (:prev-wasm-props state)
wasm-props (:wasm-props state)
objects (dsh/lookup-page-objects state)
pixel-precision false]
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))]
(set-wasm-props! objects prev-wasm-props wasm-props)
(let [structure-entries (parse-structure-modifiers modif-tree)]
(wasm.api/set-structure-modifiers structure-entries)
(let [geometry-entries (parse-geometry-modifiers modif-tree)
modifiers (wasm.api/propagate-modifiers geometry-entries pixel-precision)]
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(wasm.api/set-modifiers modifiers)
(let [ids (into [] xf:map-key geometry-entries)
selrect (wasm.api/get-selection-rect ids)]

View File

@@ -162,7 +162,7 @@
(= key "Backspace")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-backward)
(text-editor/text-editor-delete-backward ctrl?)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-backward"))
@@ -170,7 +170,7 @@
(= key "Delete")
(do
(dom/prevent-default event)
(text-editor/text-editor-delete-forward)
(text-editor/text-editor-delete-forward ctrl?)
(sync-wasm-text-editor-content!)
(wasm.api/request-render "text-delete-forward"))
@@ -178,37 +178,37 @@
(= key "ArrowLeft")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 0 shift?)
(text-editor/text-editor-move-cursor 0 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowRight")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 1 shift?)
(text-editor/text-editor-move-cursor 1 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowUp")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 2 shift?)
(text-editor/text-editor-move-cursor 2 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "ArrowDown")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 3 shift?)
(text-editor/text-editor-move-cursor 3 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "Home")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 4 shift?)
(text-editor/text-editor-move-cursor 4 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
(= key "End")
(do
(dom/prevent-default event)
(text-editor/text-editor-move-cursor 5 shift?)
(text-editor/text-editor-move-cursor 5 ctrl? shift?)
(wasm.api/request-render "text-cursor-move"))
;; Let contenteditable handle text input via on-input

View File

@@ -1,256 +0,0 @@
export const GrowType = {
"fixed": 0,
"auto-width": 1,
"auto-height": 2,
};
export const RawBlendMode = {
"normal": 3,
"screen": 14,
"overlay": 15,
"darken": 16,
"lighten": 17,
"color-dodge": 18,
"color-burn": 19,
"hard-light": 20,
"soft-light": 21,
"difference": 22,
"exclusion": 23,
"multiply": 24,
"hue": 25,
"saturation": 26,
"color": 27,
"luminosity": 28,
};
export const RawBlurType = {
"layer-blur": 0,
};
export const RawFillData = {
"solid": 0,
"linear": 1,
"radial": 2,
"image": 3,
};
export const RawFontStyle = {
"normal": 0,
"italic": 1,
};
export const RawAlignItems = {
"start": 0,
"end": 1,
"center": 2,
"stretch": 3,
};
export const RawAlignContent = {
"start": 0,
"end": 1,
"center": 2,
"space-between": 3,
"space-around": 4,
"space-evenly": 5,
"stretch": 6,
};
export const RawJustifyItems = {
"start": 0,
"end": 1,
"center": 2,
"stretch": 3,
};
export const RawJustifyContent = {
"start": 0,
"end": 1,
"center": 2,
"space-between": 3,
"space-around": 4,
"space-evenly": 5,
"stretch": 6,
};
export const RawJustifySelf = {
"none": 0,
"auto": 1,
"start": 2,
"end": 3,
"center": 4,
"stretch": 5,
};
export const RawAlignSelf = {
"none": 0,
"auto": 1,
"start": 2,
"end": 3,
"center": 4,
"stretch": 5,
};
export const RawVerticalAlign = {
"top": 0,
"center": 1,
"bottom": 2,
};
export const RawConstraintH = {
"left": 0,
"right": 1,
"leftright": 2,
"center": 3,
"scale": 4,
};
export const RawConstraintV = {
"top": 0,
"bottom": 1,
"topbottom": 2,
"center": 3,
"scale": 4,
};
export const RawFlexDirection = {
"row": 0,
"row-reverse": 1,
"column": 2,
"column-reverse": 3,
};
export const RawWrapType = {
"wrap": 0,
"nowrap": 1,
};
export const RawGridDirection = {
"row": 0,
"column": 1,
};
export const RawGridTrackType = {
"percent": 0,
"flex": 1,
"auto": 2,
"fixed": 3,
};
export const RawSizing = {
"fill": 0,
"fix": 1,
"auto": 2,
};
export const RawBoolType = {
"union": 0,
"difference": 1,
"intersection": 2,
"exclusion": 3,
};
export const RawSegmentData = {
"move-to": 1,
"line-to": 2,
"curve-to": 3,
"close": 4,
};
export const RawShadowStyle = {
"drop-shadow": 0,
"inner-shadow": 1,
};
export const RawShapeType = {
"frame": 0,
"group": 1,
"bool": 2,
"rect": 3,
"path": 4,
"text": 5,
"circle": 6,
"svg-raw": 7,
};
export const RawStrokeStyle = {
"solid": 0,
"dotted": 1,
"dashed": 2,
"mixed": 3,
};
export const RawStrokeCap = {
"none": 0,
"line-arrow": 1,
"triangle-arrow": 2,
"square-marker": 3,
"circle-marker": 4,
"diamond-marker": 5,
"round": 6,
"square": 7,
};
export const RawFillRule = {
"nonzero": 0,
"evenodd": 1,
};
export const RawStrokeLineCap = {
"butt": 0,
"round": 1,
"square": 2,
};
export const RawStrokeLineJoin = {
"miter": 0,
"round": 1,
"bevel": 2,
};
export const RawTextAlign = {
"left": 0,
"center": 1,
"right": 2,
"justify": 3,
};
export const RawTextDirection = {
"ltr": 0,
"rtl": 1,
};
export const RawTextDecoration = {
"none": 0,
"underline": 1,
"line-through": 2,
"overline": 3,
};
export const RawTextTransform = {
"none": 0,
"uppercase": 1,
"lowercase": 2,
"capitalize": 3,
};
export const RawGrowType = {
"fixed": 0,
"auto-width": 1,
"auto-height": 2,
};
export const CursorDirection = {
"backward": 0,
"forward": 1,
"line-before": 2,
"line-after": 3,
"line-start": 4,
"line-end": 5,
};
export const RawTransformEntryKind = {
"parent": 0,
"child": 1,
};

View File

@@ -78,22 +78,28 @@
(h/call wasm/internal-module "_text_editor_insert_text")
(mem/free))))
(defn text-editor-delete-backward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward")))
(defn text-editor-delete-backward
([]
(text-editor-delete-backward false))
([word-boundary]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_backward" word-boundary))))
(defn text-editor-delete-forward []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward")))
(defn text-editor-delete-forward
([]
(text-editor-delete-forward false))
([word-boundary]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_delete_forward" word-boundary))))
(defn text-editor-insert-paragraph []
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction extend-selection]
[direction word-boundary extend-selection]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
(h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0))))
(defn text-editor-select-all
[]

4
opencode.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md", "AGENTS.md"]
}

View File

@@ -123,9 +123,9 @@ fn calculate_cursor_rect(
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
(0.0, 0.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
@@ -133,9 +133,10 @@ fn calculate_cursor_rect(
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
let r = &rects[0].rect;
(r.left(), r.top(), r.height())
} else {
(0.0, laid_out_para.height())
(0.0, 0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
@@ -144,9 +145,10 @@ fn calculate_cursor_rect(
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
let r = &rects[0].rect;
(r.right(), r.top(), r.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
(laid_out_para.longest_line(), 0.0, laid_out_para.height())
}
} else {
let rects = laid_out_para.get_rects_for_range(
@@ -155,17 +157,18 @@ fn calculate_cursor_rect(
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
let r = &rects[0].rect;
(r.left(), r.top(), r.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
(pos.position as f32, 0.0, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
cursor_x,
y_offset,
y_offset + cursor_y,
editor_state.theme.cursor_width,
cursor_height,
));

View File

@@ -133,21 +133,21 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
let width = bounds.width();
let height = bounds.height();
let target_width = bounds.width().round();
let target_height = bounds.height().round();
let scale_width = if width > 0.1 {
f32::max(0.01, bounds.width().round() / bounds.width())
f32::max(0.01, target_width / width)
} else {
1.0
};
let scale_height = if height > 0.1 {
f32::max(0.01, bounds.height().round() / bounds.height())
f32::max(0.01, target_height / height)
} else {
1.0
};
if f32::is_finite(scale_width)
&& f32::is_finite(scale_height)
&& (!math::is_close_to(scale_width, 1.0) || !math::is_close_to(scale_height, 1.0))
{
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
let mut round_transform = Matrix::scale((scale_width, scale_height));
round_transform.post_concat(&tr);
round_transform.pre_concat(&tr_inv);
@@ -373,7 +373,7 @@ pub fn propagate_modifiers(
if math::identitish(&entry.transform) {
Modifier::Reflow(entry.id, false)
} else {
Modifier::Transform(*entry)
Modifier::Transform(*entry, pixel_precision)
}
})
.collect();
@@ -392,9 +392,9 @@ pub fn propagate_modifiers(
while !entries.is_empty() {
while let Some(modifier) = entries.pop_front() {
match modifier {
Modifier::Transform(entry) => propagate_transform(
Modifier::Transform(entry, pixel) => propagate_transform(
entry,
pixel_precision,
pixel,
state,
&mut entries,
&mut bounds,

View File

@@ -480,8 +480,12 @@ impl TextContent {
};
if matches {
// Skia's get_glyph_position_at_coordinate expects coordinates relative to
// the paragraph's top-left. For multi-paragraph or wrapped text, each
// paragraph has its own origin; subtract start_y so we pass paragraph-local coords.
let para_pt = Point::new(point.x, point.y - start_y);
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y));
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
// Computed position keeps the current position in terms
// of number of characters of text. This is used to know

View File

@@ -7,16 +7,16 @@ use skia::Matrix;
#[derive(PartialEq, Debug, Clone)]
pub enum Modifier {
Transform(TransformEntry),
Transform(TransformEntry, bool),
Reflow(Uuid, bool),
}
impl Modifier {
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::from_propagate(id, transform))
Modifier::Transform(TransformEntry::from_propagate(id, transform), false)
}
pub fn parent(id: Uuid, transform: Matrix) -> Self {
Modifier::Transform(TransformEntry::parent(id, transform))
Modifier::Transform(TransformEntry::parent(id, transform), false)
}
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
Modifier::Reflow(id, force_reflow)

View File

@@ -2,6 +2,7 @@
use crate::shapes::{TextContent, TextPositionWithAffinity};
use crate::uuid::Uuid;
use crate::wasm::text::helpers as text_helpers;
use skia_safe::{
textlayout::{Affinity, PositionWithAffinity},
Color,
@@ -233,11 +234,14 @@ impl TextEditorState {
if offset == chars.len() {
offset = offset.saturating_sub(1);
} else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) {
} else if !text_helpers::is_word_char(chars[offset])
&& offset > 0
&& text_helpers::is_word_char(chars[offset - 1])
{
offset -= 1;
}
if !is_word_char(chars[offset]) {
if !text_helpers::is_word_char(chars[offset]) {
self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity(
position.paragraph,
position.offset.min(chars.len()),
@@ -248,12 +252,12 @@ impl TextEditorState {
}
let mut start = offset;
while start > 0 && is_word_char(chars[start - 1]) {
while start > 0 && text_helpers::is_word_char(chars[start - 1]) {
start -= 1;
}
let mut end = offset + 1;
while end < chars.len() && is_word_char(chars[end]) {
while end < chars.len() && text_helpers::is_word_char(chars[end]) {
end += 1;
}

View File

@@ -11,6 +11,8 @@ use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_m
use crate::error::Error;
pub mod helpers;
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();

View File

@@ -0,0 +1,753 @@
use crate::shapes::{Paragraph, TextContent, TextPositionWithAffinity};
use crate::state::TextSelection;
/// Get total character count in a paragraph.
pub fn paragraph_char_count(para: &Paragraph) -> usize {
para.children()
.iter()
.map(|span| span.text.chars().count())
.sum()
}
/// Get the text direction of the span at a given offset in a paragraph.
pub fn get_span_text_direction_at_offset(
para: &Paragraph,
char_offset: usize,
) -> skia_safe::textlayout::TextDirection {
if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) {
if let Some(span) = para.children().get(span_idx) {
return span.text_direction;
}
}
// Fallback to paragraph's text direction
para.text_direction()
}
/// Clamp a cursor position to valid bounds within the text content.
pub fn clamp_cursor(
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if paragraphs.is_empty() {
return TextPositionWithAffinity::new_without_affinity(0, 0);
}
let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = position.offset.min(para_len);
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
}
/// Move cursor left by one character.
pub fn move_cursor_backward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
word_boundary: bool,
) -> TextPositionWithAffinity {
if !word_boundary {
if cursor.offset > 0 {
return TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
cursor.offset - 1,
);
}
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
return TextPositionWithAffinity::new_without_affinity(prev_para, char_count);
}
return *cursor;
}
if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() {
return *cursor;
}
let end_para = cursor.paragraph;
let end_offset = cursor
.offset
.min(paragraph_char_count(&paragraphs[end_para]));
let mut para_idx = end_para;
let mut offset = end_offset;
let mut phase = 0u8;
loop {
let current = if offset > 0 {
paragraph_text_char_at(&paragraphs[para_idx], offset - 1)
} else if para_idx > 0 {
Some('\n')
} else {
None
};
let Some(ch) = current else {
break;
};
if offset > 0 {
offset -= 1;
} else {
para_idx -= 1;
offset = paragraph_char_count(&paragraphs[para_idx]);
}
if phase == 0 {
if is_word_char(ch) {
phase = 1;
}
continue;
}
if !is_word_char(ch) {
if offset < paragraph_char_count(&paragraphs[para_idx]) {
offset += 1;
} else if para_idx < end_para || offset < end_offset {
para_idx += 1;
offset = 0;
}
break;
}
}
TextPositionWithAffinity::new_without_affinity(para_idx, offset)
}
/// Move cursor right by one character.
pub fn move_cursor_forward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
word_boundary: bool,
) -> TextPositionWithAffinity {
if !word_boundary {
let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para);
if cursor.offset < char_count {
return TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
cursor.offset + 1,
);
}
if cursor.paragraph < paragraphs.len() - 1 {
return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
}
return *cursor;
}
if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() {
return *cursor;
}
let mut para_idx = cursor.paragraph;
let mut offset = cursor
.offset
.min(paragraph_char_count(&paragraphs[para_idx]));
let mut phase = 0u8;
loop {
let para = &paragraphs[para_idx];
let para_len = paragraph_char_count(para);
let current = if offset < para_len {
paragraph_text_char_at(para, offset)
} else if para_idx < paragraphs.len() - 1 {
Some('\n')
} else {
None
};
let Some(ch) = current else {
break;
};
if phase == 0 {
if is_word_char(ch) {
phase = 1;
} else if offset < para_len {
offset += 1;
} else {
para_idx += 1;
offset = 0;
}
continue;
}
if is_word_char(ch) {
if offset < para_len {
offset += 1;
} else {
para_idx += 1;
offset = 0;
}
} else {
break;
}
}
TextPositionWithAffinity::new_without_affinity(para_idx, offset)
}
/// Move cursor up by one line.
pub fn move_cursor_up(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
pub fn move_cursor_down(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
}
/// Move cursor to start of current line.
pub fn move_cursor_line_start(
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
/// Move cursor to end of current line.
pub fn move_cursor_line_end(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
pub fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
pub fn paragraph_text_char_at(para: &Paragraph, offset: usize) -> Option<char> {
let mut remaining = offset;
for span in para.children() {
let span_len = span.text.chars().count();
if remaining < span_len {
return span.text.chars().nth(remaining);
}
remaining -= span_len;
}
None
}
pub fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> {
let children = para.children();
let mut accumulated = 0;
for (span_idx, span) in children.iter().enumerate() {
let span_len = span.text.chars().count();
if char_offset <= accumulated + span_len {
return Some((span_idx, char_offset - accumulated));
}
accumulated += span_len;
}
if !children.is_empty() {
let last_idx = children.len() - 1;
let last_len = children[last_idx].text.chars().count();
return Some((last_idx, last_len));
}
None
}
/// Insert text at a cursor position, splitting on newlines into multiple paragraphs.
/// Returns the final cursor position after insertion.
pub fn insert_text_with_newlines(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<TextPositionWithAffinity> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() {
return None;
}
let mut current_cursor = *cursor;
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, lines[0]) {
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset);
} else {
return None;
}
for line in lines.iter().skip(1) {
if !split_paragraph_at_cursor(text_content, &current_cursor) {
break;
}
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0);
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, line) {
current_cursor = TextPositionWithAffinity::new_without_affinity(
current_cursor.paragraph,
new_offset,
);
}
}
Some(current_cursor)
}
/// Insert text at a cursor position. Returns the new character offset after insertion.
pub fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let para = &mut paragraphs[cursor.paragraph];
let children = para.children_mut();
if children.is_empty() {
return None;
}
if children.len() == 1 && children[0].text.is_empty() {
children[0].set_text(text.to_string());
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
let mut new_text = span.text.clone();
let byte_offset = new_text
.char_indices()
.nth(offset_in_span)
.map(|(i, _)| i)
.unwrap_or(new_text.len());
new_text.insert_str(byte_offset, text);
span.set_text(new_text);
Some(cursor.offset + text.chars().count())
}
/// Delete a range of text specified by a selection.
pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs_mut();
if start.paragraph >= paragraphs.len() {
return;
}
if start.paragraph == end.paragraph {
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.offset,
start_para_len,
);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> =
paragraphs[end.paragraph].children_mut().drain(..).collect();
paragraphs[start.paragraph]
.children_mut()
.extend(end_para_children);
}
if end.paragraph < paragraphs.len() {
paragraphs.drain((start.paragraph + 1)..=end.paragraph);
}
let children = paragraphs[start.paragraph].children_mut();
let has_content = children.iter().any(|span| !span.text.is_empty());
if has_content {
children.retain(|span| !span.text.is_empty());
} else if children.len() > 1 {
children.truncate(1);
}
}
}
/// Delete a range of characters within a single paragraph.
pub fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) {
if start_offset >= end_offset {
return;
}
let mut accumulated = 0;
let mut delete_start_span = None;
let mut delete_end_span = None;
for (idx, span) in para.children().iter().enumerate() {
let span_len = span.text.chars().count();
let span_end = accumulated + span_len;
if delete_start_span.is_none() && start_offset < span_end {
delete_start_span = Some((idx, start_offset - accumulated));
}
if end_offset <= span_end {
delete_end_span = Some((idx, end_offset - accumulated));
break;
}
accumulated += span_len;
}
let Some((start_span_idx, start_in_span)) = delete_start_span else {
return;
};
let Some((end_span_idx, end_in_span)) = delete_end_span else {
return;
};
let children = para.children_mut();
if start_span_idx == end_span_idx {
let span = &mut children[start_span_idx];
let text = span.text.clone();
let chars: Vec<char> = text.chars().collect();
let start_clamped = start_in_span.min(chars.len());
let end_clamped = end_in_span.min(chars.len());
let new_text: String = chars[..start_clamped]
.iter()
.chain(chars[end_clamped..].iter())
.collect();
span.set_text(new_text);
} else {
let start_span = &mut children[start_span_idx];
let text = start_span.text.clone();
let start_char_count = text.chars().count();
let start_clamped = start_in_span.min(start_char_count);
let new_text: String = text.chars().take(start_clamped).collect();
start_span.set_text(new_text);
let end_span = &mut children[end_span_idx];
let text = end_span.text.clone();
let end_char_count = text.chars().count();
let end_clamped = end_in_span.min(end_char_count);
let new_text: String = text.chars().skip(end_clamped).collect();
end_span.set_text(new_text);
if end_span_idx > start_span_idx + 1 {
children.drain((start_span_idx + 1)..end_span_idx);
}
}
let has_content = children.iter().any(|span| !span.text.is_empty());
if has_content {
children.retain(|span| !span.text.is_empty());
} else if !children.is_empty() {
children.truncate(1);
}
}
/// Delete the character before the cursor. Returns the new cursor position.
pub fn delete_char_before(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
delete_pos,
))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
let prev_para_len = paragraph_char_count(&paragraphs[prev_para_idx]);
let current_children: Vec<_> = paragraphs[cursor.paragraph]
.children_mut()
.drain(..)
.collect();
paragraphs[prev_para_idx]
.children_mut()
.extend(current_children);
paragraphs.remove(cursor.paragraph);
Some(TextPositionWithAffinity::new_without_affinity(
prev_para_idx,
prev_para_len,
))
} else {
None
}
}
/// Delete the word before the cursor. Returns the new cursor position.
pub fn delete_word_before(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() {
return None;
}
let end_paragraph = cursor.paragraph;
let end_offset = cursor
.offset
.min(paragraph_char_count(&paragraphs[end_paragraph]));
let mut start_paragraph = end_paragraph;
let mut start_offset = end_offset;
let mut phase = 0u8;
loop {
let current = if start_offset > 0 {
let para = &paragraphs[start_paragraph];
paragraph_text_char_at(para, start_offset - 1)
} else if start_paragraph > 0 {
Some('\n')
} else {
None
};
let Some(ch) = current else {
break;
};
if start_offset > 0 {
start_offset -= 1;
} else {
start_paragraph -= 1;
start_offset = paragraph_char_count(&paragraphs[start_paragraph]);
}
if phase == 0 {
if is_word_char(ch) {
phase = 1;
}
continue;
}
if !is_word_char(ch) {
if start_offset < paragraph_char_count(&paragraphs[start_paragraph]) {
start_offset += 1;
} else if start_paragraph < end_paragraph || start_offset < end_offset {
start_paragraph += 1;
start_offset = 0;
}
break;
}
}
if start_paragraph == end_paragraph && start_offset == end_offset {
return None;
}
let selection = TextSelection {
anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset),
focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset),
};
delete_selection_range(text_content, &selection);
Some(TextPositionWithAffinity::new_without_affinity(
start_paragraph,
start_offset,
))
}
/// Delete the word after the cursor.
pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() {
return;
}
let start_paragraph = cursor.paragraph;
let start_offset = cursor
.offset
.min(paragraph_char_count(&paragraphs[start_paragraph]));
let mut end_paragraph = start_paragraph;
let mut end_offset = start_offset;
let mut phase = 0u8;
loop {
let para = &paragraphs[end_paragraph];
let para_len = paragraph_char_count(para);
let current = if end_offset < para_len {
paragraph_text_char_at(para, end_offset)
} else if end_paragraph < paragraphs.len() - 1 {
Some('\n')
} else {
None
};
let Some(ch) = current else {
break;
};
if phase == 0 {
if is_word_char(ch) {
phase = 1;
} else if end_offset < para_len {
end_offset += 1;
} else {
end_paragraph += 1;
end_offset = 0;
}
continue;
}
if is_word_char(ch) {
if end_offset < para_len {
end_offset += 1;
} else {
end_paragraph += 1;
end_offset = 0;
}
} else {
break;
}
}
if start_paragraph == end_paragraph && start_offset == end_offset {
return;
}
let selection = TextSelection {
anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset),
focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset),
};
delete_selection_range(text_content, &selection);
}
/// Delete the character after the cursor.
pub fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return;
}
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.offset < para_len {
let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
paragraphs[cursor.paragraph]
.children_mut()
.extend(next_children);
paragraphs.remove(next_para_idx);
}
}
/// Split a paragraph at the cursor position. Returns true if split was successful.
pub fn split_paragraph_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> bool {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return false;
}
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
return false;
};
let mut new_para_children = Vec::new();
let children = para.children();
let current_span = &children[span_idx];
let span_text = current_span.text.clone();
let chars: Vec<char> = span_text.chars().collect();
if offset_in_span < chars.len() {
let after_text: String = chars[offset_in_span..].iter().collect();
let mut new_span = current_span.clone();
new_span.set_text(after_text);
new_para_children.push(new_span);
}
for child in children.iter().skip(span_idx + 1) {
new_para_children.push(child.clone());
}
if new_para_children.is_empty() {
let mut empty_span = current_span.clone();
empty_span.set_text(String::new());
new_para_children.push(empty_span);
}
let text_align = para.text_align();
let text_direction = para.text_direction();
let text_decoration = para.text_decoration();
let text_transform = para.text_transform();
let line_height = para.line_height();
let letter_spacing = para.letter_spacing();
let para = &mut paragraphs[cursor.paragraph];
let children = para.children_mut();
children.truncate(span_idx + 1);
if !children.is_empty() {
let span = &mut children[span_idx];
let text = span.text.clone();
let new_text: String = text.chars().take(offset_in_span).collect();
span.set_text(new_text);
}
let new_para = crate::shapes::Paragraph::new(
text_align,
text_direction,
text_decoration,
text_transform,
line_height,
letter_spacing,
new_para_children,
);
paragraphs.insert(cursor.paragraph + 1, new_para);
true
}

View File

@@ -2,10 +2,11 @@ use macros::{wasm_error, ToJs};
use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::TextSelection;
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::wasm::text::helpers as text_helpers;
use crate::{with_state, with_state_mut, STATE};
use skia_safe::{textlayout::TextDirection, Color};
@@ -322,14 +323,16 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
let selection = state.text_editor_state.selection;
if selection.is_selection() {
delete_selection_range(text_content, &selection);
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
state.text_editor_state.selection.set_caret(start);
}
let cursor = state.text_editor_state.selection.focus;
if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) {
if let Some(new_cursor) =
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
{
state.text_editor_state.selection.set_caret(new_cursor);
}
@@ -352,7 +355,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
}
#[no_mangle]
pub extern "C" fn text_editor_delete_backward() {
pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
@@ -373,13 +376,18 @@ pub extern "C" fn text_editor_delete_backward() {
let selection = state.text_editor_state.selection;
if selection.is_selection() {
delete_selection_range(text_content, &selection);
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
let clamped = clamp_cursor(start, text_content.paragraphs());
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else if word_boundary {
let cursor = selection.focus;
if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) {
state.text_editor_state.selection.set_caret(new_cursor);
}
} else {
let cursor = selection.focus;
if let Some(new_cursor) = delete_char_before(text_content, &cursor) {
if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) {
state.text_editor_state.selection.set_caret(new_cursor);
}
}
@@ -400,7 +408,7 @@ pub extern "C" fn text_editor_delete_backward() {
}
#[no_mangle]
pub extern "C" fn text_editor_delete_forward() {
pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
@@ -421,14 +429,19 @@ pub extern "C" fn text_editor_delete_forward() {
let selection = state.text_editor_state.selection;
if selection.is_selection() {
delete_selection_range(text_content, &selection);
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
let clamped = clamp_cursor(start, text_content.paragraphs());
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else if word_boundary {
let cursor = selection.focus;
text_helpers::delete_word_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else {
let cursor = selection.focus;
delete_char_after(text_content, &cursor);
let clamped = clamp_cursor(cursor, text_content.paragraphs());
text_helpers::delete_char_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
}
@@ -469,14 +482,14 @@ pub extern "C" fn text_editor_insert_paragraph() {
let selection = state.text_editor_state.selection;
if selection.is_selection() {
delete_selection_range(text_content, &selection);
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
state.text_editor_state.selection.set_caret(start);
}
let cursor = state.text_editor_state.selection.focus;
if split_paragraph_at_cursor(text_content, &cursor) {
if text_helpers::split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor);
@@ -502,7 +515,11 @@ pub extern "C" fn text_editor_insert_paragraph() {
// ============================================================================
#[no_mangle]
pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_selection: bool) {
pub extern "C" fn text_editor_move_cursor(
direction: CursorDirection,
word_boundary: bool,
extend_selection: bool,
) {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
return;
@@ -529,7 +546,10 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
// Get the text direction of the span at the current cursor position
let span_text_direction = if current.paragraph < paragraphs.len() {
get_span_text_direction_at_offset(&paragraphs[current.paragraph], current.offset)
text_helpers::get_span_text_direction_at_offset(
&paragraphs[current.paragraph],
current.offset,
)
} else {
TextDirection::LTR
};
@@ -546,16 +566,22 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
};
let new_cursor = match adjusted_direction {
CursorDirection::Backward => move_cursor_backward(&current, paragraphs),
CursorDirection::Forward => move_cursor_forward(&current, paragraphs),
CursorDirection::Backward => {
text_helpers::move_cursor_backward(&current, paragraphs, word_boundary)
}
CursorDirection::Forward => {
text_helpers::move_cursor_forward(&current, paragraphs, word_boundary)
}
CursorDirection::LineBefore => {
move_cursor_up(&current, paragraphs, text_content, shape)
text_helpers::move_cursor_up(&current, paragraphs, text_content)
}
CursorDirection::LineAfter => {
move_cursor_down(&current, paragraphs, text_content, shape)
text_helpers::move_cursor_down(&current, paragraphs, text_content)
}
CursorDirection::LineStart => move_cursor_line_start(&current, paragraphs),
CursorDirection::LineEnd => move_cursor_line_end(&current, paragraphs),
CursorDirection::LineStart => {
text_helpers::move_cursor_line_start(&current, paragraphs)
}
CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&current, paragraphs),
};
if extend_selection {
@@ -979,485 +1005,3 @@ fn get_selection_rects(
rects
}
/// Get total character count in a paragraph.
fn paragraph_char_count(para: &Paragraph) -> usize {
para.children()
.iter()
.map(|span| span.text.chars().count())
.sum()
}
/// Clamp a cursor position to valid bounds within the text content.
fn clamp_cursor(
position: TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if paragraphs.is_empty() {
return TextPositionWithAffinity::new_without_affinity(0, 0);
}
let para_idx = position.paragraph.min(paragraphs.len() - 1);
let para_len = paragraph_char_count(&paragraphs[para_idx]);
let char_offset = position.offset.min(para_len);
TextPositionWithAffinity::new_without_affinity(para_idx, char_offset)
}
/// Move cursor left by one character.
fn move_cursor_backward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
if cursor.offset > 0 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1)
} else if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
TextPositionWithAffinity::new_without_affinity(prev_para, char_count)
} else {
*cursor
}
}
/// Move cursor right by one character.
fn move_cursor_forward(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
let para = &paragraphs[cursor.paragraph];
let char_count = paragraph_char_count(para);
if cursor.offset < char_count {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1)
} else if cursor.paragraph < paragraphs.len() - 1 {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0)
} else {
*cursor
}
}
/// Move cursor up by one line.
fn move_cursor_up(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph > 0 {
let prev_para = cursor.paragraph - 1;
let char_count = paragraph_char_count(&paragraphs[prev_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(prev_para, new_offset)
} else {
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
}
/// Move cursor down by one line.
fn move_cursor_down(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
_text_content: &TextContent,
_shape: &Shape,
) -> TextPositionWithAffinity {
// TODO: Implement proper line-based navigation using line metrics
if cursor.paragraph < paragraphs.len() - 1 {
let next_para = cursor.paragraph + 1;
let char_count = paragraph_char_count(&paragraphs[next_para]);
let new_offset = cursor.offset.min(char_count);
TextPositionWithAffinity::new_without_affinity(next_para, new_offset)
} else {
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
}
/// Move cursor to start of current line.
fn move_cursor_line_start(
cursor: &TextPositionWithAffinity,
_paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-start using line metrics
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0)
}
/// Move cursor to end of current line.
fn move_cursor_line_end(
cursor: &TextPositionWithAffinity,
paragraphs: &[Paragraph],
) -> TextPositionWithAffinity {
// TODO: Implement proper line-end using line metrics
let char_count = paragraph_char_count(&paragraphs[cursor.paragraph]);
TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count)
}
// ============================================================================
// HELPERS: Text Modification
// ============================================================================
fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> {
let children = para.children();
let mut accumulated = 0;
for (span_idx, span) in children.iter().enumerate() {
let span_len = span.text.chars().count();
if char_offset <= accumulated + span_len {
return Some((span_idx, char_offset - accumulated));
}
accumulated += span_len;
}
if !children.is_empty() {
let last_idx = children.len() - 1;
let last_len = children[last_idx].text.chars().count();
return Some((last_idx, last_len));
}
None
}
/// Insert text at a cursor position, splitting on newlines into multiple paragraphs.
/// Returns the final cursor position after insertion.
fn insert_text_with_newlines(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<TextPositionWithAffinity> {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() {
return None;
}
let mut current_cursor = *cursor;
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, lines[0]) {
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset);
} else {
return None;
}
for line in lines.iter().skip(1) {
if !split_paragraph_at_cursor(text_content, &current_cursor) {
break;
}
current_cursor =
TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0);
if let Some(new_offset) = insert_text_at_cursor(text_content, &current_cursor, line) {
current_cursor = TextPositionWithAffinity::new_without_affinity(
current_cursor.paragraph,
new_offset,
);
}
}
Some(current_cursor)
}
/// Get the text direction of the span at a given offset in a paragraph.
fn get_span_text_direction_at_offset(
para: &Paragraph,
char_offset: usize,
) -> skia_safe::textlayout::TextDirection {
if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) {
if let Some(span) = para.children().get(span_idx) {
return span.text_direction;
}
}
// Fallback to paragraph's text direction
para.text_direction()
}
/// Insert text at a cursor position. Returns the new character offset after insertion.
fn insert_text_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
text: &str,
) -> Option<usize> {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let para = &mut paragraphs[cursor.paragraph];
let children = para.children_mut();
if children.is_empty() {
return None;
}
if children.len() == 1 && children[0].text.is_empty() {
children[0].set_text(text.to_string());
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
let mut new_text = span.text.clone();
let byte_offset = new_text
.char_indices()
.nth(offset_in_span)
.map(|(i, _)| i)
.unwrap_or(new_text.len());
new_text.insert_str(byte_offset, text);
span.set_text(new_text);
Some(cursor.offset + text.chars().count())
}
/// Delete a range of text specified by a selection.
fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs_mut();
if start.paragraph >= paragraphs.len() {
return;
}
if start.paragraph == end.paragraph {
delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset);
} else {
let start_para_len = paragraph_char_count(&paragraphs[start.paragraph]);
delete_range_in_paragraph(
&mut paragraphs[start.paragraph],
start.offset,
start_para_len,
);
delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset);
if end.paragraph < paragraphs.len() {
let end_para_children: Vec<_> =
paragraphs[end.paragraph].children_mut().drain(..).collect();
paragraphs[start.paragraph]
.children_mut()
.extend(end_para_children);
}
if end.paragraph < paragraphs.len() {
paragraphs.drain((start.paragraph + 1)..=end.paragraph);
}
let children = paragraphs[start.paragraph].children_mut();
let has_content = children.iter().any(|span| !span.text.is_empty());
if has_content {
children.retain(|span| !span.text.is_empty());
} else if children.len() > 1 {
children.truncate(1);
}
}
}
/// Delete a range of characters within a single paragraph.
fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) {
if start_offset >= end_offset {
return;
}
let mut accumulated = 0;
let mut delete_start_span = None;
let mut delete_end_span = None;
for (idx, span) in para.children().iter().enumerate() {
let span_len = span.text.chars().count();
let span_end = accumulated + span_len;
if delete_start_span.is_none() && start_offset < span_end {
delete_start_span = Some((idx, start_offset - accumulated));
}
if end_offset <= span_end {
delete_end_span = Some((idx, end_offset - accumulated));
break;
}
accumulated += span_len;
}
let Some((start_span_idx, start_in_span)) = delete_start_span else {
return;
};
let Some((end_span_idx, end_in_span)) = delete_end_span else {
return;
};
let children = para.children_mut();
if start_span_idx == end_span_idx {
let span = &mut children[start_span_idx];
let text = span.text.clone();
let chars: Vec<char> = text.chars().collect();
let start_clamped = start_in_span.min(chars.len());
let end_clamped = end_in_span.min(chars.len());
let new_text: String = chars[..start_clamped]
.iter()
.chain(chars[end_clamped..].iter())
.collect();
span.set_text(new_text);
} else {
let start_span = &mut children[start_span_idx];
let text = start_span.text.clone();
let start_char_count = text.chars().count();
let start_clamped = start_in_span.min(start_char_count);
let new_text: String = text.chars().take(start_clamped).collect();
start_span.set_text(new_text);
let end_span = &mut children[end_span_idx];
let text = end_span.text.clone();
let end_char_count = text.chars().count();
let end_clamped = end_in_span.min(end_char_count);
let new_text: String = text.chars().skip(end_clamped).collect();
end_span.set_text(new_text);
if end_span_idx > start_span_idx + 1 {
children.drain((start_span_idx + 1)..end_span_idx);
}
}
let has_content = children.iter().any(|span| !span.text.is_empty());
if has_content {
children.retain(|span| !span.text.is_empty());
} else if !children.is_empty() {
children.truncate(1);
}
}
/// Delete the character before the cursor. Returns the new cursor position.
fn delete_char_before(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> Option<TextPositionWithAffinity> {
if cursor.offset > 0 {
let paragraphs = text_content.paragraphs_mut();
let para = &mut paragraphs[cursor.paragraph];
let delete_pos = cursor.offset - 1;
delete_range_in_paragraph(para, delete_pos, cursor.offset);
Some(TextPositionWithAffinity::new_without_affinity(
cursor.paragraph,
delete_pos,
))
} else if cursor.paragraph > 0 {
let prev_para_idx = cursor.paragraph - 1;
let paragraphs = text_content.paragraphs_mut();
let prev_para_len = paragraph_char_count(&paragraphs[prev_para_idx]);
let current_children: Vec<_> = paragraphs[cursor.paragraph]
.children_mut()
.drain(..)
.collect();
paragraphs[prev_para_idx]
.children_mut()
.extend(current_children);
paragraphs.remove(cursor.paragraph);
Some(TextPositionWithAffinity::new_without_affinity(
prev_para_idx,
prev_para_len,
))
} else {
None
}
}
/// Delete the character after the cursor.
fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return;
}
let para_len = paragraph_char_count(&paragraphs[cursor.paragraph]);
if cursor.offset < para_len {
let para = &mut paragraphs[cursor.paragraph];
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
} else if cursor.paragraph < paragraphs.len() - 1 {
let next_para_idx = cursor.paragraph + 1;
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
paragraphs[cursor.paragraph]
.children_mut()
.extend(next_children);
paragraphs.remove(next_para_idx);
}
}
/// Split a paragraph at the cursor position. Returns true if split was successful.
fn split_paragraph_at_cursor(
text_content: &mut TextContent,
cursor: &TextPositionWithAffinity,
) -> bool {
let paragraphs = text_content.paragraphs_mut();
if cursor.paragraph >= paragraphs.len() {
return false;
}
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
return false;
};
let mut new_para_children = Vec::new();
let children = para.children();
let current_span = &children[span_idx];
let span_text = current_span.text.clone();
let chars: Vec<char> = span_text.chars().collect();
if offset_in_span < chars.len() {
let after_text: String = chars[offset_in_span..].iter().collect();
let mut new_span = current_span.clone();
new_span.set_text(after_text);
new_para_children.push(new_span);
}
for child in children.iter().skip(span_idx + 1) {
new_para_children.push(child.clone());
}
if new_para_children.is_empty() {
let mut empty_span = current_span.clone();
empty_span.set_text(String::new());
new_para_children.push(empty_span);
}
let text_align = para.text_align();
let text_direction = para.text_direction();
let text_decoration = para.text_decoration();
let text_transform = para.text_transform();
let line_height = para.line_height();
let letter_spacing = para.letter_spacing();
let para = &mut paragraphs[cursor.paragraph];
let children = para.children_mut();
children.truncate(span_idx + 1);
if !children.is_empty() {
let span = &mut children[span_idx];
let text = span.text.clone();
let new_text: String = text.chars().take(offset_in_span).collect();
span.set_text(new_text);
}
let new_para = crate::shapes::Paragraph::new(
text_align,
text_direction,
text_decoration,
text_transform,
line_height,
letter_spacing,
new_para_children,
);
paragraphs.insert(cursor.paragraph + 1, new_para);
true
}