diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 782d11e..8041c47 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -65,6 +65,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build CLI (docs:check uses dist-cli) + run: pnpm build + - name: Docs check (committed markers up to date) run: pnpm docs:check diff --git a/.gitignore b/.gitignore index 09824dd..e8cb259 100755 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ target node_modules dist-js +dist-cli dist +widget-src/.bundle.js .idea .DS_Store .r2x @@ -23,7 +25,12 @@ out/ artifacts/* !artifacts/README.md !artifacts/STATUS.md -tools/hosts.env +scripts/hosts.env +scripts/apple.env +.env +.env.* +!.env.example +!scripts/apple.env.example # VitePress docs — generated trees (rebuilt by `pnpm docs:generate`) docs/.vitepress/dist diff --git a/CHANGELOG.md b/CHANGELOG.md index f639be0..598ef90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.5.0 + +Mega-refactor toward one IR SoT and aligned renderers. + +### Added +- Host-side `normalize()` before store write (`textStyle`, semantic colors, stack spacing defaults, capability degradations) +- Desktop widget sources as TypeScript (`widget-src/`) bundled into `widget.html` +- Consumer CLI (`tauri-widgets`) with `tools` proxy to maintainer stands +- Wire catalogs: Swift `Generated/WireCatalog.swift`, Android `WireTypes.kt` via `gen-native` +- Measured `schemas/capabilities.coverage.json` (CI; omitted from publish) +- Light-theme visual cases (desktop / iOS / macOS) + +### Changed +- Split renderers into layout/text/media/data/interactive/spacing groups (Android Glance, SwiftUI, Adaptive Cards, desktop HTML) +- Split `guest-js` into `api/*` barrel; docs generator into `scripts/.../generate/*` +- Publish packaging: npm schemas whitelist; crates.io excludes goldens/docs/scripts (~10 MB → ~1.4 MB) + +### Fixed +- Desktop theme resolution (`?theme=` → `data-theme` → `matchMedia`) +- Android unit tests via JDK 17 wrapper (`scripts/sh/android-gradle.sh`) + ## 0.4.3 ### Fixes diff --git a/Cargo.lock b/Cargo.lock index b7f46fb..f076760 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -578,7 +578,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -711,7 +711,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2846,6 +2846,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -2871,7 +2885,41 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -3319,6 +3367,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -3604,7 +3658,7 @@ dependencies = [ [[package]] name = "tauri-plugin-widgets" -version = "0.4.3" +version = "0.5.0" dependencies = [ "base64 0.22.1", "gtk", @@ -3618,6 +3672,7 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", + "ureq", "x11rb", ] @@ -3732,7 +3787,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4223,6 +4278,29 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4536,6 +4614,24 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4600,7 +4696,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4772,6 +4868,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5284,6 +5389,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 6898b9e..883c7e5 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,49 @@ [package] name = "tauri-plugin-widgets" -version = "0.4.3" +version = "0.5.0" authors = ["s00d"] description = "Tauri plugin for App Widgets on Android, iOS, and macOS (WidgetKit); Windows Widgets Board (Adaptive Cards) + desktop webview; Linux desktop webview with X11 DESKTOP pin." edition = "2021" rust-version = "1.70" -exclude = ["/examples", "/dist-js", "/guest-js", "/node_modules", "demo.gif"] +exclude = [ + # App / JS workspace (published via npm, not crates.io) + "/examples", + "/dist-js", + "/dist-cli", + "/guest-js", + "/src-cli", + "/widget-src", + "/templates", + "/swift", + "/node_modules", + "/docs", + "/scripts", + "/.github", + "/artifacts", + # Visual / platform harnesses (PNG goldens are bulk of the crate) + "/tests/golden", + "/tests/expected", + "/tests/desktop", + "/tests/windows", + "/tests/macos", + "/tests/linux", + "/tests/cli", + # Native test trees (not needed to compile the plugin) + "/android/src/test", + "/android/src/androidTest", + "/ios/Tests", + # Misc non-crate assets + "demo.gif", + "preview.png", + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.json", + "rollup.config.mjs", + "Justfile", + # Measured CI artifact (regenerated from goldens; not needed to build) + "schemas/capabilities.coverage.json", +] links = "tauri-plugin-widgets" license = "MIT" repository = "https://github.com/s00d/tauri-plugin-widgets" @@ -15,7 +53,7 @@ repository = "https://github.com/s00d/tauri-plugin-widgets" # *dependencies* too, and old crates (e.g. winnow 0.5) still gate on removed # nightly features like `doc_auto_cfg` → E0557. Use rustdoc-args only. # Skip `all-features`: `layer-shell` needs gtk-layer-shell sys libs. -features = ["schema", "rasterize", "codegen", "workerw", "macos-private-api"] +features = ["schema", "rasterize", "codegen", "workerw", "macos-private-api", "image-prefetch"] rustdoc-args = ["--cfg", "docsrs"] [package.metadata.platforms.support] @@ -30,9 +68,14 @@ tauri = { version = "2" } serde = { version = "1", features = ["derive"] } serde_json = "1" log = "0.4" +base64 = "0.22" schemars = { version = "0.8", optional = true } resvg = { version = "0.45", optional = true } -base64 = { version = "0.22", optional = true } + +# ureq → rustls → ring needs NDK C toolchain on Android; Rust prefetch is cfg'd +# off there anyway (Kotlin localPath). Keep the crate off the Android graph. +[target.'cfg(not(target_os = "android"))'.dependencies] +ureq = { version = ">=2.0, <2.12", default-features = true, optional = true } [target.'cfg(target_os = "linux")'.dependencies] gtk = { version = "0.18", optional = true } @@ -46,11 +89,13 @@ tempfile = "3" [features] # `linux` pulls gtk + x11rb for DESKTOP pin. Other OS backends follow `target_os` only. # `rasterize` is opt-in (resvg) for Adaptive Cards chart/canvas/gauge → PNG data-URI. -default = ["linux"] +# `image-prefetch` is default on non-Android (ureq target-gated off Android). +default = ["linux", "image-prefetch"] linux = ["dep:x11rb", "dep:raw-window-handle", "dep:gtk"] schema = ["dep:schemars"] codegen = ["schema"] -rasterize = ["dep:resvg", "dep:base64"] +rasterize = ["dep:resvg"] +image-prefetch = ["dep:ureq"] # Transparent desktop widget windows on macOS (requires host `tauri.conf` macosPrivateApi). macos-private-api = ["tauri/macos-private-api"] # Windows wallpaper WorkerW parenting (not Widgets Board). @@ -68,6 +113,11 @@ name = "gen-ts" path = "src/bin/gen_ts.rs" required-features = ["codegen"] +[[bin]] +name = "gen-native" +path = "src/bin/gen_native.rs" +required-features = ["codegen"] + [[bin]] name = "gen-adaptive-snapshots" path = "src/bin/gen_adaptive_snapshots.rs" diff --git a/Justfile b/Justfile index 5adc5d9..97e8fa4 100644 --- a/Justfile +++ b/Justfile @@ -3,10 +3,10 @@ repo := justfile_directory() android-up: - bash {{repo}}/tools/android-up.sh + bash {{repo}}/scripts/sh/android-up.sh ios-up: - bash {{repo}}/tools/ios-up.sh + bash {{repo}}/scripts/sh/ios-up.sh # Level-1 geometry (Robolectric / Playwright tree) — unchanged bulk update OK. test-geometry: @@ -65,15 +65,13 @@ record-macos case: # ─── Windows / UTM (optional remote node) ─────────────────────────────────── win-up: - bash {{repo}}/tools/win-up.sh + bash {{repo}}/scripts/sh/win-up.sh win-sync: - bash {{repo}}/tools/win/sync.sh + bash {{repo}}/scripts/win/sync.sh win-bootstrap: - bash {{repo}}/tools/win-up.sh - scp {{repo}}/tools/win/bootstrap.ps1 utm-win:C:/work/bootstrap.ps1 - ssh utm-win 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\bootstrap.ps1' + bash {{repo}}/scripts/sh/win-bootstrap.sh # Adaptive Card transpile tests on the Mac host (no VM required). test-windows-adaptive: @@ -81,8 +79,8 @@ test-windows-adaptive: # Remote smoke via ssh utm-win (after win-up + win-sync). test-windows-remote: - bash {{repo}}/tools/win-up.sh - bash {{repo}}/tools/win/sync.sh + bash {{repo}}/scripts/sh/win-up.sh + bash {{repo}}/scripts/win/sync.sh ssh utm-win 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\shot.ps1' check-windows-xwin: @@ -94,8 +92,8 @@ build-windows-example-xwin: cd {{repo}}/examples/tauri-plugin-widgets-example/src-tauri && cargo xwin build --target x86_64-pc-windows-msvc record-windows case: - bash {{repo}}/tools/win-up.sh - bash {{repo}}/tools/win/sync.sh + bash {{repo}}/scripts/sh/win-up.sh + bash {{repo}}/scripts/win/sync.sh ssh utm-win "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\work\\shot.ps1 -Mode visual -Case {{case}} -Record" mkdir -p {{repo}}/tests/golden/windows scp "utm-win:C:/work/tauri-plugin-widgets/out/windows/{{case}}.png" "{{repo}}/tests/golden/windows/{{case}}.png" || \ @@ -111,27 +109,23 @@ record-windows-all: pnpm -C {{repo}}/scripts cli gen-adaptive-snapshots test-windows-visual: - bash {{repo}}/tools/win-up.sh - bash {{repo}}/tools/win/sync.sh + bash {{repo}}/scripts/sh/win-up.sh + bash {{repo}}/scripts/win/sync.sh ssh utm-win 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\shot.ps1 -Mode visual' win-pack: - bash {{repo}}/tools/win-up.sh - bash {{repo}}/tools/win/sync.sh - scp {{repo}}/tools/win/pack.ps1 utm-win:C:/work/pack.ps1 - ssh utm-win 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\pack.ps1' + bash {{repo}}/scripts/sh/win-pack.sh win-sideload: - scp {{repo}}/tools/win/sideload.ps1 utm-win:C:/work/sideload.ps1 - ssh utm-win 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\sideload.ps1' + bash {{repo}}/scripts/sh/win-sideload.sh # ─── Linux / Docker (native arm64; X11 xprop + optional Wayland layer-shell) ─ linux-up: - bash {{repo}}/tools/linux-up.sh + bash {{repo}}/scripts/sh/linux-up.sh linux-up-wl: - bash {{repo}}/tools/linux-up-wl.sh + bash {{repo}}/scripts/sh/linux-up-wl.sh linux-down: -docker rm -f wshot wshot-wl @@ -183,7 +177,7 @@ test-linux-fallback: "$IMAGE" bash tests/linux/run-wayland-fallback.sh shot-linux FX SIZE="small": - bash {{repo}}/tools/shot-linux.sh {{FX}} {{SIZE}} + bash {{repo}}/scripts/sh/shot-linux.sh {{FX}} {{SIZE}} # Copy triage capture into golden corpus (explicit). record-linux case: @@ -192,11 +186,11 @@ record-linux case: CASE={{case}} SIZE="$(jq -r '.size // "small"' "{{repo}}/tests/cases/${CASE}.json" 2>/dev/null || echo small)" FX="$(jq -r '.fixture // empty' "{{repo}}/tests/cases/${CASE}.json" 2>/dev/null || true)" - bash {{repo}}/tools/linux-up.sh + bash {{repo}}/scripts/sh/linux-up.sh if [[ -n "$FX" ]]; then - bash {{repo}}/tools/shot-linux.sh "$CASE" "$SIZE" + bash {{repo}}/scripts/sh/shot-linux.sh "$CASE" "$SIZE" else - bash {{repo}}/tools/shot-linux.sh "$CASE" "$SIZE" + bash {{repo}}/scripts/sh/shot-linux.sh "$CASE" "$SIZE" fi mkdir -p "{{repo}}/tests/golden/linux" SRC="{{repo}}/out/linux/${CASE}-${SIZE}.png" diff --git a/README.md b/README.md index 2c0482d..6a694ff 100755 --- a/README.md +++ b/README.md @@ -18,30 +18,64 @@ Native widgets for **Android**, **iOS**, **macOS**, **Windows**, and **Linux** from one declarative JSON configuration. -**Full documentation** (schema, platform setup, elements, showcase): -[https://s00d.github.io/tauri-plugin-widgets](https://s00d.github.io/tauri-plugin-widgets/) +**Full documentation:** [https://s00d.github.io/tauri-plugin-widgets](https://s00d.github.io/tauri-plugin-widgets/) -> The complete config schema, platform setup guides, and element reference live on the documentation site — they are no longer maintained in this README. +--- + +## Two CLIs (read this first) + +| | **Using the plugin in your app** | **Developing this repository** | +|---|---|---| +| Entry | `npx tauri-widgets` / `pnpm tauri-widgets` | `pnpm -C scripts cli` / `pnpm hosts` | +| Code | published `dist-cli/` (from `src-cli/`) | `scripts/` (TypeScript + `scripts/sh`) | +| Examples | `init`, `preview`, `signing`, `doctor`, `validate`, `trace`, `clean` | `hosts up`, `shot`, `test`, `docs-generate`, `triage` | + +If you are building an app: ignore `pnpm test:android:visual` and the rest of the root `test:*` / `*-up` scripts — those are for plugin maintainers. + +Maintainer map: [`scripts/README.md`](scripts/README.md) · [Contributing](docs/contributing/development.md). + +--- ## Install ```bash -# any package manager — or: cargo tauri add tauri-plugin-widgets pnpm tauri add tauri-plugin-widgets ``` -Manual alternative: - ```toml # src-tauri/Cargo.toml [dependencies] -tauri-plugin-widgets = "0.4" +tauri-plugin-widgets = "0.5" ``` ```bash pnpm add tauri-plugin-widgets-api ``` +## Consumer CLI (app authors) + +```bash +npx tauri-widgets init # detect platforms → init-macos / ios / windows +npx tauri-widgets preview ./widget.json --size medium --watch +npx tauri-widgets signing # identities + transport verdict +npx tauri-widgets signing --apply # write plugins.widgets +npx tauri-widgets validate ./widget.json --platforms ios,android +npx tauri-widgets doctor +npx tauri-widgets trace --follow +npx tauri-widgets clean --group group.com.example.app +npx tauri-widgets clean --cache +``` + +Preview needs **no native build** — edits to the JSON reload in the browser. + +JSON Schema (IDE autocomplete): + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json" +} +``` + ## Example ```typescript @@ -49,6 +83,7 @@ import { setWidgetConfig } from "tauri-plugin-widgets-api"; await setWidgetConfig( { + $schema: "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", small: { type: "vstack", padding: 12, @@ -64,7 +99,7 @@ await setWidgetConfig( ); ``` -Register the plugin with `tauri_plugin_widgets::init()` and add `widgets:default` to capabilities. Details: [Install](https://s00d.github.io/tauri-plugin-widgets/guide/install) · [First widget](https://s00d.github.io/tauri-plugin-widgets/guide/first-widget). +Register `tauri_plugin_widgets::init()` and `widgets:default` in capabilities. Details: [Install](https://s00d.github.io/tauri-plugin-widgets/guide/install) · [First widget](https://s00d.github.io/tauri-plugin-widgets/guide/first-widget). ## Platform setup @@ -77,11 +112,32 @@ Register the plugin with `tauri_plugin_widgets::init()` and add `widgets:default | Linux | [Setup](https://s00d.github.io/tauri-plugin-widgets/guide/setup/linux) | | Desktop webview | [Setup](https://s00d.github.io/tauri-plugin-widgets/guide/setup/desktop) | -Quick scaffold: `npx tauri-plugin-widgets-api init-macos|init-ios|init-windows`. +Or: `npx tauri-widgets init` / `init-macos` / `init-ios` / `init-windows`. + +## Try from zero -## Contributing +Minimal widget JSON + preview (no Tauri app required for layout): + +```bash +# clone / degit this folder +npx degit s00d/tauri-plugin-widgets/templates/starter my-widget +cd my-widget +npx tauri-widgets preview ./widget.json --watch --open +``` + +Full app: use the [example](examples/tauri-plugin-widgets-example) or `pnpm tauri add` then `npx tauri-widgets init`. + +## Developing this plugin (maintainers) + +```bash +pnpm hosts status +pnpm hosts up ios android +pnpm shot weather.small linux +pnpm cli:test -- --platform desktop +pnpm docs:generate +``` -Architecture, goldens, harnesses: [docs/contributing/development.md](docs/contributing/development.md). +See [`scripts/README.md`](scripts/README.md). ## License diff --git a/android/src/androidTest/java/git/s00d/widgets/WidgetRenderTest.kt b/android/src/androidTest/java/git/s00d/widgets/WidgetRenderTest.kt index 15619d8..3ad3a7b 100644 --- a/android/src/androidTest/java/git/s00d/widgets/WidgetRenderTest.kt +++ b/android/src/androidTest/java/git/s00d/widgets/WidgetRenderTest.kt @@ -21,7 +21,7 @@ import org.junit.runner.RunWith /** * Level-2 visual stand: AppWidgetHost → real Glance/RemoteViews → bitmap assert. * - * Env: tools/android-up.sh (grantbind, animations off, pinned clock). + * Env: scripts/sh/android-up.sh (grantbind, animations off, pinned clock). * Record one case: * ./gradlew :connectedDebugAndroidTest \ * -Pgolden.record=true \ diff --git a/android/src/main/assets/README-material-symbols.md b/android/src/main/assets/README-material-symbols.md new file mode 100644 index 0000000..65b9d81 --- /dev/null +++ b/android/src/main/assets/README-material-symbols.md @@ -0,0 +1,10 @@ +# Optional Material Symbols font + +Drop Google’s **Material Symbols Outlined** TTF into the host app (or this library) as: + +- `assets/material_symbols_outlined.ttf`, or +- `assets/fonts/MaterialSymbolsOutlined.ttf` + +When present, Android Glance renders `image.systemName` via Material ligatures from the shared SF→Material map (`src/icons.rs`). Without the font, the same map falls back to emoji/unicode glyphs (never a random first letter). + +Desktop / Windows use the emoji (and Windows Board rasterizes to PNG when the `rasterize` feature is on). diff --git a/android/src/main/java/GlanceDraw.kt b/android/src/main/java/GlanceDraw.kt new file mode 100644 index 0000000..8502238 --- /dev/null +++ b/android/src/main/java/GlanceDraw.kt @@ -0,0 +1,758 @@ +package git.s00d.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RadialGradient +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.SweepGradient +import android.graphics.Typeface +import android.net.Uri +import android.util.Base64 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.DpSize +import androidx.core.graphics.PathParser +import androidx.glance.ImageProvider +import org.json.JSONObject +import java.util.Collections +import java.util.Locale +import kotlin.math.roundToInt + +// accessOrder LRU — all get/put/evict must run under the same lock +internal val BASE64_CACHE: MutableMap = + Collections.synchronizedMap(LinkedHashMap(64, 0.75f, true)) + +/** + * Bake gradient / border / soft shadow into a bitmap background for Glance + * (no live gradient brush or shadow APIs). Uses [slotSize] when the IR has no frame. + */ +internal fun buildStyleChromeBitmap( + context: Context, + el: JSONObject, + slotSize: DpSize? = null, +): Bitmap? { + val bg = el.opt("background") + val border = el.optJSONObject("border") + val shadow = el.optJSONObject("shadow") + val isGradient = bg is JSONObject && bg.has("colors") + if (!isGradient && border == null && shadow == null) return null + + val density = context.resources.displayMetrics.density + val frame = el.optJSONObject("frame") + val slotW = slotSize?.width?.value?.let { (it * density).roundToInt() } + val slotH = slotSize?.height?.value?.let { (it * density).roundToInt() } + val baseW = when { + frame != null && frame.optDouble("width", 0.0) > 0 -> + frame.optDouble("width").toInt().coerceAtLeast(48) + slotW != null && slotW > 0 -> slotW.coerceAtLeast(48) + else -> 200 + } + val baseH = when { + frame != null && frame.optDouble("height", 0.0) > 0 -> + frame.optDouble("height").toInt().coerceAtLeast(48) + slotH != null && slotH > 0 -> slotH.coerceAtLeast(48) + else -> 200 + } + val shadowPad = if (shadow != null) { + ((shadow.optDouble("radius", 8.0) + kotlin.math.abs(shadow.optDouble("y", 4.0))).toInt() + 4) + .coerceIn(4, 24) + } else 0 + val w = baseW + shadowPad * 2 + val h = baseH + shadowPad * 2 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val radius = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) + val content = RectF( + shadowPad.toFloat(), + shadowPad.toFloat(), + (w - shadowPad).toFloat(), + (h - shadowPad).toFloat() + ) + + if (shadow != null) { + val sx = shadow.optDouble("x", 0.0).toFloat() + val sy = shadow.optDouble("y", 4.0).toFloat() + val shadowColor = parseColor(context, shadow.widgetString("color", "rgba(0,0,0,0.35)")) + ?.toArgb() ?: Color(0x59000000).toArgb() + val shadowRect = RectF( + content.left + sx, + content.top + sy, + content.right + sx, + content.bottom + sy + ) + canvas.drawRoundRect( + shadowRect, + radius, + radius, + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = shadowColor + } + ) + } + + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + when { + isGradient && bg is JSONObject -> { + val arr = bg.optJSONArray("colors") ?: return null + val stops = mutableListOf() + for (i in 0 until arr.length()) { + val c = parseColor(context, arr.optString(i, ""))?.toArgb() + if (c != null) stops += c + } + if (stops.isEmpty()) return null + if (stops.size == 1) stops += stops[0] + val gType = bg.widgetString("gradientType", "linear").lowercase(Locale.US) + fillPaint.shader = when (gType) { + "radial" -> { + val cx = content.centerX() + val cy = content.centerY() + val r = maxOf(content.width(), content.height()) / 2f + RadialGradient(cx, cy, r.coerceAtLeast(1f), stops.toIntArray(), null, Shader.TileMode.CLAMP) + } + "angular" -> SweepGradient(content.centerX(), content.centerY(), stops.toIntArray(), null) + else -> { + val dir = bg.widgetString("direction", "topToBottom").lowercase(Locale.US) + val (x0, y0, x1, y1) = when (dir) { + "bottomtotop" -> listOf(content.left, content.bottom, content.left, content.top) + "leadingtotrailing" -> listOf(content.left, content.top, content.right, content.top) + "trailingtoleading" -> listOf(content.right, content.top, content.left, content.top) + "topleadingtobottomtrailing" -> listOf(content.left, content.top, content.right, content.bottom) + "toptrailingtobottomleading" -> listOf(content.right, content.top, content.left, content.bottom) + else -> listOf(content.left, content.top, content.left, content.bottom) + } + LinearGradient( + x0, y0, x1, y1, + stops.toIntArray(), + null, + Shader.TileMode.CLAMP + ) + } + } + canvas.drawRoundRect(content, radius, radius, fillPaint) + } + bg is String -> { + val c = parseColor(context, bg)?.toArgb() ?: return null + fillPaint.color = c + canvas.drawRoundRect(content, radius, radius, fillPaint) + } + bg is JSONObject && (bg.has("light") || bg.has("dark")) -> { + val pick = if (surfaceIsDark(context)) { + bg.widgetString("dark", bg.widgetString("light", "")) + } else { + bg.widgetString("light", bg.widgetString("dark", "")) + } + val c = parseColor(context, pick)?.toArgb() ?: return null + fillPaint.color = c + canvas.drawRoundRect(content, radius, radius, fillPaint) + } + else -> { + // border/shadow only — keep transparent fill + } + } + + if (border != null) { + val bw = border.optDouble("width", 1.0).toFloat().coerceAtLeast(1f) + val bc = parseColor(context, border.widgetString("color", "#FFFFFF"))?.toArgb() + ?: Color.White.toArgb() + val inset = bw / 2f + val strokeRect = RectF( + content.left + inset, + content.top + inset, + content.right - inset, + content.bottom - inset + ) + canvas.drawRoundRect( + strokeRect, + (radius - inset).coerceAtLeast(0f), + (radius - inset).coerceAtLeast(0f), + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = bw + color = bc + } + ) + } + return bmp +} + +internal fun imageProviderFromElement(context: Context, el: JSONObject): ImageProvider? { + val localPath = el.widgetString("localPath", "") + if (localPath.isNotBlank()) { + BitmapFactory.decodeFile(localPath)?.let { return ImageProvider(it) } + } + + val data = el.widgetString("data", "") + if (data.isNotBlank()) { + decodeBase64Bitmap(data)?.let { return ImageProvider(it) } + } + + val url = el.widgetString("url", "") + if (url.startsWith("data:", true)) { + decodeBase64Bitmap(url)?.let { return ImageProvider(it) } + } else if (url.startsWith("file://")) { + val filePath = Uri.parse(url).path.orEmpty() + if (filePath.isNotBlank()) { + BitmapFactory.decodeFile(filePath)?.let { return ImageProvider(it) } + } + } else if (url.startsWith("/")) { + BitmapFactory.decodeFile(url)?.let { return ImageProvider(it) } + } + + val systemName = el.widgetString("systemName", "") + if (systemName.isNotBlank()) { + resolveSystemImageProvider(context, systemName)?.let { return it } + } + + return null +} + +internal fun resolveSystemImageProvider(context: Context, systemName: String): ImageProvider? { + val candidates = mutableListOf() + val normalized = systemName.trim() + if (normalized.isNotBlank()) { + candidates += normalized + candidates += normalized.replace('.', '_') + candidates += normalized.replace('-', '_') + candidates += normalized.replace('.', '_').replace('-', '_') + } + + for (name in candidates.distinct()) { + val appDrawable = context.resources.getIdentifier(name, "drawable", context.packageName) + if (appDrawable != 0) return ImageProvider(appDrawable) + val appMipmap = context.resources.getIdentifier(name, "mipmap", context.packageName) + if (appMipmap != 0) return ImageProvider(appMipmap) + + val androidDrawable = context.resources.getIdentifier(name, "drawable", "android") + if (androidDrawable != 0) return ImageProvider(androidDrawable) + } + return null +} + +internal fun decodeBase64Bitmap(raw: String): Bitmap? { + val key = raw.hashCode() + synchronized(BASE64_CACHE) { + BASE64_CACHE[key]?.let { return it } + } + + val normalized = if (raw.startsWith("data:", true)) { + raw.substringAfter(',', missingDelimiterValue = "").trim() + } else { + raw.trim() + } + if (normalized.isEmpty()) return null + val bytes = runCatching { + Base64.decode(normalized, Base64.DEFAULT) + }.recoverCatching { + Base64.decode(normalized, Base64.URL_SAFE or Base64.NO_WRAP) + }.getOrNull() ?: return null + val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + // Upscale tiny (1×1) fixtures so Glance Image sizing is visible. + val out = if (bmp.width <= 2 || bmp.height <= 2) { + Bitmap.createScaledBitmap(bmp, 32.coerceAtLeast(bmp.width * 16), 32.coerceAtLeast(bmp.height * 16), false) + } else bmp + synchronized(BASE64_CACHE) { + BASE64_CACHE[key] = out + while (BASE64_CACHE.size > 80) { + val first = BASE64_CACHE.keys.firstOrNull() ?: break + BASE64_CACHE.remove(first) + } + } + return out +} + +internal fun drawChartBitmap(context: Context, el: JSONObject): Bitmap? { + val data = el.optJSONArray("chartData") ?: return null + if (data.length() == 0) return null + + val points = mutableListOf>() + for (i in 0 until data.length()) { + val p = data.optJSONObject(i) ?: continue + val value = p.optDouble("value", 0.0).toFloat() + val pointColorProvider = resolveColorProvider(context, p.opt("color")) + val pointColor = pointColorProvider?.let { colorProviderArgb(it, context, Color.Transparent.toArgb()) } + val fallback = pointColor ?: colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) + points += (value to fallback) + } + if (points.isEmpty()) return null + + val chartType = el.widgetString("chartType", "bar").lowercase(Locale.US) + val frame = el.optJSONObject("frame") + val widthDp = if (chartType == "pie") { + frame?.optDouble("width", 80.0)?.toFloat() ?: 80f + } else { + frame?.optDouble("width", 180.0)?.toFloat() ?: 180f + } + val heightDp = if (chartType == "pie") { + widthDp + } else { + frame?.optDouble("height", 84.0)?.toFloat() ?: 84f + } + val w = dpToPx(context, widthDp).coerceAtLeast(if (chartType == "pie") 64 else 80) + val h = dpToPx(context, heightDp).coerceAtLeast(if (chartType == "pie") 64 else 44) + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + + val bgProvider = resolveBackgroundProvider(context, el.opt("background")) + if (bgProvider != null) { + canvas.drawColor(colorProviderArgb(bgProvider, context, Color.Transparent.toArgb())) + } + + val pad = dpToPx(context, 6f).toFloat() + val chartRect = RectF(pad, pad, w - pad, h - pad) + val max = points.maxOf { it.first }.coerceAtLeast(0.0001f) + val min = points.minOf { it.first } + val span = (max - min).takeIf { it > 0.0001f } ?: 1f + + when (chartType) { + "line", "area" -> { + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = points.first().second + strokeWidth = dpToPx(context, 2.5f).toFloat() + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = (points.first().second and 0x55FFFFFF) + } + val xs = FloatArray(points.size) + val ys = FloatArray(points.size) + for (i in points.indices) { + xs[i] = chartRect.left + chartRect.width() * (i.toFloat() / (points.size - 1).coerceAtLeast(1)) + val yNorm = (points[i].first - min) / span + ys[i] = chartRect.bottom - yNorm * chartRect.height() + } + val path = Path() + val area = Path() + path.moveTo(xs[0], ys[0]) + area.moveTo(xs[0], chartRect.bottom) + area.lineTo(xs[0], ys[0]) + if (points.size == 2) { + path.lineTo(xs[1], ys[1]) + area.lineTo(xs[1], ys[1]) + } else { + for (i in 0 until points.size - 1) { + val x1 = xs[i] + val y1 = ys[i] + val x2 = xs[i + 1] + val y2 = ys[i + 1] + val cx1 = x1 + (x2 - x1) / 3f + val cx2 = x1 + 2f * (x2 - x1) / 3f + path.cubicTo(cx1, y1, cx2, y2, x2, y2) + area.cubicTo(cx1, y1, cx2, y2, x2, y2) + } + } + if (chartType == "area") { + area.lineTo(chartRect.right, chartRect.bottom) + area.close() + canvas.drawPath(area, fill) + } + canvas.drawPath(path, stroke) + // Point markers for readability on small tiles. + val dot = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val r = dpToPx(context, 2.5f).toFloat() + for (i in points.indices) { + dot.color = points[i].second + canvas.drawCircle(xs[i], ys[i], r, dot) + } + } + "pie" -> { + val total = points.sumOf { it.first.toDouble() }.toFloat().coerceAtLeast(0.0001f) + var start = -90f + for ((value, color) in points) { + val sweep = (value / total) * 360f + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = color + } + canvas.drawArc(chartRect, start, sweep, true, paint) + start += sweep + } + } + else -> { + // Scale from 0 (or min(0, dataMin)) so the smallest positive bar isn't zero-height. + val dataMin = points.minOf { it.first } + val barMin = minOf(0f, dataMin) + val barMax = points.maxOf { it.first }.coerceAtLeast(0.0001f) + val barSpan = (barMax - barMin).takeIf { it > 0.0001f } ?: 1f + val gap = dpToPx(context, 3f).toFloat() + val barW = ((chartRect.width() - gap * (points.size - 1)) / points.size).coerceAtLeast(1f) + for (i in points.indices) { + val value = points[i].first + val x = chartRect.left + i * (barW + gap) + val yNorm = (value - barMin) / barSpan + val y = chartRect.bottom - yNorm * chartRect.height() + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = points[i].second + } + canvas.drawRoundRect(RectF(x, y, x + barW, chartRect.bottom), barW / 4f, barW / 4f, paint) + } + } + } + return bmp +} + +internal fun drawCanvasBitmap(context: Context, el: JSONObject): Bitmap? { + val commands = el.optJSONArray("elements") ?: return null + if (commands.length() == 0) return null + val density = context.resources.displayMetrics.density + fun sx(v: Double): Float = (v * density).toFloat() + val width = dpToPx(context, el.optDouble("width", 180.0).toFloat()).coerceAtLeast(40) + val height = dpToPx(context, el.optDouble("height", 100.0).toFloat()).coerceAtLeast(30) + val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + + val bg = resolveBackgroundProvider(context, el.opt("background"))?.getColor(context) + if (bg != null) canvas.drawColor(bg.toArgb()) + + for (i in 0 until commands.length()) { + val cmd = commands.optJSONObject(i) ?: continue + val draw = cmd.widgetString("draw", "") + when (draw) { + "circle" -> { + val cx = sx(cmd.optDouble("cx", 0.0)) + val cy = sx(cmd.optDouble("cy", 0.0)) + val r = sx(cmd.optDouble("r", 0.0)) + val fill = paintFill(context, cmd.opt("fill"), "#000000") + canvas.drawCircle(cx, cy, r, fill) + val strokeRaw = cmd.opt("stroke") + if (strokeRaw != null) { + val stroke = paintStroke( + context, + strokeRaw, + sx(cmd.optDouble("strokeWidth", 1.0)), + "#000000" + ) + if (stroke != null) canvas.drawCircle(cx, cy, r, stroke) + } + } + "line" -> { + val p = paintStroke( + context, + cmd.opt("stroke"), + sx(cmd.optDouble("strokeWidth", 1.0)), + "#000000" + ) + if (p == null) continue + canvas.drawLine( + sx(cmd.optDouble("x1", 0.0)), + sx(cmd.optDouble("y1", 0.0)), + sx(cmd.optDouble("x2", 0.0)), + sx(cmd.optDouble("y2", 0.0)), + p + ) + } + "rect" -> { + val x = sx(cmd.optDouble("x", 0.0)) + val y = sx(cmd.optDouble("y", 0.0)) + val w = sx(cmd.optDouble("width", 0.0)) + val h = sx(cmd.optDouble("height", 0.0)) + val rr = sx(cmd.optDouble("cornerRadius", 0.0)) + val rect = RectF(x, y, x + w, y + h) + val fill = paintFill(context, cmd.opt("fill"), "#000000") + canvas.drawRoundRect(rect, rr, rr, fill) + val strokeRaw = cmd.opt("stroke") + if (strokeRaw != null) { + val stroke = paintStroke( + context, + strokeRaw, + sx(cmd.optDouble("strokeWidth", 1.0)), + "#000000" + ) + if (stroke != null) canvas.drawRoundRect(rect, rr, rr, stroke) + } + } + "arc" -> { + val cx = sx(cmd.optDouble("cx", 0.0)) + val cy = sx(cmd.optDouble("cy", 0.0)) + val r = sx(cmd.optDouble("r", 0.0)) + val start = cmd.optDouble("startAngle", 0.0).toFloat() + val end = cmd.optDouble("endAngle", 0.0).toFloat() + val rect = RectF(cx - r, cy - r, cx + r, cy + r) + val strokeW = sx(cmd.optDouble("strokeWidth", 1.0)) + val stroke = paintStroke(context, cmd.opt("stroke"), strokeW, null) + val fallbackStroke = if (stroke == null) { + paintStroke(context, cmd.opt("fill"), strokeW, "#000000") + } else stroke + if (fallbackStroke == null) continue + canvas.drawArc(rect, start, end - start, false, fallbackStroke) + } + "text" -> { + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = colorProviderArgb(resolveColorProvider(context, cmd.opt("color")), context, Color.Black.toArgb()) + textSize = sx(cmd.optDouble("fontSize", 12.0)) + } + canvas.drawText( + cmd.widgetString("content", ""), + sx(cmd.optDouble("x", 0.0)), + sx(cmd.optDouble("y", 0.0)), + paint + ) + } + "path" -> { + val d = cmd.widgetString("d", "") + val path = try { + PathParser.createPathFromPathData(d) + } catch (_: RuntimeException) { + continue + } + path.transform(Matrix().apply { setScale(density, density) }) + val fillRaw = cmd.opt("fill") + if (fillRaw != null && fillRaw.toString().isNotBlank() && fillRaw.toString() != "none") { + canvas.drawPath(path, paintFill(context, fillRaw, "#000000")) + } + val strokeRaw = cmd.opt("stroke") + if (strokeRaw != null) { + val stroke = paintStroke( + context, + strokeRaw, + sx(cmd.optDouble("strokeWidth", 1.0)), + "#000000" + ) + if (stroke != null) canvas.drawPath(path, stroke) + } + } + } + } + return bmp +} + +internal fun drawProgressBitmap(context: Context, el: JSONObject, percent: Int): Bitmap? { + val barStyle = el.widgetString("barStyle", "linear").lowercase(Locale.US) + val tint = colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) + val dark = surfaceIsDark(context) + val bgColor = trackColorFromTint(tint, dark) + if (barStyle == "circular") { + val sizeDp = el.optJSONObject("frame")?.optDouble("width", 40.0)?.toFloat() ?: 40f + val s = dpToPx(context, sizeDp).coerceAtLeast(32) + val bmp = Bitmap.createBitmap(s, s, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val stroke = (s * 0.12f).coerceAtLeast(3f) + val pad = stroke / 2f + 1f + val rect = RectF(pad, pad, s - pad, s - pad) + val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = bgColor + strokeCap = Paint.Cap.ROUND + } + val prog = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = tint + strokeCap = Paint.Cap.ROUND + } + canvas.drawArc(rect, -90f, 360f, false, track) + canvas.drawArc(rect, -90f, 360f * (percent.coerceIn(0, 100) / 100f), false, prog) + return bmp + } + + val frame = el.optJSONObject("frame") + val widthDp = frame?.optDouble("width", 180.0)?.toFloat() ?: 180f + val heightDp = frame?.optDouble("height", 18.0)?.toFloat() ?: 18f + val w = dpToPx(context, widthDp).coerceAtLeast(80) + val h = dpToPx(context, heightDp).coerceAtLeast(8) + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + + val radius = (h / 2f).coerceAtLeast(2f) + val outer = RectF(0f, 0f, w.toFloat(), h.toFloat()) + val filledW = (w * (percent.coerceIn(0, 100) / 100f)).coerceAtLeast(0f) + val inner = RectF(0f, 0f, filledW, h.toFloat()) + + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = bgColor } + val fgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = tint } + canvas.drawRoundRect(outer, radius, radius, bgPaint) + if (filledW > 0f) canvas.drawRoundRect(inner, radius, radius, fgPaint) + return bmp +} + +internal fun drawGaugeBitmap(context: Context, el: JSONObject, percent: Int): Bitmap? { + val gaugeStyle = el.widgetString("gaugeStyle", "circular").lowercase(Locale.US) + val tint = colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) + val dark = surfaceIsDark(context) + val ink = colorProviderArgb( + resolveColorProvider(context, el.opt("color")), + context, + if (dark) Color(0xFFF2F2F7).toArgb() else Color(0xFF111111).toArgb() + ) + val label = el.widgetString("label", "") + val valueLabel = when { + el.has("currentValueLabel") -> el.widgetString("currentValueLabel", "") + el.has("value") -> "$percent%" + else -> "" + } + + if (gaugeStyle == "linear") { + val frame = el.optJSONObject("frame") + val widthDp = frame?.optDouble("width", 160.0)?.toFloat() ?: 160f + val barH = dpToPx(context, 8f).coerceAtLeast(6) + val labelH = if (label.isNotBlank() || valueLabel.isNotBlank()) dpToPx(context, 14f) else 0 + val w = dpToPx(context, widthDp).coerceAtLeast(80) + val totalH = barH + labelH + if (labelH > 0) dpToPx(context, 4f) else 0 + val bmp = Bitmap.createBitmap(w, totalH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + var y = 0f + if (labelH > 0) { + val lp = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ink + textSize = labelH * 0.7f + typeface = Typeface.DEFAULT + } + if (label.isNotBlank()) { + lp.textAlign = Paint.Align.LEFT + canvas.drawText(label, 0f, labelH - lp.descent(), lp) + } + if (valueLabel.isNotBlank()) { + lp.textAlign = Paint.Align.RIGHT + lp.typeface = Typeface.DEFAULT_BOLD + canvas.drawText(valueLabel, w.toFloat(), labelH - lp.descent(), lp) + } + y = labelH + dpToPx(context, 4f).toFloat() + } + val trackColor = trackColorFromTint(tint, dark) + val radius = barH / 2f + val outer = RectF(0f, y, w.toFloat(), y + barH) + val filledW = (w * (percent.coerceIn(0, 100) / 100f)).coerceAtLeast(0f) + canvas.drawRoundRect(outer, radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL; color = trackColor + }) + if (filledW > 0f) { + canvas.drawRoundRect(RectF(0f, y, filledW, y + barH), radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL; color = tint + }) + } + return bmp + } + + val frame = el.optJSONObject("frame") + val sizeDp = frame?.optDouble("width", 72.0)?.toFloat() ?: 72f + val ring = dpToPx(context, sizeDp).coerceAtLeast(48) + val labelH = if (label.isNotBlank()) (ring * 0.22f).toInt().coerceAtLeast(12) else 0 + val s = ring + val totalH = ring + labelH + val bmp = Bitmap.createBitmap(s, totalH, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val stroke = (ring * 0.12f).coerceAtLeast(4f) + val pad = stroke / 2f + 1f + val rect = RectF(pad, pad, s - pad, ring - pad) + + val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = trackColorFromTint(tint, dark) + strokeCap = Paint.Cap.ROUND + } + val prog = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = stroke + color = tint + strokeCap = Paint.Cap.ROUND + } + canvas.drawArc(rect, -90f, 360f, false, track) + canvas.drawArc(rect, -90f, 360f * (percent.coerceIn(0, 100) / 100f), false, prog) + + val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ink + textAlign = Paint.Align.CENTER + textSize = ring * 0.22f + typeface = Typeface.DEFAULT_BOLD + } + val cx = s / 2f + val cy = ring / 2f - (valuePaint.descent() + valuePaint.ascent()) / 2f + canvas.drawText(valueLabel, cx, cy, valuePaint) + + if (label.isNotBlank()) { + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = ink + textAlign = Paint.Align.CENTER + textSize = ring * 0.14f + typeface = Typeface.DEFAULT + } + val ly = ring + labelH / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f + canvas.drawText(label, cx, ly, labelPaint) + } + return bmp +} + +internal fun paintFill(context: Context, color: Any?, fallbackHex: String): Paint { + return Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + this.color = colorProviderArgb(resolveColorProvider(context, color), context, parseColor(context, fallbackHex)?.toArgb() ?: Color.Black.toArgb()) + } +} + +internal fun paintStroke(context: Context, color: Any?, width: Float, fallbackHex: String?): Paint? { + if (color == null && fallbackHex == null) return null + return Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + val fallback = fallbackHex?.let { parseColor(context, it)?.toArgb() } ?: Color.Black.toArgb() + this.color = colorProviderArgb(resolveColorProvider(context, color), context, fallback) + strokeWidth = width.coerceAtLeast(1f) + } +} + +internal fun drawShapeBitmap(context: Context, el: JSONObject, sizeDp: Int): Bitmap? { + val shape = el.widgetString("shapeType", "circle").lowercase(Locale.US) + val heightPx = dpToPx(context, sizeDp.toFloat()).coerceAtLeast(8) + // Capsule: width = 2*size, height = size + val widthPx = if (shape == "capsule") { + dpToPx(context, (sizeDp * 2).toFloat()).coerceAtLeast(8) + } else { + heightPx + } + val bmp = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val rect = RectF(0f, 0f, widthPx.toFloat(), heightPx.toFloat()) + val fillColor = colorProviderArgb( + resolveColorProvider(context, el.opt("fill")), + context, + Color(0xFF888888).toArgb() + ) + val strokeProvider = resolveColorProvider(context, el.opt("stroke")) + val strokeW = el.optDouble("strokeWidth", 0.0).toFloat().coerceAtLeast(0f) + + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = fillColor + } + val capsuleRadius = heightPx / 2f + when (shape) { + "capsule" -> canvas.drawRoundRect(rect, capsuleRadius, capsuleRadius, fill) + "rectangle" -> { + val rr = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) + canvas.drawRoundRect(rect, rr, rr, fill) + } + else -> canvas.drawOval(rect, fill) + } + + if (strokeProvider != null && strokeW > 0f) { + val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = colorProviderArgb(strokeProvider, context, Color.Black.toArgb()) + strokeWidth = strokeW + } + val inset = strokeW / 2f + val sr = RectF(inset, inset, widthPx - inset, heightPx - inset) + when (shape) { + "capsule" -> canvas.drawRoundRect(sr, capsuleRadius, capsuleRadius, stroke) + "rectangle" -> { + val rr = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) + canvas.drawRoundRect(sr, rr, rr, stroke) + } + else -> canvas.drawOval(sr, stroke) + } + } + return bmp +} diff --git a/android/src/main/java/GlanceIcons.kt b/android/src/main/java/GlanceIcons.kt new file mode 100644 index 0000000..48aa716 --- /dev/null +++ b/android/src/main/java/GlanceIcons.kt @@ -0,0 +1,298 @@ +package git.s00d.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import java.util.Locale + + +internal fun iconGlyph(systemName: String): String { + val key = systemName.lowercase(Locale.US) + return when (key) { + "star" -> "\u2B50" + "star.fill" -> "\u2B50" + "heart" -> "\u2764" + "heart.fill" -> "\u2764" + "person" -> "\uD83D\uDC64" + "person.fill" -> "\uD83D\uDC64" + "person.2.fill" -> "\uD83D\uDC65" + "person.badge.plus" -> "\uD83D\uDC64" + "gear" -> "\u2699" + "gearshape" -> "\u2699" + "gearshape.fill" -> "\u2699" + "checkmark" -> "\u2713" + "checkmark.circle" -> "\u2713" + "checkmark.circle.fill" -> "\u2713" + "xmark" -> "\u2715" + "xmark.circle" -> "\u2715" + "plus" -> "+" + "plus.circle" -> "+" + "minus" -> "\u2212" + "minus.circle" -> "\u2212" + "bell" -> "\uD83D\uDD14" + "bell.fill" -> "\uD83D\uDD14" + "house" -> "\u2302" + "house.fill" -> "\u2302" + "magnifyingglass" -> "\uD83D\uDD0D" + "calendar" -> "\uD83D\uDCC5" + "calendar.badge.clock" -> "\uD83D\uDCC5" + "calendar.circle" -> "\uD83D\uDCC5" + "clock" -> "\u23F1" + "clock.fill" -> "\u23F1" + "globe" -> "\uD83C\uDF10" + "cloud.fill" -> "\u2601" + "cloud.sun.fill" -> "\u26C5" + "cloud.rain.fill" -> "\u2614" + "cloud.bolt.fill" -> "\u26A1" + "sun.max" -> "\u2600" + "sun.max.fill" -> "\u2600" + "moon.fill" -> "\u263E" + "moon.stars.fill" -> "\u263E" + "bolt" -> "\u26A1" + "bolt.fill" -> "\u26A1" + "location.fill" -> "\u2302" + "flame.fill" -> "\uD83D\uDD25" + "drop.fill" -> "\u25CF" + "leaf.fill" -> "\u273F" + "eye.fill" -> "\u25C9" + "bubble.left.fill" -> "\u25D1" + "music.note" -> "\u266A" + "music.note.list" -> "\u266A" + "hourglass" -> "\u23F3" + "hourglass.bottomhalf.filled" -> "\u23F3" + "figure.run" -> "\u27A4" + "paintpalette.fill" -> "\u2698" + "quote.opening" -> "\u201C" + "bitcoinsign.circle.fill" -> "\u20BF" + "battery.100" -> "\u25AE" + "iphone" -> "\u25AF" + "ipad" -> "\u25AD" + "applewatch" -> "\u231A" + "airpodspro" -> "\u25CB" + "desktopcomputer" -> "\u25A3" + "internaldrive" -> "\u25A0" + "network" -> "\u2261" + "envelope.fill" -> "\u2709" + "envelope" -> "\u2709" + "phone.fill" -> "\u260E" + "phone" -> "\u260E" + "message.fill" -> "\uD83D\uDCAC" + "trash" -> "\uD83D\uDDD1" + "trash.fill" -> "\uD83D\uDDD1" + "folder" -> "\uD83D\uDCC1" + "folder.fill" -> "\uD83D\uDCC1" + "doc" -> "\uD83D\uDCC4" + "doc.fill" -> "\uD83D\uDCC4" + "photo" -> "\uD83D\uDDBC" + "photo.fill" -> "\uD83D\uDDBC" + "camera" -> "\uD83D\uDCF7" + "camera.fill" -> "\uD83D\uDCF7" + "map" -> "\uD83D\uDDFA" + "map.fill" -> "\uD83D\uDDFA" + "cart" -> "\uD83D\uDED2" + "cart.fill" -> "\uD83D\uDED2" + "creditcard" -> "\uD83D\uDCB3" + "creditcard.fill" -> "\uD83D\uDCB3" + "wifi" -> "\uD83D\uDCF6" + "antenna.radiowaves.left.and.right" -> "\uD83D\uDCE1" + "lock.fill" -> "\uD83D\uDD12" + "lock" -> "\uD83D\uDD12" + "lock.open.fill" -> "\uD83D\uDD13" + "key.fill" -> "\uD83D\uDD11" + "paperplane.fill" -> "\u27A4" + "arrow.right" -> "\u2192" + "arrow.left" -> "\u2190" + "arrow.up" -> "\u2191" + "arrow.down" -> "\u2193" + "chevron.right" -> "\u203A" + "chevron.left" -> "\u2039" + "chevron.up" -> "\u02C6" + "chevron.down" -> "\u02C7" + "info.circle" -> "\u2139" + "info.circle.fill" -> "\u2139" + "exclamationmark.triangle" -> "\u26A0" + "exclamationmark.triangle.fill" -> "\u26A0" + "play.fill" -> "\u25B6" + "pause.fill" -> "\u23F8" + "stop.fill" -> "\u23F9" + "forward.fill" -> "\u23E9" + "backward.fill" -> "\u23EA" + else -> { + // Never take(1) — that rendered a random letter. Neutral bullet + Material ligature name. + if (systemName.isBlank()) "\u25CF" else "\u25CF" + } + } +} + + +internal fun iconMaterialName(systemName: String): String { + val key = systemName.lowercase(Locale.US) + return when (key) { + "star" -> "star" + "star.fill" -> "star" + "heart" -> "favorite" + "heart.fill" -> "favorite" + "person" -> "person" + "person.fill" -> "person" + "person.2.fill" -> "group" + "person.badge.plus" -> "person_add" + "gear" -> "settings" + "gearshape" -> "settings" + "gearshape.fill" -> "settings" + "checkmark" -> "check" + "checkmark.circle" -> "check_circle" + "checkmark.circle.fill" -> "check_circle" + "xmark" -> "close" + "xmark.circle" -> "cancel" + "plus" -> "add" + "plus.circle" -> "add_circle" + "minus" -> "remove" + "minus.circle" -> "do_not_disturb_on" + "bell" -> "notifications" + "bell.fill" -> "notifications" + "house" -> "home" + "house.fill" -> "home" + "magnifyingglass" -> "search" + "calendar" -> "calendar_today" + "calendar.badge.clock" -> "event" + "calendar.circle" -> "calendar_today" + "clock" -> "schedule" + "clock.fill" -> "schedule" + "globe" -> "public" + "cloud.fill" -> "cloud" + "cloud.sun.fill" -> "partly_cloudy_day" + "cloud.rain.fill" -> "rainy" + "cloud.bolt.fill" -> "thunderstorm" + "sun.max" -> "wb_sunny" + "sun.max.fill" -> "wb_sunny" + "moon.fill" -> "dark_mode" + "moon.stars.fill" -> "clear_night" + "bolt" -> "bolt" + "bolt.fill" -> "bolt" + "location.fill" -> "location_on" + "flame.fill" -> "local_fire_department" + "drop.fill" -> "water_drop" + "leaf.fill" -> "eco" + "eye.fill" -> "visibility" + "bubble.left.fill" -> "chat_bubble" + "music.note" -> "music_note" + "music.note.list" -> "queue_music" + "hourglass" -> "hourglass_empty" + "hourglass.bottomhalf.filled" -> "hourglass_bottom" + "figure.run" -> "directions_run" + "paintpalette.fill" -> "palette" + "quote.opening" -> "format_quote" + "bitcoinsign.circle.fill" -> "currency_bitcoin" + "battery.100" -> "battery_full" + "iphone" -> "phone_iphone" + "ipad" -> "tablet_mac" + "applewatch" -> "watch" + "airpodspro" -> "headphones" + "desktopcomputer" -> "desktop_windows" + "internaldrive" -> "hard_drive" + "network" -> "lan" + "envelope.fill" -> "mail" + "envelope" -> "mail" + "phone.fill" -> "call" + "phone" -> "call" + "message.fill" -> "message" + "trash" -> "delete" + "trash.fill" -> "delete" + "folder" -> "folder" + "folder.fill" -> "folder" + "doc" -> "description" + "doc.fill" -> "description" + "photo" -> "photo" + "photo.fill" -> "photo" + "camera" -> "photo_camera" + "camera.fill" -> "photo_camera" + "map" -> "map" + "map.fill" -> "map" + "cart" -> "shopping_cart" + "cart.fill" -> "shopping_cart" + "creditcard" -> "credit_card" + "creditcard.fill" -> "credit_card" + "wifi" -> "wifi" + "antenna.radiowaves.left.and.right" -> "cell_tower" + "lock.fill" -> "lock" + "lock" -> "lock" + "lock.open.fill" -> "lock_open" + "key.fill" -> "key" + "paperplane.fill" -> "send" + "arrow.right" -> "arrow_forward" + "arrow.left" -> "arrow_back" + "arrow.up" -> "arrow_upward" + "arrow.down" -> "arrow_downward" + "chevron.right" -> "chevron_right" + "chevron.left" -> "chevron_left" + "chevron.up" -> "expand_less" + "chevron.down" -> "expand_more" + "info.circle" -> "info" + "info.circle.fill" -> "info" + "exclamationmark.triangle" -> "warning" + "exclamationmark.triangle.fill" -> "warning" + "play.fill" -> "play_arrow" + "pause.fill" -> "pause" + "stop.fill" -> "stop" + "forward.fill" -> "fast_forward" + "backward.fill" -> "fast_rewind" + else -> key.trimEnd().removeSuffix(".fill").replace('.', '_').ifBlank { "circle" } + } +} + + +private var materialSymbolsTypefaceCache: Typeface? = null +private var materialSymbolsLoaded = false + +/** Optional asset: `assets/material_symbols_outlined.ttf` (Material Symbols Outlined). */ +internal fun materialSymbolsTypeface(context: Context): Typeface? { + if (materialSymbolsLoaded) return materialSymbolsTypefaceCache + materialSymbolsLoaded = true + materialSymbolsTypefaceCache = listOf( + "material_symbols_outlined.ttf", + "fonts/MaterialSymbolsOutlined.ttf", + "fonts/material_symbols_outlined.ttf", + ).firstNotNullOfOrNull { path -> + runCatching { Typeface.createFromAsset(context.assets, path) }.getOrNull() + } + return materialSymbolsTypefaceCache +} + + +internal fun drawSystemIconBitmap(context: Context, systemName: String, sizeDp: Int, argb: Int): Bitmap? { + val ms = materialSymbolsTypeface(context) + if (ms != null) { + return drawGlyphBitmap(context, iconMaterialName(systemName), sizeDp, argb, ms) + } + return drawGlyphBitmap(context, iconGlyph(systemName), sizeDp, argb) +} + + + +internal fun drawGlyphBitmap( + context: Context, + glyph: String, + sizeDp: Int, + argb: Int, + typeface: Typeface = Typeface.DEFAULT_BOLD, +): Bitmap? { + if (glyph.isBlank()) return null + val px = dpToPx(context, sizeDp.toFloat()).coerceAtLeast(16) + val bmp = Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = argb + textAlign = Paint.Align.CENTER + textSize = px * 0.78f + this.typeface = typeface + } + val x = px / 2f + val y = px / 2f - (paint.descent() + paint.ascent()) / 2f + canvas.drawText(glyph, x, y, paint) + return bmp +} + diff --git a/android/src/main/java/GlanceParse.kt b/android/src/main/java/GlanceParse.kt new file mode 100644 index 0000000..6681598 --- /dev/null +++ b/android/src/main/java/GlanceParse.kt @@ -0,0 +1,417 @@ +package git.s00d.widgets + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.sp +import androidx.glance.layout.Alignment +import androidx.glance.layout.ContentScale +import androidx.glance.text.FontWeight +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** Per-render luminance override — ThreadLocal so concurrent Glance paints don't cross-contaminate. */ +internal val WIDGET_SURFACE_DARK = ThreadLocal() + +internal fun resolveBackgroundProvider(context: Context, value: Any?): ColorProvider? { + if (value == null) return null + if (value is String) return colorProviderFromString(context, value) + if (value !is JSONObject) return null + + if (value.has("light") || value.has("dark")) { + val isDark = surfaceIsDark(context) + val pick = if (isDark) value.widgetString("dark", value.widgetString("light", "")) else value.widgetString("light", value.widgetString("dark", "")) + return colorProviderFromString(context, pick) + } + if (value.has("colors")) { + val arr = value.optJSONArray("colors") + if (arr != null && arr.length() > 0) { + // Glance has no gradient brush here — degrade to first stop explicitly. + Log.d("TauriGlanceWidget", "background gradient degraded to first color stop (Glance limitation)") + return colorProviderFromString(context, arr.optString(0, "")) + } + } + return null +} + +internal fun resolveColorProvider(context: Context, value: Any?): ColorProvider? { + if (value == null) return null + if (value is String) return colorProviderFromString(context, value) + if (value is JSONObject) { + if (value.has("light") || value.has("dark")) { + val isDark = surfaceIsDark(context) + val pick = if (isDark) value.widgetString("dark", value.widgetString("light", "")) else value.widgetString("light", value.widgetString("dark", "")) + return colorProviderFromString(context, pick) + } + } + return null +} + +internal fun colorProviderFromString(context: Context, raw: String): ColorProvider? { + val c = parseColor(context, raw) ?: return null + return ColorProvider(c) +} + +internal fun parseColor(context: Context, raw: String): Color? { + val s0 = raw.trim() + if (s0.isEmpty()) return null + val semantic = semanticColor(context, s0) + if (semantic != null) return semantic + val rgba = Regex( + """rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)""", + RegexOption.IGNORE_CASE + ).matchEntire(s0) + if (rgba != null) { + val r = rgba.groupValues[1].toFloat().coerceIn(0f, 255f) / 255f + val g = rgba.groupValues[2].toFloat().coerceIn(0f, 255f) / 255f + val b = rgba.groupValues[3].toFloat().coerceIn(0f, 255f) / 255f + val a = rgba.groupValues.getOrNull(4)?.takeIf { it.isNotBlank() }?.toFloat()?.coerceIn(0f, 1f) ?: 1f + return Color(r, g, b, a) + } + // Expand #RGB / #RGBA → full form (android.graphics.Color.parseColor rejects short hex). + val s = expandShortHex(s0) + return runCatching { + when { + s.startsWith("#") -> Color(android.graphics.Color.parseColor(s)) + s.equals("white", true) -> Color.White + s.equals("black", true) -> Color.Black + s.equals("red", true) -> Color(0xFFE53935) + s.equals("green", true) -> Color(0xFF43A047) + s.equals("blue", true) -> Color(0xFF1E88E5) + s.equals("gray", true) || s.equals("grey", true) -> Color.Gray + else -> Color(android.graphics.Color.parseColor(s)) + } + }.getOrNull() +} + +/** `#abc` → `#aabbcc`, `#abcd` → `#aabbccdd` (alpha last in CSS; Android wants AARRGGBB). + * Shared wire format is `#RRGGBBAA`; Android `Color.parseColor` expects `#AARRGGBB`. */ +internal fun expandShortHex(raw: String): String { + if (!raw.startsWith("#")) return raw + val h = raw.substring(1) + return when (h.length) { + 3 -> "#" + h.map { "$it$it" }.joinToString("") + 4 -> { + // CSS #RGBA → Android #AARRGGBB + val r = "${h[0]}${h[0]}" + val g = "${h[1]}${h[1]}" + val b = "${h[2]}${h[2]}" + val a = "${h[3]}${h[3]}" + "#$a$r$g$b" + } + 8 -> { + // Wire #RRGGBBAA → Android #AARRGGBB + val rr = h.substring(0, 2) + val gg = h.substring(2, 4) + val bb = h.substring(4, 6) + val aa = h.substring(6, 8) + "#$aa$rr$gg$bb" + } + else -> raw + } +} + +internal fun semanticColor(context: Context, raw: String): Color? { + val dark = surfaceIsDark(context) + return when (raw.lowercase(Locale.US)) { + "label" -> if (dark) Color(0xFFF2F2F7) else Color(0xFF111111) + "secondarylabel" -> if (dark) Color(0xFFC7C7CC) else Color(0xFF555555) + "separator" -> if (dark) Color(0xFF3A3A3C) else Color(0xFFD1D1D6) + "systembackground" -> if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + "accent", "systemblue" -> if (dark) Color(0xFF89B4FA) else Color(0xFF2563EB) + "systemred" -> if (dark) Color(0xFFF87171) else Color(0xFFDC2626) + "systemgreen" -> if (dark) Color(0xFF86EFAC) else Color(0xFF16A34A) + "systemorange" -> if (dark) Color(0xFFFBBF24) else Color(0xFFEA580C) + "systempurple" -> if (dark) Color(0xFFC4B5FD) else Color(0xFF7C3AED) + else -> null + } +} + +internal fun isDarkMode(context: Context): Boolean { + val mode = context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK + return mode == android.content.res.Configuration.UI_MODE_NIGHT_YES +} + +/** Prefer widget background luminance; fall back to system night mode. */ +internal fun surfaceIsDark(context: Context): Boolean { + return WIDGET_SURFACE_DARK.get() ?: isDarkMode(context) +} + +internal fun inferSurfaceDark(context: Context, background: Any?): Boolean? { + if (background == null || background === JSONObject.NULL) return null + when (background) { + is String -> { + val s = background.trim() + if (s.equals("null", true) || s.equals("undefined", true) || s.isEmpty()) return null + if (s.equals("systemBackground", true) || s.equals("adaptive", true)) return null + val c = parseColor(context, s) ?: return null + return luminanceOf(c) < 0.45f + } + is JSONObject -> { + // adaptive: { light, dark } — use light for harness luminance; production follows system + val hex = background.widgetString("light", background.widgetString("dark", "")) + if (hex.isBlank()) return null + val c = parseColor(context, hex) ?: return null + return luminanceOf(c) < 0.45f + } + else -> return null + } +} + +internal fun luminanceOf(color: Color): Float { + val r = color.red + val g = color.green + val b = color.blue + return 0.2126f * r + 0.7152f * g + 0.0722f * b +} + +/** Track color ≈ tint @ 25% alpha over transparent (matches desktop tintTrack). */ +internal fun trackColorFromTint(tintArgb: Int, surfaceDark: Boolean): Int { + val a = 0x40 // ~25% + val r = android.graphics.Color.red(tintArgb) + val g = android.graphics.Color.green(tintArgb) + val b = android.graphics.Color.blue(tintArgb) + if (a > 0) { + // Composite onto dark/light surface for opaque bitmap pixels + val sr = if (surfaceDark) 0x0d else 0xe5 + val sg = if (surfaceDark) 0x11 else 0xe7 + val sb = if (surfaceDark) 0x17 else 0xeb + val af = a / 255f + val cr = (r * af + sr * (1 - af)).toInt().coerceIn(0, 255) + val cg = (g * af + sg * (1 - af)).toInt().coerceIn(0, 255) + val cb = (b * af + sb * (1 - af)).toInt().coerceIn(0, 255) + return android.graphics.Color.rgb(cr, cg, cb) + } + return android.graphics.Color.rgb(r, g, b) +} + +internal fun textStyleFromElement(context: Context, el: JSONObject): TextStyle { + val color = resolveColorProvider(context, el.opt("color")) ?: semanticLabelProvider(context) + val semantic = semanticTextSizeSp(el.widgetString("textStyle", "")) + val explicit = el.optDouble("fontSize", -1.0) + // textStyle overrides fontSize when both are set (models.rs contract) + val size = when { + semantic > 0f -> semantic.toDouble() + explicit > 0 -> explicit + else -> -1.0 + } + val weight = when (el.widgetString("fontWeight", "").lowercase(Locale.US)) { + "bold", "heavy", "black", "semibold", "600", "700", "800", "900" -> FontWeight.Bold + "medium", "500" -> FontWeight.Medium + else -> FontWeight.Normal + } + val align = when (el.widgetString("alignment", "").lowercase(Locale.US)) { + "center", "middle" -> TextAlign.Center + "trailing", "right", "end" -> TextAlign.End + else -> TextAlign.Start + } + return if (size > 0) { + TextStyle(color = color, fontSize = size.toFloat().sp, fontWeight = weight, textAlign = align) + } else { + TextStyle(color = color, fontWeight = weight, textAlign = align) + } +} + +internal fun semanticTextSizeSp(style: String): Float { + return when (style.lowercase(Locale.US)) { + "largeTitle".lowercase(Locale.US) -> 34f + "title", "title1" -> 28f + "title2" -> 22f + "title3" -> 20f + "headline" -> 17f + "subheadline" -> 15f + "body" -> 16f + "callout" -> 16f + "footnote" -> 13f + "caption" -> 12f + "caption2" -> 11f + else -> -1f + } +} + +internal fun semanticLabelProvider(context: Context): ColorProvider { + return ColorProvider(if (surfaceIsDark(context)) Color(0xFFF2F2F7) else Color(0xFF111111)) +} + +internal fun semanticSecondaryProvider(context: Context): ColorProvider { + return ColorProvider(if (surfaceIsDark(context)) Color(0xFFC7C7CC) else Color(0xFF555555)) +} + +internal fun mediaGlyphFallback(label: String): String { + return when (label.trim()) { + "⏸", "❚❚", "pause" -> "||" + "▶", "►", "play" -> ">" + "⏮", "previous", "prev" -> "|<" + "⏭", "next" -> ">|" + "⏯" -> ">|" + else -> label + } +} + +internal fun parseContentAlignment(raw: String): Alignment { + return when (raw.lowercase(Locale.US)) { + "center" -> Alignment.Center + "top", "topcenter" -> Alignment.TopCenter + "bottom", "bottomcenter" -> Alignment.BottomCenter + "leading", "start", "centerstart", "centerleading" -> Alignment.CenterStart + "trailing", "end", "centerend", "centertrailing" -> Alignment.CenterEnd + "topleading", "topstart" -> Alignment.TopStart + "toptrailing", "topend" -> Alignment.TopEnd + "bottomleading", "bottomstart" -> Alignment.BottomStart + "bottomtrailing", "bottomend" -> Alignment.BottomEnd + else -> Alignment.TopStart + } +} + +internal fun parseHorizontalStackAlignment(raw: String): Alignment.Horizontal { + return when (raw.lowercase(Locale.US)) { + "center", "middle" -> Alignment.CenterHorizontally + "trailing", "end", "right" -> Alignment.End + else -> Alignment.Start + } +} + +internal fun parseVerticalStackAlignment(raw: String): Alignment.Vertical { + return when (raw.lowercase(Locale.US)) { + "top" -> Alignment.Top + "bottom" -> Alignment.Bottom + else -> Alignment.CenterVertically + } +} + +internal fun parseButtonContentAlignment(raw: String): Alignment { + return when (raw.lowercase(Locale.US)) { + "center", "middle" -> Alignment.Center + "trailing", "right", "end" -> Alignment.CenterEnd + else -> Alignment.CenterStart + } +} + +internal fun parseContentScale(mode: String): ContentScale { + return when (mode.lowercase(Locale.US)) { + "fill" -> ContentScale.Crop + "fit" -> ContentScale.Fit + "stretch" -> ContentScale.FillBounds + else -> ContentScale.Fit + } +} + +internal fun progressBar(percent: Int): String { + val clamped = percent.coerceIn(0, 100) + val blocks = (clamped / 10).coerceIn(0, 10) + return "[" + "#".repeat(blocks) + "-".repeat(10 - blocks) + "] $clamped%" +} + +internal fun colorProviderArgb(provider: ColorProvider?, context: Context, fallback: Int): Int { + val value: Any = provider?.getColor(context) ?: return fallback + return when (value) { + is Color -> value.toArgb() + is Long -> Color(value).toArgb() + is Int -> value + else -> fallback + } +} + +internal fun formatDateValue(raw: String, style: String): String { + val date = parseIsoDate(raw) ?: return raw + val now = System.currentTimeMillis() + return when (style.lowercase(Locale.US)) { + "time" -> SimpleDateFormat("h:mm a", Locale.US).format(date) + "relative" -> formatRelativeSpan(date.time - now) + "offset" -> formatDuration(date.time - now) + "timer" -> formatDuration(date.time - now) + else -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(date) + } +} + +internal fun formatRelativeSpan(diffMs: Long): String { + val past = diffMs < 0 + val abs = kotlin.math.abs(diffMs) + val sec = abs / 1000 + val min = sec / 60 + val hr = min / 60 + val day = hr / 24 + val mon = day / 30 + val yr = day / 365 + val (n, unit) = when { + yr >= 1L -> yr to if (yr == 1L) "yr" else "yrs" + mon >= 1L -> mon to if (mon == 1L) "mo" else "mos" + day >= 1L -> day to if (day == 1L) "day" else "days" + hr >= 1L -> hr to if (hr == 1L) "hr" else "hrs" + min >= 1L -> min to if (min == 1L) "min" else "mins" + else -> sec to if (sec == 1L) "sec" else "secs" + } + return if (past) "$n $unit ago" else "in $n $unit" +} + +internal fun formatTimerValue(raw: String, counting: String): String { + val date = parseIsoDate(raw) ?: return raw + val now = System.currentTimeMillis() + val diff = if (counting.equals("up", true)) now - date.time else date.time - now + return formatDuration(diff) +} + +internal fun formatDuration(diffMs: Long): String { + val sign = if (diffMs < 0) "-" else "" + val sec = kotlin.math.abs(diffMs) / 1000 + val h = sec / 3600 + val m = (sec % 3600) / 60 + val s = sec % 60 + return "%s%02d:%02d:%02d".format(Locale.US, sign, h, m, s) +} + +internal fun parseIsoDate(raw: String): Date? { + if (raw.isBlank()) return null + val patterns = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mmX", + "yyyy-MM-dd" + ) + for (p in patterns) { + val sdf = SimpleDateFormat(p, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } + val parsed = runCatching { sdf.parse(raw) }.getOrNull() + if (parsed != null) return parsed + } + return raw.toLongOrNull()?.let { Date(it) } +} + +internal fun safeHost(rawUrl: String): String { + return runCatching { Uri.parse(rawUrl).host }.getOrNull().orEmpty().ifBlank { "remote" } +} + +/** Material Symbols ligature for optional Typeface draw. */ +internal fun cfgHash(text: String?): String { + if (text.isNullOrEmpty()) return "null" + return text.hashCode().toUInt().toString(16) +} + +/** + * org.json [JSONObject.optString] returns the literal "null" when the value is JSON null. + * Treat JSON null / "null" / "undefined" as missing so they never render as text. + */ +internal fun JSONObject.widgetString(key: String, fallback: String = ""): String { + if (!has(key) || isNull(key)) return fallback + val raw = opt(key) ?: return fallback + if (raw === JSONObject.NULL) return fallback + val s = when (raw) { + is String -> raw + else -> raw.toString() + }.trim() + if (s.isEmpty() || s.equals("null", ignoreCase = true) || s.equals("undefined", ignoreCase = true)) { + return fallback + } + return s +} + +internal fun dpToPx(context: Context, dp: Float): Int { + return (dp * context.resources.displayMetrics.density).toInt().coerceAtLeast(1) +} diff --git a/android/src/main/java/TauriGlanceWidget.kt b/android/src/main/java/TauriGlanceWidget.kt index b5fb737..9cebeb8 100644 --- a/android/src/main/java/TauriGlanceWidget.kt +++ b/android/src/main/java/TauriGlanceWidget.kt @@ -2,86 +2,26 @@ package git.s00d.widgets import android.appwidget.AppWidgetManager import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.LinearGradient -import android.graphics.Paint -import android.graphics.Path -import android.graphics.RectF -import android.graphics.Shader -import android.graphics.Typeface -import android.net.Uri -import android.util.Base64 import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences import androidx.glance.GlanceModifier -import androidx.glance.Image -import androidx.glance.ImageProvider import androidx.glance.currentState -import androidx.glance.action.ActionParameters -import androidx.glance.action.actionParametersOf -import androidx.glance.action.clickable import androidx.glance.appwidget.AppWidgetId import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.action.actionRunCallback -import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent -import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Box -import androidx.glance.layout.Column -import androidx.glance.layout.ColumnScope -import androidx.glance.layout.ContentScale -import androidx.glance.layout.Row -import androidx.glance.layout.RowScope -import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.fillMaxHeight -import androidx.glance.layout.height import androidx.glance.layout.padding -import androidx.glance.layout.size -import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.Text -import androidx.glance.text.TextAlign -import androidx.glance.text.TextStyle -import androidx.glance.text.FontWeight -import androidx.glance.unit.ColorProvider -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.stringPreferencesKey import org.json.JSONArray import org.json.JSONObject -import java.text.SimpleDateFormat -import java.util.Collections -import java.util.Date -import java.util.Locale -import java.util.TimeZone - -private const val GLANCE_CONTAINER_LIMIT = 10 -private const val GLANCE_LIST_LIMIT = 50 -private const val CONFIG_STATE_KEY_NAME = "__widget_config_state__" -private const val NONCE_STATE_KEY_NAME = "__widget_nonce_state__" -private const val TAG = "TauriGlanceWidget" -// accessOrder LRU — all get/put/evict must run under the same lock -private val BASE64_CACHE: MutableMap = - Collections.synchronizedMap(LinkedHashMap(64, 0.75f, true)) -private val CONFIG_STATE_KEY = stringPreferencesKey(CONFIG_STATE_KEY_NAME) -private val NONCE_STATE_KEY = stringPreferencesKey(NONCE_STATE_KEY_NAME) -@Volatile private var LAST_ROOT_HASH: String = "" -@Volatile private var LAST_ROOT_SOURCE: String = "" -/** Per-render luminance override — ThreadLocal so concurrent Glance paints don't cross-contaminate. */ -private val WIDGET_SURFACE_DARK = ThreadLocal() -private const val GLANCE_LIST_CHUNK = 9 /** Collect rendered / skipped element types for receipts (thread-local per compose pass). */ internal object RenderTrace { @@ -167,123 +107,6 @@ class TauriGlanceWidget : GlanceAppWidget() { } } -private val ACTION_KEY = ActionParameters.Key("action") -private val PAYLOAD_KEY = ActionParameters.Key("payload") -private val URL_KEY = ActionParameters.Key("url") - -class WidgetActionCallback : ActionCallback { - override suspend fun onAction( - context: Context, - glanceId: androidx.glance.GlanceId, - parameters: ActionParameters, - ) { - val appWidgetId = (glanceId as? AppWidgetId)?.appWidgetId ?: -1 - val group = WidgetStoreKeys.resolveGroup(context, appWidgetId) - val action = parameters[ACTION_KEY] - val payload = parameters[PAYLOAD_KEY] - val url = parameters[URL_KEY] - val normalizedUrl = url?.trim().orEmpty().let { - if (it.equals("null", ignoreCase = true) || it.equals("undefined", ignoreCase = true)) "" else it - } - - // Action-driven buttons are the primary flow in widgets. - // Only attempt URL open when no action is provided. - if (!action.isNullOrBlank()) { - val logicalWidgetId = WidgetStoreKeys.resolveWidgetId(context, appWidgetId) - val toggled = applyLocalListToggleIfNeeded(context, glanceId, group, logicalWidgetId, action) - if (toggled) { - Log.d(TAG, "action local list toggle applied action=$action") - } - val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE) - val pendingRaw = prefs.getString(WidgetStoreKeys.PENDING_ACTIONS, "[]") ?: "[]" - val arr = runCatching { JSONArray(pendingRaw) }.getOrElse { JSONArray() } - val obj = JSONObject() - obj.put("action", action) - if (!payload.isNullOrBlank()) obj.put("payload", payload) - obj.put("ts", System.currentTimeMillis()) - obj.put("widgetId", logicalWidgetId) - obj.put("group", group) - arr.put(obj) - prefs.edit().putString(WidgetStoreKeys.PENDING_ACTIONS, arr.toString()).apply() - - val plugin = WidgetBridgePlugin.pluginInstance - if (plugin != null) { - plugin.emitWidgetAction(action, payload, logicalWidgetId, group) - } - } else if (normalizedUrl.isNotBlank()) { - val uri = Uri.parse(normalizedUrl) - val intent = Intent(Intent.ACTION_VIEW, uri).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - val canHandle = intent.resolveActivity(context.packageManager) != null - if (canHandle) { - runCatching { context.startActivity(intent) } - .onFailure { e -> Log.e(TAG, "action open url failed=${e.message} url=$normalizedUrl", e) } - } else { - Log.w(TAG, "action open url skipped: no handler url=$normalizedUrl") - } - } - - runCatching { TauriGlanceWidget().update(context, glanceId) } - .onFailure { e -> Log.e(TAG, "action-triggered update failed=${e.message}", e) } - } - - private suspend fun applyLocalListToggleIfNeeded( - context: Context, - glanceId: androidx.glance.GlanceId, - group: String, - logicalWidgetId: String, - action: String, - ): Boolean { - val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE) - val raw = prefs.getString(WidgetStoreKeys.configKey(logicalWidgetId), null) ?: return false - val root = runCatching { JSONObject(raw) }.getOrNull() ?: return false - var changed = false - listOf("small", "medium", "large").forEach { key -> - changed = toggleListItemsInElement(root.optJSONObject(key), action) || changed - } - if (!changed) return false - val updated = root.toString() - prefs.edit().putString(WidgetStoreKeys.configKey(logicalWidgetId), updated).apply() - runCatching { - updateAppWidgetState(context, glanceId) { state -> - state[CONFIG_STATE_KEY] = updated - state[NONCE_STATE_KEY] = System.currentTimeMillis().toString() - } - }.onFailure { e -> - Log.e(TAG, "local toggle update state failed=${e.message}", e) - } - return true - } - - private fun toggleListItemsInElement(el: JSONObject?, action: String): Boolean { - if (el == null) return false - var changed = false - if (el.widgetString("type", "") == "list") { - val items = el.optJSONArray("items") ?: JSONArray() - for (i in 0 until items.length()) { - val item = items.optJSONObject(i) ?: continue - if (item.widgetString("action", "") != action) continue - val hasChecked = item.has("checked") - val hasIsOn = item.has("isOn") - if (!hasChecked && !hasIsOn) continue - val current = item.optBoolean("checked", item.optBoolean("isOn", false)) - val next = !current - if (hasChecked || !hasIsOn) item.put("checked", next) - if (hasIsOn) item.put("isOn", next) - changed = true - } - } - val children = el.optJSONArray("children") - if (children != null) { - for (i in 0 until children.length()) { - changed = toggleListItemsInElement(children.optJSONObject(i), action) || changed - } - } - return changed - } -} - @Composable internal fun WidgetRoot( context: Context, @@ -336,15 +159,10 @@ private fun WidgetRootBody( nonce: Long = 0L, ) { RenderTrace.begin() - val combinedHash = cfgHash(effectiveConfig) + ":" + (stateNonce ?: "no_nonce") - if (combinedHash != LAST_ROOT_HASH || source != LAST_ROOT_SOURCE) { - LAST_ROOT_HASH = combinedHash - LAST_ROOT_SOURCE = source - Log.d( - TAG, - "WidgetRoot source=$source size=$size cfgHash=${cfgHash(effectiveConfig)} nonce=${stateNonce ?: "null"} len=${effectiveConfig?.length ?: 0}" - ) - } + Log.d( + TAG, + "WidgetRoot source=$source size=$size cfgHash=${cfgHash(effectiveConfig)} nonce=${stateNonce ?: "null"} len=${effectiveConfig?.length ?: 0}" + ) if (effectiveConfig.isNullOrBlank()) { Log.w(TAG, "WidgetRoot no configuration") @@ -385,7 +203,7 @@ private fun WidgetRootBody( // fillMaxSize on Column often collapses later siblings in Glance RemoteViews — // host Box fills the frame; content column only stretches width. Box(modifier = GlanceModifier.fillMaxSize()) { - RenderElement(context, element, GlanceModifier.fillMaxWidth().fillMaxHeight(), size) + RenderElement(RenderScope(context, size), El(element), GlanceModifier.fillMaxWidth().fillMaxHeight()) } WIDGET_SURFACE_DARK.remove() @@ -406,1892 +224,3 @@ private fun WidgetRootBody( WidgetReceipts.write(context, receipt) } } - -@Composable -internal fun RenderElement( - context: Context, - el: JSONObject, - modifier: GlanceModifier = GlanceModifier, - sizeFamily: String = "small", - inHorizontal: Boolean = false, -) { - val type = el.widgetString("type") - RenderTrace.type(type) - val spacing = el.optDouble("spacing", 0.0).toInt().coerceAtLeast(0) - val baseModifier = applyCommonStyle(context, modifier, el) - when (type) { - "vstack" -> { - val hAlign = parseHorizontalStackAlignment(el.widgetString("alignment", "")) - // Inside a Row, fillMaxWidth steals space from trailing spacer/button. - val colMod = if (inHorizontal) baseModifier else baseModifier.fillMaxWidth() - Column( - modifier = colMod, - horizontalAlignment = hAlign - ) { - renderChildrenVertical( - context, - el.optJSONArray("children"), - spacing, - sizeFamily = sizeFamily, - expandWidth = !inHorizontal, - ) - } - } - "hstack" -> { - val vAlign = parseVerticalStackAlignment(el.widgetString("alignment", "")) - Row( - modifier = baseModifier.fillMaxWidth(), - verticalAlignment = vAlign - ) { - renderChildrenHorizontal(context, el.optJSONArray("children"), spacing, sizeFamily) - } - } - "zstack" -> { - val alignment = parseContentAlignment(el.widgetString("alignment", "center").ifBlank { "center" }) - // fillMaxSize inside a Row steals all remaining width/height and zeros siblings - // (nested-dashboard badge crushed the title/button/body). Wrap in hstacks. - val boxMod = if (inHorizontal) baseModifier else baseModifier.fillMaxWidth() - Box(modifier = boxMod, contentAlignment = alignment) { - val children = el.optJSONArray("children") - if (children != null && children.length() > 0) { - for (i in 0 until children.length()) { - val child = children.optJSONObject(i) ?: continue - // Do not fillMaxSize on children — that breaks Box contentAlignment. - RenderElement(context, child, GlanceModifier, sizeFamily, inHorizontal) - } - } - } - } - "container" -> { - val alignment = parseContentAlignment(el.widgetString("contentAlignment", "center")) - Box(modifier = baseModifier, contentAlignment = alignment) { - val children = el.optJSONArray("children") - when { - children != null && children.length() == 1 -> { - // Single child: let Box contentAlignment position it (no full-width Column). - RenderElement( - context, - children.getJSONObject(0), - GlanceModifier, - sizeFamily, - inHorizontal = false, - ) - } - children != null && children.length() > 0 -> { - Column( - modifier = GlanceModifier, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - renderChildrenVertical( - context, - children, - spacing, - sizeFamily = sizeFamily, - expandWidth = false, - ) - } - } - else -> { - val elType = el.opt("type")?.toString().orEmpty() - Log.w(TAG, "container has no children; elType=$elType") - } - } - } - } - "grid" -> { - val children = el.optJSONArray("children") ?: JSONArray() - val cols = el.optInt("columns", 2).coerceAtLeast(1) - val rowSpacing = el.optDouble("rowSpacing", spacing.toDouble()).toInt().coerceAtLeast(0) - Column(modifier = baseModifier.fillMaxWidth()) { - val maxRows = GLANCE_CONTAINER_LIMIT - val visibleCols = cols.coerceAtMost(GLANCE_CONTAINER_LIMIT) - val maxChildren = maxRows * visibleCols - if (children.length() > maxChildren) { - Log.w( - TAG, - "grid truncated: requested=${children.length()} rendered=$maxChildren cols=$cols visibleCols=$visibleCols" - ) - } - var i = 0 - var rowIdx = 0 - while (i < children.length() && i < maxChildren) { - if (rowIdx > 0 && rowSpacing > 0) { - Spacer(GlanceModifier.height(rowSpacing.dp)) - } - Row(modifier = GlanceModifier.fillMaxWidth()) { - for (c in 0 until visibleCols) { - if (c > 0 && spacing > 0) { - Spacer(GlanceModifier.width(spacing.dp)) - } - val child = children.optJSONObject(i + c) - if (child != null) { - Box(modifier = GlanceModifier.defaultWeight()) { - RenderElement(context, child, GlanceModifier.fillMaxWidth(), sizeFamily, inHorizontal = true) - } - } else { - Spacer(GlanceModifier.defaultWeight()) - } - } - } - i += visibleCols - rowIdx += 1 - } - } - } - "text", "date", "timer" -> { - val content = when (type) { - "date" -> formatDateValue(el.widgetString("date", ""), el.widgetString("dateStyle", "date")) - "timer" -> formatTimerValue( - el.widgetString("targetDate", ""), - el.widgetString("counting", "down") - ) - else -> el.widgetString("content", el.widgetString("text", "")) - } - renderElementText(context, el, content, baseModifier) - } - "image" -> { - val size = el.optDouble("size", 24.0).toInt().coerceAtLeast(1) - val imageModifier = baseModifier.size(size.dp) - val provider = imageProviderFromElement(context, el) - if (provider != null) { - Image( - provider = provider, - contentDescription = el.widgetString("alt", ""), - modifier = imageModifier, - contentScale = parseContentScale(el.widgetString("contentMode", "fit")) - ) - } else { - val systemName = el.widgetString("systemName", "") - val url = el.widgetString("url", "") - val fallback = when { - systemName.isNotBlank() -> iconGlyph(systemName) - url.isNotBlank() -> "img:${safeHost(url)}" - else -> "img" - } - val tint = colorProviderArgb( - resolveColorProvider(context, el.opt("color")), - context, - semanticLabelProvider(context).getColor(context).toArgb() - ) - val glyphBitmap = drawGlyphBitmap(context, fallback, size, tint) - if (glyphBitmap != null) { - Image( - provider = ImageProvider(glyphBitmap), - contentDescription = systemName.ifBlank { "image" }, - modifier = imageModifier, - contentScale = ContentScale.Fit - ) - } else { - val baseStyle = textStyleFromElement(context, el) - val colored = resolveColorProvider(context, el.opt("color")) - val styled = if (colored != null) { - TextStyle(color = colored, fontSize = size.sp, fontWeight = baseStyle.fontWeight, textAlign = baseStyle.textAlign) - } else { - TextStyle(fontSize = size.sp, fontWeight = baseStyle.fontWeight, textAlign = baseStyle.textAlign) - } - Text(fallback, modifier = baseModifier, style = styled) - } - } - } - "progress", "gauge" -> { - val value = el.optDouble("value", 0.0) - val minV = if (type == "gauge") el.optDouble("min", 0.0) else 0.0 - val maxV = if (type == "gauge") { - el.optDouble("max", 1.0) - } else { - el.optDouble("total", 1.0) - }.coerceAtLeast(minV + 0.0001) - val pct = (((value - minV) / (maxV - minV)) * 100).toInt().coerceIn(0, 100) - val barStyle = el.widgetString("barStyle", "linear").lowercase(Locale.US) - val gaugeStyle = el.widgetString("gaugeStyle", "circular").lowercase(Locale.US) - val expand = when { - type == "gauge" && gaugeStyle == "linear" -> true - type != "gauge" && barStyle != "circular" -> true - else -> false - } - val colMod = if (expand) baseModifier.fillMaxWidth() else baseModifier - Column(modifier = colMod, horizontalAlignment = Alignment.CenterHorizontally) { - // Gauge: labels drawn inside bitmap. Progress: optional external label (linear only). - if (type != "gauge" && barStyle != "circular") { - el.widgetString("label", "").takeIf { it.isNotBlank() }?.let { lbl -> - val labelEl = JSONObject(el.toString()).apply { - if (!has("color") || isNull("color")) { - opt("tint")?.let { put("color", it) } - } - put("fontSize", optDouble("fontSize", 10.0)) - } - Text(lbl, style = textStyleFromElement(context, labelEl)) - } - } - val bmp = if (type == "gauge") drawGaugeBitmap(context, el, pct) else drawProgressBitmap(context, el, pct) - if (bmp != null) { - when { - type == "gauge" && gaugeStyle == "linear" -> { - Image( - provider = ImageProvider(bmp), - contentDescription = type, - modifier = GlanceModifier.fillMaxWidth().height(28.dp), - contentScale = ContentScale.Fit - ) - } - type == "gauge" -> { - val sizeDp = el.optJSONObject("frame")?.optDouble("width", 72.0)?.toInt()?.coerceAtLeast(1) ?: 72 - val hasLabel = el.widgetString("label", "").isNotBlank() - val hDp = if (hasLabel) (sizeDp * 1.24f).toInt() else sizeDp - Image( - provider = ImageProvider(bmp), - contentDescription = type, - modifier = GlanceModifier.width(sizeDp.dp).height(hDp.dp), - contentScale = ContentScale.Fit - ) - } - barStyle == "circular" -> { - Image( - provider = ImageProvider(bmp), - contentDescription = type, - modifier = GlanceModifier.size(40.dp), - contentScale = ContentScale.Fit - ) - } - else -> { - Image( - provider = ImageProvider(bmp), - contentDescription = type, - modifier = GlanceModifier.fillMaxWidth().height(12.dp), - contentScale = ContentScale.Fit - ) - } - } - } else { - renderElementText(context, el, progressBar(pct)) - } - } - } - "link" -> { - val action = el.widgetString("action", "") - val payload = el.widgetString("payload", "") - val url = el.widgetString("url", "") - val wrapped = applyAction(baseModifier, action, payload, url) - val children = el.optJSONArray("children") - if (children != null && children.length() > 0) { - Box(modifier = wrapped) { - for (i in 0 until children.length()) { - val child = children.optJSONObject(i) ?: continue - RenderElement(context, child, GlanceModifier, sizeFamily, inHorizontal) - } - } - } else { - renderElementText(context, el, el.widgetString("label", "link"), wrapped) - } - } - "toggle" -> { - val label = mediaGlyphFallback( - el.widgetString("label", el.widgetString("content", "toggle")) - ) - val isOn = el.optBoolean("isOn", false) - val action = el.widgetString("action", "") - val payload = el.widgetString("payload", "") - val url = el.widgetString("url", "") - val actionModifier = applyAction(baseModifier, action, payload, url) - val tint = resolveColorProvider(context, el.opt("tint")) - ?: ColorProvider(Color(0xFF4CAF50)) - val ink = resolveColorProvider(context, el.opt("color")) - ?: ColorProvider(if (surfaceIsDark(context)) Color(0xFFE2E8F0) else Color(0xFF111111)) - val glyph = if (isOn) "\u2713" else "\u25CB" - Row( - modifier = actionModifier.padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - glyph, - style = TextStyle( - color = if (isOn) tint else ColorProvider(Color(0xFF999999)), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - ) - Spacer(GlanceModifier.width(6.dp)) - Text(label, style = TextStyle(color = ink, fontSize = 14.sp)) - } - } - "button" -> { - val labelRaw = el.widgetString("label", el.widgetString("content", "button")) - val label = mediaGlyphFallback(labelRaw) - val action = el.widgetString("action", "") - val payload = el.widgetString("payload", "") - val url = el.widgetString("url", "") - val bg = resolveColorProvider(context, el.opt("backgroundColor")) ?: resolveColorProvider(context, el.opt("tint")) - val actionModifier = applyAction(baseModifier, action, payload, url) - val text = label - val buttonAlignRaw = el.widgetString("textAlignment", el.widgetString("alignment", "center")) - val textElement = JSONObject(el.toString()).apply { put("alignment", buttonAlignRaw) } - val contentAlign = parseButtonContentAlignment(buttonAlignRaw) - val radius = el.optDouble("cornerRadius", -1.0) - var boxMod = actionModifier - if (bg != null) boxMod = boxMod.background(bg) - if (radius >= 0) boxMod = boxMod.cornerRadius(radius.toInt().coerceAtLeast(0).dp) - // Honor DSL padding as content insets via applyCommonStyle; default only when absent. - val pad = el.opt("padding") - boxMod = if (pad == null || pad === JSONObject.NULL) { - boxMod.padding(horizontal = 10.dp, vertical = 6.dp) - } else { - // Explicit padding already applied on baseModifier by applyCommonStyle. - boxMod - } - if (bg != null) { - Box(modifier = boxMod) { - if (el.has("textAlignment")) { - Box(modifier = GlanceModifier.fillMaxWidth(), contentAlignment = contentAlign) { - renderElementText(context, textElement, text) - } - } else { - renderElementText(context, textElement, text) - } - } - } else { - if (el.has("textAlignment")) { - Box(modifier = actionModifier.fillMaxWidth(), contentAlignment = contentAlign) { - renderElementText(context, textElement, text) - } - } else { - renderElementText(context, textElement, text, actionModifier) - } - } - } - "divider" -> { - val line = resolveColorProvider(context, el.opt("color")) ?: ColorProvider(Color.Gray) - // RemoteViews often collapses 1.dp empty boxes — keep ≥2dp. - val thicknessDp = el.optDouble("thickness", 1.0).toFloat().coerceAtLeast(2f) - val thickness = thicknessDp.dp - if (inHorizontal) { - Box( - modifier = baseModifier - .width(thickness) - .height(48.dp) - .background(line) - ) {} - } else { - // Built-in vertical breath so section separators never sit flush against - // neighbors (parent Column spacing alone still looked tight on Glance). - val edge = 4.dp - Column(modifier = baseModifier.fillMaxWidth()) { - Spacer(GlanceModifier.height(edge)) - Box( - modifier = GlanceModifier - .fillMaxWidth() - .height(thickness) - .background(line) - ) {} - Spacer(GlanceModifier.height(edge)) - } - } - } - "spacer" -> { - // Flex / no-minLength spacers keep Column/Row weight; fixed height would kill flex. - if (el.has("minLength")) { - val h = el.optDouble("minLength", 8.0).toInt().coerceAtLeast(1) - Spacer(baseModifier.height(h.dp)) - } else { - Spacer(baseModifier) - } - } - "chart" -> { - renderChartElement(context, baseModifier, el) - } - "canvas" -> { - renderCanvasElement(context, baseModifier, el) - } - "shape" -> { - val size = el.optDouble("size", 18.0).toInt().coerceAtLeast(1) - val shapeType = el.widgetString("shapeType", "circle") - val isCapsule = shapeType.equals("capsule", ignoreCase = true) - // Capsule contract: width = 2*size, height = size (not square) - val shapeModifier = if (isCapsule) { - baseModifier.width((size * 2).dp).height(size.dp) - } else { - baseModifier.size(size.dp) - } - val shapeBmp = drawShapeBitmap(context, el, size) - if (shapeBmp != null) { - Image( - provider = ImageProvider(shapeBmp), - contentDescription = shapeType, - modifier = shapeModifier, - contentScale = ContentScale.FillBounds - ) - } else { - val symbol = when (shapeType.lowercase(Locale.US)) { - "capsule" -> "[====]" - "rectangle" -> "[##]" - else -> "(o)" - } - Text(symbol, modifier = shapeModifier, style = textStyleFromElement(context, el)) - } - } - "label" -> { - val txt = el.widgetString("text", "") - val icon = iconGlyph(el.widgetString("systemName", "*")) - val iconColor = resolveColorProvider(context, el.opt("iconColor")) - if (iconColor != null) { - Row(modifier = baseModifier.fillMaxWidth()) { - Text(icon, style = TextStyle(color = iconColor)) - renderElementText(context, el, " $txt") - } - } else { - renderElementText(context, el, "$icon $txt", baseModifier) - } - } - "list" -> { - // Column (not LazyColumn): home-screen lists are short, and LazyColumn - // often yields zero children under GlanceRemoteViews / Robolectric. - // Glance Column hard-caps ~10 children — chunk into nested Columns. - val items = el.optJSONArray("items") ?: JSONArray() - val itemList = mutableListOf() - for (i in 0 until items.length()) { - items.optJSONObject(i)?.let { itemList += it } - if (itemList.size >= GLANCE_LIST_LIMIT) break - } - val truncated = items.length() - itemList.size - if (truncated > 0) { - Log.w( - TAG, - "list items truncated: requested=${items.length()} rendered=${itemList.size} limit=$GLANCE_LIST_LIMIT" - ) - } - val anyCheckbox = itemList.any { it.has("checked") || it.has("isOn") } - val chunks = itemList.chunked(GLANCE_LIST_CHUNK) - Column(modifier = baseModifier.fillMaxWidth()) { - chunks.forEachIndexed { chunkIdx, chunk -> - Column(modifier = GlanceModifier.fillMaxWidth()) { - chunk.forEachIndexed { index, item -> - if (index > 0 && spacing > 0) { - Spacer(GlanceModifier.height(spacing.dp)) - } else if (index == 0 && chunkIdx > 0 && spacing > 0) { - Spacer(GlanceModifier.height(spacing.dp)) - } - val text = item.widgetString("text", item.widgetString("content", "")) - val hasCheckbox = item.has("checked") || item.has("isOn") - val checked = item.optBoolean("checked", false) || item.optBoolean("isOn", false) - val action = item.widgetString("action", "") - val payload = item.widgetString("payload", "") - val rowMod = applyAction(GlanceModifier.fillMaxWidth(), action, payload, "") - val prefix = when { - hasCheckbox && checked -> "✓ " - hasCheckbox -> "○ " - anyCheckbox -> " " - else -> "" - } - renderElementText(context, el, "$prefix$text", rowMod) - } - } - } - if (truncated > 0) { - if (spacing > 0) Spacer(GlanceModifier.height(spacing.dp)) - Text( - "+$truncated more", - style = TextStyle(color = semanticSecondaryProvider(context), fontSize = 11.sp) - ) - } - } - } - else -> { - if (type.isNotBlank()) RenderTrace.skip(type, "unsupported") - renderElementText(context, el, type, baseModifier) - } - } -} - -@Composable -private fun ColumnScope.renderChildrenVertical( - context: Context, - children: JSONArray?, - spacing: Int, - sizeFamily: String = "small", - expandWidth: Boolean = true, -) { - if (children == null) return - val visible = mutableListOf() - for (i in 0 until children.length()) { - val child = children.optJSONObject(i) ?: continue - visible += child - if (visible.size >= GLANCE_CONTAINER_LIMIT) break - } - if (children.length() > visible.size) { - Log.w( - TAG, - "column children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT" - ) - } - if (visible.isEmpty()) return - - for (idx in visible.indices) { - if (idx > 0 && spacing > 0) { - // Spacer between children — padding(bottom) on thin dividers often collapses in RemoteViews. - Spacer(GlanceModifier.height(spacing.dp)) - } - val child = visible[idx] - val flex = child.optDouble("flex", 0.0) - val type = child.widgetString("type", "") - // Buttons/images/shapes hug content — fillMaxWidth makes Tap a full-bleed bar. - val hugWidth = type in setOf("button", "toggle", "image", "shape", "label") - val widthMod = when { - hugWidth -> GlanceModifier - expandWidth -> GlanceModifier.fillMaxWidth() - else -> GlanceModifier - } - val base = when { - flex > 0.0 -> widthMod.defaultWeight() - type == "spacer" && !child.has("minLength") -> widthMod.defaultWeight() - type == "spacer" -> { - val h = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1) - widthMod.height(h.dp) - } - else -> widthMod - } - RenderElement(context, child, base, sizeFamily) - } -} - -@Composable -private fun RowScope.renderChildrenHorizontal( - context: Context, - children: JSONArray?, - spacing: Int, - sizeFamily: String, -) { - if (children == null) return - val visible = mutableListOf() - for (i in 0 until children.length()) { - val child = children.optJSONObject(i) ?: continue - visible += child - if (visible.size >= GLANCE_CONTAINER_LIMIT) break - } - if (children.length() > visible.size) { - Log.w( - TAG, - "row children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT" - ) - } - val hasFlex = visible.any { it.optDouble("flex", 0.0) > 0.0 } - val allButtons = visible.isNotEmpty() && visible.all { - it.widgetString("type", "") in setOf("button", "toggle") - } - for (idx in visible.indices) { - val child = visible[idx] - if (idx > 0 && spacing > 0) { - Spacer(GlanceModifier.width(spacing.dp)) - } - val t = child.widgetString("type", "") - when (t) { - "divider" -> { - val th = child.optDouble("thickness", 1.0).toFloat().coerceAtLeast(2f).toInt() - Box(modifier = GlanceModifier.width(th.dp).height(48.dp)) { - RenderElement(context, child, GlanceModifier.fillMaxSize(), sizeFamily, inHorizontal = true) - } - } - "spacer" -> { - val min = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1) - val flex = child.optDouble("flex", 0.0) - val base = if (flex > 0.0 || !child.has("minLength")) { - GlanceModifier.defaultWeight() - } else { - GlanceModifier.width(min.dp) - } - Spacer(modifier = base) - } - else -> { - // Only flex / expanding spacer / equal buttons take weight. - // Title columns wrap to intrinsic width so labels aren't ellipsized - // while the trailing spacer still pushes Sync to the end. - val wantsWeight = when { - child.optDouble("flex", 0.0) > 0.0 -> true - hasFlex -> false - allButtons -> true - else -> false - } - val base = if (wantsWeight) GlanceModifier.defaultWeight() else GlanceModifier - Box(modifier = base) { - RenderElement(context, child, GlanceModifier, sizeFamily, inHorizontal = true) - } - } - } - } -} - -private fun applyAction(modifier: GlanceModifier, action: String, payload: String, url: String): GlanceModifier { - if (action.isBlank() && url.isBlank()) return modifier - return modifier.clickable( - actionRunCallback( - actionParametersOf( - ACTION_KEY to action, - PAYLOAD_KEY to payload, - URL_KEY to url - ) - ) - ) -} - -private fun applyCommonStyle(context: Context, modifier: GlanceModifier, el: JSONObject): GlanceModifier { - var m = modifier - - val chrome = buildStyleChromeBitmap(context, el) - if (chrome != null) { - m = m.background(ImageProvider(chrome)) - } else { - val bg = resolveBackgroundProvider(context, el.opt("background")) - if (bg != null) m = m.background(bg) - } - - val frame = el.optJSONObject("frame") - if (frame != null) { - val w = frame.optDouble("width", -1.0) - val h = frame.optDouble("height", -1.0) - if (w > 0) m = m.width(w.toInt().dp) - if (h > 0) m = m.height(h.toInt().dp) - } - - val p = el.opt("padding") - if (p is Number) { - m = m.padding(p.toInt().dp) - } else if (p is JSONObject) { - val top = p.optDouble("top", 0.0).toInt().dp - val bottom = p.optDouble("bottom", 0.0).toInt().dp - val start = p.optDouble("leading", p.optDouble("start", 0.0)).toInt().dp - val end = p.optDouble("trailing", p.optDouble("end", 0.0)).toInt().dp - m = m.padding(start = start, top = top, end = end, bottom = bottom) - } - - val explicitRadius = el.optDouble("cornerRadius", -1.0).toFloat() - val clipShape = el.widgetString("clipShape", "").lowercase(Locale.US) - val frameW = frame?.optDouble("width", -1.0)?.toFloat() ?: -1f - val frameH = frame?.optDouble("height", -1.0)?.toFloat() ?: -1f - val clipRadius = when { - explicitRadius > 0f -> explicitRadius - clipShape == "circle" -> { - val base = listOf(frameW, frameH, el.optDouble("size", -1.0).toFloat()) - .filter { it > 0f } - .minOrNull() ?: 24f - (base / 2f).coerceAtLeast(1f) - } - clipShape == "capsule" -> { - val base = listOf(frameW, frameH, el.optDouble("size", -1.0).toFloat()) - .filter { it > 0f } - .minOrNull() ?: 28f - (base / 2f).coerceAtLeast(1f) - } - clipShape == "rectangle" -> 0f - else -> 0f - } - if (clipRadius > 0f) { - m = m.cornerRadius(clipRadius.dp) - } - - return m -} - -/** - * Bake gradient / border / soft shadow into a bitmap background for Glance - * (no live gradient brush or shadow APIs). - */ -private fun buildStyleChromeBitmap(context: Context, el: JSONObject): Bitmap? { - val bg = el.opt("background") - val border = el.optJSONObject("border") - val shadow = el.optJSONObject("shadow") - val isGradient = bg is JSONObject && bg.has("colors") - if (!isGradient && border == null && shadow == null) return null - - val frame = el.optJSONObject("frame") - val baseW = frame?.optDouble("width", 200.0)?.toInt()?.coerceAtLeast(48) ?: 200 - val baseH = frame?.optDouble("height", 200.0)?.toInt()?.coerceAtLeast(48) ?: 200 - val shadowPad = if (shadow != null) { - ((shadow.optDouble("radius", 8.0) + kotlin.math.abs(shadow.optDouble("y", 4.0))).toInt() + 4) - .coerceIn(4, 24) - } else 0 - val w = baseW + shadowPad * 2 - val h = baseH + shadowPad * 2 - val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - val radius = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) - val content = RectF( - shadowPad.toFloat(), - shadowPad.toFloat(), - (w - shadowPad).toFloat(), - (h - shadowPad).toFloat() - ) - - if (shadow != null) { - val sx = shadow.optDouble("x", 0.0).toFloat() - val sy = shadow.optDouble("y", 4.0).toFloat() - val shadowColor = parseColor(context, shadow.widgetString("color", "rgba(0,0,0,0.35)")) - ?.toArgb() ?: Color(0x59000000).toArgb() - val shadowRect = RectF( - content.left + sx, - content.top + sy, - content.right + sx, - content.bottom + sy - ) - canvas.drawRoundRect( - shadowRect, - radius, - radius, - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = shadowColor - } - ) - } - - val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - when { - isGradient && bg is JSONObject -> { - val arr = bg.optJSONArray("colors") ?: return null - val stops = mutableListOf() - for (i in 0 until arr.length()) { - val c = parseColor(context, arr.optString(i, ""))?.toArgb() - if (c != null) stops += c - } - if (stops.isEmpty()) return null - if (stops.size == 1) stops += stops[0] - val dir = bg.widgetString("direction", "topToBottom").lowercase(Locale.US) - val (x0, y0, x1, y1) = when (dir) { - "bottomtotop" -> listOf(content.left, content.bottom, content.left, content.top) - "leadingtotrailing" -> listOf(content.left, content.top, content.right, content.top) - "trailingtoleading" -> listOf(content.right, content.top, content.left, content.top) - "topleadingtobottomtrailing" -> listOf(content.left, content.top, content.right, content.bottom) - "toptrailingtobottomleading" -> listOf(content.right, content.top, content.left, content.bottom) - else -> listOf(content.left, content.top, content.left, content.bottom) - } - fillPaint.shader = LinearGradient( - x0, y0, x1, y1, - stops.toIntArray(), - null, - Shader.TileMode.CLAMP - ) - canvas.drawRoundRect(content, radius, radius, fillPaint) - } - bg is String -> { - val c = parseColor(context, bg)?.toArgb() ?: return null - fillPaint.color = c - canvas.drawRoundRect(content, radius, radius, fillPaint) - } - bg is JSONObject && (bg.has("light") || bg.has("dark")) -> { - val pick = if (surfaceIsDark(context)) { - bg.widgetString("dark", bg.widgetString("light", "")) - } else { - bg.widgetString("light", bg.widgetString("dark", "")) - } - val c = parseColor(context, pick)?.toArgb() ?: return null - fillPaint.color = c - canvas.drawRoundRect(content, radius, radius, fillPaint) - } - else -> { - // border/shadow only — keep transparent fill - } - } - - if (border != null) { - val bw = border.optDouble("width", 1.0).toFloat().coerceAtLeast(1f) - val bc = parseColor(context, border.widgetString("color", "#FFFFFF"))?.toArgb() - ?: Color.White.toArgb() - val inset = bw / 2f - val strokeRect = RectF( - content.left + inset, - content.top + inset, - content.right - inset, - content.bottom - inset - ) - canvas.drawRoundRect( - strokeRect, - (radius - inset).coerceAtLeast(0f), - (radius - inset).coerceAtLeast(0f), - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = bw - color = bc - } - ) - } - return bmp -} - -private fun resolveBackgroundProvider(context: Context, value: Any?): ColorProvider? { - if (value == null) return null - if (value is String) return colorProviderFromString(context, value) - if (value !is JSONObject) return null - - if (value.has("light") || value.has("dark")) { - val isDark = surfaceIsDark(context) - val pick = if (isDark) value.widgetString("dark", value.widgetString("light", "")) else value.widgetString("light", value.widgetString("dark", "")) - return colorProviderFromString(context, pick) - } - if (value.has("colors")) { - val arr = value.optJSONArray("colors") - if (arr != null && arr.length() > 0) { - // Glance has no gradient brush here — degrade to first stop explicitly. - Log.d(TAG, "background gradient degraded to first color stop (Glance limitation)") - return colorProviderFromString(context, arr.optString(0, "")) - } - } - return null -} - -private fun resolveColorProvider(context: Context, value: Any?): ColorProvider? { - if (value == null) return null - if (value is String) return colorProviderFromString(context, value) - if (value is JSONObject) { - if (value.has("light") || value.has("dark")) { - val isDark = surfaceIsDark(context) - val pick = if (isDark) value.widgetString("dark", value.widgetString("light", "")) else value.widgetString("light", value.widgetString("dark", "")) - return colorProviderFromString(context, pick) - } - } - return null -} - -private fun colorProviderFromString(context: Context, raw: String): ColorProvider? { - val c = parseColor(context, raw) ?: return null - return ColorProvider(c) -} - -private fun parseColor(context: Context, raw: String): Color? { - val s0 = raw.trim() - if (s0.isEmpty()) return null - val semantic = semanticColor(context, s0) - if (semantic != null) return semantic - val rgba = Regex( - """rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)""", - RegexOption.IGNORE_CASE - ).matchEntire(s0) - if (rgba != null) { - val r = rgba.groupValues[1].toFloat().coerceIn(0f, 255f) / 255f - val g = rgba.groupValues[2].toFloat().coerceIn(0f, 255f) / 255f - val b = rgba.groupValues[3].toFloat().coerceIn(0f, 255f) / 255f - val a = rgba.groupValues.getOrNull(4)?.takeIf { it.isNotBlank() }?.toFloat()?.coerceIn(0f, 1f) ?: 1f - return Color(r, g, b, a) - } - // Expand #RGB / #RGBA → full form (android.graphics.Color.parseColor rejects short hex). - val s = expandShortHex(s0) - return runCatching { - when { - s.startsWith("#") -> Color(android.graphics.Color.parseColor(s)) - s.equals("white", true) -> Color.White - s.equals("black", true) -> Color.Black - s.equals("red", true) -> Color(0xFFE53935) - s.equals("green", true) -> Color(0xFF43A047) - s.equals("blue", true) -> Color(0xFF1E88E5) - s.equals("gray", true) || s.equals("grey", true) -> Color.Gray - else -> Color(android.graphics.Color.parseColor(s)) - } - }.getOrNull() -} - -/** `#abc` → `#aabbcc`, `#abcd` → `#aabbccdd` (alpha last in CSS; Android wants AARRGGBB). */ -private fun expandShortHex(raw: String): String { - if (!raw.startsWith("#")) return raw - val h = raw.substring(1) - return when (h.length) { - 3 -> "#" + h.map { "$it$it" }.joinToString("") - 4 -> { - // CSS #RGBA → Android #AARRGGBB - val r = "${h[0]}${h[0]}" - val g = "${h[1]}${h[1]}" - val b = "${h[2]}${h[2]}" - val a = "${h[3]}${h[3]}" - "#$a$r$g$b" - } - else -> raw - } -} - -private fun semanticColor(context: Context, raw: String): Color? { - val dark = surfaceIsDark(context) - return when (raw.lowercase(Locale.US)) { - "label" -> if (dark) Color(0xFFF2F2F7) else Color(0xFF111111) - "secondarylabel" -> if (dark) Color(0xFFC7C7CC) else Color(0xFF555555) - "separator" -> if (dark) Color(0xFF3A3A3C) else Color(0xFFD1D1D6) - "systembackground" -> if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - "accent", "systemblue" -> if (dark) Color(0xFF89B4FA) else Color(0xFF2563EB) - "systemred" -> if (dark) Color(0xFFF87171) else Color(0xFFDC2626) - "systemgreen" -> if (dark) Color(0xFF86EFAC) else Color(0xFF16A34A) - "systemorange" -> if (dark) Color(0xFFFBBF24) else Color(0xFFEA580C) - "systempurple" -> if (dark) Color(0xFFC4B5FD) else Color(0xFF7C3AED) - else -> null - } -} - -private fun isDarkMode(context: Context): Boolean { - val mode = context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK - return mode == android.content.res.Configuration.UI_MODE_NIGHT_YES -} - -/** Prefer widget background luminance; fall back to system night mode. */ -private fun surfaceIsDark(context: Context): Boolean { - return WIDGET_SURFACE_DARK.get() ?: isDarkMode(context) -} - -private fun inferSurfaceDark(context: Context, background: Any?): Boolean? { - if (background == null || background === JSONObject.NULL) return null - when (background) { - is String -> { - val s = background.trim() - if (s.equals("null", true) || s.equals("undefined", true) || s.isEmpty()) return null - if (s.equals("systemBackground", true) || s.equals("adaptive", true)) return null - val c = parseColor(context, s) ?: return null - return luminanceOf(c) < 0.45f - } - is JSONObject -> { - // adaptive: { light, dark } — use light for harness luminance; production follows system - val hex = background.widgetString("light", background.widgetString("dark", "")) - if (hex.isBlank()) return null - val c = parseColor(context, hex) ?: return null - return luminanceOf(c) < 0.45f - } - else -> return null - } -} - -private fun luminanceOf(color: Color): Float { - val r = color.red - val g = color.green - val b = color.blue - return 0.2126f * r + 0.7152f * g + 0.0722f * b -} - -/** Track color ≈ tint @ 25% alpha over transparent (matches desktop tintTrack). */ -private fun trackColorFromTint(tintArgb: Int, surfaceDark: Boolean): Int { - val a = 0x40 // ~25% - val r = android.graphics.Color.red(tintArgb) - val g = android.graphics.Color.green(tintArgb) - val b = android.graphics.Color.blue(tintArgb) - if (a > 0) { - // Composite onto dark/light surface for opaque bitmap pixels - val sr = if (surfaceDark) 0x0d else 0xe5 - val sg = if (surfaceDark) 0x11 else 0xe7 - val sb = if (surfaceDark) 0x17 else 0xeb - val af = a / 255f - val cr = (r * af + sr * (1 - af)).toInt().coerceIn(0, 255) - val cg = (g * af + sg * (1 - af)).toInt().coerceIn(0, 255) - val cb = (b * af + sb * (1 - af)).toInt().coerceIn(0, 255) - return android.graphics.Color.rgb(cr, cg, cb) - } - return android.graphics.Color.rgb(r, g, b) -} - -private fun textStyleFromElement(context: Context, el: JSONObject): TextStyle { - val color = resolveColorProvider(context, el.opt("color")) ?: semanticLabelProvider(context) - val semantic = semanticTextSizeSp(el.widgetString("textStyle", "")) - val explicit = el.optDouble("fontSize", -1.0) - // textStyle overrides fontSize when both are set (models.rs contract) - val size = when { - semantic > 0f -> semantic.toDouble() - explicit > 0 -> explicit - else -> -1.0 - } - val weight = when (el.widgetString("fontWeight", "").lowercase(Locale.US)) { - "bold", "heavy", "black", "semibold", "600", "700", "800", "900" -> FontWeight.Bold - "medium", "500" -> FontWeight.Medium - else -> FontWeight.Normal - } - val align = when (el.widgetString("alignment", "").lowercase(Locale.US)) { - "center", "middle" -> TextAlign.Center - "trailing", "right", "end" -> TextAlign.End - else -> TextAlign.Start - } - return if (size > 0) { - TextStyle(color = color, fontSize = size.toFloat().sp, fontWeight = weight, textAlign = align) - } else { - TextStyle(color = color, fontWeight = weight, textAlign = align) - } -} - -private fun semanticTextSizeSp(style: String): Float { - return when (style.lowercase(Locale.US)) { - "largeTitle".lowercase(Locale.US) -> 34f - "title", "title1" -> 28f - "title2" -> 22f - "title3" -> 20f - "headline" -> 17f - "subheadline" -> 15f - "body" -> 16f - "callout" -> 16f - "footnote" -> 13f - "caption" -> 12f - "caption2" -> 11f - else -> -1f - } -} - -private fun semanticLabelProvider(context: Context): ColorProvider { - return ColorProvider(if (surfaceIsDark(context)) Color(0xFFF2F2F7) else Color(0xFF111111)) -} - -private fun semanticSecondaryProvider(context: Context): ColorProvider { - return ColorProvider(if (surfaceIsDark(context)) Color(0xFFC7C7CC) else Color(0xFF555555)) -} - -private fun mediaGlyphFallback(label: String): String { - return when (label.trim()) { - "⏸", "❚❚", "pause" -> "||" - "▶", "►", "play" -> ">" - "⏮", "previous", "prev" -> "|<" - "⏭", "next" -> ">|" - "⏯" -> ">|" - else -> label - } -} - -private fun parseContentAlignment(raw: String): Alignment { - return when (raw.lowercase(Locale.US)) { - "center" -> Alignment.Center - "top", "topcenter" -> Alignment.TopCenter - "bottom", "bottomcenter" -> Alignment.BottomCenter - "leading", "start", "centerstart", "centerleading" -> Alignment.CenterStart - "trailing", "end", "centerend", "centertrailing" -> Alignment.CenterEnd - "topleading", "topstart" -> Alignment.TopStart - "toptrailing", "topend" -> Alignment.TopEnd - "bottomleading", "bottomstart" -> Alignment.BottomStart - "bottomtrailing", "bottomend" -> Alignment.BottomEnd - else -> Alignment.TopStart - } -} - -private fun parseHorizontalStackAlignment(raw: String): Alignment.Horizontal { - return when (raw.lowercase(Locale.US)) { - "center", "middle" -> Alignment.CenterHorizontally - "trailing", "end", "right" -> Alignment.End - else -> Alignment.Start - } -} - -private fun parseVerticalStackAlignment(raw: String): Alignment.Vertical { - return when (raw.lowercase(Locale.US)) { - "top" -> Alignment.Top - "bottom" -> Alignment.Bottom - else -> Alignment.CenterVertically - } -} - -private fun parseButtonContentAlignment(raw: String): Alignment { - return when (raw.lowercase(Locale.US)) { - "center", "middle" -> Alignment.Center - "trailing", "right", "end" -> Alignment.CenterEnd - else -> Alignment.CenterStart - } -} - -private fun imageProviderFromElement(context: Context, el: JSONObject): ImageProvider? { - val localPath = el.widgetString("localPath", "") - if (localPath.isNotBlank()) { - BitmapFactory.decodeFile(localPath)?.let { return ImageProvider(it) } - } - - val data = el.widgetString("data", "") - if (data.isNotBlank()) { - decodeBase64Bitmap(data)?.let { return ImageProvider(it) } - } - - val url = el.widgetString("url", "") - if (url.startsWith("data:", true)) { - decodeBase64Bitmap(url)?.let { return ImageProvider(it) } - } else if (url.startsWith("file://")) { - val filePath = Uri.parse(url).path.orEmpty() - if (filePath.isNotBlank()) { - BitmapFactory.decodeFile(filePath)?.let { return ImageProvider(it) } - } - } else if (url.startsWith("/")) { - BitmapFactory.decodeFile(url)?.let { return ImageProvider(it) } - } - - val systemName = el.widgetString("systemName", "") - if (systemName.isNotBlank()) { - resolveSystemImageProvider(context, systemName)?.let { return it } - } - - return null -} - -private fun resolveSystemImageProvider(context: Context, systemName: String): ImageProvider? { - val candidates = mutableListOf() - val normalized = systemName.trim() - if (normalized.isNotBlank()) { - candidates += normalized - candidates += normalized.replace('.', '_') - candidates += normalized.replace('-', '_') - candidates += normalized.replace('.', '_').replace('-', '_') - } - - for (name in candidates.distinct()) { - val appDrawable = context.resources.getIdentifier(name, "drawable", context.packageName) - if (appDrawable != 0) return ImageProvider(appDrawable) - val appMipmap = context.resources.getIdentifier(name, "mipmap", context.packageName) - if (appMipmap != 0) return ImageProvider(appMipmap) - - val androidDrawable = context.resources.getIdentifier(name, "drawable", "android") - if (androidDrawable != 0) return ImageProvider(androidDrawable) - } - return null -} - -private fun decodeBase64Bitmap(raw: String): Bitmap? { - val key = raw.hashCode() - synchronized(BASE64_CACHE) { - BASE64_CACHE[key]?.let { return it } - } - - val normalized = if (raw.startsWith("data:", true)) { - raw.substringAfter(',', missingDelimiterValue = "").trim() - } else { - raw.trim() - } - if (normalized.isEmpty()) return null - val bytes = runCatching { - Base64.decode(normalized, Base64.DEFAULT) - }.recoverCatching { - Base64.decode(normalized, Base64.URL_SAFE or Base64.NO_WRAP) - }.getOrNull() ?: return null - val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - // Upscale tiny (1×1) fixtures so Glance Image sizing is visible. - val out = if (bmp.width <= 2 || bmp.height <= 2) { - Bitmap.createScaledBitmap(bmp, 32.coerceAtLeast(bmp.width * 16), 32.coerceAtLeast(bmp.height * 16), false) - } else bmp - synchronized(BASE64_CACHE) { - BASE64_CACHE[key] = out - while (BASE64_CACHE.size > 80) { - val first = BASE64_CACHE.keys.firstOrNull() ?: break - BASE64_CACHE.remove(first) - } - } - return out -} - -@Composable -private fun renderChartElement(context: Context, modifier: GlanceModifier, el: JSONObject) { - val chartType = el.widgetString("chartType", "bar").lowercase(Locale.US) - val bmp = drawChartBitmap(context, el) - if (bmp != null) { - val imageMod = if (chartType == "pie") { - modifier.size(80.dp) - } else { - modifier.fillMaxWidth().height(84.dp) - } - Image( - provider = ImageProvider(bmp), - contentDescription = "chart", - modifier = imageMod, - contentScale = ContentScale.Fit - ) - } else { - Text("chart: empty", modifier = modifier, style = textStyleFromElement(context, el)) - } -} - -@Composable -private fun renderCanvasElement(context: Context, modifier: GlanceModifier, el: JSONObject) { - val bmp = drawCanvasBitmap(context, el) - if (bmp != null) { - val widthDp = el.optDouble("width", 0.0).toInt() - val heightDp = el.optDouble("height", 0.0).toInt() - var imageModifier = modifier - if (widthDp > 0) imageModifier = imageModifier.width(widthDp.dp) - if (heightDp > 0) imageModifier = imageModifier.height(heightDp.dp) - Image( - provider = ImageProvider(bmp), - contentDescription = "canvas", - modifier = imageModifier, - contentScale = ContentScale.FillBounds - ) - } else { - Text("canvas: empty", modifier = modifier, style = textStyleFromElement(context, el)) - } -} - -private fun parseContentScale(mode: String): ContentScale { - return when (mode.lowercase(Locale.US)) { - "fill" -> ContentScale.Crop - "fit" -> ContentScale.Fit - "stretch" -> ContentScale.FillBounds - else -> ContentScale.Fit - } -} - -@Composable -private fun renderElementText( - context: Context, - el: JSONObject, - content: String, - modifier: GlanceModifier = GlanceModifier, -) { - val style = textStyleFromElement(context, el) - val limit = el.optInt("lineLimit", -1) - if (limit > 0) { - Text(content, modifier = modifier, style = style, maxLines = limit) - } else { - Text(content, modifier = modifier, style = style) - } -} - -private fun progressBar(percent: Int): String { - val clamped = percent.coerceIn(0, 100) - val blocks = (clamped / 10).coerceIn(0, 10) - return "[" + "#".repeat(blocks) + "-".repeat(10 - blocks) + "] $clamped%" -} - -private fun drawChartBitmap(context: Context, el: JSONObject): Bitmap? { - val data = el.optJSONArray("chartData") ?: return null - if (data.length() == 0) return null - - val points = mutableListOf>() - for (i in 0 until data.length()) { - val p = data.optJSONObject(i) ?: continue - val value = p.optDouble("value", 0.0).toFloat() - val pointColorProvider = resolveColorProvider(context, p.opt("color")) - val pointColor = pointColorProvider?.let { colorProviderArgb(it, context, Color.Transparent.toArgb()) } - val fallback = pointColor ?: colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) - points += (value to fallback) - } - if (points.isEmpty()) return null - - val chartType = el.widgetString("chartType", "bar").lowercase(Locale.US) - val frame = el.optJSONObject("frame") - val widthDp = if (chartType == "pie") { - frame?.optDouble("width", 80.0)?.toFloat() ?: 80f - } else { - frame?.optDouble("width", 180.0)?.toFloat() ?: 180f - } - val heightDp = if (chartType == "pie") { - widthDp - } else { - frame?.optDouble("height", 84.0)?.toFloat() ?: 84f - } - val w = dpToPx(context, widthDp).coerceAtLeast(if (chartType == "pie") 64 else 80) - val h = dpToPx(context, heightDp).coerceAtLeast(if (chartType == "pie") 64 else 44) - val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - - val bgProvider = resolveBackgroundProvider(context, el.opt("background")) - if (bgProvider != null) { - canvas.drawColor(colorProviderArgb(bgProvider, context, Color.Transparent.toArgb())) - } - - val pad = dpToPx(context, 6f).toFloat() - val chartRect = RectF(pad, pad, w - pad, h - pad) - val max = points.maxOf { it.first }.coerceAtLeast(0.0001f) - val min = points.minOf { it.first } - val span = (max - min).takeIf { it > 0.0001f } ?: 1f - - when (chartType) { - "line", "area" -> { - val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - color = points.first().second - strokeWidth = dpToPx(context, 2f).toFloat() - } - val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = (points.first().second and 0x55FFFFFF) - } - val path = Path() - val area = Path() - for (i in points.indices) { - val x = chartRect.left + chartRect.width() * (i.toFloat() / (points.size - 1).coerceAtLeast(1)) - val yNorm = (points[i].first - min) / span - val y = chartRect.bottom - yNorm * chartRect.height() - if (i == 0) { - path.moveTo(x, y) - area.moveTo(x, chartRect.bottom) - area.lineTo(x, y) - } else { - path.lineTo(x, y) - area.lineTo(x, y) - } - } - if (chartType == "area") { - area.lineTo(chartRect.right, chartRect.bottom) - area.close() - canvas.drawPath(area, fill) - } - canvas.drawPath(path, stroke) - } - "pie" -> { - val total = points.sumOf { it.first.toDouble() }.toFloat().coerceAtLeast(0.0001f) - var start = -90f - for ((value, color) in points) { - val sweep = (value / total) * 360f - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - this.color = color - } - canvas.drawArc(chartRect, start, sweep, true, paint) - start += sweep - } - } - else -> { - // Scale from 0 (or min(0, dataMin)) so the smallest positive bar isn't zero-height. - val dataMin = points.minOf { it.first } - val barMin = minOf(0f, dataMin) - val barMax = points.maxOf { it.first }.coerceAtLeast(0.0001f) - val barSpan = (barMax - barMin).takeIf { it > 0.0001f } ?: 1f - val gap = dpToPx(context, 3f).toFloat() - val barW = ((chartRect.width() - gap * (points.size - 1)) / points.size).coerceAtLeast(1f) - for (i in points.indices) { - val value = points[i].first - val x = chartRect.left + i * (barW + gap) - val yNorm = (value - barMin) / barSpan - val y = chartRect.bottom - yNorm * chartRect.height() - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = points[i].second - } - canvas.drawRoundRect(RectF(x, y, x + barW, chartRect.bottom), barW / 4f, barW / 4f, paint) - } - } - } - return bmp -} - -private fun drawCanvasBitmap(context: Context, el: JSONObject): Bitmap? { - val commands = el.optJSONArray("elements") ?: return null - if (commands.length() == 0) return null - val density = context.resources.displayMetrics.density - fun sx(v: Double): Float = (v * density).toFloat() - val width = dpToPx(context, el.optDouble("width", 180.0).toFloat()).coerceAtLeast(40) - val height = dpToPx(context, el.optDouble("height", 100.0).toFloat()).coerceAtLeast(30) - val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - - val bg = resolveBackgroundProvider(context, el.opt("background"))?.getColor(context) - if (bg != null) canvas.drawColor(bg.toArgb()) - - for (i in 0 until commands.length()) { - val cmd = commands.optJSONObject(i) ?: continue - val draw = cmd.widgetString("draw", "") - when (draw) { - "circle" -> { - val cx = sx(cmd.optDouble("cx", 0.0)) - val cy = sx(cmd.optDouble("cy", 0.0)) - val r = sx(cmd.optDouble("r", 0.0)) - val fill = paintFill(context, cmd.opt("fill"), "#000000") - canvas.drawCircle(cx, cy, r, fill) - val strokeRaw = cmd.opt("stroke") - if (strokeRaw != null) { - val stroke = paintStroke( - context, - strokeRaw, - sx(cmd.optDouble("strokeWidth", 1.0)), - "#000000" - ) - if (stroke != null) canvas.drawCircle(cx, cy, r, stroke) - } - } - "line" -> { - val p = paintStroke( - context, - cmd.opt("stroke"), - sx(cmd.optDouble("strokeWidth", 1.0)), - "#000000" - ) - if (p == null) continue - canvas.drawLine( - sx(cmd.optDouble("x1", 0.0)), - sx(cmd.optDouble("y1", 0.0)), - sx(cmd.optDouble("x2", 0.0)), - sx(cmd.optDouble("y2", 0.0)), - p - ) - } - "rect" -> { - val x = sx(cmd.optDouble("x", 0.0)) - val y = sx(cmd.optDouble("y", 0.0)) - val w = sx(cmd.optDouble("width", 0.0)) - val h = sx(cmd.optDouble("height", 0.0)) - val rr = sx(cmd.optDouble("cornerRadius", 0.0)) - val rect = RectF(x, y, x + w, y + h) - val fill = paintFill(context, cmd.opt("fill"), "#000000") - canvas.drawRoundRect(rect, rr, rr, fill) - val strokeRaw = cmd.opt("stroke") - if (strokeRaw != null) { - val stroke = paintStroke( - context, - strokeRaw, - sx(cmd.optDouble("strokeWidth", 1.0)), - "#000000" - ) - if (stroke != null) canvas.drawRoundRect(rect, rr, rr, stroke) - } - } - "arc" -> { - val cx = sx(cmd.optDouble("cx", 0.0)) - val cy = sx(cmd.optDouble("cy", 0.0)) - val r = sx(cmd.optDouble("r", 0.0)) - val start = cmd.optDouble("startAngle", 0.0).toFloat() - val end = cmd.optDouble("endAngle", 0.0).toFloat() - val rect = RectF(cx - r, cy - r, cx + r, cy + r) - val strokeW = sx(cmd.optDouble("strokeWidth", 1.0)) - val stroke = paintStroke(context, cmd.opt("stroke"), strokeW, null) - val fallbackStroke = if (stroke == null) { - paintStroke(context, cmd.opt("fill"), strokeW, "#000000") - } else stroke - if (fallbackStroke == null) continue - canvas.drawArc(rect, start, end - start, false, fallbackStroke) - } - "text" -> { - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = colorProviderArgb(resolveColorProvider(context, cmd.opt("color")), context, Color.Black.toArgb()) - textSize = sx(cmd.optDouble("fontSize", 12.0)) - } - canvas.drawText( - cmd.widgetString("content", ""), - sx(cmd.optDouble("x", 0.0)), - sx(cmd.optDouble("y", 0.0)), - paint - ) - } - "path" -> { - val path = parseSimpleSvgPath(cmd.widgetString("d", "")) { sx(it) } - val fillRaw = cmd.opt("fill") - if (fillRaw != null && fillRaw.toString().isNotBlank() && fillRaw.toString() != "none") { - canvas.drawPath(path, paintFill(context, fillRaw, "#000000")) - } - val strokeRaw = cmd.opt("stroke") - if (strokeRaw != null) { - val stroke = paintStroke( - context, - strokeRaw, - sx(cmd.optDouble("strokeWidth", 1.0)), - "#000000" - ) - if (stroke != null) canvas.drawPath(path, stroke) - } - } - } - } - return bmp -} - -private fun parseSimpleSvgPath(raw: String, sx: (Double) -> Float): Path { - val path = Path() - val normalized = raw.replace(",", " ") - val cmds = Regex("""([MLZmlz])([^MLZmlz]*)""").findAll(normalized) - for (m in cmds) { - val cmd = m.groupValues[1] - val nums = Regex("""[-+]?(?:\d+\.?\d*|\.\d+)""") - .findAll(m.groupValues[2]) - .map { it.value.toDouble() } - .toList() - when (cmd) { - "M", "m" -> if (nums.size >= 2) { - if (cmd == "M") path.moveTo(sx(nums[0]), sx(nums[1])) - else path.rMoveTo(sx(nums[0]), sx(nums[1])) - var i = 2 - while (i + 1 < nums.size) { - if (cmd == "M") path.lineTo(sx(nums[i]), sx(nums[i + 1])) - else path.rLineTo(sx(nums[i]), sx(nums[i + 1])) - i += 2 - } - } - "L", "l" -> { - var i = 0 - while (i + 1 < nums.size) { - if (cmd == "L") path.lineTo(sx(nums[i]), sx(nums[i + 1])) - else path.rLineTo(sx(nums[i]), sx(nums[i + 1])) - i += 2 - } - } - "Z", "z" -> path.close() - } - } - return path -} - -private fun drawProgressBitmap(context: Context, el: JSONObject, percent: Int): Bitmap? { - val barStyle = el.widgetString("barStyle", "linear").lowercase(Locale.US) - val tint = colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) - val dark = surfaceIsDark(context) - val bgColor = trackColorFromTint(tint, dark) - if (barStyle == "circular") { - val sizeDp = el.optJSONObject("frame")?.optDouble("width", 40.0)?.toFloat() ?: 40f - val s = dpToPx(context, sizeDp).coerceAtLeast(32) - val bmp = Bitmap.createBitmap(s, s, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - val stroke = (s * 0.12f).coerceAtLeast(3f) - val pad = stroke / 2f + 1f - val rect = RectF(pad, pad, s - pad, s - pad) - val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = stroke - color = bgColor - strokeCap = Paint.Cap.ROUND - } - val prog = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = stroke - color = tint - strokeCap = Paint.Cap.ROUND - } - canvas.drawArc(rect, -90f, 360f, false, track) - canvas.drawArc(rect, -90f, 360f * (percent.coerceIn(0, 100) / 100f), false, prog) - return bmp - } - - val frame = el.optJSONObject("frame") - val widthDp = frame?.optDouble("width", 180.0)?.toFloat() ?: 180f - val heightDp = frame?.optDouble("height", 18.0)?.toFloat() ?: 18f - val w = dpToPx(context, widthDp).coerceAtLeast(80) - val h = dpToPx(context, heightDp).coerceAtLeast(8) - val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - - val radius = (h / 2f).coerceAtLeast(2f) - val outer = RectF(0f, 0f, w.toFloat(), h.toFloat()) - val filledW = (w * (percent.coerceIn(0, 100) / 100f)).coerceAtLeast(0f) - val inner = RectF(0f, 0f, filledW, h.toFloat()) - - val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = bgColor } - val fgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL; color = tint } - canvas.drawRoundRect(outer, radius, radius, bgPaint) - if (filledW > 0f) canvas.drawRoundRect(inner, radius, radius, fgPaint) - return bmp -} - -private fun drawGaugeBitmap(context: Context, el: JSONObject, percent: Int): Bitmap? { - val gaugeStyle = el.widgetString("gaugeStyle", "circular").lowercase(Locale.US) - val tint = colorProviderArgb(resolveColorProvider(context, el.opt("tint")), context, Color(0xFF4FC3F7).toArgb()) - val dark = surfaceIsDark(context) - val ink = colorProviderArgb( - resolveColorProvider(context, el.opt("color")), - context, - if (dark) Color(0xFFF2F2F7).toArgb() else Color(0xFF111111).toArgb() - ) - val label = el.widgetString("label", "") - val valueLabel = when { - el.has("currentValueLabel") -> el.widgetString("currentValueLabel", "") - el.has("value") -> "$percent%" - else -> "" - } - - if (gaugeStyle == "linear") { - val frame = el.optJSONObject("frame") - val widthDp = frame?.optDouble("width", 160.0)?.toFloat() ?: 160f - val barH = dpToPx(context, 8f).coerceAtLeast(6) - val labelH = if (label.isNotBlank() || valueLabel.isNotBlank()) dpToPx(context, 14f) else 0 - val w = dpToPx(context, widthDp).coerceAtLeast(80) - val totalH = barH + labelH + if (labelH > 0) dpToPx(context, 4f) else 0 - val bmp = Bitmap.createBitmap(w, totalH, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - var y = 0f - if (labelH > 0) { - val lp = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = ink - textSize = labelH * 0.7f - typeface = Typeface.DEFAULT - } - if (label.isNotBlank()) { - lp.textAlign = Paint.Align.LEFT - canvas.drawText(label, 0f, labelH - lp.descent(), lp) - } - if (valueLabel.isNotBlank()) { - lp.textAlign = Paint.Align.RIGHT - lp.typeface = Typeface.DEFAULT_BOLD - canvas.drawText(valueLabel, w.toFloat(), labelH - lp.descent(), lp) - } - y = labelH + dpToPx(context, 4f).toFloat() - } - val trackColor = trackColorFromTint(tint, dark) - val radius = barH / 2f - val outer = RectF(0f, y, w.toFloat(), y + barH) - val filledW = (w * (percent.coerceIn(0, 100) / 100f)).coerceAtLeast(0f) - canvas.drawRoundRect(outer, radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL; color = trackColor - }) - if (filledW > 0f) { - canvas.drawRoundRect(RectF(0f, y, filledW, y + barH), radius, radius, Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL; color = tint - }) - } - return bmp - } - - val frame = el.optJSONObject("frame") - val sizeDp = frame?.optDouble("width", 72.0)?.toFloat() ?: 72f - val ring = dpToPx(context, sizeDp).coerceAtLeast(48) - val labelH = if (label.isNotBlank()) (ring * 0.22f).toInt().coerceAtLeast(12) else 0 - val s = ring - val totalH = ring + labelH - val bmp = Bitmap.createBitmap(s, totalH, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - val stroke = (ring * 0.12f).coerceAtLeast(4f) - val pad = stroke / 2f + 1f - val rect = RectF(pad, pad, s - pad, ring - pad) - - val track = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = stroke - color = trackColorFromTint(tint, dark) - strokeCap = Paint.Cap.ROUND - } - val prog = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = stroke - color = tint - strokeCap = Paint.Cap.ROUND - } - canvas.drawArc(rect, -90f, 360f, false, track) - canvas.drawArc(rect, -90f, 360f * (percent.coerceIn(0, 100) / 100f), false, prog) - - val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = ink - textAlign = Paint.Align.CENTER - textSize = ring * 0.22f - typeface = Typeface.DEFAULT_BOLD - } - val cx = s / 2f - val cy = ring / 2f - (valuePaint.descent() + valuePaint.ascent()) / 2f - canvas.drawText(valueLabel, cx, cy, valuePaint) - - if (label.isNotBlank()) { - val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = ink - textAlign = Paint.Align.CENTER - textSize = ring * 0.14f - typeface = Typeface.DEFAULT - } - val ly = ring + labelH / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f - canvas.drawText(label, cx, ly, labelPaint) - } - return bmp -} - -private fun paintFill(context: Context, color: Any?, fallbackHex: String): Paint { - return Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - this.color = colorProviderArgb(resolveColorProvider(context, color), context, parseColor(context, fallbackHex)?.toArgb() ?: Color.Black.toArgb()) - } -} - -private fun paintStroke(context: Context, color: Any?, width: Float, fallbackHex: String?): Paint? { - if (color == null && fallbackHex == null) return null - return Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - val fallback = fallbackHex?.let { parseColor(context, it)?.toArgb() } ?: Color.Black.toArgb() - this.color = colorProviderArgb(resolveColorProvider(context, color), context, fallback) - strokeWidth = width.coerceAtLeast(1f) - } -} - -private fun dpToPx(context: Context, dp: Float): Int { - return (dp * context.resources.displayMetrics.density).toInt().coerceAtLeast(1) -} - -private fun drawGlyphBitmap(context: Context, glyph: String, sizeDp: Int, argb: Int): Bitmap? { - if (glyph.isBlank()) return null - val px = dpToPx(context, sizeDp.toFloat()).coerceAtLeast(16) - val bmp = Bitmap.createBitmap(px, px, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = argb - textAlign = Paint.Align.CENTER - textSize = px * 0.78f - typeface = Typeface.DEFAULT_BOLD - } - val x = px / 2f - val y = px / 2f - (paint.descent() + paint.ascent()) / 2f - canvas.drawText(glyph, x, y, paint) - return bmp -} - -private fun drawShapeBitmap(context: Context, el: JSONObject, sizeDp: Int): Bitmap? { - val shape = el.widgetString("shapeType", "circle").lowercase(Locale.US) - val heightPx = dpToPx(context, sizeDp.toFloat()).coerceAtLeast(8) - // Capsule: width = 2*size, height = size - val widthPx = if (shape == "capsule") { - dpToPx(context, (sizeDp * 2).toFloat()).coerceAtLeast(8) - } else { - heightPx - } - val bmp = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - val rect = RectF(0f, 0f, widthPx.toFloat(), heightPx.toFloat()) - val fillColor = colorProviderArgb( - resolveColorProvider(context, el.opt("fill")), - context, - Color(0xFF888888).toArgb() - ) - val strokeProvider = resolveColorProvider(context, el.opt("stroke")) - val strokeW = el.optDouble("strokeWidth", 0.0).toFloat().coerceAtLeast(0f) - - val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = fillColor - } - val capsuleRadius = heightPx / 2f - when (shape) { - "capsule" -> canvas.drawRoundRect(rect, capsuleRadius, capsuleRadius, fill) - "rectangle" -> { - val rr = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) - canvas.drawRoundRect(rect, rr, rr, fill) - } - else -> canvas.drawOval(rect, fill) - } - - if (strokeProvider != null && strokeW > 0f) { - val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - color = colorProviderArgb(strokeProvider, context, Color.Black.toArgb()) - strokeWidth = strokeW - } - val inset = strokeW / 2f - val sr = RectF(inset, inset, widthPx - inset, heightPx - inset) - when (shape) { - "capsule" -> canvas.drawRoundRect(sr, capsuleRadius, capsuleRadius, stroke) - "rectangle" -> { - val rr = el.optDouble("cornerRadius", 0.0).toFloat().coerceAtLeast(0f) - canvas.drawRoundRect(sr, rr, rr, stroke) - } - else -> canvas.drawOval(sr, stroke) - } - } - return bmp -} - -private fun colorProviderArgb(provider: ColorProvider?, context: Context, fallback: Int): Int { - val value: Any = provider?.getColor(context) ?: return fallback - return when (value) { - is Color -> value.toArgb() - is Long -> Color(value).toArgb() - is Int -> value - else -> fallback - } -} - -private fun formatDateValue(raw: String, style: String): String { - val date = parseIsoDate(raw) ?: return raw - val now = System.currentTimeMillis() - return when (style.lowercase(Locale.US)) { - "time" -> SimpleDateFormat("h:mm a", Locale.US).format(date) - "relative" -> formatRelativeSpan(date.time - now) - "offset" -> formatDuration(date.time - now) - "timer" -> formatDuration(date.time - now) - else -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(date) - } -} - -private fun formatRelativeSpan(diffMs: Long): String { - val past = diffMs < 0 - val abs = kotlin.math.abs(diffMs) - val sec = abs / 1000 - val min = sec / 60 - val hr = min / 60 - val day = hr / 24 - val mon = day / 30 - val yr = day / 365 - val (n, unit) = when { - yr >= 1L -> yr to if (yr == 1L) "yr" else "yrs" - mon >= 1L -> mon to if (mon == 1L) "mo" else "mos" - day >= 1L -> day to if (day == 1L) "day" else "days" - hr >= 1L -> hr to if (hr == 1L) "hr" else "hrs" - min >= 1L -> min to if (min == 1L) "min" else "mins" - else -> sec to if (sec == 1L) "sec" else "secs" - } - return if (past) "$n $unit ago" else "in $n $unit" -} - -private fun formatTimerValue(raw: String, counting: String): String { - val date = parseIsoDate(raw) ?: return raw - val now = System.currentTimeMillis() - val diff = if (counting.equals("up", true)) now - date.time else date.time - now - return formatDuration(diff) -} - -private fun formatDuration(diffMs: Long): String { - val sign = if (diffMs < 0) "-" else "" - val sec = kotlin.math.abs(diffMs) / 1000 - val h = sec / 3600 - val m = (sec % 3600) / 60 - val s = sec % 60 - return "%s%02d:%02d:%02d".format(Locale.US, sign, h, m, s) -} - -private fun parseIsoDate(raw: String): Date? { - if (raw.isBlank()) return null - val patterns = listOf( - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mmX", - "yyyy-MM-dd" - ) - for (p in patterns) { - val sdf = SimpleDateFormat(p, Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } - val parsed = runCatching { sdf.parse(raw) }.getOrNull() - if (parsed != null) return parsed - } - return raw.toLongOrNull()?.let { Date(it) } -} - -private fun safeHost(rawUrl: String): String { - return runCatching { Uri.parse(rawUrl).host }.getOrNull().orEmpty().ifBlank { "remote" } -} - -private fun iconGlyph(systemName: String): String { - return when (systemName.lowercase(Locale.US)) { - "cloud.sun.fill" -> "\u26C5" - "sun.max.fill" -> "\u2600" - "cloud.rain.fill" -> "\u2614" - "cloud.fill" -> "\u2601" - "cloud.bolt.fill", "bolt.fill" -> "\u26A1" - "moon.stars.fill" -> "\u263E" - "paintpalette.fill" -> "\u2698" - "location.fill" -> "\u2302" - "person.fill" -> "\uD83D\uDC64" - "person.2.fill" -> "\uD83D\uDC65" - "person.badge.plus" -> "\uD83D\uDC64+" - "heart.fill" -> "\u2764" - "star.fill" -> "\u2B50" - "gear", "gearshape", "gearshape.fill" -> "\u2699" - "drop.fill" -> "\u25CF" - "leaf.fill" -> "\u273F" - "flame.fill" -> "\uD83D\uDD25" - "hourglass", "hourglass.bottomhalf.filled" -> "\u23F3" - "music.note", "music.note.list" -> "\u266A" - "bitcoinsign.circle.fill" -> "\u20BF" - "quote.opening" -> "\u201C" - "figure.run" -> "\u27A4" - "globe" -> "\u25CE" - "clock" -> "\u23F0" - "desktopcomputer" -> "\u25A3" - "network" -> "\u2261" - "internaldrive" -> "\u25A0" - "battery.100" -> "\u25AE" - "iphone" -> "\u25AF" - "applewatch" -> "\u231A" - "airpodspro" -> "\u25CB" - "ipad" -> "\u25AD" - "eye.fill" -> "\u25C9" - "bubble.left.fill" -> "\u25ED" - else -> if (systemName.isNotBlank()) systemName.take(1) else "*" - } -} - -private fun cfgHash(text: String?): String { - if (text.isNullOrEmpty()) return "null" - return text.hashCode().toUInt().toString(16) -} - -/** - * org.json [JSONObject.optString] returns the literal "null" when the value is JSON null. - * Treat JSON null / "null" / "undefined" as missing so they never render as text. - */ -private fun JSONObject.widgetString(key: String, fallback: String = ""): String { - if (!has(key) || isNull(key)) return fallback - val raw = opt(key) ?: return fallback - if (raw === JSONObject.NULL) return fallback - val s = when (raw) { - is String -> raw - else -> raw.toString() - }.trim() - if (s.isEmpty() || s.equals("null", ignoreCase = true) || s.equals("undefined", ignoreCase = true)) { - return fallback - } - return s -} - diff --git a/android/src/main/java/WidgetActionCallback.kt b/android/src/main/java/WidgetActionCallback.kt new file mode 100644 index 0000000..7235ddb --- /dev/null +++ b/android/src/main/java/WidgetActionCallback.kt @@ -0,0 +1,143 @@ +package git.s00d.widgets + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.action.ActionParameters +import androidx.glance.appwidget.AppWidgetId +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.state.updateAppWidgetState +import org.json.JSONArray +import org.json.JSONObject + +private const val CONFIG_STATE_KEY_NAME = "__widget_config_state__" +private const val NONCE_STATE_KEY_NAME = "__widget_nonce_state__" +internal val CONFIG_STATE_KEY = stringPreferencesKey(CONFIG_STATE_KEY_NAME) +internal val NONCE_STATE_KEY = stringPreferencesKey(NONCE_STATE_KEY_NAME) + +internal val ACTION_KEY = ActionParameters.Key("action") +internal val PAYLOAD_KEY = ActionParameters.Key("payload") +internal val URL_KEY = ActionParameters.Key("url") + +class WidgetActionCallback : ActionCallback { + override suspend fun onAction( + context: Context, + glanceId: androidx.glance.GlanceId, + parameters: ActionParameters, + ) { + val appWidgetId = (glanceId as? AppWidgetId)?.appWidgetId ?: -1 + val group = WidgetStoreKeys.resolveGroup(context, appWidgetId) + val action = parameters[ACTION_KEY] + val payload = parameters[PAYLOAD_KEY] + val url = parameters[URL_KEY] + val normalizedUrl = url?.trim().orEmpty().let { + if (it.equals("null", ignoreCase = true) || it.equals("undefined", ignoreCase = true)) "" else it + } + + // Action-driven buttons are the primary flow in widgets. + // Only attempt URL open when no action is provided. + if (!action.isNullOrBlank()) { + val logicalWidgetId = WidgetStoreKeys.resolveWidgetId(context, appWidgetId) + val toggled = applyLocalListToggleIfNeeded( + context, glanceId, group, logicalWidgetId, action, payload, + ) + if (toggled) { + Log.d(TAG, "action local list toggle applied action=$action") + } + val plugin = WidgetBridgePlugin.pluginInstance + if (plugin != null) { + plugin.emitWidgetAction(action, payload, logicalWidgetId, group) + } else { + // Queue only when the host plugin is unavailable (matches WidgetActionReceiver). + val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE) + val pendingRaw = prefs.getString(WidgetStoreKeys.PENDING_ACTIONS, "[]") ?: "[]" + val arr = runCatching { JSONArray(pendingRaw) }.getOrElse { JSONArray() } + val obj = JSONObject() + obj.put("action", action) + if (!payload.isNullOrBlank()) obj.put("payload", payload) + obj.put("ts", System.currentTimeMillis()) + obj.put("widgetId", logicalWidgetId) + obj.put("group", group) + arr.put(obj) + prefs.edit().putString(WidgetStoreKeys.PENDING_ACTIONS, arr.toString()).apply() + } + } else if (normalizedUrl.isNotBlank()) { + val uri = Uri.parse(normalizedUrl) + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + val canHandle = intent.resolveActivity(context.packageManager) != null + if (canHandle) { + runCatching { context.startActivity(intent) } + .onFailure { e -> Log.e(TAG, "action open url failed=${e.message} url=$normalizedUrl", e) } + } else { + Log.w(TAG, "action open url skipped: no handler url=$normalizedUrl") + } + } + + runCatching { TauriGlanceWidget().update(context, glanceId) } + .onFailure { e -> Log.e(TAG, "action-triggered update failed=${e.message}", e) } + } + + private suspend fun applyLocalListToggleIfNeeded( + context: Context, + glanceId: androidx.glance.GlanceId, + group: String, + logicalWidgetId: String, + action: String, + payload: String?, + ): Boolean { + val prefs = context.getSharedPreferences(group, Context.MODE_PRIVATE) + val raw = prefs.getString(WidgetStoreKeys.configKey(logicalWidgetId), null) ?: return false + val root = runCatching { JSONObject(raw) }.getOrNull() ?: return false + var changed = false + listOf("small", "medium", "large").forEach { key -> + changed = toggleListItemsInElement(root.optJSONObject(key), action, payload) || changed + } + if (!changed) return false + val updated = root.toString() + prefs.edit().putString(WidgetStoreKeys.configKey(logicalWidgetId), updated).apply() + runCatching { + updateAppWidgetState(context, glanceId) { state -> + state[CONFIG_STATE_KEY] = updated + state[NONCE_STATE_KEY] = System.currentTimeMillis().toString() + } + }.onFailure { e -> + Log.e(TAG, "local toggle update state failed=${e.message}", e) + } + return true + } + + private fun toggleListItemsInElement(el: JSONObject?, action: String, payload: String?): Boolean { + if (el == null) return false + var changed = false + if (el.widgetString("type", "") == "list") { + val items = el.optJSONArray("items") ?: JSONArray() + for (i in 0 until items.length()) { + val item = items.optJSONObject(i) ?: continue + if (item.widgetString("action", "") != action) continue + if (!payload.isNullOrBlank()) { + val itemPayload = item.widgetString("payload", "") + if (itemPayload != payload) continue + } + val hasChecked = item.has("checked") + val hasIsOn = item.has("isOn") + if (!hasChecked && !hasIsOn) continue + val current = item.optBoolean("checked", item.optBoolean("isOn", false)) + val next = !current + if (hasChecked || !hasIsOn) item.put("checked", next) + if (hasIsOn) item.put("isOn", next) + changed = true + } + } + val children = el.optJSONArray("children") + if (children != null) { + for (i in 0 until children.length()) { + changed = toggleListItemsInElement(children.optJSONObject(i), action, payload) || changed + } + } + return changed + } +} diff --git a/android/src/main/java/render/El.kt b/android/src/main/java/render/El.kt new file mode 100644 index 0000000..cf70d9b --- /dev/null +++ b/android/src/main/java/render/El.kt @@ -0,0 +1,42 @@ +package git.s00d.widgets + +import org.json.JSONObject + +/** Typed read-only view over one widget-IR node; keeps `org.json` quirks in one place. */ +@JvmInline +value class El(val raw: JSONObject) { + val type: String get() = str("type") + + fun str(key: String, def: String = ""): String = raw.widgetString(key, def) + + fun num(key: String): Double? = + if (!raw.has(key) || raw.isNull(key)) null else raw.optDouble(key).takeIf { !it.isNaN() } + + fun num(key: String, def: Double): Double = + if (!raw.has(key) || raw.isNull(key)) def else { + val v = raw.optDouble(key, def); if (v.isNaN()) def else v + } + + fun dp(key: String, def: Int): Int = num(key)?.toInt()?.coerceAtLeast(0) ?: def + + fun int(key: String, def: Int): Int = + if (!raw.has(key) || raw.isNull(key)) def else raw.optInt(key, def) + + fun bool(key: String, def: Boolean = false): Boolean = + if (!raw.has(key) || raw.isNull(key)) def else raw.optBoolean(key, def) + + fun obj(key: String): JSONObject? = raw.optJSONObject(key) + + fun arr(key: String): org.json.JSONArray? = raw.optJSONArray(key) + + fun has(key: String): Boolean = raw.has(key) + + fun isNull(key: String): Boolean = raw.isNull(key) + + fun opt(key: String): Any? = raw.opt(key) + + val children: List + get() = arr("children")?.let { a -> + (0 until a.length()).mapNotNull { a.optJSONObject(it)?.let(::El) } + } ?: emptyList() +} diff --git a/android/src/main/java/render/Limits.kt b/android/src/main/java/render/Limits.kt new file mode 100644 index 0000000..804489f --- /dev/null +++ b/android/src/main/java/render/Limits.kt @@ -0,0 +1,6 @@ +package git.s00d.widgets + +internal const val GLANCE_CONTAINER_LIMIT = 10 +internal const val GLANCE_LIST_LIMIT = 50 +internal const val GLANCE_LIST_CHUNK = 9 +internal const val TAG = "TauriGlanceWidget" diff --git a/android/src/main/java/render/RenderData.kt b/android/src/main/java/render/RenderData.kt new file mode 100644 index 0000000..c3bc88f --- /dev/null +++ b/android/src/main/java/render/RenderData.kt @@ -0,0 +1,182 @@ +package git.s00d.widgets + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.ContentScale +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import org.json.JSONArray +import org.json.JSONObject +import java.util.Locale + +@Composable +internal fun RenderData(scope: RenderScope, el: El, modifier: GlanceModifier) { + val context = scope.context + val type = el.type + val spacing = el.num("spacing", 0.0).toInt().coerceAtLeast(0) + when (type) { + "chart" -> { + renderChartElement(context, modifier, el) + } + "list" -> { + // Column (not LazyColumn): home-screen lists are short, and LazyColumn + // often yields zero children under GlanceRemoteViews / Robolectric. + // Glance Column hard-caps ~10 children — chunk into nested Columns. + val items = el.arr("items") ?: JSONArray() + val itemList = mutableListOf() + for (i in 0 until items.length()) { + items.optJSONObject(i)?.let { itemList += it } + if (itemList.size >= GLANCE_LIST_LIMIT) break + } + val truncated = items.length() - itemList.size + if (truncated > 0) { + Log.w( + TAG, + "list items truncated: requested=${items.length()} rendered=${itemList.size} limit=$GLANCE_LIST_LIMIT" + ) + } + val anyCheckbox = itemList.any { it.has("checked") || it.has("isOn") } + val chunks = itemList.chunked(GLANCE_LIST_CHUNK) + Column(modifier = modifier.fillMaxWidth()) { + chunks.forEachIndexed { chunkIdx, chunk -> + Column(modifier = GlanceModifier.fillMaxWidth()) { + chunk.forEachIndexed { index, item -> + if (index > 0 && spacing > 0) { + Spacer(GlanceModifier.height(spacing.dp)) + } else if (index == 0 && chunkIdx > 0 && spacing > 0) { + Spacer(GlanceModifier.height(spacing.dp)) + } + val text = item.widgetString("text", item.widgetString("content", "")) + val hasCheckbox = item.has("checked") || item.has("isOn") + val checked = item.optBoolean("checked", false) || item.optBoolean("isOn", false) + val action = item.widgetString("action", "") + val payload = item.widgetString("payload", "") + val rowMod = applyAction(GlanceModifier.fillMaxWidth(), action, payload, "") + val prefix = when { + hasCheckbox && checked -> "✓ " + hasCheckbox -> "○ " + anyCheckbox -> " " + else -> "" + } + renderElementText(context, el, "$prefix$text", rowMod) + } + } + } + if (truncated > 0) { + if (spacing > 0) Spacer(GlanceModifier.height(spacing.dp)) + Text( + "+$truncated more", + style = TextStyle(color = semanticSecondaryProvider(context), fontSize = 11.sp) + ) + } + } + } + else -> { + val value = el.num("value", 0.0) + val minV = if (type == "gauge") el.num("min", 0.0) else 0.0 + val maxV = if (type == "gauge") { + el.num("max", 1.0) + } else { + el.num("total", 1.0) + }.coerceAtLeast(minV + 0.0001) + val pct = (((value - minV) / (maxV - minV)) * 100).toInt().coerceIn(0, 100) + val barStyle = el.str("barStyle", "linear").lowercase(Locale.US) + val gaugeStyle = el.str("gaugeStyle", "circular").lowercase(Locale.US) + val expand = when { + type == "gauge" && gaugeStyle == "linear" -> true + type != "gauge" && barStyle != "circular" -> true + else -> false + } + val colMod = if (expand) modifier.fillMaxWidth() else modifier + Column(modifier = colMod, horizontalAlignment = Alignment.CenterHorizontally) { + // Gauge: labels drawn inside bitmap. Progress: optional external label (linear only). + if (type != "gauge" && barStyle != "circular") { + el.str("label", "").takeIf { it.isNotBlank() }?.let { lbl -> + val labelEl = JSONObject(el.raw.toString()).apply { + if (!has("color") || isNull("color")) { + opt("tint")?.let { put("color", it) } + } + put("fontSize", optDouble("fontSize", 10.0)) + } + Text(lbl, style = textStyleFromElement(context, labelEl)) + } + } + val bmp = if (type == "gauge") drawGaugeBitmap(context, el.raw, pct) else drawProgressBitmap(context, el.raw, pct) + if (bmp != null) { + when { + type == "gauge" && gaugeStyle == "linear" -> { + Image( + provider = ImageProvider(bmp), + contentDescription = type, + modifier = GlanceModifier.fillMaxWidth().height(28.dp), + contentScale = ContentScale.Fit + ) + } + type == "gauge" -> { + val sizeDp = el.obj("frame")?.optDouble("width", 72.0)?.toInt()?.coerceAtLeast(1) ?: 72 + val hasLabel = el.str("label", "").isNotBlank() + val hDp = if (hasLabel) (sizeDp * 1.24f).toInt() else sizeDp + Image( + provider = ImageProvider(bmp), + contentDescription = type, + modifier = GlanceModifier.width(sizeDp.dp).height(hDp.dp), + contentScale = ContentScale.Fit + ) + } + barStyle == "circular" -> { + Image( + provider = ImageProvider(bmp), + contentDescription = type, + modifier = GlanceModifier.size(40.dp), + contentScale = ContentScale.Fit + ) + } + else -> { + Image( + provider = ImageProvider(bmp), + contentDescription = type, + modifier = GlanceModifier.fillMaxWidth().height(12.dp), + contentScale = ContentScale.Fit + ) + } + } + } else { + renderElementText(context, el, progressBar(pct)) + } + } + } + } +} + +@Composable +private fun renderChartElement(context: android.content.Context, modifier: GlanceModifier, el: El) { + val chartType = el.str("chartType", "bar").lowercase(Locale.US) + val bmp = drawChartBitmap(context, el.raw) + if (bmp != null) { + val imageMod = if (chartType == "pie") { + modifier.size(80.dp) + } else { + modifier.fillMaxWidth().height(84.dp) + } + Image( + provider = ImageProvider(bmp), + contentDescription = "chart", + modifier = imageMod, + contentScale = ContentScale.Fit + ) + } else { + Text("chart: empty", modifier = modifier, style = textStyleFromElement(context, el.raw)) + } +} diff --git a/android/src/main/java/render/RenderElement.kt b/android/src/main/java/render/RenderElement.kt new file mode 100644 index 0000000..687c837 --- /dev/null +++ b/android/src/main/java/render/RenderElement.kt @@ -0,0 +1,51 @@ +package git.s00d.widgets + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceModifier +import org.json.JSONObject + +/** Ambient render state threaded through the element tree (host context, slot size, layout axis). */ +internal data class RenderScope( + val context: Context, + val sizeFamily: String, + val axis: Axis = Axis.Vertical, +) { + enum class Axis { Vertical, Horizontal, Overlay } + + fun horizontal() = copy(axis = Axis.Horizontal) + + fun vertical() = copy(axis = Axis.Vertical) + + val inHorizontal: Boolean get() = axis == Axis.Horizontal +} + +@Composable +internal fun RenderElement(scope: RenderScope, el: El, modifier: GlanceModifier = GlanceModifier) { + RenderTrace.type(el.type) + val m = applyCommonStyle(scope.context, modifier, el) + when (el.type) { + "vstack", "hstack", "zstack", "grid", "container" -> RenderLayout(scope, el, m) + "text", "label", "date", "timer" -> RenderText(scope, el, m) + "image", "shape", "canvas" -> RenderMedia(scope, el, m) + "progress", "gauge", "chart", "list" -> RenderData(scope, el, m) + "button", "toggle", "link" -> RenderInteractive(scope, el, m) + "spacer", "divider" -> RenderSpacing(scope, el, m) + else -> { + if (el.type.isNotBlank()) RenderTrace.skip(el.type, "unsupported") + renderElementText(scope.context, el, el.type, m) + } + } +} + +@Composable +internal fun RenderElement( + context: Context, + el: JSONObject, + modifier: GlanceModifier = GlanceModifier, + sizeFamily: String = "small", + inHorizontal: Boolean = false, +) { + val axis = if (inHorizontal) RenderScope.Axis.Horizontal else RenderScope.Axis.Vertical + RenderElement(RenderScope(context, sizeFamily, axis), El(el), modifier) +} diff --git a/android/src/main/java/render/RenderInteractive.kt b/android/src/main/java/render/RenderInteractive.kt new file mode 100644 index 0000000..b340adc --- /dev/null +++ b/android/src/main/java/render/RenderInteractive.kt @@ -0,0 +1,136 @@ +package git.s00d.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import org.json.JSONObject + +@Composable +internal fun RenderInteractive(scope: RenderScope, el: El, modifier: GlanceModifier) { + val context = scope.context + when (el.type) { + "link" -> { + val action = el.str("action", "") + val payload = el.str("payload", "") + val url = el.str("url", "") + val wrapped = applyAction(modifier, action, payload, url) + val children = el.arr("children") + if (children != null && children.length() > 0) { + Box(modifier = wrapped) { + for (i in 0 until children.length()) { + val child = children.optJSONObject(i) ?: continue + RenderElement(scope, El(child), GlanceModifier) + } + } + } else { + renderElementText(context, el, el.str("label", "link"), wrapped) + } + } + "toggle" -> { + val label = mediaGlyphFallback( + el.str("label", el.str("content", "toggle")) + ) + val isOn = el.bool("isOn", false) + val action = el.str("action", "") + // Next state when no explicit payload — matches iOS/desktop toggle contract. + val payload = el.str("payload", if (isOn) "false" else "true") + val url = el.str("url", "") + val actionModifier = applyAction(modifier, action, payload, url) + val tint = resolveColorProvider(context, el.opt("tint")) + ?: ColorProvider(Color(0xFF4CAF50)) + val ink = resolveColorProvider(context, el.opt("color")) + ?: ColorProvider(if (surfaceIsDark(context)) Color(0xFFE2E8F0) else Color(0xFF111111)) + val glyph = if (isOn) "\u2713" else "\u25CB" + Row( + modifier = actionModifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + glyph, + style = TextStyle( + color = if (isOn) tint else ColorProvider(Color(0xFF999999)), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ) + Spacer(GlanceModifier.width(6.dp)) + Text(label, style = TextStyle(color = ink, fontSize = 14.sp)) + } + } + else -> { + val labelRaw = el.str("label", el.str("content", "button")) + val label = mediaGlyphFallback(labelRaw) + val action = el.str("action", "") + val payload = el.str("payload", "") + val url = el.str("url", "") + val bg = resolveColorProvider(context, el.opt("backgroundColor")) ?: resolveColorProvider(context, el.opt("tint")) + val actionModifier = applyAction(modifier, action, payload, url) + val text = label + val buttonAlignRaw = el.str("textAlignment", el.str("alignment", "center")) + val textElement = El(JSONObject(el.raw.toString()).apply { put("alignment", buttonAlignRaw) }) + val contentAlign = parseButtonContentAlignment(buttonAlignRaw) + val radius = el.num("cornerRadius", -1.0) + var boxMod = actionModifier + if (bg != null) boxMod = boxMod.background(bg) + if (radius >= 0) boxMod = boxMod.cornerRadius(radius.toInt().coerceAtLeast(0).dp) + // Honor DSL padding as content insets via applyCommonStyle; default only when absent. + val pad = el.opt("padding") + boxMod = if (pad == null || pad === JSONObject.NULL) { + boxMod.padding(horizontal = 10.dp, vertical = 6.dp) + } else { + // Explicit padding already applied on baseModifier by applyCommonStyle. + boxMod + } + if (bg != null) { + Box(modifier = boxMod) { + if (el.has("textAlignment")) { + Box(modifier = GlanceModifier.fillMaxWidth(), contentAlignment = contentAlign) { + renderElementText(context, textElement, text) + } + } else { + renderElementText(context, textElement, text) + } + } + } else { + if (el.has("textAlignment")) { + Box(modifier = actionModifier.fillMaxWidth(), contentAlignment = contentAlign) { + renderElementText(context, textElement, text) + } + } else { + renderElementText(context, textElement, text, actionModifier) + } + } + } + } +} + +internal fun applyAction(modifier: GlanceModifier, action: String, payload: String, url: String): GlanceModifier { + if (action.isBlank() && url.isBlank()) return modifier + return modifier.clickable( + actionRunCallback( + actionParametersOf( + ACTION_KEY to action, + PAYLOAD_KEY to payload, + URL_KEY to url + ) + ) + ) +} diff --git a/android/src/main/java/render/RenderLayout.kt b/android/src/main/java/render/RenderLayout.kt new file mode 100644 index 0000000..7a20162 --- /dev/null +++ b/android/src/main/java/render/RenderLayout.kt @@ -0,0 +1,258 @@ +package git.s00d.widgets + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.Row +import androidx.glance.layout.RowScope +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width +import org.json.JSONArray +import org.json.JSONObject + +@Composable +internal fun RenderLayout(scope: RenderScope, el: El, modifier: GlanceModifier) { + val inHorizontal = scope.axis == RenderScope.Axis.Horizontal + val spacing = el.num("spacing", 0.0).toInt().coerceAtLeast(0) + when (el.type) { + "vstack" -> { + val hAlign = parseHorizontalStackAlignment(el.str("alignment", "")) + // Inside a Row, fillMaxWidth steals space from trailing spacer/button. + val colMod = if (inHorizontal) modifier else modifier.fillMaxWidth() + Column( + modifier = colMod, + horizontalAlignment = hAlign + ) { + renderChildrenVertical( + scope, + el.arr("children"), + spacing, + expandWidth = !inHorizontal, + ) + } + } + "hstack" -> { + val vAlign = parseVerticalStackAlignment(el.str("alignment", "")) + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = vAlign + ) { + renderChildrenHorizontal(scope, el.arr("children"), spacing) + } + } + "zstack" -> { + val alignment = parseContentAlignment(el.str("alignment", "center").ifBlank { "center" }) + // fillMaxSize inside a Row steals all remaining width/height and zeros siblings + // (nested-dashboard badge crushed the title/button/body). Wrap in hstacks. + val boxMod = if (inHorizontal) modifier else modifier.fillMaxWidth() + Box(modifier = boxMod, contentAlignment = alignment) { + val children = el.arr("children") + if (children != null && children.length() > 0) { + for (i in 0 until children.length()) { + val child = children.optJSONObject(i) ?: continue + // Do not fillMaxSize on children — that breaks Box contentAlignment. + RenderElement(scope, El(child), GlanceModifier) + } + } + } + } + "container" -> { + val alignment = parseContentAlignment(el.str("contentAlignment", "center")) + Box(modifier = modifier, contentAlignment = alignment) { + val children = el.arr("children") + when { + children != null && children.length() == 1 -> { + // Single child: let Box contentAlignment position it (no full-width Column). + RenderElement( + scope.vertical(), + El(children.getJSONObject(0)), + GlanceModifier, + ) + } + children != null && children.length() > 0 -> { + Column( + modifier = GlanceModifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + renderChildrenVertical( + scope, + children, + spacing, + expandWidth = false, + ) + } + } + else -> { + val elType = el.opt("type")?.toString().orEmpty() + Log.w(TAG, "container has no children; elType=$elType") + } + } + } + } + else -> { + val children = el.arr("children") ?: JSONArray() + val cols = el.int("columns", 2).coerceAtLeast(1) + val rowSpacing = el.num("rowSpacing", spacing.toDouble()).toInt().coerceAtLeast(0) + Column(modifier = modifier.fillMaxWidth()) { + val maxRows = GLANCE_CONTAINER_LIMIT + val visibleCols = cols.coerceAtMost(GLANCE_CONTAINER_LIMIT) + val maxChildren = maxRows * visibleCols + if (children.length() > maxChildren) { + Log.w( + TAG, + "grid truncated: requested=${children.length()} rendered=$maxChildren cols=$cols visibleCols=$visibleCols" + ) + } + var i = 0 + var rowIdx = 0 + while (i < children.length() && i < maxChildren) { + if (rowIdx > 0 && rowSpacing > 0) { + Spacer(GlanceModifier.height(rowSpacing.dp)) + } + Row(modifier = GlanceModifier.fillMaxWidth()) { + for (c in 0 until visibleCols) { + if (c > 0 && spacing > 0) { + Spacer(GlanceModifier.width(spacing.dp)) + } + val child = children.optJSONObject(i + c) + if (child != null) { + Box(modifier = GlanceModifier.defaultWeight()) { + RenderElement(scope.horizontal(), El(child), GlanceModifier.fillMaxWidth()) + } + } else { + Spacer(GlanceModifier.defaultWeight()) + } + } + } + i += visibleCols + rowIdx += 1 + } + } + } + } +} + +@Composable +private fun ColumnScope.renderChildrenVertical( + scope: RenderScope, + children: JSONArray?, + spacing: Int, + expandWidth: Boolean = true, +) { + if (children == null) return + val visible = mutableListOf() + for (i in 0 until children.length()) { + val child = children.optJSONObject(i) ?: continue + visible += child + if (visible.size >= GLANCE_CONTAINER_LIMIT) break + } + if (children.length() > visible.size) { + Log.w( + TAG, + "column children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT" + ) + } + if (visible.isEmpty()) return + + val childScope = scope.vertical() + for (idx in visible.indices) { + if (idx > 0 && spacing > 0) { + // Spacer between children — padding(bottom) on thin dividers often collapses in RemoteViews. + Spacer(GlanceModifier.height(spacing.dp)) + } + val child = visible[idx] + val flex = child.optDouble("flex", 0.0) + val type = child.widgetString("type", "") + // Buttons/images/shapes hug content — fillMaxWidth makes Tap a full-bleed bar. + val hugWidth = type in setOf("button", "toggle", "image", "shape", "label") + val widthMod = when { + hugWidth -> GlanceModifier + expandWidth -> GlanceModifier.fillMaxWidth() + else -> GlanceModifier + } + val base = when { + flex > 0.0 -> widthMod.defaultWeight() + type == "spacer" && !child.has("minLength") -> widthMod.defaultWeight() + type == "spacer" -> { + val h = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1) + widthMod.height(h.dp) + } + else -> widthMod + } + RenderElement(childScope, El(child), base) + } +} + +@Composable +private fun RowScope.renderChildrenHorizontal( + scope: RenderScope, + children: JSONArray?, + spacing: Int, +) { + if (children == null) return + val visible = mutableListOf() + for (i in 0 until children.length()) { + val child = children.optJSONObject(i) ?: continue + visible += child + if (visible.size >= GLANCE_CONTAINER_LIMIT) break + } + if (children.length() > visible.size) { + Log.w( + TAG, + "row children truncated: requested=${children.length()} rendered=${visible.size} limit=$GLANCE_CONTAINER_LIMIT" + ) + } + val hasFlex = visible.any { it.optDouble("flex", 0.0) > 0.0 } + val allButtons = visible.isNotEmpty() && visible.all { + it.widgetString("type", "") in setOf("button", "toggle") + } + val childScope = scope.horizontal() + for (idx in visible.indices) { + val child = visible[idx] + if (idx > 0 && spacing > 0) { + Spacer(GlanceModifier.width(spacing.dp)) + } + val t = child.widgetString("type", "") + when (t) { + "divider" -> { + val th = child.optDouble("thickness", 1.0).toFloat().coerceAtLeast(2f).toInt() + Box(modifier = GlanceModifier.width(th.dp).height(48.dp)) { + RenderElement(childScope, El(child), GlanceModifier.fillMaxSize()) + } + } + "spacer" -> { + val min = child.optDouble("minLength", 8.0).toInt().coerceAtLeast(1) + val flex = child.optDouble("flex", 0.0) + val base = if (flex > 0.0 || !child.has("minLength")) { + GlanceModifier.defaultWeight() + } else { + GlanceModifier.width(min.dp) + } + Spacer(modifier = base) + } + else -> { + // Only flex / expanding spacer / equal buttons take weight. + // Title columns wrap to intrinsic width so labels aren't ellipsized + // while the trailing spacer still pushes Sync to the end. + val wantsWeight = when { + child.optDouble("flex", 0.0) > 0.0 -> true + hasFlex -> false + allButtons -> true + else -> false + } + val base = if (wantsWeight) GlanceModifier.defaultWeight() else GlanceModifier + Box(modifier = base) { + RenderElement(childScope, El(child), GlanceModifier) + } + } + } + } +} diff --git a/android/src/main/java/render/RenderMedia.kt b/android/src/main/java/render/RenderMedia.kt new file mode 100644 index 0000000..cdad569 --- /dev/null +++ b/android/src/main/java/render/RenderMedia.kt @@ -0,0 +1,121 @@ +package git.s00d.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.layout.ContentScale +import androidx.glance.layout.height +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import java.util.Locale + +@Composable +internal fun RenderMedia(scope: RenderScope, el: El, modifier: GlanceModifier) { + val context = scope.context + when (el.type) { + "image" -> { + val size = el.num("size", 24.0).toInt().coerceAtLeast(1) + val imageModifier = modifier.size(size.dp) + val provider = imageProviderFromElement(context, el.raw) + if (provider != null) { + Image( + provider = provider, + contentDescription = el.str("alt", ""), + modifier = imageModifier, + contentScale = parseContentScale(el.str("contentMode", "fit")) + ) + } else { + val systemName = el.str("systemName", "") + val url = el.str("url", "") + val fallback = when { + systemName.isNotBlank() -> iconGlyph(systemName) + url.isNotBlank() -> "img:${safeHost(url)}" + else -> "img" + } + val tint = colorProviderArgb( + resolveColorProvider(context, el.opt("color")), + context, + semanticLabelProvider(context).getColor(context).toArgb() + ) + val glyphBitmap = if (systemName.isNotBlank()) { + drawSystemIconBitmap(context, systemName, size, tint) + } else { + drawGlyphBitmap(context, fallback, size, tint) + } + if (glyphBitmap != null) { + Image( + provider = ImageProvider(glyphBitmap), + contentDescription = systemName.ifBlank { "image" }, + modifier = imageModifier, + contentScale = ContentScale.Fit + ) + } else { + val baseStyle = textStyleFromElement(context, el.raw) + val colored = resolveColorProvider(context, el.opt("color")) + val styled = if (colored != null) { + TextStyle(color = colored, fontSize = size.sp, fontWeight = baseStyle.fontWeight, textAlign = baseStyle.textAlign) + } else { + TextStyle(fontSize = size.sp, fontWeight = baseStyle.fontWeight, textAlign = baseStyle.textAlign) + } + Text(fallback, modifier = modifier, style = styled) + } + } + } + "shape" -> { + val size = el.num("size", 18.0).toInt().coerceAtLeast(1) + val shapeType = el.str("shapeType", "circle") + val isCapsule = shapeType.equals("capsule", ignoreCase = true) + // Capsule contract: width = 2*size, height = size (not square) + val shapeModifier = if (isCapsule) { + modifier.width((size * 2).dp).height(size.dp) + } else { + modifier.size(size.dp) + } + val shapeBmp = drawShapeBitmap(context, el.raw, size) + if (shapeBmp != null) { + Image( + provider = ImageProvider(shapeBmp), + contentDescription = shapeType, + modifier = shapeModifier, + contentScale = ContentScale.FillBounds + ) + } else { + val symbol = when (shapeType.lowercase(Locale.US)) { + "capsule" -> "[====]" + "rectangle" -> "[##]" + else -> "(o)" + } + Text(symbol, modifier = shapeModifier, style = textStyleFromElement(context, el.raw)) + } + } + else -> { + renderCanvasElement(context, modifier, el) + } + } +} + +@Composable +private fun renderCanvasElement(context: android.content.Context, modifier: GlanceModifier, el: El) { + val bmp = drawCanvasBitmap(context, el.raw) + if (bmp != null) { + val widthDp = el.num("width", 0.0).toInt() + val heightDp = el.num("height", 0.0).toInt() + var imageModifier = modifier + if (widthDp > 0) imageModifier = imageModifier.width(widthDp.dp) + if (heightDp > 0) imageModifier = imageModifier.height(heightDp.dp) + Image( + provider = ImageProvider(bmp), + contentDescription = "canvas", + modifier = imageModifier, + contentScale = ContentScale.FillBounds + ) + } else { + Text("canvas: empty", modifier = modifier, style = textStyleFromElement(context, el.raw)) + } +} diff --git a/android/src/main/java/render/RenderSpacing.kt b/android/src/main/java/render/RenderSpacing.kt new file mode 100644 index 0000000..04f7ea5 --- /dev/null +++ b/android/src/main/java/render/RenderSpacing.kt @@ -0,0 +1,58 @@ +package git.s00d.widgets + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.width +import androidx.glance.unit.ColorProvider + +@Composable +internal fun RenderSpacing(scope: RenderScope, el: El, modifier: GlanceModifier) { + val context = scope.context + when (el.type) { + "divider" -> { + val line = resolveColorProvider(context, el.opt("color")) ?: ColorProvider(Color.Gray) + // RemoteViews often collapses 1.dp empty boxes — keep ≥2dp. + val thicknessDp = el.num("thickness", 1.0).toFloat().coerceAtLeast(2f) + val thickness = thicknessDp.dp + if (scope.axis == RenderScope.Axis.Horizontal) { + Box( + modifier = modifier + .width(thickness) + .height(48.dp) + .background(line) + ) {} + } else { + // Built-in vertical breath so section separators never sit flush against + // neighbors (parent Column spacing alone still looked tight on Glance). + val edge = 4.dp + Column(modifier = modifier.fillMaxWidth()) { + Spacer(GlanceModifier.height(edge)) + Box( + modifier = GlanceModifier + .fillMaxWidth() + .height(thickness) + .background(line) + ) {} + Spacer(GlanceModifier.height(edge)) + } + } + } + else -> { + // Flex / no-minLength spacers keep Column/Row weight; fixed height would kill flex. + if (el.has("minLength")) { + val h = el.num("minLength", 8.0).toInt().coerceAtLeast(1) + Spacer(modifier.height(h.dp)) + } else { + Spacer(modifier) + } + } + } +} diff --git a/android/src/main/java/render/RenderText.kt b/android/src/main/java/render/RenderText.kt new file mode 100644 index 0000000..aca0dd3 --- /dev/null +++ b/android/src/main/java/render/RenderText.kt @@ -0,0 +1,107 @@ +package git.s00d.widgets + +import android.content.Context +import android.os.Build +import android.os.SystemClock +import android.util.TypedValue +import android.widget.RemoteViews +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.AndroidRemoteViews +import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxWidth +import androidx.glance.text.Text +import androidx.glance.text.TextStyle + +@Composable +internal fun RenderText(scope: RenderScope, el: El, modifier: GlanceModifier) { + val context = scope.context + val type = el.type + when (type) { + "label" -> { + val txt = el.str("text", "") + val icon = iconGlyph(el.str("systemName", "*")) + val iconColor = resolveColorProvider(context, el.opt("iconColor")) + if (iconColor != null) { + Row(modifier = modifier.fillMaxWidth()) { + Text(icon, style = TextStyle(color = iconColor)) + renderElementText(context, el, " $txt") + } + } else { + renderElementText(context, el, "$icon $txt", modifier) + } + } + else -> { + when { + type == "timer" -> renderLiveTimer(context, el, modifier) + else -> { + val content = when (type) { + "date" -> formatDateValue(el.str("date", ""), el.str("dateStyle", "date")) + "timer" -> formatTimerValue( + el.str("targetDate", ""), + el.str("counting", "down") + ) + else -> el.str("content", el.str("text", "")) + } + renderElementText(context, el, content, modifier) + } + } + } + } +} + +@Composable +private fun renderLiveTimer(context: Context, el: El, modifier: GlanceModifier) { + val target = parseIsoDate(el.str("targetDate", "")) + val countingDown = !el.str("counting", "down").equals("up", ignoreCase = true) + if (target == null || (countingDown && Build.VERSION.SDK_INT < 24)) { + renderElementText( + context, + el, + formatTimerValue(el.str("targetDate", ""), el.str("counting", "down")), + modifier, + ) + return + } + val wallDelta = target.time - System.currentTimeMillis() + val base = if (countingDown) { + SystemClock.elapsedRealtime() + wallDelta + } else { + SystemClock.elapsedRealtime() - (System.currentTimeMillis() - target.time) + } + val argb = colorProviderArgb( + resolveColorProvider(context, el.opt("color")), + context, + semanticLabelProvider(context).getColor(context).toArgb(), + ) + val fontSize = el.num( + "fontSize", + semanticTextSizeSp(el.str("textStyle", "")).toDouble(), + ).toFloat().takeIf { it > 0f } ?: 14f + val rv = RemoteViews(context.packageName, R.layout.tauri_chronometer).apply { + setChronometer(R.id.tauri_chrono, base, null, true) + if (Build.VERSION.SDK_INT >= 24) { + setChronometerCountDown(R.id.tauri_chrono, countingDown) + } + setTextColor(R.id.tauri_chrono, argb) + setTextViewTextSize(R.id.tauri_chrono, TypedValue.COMPLEX_UNIT_SP, fontSize) + } + AndroidRemoteViews(remoteViews = rv, modifier = modifier) +} + +@Composable +internal fun renderElementText( + context: Context, + el: El, + content: String, + modifier: GlanceModifier = GlanceModifier, +) { + val style = textStyleFromElement(context, el.raw) + val limit = el.int("lineLimit", -1) + if (limit > 0) { + Text(content, modifier = modifier, style = style, maxLines = limit) + } else { + Text(content, modifier = modifier, style = style) + } +} diff --git a/android/src/main/java/render/Style.kt b/android/src/main/java/render/Style.kt new file mode 100644 index 0000000..bdfaa6c --- /dev/null +++ b/android/src/main/java/render/Style.kt @@ -0,0 +1,75 @@ +package git.s00d.widgets + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import org.json.JSONObject +import java.util.Locale + +@Composable +internal fun applyCommonStyle(context: Context, modifier: GlanceModifier, el: El): GlanceModifier { + var m = modifier + + val slotSize = runCatching { LocalSize.current }.getOrNull() + val chrome = buildStyleChromeBitmap(context, el.raw, slotSize) + if (chrome != null) { + m = m.background(ImageProvider(chrome)) + } else { + val bg = resolveBackgroundProvider(context, el.opt("background")) + if (bg != null) m = m.background(bg) + } + + val frame = el.obj("frame") + if (frame != null) { + val w = frame.optDouble("width", -1.0) + val h = frame.optDouble("height", -1.0) + if (w > 0) m = m.width(w.toInt().dp) + if (h > 0) m = m.height(h.toInt().dp) + } + + val p = el.opt("padding") + if (p is Number) { + m = m.padding(p.toInt().dp) + } else if (p is JSONObject) { + val top = p.optDouble("top", 0.0).toInt().dp + val bottom = p.optDouble("bottom", 0.0).toInt().dp + val start = p.optDouble("leading", p.optDouble("start", 0.0)).toInt().dp + val end = p.optDouble("trailing", p.optDouble("end", 0.0)).toInt().dp + m = m.padding(start = start, top = top, end = end, bottom = bottom) + } + + val explicitRadius = el.num("cornerRadius", -1.0).toFloat() + val clipShape = el.str("clipShape", "").lowercase(Locale.US) + val frameW = frame?.optDouble("width", -1.0)?.toFloat() ?: -1f + val frameH = frame?.optDouble("height", -1.0)?.toFloat() ?: -1f + val clipRadius = when { + explicitRadius > 0f -> explicitRadius + clipShape == "circle" -> { + val base = listOf(frameW, frameH, el.num("size", -1.0).toFloat()) + .filter { it > 0f } + .minOrNull() ?: 24f + (base / 2f).coerceAtLeast(1f) + } + clipShape == "capsule" -> { + val base = listOf(frameW, frameH, el.num("size", -1.0).toFloat()) + .filter { it > 0f } + .minOrNull() ?: 28f + (base / 2f).coerceAtLeast(1f) + } + clipShape == "rectangle" -> 0f + else -> 0f + } + if (clipRadius > 0f) { + m = m.cornerRadius(clipRadius.dp) + } + + return m +} diff --git a/android/src/main/java/render/WireTypes.kt b/android/src/main/java/render/WireTypes.kt new file mode 100644 index 0000000..af3b2f0 --- /dev/null +++ b/android/src/main/java/render/WireTypes.kt @@ -0,0 +1,54 @@ +// AUTO-GENERATED by `cargo run --bin gen-native --features codegen` +// Do not edit. Source: `src/codegen.rs` IR_ELEMENTS. +// Runtime still uses org.json.JSONObject via render.El. + +package git.s00d.widgets + +/** Known `type` wire values for widget IR elements. */ +object WireTypes { + const val VSTACK = "vstack" + const val HSTACK = "hstack" + const val ZSTACK = "zstack" + const val GRID = "grid" + const val CONTAINER = "container" + const val TEXT = "text" + const val IMAGE = "image" + const val PROGRESS = "progress" + const val GAUGE = "gauge" + const val BUTTON = "button" + const val TOGGLE = "toggle" + const val DIVIDER = "divider" + const val DATE = "date" + const val CHART = "chart" + const val LIST = "list" + const val LINK = "link" + const val SHAPE = "shape" + const val TIMER = "timer" + const val CANVAS = "canvas" + const val LABEL = "label" + const val SPACER = "spacer" + + val ALL: Set = setOf( + VSTACK, + HSTACK, + ZSTACK, + GRID, + CONTAINER, + TEXT, + IMAGE, + PROGRESS, + GAUGE, + BUTTON, + TOGGLE, + DIVIDER, + DATE, + CHART, + LIST, + LINK, + SHAPE, + TIMER, + CANVAS, + LABEL, + SPACER, + ) +} diff --git a/android/src/main/res/layout/tauri_chronometer.xml b/android/src/main/res/layout/tauri_chronometer.xml new file mode 100644 index 0000000..c3a3898 --- /dev/null +++ b/android/src/main/res/layout/tauri_chronometer.xml @@ -0,0 +1,6 @@ + + diff --git a/android/src/test/java/git/s00d/widgets/ElTest.kt b/android/src/test/java/git/s00d/widgets/ElTest.kt new file mode 100644 index 0000000..a4aff09 --- /dev/null +++ b/android/src/test/java/git/s00d/widgets/ElTest.kt @@ -0,0 +1,62 @@ +package git.s00d.widgets + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit coverage for El typed JSON accessors. */ +class ElTest { + @Test + fun str_returnsDefaultWhenMissing() { + val el = El(JSONObject("""{"type":"text"}""")) + assertEquals("", el.str("content")) + assertEquals("x", el.str("content", "x")) + } + + @Test + fun str_readsString() { + val el = El(JSONObject("""{"content":"hello"}""")) + assertEquals("hello", el.str("content")) + } + + @Test + fun num_nullWhenMissing() { + val el = El(JSONObject("{}")) + assertNull(el.num("x")) + } + + @Test + fun num_readsDouble() { + val el = El(JSONObject("""{"x":3.5}""")) + assertEquals(3.5, el.num("x")!!, 0.001) + } + + @Test + fun num_defaultWhenMissing() { + val el = El(JSONObject("{}")) + assertEquals(2.0, el.num("x", 2.0), 0.001) + } + + @Test + fun bool_defaultsFalse() { + val el = El(JSONObject("{}")) + assertFalse(el.bool("checked")) + assertTrue(el.bool("checked", true)) + } + + @Test + fun bool_readsValue() { + val el = El(JSONObject("""{"checked":true}""")) + assertTrue(el.bool("checked")) + } + + @Test + fun bool_nullJsonIsDefault() { + val el = El(JSONObject("""{"checked":null}""")) + assertFalse(el.bool("checked")) + assertTrue(el.bool("checked", true)) + } +} diff --git a/android/src/test/java/git/s00d/widgets/RenderGroupsTest.kt b/android/src/test/java/git/s00d/widgets/RenderGroupsTest.kt new file mode 100644 index 0000000..6b967dd --- /dev/null +++ b/android/src/test/java/git/s00d/widgets/RenderGroupsTest.kt @@ -0,0 +1,61 @@ +package git.s00d.widgets + +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Guard for the six-group [RenderElement] dispatcher. + * Renders one fixture covering layout/text/media/data/interactive/spacing and + * asserts [RenderTrace] saw each group — catches a type dropped from the when(). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RenderGroupsTest { + @Test + fun allSixDocsGroupsReachRenderTrace() { + val cfg = JSONObject( + """ + { + "small": { + "type": "vstack", + "spacing": 4, + "children": [ + { "type": "text", "content": "hi", "fontSize": 12 }, + { "type": "shape", "shapeType": "circle", "size": 12, "fill": "#00ff00" }, + { "type": "progress", "value": 0.4, "total": 1, "barStyle": "linear" }, + { "type": "button", "label": "Go", "action": "tap" }, + { "type": "spacer", "minLength": 6 }, + { "type": "divider" } + ] + } + } + """.trimIndent(), + ).toString() + + RenderTrace.begin() + val rendered = RenderHarness.render(cfg, "small") + assertTrue("empty bitmap", rendered.bitmap.width > 0 && rendered.bitmap.height > 0) + + val types = RenderTrace.renderedTypes().toSet() + val required = listOf( + "vstack", // layout + "text", // text + "shape", // media + "progress", // data + "button", // interactive + "spacer", // spacing + "divider", // spacing + ) + val missing = required.filter { it !in types } + if (missing.isNotEmpty()) { + fail("RenderTrace missing $missing — saw $types (dispatcher drop?)") + } + } +} diff --git a/artifacts/STATUS.md b/artifacts/STATUS.md index 6767bb3..c2a05c5 100644 --- a/artifacts/STATUS.md +++ b/artifacts/STATUS.md @@ -1,8 +1,13 @@ # Example build status (local) -Rebuilt: `2026-08-02T14:26Z` — Tasks medium layout + widget→host AppIntent appGroup fix. +Rebuilt: `2026-08-03T08:36Z` — release/0.5.0 mega-refactor. | Platform | Artifact | Notes | |----------|----------|-------| -| macOS | `macos/widget-example.app` (+ `/Applications`) | OK | -| iOS / Android / Linux / Windows | — | unchanged this run | +| macOS | `macos/widget-example.app`, binary, `widget-example_0.5.0_aarch64.dmg` | OK | +| iOS | `ios/widget-example-iphoneos.app`, `iphonesimulator.app`, `.ipa` | OK | +| Android | `android/app-universal-release-unsigned.apk` | OK (unsigned, aarch64) | +| Linux | `linux/widget-example` (+ `tauri-plugin-widgets-example`) | OK (Docker `widgets-linux`, aarch64 ELF) | +| Windows | — | SKIP — UTM `utm-win` SSH unreachable | + +Logs under `artifacts/logs/rebuild-all-*`, `rebuild-linux-*`, `rebuild-macos-*`. diff --git a/bin/cli.mjs b/bin/cli.mjs deleted file mode 100755 index b17445e..0000000 --- a/bin/cli.mjs +++ /dev/null @@ -1,862 +0,0 @@ -#!/usr/bin/env node - -import { defineCommand, runMain } from "citty"; -import { readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync, existsSync, readdirSync } from "node:fs"; -import { resolve, dirname, join, basename } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const PLUGIN_ROOT = resolve(__dirname, ".."); - -function findTauriConf(cwd) { - const candidates = [ - join(cwd, "src-tauri", "tauri.conf.json"), - join(cwd, "tauri.conf.json"), - ]; - for (const c of candidates) { - if (existsSync(c)) return c; - } - return null; -} - -function readTauriConf(cwd) { - const confPath = findTauriConf(cwd); - if (!confPath) return null; - try { - return { path: confPath, data: JSON.parse(readFileSync(confPath, "utf-8")) }; - } catch { - return null; - } -} - -function detectTauriIdentifier(conf) { - const id = String(conf?.data?.identifier || "").trim(); - return id || null; -} - -function replaceAll(str, search, replacement) { - return str.split(search).join(replacement); -} - -function copyTemplate(src, dest, replacements) { - let content = readFileSync(src, "utf-8"); - for (const [search, replacement] of Object.entries(replacements)) { - content = replaceAll(content, search, replacement); - } - writeFileSync(dest, content, "utf-8"); -} - -function widgetTemplateReplacements(appGroup, opts = {}) { - const widgetKind = opts.widgetKind || "MyTauriWidget"; - const widgetId = opts.widgetId || "default"; - return { - "{{APP_GROUP}}": appGroup, - "{{WIDGET_KIND}}": widgetKind, - "{{WIDGET_ID}}": widgetId, - "group.com.example.myapp": appGroup, - }; -} - -function renderIosWidgetSwift(templatePath, destPath, appGroup) { - let content = readFileSync(templatePath, "utf-8"); - const replacements = widgetTemplateReplacements(appGroup); - for (const [search, replacement] of Object.entries(replacements)) { - content = replaceAll(content, search, replacement); - } - - // Xcode-generated widget targets usually include `Bundle.swift` with @main. - // In that case this file should declare `struct : Widget` (without @main) - // so Bundle can reference it. - const widgetName = basename(destPath, ".swift"); - const bundlePath = join(dirname(destPath), `${widgetName}Bundle.swift`); - if (existsSync(bundlePath)) { - content = content.replace(/@main\s*\n\s*struct\s+MyWidget\s*:\s*Widget/, `struct ${widgetName}: Widget`); - } - - return content; -} - -function findIosWidgetEntryFiles(cwd) { - const appleDir = resolve(cwd, "src-tauri", "gen", "apple"); - if (!existsSync(appleDir)) return []; - - const entries = readdirSync(appleDir, { withFileTypes: true }); - const result = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const name = entry.name; - - if ( - name.startsWith(".") || - name.endsWith(".xcodeproj") || - name === "Externals" || - name === "Sources" || - name === "assets" || - name === "Assets.xcassets" - ) { - continue; - } - - const dirPath = join(appleDir, name); - const files = readdirSync(dirPath, { withFileTypes: true }) - .filter((f) => f.isFile() && f.name.endsWith(".swift")) - .map((f) => join(dirPath, f.name)); - - const candidates = files.filter((filePath) => { - const fileName = filePath.split("/").pop() || ""; - if (fileName.endsWith("Bundle.swift")) return false; - const content = readFileSync(filePath, "utf-8"); - return content.includes("import WidgetKit") && content.includes("WidgetConfiguration"); - }); - - if (!candidates.length) continue; - - const preferred = candidates.find((p) => p.endsWith(`/${name}.swift`)); - result.push(preferred || candidates[0]); - } - - return Array.from(new Set(result)); -} - -function findIosWidgetTargetNames(cwd) { - const appleDir = resolve(cwd, "src-tauri", "gen", "apple"); - if (!existsSync(appleDir)) return []; - - const entries = readdirSync(appleDir, { withFileTypes: true }); - const targets = new Set(); - - for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.endsWith(".xcodeproj")) continue; - const pbxprojPath = join(appleDir, entry.name, "project.pbxproj"); - if (!existsSync(pbxprojPath)) continue; - - const pbx = readFileSync(pbxprojPath, "utf-8"); - const sectionStart = pbx.indexOf("/* Begin PBXNativeTarget section */"); - const sectionEnd = pbx.indexOf("/* End PBXNativeTarget section */"); - if (sectionStart === -1 || sectionEnd === -1 || sectionEnd <= sectionStart) continue; - - const section = pbx.slice(sectionStart, sectionEnd); - const blockRe = /\bisa = PBXNativeTarget;[\s\S]*?};/g; - let match; - while ((match = blockRe.exec(section)) !== null) { - const block = String(match[0] || ""); - if (!block.includes('productType = "com.apple.product-type.app-extension";')) continue; - - const nameMatch = block.match(/\bname = ([^;]+);/); - const rawName = String(nameMatch?.[1] || "").trim(); - if (!rawName) continue; - const cleanName = rawName.replace(/^"|"$/g, ""); - if (cleanName) targets.add(cleanName); - } - } - - return Array.from(targets); -} - -// ─── init-macos ───────────────────────────────────────────────────────────── - -const initMacos = defineCommand({ - meta: { - name: "init-macos", - description: "Initialize macOS widget extension in src-tauri/macos-widget/", - }, - args: { - "bundle-id": { - type: "string", - description: "Widget bundle identifier (e.g. com.example.myapp.widgetkit). Auto-detected from tauri.conf.json if omitted.", - }, - "app-group": { - type: "string", - description: "App Group identifier (e.g. group.com.example.myapp). Auto-detected from tauri.conf.json if omitted.", - }, - dir: { - type: "string", - description: "Target directory (default: src-tauri/macos-widget)", - default: "src-tauri/macos-widget", - }, - force: { - type: "boolean", - description: "Overwrite existing files", - default: false, - }, - }, - run({ args }) { - const cwd = process.cwd(); - const conf = readTauriConf(cwd); - - let bundleId = args["bundle-id"]; - let appGroup = args["app-group"]; - - if (!bundleId || !appGroup) { - if (!conf) { - console.error("ERROR: Could not find tauri.conf.json. Run from your Tauri project root, or pass --bundle-id and --app-group explicitly."); - process.exit(1); - } - const identifier = detectTauriIdentifier(conf); - if (!identifier) { - console.error("ERROR: tauri.conf.json is missing 'identifier'. Set it, or pass --bundle-id and --app-group explicitly."); - process.exit(1); - } - if (!bundleId) { - bundleId = identifier + ".widgetkit"; - console.log(` Auto-detected bundle-id: ${bundleId}`); - } - if (!appGroup) { - appGroup = "group." + identifier; - console.log(` Auto-detected app-group: ${appGroup}`); - } - } - - const widgetDir = resolve(cwd, args.dir); - const templateDir = join(PLUGIN_ROOT, "templates", "macos-widget"); - const swiftPackagePath = resolve(PLUGIN_ROOT, "swift"); - - if (existsSync(widgetDir) && !args.force) { - console.error(`ERROR: ${args.dir} already exists. Use --force to overwrite.`); - process.exit(1); - } - - console.log(`\nCreating macOS widget extension in ${args.dir}...\n`); - - mkdirSync(join(widgetDir, "Sources"), { recursive: true }); - - copyTemplate( - join(templateDir, "MyWidget.swift"), - join(widgetDir, "Sources", "MyWidget.swift"), - widgetTemplateReplacements(appGroup), - ); - console.log(" Created Sources/MyWidget.swift"); - - copyTemplate( - join(templateDir, "Entitlements.plist"), - join(widgetDir, "TauriWidgetExtension.entitlements"), - { "group.com.example.myapp": appGroup }, - ); - console.log(" Created TauriWidgetExtension.entitlements"); - - copyTemplate( - join(templateDir, "App.entitlements"), - join(widgetDir, "App.entitlements"), - { "group.com.example.myapp": appGroup }, - ); - console.log(" Created App.entitlements"); - - copyTemplate( - join(templateDir, "project.yml"), - join(widgetDir, "project.yml"), - { - "TAURI_WIDGETS_SWIFT_PATH": swiftPackagePath, - "WIDGET_BUNDLE_ID": bundleId, - }, - ); - console.log(" Created project.yml"); - - copyFileSync( - join(templateDir, "Info.plist"), - join(widgetDir, "Info.plist"), - ); - console.log(" Created Info.plist"); - - copyFileSync( - join(templateDir, "build-widget.sh"), - join(widgetDir, "build-widget.sh"), - ); - chmodSync(join(widgetDir, "build-widget.sh"), 0o755); - console.log(" Created build-widget.sh"); - - copyFileSync( - join(templateDir, "embed-widget.sh"), - join(widgetDir, "embed-widget.sh"), - ); - chmodSync(join(widgetDir, "embed-widget.sh"), 0o755); - console.log(" Created embed-widget.sh"); - - writeFileSync(join(widgetDir, ".gitignore"), "build/\n*.xcodeproj\nxcuserdata/\n"); - console.log(" Created .gitignore"); - - // Auto-patch tauri.conf.json — .appex ships via beforeBundle + macOS.files - if (conf) { - let modified = false; - const data = conf.data; - const widgetRel = args.dir.replace(/\\/g, "/"); - const appexSrc = `./${widgetRel.replace(/^src-tauri\//, "")}/build/Build/Products/Release/TauriWidgetExtension.appex`; - // Paths in bundle.macOS are relative to src-tauri/ - const entitlementsRel = `./${widgetRel.replace(/^src-tauri\//, "")}/App.entitlements`; - const beforeCmd = `./${widgetRel}/build-widget.sh`; - - if (!data.build) data.build = {}; - if (!data.build.beforeBundleCommand) { - data.build.beforeBundleCommand = beforeCmd; - modified = true; - console.log(` Set build.beforeBundleCommand → ${beforeCmd}`); - } else if (String(data.build.beforeBundleCommand).includes("|| true")) { - data.build.beforeBundleCommand = String(data.build.beforeBundleCommand) - .replace(/\s*\|\|\s*true\s*$/, "") - .trim(); - modified = true; - console.log(" Removed '|| true' from beforeBundleCommand"); - } - - if (!data.bundle) data.bundle = {}; - if (!data.bundle.macOS) data.bundle.macOS = {}; - const mac = data.bundle.macOS; - - if (!mac.entitlements) { - mac.entitlements = entitlementsRel; - modified = true; - console.log(` Set bundle.macOS.entitlements → ${entitlementsRel}`); - } - - if (!mac.files) mac.files = {}; - const plugKey = "PlugIns/TauriWidgetExtension.appex"; - if (!mac.files[plugKey]) { - mac.files[plugKey] = appexSrc; - modified = true; - console.log(` Set bundle.macOS.files[${plugKey}] → ${appexSrc}`); - } - - if (modified) { - writeFileSync(conf.path, JSON.stringify(data, null, 2) + "\n", "utf-8"); - console.log(`\n Updated ${conf.path.replace(cwd + "/", "")}`); - } - } - - console.log(` -Done! Next steps: - - 1. Install xcodegen if not already: - brew install xcodegen - - 2. Build (builds .appex, copies into Contents/PlugIns/, signs, DMG): - pnpm tauri build - - Bundle ID: ${bundleId} - App Group: ${appGroup} - - Note: embed-widget.sh is deprecated — PlugIns come from bundle.macOS.files. -`); - }, -}); - -// ─── init-ios ────────────────────────────────────────────────────────────── - -const initIos = defineCommand({ - meta: { - name: "init-ios", - description: "Copy iOS widget extension template to your project", - }, - args: { - "app-group": { - type: "string", - description: "App Group identifier (e.g. group.com.example.myapp). Auto-detected from tauri.conf.json if omitted.", - }, - dir: { - type: "string", - description: "Target directory for the widget Swift file (default: src-tauri/ios-widget)", - default: "src-tauri/ios-widget", - }, - force: { - type: "boolean", - description: "Overwrite existing files", - default: false, - }, - }, - run({ args }) { - const cwd = process.cwd(); - const conf = readTauriConf(cwd); - - let appGroup = args["app-group"]; - - if (!appGroup) { - if (!conf) { - console.error("ERROR: Could not find tauri.conf.json. Run from your Tauri project root, or pass --app-group explicitly."); - process.exit(1); - } - const identifier = detectTauriIdentifier(conf); - if (!identifier) { - console.error("ERROR: tauri.conf.json is missing 'identifier'. Set it, or pass --app-group explicitly."); - process.exit(1); - } - appGroup = "group." + identifier; - console.log(` Auto-detected app-group: ${appGroup}`); - } - - const targetDir = resolve(cwd, args.dir); - const templateDir = join(PLUGIN_ROOT, "templates", "ios-widget"); - - console.log(`\nPreparing iOS widget template in ${args.dir}...\n`); - - mkdirSync(targetDir, { recursive: true }); - - const templatePath = join(templateDir, "MyWidget.swift"); - const localTemplatePath = join(targetDir, "MyWidget.swift"); - - if (!existsSync(localTemplatePath) || args.force) { - copyTemplate(templatePath, localTemplatePath, widgetTemplateReplacements(appGroup)); - console.log(" Wrote ios-widget/MyWidget.swift"); - } else { - console.log(" Kept existing ios-widget/MyWidget.swift (use --force to overwrite)"); - } - - const xcodeWidgetFiles = findIosWidgetEntryFiles(cwd); - const widgetTargets = findIosWidgetTargetNames(cwd); - if (xcodeWidgetFiles.length) { - for (const filePath of xcodeWidgetFiles) { - const rendered = renderIosWidgetSwift(templatePath, filePath, appGroup); - writeFileSync(filePath, rendered, "utf-8"); - const pretty = filePath.replace(cwd + "/", ""); - console.log(` Synced ${pretty}`); - } - } else { - console.log(" No WidgetExtension swift file detected in src-tauri/gen/apple (create target in Xcode, then run init-ios again)"); - } - - console.log(` -Done! Next steps: - - 1. Run: pnpm tauri ios init - - 2. Open Xcode: - open src-tauri/gen/apple/*.xcodeproj - - 3. File → New → Target → Widget Extension - - 4. Add TauriWidgets Swift Package: - File → Add Package Dependencies → Add Local → - select node_modules/tauri-plugin-widgets-api/swift/ - Add to Target: ${widgetTargets.length ? widgetTargets.join(", ") : "WidgetExtension (or WidgetExtensionExtension)"} - - 5. Re-run this command after creating the Widget Extension target: - npx tauri-plugin-widgets-api init-ios - (it auto-syncs generated WidgetExtension.swift in src-tauri/gen/apple/*) - - 6. Enable App Groups in BOTH targets with: - ${appGroup} - - 7. Run: pnpm tauri ios dev -`); - }, -}); - -// ─── init-windows ────────────────────────────────────────────────────────── - -const initWindows = defineCommand({ - meta: { - name: "init-windows", - description: "Initialize Windows Widgets Board provider (C# Adaptive Cards) in src-tauri/windows-widget/", - }, - args: { - "display-name": { - type: "string", - description: "Widget display name shown on Widgets Board", - }, - "package-name": { - type: "string", - description: "Assembly / package name stem (e.g. MyApp). Auto-detected from tauri.conf.json if omitted.", - }, - "clsid": { - type: "string", - description: "COM CLSID for IWidgetProvider (GUID). Generated if omitted.", - }, - dir: { - type: "string", - description: "Target directory (default: src-tauri/windows-widget)", - default: "src-tauri/windows-widget", - }, - force: { - type: "boolean", - description: "Overwrite existing files", - default: false, - }, - }, - run({ args }) { - const cwd = process.cwd(); - const conf = readTauriConf(cwd); - - let packageName = args["package-name"]; - let displayName = args["display-name"]; - let clsid = args.clsid; - - if (!packageName) { - const identifier = conf ? detectTauriIdentifier(conf) : null; - if (identifier) { - const parts = identifier.split("."); - packageName = parts[parts.length - 1] || "TauriApp"; - console.log(` Auto-detected package-name: ${packageName}`); - } else { - packageName = "TauriApp"; - console.log(` Using default package-name: ${packageName}`); - } - } - - if (!displayName) { - displayName = conf?.data?.productName || packageName; - console.log(` Using display-name: ${displayName}`); - } - - if (!clsid) { - clsid = cryptoRandomGuid(); - console.log(` Generated CLSID: ${clsid}`); - } else { - clsid = String(clsid).replace(/[{}]/g, "").toLowerCase(); - } - - const widgetDir = resolve(cwd, args.dir); - const templateDir = join(PLUGIN_ROOT, "templates", "windows-widget"); - - if (existsSync(widgetDir) && !args.force) { - console.error(`ERROR: ${args.dir} already exists. Use --force to overwrite.`); - process.exit(1); - } - - const extensionId = `${packageName}.Widgets`; - const definitionId = `${packageName}.Widget`; - - const replacements = { - "{{WIDGET_PROVIDER_CLSID}}": clsid, - "{{WIDGET_DISPLAY_NAME}}": displayName, - "{{PACKAGE_NAME}}": packageName, - "{{WIDGET_EXTENSION_ID}}": extensionId, - "{{WIDGET_DEFINITION_ID}}": definitionId, - }; - - console.log(`\nCreating Windows widget provider in ${args.dir}...\n`); - - mkdirSync(join(widgetDir, "WidgetProvider"), { recursive: true }); - - for (const name of ["Provider.cs", "Store.cs", "Program.cs", "WidgetProvider.csproj"]) { - copyTemplate( - join(templateDir, "WidgetProvider", name), - join(widgetDir, "WidgetProvider", name), - replacements, - ); - console.log(` Created WidgetProvider/${name}`); - } - - mkdirSync(join(widgetDir, "PreviewHost"), { recursive: true }); - for (const name of ["Program.cs", "PreviewHost.csproj"]) { - copyTemplate( - join(templateDir, "PreviewHost", name), - join(widgetDir, "PreviewHost", name), - replacements, - ); - console.log(` Created PreviewHost/${name}`); - } - - mkdirSync(join(widgetDir, "Assets"), { recursive: true }); - const logoSrc = join(templateDir, "Assets", "StoreLogo.png"); - if (existsSync(logoSrc)) { - copyFileSync(logoSrc, join(widgetDir, "Assets", "StoreLogo.png")); - console.log(" Created Assets/StoreLogo.png"); - } - - copyTemplate( - join(templateDir, "Package.appxmanifest.fragment.xml"), - join(widgetDir, "Package.appxmanifest.fragment.xml"), - replacements, - ); - console.log(" Created Package.appxmanifest.fragment.xml"); - - copyTemplate( - join(templateDir, "README.md"), - join(widgetDir, "README.md"), - replacements, - ); - console.log(" Created README.md"); - - writeFileSync( - join(widgetDir, ".gitignore"), - "bin/\nobj/\n*.user\n.vs/\nAppPackages/\nBundleArtifacts/\n", - "utf-8", - ); - console.log(" Created .gitignore"); - - console.log(` -Done! Next steps: - - 1. Merge Package.appxmanifest.fragment.xml into your MSIX Package.appxmanifest - (add xmlns:com and xmlns:uap3 if missing). Copy Assets/StoreLogo.png into the package. - - 2. Point the provider at the Rust host store: - set TAURI_WIDGETS_DATA to the directory that contains widget_data.json - - 3. Build smoke (no VS Appx tools): - dotnet build ${args.dir}/WidgetProvider/WidgetProvider.csproj -c Release -p:Smoke=true - - 4. Full provider (after VS Build Tools / WinAppSDK): - dotnet build ${args.dir}/WidgetProvider/WidgetProvider.csproj -c Release -p:Smoke=false - - 5. Pack / sideload helpers (from plugin repo / UTM): - just win-pack && just win-sideload - - Display name: ${displayName} - Package name: ${packageName} - CLSID: ${clsid} -`); - }, -}); - -function cryptoRandomGuid() { - // RFC4122 v4-ish GUID for COM CLSID placeholders. - const bytes = new Uint8Array(16); - for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} - -function findEntitlementsFiles(cwd) { - // Walk src-tauri once (covers macos-widget + gen/apple). Dedupe by real path. - const root = join(cwd, "src-tauri"); - const seen = new Set(); - const out = []; - if (!existsSync(root)) return out; - const walk = (dir, depth = 0) => { - if (depth > 6) return; - let entries = []; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - if (e.name === "node_modules" || e.name === "target" || e.name.startsWith(".")) continue; - const p = join(dir, e.name); - if (e.isDirectory()) { - walk(p, depth + 1); - continue; - } - if (!e.name.endsWith(".entitlements")) continue; - let key = p; - try { - key = resolve(p); - } catch { - /* ignore */ - } - if (seen.has(key)) continue; - seen.add(key); - out.push(p); - } - }; - walk(root); - return out; -} - -function extractAppGroups(filePath) { - try { - const raw = readFileSync(filePath, "utf-8"); - const groups = []; - const re = /group\.[A-Za-z0-9._-]+/g; - let m; - while ((m = re.exec(raw))) groups.push(m[0]); - return [...new Set(groups)]; - } catch { - return []; - } -} - -function findFilesNamed(dir, name, depth = 0, out = []) { - if (depth > 5 || !existsSync(dir)) return out; - let entries = []; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return out; - } - for (const e of entries) { - const p = join(dir, e.name); - if (e.isFile() && e.name === name) out.push(p); - else if (e.isDirectory() && !e.name.startsWith(".")) findFilesNamed(p, name, depth + 1, out); - } - return out; -} - -function loadTraceFile(cwd, identifier) { - const candidates = [ - join(cwd, "src-tauri", "target", "debug", "widgets", "widget_trace.json"), - join(cwd, "src-tauri", "target", "release", "widgets", "widget_trace.json"), - ]; - const home = process.env.HOME || ""; - if (home) { - const support = join(home, "Library", "Application Support"); - // Tauri app data: Application Support//widgets/widget_trace.json - if (identifier) { - candidates.push(join(support, identifier, "widgets", "widget_trace.json")); - } - candidates.push(...findFilesNamed(support, "widget_trace.json").slice(0, 8)); - } - for (const c of candidates) { - if (!c || !existsSync(c)) continue; - try { - const events = JSON.parse(readFileSync(c, "utf-8")); - if (Array.isArray(events)) return { path: c, events }; - } catch { - /* ignore */ - } - } - return null; -} - -const doctor = defineCommand({ - meta: { - name: "doctor", - description: "Static + optional runtime checks for widget transport / signing / bundle", - }, - args: { - cwd: { - type: "positional", - description: "App project root (default: cwd)", - required: false, - }, - }, - async run({ args }) { - const cwd = resolve(args.cwd || process.cwd()); - let failures = 0; - let warnings = 0; - const ok = (msg) => console.log(` ✓ ${msg}`); - const bad = (msg) => { - failures++; - console.log(` ✗ ${msg}`); - }; - const warn = (msg) => { - warnings++; - console.log(` ⚠ ${msg}`); - }; - - console.log("transport"); - const conf = readTauriConf(cwd); - if (!conf) { - bad("tauri.conf.json not found under src-tauri/ or cwd"); - } else { - const widgets = conf.data?.plugins?.widgets || {}; - const transport = widgets.transport || "auto"; - const appGroup = widgets.appGroup || ""; - ok(`${conf.path}`); - ok(`plugins.widgets.transport = ${transport}`); - if (!appGroup) bad("plugins.widgets.appGroup is empty"); - else ok(`appGroup = ${appGroup}`); - - if (transport === "widgetContainer") { - warn("widgetContainer is macOS ad-hoc friendly; invalid on iOS (use appGroup)"); - } - if (transport === "appGroup" && !appGroup) { - bad("transport=appGroup requires plugins.widgets.appGroup"); - } - if (transport === "auto") { - warn("transport=auto is for local dev — pin appGroup or widgetContainer before release"); - } - } - - console.log("entitlements"); - const appGroupConf = conf?.data?.plugins?.widgets?.appGroup || ""; - const identifier = detectTauriIdentifier(conf) || ""; - const ents = findEntitlementsFiles(cwd); - if (!ents.length) warn("no .entitlements files found under src-tauri"); - const allGroups = new Set(); - for (const f of ents) { - const gs = extractAppGroups(f); - gs.forEach((g) => allGroups.add(g)); - const rel = f.startsWith(cwd) ? f.slice(cwd.length + 1) : f; - if (appGroupConf && gs.length && !gs.includes(appGroupConf)) { - bad(`${rel}: App Groups ${gs.join(", ")} ≠ conf ${appGroupConf}`); - } else if (appGroupConf && gs.includes(appGroupConf)) { - ok(`${rel} contains ${appGroupConf}`); - } else if (gs.length) { - ok(`${rel}: ${gs.join(", ")}`); - } - } - if (appGroupConf && allGroups.size && !allGroups.has(appGroupConf)) { - bad(`no entitlements file lists ${appGroupConf}`); - } - - console.log("\nbundle"); - const appexCandidates = [ - join(cwd, "src-tauri", "target", "release", "bundle", "macos"), - join(cwd, "artifacts", "macos"), - join(PLUGIN_ROOT, "artifacts", "macos"), - ]; - let foundAppex = false; - for (const root of appexCandidates) { - if (!existsSync(root)) continue; - const walk = (dir, depth = 0) => { - if (depth > 5 || foundAppex) return; - let entries = []; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - const p = join(dir, e.name); - if (e.isDirectory() && e.name.endsWith(".appex")) { - foundAppex = true; - ok(`.appex found: ${p}`); - return; - } - if (e.isDirectory()) walk(p, depth + 1); - } - }; - walk(root); - } - if (!foundAppex) warn("no .appex found in release bundle / artifacts (ok if not built yet)"); - - console.log("\nruntime (trace)"); - const trace = loadTraceFile(cwd, identifier); - if (!trace) { - warn( - "widget_trace.json not found — run `pnpm tauri dev` once (debug journals by default), or set WIDGET_DEBUG=1 for release", - ); - } else { - ok(`trace ${trace.path} (${trace.events.length} events)`); - const dayAgo = Date.now() - 86_400_000; - const recent = trace.events.filter((e) => Number(e.ts) >= dayAgo); - const reloads = recent.filter((e) => e.kind === "reload"); - const throttled = reloads.filter( - (e) => e.reason && (e.reason.outcome === "throttled" || e.reason.Throttled), - ); - if (throttled.length) { - bad(`${throttled.length}/${reloads.length} reloads throttled in last 24h — TAURI_WIDGET_MIN_RELOAD_SECS or skipReload`); - } else if (reloads.length) { - ok(`${reloads.length} reload events in last 24h`); - } - if (reloads.length >= 60) { - warn(`${reloads.length} reloads/24h — near WidgetKit daily budget`); - } - const renders = recent.filter((e) => e.kind === "render"); - if (renders.length) { - const last = renders[renders.length - 1]; - const ageSec = Math.round((Date.now() - Number(last.ts)) / 1000); - ok(`last render ${ageSec}s ago, nonce ${last.nonce}, trigger=${last.trigger}`); - } else { - warn("no render events in trace (native/desktop widget may not have painted)"); - } - } - - console.log("\nschema"); - ok("crate receipt schema=1 (host + Swift/Kotlin default)"); - - console.log(`\n${failures ? "FAIL" : "OK"} — ${failures} error(s), ${warnings} warning(s)`); - process.exitCode = failures ? 1 : 0; - }, -}); - -// ─── Main ────────────────────────────────────────────────────────────────── - -const main = defineCommand({ - meta: { - name: "tauri-widgets", - version: "0.4.0", - description: "CLI for tauri-plugin-widgets — initialize native widget extensions", - }, - subCommands: { - "init-macos": initMacos, - "init-ios": initIos, - "init-windows": initWindows, - doctor, - }, -}); - -runMain(main); diff --git a/build.rs b/build.rs index 64d641b..5c58a8d 100755 --- a/build.rs +++ b/build.rs @@ -35,8 +35,10 @@ fn main() { result.unwrap(); } - // Skip native Swift bridge when building codegen tooling (avoids duplicate `main`). - if target.contains("apple-darwin") && std::env::var("CARGO_FEATURE_CODEGEN").is_err() { + // Always compile the WidgetKit reload bridge on macOS host builds. + // `swiftc -parse-as-library` already avoids a duplicate `main`; gating on + // `codegen` left `macos_transport` with undefined symbols under `--all-features`. + if target.contains("apple-darwin") { let sdk = macos_sdk_path(); let arch = if target.contains("aarch64") { "arm64" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9e89a45..b72606d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -12,7 +12,9 @@ const sidebar = [ { text: "Install", link: "/guide/install" }, { text: "First widget", link: "/guide/first-widget" }, { text: "Concepts", link: "/guide/concepts" }, - { text: "Transport", link: "/guide/transport" }, + { text: "Transport & signing", link: "/guide/transport" }, + { text: "Doctor", link: "/guide/doctor" }, + { text: "CLI", link: "/guide/cli" }, { text: "Core vs extended", link: "/guide/tiers" }, { text: "Troubleshooting", link: "/guide/troubleshooting" }, ], diff --git a/docs/api/cargo-features.md b/docs/api/cargo-features.md index 68fc698..a295329 100644 --- a/docs/api/cargo-features.md +++ b/docs/api/cargo-features.md @@ -7,19 +7,20 @@ title: Cargo features OS backends follow `target_os` (no empty Cargo feature toggles). Optional features: - `linux` (**default**) — gtk + x11rb for `_NET_WM_WINDOW_TYPE_DESKTOP` pin -- `rasterize` — SVG→PNG data-URI for Adaptive Cards chart/canvas/gauge (pulls resvg) +- `image-prefetch` (**default**) — host-side remote `image.url` → `data:` URI with disk/memory TTL cache (ureq). **Not linked on Android** (target-gated; Kotlin `localPath` owns remote images). Disable on desktop/iOS/macOS/Windows to drop TLS from the dependency tree. +- `rasterize` — SVG→PNG data-URI for Adaptive Cards chart/canvas/gauge/shape/zstack, gradient backgrounds, and `image.systemName` glyphs (pulls resvg). **Without it, Windows Widgets Board falls back to emoji `TextBlock` for SF symbols.** Enable for Windows Widgets Board parity. - `workerw` — Windows wallpaper WorkerW parenting - `layer-shell` — Wayland gtk-layer-shell Background (requires `linux`) - `macos-private-api` — transparent macOS desktop widget webviews (also enable Tauri `macosPrivateApi` in conf) - `schema` / `codegen` — JSON Schema / TS codegen tooling (dev) ```toml -# Default (linux pin enabled; other OS via target_os) -tauri-plugin-widgets = "0.4" +# Default (linux pin + image-prefetch; other OS via target_os) +tauri-plugin-widgets = "0.5" -# Slim: no linux pin / no gtk -tauri-plugin-widgets = { version = "0.4", default-features = false } +# Slim: no linux pin / no gtk / no ureq +tauri-plugin-widgets = { version = "0.5", default-features = false } # Windows Widgets Board with chart rasterization -tauri-plugin-widgets = { version = "0.4", default-features = false, features = ["rasterize"] } +tauri-plugin-widgets = { version = "0.5", default-features = false, features = ["rasterize"] } ``` diff --git a/docs/api/env.md b/docs/api/env.md index e863890..3617dc8 100644 --- a/docs/api/env.md +++ b/docs/api/env.md @@ -27,6 +27,7 @@ TAURI_WIDGET_MIN_RELOAD_SECS=0 pnpm tauri ios dev WIDGET_TRANSPORT=widgetContainer pnpm tauri dev # macOS build with explicit signing identity for the .appex +# (copy the string from `npx tauri-widgets signing`) WIDGET_SIGN_IDENTITY="Apple Development: you@example.com (TEAMID)" \ pnpm tauri build ``` diff --git a/docs/api/json-schema.md b/docs/api/json-schema.md index 4f0238d..c88540e 100644 --- a/docs/api/json-schema.md +++ b/docs/api/json-schema.md @@ -4,31 +4,46 @@ title: JSON Schema # JSON Schema -The widget config schema is generated from Rust models (`pnpm codegen`): +Generated from Rust (`pnpm codegen` / `cargo run --bin gen-schema --features codegen`): -[`schemas/widget-config.v1.json`](https://github.com/s00d/tauri-plugin-widgets/blob/main/schemas/widget-config.v1.json) +| Schema | Pages URL | +| --- | --- | +| Widget IR | https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json | +| `plugins.widgets` | https://s00d.github.io/tauri-plugin-widgets/schemas/plugin-config.v1.json | +| Capabilities matrix | https://s00d.github.io/tauri-plugin-widgets/schemas/capabilities.json | -Raw URL for editors: +Also in the repo under `schemas/` and shipped in the npm package (`schemas/`). + +GitHub raw (fallback): ```text https://raw.githubusercontent.com/s00d/tauri-plugin-widgets/main/schemas/widget-config.v1.json ``` -The npm package (`tauri-plugin-widgets-api`) does **not** ship `schemas/` — point tools at the GitHub raw URL or a local clone of this repo. - ## IDE association -Example VS Code / Cursor `settings.json`: +Put `$schema` in the widget JSON (preferred): + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "small": { "type": "text", "content": "Hi", "color": "#fff" } +} +``` + +Or VS Code / Cursor `settings.json`: ```json { "json.schemas": [ { "fileMatch": ["**/widget*.json", "**/fixtures/**/*.json"], - "url": "https://raw.githubusercontent.com/s00d/tauri-plugin-widgets/main/schemas/widget-config.v1.json" + "url": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json" } ] } ``` -Element reference: [Elements](/elements/). +Validate from the CLI: `npx tauri-widgets validate ./widget.json --platforms ios,android`. + +Element reference: [Elements](/elements/) · [CLI](/guide/cli). diff --git a/docs/api/plugin-config.md b/docs/api/plugin-config.md index 5fe2aea..23411ee 100644 --- a/docs/api/plugin-config.md +++ b/docs/api/plugin-config.md @@ -24,6 +24,6 @@ Configure the plugin under `plugins.widgets` in `tauri.conf.json`: | `transport` | `appGroup` \| `userDefaults` \| `widgetContainer` \| `auto` | Host write driver (Apple). iOS: `appGroup` only. See [Transport](/guide/transport). | | `extensionBundleId` | string | Widget extension bundle id (macOS `widgetContainer` / tooling) | -`init-macos` / `init-ios` do not invent this block for you — add it under `plugins.widgets` in `tauri.conf.json`. Linux / Windows desktop webview can omit it. +`init-macos` / `init-ios` write this block from your App Group + codesign identities. Linux / Windows desktop webview can omit it. Re-apply with `npx tauri-widgets signing --apply`. Permissions: [Permissions](/api/permissions). Environment overrides: [Environment](/api/env). diff --git a/docs/contributing/development.md b/docs/contributing/development.md index 5f8721c..367d014 100644 --- a/docs/contributing/development.md +++ b/docs/contributing/development.md @@ -108,19 +108,22 @@ pnpm build:example macos # or ios / android / … ## Documentation site -VitePress is the `@tauri-plugin-widgets/docs` workspace (`docs/`). Maintainer tooling is `@tauri-plugin-widgets/scripts` (citty + TypeScript under `scripts/src`). Consumer CLI `bin/cli.mjs` (`init-macos|ios|windows`) stays separate. +VitePress is the `@tauri-plugin-widgets/docs` workspace (`docs/`). Maintainer tooling is `@tauri-plugin-widgets/scripts` (citty + TypeScript under `scripts/src`). + +**Boundary:** consumer commands (`preview`, `signing`, `doctor`, …) live in `src-cli/` → published `dist-cli/cli.mjs`. Visual stands / goldens / docs codegen live under `scripts/` (`pnpm hosts`, `pnpm cli:test`). See root [README](../../README.md) and [scripts/README.md](../../scripts/README.md). ```bash -pnpm docs:generate # gallery, elements, shots, permissions, … +pnpm docs:generate # gallery, elements, shots, permissions, schemas → docs/public pnpm docs:check # marker drift + coverage audit pnpm docs:audit # coverage + schema docs asserts pnpm docs:dev # generate + local VitePress pnpm docs:build # generate + static site pnpm -C scripts cli --help # all maintainer commands pnpm -C scripts typecheck +pnpm test:cli # consumer CLI behavior fixtures ``` -Root aliases (`pnpm triage`, `pnpm ios-up`, `pnpm build:example`, …) call `pnpm -C scripts cli …`. Bash stands under `tools/*.sh` are unchanged; citty only `spawn`s them. +Root aliases (`pnpm triage`, `pnpm ios-up`, `pnpm build:example`, …) call `pnpm -C scripts cli …`. Bash/PowerShell stands live under `scripts/sh/` and `scripts/win/`; citty only `spawn`s them. Generated trees (`docs/public/shots/**`, showcase/elements marker bodies) are rebuilt by `pnpm docs:generate`. diff --git a/docs/contributing/linux-harness.md b/docs/contributing/linux-harness.md index 5673cc9..cde2ac0 100644 --- a/docs/contributing/linux-harness.md +++ b/docs/contributing/linux-harness.md @@ -15,13 +15,13 @@ OS backends follow `target_os`. The only OS-related Cargo feature is `linux` (gt ```toml # Default (linux pin) -tauri-plugin-widgets = "0.4" +tauri-plugin-widgets = "0.5" # No pin / no gtk -tauri-plugin-widgets = { version = "0.4", default-features = false } +tauri-plugin-widgets = { version = "0.5", default-features = false } # Widgets Board charts -tauri-plugin-widgets = { version = "0.4", features = ["rasterize"] } +tauri-plugin-widgets = { version = "0.5", features = ["rasterize"] } ``` ## What is tested @@ -49,7 +49,7 @@ just test-linux-fallback just linux-down ``` -`tools/shot-linux.sh` is **one-shot** (no inbox watch): spins a container, opens the fixture, grabs the live window, exits. +`scripts/sh/shot-linux.sh` is **one-shot** (no inbox watch): spins a container, opens the fixture, grabs the live window, exits. Persistent `wshot` is optional for interactive iteration. ## Files diff --git a/docs/contributing/windows-surfaces.md b/docs/contributing/windows-surfaces.md index c5dd679..6ea1e85 100644 --- a/docs/contributing/windows-surfaces.md +++ b/docs/contributing/windows-surfaces.md @@ -22,7 +22,7 @@ Two independent Windows integrations. Do **not** mix them in one code path. - API: `tauri_plugin_widgets::windows::workerw::{find_workerw, attach_to_workerw}` - Attach after creating a frameless desktop widget window; pass its HWND - Compile-check via `cargo xwin check --features workerw` -- Runtime smoke only on an interactive Windows desktop session (`tools/win/workerw-smoke.ps1` optional) +- Runtime smoke only on an interactive Windows desktop session (`scripts/win/workerw-smoke.ps1` optional) ## UTM tooling diff --git a/docs/guide/_generated/capability-matrix.md b/docs/guide/_generated/capability-matrix.md index 5668c0d..8904476 100644 --- a/docs/guide/_generated/capability-matrix.md +++ b/docs/guide/_generated/capability-matrix.md @@ -1,44 +1,112 @@ # Capability matrix (element × platform) -Generated from `tauri_plugin_widgets::capabilities`. Do not edit by hand. +Table is authored in `src/capabilities.rs`; this page embeds it automatically. -## Core (strict snapshot contract) +### Element lists + +**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) + +**Extended** (`EXTENDED_ELEMENTS`): [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas), [`label`](/elements/text#el-label) + +### Core | Element | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `vstack` | full | full | full | full | full (Adaptive Cards 1.5) | -| `hstack` | full | full | full | full | full (Adaptive Cards 1.5) | -| `zstack` | full | full | full | full | degraded (flattened Container, no overlay) | -| `container` | full | full | full | full | full (Adaptive Cards 1.5) | -| `grid` | full | full | full | full | full (Adaptive Cards 1.5) | -| `text` | full | full | full | full | full (Adaptive Cards 1.5) | -| `image` | full | full | degraded (systemName via glyph map; url via localPath preprocess) | full | degraded (url/data URI; systemName unsupported) | -| `spacer` | full | full | full | full | full (Adaptive Cards 1.5) | -| `divider` | full | full | full | full | full (Adaptive Cards 1.5) | -| `progress` | full | full | full | full | full (Adaptive Cards 1.5) | -| `button` | full | full | full | full | full (Adaptive Cards 1.5) | -| `link` | full | full | full | full | full (Adaptive Cards 1.5) | -| `shape` | full | full | full | full | degraded (rasterized PNG) | - -## Extended (best-effort, platform-dependent) +| [`vstack`](/elements/layout#el-vstack) | full | full | full | full | full1 | +| [`hstack`](/elements/layout#el-hstack) | full | full | full | full | full1 | +| [`zstack`](/elements/layout#el-zstack) | full | full | full | full | degraded2 | +| [`container`](/elements/layout#el-container) | full | full | full | full | full1 | +| [`grid`](/elements/layout#el-grid) | full | full | full | full | full1 | +| [`text`](/elements/text#el-text) | full | full | full | full | full1 | +| [`image`](/elements/media#el-image) | full | full | full3 | full | full4 | +| [`spacer`](/elements/spacing#el-spacer) | full | full | full | full | full1 | +| [`divider`](/elements/spacing#el-divider) | full | full | full | full | full1 | +| [`progress`](/elements/data#el-progress) | full | full | full | full | full1 | +| [`button`](/elements/interactive#el-button) | full | full | full | full | full1 | +| [`link`](/elements/interactive#el-link) | full | full | full | full | full1 | +| [`shape`](/elements/media#el-shape) | full | full | full | full | degraded5 | + +### Extended | Element | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `gauge` | full | full | full | full | degraded (rasterized PNG) | -| `toggle` | full | full | full | full | full (Adaptive Cards 1.5) | -| `date` | full | full | full | full | full (Adaptive Cards 1.5) | -| `chart` | full | full | degraded (simplified bar/line rendering) | full (SVG) | degraded (rasterized PNG) | -| `list` | full | full | full (Glance LazyColumn; depth/children limited) | full | degraded (flattened TextBlocks) | -| `timer` | full | full | degraded (static snapshot, not live Chronometer in all hosts) | full (setInterval) | degraded (static TextBlock of targetDate) | -| `canvas` | full | full | degraded (bitmap canvas; path support limited) | full (SVG) | degraded (rasterized PNG) | -| `label` | full | full | full | full | full (Adaptive Cards 1.5) | +| [`gauge`](/elements/data#el-gauge) | full | full | full | full | degraded5 | +| [`toggle`](/elements/interactive#el-toggle) | full | full | full | full | full1 | +| [`date`](/elements/text#el-date) | full | full | full | full | full1 | +| [`chart`](/elements/data#el-chart) | full | full | full6 | full7 | degraded5 | +| [`list`](/elements/data#el-list) | full | full | full8 | full | degraded9 | +| [`timer`](/elements/text#el-timer) | full | full | full10 | full11 | degraded12 | +| [`canvas`](/elements/media#el-canvas) | full | full | degraded13 | full7 | degraded5 | +| [`label`](/elements/text#el-label) | full | full | full | full | full1 | -## Feature notes +### Feature notes | Feature | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `image.url` | unsupported (prefetch into shared container not wired) | unsupported (prefetch into shared container not wired) | full (preprocess to localPath on setWidgetConfig) | full | full (Adaptive Cards Image.url) | -| `image.systemName` | full (SF Symbols) | full (SF Symbols) | degraded (glyph / drawable name map) | degraded (placeholder glyph) | unsupported (no SF Symbols on Adaptive Cards) | -| `background.gradient` | degraded (linear primary; radial/angular limited) | degraded (linear primary; radial/angular limited) | degraded (first color stop only (Glance)) | full (linear/radial/angular CSS/SVG) | unsupported (Container style=emphasis only) | -| `canvas.path` | degraded (M/L/H/V/Z subset) | degraded (M/L/H/V/Z subset) | degraded (limited path commands) | full (SVG path) | degraded (rasterized via SVG) | -| `timer.live` | full (Text(..., .timer)) | full (Text(..., .timer)) | unsupported (no live timer in Glance snapshot) | full (JS interval) | unsupported (static only) | +| [`image.url`](/elements/media#el-image) | full14 | full14 | full15 | full | full16 | +| [`image.systemName`](/elements/media#el-image) | full17 | full17 | degraded18 | degraded18 | degraded19 | +| [`background.gradient`](/elements/style) | full20 | full20 | full21 | full22 | degraded23 | +| [`canvas.path`](/elements/media#el-canvas) | full24 | full24 | full25 | full26 | degraded27 | +| [`timer.live`](/elements/text#el-timer) | full28 | full28 | full10 | full29 | degraded30 | + +### Choosing a surface set + +Pick the platforms you ship, then stay in the **full** set for that profile. Degraded cells still render, but check the [Notes](#notes) and element pages. + +#### Apple only + +Platforms: **iOS + macOS**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) +- **Full extended:** [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas), [`label`](/elements/text#el-label) + +#### Apple + Android + +Platforms: **iOS + macOS + Android**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) +- **Full extended:** [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`label`](/elements/text#el-label) +- **Extended with degraded/unsupported cells:** [`canvas`](/elements/media#el-canvas) + +#### All five matrix columns + +Platforms: **iOS + macOS + Android + Desktop + Windows**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link) +- **Core with degraded/unsupported cells:** [`zstack`](/elements/layout#el-zstack), [`shape`](/elements/media#el-shape) +- **Full extended:** [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`label`](/elements/text#el-label) +- **Extended with degraded/unsupported cells:** [`gauge`](/elements/data#el-gauge), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas) + + +### Notes {#notes} + +1. Adaptive Cards 1.5 +2. rasterized PNG overlay when possible; else flattened Container +3. url via localPath preprocess; see image.systemName +4. url/data URI; see image.systemName +5. rasterized PNG +6. bitmap bar/line/area/pie +7. SVG +8. Column chunking; soft cap ~50 items +9. Adaptive Cards Table +10. Chronometer via AndroidRemoteViews +11. setInterval +12. provider minute push + static TextBlock +13. bitmap canvas (full SVG path via PathParser) +14. host prefetch to data URI on setWidgetConfig +15. preprocess to localPath on setWidgetConfig +16. Adaptive Cards Image.url +17. SF Symbols +18. SF→Material / emoji map (not SF Symbols) +19. emoji TextBlock by default; glyph PNG Image with feature rasterize +20. linear/radial/angular SwiftUI +21. baked bitmap at LocalSize / frame +22. linear/radial/angular CSS/SVG +23. rasterized PNG backgroundImage when rasterize enabled +24. SVG path grammar +25. PathParser full SVG path +26. SVG path +27. rasterized via SVG +28. Text(..., .timer) +29. JS interval +30. provider pushes UpdateWidget ~1/min diff --git a/docs/guide/cli.md b/docs/guide/cli.md new file mode 100644 index 0000000..699921d --- /dev/null +++ b/docs/guide/cli.md @@ -0,0 +1,51 @@ +--- +title: CLI +--- + +# Consumer CLI (`tauri-widgets`) + +Published with the npm package. **Not** the same as maintainer `pnpm -C scripts cli` (stands / goldens / docs). + +```bash +npx tauri-widgets +# or: pnpm tauri-widgets +``` + +| Command | Purpose | +| --- | --- | +| `init` | Detect platforms → run `init-macos` / `init-ios` / `init-windows` | +| `init-macos` / `init-ios` / `init-windows` | Scaffold native extension | +| `preview ` | Browser preview of `widget.html` (`--watch`, `--size`, `--open`) | +| `signing` | Codesign identities + transport verdict (`--apply`, `--write-entitlements`, `--dev-cert`) | +| `validate ` | Schema shape + `capabilities.json` per platform | +| `doctor [app-root]` | Transport / entitlements / identities / trace / plugin-config | +| `trace` | Print / `--follow` `widget_trace.json` | +| `clean` | Clear store/trace (`--group`) or `--cache` (image prefetch) | + +## Preview + +```bash +npx tauri-widgets preview ./widget.json --size medium --watch --open +``` + +No Xcode / Gradle build — JSON edits reload over SSE. + +## Validate + +```bash +npx tauri-widgets validate ./widget.json --platforms ios,android,windows +``` + +Uses `schemas/capabilities.json` (generated from `src/capabilities.rs`). + +## Schemas + +| File | URL (Pages) | +| --- | --- | +| Widget IR | https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json | +| `plugins.widgets` | https://s00d.github.io/tauri-plugin-widgets/schemas/plugin-config.v1.json | +| Capabilities | https://s00d.github.io/tauri-plugin-widgets/schemas/capabilities.json | + +Add `"$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json"` to widget JSON for editor autocomplete. + +Maintainer CLI: [scripts/README.md](https://github.com/s00d/tauri-plugin-widgets/blob/main/scripts/README.md) · [Doctor](/guide/doctor). diff --git a/docs/guide/doctor.md b/docs/guide/doctor.md new file mode 100644 index 0000000..8eadb63 --- /dev/null +++ b/docs/guide/doctor.md @@ -0,0 +1,47 @@ +--- +title: Doctor +--- + +# `tauri-widgets doctor` + +Static + optional runtime checks for widget transport, App Groups, signing, bundle layout, receipt schema, and the host trace journal. + +```bash +pnpm tauri-widgets doctor +# or against an app checkout: +pnpm tauri-widgets doctor /path/to/my-app +``` + +## What it checks + +| Section | Passes when | +| --- | --- | +| **transport** | `tauri.conf.json` found; `plugins.widgets.appGroup` set; warns on `auto` / `widgetContainer` misuse | +| **entitlements** | `.entitlements` under `src-tauri` list the same App Group as conf | +| **app group (3 places)** | conf + entitlements + project files (`Info.plist` / `.pbxproj` / widget targets / `gen/apple`) mention the group | +| **signing** | Keychain identities (before build): `appGroup` without Team ID → error; `widgetContainer` with Team ID → warn. Also inspects built `.app` when present | +| **bundle** | `.appex` present in release artifacts (warn if not built yet) | +| **runtime (trace)** | Reads `widget_trace.json`; fails on throttled reloads; warns near WidgetKit daily budget | +| **plugin-config** | `plugins.widgets` shape (transport enum, appGroup required) | +| **schema** | Rust / Swift / Kotlin receipt `schema` defaults still match (`1`) | + +Related commands: + +```bash +npx tauri-widgets signing # identities + transport verdict +npx tauri-widgets signing --apply # write plugins.widgets + macos entitlements +npx tauri-widgets signing --write-entitlements +npx tauri-widgets signing --dev-cert # Windows DevCert.ps1 +``` + +macOS `build-widget.sh` regenerates `App.entitlements` / `TauriWidgetExtension.entitlements` from `plugins.widgets.appGroup` on every build (files are gitignored after `init-macos`). + +Exit code `1` when any ✗ error is reported; ⚠ warnings alone still exit `0`. + +## When to run it + +- After `init-macos` / `init-ios` +- Before publishing / filing a “widget blank / no update” bug +- When investigating throttled reloads (`getWidgetTrace` / `WIDGET_DEBUG=1`) + +See also: [Troubleshooting](/guide/troubleshooting) · [Transport](/guide/transport) · [JS API — getWidgetTrace](/api/js). diff --git a/docs/guide/first-widget.md b/docs/guide/first-widget.md index 7e5a990..b84dae6 100644 --- a/docs/guide/first-widget.md +++ b/docs/guide/first-widget.md @@ -18,7 +18,7 @@ Follow [Install](/guide/install) — preferably `pnpm tauri add tauri-plugin-wid ### macOS host caveat -Even for a **desktop-only** webview (no WidgetKit), macOS still resolves Apple transport at plugin init. Add `plugins.widgets` **before** `tauri dev` or the app will not start: +Even for a **desktop-only** webview (no WidgetKit), macOS still resolves Apple transport at plugin init. Run `npx tauri-widgets signing --apply` (or `init-macos`) so `plugins.widgets` exists **before** `tauri dev`, or the app will not start: ```json { @@ -31,7 +31,7 @@ Even for a **desktop-only** webview (no WidgetKit), macOS still resolves Apple t } ``` -Use `widgetContainer` for local ad-hoc signing; switch to `appGroup` once Team ID + App Groups work. Linux / Windows hosts can skip this block. See [Transport](/guide/transport). +`signing` picks `widgetContainer` when no Team ID cert is installed, `appGroup` when one is. Linux / Windows hosts can skip this block. See [Transport](/guide/transport). ### Capabilities diff --git a/docs/guide/index.md b/docs/guide/index.md index 7db7cdb..930f283 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -41,7 +41,7 @@ Full diagram + action sequence: [Concepts → Architecture flowchart](/guide/con 1. [Install](/guide/install) 2. [First widget](/guide/first-widget) (desktop) -3. [Concepts](/guide/concepts) · [Transport](/guide/transport) +3. [Concepts](/guide/concepts) · [Transport & signing](/guide/transport) · [Doctor](/guide/doctor) 4. [Platform setup](/guide/setup/) when you need a native surface 5. [Elements](/elements/) · [Showcase](/showcase) diff --git a/docs/guide/install.md b/docs/guide/install.md index 4c8f483..b2e7c65 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -42,7 +42,7 @@ Optional Cargo features: [Cargo features](/api/cargo-features). ```toml # src-tauri/Cargo.toml [dependencies] -tauri-plugin-widgets = "0.4" +tauri-plugin-widgets = "0.5" ``` Or: @@ -74,9 +74,9 @@ pub fn run() { ## Apple hosts: plugin config -`init-macos` / `init-ios` do **not** write `plugins.widgets` for you. Without it, **plugin init fails** on macOS and iOS (`plugins.widgets.appGroup is required`). +`init-macos` / `init-ios` **write** `plugins.widgets` (appGroup + transport). Without that block, **plugin init fails** on macOS and iOS (`plugins.widgets.appGroup is required`). -Add to `src-tauri/tauri.conf.json` (use your real App Group id): +If you scaffolded manually, add to `src-tauri/tauri.conf.json`: ```json { @@ -89,13 +89,20 @@ Add to `src-tauri/tauri.conf.json` (use your real App Group id): } ``` +Or let discovery fill it: + +```bash +npx tauri-widgets signing # see identities + verdict +npx tauri-widgets signing --apply # write transport from certificates +``` + | Host | Notes | | --- | --- | -| **iOS** | `transport` must be `appGroup` (or omit / `auto` → appGroup). | -| **macOS** local ad-hoc | Prefer `"transport": "widgetContainer"` until you have a Team ID + App Groups. | +| **iOS** | `transport` must be `appGroup` (`init-ios` sets this). Physical device widgets need a **paid** Apple Developer account (App Groups). | +| **macOS** local ad-hoc | `init-macos` / `signing` pick `"widgetContainer"` when no Team ID cert exists. | | **macOS** Team ID / MAS | `"transport": "appGroup"`. | -Details: [Plugin config](/api/plugin-config) · [Transport](/guide/transport). +Details: [Plugin config](/api/plugin-config) · [Transport — App Groups & signing](/guide/transport#app-groups--signing-plugin-consumers). Linux / Windows desktop webview do not need this block for the built-in window path. diff --git a/docs/guide/setup/index.md b/docs/guide/setup/index.md index bd4666c..2b9b76c 100644 --- a/docs/guide/setup/index.md +++ b/docs/guide/setup/index.md @@ -21,6 +21,6 @@ Pick a surface. Desktop is the fastest feedback loop; native extensions take lon | Windows | Widgets Board and/or desktop webview | ~1 h | [Windows](/guide/setup/windows) | | Linux | Desktop webview + X11 pin | ~30 min | [Linux](/guide/setup/linux) | -**Before any Apple surface (including desktop webview on a Mac):** add `plugins.widgets.appGroup` — [Install](/guide/install#apple-hosts-plugin-config). CLI scaffolds do not write it. +**Before any Apple surface (including desktop webview on a Mac):** run `init-macos` / `init-ios` (writes `plugins.widgets`) or `npx tauri-widgets signing --apply`. See [Install](/guide/install#apple-hosts-plugin-config). Each setup page ends with **When it fails**. Symptom index: [Troubleshooting](/guide/troubleshooting). diff --git a/docs/guide/setup/ios.md b/docs/guide/setup/ios.md index 937c039..a11080e 100644 --- a/docs/guide/setup/ios.md +++ b/docs/guide/setup/ios.md @@ -90,18 +90,23 @@ struct MyWidget: Widget { Or copy from `node_modules/tauri-plugin-widgets-api/templates/ios-widget/MyWidget.swift` (plugin repo path: `templates/ios-widget/MyWidget.swift`). -## Step 5: Configure App Groups +## Step 5: App Groups (portal + entitlements) -1. Select the **main app target** (your iOS app, e.g. `myapp_iOS`) → **Signing & Capabilities**. -2. Click **+ Capability** → add **App Groups**. -3. In the App Groups block click `+` and add your group (use the value printed by `init-ios`). -4. Repeat the same for the **WidgetExtension** target. -5. Verify the App Group value is **exactly the same** in both targets. -6. If **+ Capability** is disabled, set a valid **Team** in Signing for that target first. +> **Widgets on a physical iOS device require a paid Apple Developer account** — free provisioning cannot grant App Groups. The Simulator works without a paid seat. + +`init-ios` writes both `.entitlements` under `src-tauri/gen/apple/` and sets `CODE_SIGN_ENTITLEMENTS` on the app + widget targets. You still must register the group on the portal (Apple does not allow automating that): + +1. Open [Identifiers](https://developer.apple.com/account/resources/identifiers/list). +2. Register App Group = the id printed by `init-ios` (e.g. `group.com.example.myapp`). +3. Enable **App Groups** on **both** App IDs (main + `*.WidgetExtension`) and tick that group. + +Full checklist and “id not available” notes: [Apple data transport → App Groups & signing](/guide/transport#app-groups--signing-plugin-consumers). + +In Xcode you only need a valid **Team** on both targets (Automatic signing). Capability UI is optional once entitlements + portal match. ## Step 6: Plugin config (`tauri.conf.json`) -Xcode App Groups alone are not enough. The **Rust host** must know the same id or plugin init fails: +`init-ios` **writes** `plugins.widgets` with `transport: "appGroup"` (required on iOS): ```json { @@ -114,9 +119,7 @@ Xcode App Groups alone are not enough. The **Rust host** must know the same id o } ``` -- `init-ios` does **not** patch this — add it yourself. -- On iOS, `transport` must be `appGroup` (other values fail at init). -- JS `setWidgetConfig(..., group, widgetId)` must use that same `group` string. +JS `setWidgetConfig(..., group, widgetId)` must use that same `group` string. Also keep Swift `TauriWidgetProvider(..., widgetId:)` aligned with the JS `widgetId` (CLI default is `"default"`). If your app writes `widgetId: "weather"`, pass `widgetId: "weather"` into the provider or the extension reads `config:default` while the host wrote `config:weather`. diff --git a/docs/guide/setup/linux.md b/docs/guide/setup/linux.md index 6ea51f7..fdd845f 100644 --- a/docs/guide/setup/linux.md +++ b/docs/guide/setup/linux.md @@ -18,9 +18,9 @@ Linux desktop widgets use the same **embedded** webview path as [Desktop webview 1. Keep default features, or enable explicitly: ```toml -tauri-plugin-widgets = { version = "0.4", features = ["linux"] } +tauri-plugin-widgets = { version = "0.5", features = ["linux"] } # Wayland: -# tauri-plugin-widgets = { version = "0.4", features = ["linux", "layer-shell"] } +# tauri-plugin-widgets = { version = "0.5", features = ["linux", "layer-shell"] } ``` 2. Allow the window label in capabilities (`windows` + `widgets:default`) — [Install](/guide/install#permissions). diff --git a/docs/guide/setup/macos.md b/docs/guide/setup/macos.md index b56a023..af70f53 100644 --- a/docs/guide/setup/macos.md +++ b/docs/guide/setup/macos.md @@ -25,36 +25,50 @@ npx tauri-plugin-widgets-api init-macos \ This creates `src-tauri/macos-widget/` with: - `Sources/MyWidget.swift` — widget code using `TauriWidgetProvider` -- `TauriWidgetExtension.entitlements` — App Group for the extension -- `App.entitlements` — App Group for the host app (wired via `bundle.macOS.entitlements`) +- `App.entitlements` / `TauriWidgetExtension.entitlements` — regenerated on each build from `plugins.widgets.appGroup` - `project.yml` — xcodegen spec - `build-widget.sh` — builds and signs the `.appex` (`beforeBundleCommand`) - `embed-widget.sh` — **deprecated** emergency re-embed; not part of the normal DX ## Step 2: Plugin config (`plugins.widgets`) -`init-macos` patches bundle / beforeBundle paths, but **does not** write `plugins.widgets`. Without this block the **host app fails at plugin init** even before you care about WidgetKit: +`init-macos` **writes** `plugins.widgets` for you. Transport is chosen from installed codesign identities: + +| Identities | `transport` | +|------------|-------------| +| Team ID Development / Developer ID present | `appGroup` | +| None / ad-hoc only | `widgetContainer` | ```json { "plugins": { "widgets": { "appGroup": "group.com.example.myapp", - "transport": "appGroup", + "transport": "widgetContainer", "extensionBundleId": "com.example.myapp.widgetkit" } } } ``` +Inspect / re-apply later: + +```bash +npx tauri-widgets signing +npx tauri-widgets signing --apply # plugins.widgets + entitlements +npx tauri-widgets signing --write-entitlements # entitlements only +``` + +`build-widget.sh` also regenerates both `.entitlements` from `plugins.widgets.appGroup` on every `tauri build` (files listed in `macos-widget/.gitignore`). + | Situation | `transport` | |-----------|-------------| | Release / Team ID + App Groups enabled | `appGroup` | | Mac App Store | `appGroup` | | Local ad-hoc signing (no shared App Group container) | `widgetContainer` | -| Not sure yet | `auto` once at startup (dev only) — read the log, then pin the winner in conf | +| Not sure yet | run `signing` — do not ship `auto` | -Wrong `transport` / missing `appGroup` **fails plugin init** with a concrete message. Full driver table: [Apple data transport](/guide/transport) (also summarized below). +Wrong `transport` / missing `appGroup` **fails plugin init** with a concrete message. Full driver table: [Apple data transport](/guide/transport). ## Step 3: Configure tauri.conf.json bundle hooks @@ -85,7 +99,7 @@ The CLI patches these fields (paths relative to `src-tauri/`): ```bash pnpm tauri build -# Optional: identity for signing the .appex before bundling +# Prefer the identity printed by `npx tauri-widgets signing`: WIDGET_SIGN_IDENTITY="Apple Development: you@example.com (TEAMID)" pnpm tauri build ``` @@ -124,6 +138,7 @@ Match Swift `TauriWidgetProvider` `widgetId` with JS `setWidgetConfig` (CLI temp 3. Fallback: `-` (ad-hoc) ```bash +npx tauri-widgets signing # lists identities + recommended transport security find-identity -v -p codesigning ``` @@ -133,6 +148,8 @@ security find-identity -v -p codesigning | Apple Development | Yes | `appGroup` | Local + TestFlight | | Developer ID | Yes | `appGroup` | Direct distribution | +For **App Groups on a Team ID** (portal registration, “id not available”, doctor): [Apple data transport → App Groups & signing](/guide/transport#app-groups--signing-plugin-consumers). + **Important:** The main app's `App.entitlements` should **not** include `com.apple.security.app-sandbox` when using `widgetContainer` (host must write into the extension container). The widget extension is always sandboxed (required by WidgetKit). ## Debugging the widget separately diff --git a/docs/guide/setup/windows.md b/docs/guide/setup/windows.md index 6a4c5c7..8c3cbe5 100644 --- a/docs/guide/setup/windows.md +++ b/docs/guide/setup/windows.md @@ -56,6 +56,24 @@ dotnet build src-tauri/windows-widget/WidgetProvider/WidgetProvider.csproj -c Re Pack / sideload helpers used in this repo: `just win-pack`, `just win-sideload` (see [Windows surfaces](/contributing/windows-surfaces)). +### 5. Dev certificate (sideload) + +The MSIX **Publisher** string must match the certificate Subject exactly. + +```bash +npx tauri-widgets signing --dev-cert +# writes src-tauri/windows-widget/DevCert.ps1 and prints it +``` + +```powershell +# elevated on Windows +powershell -ExecutionPolicy Bypass -File src-tauri/windows-widget/DevCert.ps1 +# or sign in one shot: +powershell -ExecutionPolicy Bypass -File src-tauri/windows-widget/DevCert.ps1 -MsixPath path\to\app.msix +``` + +`init-windows` also drops a starter `DevCert.ps1` into the scaffold. + Optional wallpaper parenting (not Widgets Board): Cargo feature `workerw`. --- diff --git a/docs/guide/tiers.md b/docs/guide/tiers.md index b4ab723..10a67a8 100644 --- a/docs/guide/tiers.md +++ b/docs/guide/tiers.md @@ -4,7 +4,7 @@ title: Core vs extended # Core vs extended -Not every element is held to the same cross-platform bar. +Not every element is held to the same cross-platform bar. **Pixel-identical Core+Extended everywhere is not possible** — Adaptive Cards, Glance, and WidgetKit each miss overlays, live timers, or network fetch. Aim for a **portable Core** and treat Extended as best-effort.
@@ -14,80 +14,172 @@ Not every element is held to the same cross-platform bar. ## Core -Defined in Rust as `CORE_ELEMENTS` (`src/snapshot.rs`). These have a **strict snapshot contract** across iOS, macOS, Android, Desktop HTML, and Windows Adaptive Cards (with documented Windows nuances such as flattened `zstack`). +**Core** means the element is present on every matrix platform and keeps its IR semantics (layout role, text, tap target, bitmap image, …). It does **not** mean identical pixels. Degraded cells (for example Windows `zstack` / `shape`, or `image.systemName` outside Apple) are documented in the matrix below — check them before assuming Board/Glance look like WidgetKit. -``` -vstack, hstack, zstack, container, grid, -text, image, spacer, divider, progress, button, link, shape -``` - -If you need “works the same everywhere,” stay in this set. +The authoritative list is `CORE_ELEMENTS` in `src/snapshot.rs` (also mirrored under [Capability matrix](#capability-matrix)). Prefer that set when you need broad coverage. -## Extended +### Portable Core checklist -`EXTENDED_ELEMENTS` — best-effort, platform-dependent: +- Prefer solid backgrounds (or accept gradient bake / first-stop degradation). +- Prefer `image.data` / `image.url` over `image.systemName` for Board/Android parity — SF Symbols exist only on Apple; elsewhere the feature row is degraded (glyph/emoji). +- Remote `image.url` is prefetched on the host where wired — do not rely on live network inside the extension process. +- Prefer Desktop webview when you need true overlays, live timers, or full SVG canvas. -``` -gauge, toggle, date, chart, list, timer, canvas, label -``` +## Extended -Expect `degraded` or `unsupported` cells (for example live `timer` on Android/Windows, rich `canvas.path` subsets, chart rasterization on Adaptive Cards). +**Extended** (`EXTENDED_ELEMENTS`) is best-effort and platform-dependent. Expect `degraded` or `unsupported` cells (live `timer` on Windows Board, chart rasterization on Adaptive Cards). The live list is in the matrix section. ## How to read the matrix | Level | Meaning | | --- | --- | | full | Supported with the intended semantics | -| degraded | Renders with known limitations (see matrix notes) | +| degraded | Renders with known limitations (see footnotes under the matrix) | | unsupported | Do not rely on this feature on that platform | +**Desktop column:** one IR path shared by the desktop webview hosts — WKWebView (macOS window), WebView2 (Windows window), and webkit2gtk (Linux). Semantics match; differences are system fonts and CSS engine version. Linux also has its own golden stand (`tests/golden/linux/`) for pinning / layer-shell, but it is not a sixth matrix column. Windows **Widgets Board** (Adaptive Cards) is the separate **Windows** column. + Showcase presets exercise both tiers; docs build fails if a schema element has **zero** fixture coverage. +## Runtime behavior + +### Capability warnings on `setWidgetConfig` + +Each `setWidgetConfig` runs `validate_config` for the current host platform and emits `log::warn!` via `log_capabilities` when a used element or feature is `degraded` / `unsupported` (for example `image.systemName` outside Apple). Warnings are advisory — the write still proceeds. There is no public guest API to dry-run validation yet; treat host logs (and the matrix) as the source of truth before shipping a config. + +### Skipped elements in render receipts + +When a renderer cannot express a node (common on Adaptive Cards without the `rasterize` feature, or after a flatten), the element is recorded on the render receipt as `skipped: [{ type, reason }]`, not as a silent hole you must guess about. + +Inspect recent receipts with [`getWidgetDiagnostics(group)`](/api/js): + +```ts +import { getWidgetDiagnostics } from "tauri-plugin-widgets-api"; + +const receipts = await getWidgetDiagnostics("com.example.widgets"); +for (const r of receipts) { + if (r.skipped?.length) { + console.warn(r.widgetId, r.skipped); + // e.g. [{ type: "canvas", reason: "adaptive-cards: enable crate feature `rasterize`" }] + } +} +``` + +Desktop / debug builds can also use `getWidgetTrace(group)` for the host journal plus receipt history. + ## Capability matrix -Generated from `src/capabilities.rs`. Do not edit by hand. +Table is authored in `src/capabilities.rs`; this page embeds it automatically. Refresh with `cargo test --lib capabilities::write_docs::capability_matrix_doc_matches` (delete `docs/guide/_generated/capability-matrix.md` first if drifted) then `pnpm docs:generate`. -## Core (strict snapshot contract) +### Element lists + +**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) + +**Extended** (`EXTENDED_ELEMENTS`): [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas), [`label`](/elements/text#el-label) + +### Core | Element | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `vstack` | full | full | full | full | full (Adaptive Cards 1.5) | -| `hstack` | full | full | full | full | full (Adaptive Cards 1.5) | -| `zstack` | full | full | full | full | degraded (flattened Container, no overlay) | -| `container` | full | full | full | full | full (Adaptive Cards 1.5) | -| `grid` | full | full | full | full | full (Adaptive Cards 1.5) | -| `text` | full | full | full | full | full (Adaptive Cards 1.5) | -| `image` | full | full | degraded (systemName via glyph map; url via localPath preprocess) | full | degraded (url/data URI; systemName unsupported) | -| `spacer` | full | full | full | full | full (Adaptive Cards 1.5) | -| `divider` | full | full | full | full | full (Adaptive Cards 1.5) | -| `progress` | full | full | full | full | full (Adaptive Cards 1.5) | -| `button` | full | full | full | full | full (Adaptive Cards 1.5) | -| `link` | full | full | full | full | full (Adaptive Cards 1.5) | -| `shape` | full | full | full | full | degraded (rasterized PNG) | - -## Extended (best-effort, platform-dependent) +| [`vstack`](/elements/layout#el-vstack) | full | full | full | full | full1 | +| [`hstack`](/elements/layout#el-hstack) | full | full | full | full | full1 | +| [`zstack`](/elements/layout#el-zstack) | full | full | full | full | degraded2 | +| [`container`](/elements/layout#el-container) | full | full | full | full | full1 | +| [`grid`](/elements/layout#el-grid) | full | full | full | full | full1 | +| [`text`](/elements/text#el-text) | full | full | full | full | full1 | +| [`image`](/elements/media#el-image) | full | full | full3 | full | full4 | +| [`spacer`](/elements/spacing#el-spacer) | full | full | full | full | full1 | +| [`divider`](/elements/spacing#el-divider) | full | full | full | full | full1 | +| [`progress`](/elements/data#el-progress) | full | full | full | full | full1 | +| [`button`](/elements/interactive#el-button) | full | full | full | full | full1 | +| [`link`](/elements/interactive#el-link) | full | full | full | full | full1 | +| [`shape`](/elements/media#el-shape) | full | full | full | full | degraded5 | + +### Extended | Element | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `gauge` | full | full | full | full | degraded (rasterized PNG) | -| `toggle` | full | full | full | full | full (Adaptive Cards 1.5) | -| `date` | full | full | full | full | full (Adaptive Cards 1.5) | -| `chart` | full | full | degraded (simplified bar/line rendering) | full (SVG) | degraded (rasterized PNG) | -| `list` | full | full | full (Glance LazyColumn; depth/children limited) | full | degraded (flattened TextBlocks) | -| `timer` | full | full | degraded (static snapshot, not live Chronometer in all hosts) | full (setInterval) | degraded (static TextBlock of targetDate) | -| `canvas` | full | full | degraded (bitmap canvas; path support limited) | full (SVG) | degraded (rasterized PNG) | -| `label` | full | full | full | full | full (Adaptive Cards 1.5) | +| [`gauge`](/elements/data#el-gauge) | full | full | full | full | degraded5 | +| [`toggle`](/elements/interactive#el-toggle) | full | full | full | full | full1 | +| [`date`](/elements/text#el-date) | full | full | full | full | full1 | +| [`chart`](/elements/data#el-chart) | full | full | full6 | full7 | degraded5 | +| [`list`](/elements/data#el-list) | full | full | full8 | full | degraded9 | +| [`timer`](/elements/text#el-timer) | full | full | full10 | full11 | degraded12 | +| [`canvas`](/elements/media#el-canvas) | full | full | degraded13 | full7 | degraded5 | +| [`label`](/elements/text#el-label) | full | full | full | full | full1 | -## Feature notes +### Feature notes | Feature | iOS | macOS | Android | Desktop | Windows | |---------|-----|-------|---------|---------|----------| -| `image.url` | unsupported (prefetch into shared container not wired) | unsupported (prefetch into shared container not wired) | full (preprocess to localPath on setWidgetConfig) | full | full (Adaptive Cards Image.url) | -| `image.systemName` | full (SF Symbols) | full (SF Symbols) | degraded (glyph / drawable name map) | degraded (placeholder glyph) | unsupported (no SF Symbols on Adaptive Cards) | -| `background.gradient` | degraded (linear primary; radial/angular limited) | degraded (linear primary; radial/angular limited) | degraded (first color stop only (Glance)) | full (linear/radial/angular CSS/SVG) | unsupported (Container style=emphasis only) | -| `canvas.path` | degraded (M/L/H/V/Z subset) | degraded (M/L/H/V/Z subset) | degraded (limited path commands) | full (SVG path) | degraded (rasterized via SVG) | -| `timer.live` | full (Text(..., .timer)) | full (Text(..., .timer)) | unsupported (no live timer in Glance snapshot) | full (JS interval) | unsupported (static only) | +| [`image.url`](/elements/media#el-image) | full14 | full14 | full15 | full | full16 | +| [`image.systemName`](/elements/media#el-image) | full17 | full17 | degraded18 | degraded18 | degraded19 | +| [`background.gradient`](/elements/style) | full20 | full20 | full21 | full22 | degraded23 | +| [`canvas.path`](/elements/media#el-canvas) | full24 | full24 | full25 | full26 | degraded27 | +| [`timer.live`](/elements/text#el-timer) | full28 | full28 | full10 | full29 | degraded30 | + +### Choosing a surface set + +Pick the platforms you ship, then stay in the **full** set for that profile. Degraded cells still render, but check the [Notes](#notes) and element pages. + +#### Apple only + +Platforms: **iOS + macOS**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) +- **Full extended:** [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas), [`label`](/elements/text#el-label) + +#### Apple + Android + +Platforms: **iOS + macOS + Android**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`zstack`](/elements/layout#el-zstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link), [`shape`](/elements/media#el-shape) +- **Full extended:** [`gauge`](/elements/data#el-gauge), [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`label`](/elements/text#el-label) +- **Extended with degraded/unsupported cells:** [`canvas`](/elements/media#el-canvas) + +#### All five matrix columns + +Platforms: **iOS + macOS + Android + Desktop + Windows**. + +- **Full core:** [`vstack`](/elements/layout#el-vstack), [`hstack`](/elements/layout#el-hstack), [`container`](/elements/layout#el-container), [`grid`](/elements/layout#el-grid), [`text`](/elements/text#el-text), [`image`](/elements/media#el-image), [`spacer`](/elements/spacing#el-spacer), [`divider`](/elements/spacing#el-divider), [`progress`](/elements/data#el-progress), [`button`](/elements/interactive#el-button), [`link`](/elements/interactive#el-link) +- **Core with degraded/unsupported cells:** [`zstack`](/elements/layout#el-zstack), [`shape`](/elements/media#el-shape) +- **Full extended:** [`toggle`](/elements/interactive#el-toggle), [`date`](/elements/text#el-date), [`label`](/elements/text#el-label) +- **Extended with degraded/unsupported cells:** [`gauge`](/elements/data#el-gauge), [`chart`](/elements/data#el-chart), [`list`](/elements/data#el-list), [`timer`](/elements/text#el-timer), [`canvas`](/elements/media#el-canvas) + + +### Notes {#notes} + +1. Adaptive Cards 1.5 +2. rasterized PNG overlay when possible; else flattened Container +3. url via localPath preprocess; see image.systemName +4. url/data URI; see image.systemName +5. rasterized PNG +6. bitmap bar/line/area/pie +7. SVG +8. Column chunking; soft cap ~50 items +9. Adaptive Cards Table +10. Chronometer via AndroidRemoteViews +11. setInterval +12. provider minute push + static TextBlock +13. bitmap canvas (full SVG path via PathParser) +14. host prefetch to data URI on setWidgetConfig +15. preprocess to localPath on setWidgetConfig +16. Adaptive Cards Image.url +17. SF Symbols +18. SF→Material / emoji map (not SF Symbols) +19. emoji TextBlock by default; glyph PNG Image with feature rasterize +20. linear/radial/angular SwiftUI +21. baked bitmap at LocalSize / frame +22. linear/radial/angular CSS/SVG +23. rasterized PNG backgroundImage when rasterize enabled +24. SVG path grammar +25. PathParser full SVG path +26. SVG path +27. rasterized via SVG +28. Text(..., .timer) +29. JS interval +30. provider pushes UpdateWidget ~1/min diff --git a/docs/guide/transport.md b/docs/guide/transport.md index 927a1f6..55806eb 100644 --- a/docs/guide/transport.md +++ b/docs/guide/transport.md @@ -41,10 +41,98 @@ The host writes widget data through **one** transport you choose in config. You Wrong `transport` / missing `appGroup` **fails plugin init** with a concrete message (empty widgets from a silent fallback are not a thing). -`init-macos` / `init-ios` do **not** write this block — add `plugins.widgets` yourself ([Install](/guide/install#apple-hosts-plugin-config)). +`init-macos` / `init-ios` write this block (transport inferred on macOS from codesign identities; iOS always `appGroup`). Re-run discovery anytime: + +```bash +npx tauri-widgets signing +npx tauri-widgets signing --apply +``` Override without editing conf: `WIDGET_TRANSPORT=widgetContainer`. The **widget extension** still reads all channels and picks the freshest map (so it can find data wherever the host wrote). Host-side writes use only the configured driver. Render receipts feed `getWidgetDiagnostics`, not transport selection. `setItems` skips disk I/O when the value is unchanged (no nonce bump). + +## App Groups & signing (plugin consumers) + +For live widgets that share data with the host you need the **same** App Group id in four places, plus a Team ID that actually owns that group. + +| Place | What to set | +| --- | --- | +| [Apple Developer → Identifiers](https://developer.apple.com/account/resources/identifiers/list) | App Group + both App IDs with **App Groups** capability | +| Xcode **Signing & Capabilities** (app + widget targets) | Same group checked | +| `plugins.widgets.appGroup` in `tauri.conf.json` | Same string | +| JS `setWidgetConfig(config, group, …)` | Same string as `group` | + +Swift `TauriWidgetProvider(appGroup:widgetId:)` must use that group too (CLI templates fill it). + +### Register on the Developer portal + +You need **Account Holder** or **Admin** on the team that signs the app (the Team ID in Xcode / `bundle.iOS.developmentTeam`). + +1. **Identifiers → + → App Groups** + Identifier like `group.com.example.myapp` (reverse-DNS). Register. +2. **Identifiers → + → App IDs** (type App) for the **main** bundle, e.g. `com.example.myapp` + Enable **App Groups** → Configure → tick your group → Save. +3. Repeat for the **widget** bundle, e.g. `com.example.myapp.WidgetExtension` + Same App Groups tick → Save. +4. In Xcode, set **Team** on both targets, Automatic signing, and add the App Groups capability if it is not already there. + +Then pin config: + +```json +{ + "plugins": { + "widgets": { + "appGroup": "group.com.example.myapp", + "transport": "appGroup" + } + }, + "bundle": { + "iOS": { + "developmentTeam": "YOUR_TEAM_ID" + } + } +} +``` + +iOS always needs `transport: "appGroup"`. macOS with a real Team ID should use `appGroup` too; for local ad-hoc without a shared container use `widgetContainer` ([macOS setup](/guide/setup/macos)). + +### “Identifier is not available” + +If the portal says *An Application Group with Identifier '…' is not available*: + +- The id is **already registered on another team** (or another Apple ID). You cannot recreate it under this team. +- Either switch Xcode / `developmentTeam` to the team that owns the group, **or** register a **new** unique id (e.g. `group.com.example.myapp2`) and update conf, entitlements, Swift, and JS together. + +Confirm you are editing Identifiers for the **same** team as `DEVELOPMENT_TEAM` / `bundle.iOS.developmentTeam`. + +### Signing cheat sheet + +| Host | Signing | `transport` | +| --- | --- | --- | +| iOS device / TestFlight | Apple Development or Distribution + Team ID | `appGroup` | +| iOS Simulator | Development / Automatic | `appGroup` (portal group still required for Automatic profiles with the entitlement) | +| macOS, Team ID / Developer ID / MAS | Real identity | `appGroup` | +| macOS, ad-hoc (`-`) | No shared App Group container | `widgetContainer` | + +List identities (or use the CLI): + +```bash +npx tauri-widgets signing +security find-identity -v -p codesigning +``` + +For CI / local env vars used by Tauri Apple builds (`APPLE_DEVELOPMENT_TEAM`, optional ASC API key), keep secrets out of git. Unset `APPLE_API_KEY` / `APPLE_API_ISSUER` / `APPLE_API_KEY_PATH` when running `tauri ios build` if you need real codesign — with those set, tauri-cli may stamp a dummy identity. + +### Sanity check + +```bash +npx tauri-widgets doctor +# or: pnpm tauri-widgets doctor /path/to/your-app +``` + +Checks conf ↔ entitlements ↔ project mentions of the App Group, and (when a built `.app` exists) ad-hoc vs Team ID mismatches. Details: [Doctor](/guide/doctor). + +Platform walkthroughs: [iOS setup](/guide/setup/ios) · [macOS setup](/guide/setup/macos). diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 9262ccc..ad57fad 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -8,16 +8,18 @@ Find the symptom, then jump into the platform page (setup guides keep the detail | Symptom | Where to look | | --- | --- | -| App won't start on macOS/iOS (`appGroup is required`) | [Install → Apple hosts](/guide/install#apple-hosts-plugin-config) · [Plugin config](/api/plugin-config) · [Transport](/guide/transport) | -| Widget shows "No configuration" (macOS) | [macOS setup → When it fails](/guide/setup/macos#when-it-fails) · [Transport](/guide/transport) | +| App won't start on macOS/iOS (`appGroup is required`) | [Install → Apple hosts](/guide/install#apple-hosts-plugin-config) · [Plugin config](/api/plugin-config) · [Transport & signing](/guide/transport) | +| App Group “Identifier is not available” in Developer portal | [Transport → App Groups & signing](/guide/transport#app-groups--signing-plugin-consumers) — id owned by another team; pick a new group or switch Team | +| Widget shows "No configuration" (macOS) | [macOS setup → When it fails](/guide/setup/macos#when-it-fails) · [Transport](/guide/transport) · [`doctor`](/guide/doctor) | | Widget shows "No configuration" (iOS) | [iOS setup → When it fails](/guide/setup/ios#when-it-fails) — also check Swift `widgetId` vs JS | -| Widget doesn't update (Apple) | [iOS](/guide/setup/ios#when-it-fails) / [macOS](/guide/setup/macos#when-it-fails) · reload budget in [Updating data](/guide/recipes/updating-data) | +| Widget doesn't update (Apple) | [iOS](/guide/setup/ios#when-it-fails) / [macOS](/guide/setup/macos#when-it-fails) · reload budget in [Updating data](/guide/recipes/updating-data) · [`doctor` trace](/guide/doctor) | | Android widget empty or crashes | [Android setup → When it fails](/guide/setup/android#when-it-fails) — JS `group` must match package / meta-data | | Desktop window not appearing / empty | [Desktop setup → When it fails](/guide/setup/desktop#when-it-fails) — capabilities `windows` labels | | Looking for `widget.html` in the app | You don't copy it for the default path — [Desktop webview](/guide/setup/desktop) | | Windows Widgets Board missing template | [Windows setup](/guide/setup/windows) — path is `src-tauri/windows-widget/`, set `TAURI_WIDGETS_DATA` | | Linux pin / layer-shell issues | [Linux setup → When it fails](/guide/setup/linux#when-it-fails) · [Linux harness](/contributing/linux-harness) | -| Empty widget after wrong Apple transport | [Transport](/guide/transport) — init fails loud; check `plugins.widgets.transport` | +| Empty widget after wrong Apple transport | [Transport](/guide/transport) — init fails loud; check `plugins.widgets.transport` · [`doctor`](/guide/doctor) | | Capability degraded / unsupported | [Core vs extended](/guide/tiers) · `getWidgetDiagnostics` in [JS API](/api/js) | +| Need a one-shot health dump | [`pnpm tauri-widgets doctor`](/guide/doctor) | Contributor tooling (goldens, harnesses): [Contributing](/contributing/). diff --git a/docs/public/schemas/capabilities.json b/docs/public/schemas/capabilities.json new file mode 100644 index 0000000..25c2445 --- /dev/null +++ b/docs/public/schemas/capabilities.json @@ -0,0 +1,611 @@ +{ + "core": [ + "vstack", + "hstack", + "zstack", + "container", + "grid", + "text", + "image", + "spacer", + "divider", + "progress", + "button", + "link", + "shape" + ], + "elements": { + "button": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "canvas": { + "android": { + "note": "bitmap canvas (full SVG path via PathParser)", + "support": "degraded" + }, + "desktop": { + "note": "SVG", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "chart": { + "android": { + "note": "bitmap bar/line/area/pie", + "support": "full" + }, + "desktop": { + "note": "SVG", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "container": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "date": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "divider": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "gauge": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "grid": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "hstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "image": { + "android": { + "note": "url via localPath preprocess; see image.systemName", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "url/data URI; see image.systemName", + "support": "full" + } + }, + "label": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "link": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "list": { + "android": { + "note": "Column chunking; soft cap ~50 items", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards Table", + "support": "degraded" + } + }, + "progress": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "shape": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "spacer": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "text": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "timer": { + "android": { + "note": "Chronometer via AndroidRemoteViews", + "support": "full" + }, + "desktop": { + "note": "setInterval", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "provider minute push + static TextBlock", + "support": "degraded" + } + }, + "toggle": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "vstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "zstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG overlay when possible; else flattened Container", + "support": "degraded" + } + } + }, + "extended": [ + "gauge", + "toggle", + "date", + "chart", + "list", + "timer", + "canvas", + "label" + ], + "features": { + "background.gradient": { + "android": { + "note": "baked bitmap at LocalSize / frame", + "support": "full" + }, + "desktop": { + "note": "linear/radial/angular CSS/SVG", + "support": "full" + }, + "ios": { + "note": "linear/radial/angular SwiftUI", + "support": "full" + }, + "macos": { + "note": "linear/radial/angular SwiftUI", + "support": "full" + }, + "windows": { + "note": "rasterized PNG backgroundImage when rasterize enabled", + "support": "degraded" + } + }, + "canvas.path": { + "android": { + "note": "PathParser full SVG path", + "support": "full" + }, + "desktop": { + "note": "SVG path", + "support": "full" + }, + "ios": { + "note": "SVG path grammar", + "support": "full" + }, + "macos": { + "note": "SVG path grammar", + "support": "full" + }, + "windows": { + "note": "rasterized via SVG", + "support": "degraded" + } + }, + "image.systemName": { + "android": { + "note": "SF→Material / emoji map (not SF Symbols)", + "support": "degraded" + }, + "desktop": { + "note": "SF→Material / emoji map (not SF Symbols)", + "support": "degraded" + }, + "ios": { + "note": "SF Symbols", + "support": "full" + }, + "macos": { + "note": "SF Symbols", + "support": "full" + }, + "windows": { + "note": "emoji TextBlock by default; glyph PNG Image with feature rasterize", + "support": "degraded" + } + }, + "image.url": { + "android": { + "note": "preprocess to localPath on setWidgetConfig", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "host prefetch to data URI on setWidgetConfig", + "support": "full" + }, + "macos": { + "note": "host prefetch to data URI on setWidgetConfig", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards Image.url", + "support": "full" + } + }, + "timer.live": { + "android": { + "note": "Chronometer via AndroidRemoteViews", + "support": "full" + }, + "desktop": { + "note": "JS interval", + "support": "full" + }, + "ios": { + "note": "Text(..., .timer)", + "support": "full" + }, + "macos": { + "note": "Text(..., .timer)", + "support": "full" + }, + "windows": { + "note": "provider pushes UpdateWidget ~1/min", + "support": "degraded" + } + } + }, + "platforms": [ + "ios", + "macos", + "android", + "desktop", + "windows" + ], + "version": 1 +} diff --git a/docs/public/schemas/plugin-config.v1.json b/docs/public/schemas/plugin-config.v1.json new file mode 100644 index 0000000..a92f496 --- /dev/null +++ b/docs/public/schemas/plugin-config.v1.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WidgetsPluginConfig", + "description": "`plugins.widgets` object from `tauri.conf.json`.", + "type": "object", + "properties": { + "appGroup": { + "description": "App Group id, e.g. `group.com.example.app`.", + "type": [ + "string", + "null" + ] + }, + "extensionBundleId": { + "description": "Widget extension bundle id (macOS container path). Default: `{app_id}.widgetkit`.", + "type": [ + "string", + "null" + ] + }, + "transport": { + "description": "Host→widget transport driver.", + "allOf": [ + { + "$ref": "#/definitions/TransportKind" + } + ] + } + }, + "definitions": { + "TransportKind": { + "description": "Which Apple host→widget channel to use.\n\nPick this at build time (you know your signing). Do not rely on runtime fan-out.", + "oneOf": [ + { + "description": "App Group shared container file (`widget_data.json`). Needs a real Team ID. Production-safe default; use `auto` explicitly for ad-hoc probing.", + "type": "string", + "enum": [ + "appGroup" + ] + }, + { + "description": "App Group `UserDefaults` suite. Same signing requirements as [`AppGroup`].", + "type": "string", + "enum": [ + "userDefaults" + ] + }, + { + "description": "Widget extension container file. Works with ad-hoc signing; macOS host must not be sandboxed.", + "type": "string", + "enum": [ + "widgetContainer" + ] + }, + { + "description": "One-shot probe at startup (dev only). Prefer pinning an explicit driver in conf.", + "type": "string", + "enum": [ + "auto" + ] + } + ] + } + } +} diff --git a/docs/public/schemas/widget-config.v1.json b/docs/public/schemas/widget-config.v1.json new file mode 100644 index 0000000..407345c --- /dev/null +++ b/docs/public/schemas/widget-config.v1.json @@ -0,0 +1,4189 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WidgetConfig", + "description": "Top-level widget config with layouts per size family.", + "type": "object", + "properties": { + "large": { + "description": "Layout for the large size family.", + "anyOf": [ + { + "$ref": "#/definitions/WidgetElement" + }, + { + "type": "null" + } + ] + }, + "medium": { + "description": "Layout for the medium size family.", + "anyOf": [ + { + "$ref": "#/definitions/WidgetElement" + }, + { + "type": "null" + } + ] + }, + "small": { + "description": "Layout for the small size family.", + "anyOf": [ + { + "$ref": "#/definitions/WidgetElement" + }, + { + "type": "null" + } + ] + }, + "version": { + "description": "Schema version. Defaults to `1`.", + "default": 1, + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + }, + "definitions": { + "BackgroundValue": { + "description": "Background: solid color string, adaptive pair, gradient, or material blur.", + "anyOf": [ + { + "description": "Hex string or semantic color name.", + "type": "string" + }, + { + "description": "Linear, radial, or angular gradient.", + "allOf": [ + { + "$ref": "#/definitions/GradientConfig" + } + ] + }, + { + "description": "Distinct colors for light and dark appearance.", + "type": "object", + "required": [ + "dark", + "light" + ], + "properties": { + "dark": { + "description": "Color used in dark appearance.", + "type": "string" + }, + "light": { + "description": "Color used in light appearance.", + "type": "string" + } + } + } + ] + }, + "BorderConfig": { + "description": "Border color and width.", + "type": "object", + "required": [ + "color" + ], + "properties": { + "color": { + "description": "Border color (hex string or semantic name).", + "type": "string" + }, + "width": { + "description": "Border width in points. Default `1.0`.", + "default": 1.0, + "type": "number", + "format": "double" + } + } + }, + "CanvasDrawCommand": { + "description": "A single declarative draw command for [`crate::models::CanvasElement`].", + "oneOf": [ + { + "description": "Filled / stroked circle.", + "type": "object", + "required": [ + "cx", + "cy", + "draw", + "r" + ], + "properties": { + "cx": { + "description": "Center x-coordinate.", + "type": "number", + "format": "double" + }, + "cy": { + "description": "Center y-coordinate.", + "type": "number", + "format": "double" + }, + "draw": { + "type": "string", + "enum": [ + "circle" + ] + }, + "fill": { + "description": "Fill color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "r": { + "description": "Radius.", + "type": "number", + "format": "double" + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + }, + { + "description": "Straight line segment.", + "type": "object", + "required": [ + "draw", + "x1", + "x2", + "y1", + "y2" + ], + "properties": { + "draw": { + "type": "string", + "enum": [ + "line" + ] + }, + "lineCap": { + "description": "Line cap style (`\"butt\"`, `\"round\"`, or `\"square\"`).", + "type": [ + "string", + "null" + ] + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "x1": { + "description": "Start x-coordinate.", + "type": "number", + "format": "double" + }, + "x2": { + "description": "End x-coordinate.", + "type": "number", + "format": "double" + }, + "y1": { + "description": "Start y-coordinate.", + "type": "number", + "format": "double" + }, + "y2": { + "description": "End y-coordinate.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Filled / stroked rectangle, optionally rounded.", + "type": "object", + "required": [ + "draw", + "height", + "width", + "x", + "y" + ], + "properties": { + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "draw": { + "type": "string", + "enum": [ + "rect" + ] + }, + "fill": { + "description": "Fill color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "height": { + "description": "Rectangle height.", + "type": "number", + "format": "double" + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "width": { + "description": "Rectangle width.", + "type": "number", + "format": "double" + }, + "x": { + "description": "Top-left x-coordinate.", + "type": "number", + "format": "double" + }, + "y": { + "description": "Top-left y-coordinate.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Filled / stroked arc.", + "type": "object", + "required": [ + "cx", + "cy", + "draw", + "endAngle", + "r", + "startAngle" + ], + "properties": { + "cx": { + "description": "Center x-coordinate.", + "type": "number", + "format": "double" + }, + "cy": { + "description": "Center y-coordinate.", + "type": "number", + "format": "double" + }, + "draw": { + "type": "string", + "enum": [ + "arc" + ] + }, + "endAngle": { + "description": "End angle in degrees.", + "type": "number", + "format": "double" + }, + "fill": { + "description": "Fill color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "r": { + "description": "Radius.", + "type": "number", + "format": "double" + }, + "startAngle": { + "description": "Start angle in degrees.", + "type": "number", + "format": "double" + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + }, + { + "description": "Text label drawn at a point.", + "type": "object", + "required": [ + "content", + "draw", + "x", + "y" + ], + "properties": { + "anchor": { + "description": "Text anchor (`\"start\"`, `\"middle\"`, or `\"end\"`).", + "type": [ + "string", + "null" + ] + }, + "color": { + "description": "Text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "Text to draw.", + "type": "string" + }, + "draw": { + "type": "string", + "enum": [ + "text" + ] + }, + "fontSize": { + "description": "Font size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "x": { + "description": "x-coordinate of the anchor point.", + "type": "number", + "format": "double" + }, + "y": { + "description": "y-coordinate of the anchor point.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Arbitrary vector path.", + "type": "object", + "required": [ + "d", + "draw" + ], + "properties": { + "d": { + "description": "SVG path data (e.g. `\"M10 10 L90 90\"`)", + "type": "string" + }, + "draw": { + "type": "string", + "enum": [ + "path" + ] + }, + "fill": { + "description": "Fill color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + } + ] + }, + "ChartDataPoint": { + "description": "A single labeled data point for [`crate::models::ChartElement`].", + "type": "object", + "required": [ + "label", + "value" + ], + "properties": { + "color": { + "description": "Optional per-point color override.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Category / x-axis label.", + "type": "string" + }, + "value": { + "description": "Value plotted for this point.", + "type": "number", + "format": "double" + } + } + }, + "ChartType": { + "description": "Chart kind.", + "oneOf": [ + { + "description": "`bar`.", + "type": "string", + "enum": [ + "bar" + ] + }, + { + "description": "`line`.", + "type": "string", + "enum": [ + "line" + ] + }, + { + "description": "`area`.", + "type": "string", + "enum": [ + "area" + ] + }, + { + "description": "`pie`.", + "type": "string", + "enum": [ + "pie" + ] + } + ] + }, + "ClipShape": { + "description": "Clip shape for content masking.", + "oneOf": [ + { + "description": "`circle`.", + "type": "string", + "enum": [ + "circle" + ] + }, + { + "description": "`capsule`.", + "type": "string", + "enum": [ + "capsule" + ] + }, + { + "description": "`rectangle`.", + "type": "string", + "enum": [ + "rectangle" + ] + } + ] + }, + "ColorValue": { + "description": "Color value — hex string, semantic name, or adaptive `{ light, dark }` pair.\n\nSemantic names: `\"label\"`, `\"secondaryLabel\"`, `\"systemBackground\"`, `\"secondarySystemBackground\"`, `\"accent\"`, `\"separator\"`.", + "anyOf": [ + { + "description": "Hex string or semantic color name.", + "type": "string" + }, + { + "description": "Distinct colors for light and dark appearance.", + "type": "object", + "required": [ + "dark", + "light" + ], + "properties": { + "dark": { + "description": "Color used in dark appearance.", + "type": "string" + }, + "light": { + "description": "Color used in light appearance.", + "type": "string" + } + } + } + ] + }, + "ContentMode": { + "description": "How an image fills its frame.", + "oneOf": [ + { + "description": "`fit`.", + "type": "string", + "enum": [ + "fit" + ] + }, + { + "description": "`fill`.", + "type": "string", + "enum": [ + "fill" + ] + } + ] + }, + "DateStyle": { + "description": "Date / relative-time display style.", + "oneOf": [ + { + "description": "`time`.", + "type": "string", + "enum": [ + "time" + ] + }, + { + "description": "`date`.", + "type": "string", + "enum": [ + "date" + ] + }, + { + "description": "`relative`.", + "type": "string", + "enum": [ + "relative" + ] + }, + { + "description": "`offset`.", + "type": "string", + "enum": [ + "offset" + ] + }, + { + "description": "`timer`.", + "type": "string", + "enum": [ + "timer" + ] + } + ] + }, + "FontDesign": { + "description": "Font design (default, monospaced, rounded, serif).", + "oneOf": [ + { + "description": "`default`.", + "type": "string", + "enum": [ + "default" + ] + }, + { + "description": "`monospaced`.", + "type": "string", + "enum": [ + "monospaced" + ] + }, + { + "description": "`rounded`.", + "type": "string", + "enum": [ + "rounded" + ] + }, + { + "description": "`serif`.", + "type": "string", + "enum": [ + "serif" + ] + } + ] + }, + "FontWeight": { + "description": "Font weight.", + "oneOf": [ + { + "description": "`ultralight`.", + "type": "string", + "enum": [ + "ultralight" + ] + }, + { + "description": "`thin`.", + "type": "string", + "enum": [ + "thin" + ] + }, + { + "description": "`light`.", + "type": "string", + "enum": [ + "light" + ] + }, + { + "description": "`regular`.", + "type": "string", + "enum": [ + "regular" + ] + }, + { + "description": "`medium`.", + "type": "string", + "enum": [ + "medium" + ] + }, + { + "description": "`semibold`.", + "type": "string", + "enum": [ + "semibold" + ] + }, + { + "description": "`bold`.", + "type": "string", + "enum": [ + "bold" + ] + }, + { + "description": "`heavy`.", + "type": "string", + "enum": [ + "heavy" + ] + }, + { + "description": "`black`.", + "type": "string", + "enum": [ + "black" + ] + } + ] + }, + "FrameConfig": { + "description": "Explicit width / height / max-size constraints for an element.", + "type": "object", + "properties": { + "height": { + "description": "Fixed height in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "maxHeight": { + "description": "Maximum height — a fixed point value or `\"infinity\"`.", + "anyOf": [ + { + "$ref": "#/definitions/FrameDimension" + }, + { + "type": "null" + } + ] + }, + "maxWidth": { + "description": "Maximum width — a fixed point value or `\"infinity\"`.", + "anyOf": [ + { + "$ref": "#/definitions/FrameDimension" + }, + { + "type": "null" + } + ] + }, + "width": { + "description": "Fixed width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + }, + "FrameDimension": { + "description": "A frame dimension — either a fixed point value or `\"infinity\"`.", + "anyOf": [ + { + "description": "Fixed point value.", + "type": "number", + "format": "double" + }, + { + "description": "Keyword `\"infinity\"`.", + "type": "string", + "enum": [ + "infinity" + ] + } + ] + }, + "GaugeStyle": { + "description": "Gauge visual style.", + "oneOf": [ + { + "description": "`circular`.", + "type": "string", + "enum": [ + "circular" + ] + }, + { + "description": "`linear`.", + "type": "string", + "enum": [ + "linear" + ] + } + ] + }, + "GradientConfig": { + "description": "Linear, radial, or angular gradient background.", + "type": "object", + "required": [ + "colors", + "gradientType" + ], + "properties": { + "colors": { + "description": "Stop colors, in order.", + "type": "array", + "items": { + "type": "string" + } + }, + "direction": { + "description": "Direction for linear gradients", + "anyOf": [ + { + "$ref": "#/definitions/GradientDirection" + }, + { + "type": "null" + } + ] + }, + "gradientType": { + "description": "`\"linear\"`, `\"radial\"`, or `\"angular\"`", + "allOf": [ + { + "$ref": "#/definitions/GradientType" + } + ] + } + } + }, + "GradientDirection": { + "description": "Direction for linear gradients.", + "oneOf": [ + { + "description": "`top to bottom`.", + "type": "string", + "enum": [ + "topToBottom" + ] + }, + { + "description": "`bottom to top`.", + "type": "string", + "enum": [ + "bottomToTop" + ] + }, + { + "description": "`leading to trailing`.", + "type": "string", + "enum": [ + "leadingToTrailing" + ] + }, + { + "description": "`trailing to leading`.", + "type": "string", + "enum": [ + "trailingToLeading" + ] + }, + { + "description": "`top leading to bottom trailing`.", + "type": "string", + "enum": [ + "topLeadingToBottomTrailing" + ] + }, + { + "description": "`top trailing to bottom leading`.", + "type": "string", + "enum": [ + "topTrailingToBottomLeading" + ] + } + ] + }, + "GradientType": { + "description": "Gradient shape.", + "oneOf": [ + { + "description": "`linear`.", + "type": "string", + "enum": [ + "linear" + ] + }, + { + "description": "`radial`.", + "type": "string", + "enum": [ + "radial" + ] + }, + { + "description": "`angular`.", + "type": "string", + "enum": [ + "angular" + ] + } + ] + }, + "HorizontalAlignment": { + "description": "Horizontal alignment of children within a stack.", + "oneOf": [ + { + "description": "`leading`.", + "type": "string", + "enum": [ + "leading" + ] + }, + { + "description": "`center`.", + "type": "string", + "enum": [ + "center" + ] + }, + { + "description": "`trailing`.", + "type": "string", + "enum": [ + "trailing" + ] + } + ] + }, + "ListItem": { + "description": "A single row for [`crate::models::ListElement`].", + "type": "object", + "required": [ + "text" + ], + "properties": { + "action": { + "description": "Action identifier — emits a `widget-action` event when tapped.", + "type": [ + "string", + "null" + ] + }, + "checked": { + "description": "Optional checked/checkbox marker.", + "type": [ + "boolean", + "null" + ] + }, + "payload": { + "description": "Opaque payload forwarded alongside the action event.", + "type": [ + "string", + "null" + ] + }, + "text": { + "description": "Row text.", + "type": "string" + } + } + }, + "PaddingValue": { + "description": "Inset padding — a single uniform value or per-edge overrides.", + "anyOf": [ + { + "description": "Same padding on every edge.", + "type": "number", + "format": "double" + }, + { + "description": "Independent padding per edge.", + "type": "object", + "properties": { + "bottom": { + "description": "Bottom edge padding.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "leading": { + "description": "Leading (left in LTR) edge padding.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "top": { + "description": "Top edge padding.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "trailing": { + "description": "Trailing (right in LTR) edge padding.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + } + ] + }, + "ProgressStyle": { + "description": "Progress indicator style.", + "oneOf": [ + { + "description": "`linear`.", + "type": "string", + "enum": [ + "linear" + ] + }, + { + "description": "`circular`.", + "type": "string", + "enum": [ + "circular" + ] + } + ] + }, + "ShadowConfig": { + "description": "Drop shadow configuration.", + "type": "object", + "properties": { + "color": { + "description": "Shadow color (hex string or semantic name).", + "type": [ + "string", + "null" + ] + }, + "radius": { + "description": "Blur radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "x": { + "description": "Horizontal offset in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "y": { + "description": "Vertical offset in points.", + "type": [ + "number", + "null" + ], + "format": "double" + } + } + }, + "ShapeType": { + "description": "Shape kind for [`crate::models::ShapeElement`].", + "oneOf": [ + { + "description": "`circle`.", + "type": "string", + "enum": [ + "circle" + ] + }, + { + "description": "`capsule`.", + "type": "string", + "enum": [ + "capsule" + ] + }, + { + "description": "`rectangle`.", + "type": "string", + "enum": [ + "rectangle" + ] + } + ] + }, + "TextAlignment": { + "description": "Text alignment within the line.", + "oneOf": [ + { + "description": "`leading`.", + "type": "string", + "enum": [ + "leading" + ] + }, + { + "description": "`center`.", + "type": "string", + "enum": [ + "center" + ] + }, + { + "description": "`trailing`.", + "type": "string", + "enum": [ + "trailing" + ] + } + ] + }, + "TextStyle": { + "description": "Semantic text style — respects Dynamic Type / accessibility settings.", + "oneOf": [ + { + "description": "`large title`.", + "type": "string", + "enum": [ + "largeTitle" + ] + }, + { + "description": "`title`.", + "type": "string", + "enum": [ + "title" + ] + }, + { + "description": "`title2`.", + "type": "string", + "enum": [ + "title2" + ] + }, + { + "description": "`title3`.", + "type": "string", + "enum": [ + "title3" + ] + }, + { + "description": "`headline`.", + "type": "string", + "enum": [ + "headline" + ] + }, + { + "description": "`subheadline`.", + "type": "string", + "enum": [ + "subheadline" + ] + }, + { + "description": "`body`.", + "type": "string", + "enum": [ + "body" + ] + }, + { + "description": "`callout`.", + "type": "string", + "enum": [ + "callout" + ] + }, + { + "description": "`footnote`.", + "type": "string", + "enum": [ + "footnote" + ] + }, + { + "description": "`caption`.", + "type": "string", + "enum": [ + "caption" + ] + }, + { + "description": "`caption2`.", + "type": "string", + "enum": [ + "caption2" + ] + } + ] + }, + "TimerCounting": { + "description": "Countdown/countup direction for [`crate::models::TimerElement`].", + "oneOf": [ + { + "description": "`up`.", + "type": "string", + "enum": [ + "up" + ] + }, + { + "description": "`down`.", + "type": "string", + "enum": [ + "down" + ] + } + ] + }, + "VerticalAlignment": { + "description": "Vertical alignment of children within a stack.", + "oneOf": [ + { + "description": "`top`.", + "type": "string", + "enum": [ + "top" + ] + }, + { + "description": "`center`.", + "type": "string", + "enum": [ + "center" + ] + }, + { + "description": "`bottom`.", + "type": "string", + "enum": [ + "bottom" + ] + } + ] + }, + "WidgetElement": { + "description": "A UI element that can be a layout container or a leaf widget.", + "oneOf": [ + { + "description": "Vertical stack of children.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "alignment": { + "description": "Horizontal alignment of children.", + "anyOf": [ + { + "$ref": "#/definitions/HorizontalAlignment" + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Child elements, top to bottom.", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "spacing": { + "description": "Space between children (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "vstack" + ] + } + } + }, + { + "description": "Horizontal stack of children.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "alignment": { + "description": "Vertical alignment of children.", + "anyOf": [ + { + "$ref": "#/definitions/VerticalAlignment" + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Child elements, leading to trailing.", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "spacing": { + "description": "Space between children (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "hstack" + ] + } + } + }, + { + "description": "Overlay stack — children layered on top of each other.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "alignment": { + "description": "Alignment of layers within the stack (e.g. `center`, `topLeading`).", + "type": [ + "string", + "null" + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Layered children (later draw on top).", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "zstack" + ] + } + } + }, + { + "description": "Fixed-column grid of children.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Grid cells in row-major order.", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "columns": { + "description": "Number of columns. Default `2`.", + "default": 2, + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "rowSpacing": { + "description": "Row spacing (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "spacing": { + "description": "Column spacing (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "grid" + ] + } + } + }, + { + "description": "Single-child wrapper for cards, badges, and overlays.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Nested content (typically one child).", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "contentAlignment": { + "description": "Content alignment inside the box (e.g. `center`, `topLeading`).", + "type": [ + "string", + "null" + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "container" + ] + } + } + }, + { + "description": "Text label with optional semantic typography.", + "type": "object", + "required": [ + "content", + "type" + ], + "properties": { + "alignment": { + "description": "Text alignment within the line.", + "anyOf": [ + { + "$ref": "#/definitions/TextAlignment" + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "content": { + "description": "String to display.", + "type": "string" + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontDesign": { + "description": "Font design (default, monospaced, rounded, serif).", + "anyOf": [ + { + "$ref": "#/definitions/FontDesign" + }, + { + "type": "null" + } + ] + }, + "fontSize": { + "description": "Font size in points (overridden by `textStyle` when set).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontWeight": { + "description": "Font weight.", + "anyOf": [ + { + "$ref": "#/definitions/FontWeight" + }, + { + "type": "null" + } + ] + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "lineLimit": { + "description": "Maximum number of lines before truncation.", + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "textStyle": { + "description": "Semantic text style (uses Dynamic Type on Apple, sp on Android). Overrides `fontSize` when set.", + "anyOf": [ + { + "$ref": "#/definitions/TextStyle" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "text" + ] + } + } + }, + { + "description": "Image from SF Symbol / drawable name, base64 data, or URL.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Tint color for template / symbol images.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "contentMode": { + "description": "How the image fills its frame (`fit` or `fill`).", + "anyOf": [ + { + "$ref": "#/definitions/ContentMode" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "data": { + "description": "Base64-encoded image data (with or without `data:image/...;base64,` prefix).", + "type": [ + "string", + "null" + ] + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "size": { + "description": "Display size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "systemName": { + "description": "SF Symbol name (Apple) or Material / drawable name (Android).", + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "url": { + "description": "Remote image URL (platform support varies — see capability matrix).", + "type": [ + "string", + "null" + ] + } + } + }, + { + "description": "Linear or circular progress indicator.", + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "barStyle": { + "description": "`linear` (default) or `circular`.", + "anyOf": [ + { + "$ref": "#/definitions/ProgressStyle" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Track / label color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Caption above the bar. Always set on Android so hosts never show null.", + "type": [ + "string", + "null" + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "tint": { + "description": "Accent / fill color for the completed portion.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "total": { + "description": "Denominator for the ratio. Default `1.0`.", + "default": 1.0, + "type": "number", + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "progress" + ] + }, + "value": { + "description": "Current value. Clamped to `0..=total` by renderers.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Circular or capacity-style gauge.", + "type": "object", + "required": [ + "type", + "value" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Secondary / track color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "currentValueLabel": { + "description": "Text shown for the current value (e.g. `\"72%\"`).", + "type": [ + "string", + "null" + ] + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "gaugeStyle": { + "description": "Visual style (e.g. `circular`).", + "anyOf": [ + { + "$ref": "#/definitions/GaugeStyle" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Optional caption.", + "type": [ + "string", + "null" + ] + }, + "max": { + "description": "Upper bound. Default `1`.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "min": { + "description": "Lower bound. Default `0`.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "tint": { + "description": "Accent color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "gauge" + ] + }, + "value": { + "description": "Current value within `[min, max]`.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Tappable button that opens a URL or emits `widget-action`.", + "type": "object", + "required": [ + "label", + "type" + ], + "properties": { + "action": { + "description": "Action identifier — emits a `widget-action` Tauri event when tapped.", + "type": [ + "string", + "null" + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "backgroundColor": { + "description": "Button background color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Label text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontSize": { + "description": "Label font size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "label": { + "description": "Button label text.", + "type": "string" + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "textAlignment": { + "description": "Label text alignment.", + "anyOf": [ + { + "$ref": "#/definitions/TextAlignment" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "button" + ] + }, + "url": { + "description": "Deep link URL to open the app (used when no action is set).", + "type": [ + "string", + "null" + ] + } + } + }, + { + "description": "On/off toggle control.", + "type": "object", + "required": [ + "isOn", + "type" + ], + "properties": { + "action": { + "description": "Action identifier sent back to the app.", + "type": [ + "string", + "null" + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "isOn": { + "description": "Whether the toggle is on.", + "type": "boolean" + }, + "label": { + "description": "Optional label beside the control.", + "type": [ + "string", + "null" + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "tint": { + "description": "Accent color when on.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "toggle" + ] + } + } + }, + { + "description": "Horizontal or vertical rule.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Line color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "thickness": { + "description": "Line thickness in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "divider" + ] + } + } + }, + { + "description": "Flexible empty space.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "minLength": { + "description": "Minimum length along the parent axis (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "spacer" + ] + } + } + }, + { + "description": "Formatted date / relative time display.", + "type": "object", + "required": [ + "date", + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "date": { + "description": "ISO 8601 date string.", + "type": "string" + }, + "dateStyle": { + "description": "Display style (`time`, `date`, `relative`, `offset`, `timer`).", + "anyOf": [ + { + "$ref": "#/definitions/DateStyle" + }, + { + "type": "null" + } + ] + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontSize": { + "description": "Font size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "date" + ] + } + } + }, + { + "description": "Bar, line, area, or pie chart.", + "type": "object", + "required": [ + "chartType", + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "chartData": { + "description": "Data points (`label` + `value`, optional per-point `color`).", + "type": "array", + "items": { + "$ref": "#/definitions/ChartDataPoint" + } + }, + "chartType": { + "description": "Chart kind: `bar`, `line`, `area`, or `pie`.", + "allOf": [ + { + "$ref": "#/definitions/ChartType" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "tint": { + "description": "Default series tint.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "chart" + ] + } + } + }, + { + "description": "Collection list of rows (text, optional checked marker and action).", + "type": "object", + "required": [ + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Row text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontSize": { + "description": "Row text font size.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "items": { + "description": "Row items.", + "type": "array", + "items": { + "$ref": "#/definitions/ListItem" + } + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "spacing": { + "description": "Space between rows (points).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "list" + ] + } + } + }, + { + "description": "Tappable wrapper — makes nested content clickable.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "action": { + "description": "Action identifier — emits `widget-action` event.", + "type": [ + "string", + "null" + ] + }, + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "children": { + "description": "Nested content to wrap.", + "type": "array", + "items": { + "$ref": "#/definitions/WidgetElement" + } + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "url": { + "description": "Deep-link URL to open.", + "type": [ + "string", + "null" + ] + } + } + }, + { + "description": "Colored shape — circle, capsule, or rectangle.", + "type": "object", + "required": [ + "shapeType", + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fill": { + "description": "Fill color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "shapeType": { + "description": "Shape kind.", + "allOf": [ + { + "$ref": "#/definitions/ShapeType" + } + ] + }, + "size": { + "description": "Bounding size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "stroke": { + "description": "Stroke color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "strokeWidth": { + "description": "Stroke width in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "type": { + "type": "string", + "enum": [ + "shape" + ] + } + } + }, + { + "description": "Live countdown/countup timer that updates without timeline refresh.", + "type": "object", + "required": [ + "targetDate", + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "counting": { + "description": "Count direction. Default: `down`.", + "anyOf": [ + { + "$ref": "#/definitions/TimerCounting" + }, + { + "type": "null" + } + ] + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontSize": { + "description": "Font size in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontWeight": { + "description": "Font weight.", + "anyOf": [ + { + "$ref": "#/definitions/FontWeight" + }, + { + "type": "null" + } + ] + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "targetDate": { + "description": "ISO 8601 target date.", + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "timer" + ] + } + } + }, + { + "description": "Declarative canvas — draw arbitrary shapes via JSON commands.", + "type": "object", + "required": [ + "height", + "type", + "width" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "elements": { + "description": "Draw commands (`circle`, `line`, `rect`, `arc`, `text`, `path`).", + "type": "array", + "items": { + "$ref": "#/definitions/CanvasDrawCommand" + } + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "height": { + "description": "Canvas height in points.", + "type": "number", + "format": "double" + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "canvas" + ] + }, + "width": { + "description": "Canvas width in points.", + "type": "number", + "format": "double" + } + } + }, + { + "description": "Convenience element combining an SF Symbol / icon with text.", + "type": "object", + "required": [ + "systemName", + "text", + "type" + ], + "properties": { + "background": { + "description": "Solid, adaptive, or gradient background.", + "anyOf": [ + { + "$ref": "#/definitions/BackgroundValue" + }, + { + "type": "null" + } + ] + }, + "border": { + "description": "Border color and width.", + "anyOf": [ + { + "$ref": "#/definitions/BorderConfig" + }, + { + "type": "null" + } + ] + }, + "clipShape": { + "description": "Clip content to a shape (e.g. circle avatar from square image).", + "anyOf": [ + { + "$ref": "#/definitions/ClipShape" + }, + { + "type": "null" + } + ] + }, + "color": { + "description": "Text color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "cornerRadius": { + "description": "Corner radius in points.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "flex": { + "description": "Layout weight for flexible sizing inside stacks (like Android `layout_weight`).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontSize": { + "description": "Text font size.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "fontWeight": { + "description": "Text font weight.", + "anyOf": [ + { + "$ref": "#/definitions/FontWeight" + }, + { + "type": "null" + } + ] + }, + "frame": { + "description": "Explicit width / height / max constraints.", + "anyOf": [ + { + "$ref": "#/definitions/FrameConfig" + }, + { + "type": "null" + } + ] + }, + "iconColor": { + "description": "Icon tint color.", + "anyOf": [ + { + "$ref": "#/definitions/ColorValue" + }, + { + "type": "null" + } + ] + }, + "opacity": { + "description": "Opacity from `0` (invisible) to `1` (opaque).", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "padding": { + "description": "Inset padding (number or per-edge object).", + "anyOf": [ + { + "$ref": "#/definitions/PaddingValue" + }, + { + "type": "null" + } + ] + }, + "shadow": { + "description": "Drop shadow.", + "anyOf": [ + { + "$ref": "#/definitions/ShadowConfig" + }, + { + "type": "null" + } + ] + }, + "spacing": { + "description": "Space between icon and text.", + "type": [ + "number", + "null" + ], + "format": "double" + }, + "systemName": { + "description": "SF Symbol or platform icon name.", + "type": "string" + }, + "text": { + "description": "Label text.", + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "label" + ] + } + } + } + ] + } + } +} diff --git a/docs/showcase.md b/docs/showcase.md index 295cb85..ec23d06 100644 --- a/docs/showcase.md +++ b/docs/showcase.md @@ -27,6 +27,7 @@ Sizes: large ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": null, "medium": null, @@ -70,7 +71,7 @@ Sizes: large - + ## calculator @@ -85,6 +86,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -440,7 +442,7 @@ Sizes: small - + ## canvas @@ -455,6 +457,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "canvas", @@ -488,7 +491,7 @@ Sizes: small - + ## canvas-draws @@ -503,6 +506,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "canvas", @@ -564,7 +568,7 @@ Sizes: small - + ## canvas-scale @@ -579,6 +583,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -618,7 +623,7 @@ Sizes: small - + ## chart-mix @@ -633,6 +638,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -764,7 +770,7 @@ Sizes: medium - + ## container-card @@ -779,6 +785,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "container", @@ -818,7 +825,7 @@ Sizes: small - + ## date-timer @@ -833,6 +840,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "vstack", @@ -923,7 +931,7 @@ Sizes: medium - + ## fitness @@ -942,6 +950,7 @@ Sizes: small, medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -1057,7 +1066,7 @@ Sizes: small, medium - + ## flex-stroke-grid @@ -1072,6 +1081,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "vstack", @@ -1161,7 +1171,7 @@ Sizes: medium - + ## gauge-square @@ -1176,6 +1186,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -1200,7 +1211,7 @@ Sizes: small - + ## hstack-divider @@ -1215,6 +1226,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -1246,7 +1258,7 @@ Sizes: medium - + ## image-sources @@ -1261,6 +1273,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -1335,7 +1348,7 @@ Sizes: medium - + ## layout @@ -1350,6 +1363,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -1403,7 +1417,7 @@ Sizes: small - + ## link-chip @@ -1418,6 +1432,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -1463,7 +1478,7 @@ Sizes: small - + ## list-20-items @@ -1478,6 +1493,7 @@ Sizes: large ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "large": { "type": "list", @@ -1556,7 +1572,7 @@ Sizes: large - + ## music @@ -1571,6 +1587,7 @@ Sizes: medium ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -1653,7 +1670,7 @@ Sizes: medium - + ## nested-dashboard @@ -1668,6 +1685,7 @@ Sizes: large ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "large": { "type": "vstack", @@ -1890,7 +1908,7 @@ Sizes: large - + ## null-fields @@ -1905,6 +1923,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -1939,7 +1958,7 @@ Sizes: small - + ## progress-gauge-variants @@ -1954,6 +1973,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2021,7 +2041,7 @@ Sizes: small - + ## shape-capsule @@ -2036,6 +2056,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2062,7 +2083,7 @@ Sizes: small - + ## style-chrome @@ -2077,6 +2098,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2084,12 +2106,8 @@ Sizes: small "spacing": 10, "cornerRadius": 16, "background": { - "gradientType": "linear", - "colors": [ - "#0f172a", - "#312e81" - ], - "direction": "topLeadingToBottomTrailing" + "light": "#EEF2FF", + "dark": "#0f172a" }, "border": { "color": "#6366f1", @@ -2124,7 +2142,7 @@ Sizes: small "content": "Chrome", "fontSize": 15, "fontWeight": "semibold", - "color": "#f8fafc", + "color": "label", "opacity": 0.85 } ] @@ -2136,7 +2154,78 @@ Sizes: small - + + +## style-chrome.light + +Sizes: small + +### small + + + +
+Config JSON + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "version": 1, + "small": { + "type": "vstack", + "padding": 12, + "spacing": 10, + "cornerRadius": 16, + "background": { + "light": "#EEF2FF", + "dark": "#0f172a" + }, + "border": { + "color": "#6366f1", + "width": 2 + }, + "shadow": { + "color": "rgba(99,102,241,0.45)", + "radius": 10, + "x": 0, + "y": 4 + }, + "children": [ + { + "type": "hstack", + "spacing": 10, + "alignment": "center", + "children": [ + { + "type": "image", + "systemName": "person.fill", + "size": 36, + "color": "#fff", + "clipShape": "circle", + "background": "#6366f1", + "frame": { + "width": 36, + "height": 36 + } + }, + { + "type": "text", + "content": "Chrome", + "fontSize": 15, + "fontWeight": "semibold", + "color": "label", + "opacity": 0.85 + } + ] + } + ] + } +} +``` + +
+ + ## tasks @@ -2151,6 +2240,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2224,7 +2314,7 @@ Sizes: small - + ## text-align @@ -2239,6 +2329,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2273,7 +2364,7 @@ Sizes: small - + ## toggle-row @@ -2288,6 +2379,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2319,7 +2411,7 @@ Sizes: small - + ## type-styles @@ -2334,6 +2426,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -2379,7 +2472,7 @@ Sizes: small - + ## upcoming-payments @@ -2402,6 +2495,7 @@ Sizes: small, medium, large ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -3155,7 +3249,7 @@ Sizes: small, medium, large - + ## upcoming-payments-empty @@ -3178,6 +3272,7 @@ Sizes: small, medium, large ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -3341,112 +3436,967 @@ Sizes: small, medium, large - + -## vstack-spacer +## upcoming-payments.light Sizes: small ### small - +
Config JSON ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", - "spacing": 4, - "padding": 8, - "background": "#111827", + "padding": 14, + "spacing": 10, + "cornerRadius": 16, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { - "type": "text", - "content": "Top", - "fontSize": 14, - "color": "#fff" + "type": "divider", + "color": "#3878FA", + "thickness": 4 + }, + { + "type": "hstack", + "spacing": 10, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 44, + "cornerRadius": 10 + }, + { + "type": "text", + "content": "S", + "fontSize": 18, + "fontWeight": "bold", + "fontDesign": "rounded", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 2, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Streaming", + "fontSize": 15, + "fontWeight": "bold", + "fontDesign": "rounded", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 1", + "fontSize": 11, + "fontWeight": "semibold", + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + } + ] }, { "type": "spacer" }, { "type": "text", - "content": "Bottom", - "fontSize": 14, - "color": "#fff" + "content": "$12.50", + "fontSize": 22, + "fontWeight": "bold", + "fontDesign": "rounded", + "color": "#3878FA", + "alignment": "leading", + "lineLimit": 1 } ] - } -} -``` - -
- - - -## weather - -Sizes: small - -### small - - - -
-Config JSON - -```json -{ - "version": 1, - "small": { + }, + "medium": { "type": "vstack", + "padding": 10, + "spacing": 4, + "cornerRadius": 16, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { - "type": "text", - "content": "72°", - "fontSize": 36, + "type": "label", + "text": "Upcoming", + "systemName": "calendar.badge.clock", + "fontSize": 11, "fontWeight": "bold", - "fontDesign": null, - "textStyle": null, - "color": "#fff", - "alignment": null, - "lineLimit": null, - "padding": null, - "background": null, - "cornerRadius": null, - "opacity": null, - "frame": null, - "border": null, - "shadow": null, - "clipShape": null, - "flex": null + "color": "label", + "iconColor": "#3878FA", + "spacing": 4 }, { - "type": "progress", - "value": 0.7, - "total": 1, - "label": "Humidity", - "tint": "#4CAF50", - "color": null, - "bar_style": null, - "padding": null, - "background": null, - "cornerRadius": null, - "opacity": null, - "frame": null, - "border": null, - "shadow": null, - "clipShape": null, - "flex": null - } - ], - "spacing": 8, - "alignment": null, - "padding": 12, - "background": "#1a1a2e", + "type": "grid", + "columns": 2, + "spacing": 8, + "rowSpacing": 6, + "children": [ + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "S", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Streaming", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 1", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$12.50", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "C", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Cloud Pro", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Today", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$9.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "M", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Music Plus", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Tomorrow", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$4.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "N", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "News Daily", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 5", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$2.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + } + ] + }, + { + "type": "spacer", + "minLength": 2 + }, + { + "type": "text", + "content": "+2 more", + "fontSize": 9, + "fontWeight": "bold", + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + "large": { + "type": "vstack", + "padding": 12, + "spacing": 4, + "cornerRadius": 16, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, + "children": [ + { + "type": "label", + "text": "Upcoming", + "systemName": "calendar.badge.clock", + "fontSize": 11, + "fontWeight": "bold", + "color": "label", + "iconColor": "#3878FA", + "spacing": 4 + }, + { + "type": "grid", + "columns": 2, + "spacing": 10, + "rowSpacing": 8, + "children": [ + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "S", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Streaming", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 1", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$12.50", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "C", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Cloud Pro", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Today", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$9.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "M", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Music Plus", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Tomorrow", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$4.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "N", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "News Daily", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 5", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$2.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "G", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "Gym Club", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 8", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$29.00", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + }, + { + "type": "hstack", + "spacing": 6, + "alignment": "center", + "children": [ + { + "type": "zstack", + "alignment": "center", + "children": [ + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "V", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } + ] + }, + { + "type": "vstack", + "spacing": 0, + "alignment": "leading", + "flex": 1, + "children": [ + { + "type": "text", + "content": "VPN Secure", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 12", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + }, + { + "type": "text", + "content": "$6.49", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } + ] + } + ] + }, + { + "type": "spacer", + "minLength": 4 + }, + { + "type": "text", + "content": "+4 more", + "fontSize": 9, + "fontWeight": "bold", + "color": "secondaryLabel", + "lineLimit": 1 + } + ] + } +} +``` + +
+ + + +## vstack-spacer + +Sizes: small + +### small + + + +
+Config JSON + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "version": 1, + "small": { + "type": "vstack", + "spacing": 4, + "padding": 8, + "background": "#111827", + "children": [ + { + "type": "text", + "content": "Top", + "fontSize": 14, + "color": "#fff" + }, + { + "type": "spacer" + }, + { + "type": "text", + "content": "Bottom", + "fontSize": 14, + "color": "#fff" + } + ] + } +} +``` + +
+ + + +## weather + +Sizes: small + +### small + + + +
+Config JSON + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "version": 1, + "small": { + "type": "vstack", + "children": [ + { + "type": "text", + "content": "72°", + "fontSize": 36, + "fontWeight": "bold", + "fontDesign": null, + "textStyle": null, + "color": "#fff", + "alignment": null, + "lineLimit": null, + "padding": null, + "background": null, + "cornerRadius": null, + "opacity": null, + "frame": null, + "border": null, + "shadow": null, + "clipShape": null, + "flex": null + }, + { + "type": "progress", + "value": 0.7, + "total": 1, + "label": "Humidity", + "tint": "#4CAF50", + "color": null, + "bar_style": null, + "padding": null, + "background": null, + "cornerRadius": null, + "opacity": null, + "frame": null, + "border": null, + "shadow": null, + "clipShape": null, + "flex": null + } + ], + "spacing": 8, + "alignment": null, + "padding": 12, + "background": { + "light": "#F2F2F7", + "dark": "#1a1a2e" + }, + "cornerRadius": 16, + "opacity": null, + "frame": null, + "border": null, + "shadow": null, + "clipShape": null, + "flex": null + }, + "medium": null, + "large": null +} +``` + +
+ + + +## weather.light + +Sizes: small + +### small + + + +
+Config JSON + +```json +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "version": 1, + "small": { + "type": "vstack", + "children": [ + { + "type": "text", + "content": "72°", + "fontSize": 36, + "fontWeight": "bold", + "fontDesign": null, + "textStyle": null, + "color": "#fff", + "alignment": null, + "lineLimit": null, + "padding": null, + "background": null, + "cornerRadius": null, + "opacity": null, + "frame": null, + "border": null, + "shadow": null, + "clipShape": null, + "flex": null + }, + { + "type": "progress", + "value": 0.7, + "total": 1, + "label": "Humidity", + "tint": "#4CAF50", + "color": null, + "bar_style": null, + "padding": null, + "background": null, + "cornerRadius": null, + "opacity": null, + "frame": null, + "border": null, + "shadow": null, + "clipShape": null, + "flex": null + } + ], + "spacing": 8, + "alignment": null, + "padding": 12, + "background": { + "light": "#F2F2F7", + "dark": "#1a1a2e" + }, "cornerRadius": 16, "opacity": null, "frame": null, @@ -3462,7 +4412,7 @@ Sizes: small
- + ## zstack-align @@ -3477,6 +4427,7 @@ Sizes: small ```json { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "zstack", @@ -3503,6 +4454,6 @@ Sizes: small - + diff --git a/examples/tauri-plugin-widgets-example/README.md b/examples/tauri-plugin-widgets-example/README.md index caab6a5..eb24cb5 100755 --- a/examples/tauri-plugin-widgets-example/README.md +++ b/examples/tauri-plugin-widgets-example/README.md @@ -157,4 +157,4 @@ Proportional space distribution within stacks: ## App Group -This example uses `group.com.s00d.tauri-plugin-widgets-example` as the App Group identifier. When setting up native widgets on iOS/macOS, ensure both the main app and widget extension use this same identifier. +This example uses `group.com.s00d.tauriwidgets.example` as the App Group identifier. When setting up native widgets on iOS/macOS, ensure both the main app and widget extension use this same identifier. diff --git a/examples/tauri-plugin-widgets-example/package.json b/examples/tauri-plugin-widgets-example/package.json index 035ba27..f760809 100755 --- a/examples/tauri-plugin-widgets-example/package.json +++ b/examples/tauri-plugin-widgets-example/package.json @@ -1,7 +1,7 @@ { "name": "tauri-plugin-widgets-example", "private": true, - "version": "0.4.1", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/examples/tauri-plugin-widgets-example/src-tauri/Cargo.lock b/examples/tauri-plugin-widgets-example/src-tauri/Cargo.lock index db706ae..86856b1 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/Cargo.lock +++ b/examples/tauri-plugin-widgets-example/src-tauri/Cargo.lock @@ -2938,6 +2938,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2960,6 +2974,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3370,6 +3418,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3648,8 +3702,9 @@ dependencies = [ [[package]] name = "tauri-plugin-widgets" -version = "0.4.3" +version = "0.5.0" dependencies = [ + "base64 0.22.1", "gtk", "log", "raw-window-handle", @@ -3657,12 +3712,13 @@ dependencies = [ "serde_json", "tauri", "tauri-plugin", + "ureq", "x11rb", ] [[package]] name = "tauri-plugin-widgets-example" -version = "0.4.1" +version = "0.5.0" dependencies = [ "serde", "serde_json", @@ -4187,6 +4243,29 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4477,6 +4556,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4707,6 +4804,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5268,6 +5374,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/examples/tauri-plugin-widgets-example/src-tauri/Cargo.toml b/examples/tauri-plugin-widgets-example/src-tauri/Cargo.toml index fd4da0b..d9df4ab 100755 --- a/examples/tauri-plugin-widgets-example/src-tauri/Cargo.toml +++ b/examples/tauri-plugin-widgets-example/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-plugin-widgets-example" -version = "0.4.1" +version = "0.5.0" description = "Example app for tauri-plugin-widgets" authors = ["s00d"] edition = "2021" diff --git a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtension/WidgetExtension.swift b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtension/WidgetExtension.swift index 0ce9a16..9c03944 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtension/WidgetExtension.swift +++ b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtension/WidgetExtension.swift @@ -4,7 +4,7 @@ import TauriWidgets struct WidgetExtension: Widget { let kind = "ExampleWidget" - let appGroup = "group.com.s00d.tauri-plugin-widgets-example" + let appGroup = "group.com.s00d.tauriwidgets.example" let widgetId = "example" var body: some WidgetConfiguration { diff --git a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtensionExtension.entitlements b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtensionExtension.entitlements index fc8609d..4f83bda 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtensionExtension.entitlements +++ b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/WidgetExtensionExtension.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.s00d.tauri-plugin-widgets-example + group.com.s00d.tauriwidgets.example diff --git a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/tauri-plugin-widgets-example_iOS/tauri-plugin-widgets-example_iOS.entitlements b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/tauri-plugin-widgets-example_iOS/tauri-plugin-widgets-example_iOS.entitlements index fc8609d..4f83bda 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/tauri-plugin-widgets-example_iOS/tauri-plugin-widgets-example_iOS.entitlements +++ b/examples/tauri-plugin-widgets-example/src-tauri/gen/apple/tauri-plugin-widgets-example_iOS/tauri-plugin-widgets-example_iOS.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.s00d.tauri-plugin-widgets-example + group.com.s00d.tauriwidgets.example diff --git a/examples/tauri-plugin-widgets-example/src-tauri/gen/schemas/linux-schema.json b/examples/tauri-plugin-widgets-example/src-tauri/gen/schemas/linux-schema.json index 34dfbbd..489525c 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/gen/schemas/linux-schema.json +++ b/examples/tauri-plugin-widgets-example/src-tauri/gen/schemas/linux-schema.json @@ -2361,10 +2361,10 @@ "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." }, { - "description": "Default permissions for the widgets plugin — enables all commands.\n#### This default permission set includes:\n\n- `allow-set-items`\n- `allow-get-items`\n- `allow-set-register-widget`\n- `allow-reload-all-timelines`\n- `allow-reload-timelines`\n- `allow-request-widget`\n- `allow-create-widget-window`\n- `allow-close-widget-window`\n- `allow-set-widget-config`\n- `allow-get-widget-config`\n- `allow-widget-action`\n- `allow-poll-pending-actions`\n- `allow-report-receipt`\n- `allow-get-widget-diagnostics`", + "description": "Default permissions for the widgets plugin — enables all commands.\n#### This default permission set includes:\n\n- `allow-set-items`\n- `allow-get-items`\n- `allow-set-register-widget`\n- `allow-reload-all-timelines`\n- `allow-reload-timelines`\n- `allow-request-widget`\n- `allow-create-widget-window`\n- `allow-close-widget-window`\n- `allow-set-widget-config`\n- `allow-get-widget-config`\n- `allow-widget-action`\n- `allow-poll-pending-actions`\n- `allow-report-receipt`\n- `allow-get-widget-diagnostics`\n- `allow-get-widget-trace`\n- `allow-flush-widget-trace`", "type": "string", "const": "widgets:default", - "markdownDescription": "Default permissions for the widgets plugin — enables all commands.\n#### This default permission set includes:\n\n- `allow-set-items`\n- `allow-get-items`\n- `allow-set-register-widget`\n- `allow-reload-all-timelines`\n- `allow-reload-timelines`\n- `allow-request-widget`\n- `allow-create-widget-window`\n- `allow-close-widget-window`\n- `allow-set-widget-config`\n- `allow-get-widget-config`\n- `allow-widget-action`\n- `allow-poll-pending-actions`\n- `allow-report-receipt`\n- `allow-get-widget-diagnostics`" + "markdownDescription": "Default permissions for the widgets plugin — enables all commands.\n#### This default permission set includes:\n\n- `allow-set-items`\n- `allow-get-items`\n- `allow-set-register-widget`\n- `allow-reload-all-timelines`\n- `allow-reload-timelines`\n- `allow-request-widget`\n- `allow-create-widget-window`\n- `allow-close-widget-window`\n- `allow-set-widget-config`\n- `allow-get-widget-config`\n- `allow-widget-action`\n- `allow-poll-pending-actions`\n- `allow-report-receipt`\n- `allow-get-widget-diagnostics`\n- `allow-get-widget-trace`\n- `allow-flush-widget-trace`" }, { "description": "Enables the close_widget_window command without any pre-configured scope.", @@ -2378,6 +2378,12 @@ "const": "widgets:allow-create-widget-window", "markdownDescription": "Enables the create_widget_window command without any pre-configured scope." }, + { + "description": "Enables the flush_widget_trace command without any pre-configured scope.", + "type": "string", + "const": "widgets:allow-flush-widget-trace", + "markdownDescription": "Enables the flush_widget_trace command without any pre-configured scope." + }, { "description": "Enables the get_items command without any pre-configured scope.", "type": "string", @@ -2396,6 +2402,12 @@ "const": "widgets:allow-get-widget-diagnostics", "markdownDescription": "Enables the get_widget_diagnostics command without any pre-configured scope." }, + { + "description": "Enables the get_widget_trace command without any pre-configured scope.", + "type": "string", + "const": "widgets:allow-get-widget-trace", + "markdownDescription": "Enables the get_widget_trace command without any pre-configured scope." + }, { "description": "Enables the poll_pending_actions command without any pre-configured scope.", "type": "string", @@ -2462,6 +2474,12 @@ "const": "widgets:deny-create-widget-window", "markdownDescription": "Denies the create_widget_window command without any pre-configured scope." }, + { + "description": "Denies the flush_widget_trace command without any pre-configured scope.", + "type": "string", + "const": "widgets:deny-flush-widget-trace", + "markdownDescription": "Denies the flush_widget_trace command without any pre-configured scope." + }, { "description": "Denies the get_items command without any pre-configured scope.", "type": "string", @@ -2480,6 +2498,12 @@ "const": "widgets:deny-get-widget-diagnostics", "markdownDescription": "Denies the get_widget_diagnostics command without any pre-configured scope." }, + { + "description": "Denies the get_widget_trace command without any pre-configured scope.", + "type": "string", + "const": "widgets:deny-get-widget-trace", + "markdownDescription": "Denies the get_widget_trace command without any pre-configured scope." + }, { "description": "Denies the poll_pending_actions command without any pre-configured scope.", "type": "string", diff --git a/examples/tauri-plugin-widgets-example/src-tauri/ios-widget/MyWidget.swift b/examples/tauri-plugin-widgets-example/src-tauri/ios-widget/MyWidget.swift index 101a2bc..a0b3990 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/ios-widget/MyWidget.swift +++ b/examples/tauri-plugin-widgets-example/src-tauri/ios-widget/MyWidget.swift @@ -3,12 +3,12 @@ import SwiftUI import TauriWidgets // Enable App Groups in BOTH targets with: -// group.com.s00d.tauri-plugin-widgets-example +// group.com.s00d.tauriwidgets.example @main struct MyWidget: Widget { let kind = "ExampleWidget" - let appGroup = "group.com.s00d.tauri-plugin-widgets-example" + let appGroup = "group.com.s00d.tauriwidgets.example" let widgetId = "example" var body: some WidgetConfiguration { diff --git a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/.gitignore b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/.gitignore index 37e0146..0685ad3 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/.gitignore +++ b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/.gitignore @@ -1,3 +1,7 @@ build/ *.xcodeproj xcuserdata/ + +# regenerated by build-widget.sh from plugins.widgets.appGroup +App.entitlements +TauriWidgetExtension.entitlements diff --git a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/App.entitlements b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/App.entitlements index 131150c..0b0277b 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/App.entitlements +++ b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/App.entitlements @@ -4,7 +4,7 @@ com.apple.security.application-groups - group.com.s00d.tauri-plugin-widgets-example + group.com.s00d.tauriwidgets.example diff --git a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/Sources/MyWidget.swift b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/Sources/MyWidget.swift index 0aa3d27..6a77358 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/Sources/MyWidget.swift +++ b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/Sources/MyWidget.swift @@ -5,7 +5,7 @@ import TauriWidgets @main struct MyWidget: Widget { let kind = "ExampleWidget" - let appGroup = "group.com.s00d.tauri-plugin-widgets-example" + let appGroup = "group.com.s00d.tauriwidgets.example" let widgetId = "example" var body: some WidgetConfiguration { diff --git a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/TauriWidgetExtension.entitlements b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/TauriWidgetExtension.entitlements index 5de5862..ee86812 100644 --- a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/TauriWidgetExtension.entitlements +++ b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/TauriWidgetExtension.entitlements @@ -6,7 +6,7 @@ com.apple.security.application-groups - group.com.s00d.tauri-plugin-widgets-example + group.com.s00d.tauriwidgets.example diff --git a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/build-widget.sh b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/build-widget.sh index 57d31b5..09ff609 100755 --- a/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/build-widget.sh +++ b/examples/tauri-plugin-widgets-example/src-tauri/macos-widget/build-widget.sh @@ -10,9 +10,69 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIGURATION="${1:-Release}" DERIVED_DATA="$SCRIPT_DIR/build" -ENTITLEMENTS="$SCRIPT_DIR/TauriWidgetExtension.entitlements" +HOST_ENTS="$SCRIPT_DIR/App.entitlements" +EXT_ENTS="$SCRIPT_DIR/TauriWidgetExtension.entitlements" IDENTITY="${WIDGET_SIGN_IDENTITY:-${APPLE_SIGNING_IDENTITY:--}}" +# ─── Regenerate entitlements from plugins.widgets.appGroup ─────────────────── +# Single source of truth in tauri.conf.json — avoids host/extension drift. + +CONF="$SCRIPT_DIR/../tauri.conf.json" +if [[ ! -f "$CONF" ]]; then + CONF="$(cd "$SCRIPT_DIR/../.." && pwd)/src-tauri/tauri.conf.json" +fi + +gen_entitlements() { + local dest="$1" + local group="$2" + local sandbox="$3" + { + echo '' + echo '' + echo '' + echo '' + if [[ "$sandbox" == "1" ]]; then + echo ' com.apple.security.app-sandbox' + echo ' ' + fi + echo ' com.apple.security.application-groups' + echo ' ' + echo " ${group}" + echo ' ' + echo '' + echo '' + } >"$dest" +} + +if [[ -f "$CONF" ]] && command -v node >/dev/null 2>&1; then + GROUP="$(node -e " + const fs = require('fs'); + const conf = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); + const g = conf.plugins && conf.plugins.widgets && conf.plugins.widgets.appGroup; + if (!g) { console.error('ERROR: plugins.widgets.appGroup missing in', process.argv[1]); process.exit(1); } + process.stdout.write(String(g)); + " "$CONF")" + if [[ ! "$GROUP" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "ERROR: invalid appGroup identifier: $GROUP" >&2 + exit 1 + fi + gen_entitlements "$HOST_ENTS" "$GROUP" 0 + gen_entitlements "$EXT_ENTS" "$GROUP" 1 + echo "[widget] Regenerated entitlements for App Group: $GROUP" + for swift in "$SCRIPT_DIR"/Sources/*.swift "$SCRIPT_DIR"/MyWidget.swift; do + [[ -f "$swift" ]] || continue + if grep -q 'let appGroup = "' "$swift" 2>/dev/null; then + sed -i '' "s/let appGroup = \"[^\"]*\"/let appGroup = \"${GROUP}\"/" "$swift" + fi + done + echo "[widget] Synced appGroup in Swift sources" +elif [[ -f "$EXT_ENTS" ]]; then + echo "[widget] WARNING: could not read plugins.widgets.appGroup — using existing entitlements" +else + echo "ERROR: need plugins.widgets.appGroup in tauri.conf.json (and node on PATH) to generate entitlements" >&2 + exit 1 +fi + # ─── Generate Xcode project ───────────────────────────────────────────────── if command -v xcodegen &>/dev/null; then @@ -58,13 +118,13 @@ rm -rf "$APPEX_PATH/Contents/Frameworks" # ─── Sign .appex with WidgetKit entitlements ───────────────────────────────── -if [ -f "$ENTITLEMENTS" ]; then +if [ -f "$EXT_ENTS" ]; then codesign --force --options runtime --sign "$IDENTITY" \ - --entitlements "$ENTITLEMENTS" \ + --entitlements "$EXT_ENTS" \ "$APPEX_PATH" echo "[widget] Signed .appex with entitlements ($IDENTITY)" else - echo "WARNING: Entitlements not found at $ENTITLEMENTS — signing without them" + echo "WARNING: Entitlements not found at $EXT_ENTS — signing without them" codesign --force --options runtime --sign "$IDENTITY" "$APPEX_PATH" fi diff --git a/examples/tauri-plugin-widgets-example/src-tauri/tauri.conf.json b/examples/tauri-plugin-widgets-example/src-tauri/tauri.conf.json index 67ecf5b..51dc8df 100755 --- a/examples/tauri-plugin-widgets-example/src-tauri/tauri.conf.json +++ b/examples/tauri-plugin-widgets-example/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "widget-example", - "version": "0.3.0", + "version": "../package.json", "identifier": "com.s00d.tauri-plugin-widgets-example", "build": { "beforeDevCommand": "pnpm dev", @@ -24,7 +24,7 @@ }, "plugins": { "widgets": { - "appGroup": "group.com.s00d.tauri-plugin-widgets-example", + "appGroup": "group.com.s00d.tauriwidgets.example", "transport": "auto", "extensionBundleId": "com.s00d.tauri-plugin-widgets-example.widgetkit" } diff --git a/examples/tauri-plugin-widgets-example/src/App.tsx b/examples/tauri-plugin-widgets-example/src/App.tsx index 2ec2f17..111e531 100755 --- a/examples/tauri-plugin-widgets-example/src/App.tsx +++ b/examples/tauri-plugin-widgets-example/src/App.tsx @@ -15,7 +15,7 @@ import { PRESETS } from "./presets"; import "./App.css"; const WIDGET_KIND = "ExampleWidget"; -const APP_GROUP = "group.com.s00d.tauri-plugin-widgets-example"; +const APP_GROUP = "group.com.s00d.tauriwidgets.example"; const WIDGET_ID = "example"; const WIDGET_LABEL = "desktop-widget"; @@ -122,6 +122,8 @@ function App() { const stop = await startWidgetUpdater(builder, APP_GROUP, WIDGET_ID, { intervalMs, immediate: true, + // One-shot apply: reload WidgetKit once. Live ticks must not burn the budget. + reload: intervalMs === 0, }); // A newer click won the race — drop this updater. @@ -165,6 +167,7 @@ function App() { const stop = await startWidgetUpdater(() => config, APP_GROUP, WIDGET_ID, { intervalMs: 0, immediate: true, + reload: true, }); if (gen !== applyGenRef.current) { stop(); diff --git a/guest-js/api/actions.ts b/guest-js/api/actions.ts new file mode 100644 index 0000000..8763971 --- /dev/null +++ b/guest-js/api/actions.ts @@ -0,0 +1,110 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { PLUGIN_ID } from "./plugin"; + +// ─── Widget Action API ────────────────────────────────────────────────────── + +/** Payload delivered by the `widget-action` event. */ +export interface WidgetActionPayload { + /** The action identifier from the button config. */ + action: string; + /** Optional extra data (e.g. serialized JSON from the widget). */ + payload?: string; + /** Unix epoch ms when the action was enqueued / emitted. */ + ts?: number; + /** Widget identity that emitted the action. */ + widgetId?: string; + /** App group / prefs namespace. */ + group?: string; +} + +/** + * Trigger a `widget-action` event via the Rust plugin. + * + * The Rust command broadcasts the event through `app.emit()`, so every + * window that called `onWidgetAction()` (or `listen("widget-action", …)`) + * will receive it — including the main application window. + * + * @param action - Action identifier (matches `ButtonElement.action`). + * @param payload - Optional extra string payload. + */ +export async function widgetAction( + action: string, + payload?: string, + opts?: { widgetId?: string; group?: string }, +): Promise { + if (!action) throw new Error("widgetAction: 'action' must not be empty"); + return await invoke(`${PLUGIN_ID}|widget_action`, { + action, + payload, + widgetId: opts?.widgetId, + group: opts?.group, + }); +} + +/** + * Listen for `widget-action` events emitted by widget buttons. + * + * @param callback - Called with the action payload each time a widget button + * with an `action` field is tapped. + * @returns A function to stop listening. + * + * @example + * ```ts + * import { onWidgetAction } from "tauri-plugin-widgets-api"; + * + * const unlisten = await onWidgetAction((data) => { + * console.log("Widget action:", data.action, data.payload); + * if (data.action === "refresh_data") { + * fetchLatestData(); + * } + * }); + * ``` + */ +export async function onWidgetAction( + callback: (data: WidgetActionPayload) => void, +): Promise { + return await listen("widget-action", (event) => { + callback(event.payload); + }); +} + +/** Poll pending widget actions queued by native side (Android fallback path). */ +export async function pollPendingWidgetActions( + group: string, +): Promise { + let res: unknown; + try { + res = await invoke(`${PLUGIN_ID}|poll_pending_actions`, { group }); + } catch (e) { + const msg = String(e ?? ""); + // iOS/macOS builds may not expose this Android-specific command path. + if (msg.includes("No command pollPendingActions") || msg.includes("pollPendingActions")) { + return []; + } + throw e; + } + const raw = Array.isArray(res) + ? res + : ((res as { results?: unknown } | null | undefined)?.results ?? null); + if (!Array.isArray(raw)) return []; + const parsed: Array = raw.map((item) => { + if (!item || typeof item !== "object") return null; + const obj = item as { + action?: unknown; + payload?: unknown; + ts?: unknown; + widgetId?: unknown; + group?: unknown; + }; + if (typeof obj.action !== "string" || obj.action.length === 0) return null; + return { + action: obj.action, + ...(typeof obj.payload === "string" ? { payload: obj.payload } : {}), + ...(typeof obj.ts === "number" ? { ts: obj.ts } : {}), + ...(typeof obj.widgetId === "string" ? { widgetId: obj.widgetId } : {}), + ...(typeof obj.group === "string" ? { group: obj.group } : {}), + }; + }); + return parsed.filter((v): v is WidgetActionPayload => v !== null); +} diff --git a/guest-js/api/config.ts b/guest-js/api/config.ts new file mode 100644 index 0000000..11d8fd1 --- /dev/null +++ b/guest-js/api/config.ts @@ -0,0 +1,99 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { WidgetConfig } from "../generated/widget-types"; +import { PLUGIN_ID } from "./plugin"; + +/** + * Why a config write was skipped. + */ +export type SkipReason = + | { reason: "unchanged"; hash: number } + | { reason: "noInstances" } + | { reason: "transportUnavailable"; name: string }; + +/** + * Result of a native WidgetKit / AppWidget reload attempt. + */ +export type ReloadOutcome = + | { outcome: "ok" } + | { outcome: "throttled"; remainingSecs: number } + | { outcome: "skipped"; why: string } + | { outcome: "failed"; error: string }; + +/** + * Full result of {@link setWidgetConfig}. Never treat success as “reload happened”. + */ +export interface ApplyOutcome { + /** `true` when store bytes changed and were written. */ + written: boolean; + reload: ReloadOutcome; + /** Transport names that received the map (empty when not written). */ + transports: string[]; + skip?: SkipReason; +} + +/** + * Send a declarative UI configuration to native widgets. + * + * The JSON config is stored in the widget data store and read by the native + * widget extension, which renders it using platform-native components: + * - **iOS / macOS** — SwiftUI views (`VStack`, `Text`, `Gauge`, etc.) + * - **Android** — `RemoteViews` (`LinearLayout`, `TextView`, etc.) + * - **Desktop** — HTML/CSS in the widget window + * + * Returns {@link ApplyOutcome}: check `written` and `reload.outcome` — a + * successful invoke no longer means a native reload was performed. + * + * @param config - The widget UI configuration. + * @param group - Widget group identifier (same as `setItems` group). + * + * @example + * ```ts + * const outcome = await setWidgetConfig({ + * small: { + * type: "vstack", + * padding: 12, + * background: "#1a1a2e", + * children: [ + * { type: "text", content: "72°", fontSize: 36, fontWeight: "bold", color: "#fff" }, + * { type: "progress", value: 0.7, tint: "#4CAF50" }, + * ], + * }, + * }, "group.com.example.myapp", "weather"); + * if (outcome.reload.outcome === "throttled") { + * console.warn("reload delayed", outcome.reload.remainingSecs); + * } + * ``` + */ +export async function setWidgetConfig( + config: WidgetConfig, + group: string, + widgetId: string, + /** Skip native widget reload (WidgetKit / AppWidgetManager). + * Desktop widget windows are always updated instantly via eval push. */ + skipReload = false, +): Promise { + if (!group) throw new Error("setWidgetConfig: 'group' must not be empty"); + if (!widgetId) throw new Error("setWidgetConfig: 'widgetId' must not be empty"); + return await invoke(`${PLUGIN_ID}|set_widget_config`, { + config, group, widgetId, skipReload, + }); +} + +/** + * Read the current widget UI configuration from the data store. + * + * @param group - Widget group identifier. + * @param widgetId - Widget identity within the group. + * @returns The current `WidgetConfig`, or `null` if none has been set. + */ +export async function getWidgetConfig( + group: string, + widgetId: string, +): Promise { + if (!group) throw new Error("getWidgetConfig: 'group' must not be empty"); + if (!widgetId) throw new Error("getWidgetConfig: 'widgetId' must not be empty"); + return await invoke(`${PLUGIN_ID}|get_widget_config`, { + group, + widgetId, + }); +} diff --git a/guest-js/api/data.ts b/guest-js/api/data.ts new file mode 100644 index 0000000..2ecb01c --- /dev/null +++ b/guest-js/api/data.ts @@ -0,0 +1,43 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PLUGIN_ID } from "./plugin"; + +/** + * Store a key-value pair in the widget data store. + * + * The storage backend depends on the platform: + * - **Android** — `SharedPreferences` scoped by `group`. + * - **iOS / macOS** — JSON file in App Group shared container. + * - **Desktop** — JSON file in the app data directory (macOS: shared container when available). + * + * @param key - Setting name (e.g. `"temperature"`). + * @param value - Setting value. Use `JSON.stringify()` for complex data. + * @param group - Widget group identifier: + * Android: SharedPreferences name; + * iOS/macOS: App Group ID (e.g. `"group.com.example.myapp"`); + * Desktop: arbitrary string used as filename. + */ +export async function setItems( + key: string, + value: string, + group: string, +): Promise { + if (!key) throw new Error("setItems: 'key' must not be empty"); + if (!group) throw new Error("setItems: 'group' must not be empty"); + return await invoke(`${PLUGIN_ID}|set_items`, { key, value, group }); +} + +/** + * Read a previously stored value from the widget data store. + * + * @param key - The key to look up. + * @param group - The widget group identifier. + * @returns The stored string value, or `null` if the key was never set. + */ +export async function getItems( + key: string, + group: string, +): Promise { + if (!key) throw new Error("getItems: 'key' must not be empty"); + if (!group) throw new Error("getItems: 'group' must not be empty"); + return await invoke(`${PLUGIN_ID}|get_items`, { key, group }); +} diff --git a/guest-js/api/diagnostics.ts b/guest-js/api/diagnostics.ts new file mode 100644 index 0000000..43d8cb9 --- /dev/null +++ b/guest-js/api/diagnostics.ts @@ -0,0 +1,111 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PLUGIN_ID } from "./plugin"; +import type { ReloadOutcome } from "./config"; + +/** Element the renderer skipped, with reason. */ +export interface SkippedElement { + type: string; + reason: string; +} + +/** Cross-platform render receipt (diagnostics — not a critical path). */ +export interface WidgetRenderReceipt { + widgetId: string; + group: string; + instance: string; + nonce: number; + size?: string; + theme?: string; + schema?: number; + /** prefs | state | appgroup | defaults | container | push | pull */ + source: string; + /** reload | timeline | action | added | resize | snapshot */ + trigger?: string; + rendered?: string[]; + skipped?: SkippedElement[]; + ts: number; +} + +/** One host journal entry (`WIDGET_DEBUG` / debug builds). */ +export type TraceEvent = + | { + kind: "configSet"; + widgetId: string; + nonce: number; + bytes: number; + changed: boolean; + skip?: { reason: "unchanged"; hash: number } | { reason: "noInstances" } | { + reason: "transportUnavailable"; + name: string; + }; + } + | { + kind: "write"; + transport: string; + ok: boolean; + durationMs: number; + error?: string; + } + | { kind: "reload"; performed: boolean; reason: ReloadOutcome } + | { kind: "poll"; count: number } + | { + kind: "render"; + instance: string; + nonce: number; + source: string; + trigger: string; + lagMs: number; + skipped: SkippedElement[]; + }; + +export interface TraceEntry { + ts: number; + kind: TraceEvent["kind"]; + [key: string]: unknown; +} + +export interface WidgetTrace { + enabled: boolean; + events: TraceEntry[]; + receipts: WidgetRenderReceipt[]; +} + +/** + * Report that a renderer painted a config (desktop widget.html / tests). + * Native widgets write receipts themselves; this is for the host webview path. + */ +export async function reportReceipt(receipt: WidgetRenderReceipt): Promise { + return await invoke(`${PLUGIN_ID}|report_receipt`, { receipt }); +} + +/** + * Live widget instances that recently rendered for `group`. + */ +export async function getWidgetDiagnostics( + group: string, +): Promise { + if (!group) throw new Error("getWidgetDiagnostics: 'group' must not be empty"); + return await invoke(`${PLUGIN_ID}|get_widget_diagnostics`, { + group, + }); +} + +/** + * Host delivery journal + receipt history for `group`. + * Active in debug builds or when `WIDGET_DEBUG=1`. + */ +export async function getWidgetTrace( + group: string, + opts?: { since?: number }, +): Promise { + if (!group) throw new Error("getWidgetTrace: 'group' must not be empty"); + return await invoke(`${PLUGIN_ID}|get_widget_trace`, { + group, + sinceMs: opts?.since ?? null, + }); +} + +/** Force-flush the in-memory journal to disk (desktop). */ +export async function flushWidgetTrace(): Promise { + return await invoke(`${PLUGIN_ID}|flush_widget_trace`); +} diff --git a/guest-js/api/plugin.ts b/guest-js/api/plugin.ts new file mode 100644 index 0000000..7c7446d --- /dev/null +++ b/guest-js/api/plugin.ts @@ -0,0 +1,2 @@ +/** Shared plugin invoke prefix. */ +export const PLUGIN_ID = "plugin:widgets"; diff --git a/guest-js/api/register.ts b/guest-js/api/register.ts new file mode 100644 index 0000000..da144b6 --- /dev/null +++ b/guest-js/api/register.ts @@ -0,0 +1,32 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PLUGIN_ID } from "./plugin"; + +// ─── Registration & Pinning ───────────────────────────────────────────────── + +/** + * Register widget provider class names or kind strings. + * + * Must be called before `reloadAllTimelines()` or `requestWidget()`. + * + * @param widgets - Array of provider identifiers: + * - **Android**: fully-qualified class names (e.g. `"com.example.app.MyWidget"`) + * - **iOS / macOS**: widget kind strings from your `WidgetConfiguration` + */ +export async function setRegisterWidget( + widgets: string[], +): Promise { + if (!widgets?.length) { + throw new Error("setRegisterWidget: 'widgets' must be a non-empty array"); + } + return await invoke(`${PLUGIN_ID}|set_register_widget`, { widgets }); +} + +/** + * Request the OS to pin (add) a widget to the home screen. + * + * - **Android** (API 26+) — calls `AppWidgetManager.requestPinAppWidget`. + * - **iOS / macOS / Desktop** — no-op (users add widgets manually). + */ +export async function requestWidget(): Promise { + return await invoke(`${PLUGIN_ID}|request_widget`); +} diff --git a/guest-js/api/reload.ts b/guest-js/api/reload.ts new file mode 100644 index 0000000..a37fa86 --- /dev/null +++ b/guest-js/api/reload.ts @@ -0,0 +1,27 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PLUGIN_ID } from "./plugin"; + +// ─── Reload API ────────────────────────────────────────────────────────────── + +/** + * Reload all registered widget timelines. + * + * - **Android** — sends `ACTION_APPWIDGET_UPDATE` broadcast for every registered provider. + * - **iOS / macOS** — calls `WidgetCenter.shared.reloadAllTimelines()`. + * - **Desktop** — emits a `"widget-reload"` Tauri event that widget windows can listen for. + */ +export async function reloadAllTimelines(): Promise { + return await invoke(`${PLUGIN_ID}|reload_all_timelines`); +} + +/** + * Reload timelines for a specific widget kind or class. + * + * @param ofKind - Widget class name (Android: fully-qualified class, e.g. + * `"com.example.app.MyWidget"`; iOS: widget kind string from + * your `WidgetConfiguration`). + */ +export async function reloadTimelines(ofKind: string): Promise { + if (!ofKind) throw new Error("reloadTimelines: 'ofKind' must not be empty"); + return await invoke(`${PLUGIN_ID}|reload_timelines`, { ofKind }); +} diff --git a/guest-js/api/updater.ts b/guest-js/api/updater.ts new file mode 100644 index 0000000..f06e6d1 --- /dev/null +++ b/guest-js/api/updater.ts @@ -0,0 +1,203 @@ +import type { WidgetConfig } from "../generated/widget-types"; +import { setWidgetConfig } from "./config"; +import { reloadAllTimelines } from "./reload"; +import { + onWidgetAction, + pollPendingWidgetActions, + type WidgetActionPayload, +} from "./actions"; + +// ─── Widget Updater ───────────────────────────────────────────────────────── + +/** + * Handlers registered by `startWidgetUpdater` instances, keyed by group then + * widgetId. Polling a group's pending-action queue drains it entirely, so a + * single shared dispatcher is used to route every envelope to the matching + * sibling widget instead of losing actions to whichever updater happened to + * poll first. + */ +const pendingActionHandlers = new Map void>>(); +/** In-flight poll per group, so concurrent ticks share a single drain. */ +const pendingActionPolls = new Map>(); + +function pollAndDispatchPendingActions(group: string): Promise { + const inFlight = pendingActionPolls.get(group); + if (inFlight) return inFlight; + const promise = (async () => { + let pending: WidgetActionPayload[]; + try { + pending = await pollPendingWidgetActions(group); + } catch (e) { + console.debug("[widget-updater] pollPendingWidgetActions failed:", e); + return; + } + const handlers = pendingActionHandlers.get(group); + if (!handlers || handlers.size === 0) return; + for (const item of pending) { + for (const [widgetId, handler] of handlers) { + if (item.widgetId && item.widgetId !== widgetId) continue; + if (item.group && item.group !== group) continue; + handler(item); + } + } + })(); + pendingActionPolls.set(group, promise); + return promise.finally(() => { + if (pendingActionPolls.get(group) === promise) pendingActionPolls.delete(group); + }); +} + +/** + * Start a periodic widget updater that calls `builder` on a fixed interval, + * sends the returned config to the native widget, and optionally reloads + * widget timelines. + * + * This is a convenience wrapper around `setInterval` + `setWidgetConfig` + + * `reloadAllTimelines` so you don't have to write boilerplate timers. + * + * **Important (iOS/macOS):** Apple enforces a daily widget reload budget + * (~40-70 reloads/day). The Rust backend throttles `reloadAllTimelines()` + * to at most once every 15 minutes. For frequent data updates, set + * `reload: false` (the default) — desktop webview widgets update instantly + * via events without counting against the budget. + * + * @param builder - Called every `intervalMs`. Return the new `WidgetConfig`. + * May be async. + * @param group - App Group / SharedPreferences group identifier. + * @param options - Optional settings. + * @param options.intervalMs - Update interval in milliseconds (default `5000`). + * For native mobile widgets, consider using a + * longer interval (60000+) to avoid wasted work. + * @param options.immediate - If `true`, run the builder immediately before + * the first interval tick (default `true`). + * @param options.reload - If `true`, call `reloadAllTimelines()` after + * each update so native widgets refresh + * (default `false`). Set to `true` for native + * widgets; leave `false` for desktop-only. + * Note: backend throttles this to once per 15 min + * on iOS/macOS to respect the budget. + * @returns A `stop()` function. Call it to clear the timer. + * + * @example + * ```ts + * import { startWidgetUpdater } from "tauri-plugin-widgets-api"; + * + * const stop = await startWidgetUpdater( + * () => ({ + * small: { + * type: "vstack", padding: 12, background: "#1a1a2e", + * children: [ + * { type: "text", content: new Date().toLocaleTimeString(), + * fontSize: 24, fontWeight: "bold", color: "#fff" }, + * ], + * }, + * }), + * "group.com.example.myapp", + * "clock", + * { + * intervalMs: 60_000, + * reload: true, + * onAction: (action, payload) => { + * console.log("Widget action:", action, payload); + * }, + * }, + * ); + * + * // later — stop updating and action listener: + * stop(); + * ``` + */ +export async function startWidgetUpdater( + builder: () => WidgetConfig | Promise, + group: string, + widgetId: string, + options?: { + /** Tick interval in ms. Use `0` for a one-shot call (no periodic updates). Default: `1000`. */ + intervalMs?: number; + /** Call `builder` immediately before starting the interval. Default: `true`. */ + immediate?: boolean; + /** Also call `reloadAllTimelines()` after each tick. Default: `false`. + * Backend throttles to once per 15 min on iOS/macOS. */ + reload?: boolean; + /** Subscribe to `widget-action` events for the lifetime of this updater. + * The listener is automatically removed when the returned stop function is called. */ + onAction?: (action: string, payload?: string) => void; + }, +): Promise<() => void> { + if (!widgetId) throw new Error("startWidgetUpdater: 'widgetId' must not be empty"); + const intervalMs = options?.intervalMs ?? 5000; + const immediate = options?.immediate ?? true; + const reload = options?.reload ?? false; + + let running = false; + let cancelled = false; + + if (options?.onAction) { + const handler = options.onAction; + let handlers = pendingActionHandlers.get(group); + if (!handlers) { + handlers = new Map(); + pendingActionHandlers.set(group, handlers); + } + handlers.set(widgetId, (item) => handler(item.action, item.payload)); + } + + async function tick() { + if (cancelled || running) return; + running = true; + try { + // Drain native pending_actions (iOS WidgetKit / Android fallback) into handlers. + // Only poll when an action handler is registered — otherwise draining + // would silently discard actions nobody is listening for. + if (options?.onAction) { + await pollAndDispatchPendingActions(group); + if (cancelled) return; + } + + const config = await builder(); + if (cancelled) return; + // skipReload: true — the native reload-on-write is suppressed here so + // the explicit reloadAllTimelines() call below stays the single source + // of truth for `options.reload`, matching the documented behavior. + await setWidgetConfig(config, group, widgetId, true); + if (cancelled) return; + if (reload) { + await reloadAllTimelines(); + } + } catch (e) { + if (!cancelled) console.error("[widget-updater] tick failed:", e); + } finally { + running = false; + } + } + + // Register the event listener BEFORE the immediate tick so a macOS + // background poller / host emit cannot win the race and drop actions. + let actionUnsub: (() => void) | null = null; + if (!cancelled && options?.onAction) { + const handler = options.onAction; + actionUnsub = await onWidgetAction((data) => { + if (cancelled) return; + if (data.widgetId && data.widgetId !== widgetId) return; + if (data.group && data.group !== group) return; + handler(data.action, data.payload); + }); + } + + if (immediate) { + await tick(); + } + + const id = !cancelled && intervalMs > 0 ? setInterval(tick, intervalMs) : null; + + return () => { + cancelled = true; + if (id !== null) clearInterval(id); + if (actionUnsub) { actionUnsub(); actionUnsub = null; } + const handlers = pendingActionHandlers.get(group); + if (handlers) { + handlers.delete(widgetId); + if (handlers.size === 0) pendingActionHandlers.delete(group); + } + }; +} diff --git a/guest-js/api/window.ts b/guest-js/api/window.ts new file mode 100644 index 0000000..53a4700 --- /dev/null +++ b/guest-js/api/window.ts @@ -0,0 +1,105 @@ +import { invoke } from "@tauri-apps/api/core"; +import { PLUGIN_ID } from "./plugin"; + +// ─── Widget Window API (desktop only) ─────────────────────────────────────── + +/** Configuration for creating a desktop widget window. */ +export interface WidgetWindowConfig { + /** Unique window label (e.g. `"cpu-widget"`). Used to reference the window later. */ + label: string; + /** + * Frontend route to load. When omitted (or empty) the plugin + * serves its built-in widget renderer automatically — no extra + * files needed. Set to a path (e.g. `"/widget.html"`) only if + * you want to use your own custom renderer. + */ + url?: string; + /** Window width in logical pixels. */ + width: number; + /** Window height in logical pixels. */ + height: number; + /** X position on screen (pixels from left). */ + x?: number; + /** Y position on screen (pixels from top). */ + y?: number; + /** Keep above all other windows. Default: `false`. */ + alwaysOnTop?: boolean; + /** Hide from taskbar / dock. Default: `true`. */ + skipTaskbar?: boolean; + /** + * Widget group identifier. Required when using the built-in + * renderer — tells it which config to load via `getWidgetConfig`. + * Ignored when a custom `url` is provided. + */ + group?: string; + /** + * Widget identity within the group. Required for the built-in renderer. + */ + widgetId?: string; + /** + * Size family the built-in renderer should display: + * `"small"`, `"medium"`, or `"large"`. Defaults to `"small"`. + */ + size?: "small" | "medium" | "large"; +} + +/** + * Create a frameless, transparent desktop widget window. + * + * When no `url` is specified the plugin serves its built-in renderer + * automatically — it reads the config via `getWidgetConfig(group)` and + * renders it using vanilla HTML/CSS/SVG. The window has a drag handle + * at the top and a close button that appears on hover. + * + * You may provide your own `url` (e.g. `"/my-widget.html"`) if you + * need full control over the rendering. + * + * **Desktop only.** Returns an error on mobile. + * + * @example + * ```ts + * // Built-in renderer (recommended): + * await createWidgetWindow({ + * label: "weather", + * width: 280, + * height: 200, + * group: "group.com.example.myapp", + * widgetId: "weather", + * size: "small", + * }); + * + * // Custom renderer: + * await createWidgetWindow({ + * label: "weather", + * url: "/my-widget.html", + * width: 280, + * height: 200, + * }); + * ``` + */ +export async function createWidgetWindow( + config: WidgetWindowConfig, +): Promise { + if (!config.label) throw new Error("createWidgetWindow: 'label' is required"); + if (!config.url) { + if (!config.group) { + throw new Error("createWidgetWindow: 'group' is required for the built-in renderer"); + } + if (!config.widgetId) { + throw new Error("createWidgetWindow: 'widgetId' is required for the built-in renderer"); + } + } + return await invoke(`${PLUGIN_ID}|create_widget_window`, { config }); +} + +/** + * Close a previously created desktop widget window. + * + * @param label - The window label used when creating it. + * + * **Desktop only.** + */ +export async function closeWidgetWindow(label: string): Promise { + if (!label) throw new Error("closeWidgetWindow: 'label' is required"); + return await invoke(`${PLUGIN_ID}|close_widget_window`, { label }); +} diff --git a/guest-js/generated/widget-types.ts b/guest-js/generated/widget-types.ts index 6847b09..cc1379d 100644 --- a/guest-js/generated/widget-types.ts +++ b/guest-js/generated/widget-types.ts @@ -1,8 +1,9 @@ /** -* Generated widget IR types — do not edit by hand. -* Source of truth: Rust `src/models.rs` via `cargo run --bin gen-ts --features codegen`. -* Emitter: `src/codegen.rs` IR_SPEC. -*/ + * Generated widget IR types — do not edit by hand. + * Source of truth: Rust `src/models` via `cargo run --bin gen-ts --features codegen`. + * Emitter: `src/codegen.rs` IR_SPEC (+ `src/codegen/preamble.ts`). + */ + // ── Enums ── @@ -20,70 +21,70 @@ export type ShapeType = "circle" | "capsule" | "rectangle"; export type TimerCounting = "up" | "down"; export type ClipShape = "circle" | "capsule" | "rectangle"; export type TextStyle = -| "largeTitle" | "title" | "title2" | "title3" -| "headline" | "subheadline" -| "body" | "callout" -| "footnote" | "caption" | "caption2"; + | "largeTitle" | "title" | "title2" | "title3" + | "headline" | "subheadline" + | "body" | "callout" + | "footnote" | "caption" | "caption2"; export type GradientType = "linear" | "radial" | "angular"; export type GradientDirection = -| "topToBottom" | "bottomToTop" -| "leadingToTrailing" | "trailingToLeading" -| "topLeadingToBottomTrailing" | "topTrailingToBottomLeading"; + | "topToBottom" | "bottomToTop" + | "leadingToTrailing" | "trailingToLeading" + | "topLeadingToBottomTrailing" | "topTrailingToBottomLeading"; // ── Supporting types ── export type ColorValue = string | { light: string; dark: string }; export interface ChartDataPoint { -label: string; -value: number; -color?: ColorValue; + label: string; + value: number; + color?: ColorValue; } export interface FrameConfig { -width?: number; -height?: number; -maxWidth?: number | "infinity"; -maxHeight?: number | "infinity"; + width?: number; + height?: number; + maxWidth?: number | "infinity"; + maxHeight?: number | "infinity"; } export interface BorderConfig { -color: string; -width?: number; + color: string; + width?: number; } export interface GradientConfig { -gradientType: GradientType; -colors: string[]; -direction?: GradientDirection; + gradientType: GradientType; + colors: string[]; + direction?: GradientDirection; } export interface ShadowConfig { -color?: string; -radius?: number; -x?: number; -y?: number; + color?: string; + radius?: number; + x?: number; + y?: number; } export type BackgroundValue = string | GradientConfig | { light: string; dark: string }; export type PaddingValue = number | { -top?: number; -bottom?: number; -leading?: number; -trailing?: number; + top?: number; + bottom?: number; + leading?: number; + trailing?: number; }; export interface ElementStyle { -padding?: PaddingValue; -background?: BackgroundValue; -cornerRadius?: number; -opacity?: number; -frame?: FrameConfig; -border?: BorderConfig; -shadow?: ShadowConfig; -clipShape?: ClipShape; -flex?: number; + padding?: PaddingValue; + background?: BackgroundValue; + cornerRadius?: number; + opacity?: number; + frame?: FrameConfig; + border?: BorderConfig; + shadow?: ShadowConfig; + clipShape?: ClipShape; + flex?: number; } export interface VStackElement extends ElementStyle { @@ -255,51 +256,6 @@ export interface LabelElement extends ElementStyle { spacing?: number; } -export interface SpacerElement { -type: "spacer"; -minLength?: number; -} - -export interface ListItem { -text: string; -checked?: boolean; -action?: string; -payload?: string; -} - -export interface CanvasCircle { -draw: "circle"; -cx: number; cy: number; r: number; -fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; -} -export interface CanvasLine { -draw: "line"; -x1: number; y1: number; x2: number; y2: number; -stroke?: ColorValue; strokeWidth?: number; lineCap?: "butt" | "round" | "square"; -} -export interface CanvasRect { -draw: "rect"; -x: number; y: number; width: number; height: number; -fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; cornerRadius?: number; -} -export interface CanvasArc { -draw: "arc"; -cx: number; cy: number; r: number; -startAngle: number; endAngle: number; -fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; -} -export interface CanvasText { -draw: "text"; -x: number; y: number; content: string; -fontSize?: number; color?: ColorValue; anchor?: "start" | "middle" | "end"; -} -export interface CanvasPath { -draw: "path"; -d: string; -fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; -} -export type CanvasDrawCommand = CanvasCircle | CanvasLine | CanvasRect | CanvasArc | CanvasText | CanvasPath; - export type WidgetElement = | VStackElement | HStackElement @@ -323,9 +279,54 @@ export type WidgetElement = | LabelElement | SpacerElement; +export interface SpacerElement { + type: "spacer"; + minLength?: number; +} + +export interface ListItem { + text: string; + checked?: boolean; + action?: string; + payload?: string; +} + +export interface CanvasCircle { + draw: "circle"; + cx: number; cy: number; r: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export interface CanvasLine { + draw: "line"; + x1: number; y1: number; x2: number; y2: number; + stroke?: ColorValue; strokeWidth?: number; lineCap?: "butt" | "round" | "square"; +} +export interface CanvasRect { + draw: "rect"; + x: number; y: number; width: number; height: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; cornerRadius?: number; +} +export interface CanvasArc { + draw: "arc"; + cx: number; cy: number; r: number; + startAngle: number; endAngle: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export interface CanvasText { + draw: "text"; + x: number; y: number; content: string; + fontSize?: number; color?: ColorValue; anchor?: "start" | "middle" | "end"; +} +export interface CanvasPath { + draw: "path"; + d: string; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export type CanvasDrawCommand = CanvasCircle | CanvasLine | CanvasRect | CanvasArc | CanvasText | CanvasPath; + export interface WidgetConfig { -version?: number; -small?: WidgetElement; -medium?: WidgetElement; -large?: WidgetElement; + version?: number; + small?: WidgetElement; + medium?: WidgetElement; + large?: WidgetElement; } diff --git a/guest-js/index.ts b/guest-js/index.ts index 8e623b0..2c61f9c 100755 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -32,720 +32,39 @@ * @module */ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; - -const PLUGIN_ID = "plugin:widgets"; - -// ─── Data API ──────────────────────────────────────────────────────────────── - -/** - * Store a key-value pair in the widget data store. - * - * The storage backend depends on the platform: - * - **Android** — `SharedPreferences` scoped by `group`. - * - **iOS / macOS** — JSON file in App Group shared container. - * - **Desktop** — JSON file in the app data directory (macOS: shared container when available). - * - * @param key - Setting name (e.g. `"temperature"`). - * @param value - Setting value. Use `JSON.stringify()` for complex data. - * @param group - Widget group identifier: - * Android: SharedPreferences name; - * iOS/macOS: App Group ID (e.g. `"group.com.example.myapp"`); - * Desktop: arbitrary string used as filename. - */ -export async function setItems( - key: string, - value: string, - group: string, -): Promise { - if (!key) throw new Error("setItems: 'key' must not be empty"); - if (!group) throw new Error("setItems: 'group' must not be empty"); - return await invoke(`${PLUGIN_ID}|set_items`, { key, value, group }); -} - -/** - * Read a previously stored value from the widget data store. - * - * @param key - The key to look up. - * @param group - The widget group identifier. - * @returns The stored string value, or `null` if the key was never set. - */ -export async function getItems( - key: string, - group: string, -): Promise { - if (!key) throw new Error("getItems: 'key' must not be empty"); - if (!group) throw new Error("getItems: 'group' must not be empty"); - return await invoke(`${PLUGIN_ID}|get_items`, { key, group }); -} - -// ─── Reload API ────────────────────────────────────────────────────────────── - -/** - * Reload all registered widget timelines. - * - * - **Android** — sends `ACTION_APPWIDGET_UPDATE` broadcast for every registered provider. - * - **iOS / macOS** — calls `WidgetCenter.shared.reloadAllTimelines()`. - * - **Desktop** — emits a `"widget-reload"` Tauri event that widget windows can listen for. - */ -export async function reloadAllTimelines(): Promise { - return await invoke(`${PLUGIN_ID}|reload_all_timelines`); -} - -/** - * Reload timelines for a specific widget kind or class. - * - * @param ofKind - Widget class name (Android: fully-qualified class, e.g. - * `"com.example.app.MyWidget"`; iOS: widget kind string from - * your `WidgetConfiguration`). - */ -export async function reloadTimelines(ofKind: string): Promise { - if (!ofKind) throw new Error("reloadTimelines: 'ofKind' must not be empty"); - return await invoke(`${PLUGIN_ID}|reload_timelines`, { ofKind }); -} - -// ─── Registration & Pinning ───────────────────────────────────────────────── - -/** - * Register widget provider class names or kind strings. - * - * Must be called before `reloadAllTimelines()` or `requestWidget()`. - * - * @param widgets - Array of provider identifiers: - * - **Android**: fully-qualified class names (e.g. `"com.example.app.MyWidget"`) - * - **iOS / macOS**: widget kind strings from your `WidgetConfiguration` - */ -export async function setRegisterWidget( - widgets: string[], -): Promise { - if (!widgets?.length) { - throw new Error("setRegisterWidget: 'widgets' must be a non-empty array"); - } - return await invoke(`${PLUGIN_ID}|set_register_widget`, { widgets }); -} - -/** - * Request the OS to pin (add) a widget to the home screen. - * - * - **Android** (API 26+) — calls `AppWidgetManager.requestPinAppWidget`. - * - **iOS / macOS / Desktop** — no-op (users add widgets manually). - */ -export async function requestWidget(): Promise { - return await invoke(`${PLUGIN_ID}|request_widget`); -} - -// ─── Widget Window API (desktop only) ─────────────────────────────────────── - -/** Configuration for creating a desktop widget window. */ -export interface WidgetWindowConfig { - /** Unique window label (e.g. `"cpu-widget"`). Used to reference the window later. */ - label: string; - /** - * Frontend route to load. When omitted (or empty) the plugin - * serves its built-in widget renderer automatically — no extra - * files needed. Set to a path (e.g. `"/widget.html"`) only if - * you want to use your own custom renderer. - */ - url?: string; - /** Window width in logical pixels. */ - width: number; - /** Window height in logical pixels. */ - height: number; - /** X position on screen (pixels from left). */ - x?: number; - /** Y position on screen (pixels from top). */ - y?: number; - /** Keep above all other windows. Default: `false`. */ - alwaysOnTop?: boolean; - /** Hide from taskbar / dock. Default: `true`. */ - skipTaskbar?: boolean; - /** - * Widget group identifier. Required when using the built-in - * renderer — tells it which config to load via `getWidgetConfig`. - * Ignored when a custom `url` is provided. - */ - group?: string; - /** - * Widget identity within the group. Required for the built-in renderer. - */ - widgetId?: string; - /** - * Size family the built-in renderer should display: - * `"small"`, `"medium"`, or `"large"`. Defaults to `"small"`. - */ - size?: "small" | "medium" | "large"; -} - -/** - * Create a frameless, transparent desktop widget window. - * - * When no `url` is specified the plugin serves its built-in renderer - * automatically — it reads the config via `getWidgetConfig(group)` and - * renders it using vanilla HTML/CSS/SVG. The window has a drag handle - * at the top and a close button that appears on hover. - * - * You may provide your own `url` (e.g. `"/my-widget.html"`) if you - * need full control over the rendering. - * - * **Desktop only.** Returns an error on mobile. - * - * @example - * ```ts - * // Built-in renderer (recommended): - * await createWidgetWindow({ - * label: "weather", - * width: 280, - * height: 200, - * group: "group.com.example.myapp", - * widgetId: "weather", - * size: "small", - * }); - * - * // Custom renderer: - * await createWidgetWindow({ - * label: "weather", - * url: "/my-widget.html", - * width: 280, - * height: 200, - * }); - * ``` - */ -export async function createWidgetWindow( - config: WidgetWindowConfig, -): Promise { - if (!config.label) throw new Error("createWidgetWindow: 'label' is required"); - if (!config.url) { - if (!config.group) { - throw new Error("createWidgetWindow: 'group' is required for the built-in renderer"); - } - if (!config.widgetId) { - throw new Error("createWidgetWindow: 'widgetId' is required for the built-in renderer"); - } - } - return await invoke(`${PLUGIN_ID}|create_widget_window`, { config }); -} - -/** - * Close a previously created desktop widget window. - * - * @param label - The window label used when creating it. - * - * **Desktop only.** - */ -export async function closeWidgetWindow(label: string): Promise { - if (!label) throw new Error("closeWidgetWindow: 'label' is required"); - return await invoke(`${PLUGIN_ID}|close_widget_window`, { label }); -} - -// ─── Widget Config API ────────────────────────────────────────────────────── - -/** IR types generated from Rust `src/models.rs` (SoT). Do not hand-edit. */ export * from "./generated/widget-types"; -import type { WidgetConfig } from "./generated/widget-types"; - -/** - * Why a config write was skipped. - */ -export type SkipReason = { - reason: "unchanged"; - hash: number; -}; - -/** - * Result of a native WidgetKit / AppWidget reload attempt. - */ -export type ReloadOutcome = - | { outcome: "ok" } - | { outcome: "throttled"; remainingSecs: number } - | { outcome: "skipped"; why: string } - | { outcome: "failed"; error: string }; - -/** - * Full result of {@link setWidgetConfig}. Never treat success as “reload happened”. - */ -export interface ApplyOutcome { - /** `true` when store bytes changed and were written. */ - written: boolean; - reload: ReloadOutcome; - /** Transport names that received the map (empty when not written). */ - transports: string[]; - skip?: SkipReason; -} - -/** - * Send a declarative UI configuration to native widgets. - * - * The JSON config is stored in the widget data store and read by the native - * widget extension, which renders it using platform-native components: - * - **iOS / macOS** — SwiftUI views (`VStack`, `Text`, `Gauge`, etc.) - * - **Android** — `RemoteViews` (`LinearLayout`, `TextView`, etc.) - * - **Desktop** — HTML/CSS in the widget window - * - * Returns {@link ApplyOutcome}: check `written` and `reload.outcome` — a - * successful invoke no longer means a native reload was performed. - * - * @param config - The widget UI configuration. - * @param group - Widget group identifier (same as `setItems` group). - * - * @example - * ```ts - * const outcome = await setWidgetConfig({ - * small: { - * type: "vstack", - * padding: 12, - * background: "#1a1a2e", - * children: [ - * { type: "text", content: "72°", fontSize: 36, fontWeight: "bold", color: "#fff" }, - * { type: "progress", value: 0.7, tint: "#4CAF50" }, - * ], - * }, - * }, "group.com.example.myapp", "weather"); - * if (outcome.reload.outcome === "throttled") { - * console.warn("reload delayed", outcome.reload.remainingSecs); - * } - * ``` - */ -export async function setWidgetConfig( - config: WidgetConfig, - group: string, - widgetId: string, - /** Skip native widget reload (WidgetKit / AppWidgetManager). - * Desktop widget windows are always updated instantly via eval push. */ - skipReload = false, -): Promise { - if (!group) throw new Error("setWidgetConfig: 'group' must not be empty"); - if (!widgetId) throw new Error("setWidgetConfig: 'widgetId' must not be empty"); - return await invoke(`${PLUGIN_ID}|set_widget_config`, { - config, group, widgetId, skipReload, - }); -} - -/** - * Read the current widget UI configuration from the data store. - * - * @param group - Widget group identifier. - * @param widgetId - Widget identity within the group. - * @returns The current `WidgetConfig`, or `null` if none has been set. - */ -export async function getWidgetConfig( - group: string, - widgetId: string, -): Promise { - if (!group) throw new Error("getWidgetConfig: 'group' must not be empty"); - if (!widgetId) throw new Error("getWidgetConfig: 'widgetId' must not be empty"); - return await invoke(`${PLUGIN_ID}|get_widget_config`, { - group, - widgetId, - }); -} - -/** Element the renderer skipped, with reason. */ -export interface SkippedElement { - type: string; - reason: string; -} - -/** Cross-platform render receipt (diagnostics — not a critical path). */ -export interface WidgetRenderReceipt { - widgetId: string; - group: string; - instance: string; - nonce: number; - size?: string; - theme?: string; - schema?: number; - /** prefs | state | appgroup | defaults | container | push | pull */ - source: string; - /** reload | timeline | action | added | resize | snapshot */ - trigger?: string; - rendered?: string[]; - skipped?: SkippedElement[]; - ts: number; -} - -/** One host journal entry (`WIDGET_DEBUG` / debug builds). */ -export type TraceEvent = - | { - kind: "configSet"; - widgetId: string; - nonce: number; - bytes: number; - changed: boolean; - skip?: { reason: "unchanged"; hash: number } | { reason: "noInstances" } | { - reason: "transportUnavailable"; - name: string; - }; - } - | { - kind: "write"; - transport: string; - ok: boolean; - durationMs: number; - error?: string; - } - | { kind: "reload"; performed: boolean; reason: ReloadOutcome } - | { kind: "poll"; count: number } - | { - kind: "render"; - instance: string; - nonce: number; - source: string; - trigger: string; - lagMs: number; - skipped: SkippedElement[]; - }; - -export interface TraceEntry { - ts: number; - kind: TraceEvent["kind"]; - [key: string]: unknown; -} - -export interface WidgetTrace { - enabled: boolean; - events: TraceEntry[]; - receipts: WidgetRenderReceipt[]; -} - -/** - * Report that a renderer painted a config (desktop widget.html / tests). - * Native widgets write receipts themselves; this is for the host webview path. - */ -export async function reportReceipt(receipt: WidgetRenderReceipt): Promise { - return await invoke(`${PLUGIN_ID}|report_receipt`, { receipt }); -} - -/** - * Live widget instances that recently rendered for `group`. - */ -export async function getWidgetDiagnostics( - group: string, -): Promise { - if (!group) throw new Error("getWidgetDiagnostics: 'group' must not be empty"); - return await invoke(`${PLUGIN_ID}|get_widget_diagnostics`, { - group, - }); -} - -/** - * Host delivery journal + receipt history for `group`. - * Active in debug builds or when `WIDGET_DEBUG=1`. - */ -export async function getWidgetTrace( - group: string, - opts?: { since?: number }, -): Promise { - if (!group) throw new Error("getWidgetTrace: 'group' must not be empty"); - return await invoke(`${PLUGIN_ID}|get_widget_trace`, { - group, - sinceMs: opts?.since ?? null, - }); -} - -/** Force-flush the in-memory journal to disk (desktop). */ -export async function flushWidgetTrace(): Promise { - return await invoke(`${PLUGIN_ID}|flush_widget_trace`); -} - -// ─── Widget Action API ────────────────────────────────────────────────────── - -/** Payload delivered by the `widget-action` event. */ -export interface WidgetActionPayload { - /** The action identifier from the button config. */ - action: string; - /** Optional extra data (e.g. serialized JSON from the widget). */ - payload?: string; - /** Unix epoch ms when the action was enqueued / emitted. */ - ts?: number; - /** Widget identity that emitted the action. */ - widgetId?: string; - /** App group / prefs namespace. */ - group?: string; -} - -/** - * Trigger a `widget-action` event via the Rust plugin. - * - * The Rust command broadcasts the event through `app.emit()`, so every - * window that called `onWidgetAction()` (or `listen("widget-action", …)`) - * will receive it — including the main application window. - * - * @param action - Action identifier (matches `ButtonElement.action`). - * @param payload - Optional extra string payload. - */ -export async function widgetAction( - action: string, - payload?: string, - opts?: { widgetId?: string; group?: string }, -): Promise { - if (!action) throw new Error("widgetAction: 'action' must not be empty"); - return await invoke(`${PLUGIN_ID}|widget_action`, { - action, - payload, - widgetId: opts?.widgetId, - group: opts?.group, - }); -} - -/** - * Listen for `widget-action` events emitted by widget buttons. - * - * @param callback - Called with the action payload each time a widget button - * with an `action` field is tapped. - * @returns A function to stop listening. - * - * @example - * ```ts - * import { onWidgetAction } from "tauri-plugin-widgets-api"; - * - * const unlisten = await onWidgetAction((data) => { - * console.log("Widget action:", data.action, data.payload); - * if (data.action === "refresh_data") { - * fetchLatestData(); - * } - * }); - * ``` - */ -export async function onWidgetAction( - callback: (data: WidgetActionPayload) => void, -): Promise { - return await listen("widget-action", (event) => { - callback(event.payload); - }); -} - -/** Poll pending widget actions queued by native side (Android fallback path). */ -export async function pollPendingWidgetActions( - group: string, -): Promise { - let res: unknown; - try { - res = await invoke(`${PLUGIN_ID}|poll_pending_actions`, { group }); - } catch (e) { - const msg = String(e ?? ""); - // iOS/macOS builds may not expose this Android-specific command path. - if (msg.includes("No command pollPendingActions") || msg.includes("pollPendingActions")) { - return []; - } - throw e; - } - const raw = Array.isArray(res) - ? res - : ((res as { results?: unknown } | null | undefined)?.results ?? null); - if (!Array.isArray(raw)) return []; - const parsed: Array = raw.map((item) => { - if (!item || typeof item !== "object") return null; - const obj = item as { - action?: unknown; - payload?: unknown; - ts?: unknown; - widgetId?: unknown; - group?: unknown; - }; - if (typeof obj.action !== "string" || obj.action.length === 0) return null; - return { - action: obj.action, - ...(typeof obj.payload === "string" ? { payload: obj.payload } : {}), - ...(typeof obj.ts === "number" ? { ts: obj.ts } : {}), - ...(typeof obj.widgetId === "string" ? { widgetId: obj.widgetId } : {}), - ...(typeof obj.group === "string" ? { group: obj.group } : {}), - }; - }); - return parsed.filter((v): v is WidgetActionPayload => v !== null); -} - -// ─── Widget Updater ───────────────────────────────────────────────────────── - -/** - * Handlers registered by `startWidgetUpdater` instances, keyed by group then - * widgetId. Polling a group's pending-action queue drains it entirely, so a - * single shared dispatcher is used to route every envelope to the matching - * sibling widget instead of losing actions to whichever updater happened to - * poll first. - */ -const pendingActionHandlers = new Map void>>(); -/** In-flight poll per group, so concurrent ticks share a single drain. */ -const pendingActionPolls = new Map>(); - -function pollAndDispatchPendingActions(group: string): Promise { - const inFlight = pendingActionPolls.get(group); - if (inFlight) return inFlight; - const promise = (async () => { - let pending: WidgetActionPayload[]; - try { - pending = await pollPendingWidgetActions(group); - } catch (e) { - console.debug("[widget-updater] pollPendingWidgetActions failed:", e); - return; - } - const handlers = pendingActionHandlers.get(group); - if (!handlers || handlers.size === 0) return; - for (const item of pending) { - for (const [widgetId, handler] of handlers) { - if (item.widgetId && item.widgetId !== widgetId) continue; - if (item.group && item.group !== group) continue; - handler(item); - } - } - })(); - pendingActionPolls.set(group, promise); - return promise.finally(() => { - if (pendingActionPolls.get(group) === promise) pendingActionPolls.delete(group); - }); -} - -/** - * Start a periodic widget updater that calls `builder` on a fixed interval, - * sends the returned config to the native widget, and optionally reloads - * widget timelines. - * - * This is a convenience wrapper around `setInterval` + `setWidgetConfig` + - * `reloadAllTimelines` so you don't have to write boilerplate timers. - * - * **Important (iOS/macOS):** Apple enforces a daily widget reload budget - * (~40-70 reloads/day). The Rust backend throttles `reloadAllTimelines()` - * to at most once every 15 minutes. For frequent data updates, set - * `reload: false` (the default) — desktop webview widgets update instantly - * via events without counting against the budget. - * - * @param builder - Called every `intervalMs`. Return the new `WidgetConfig`. - * May be async. - * @param group - App Group / SharedPreferences group identifier. - * @param options - Optional settings. - * @param options.intervalMs - Update interval in milliseconds (default `5000`). - * For native mobile widgets, consider using a - * longer interval (60000+) to avoid wasted work. - * @param options.immediate - If `true`, run the builder immediately before - * the first interval tick (default `true`). - * @param options.reload - If `true`, call `reloadAllTimelines()` after - * each update so native widgets refresh - * (default `false`). Set to `true` for native - * widgets; leave `false` for desktop-only. - * Note: backend throttles this to once per 15 min - * on iOS/macOS to respect the budget. - * @returns A `stop()` function. Call it to clear the timer. - * - * @example - * ```ts - * import { startWidgetUpdater } from "tauri-plugin-widgets-api"; - * - * const stop = await startWidgetUpdater( - * () => ({ - * small: { - * type: "vstack", padding: 12, background: "#1a1a2e", - * children: [ - * { type: "text", content: new Date().toLocaleTimeString(), - * fontSize: 24, fontWeight: "bold", color: "#fff" }, - * ], - * }, - * }), - * "group.com.example.myapp", - * "clock", - * { - * intervalMs: 60_000, - * reload: true, - * onAction: (action, payload) => { - * console.log("Widget action:", action, payload); - * }, - * }, - * ); - * - * // later — stop updating and action listener: - * stop(); - * ``` - */ -export async function startWidgetUpdater( - builder: () => WidgetConfig | Promise, - group: string, - widgetId: string, - options?: { - /** Tick interval in ms. Use `0` for a one-shot call (no periodic updates). Default: `1000`. */ - intervalMs?: number; - /** Call `builder` immediately before starting the interval. Default: `true`. */ - immediate?: boolean; - /** Also call `reloadAllTimelines()` after each tick. Default: `false`. - * Backend throttles to once per 15 min on iOS/macOS. */ - reload?: boolean; - /** Subscribe to `widget-action` events for the lifetime of this updater. - * The listener is automatically removed when the returned stop function is called. */ - onAction?: (action: string, payload?: string) => void; - }, -): Promise<() => void> { - if (!widgetId) throw new Error("startWidgetUpdater: 'widgetId' must not be empty"); - const intervalMs = options?.intervalMs ?? 5000; - const immediate = options?.immediate ?? true; - const reload = options?.reload ?? false; - - let running = false; - let cancelled = false; - - if (options?.onAction) { - const handler = options.onAction; - let handlers = pendingActionHandlers.get(group); - if (!handlers) { - handlers = new Map(); - pendingActionHandlers.set(group, handlers); - } - handlers.set(widgetId, (item) => handler(item.action, item.payload)); - } - - async function tick() { - if (cancelled || running) return; - running = true; - try { - // Drain native pending_actions (iOS WidgetKit / Android fallback) into handlers. - // Only poll when an action handler is registered — otherwise draining - // would silently discard actions nobody is listening for. - if (options?.onAction) { - await pollAndDispatchPendingActions(group); - if (cancelled) return; - } - - const config = await builder(); - if (cancelled) return; - // skipReload: true — the native reload-on-write is suppressed here so - // the explicit reloadAllTimelines() call below stays the single source - // of truth for `options.reload`, matching the documented behavior. - await setWidgetConfig(config, group, widgetId, true); - if (cancelled) return; - if (reload) { - await reloadAllTimelines(); - } - } catch (e) { - if (!cancelled) console.error("[widget-updater] tick failed:", e); - } finally { - running = false; - } - } - - // Register the event listener BEFORE the immediate tick so a macOS - // background poller / host emit cannot win the race and drop actions. - let actionUnsub: (() => void) | null = null; - if (!cancelled && options?.onAction) { - const handler = options.onAction; - actionUnsub = await onWidgetAction((data) => { - if (cancelled) return; - if (data.widgetId && data.widgetId !== widgetId) return; - if (data.group && data.group !== group) return; - handler(data.action, data.payload); - }); - } - - if (immediate) { - await tick(); - } - - const id = !cancelled && intervalMs > 0 ? setInterval(tick, intervalMs) : null; - return () => { - cancelled = true; - if (id !== null) clearInterval(id); - if (actionUnsub) { actionUnsub(); actionUnsub = null; } - const handlers = pendingActionHandlers.get(group); - if (handlers) { - handlers.delete(widgetId); - if (handlers.size === 0) pendingActionHandlers.delete(group); - } - }; -} +export { PLUGIN_ID } from "./api/plugin"; +export { setItems, getItems } from "./api/data"; +export { reloadAllTimelines, reloadTimelines } from "./api/reload"; +export { setRegisterWidget, requestWidget } from "./api/register"; +export { + createWidgetWindow, + closeWidgetWindow, + type WidgetWindowConfig, +} from "./api/window"; +export { + setWidgetConfig, + getWidgetConfig, + type SkipReason, + type ReloadOutcome, + type ApplyOutcome, +} from "./api/config"; +export { + reportReceipt, + getWidgetDiagnostics, + getWidgetTrace, + flushWidgetTrace, + type SkippedElement, + type WidgetRenderReceipt, + type TraceEvent, + type TraceEntry, + type WidgetTrace, +} from "./api/diagnostics"; +export { + widgetAction, + onWidgetAction, + pollPendingWidgetActions, + type WidgetActionPayload, +} from "./api/actions"; +export { startWidgetUpdater } from "./api/updater"; diff --git a/package.json b/package.json index 2949fbb..45cae96 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tauri-plugin-widgets-api", - "version": "0.4.3", + "version": "0.5.0", "author": "s00d", "description": "A Tauri v2 plugin to interact with App Widgets on Android and iOS, with desktop fallback. SharedPreferences / UserDefaults / WidgetKit / AppWidgetManager.", "license": "MIT", @@ -18,13 +18,16 @@ "require": "./dist-js/index.cjs" }, "bin": { - "tauri-plugin-widgets-api": "./bin/cli.mjs", - "tauri-widgets": "./bin/cli.mjs" + "tauri-plugin-widgets-api": "./dist-cli/cli.mjs", + "tauri-widgets": "./dist-cli/cli.mjs" }, "files": [ "dist-js", - "bin", + "dist-cli", "templates", + "schemas/widget-config.v1.json", + "schemas/plugin-config.v1.json", + "schemas/capabilities.json", "swift/Package.swift", "swift/Sources", "widget.html", @@ -33,42 +36,61 @@ ], "scripts": { "build": "rollup -c rollup.config.mjs", + "build:widget": "WIDGET_ONLY=1 rollup -c rollup.config.mjs", "prepublishOnly": "pnpm build", "pretest": "pnpm build", + "test": "pnpm test:rust && pnpm test:cli && pnpm test:desktop && pnpm test:geometry && pnpm test:events && pnpm test:android && pnpm test:swift && pnpm docs:check", + "test:rust": "cargo test --all-features", + "typecheck:cli": "tsc -p src-cli/tsconfig.json --noEmit", + "typecheck:widget": "tsc -p widget-src/tsconfig.json --noEmit", + "typecheck": "pnpm typecheck:cli && pnpm typecheck:widget", "dev": "pnpm --dir examples/tauri-plugin-widgets-example run tauri dev", "build:example": "pnpm -C scripts cli build-example", "build:example:mobile": "pnpm -C scripts cli build-example ios android", - "tauri-widgets": "node bin/cli.mjs", + "tauri-widgets": "node dist-cli/cli.mjs", "playground": "pnpm --dir examples/tauri-plugin-widgets-example run tauri dev", - "codegen": "cargo run --bin gen-schema --features codegen && cargo run --bin gen-ts --features codegen", - "check:codegen": "pnpm codegen && git diff --exit-code schemas/widget-config.v1.json guest-js/generated/widget-types.ts docs/guide/_generated/capability-matrix.md", + "codegen": "cargo run --bin gen-schema --features codegen && cargo run --bin gen-ts --features codegen && cargo run --bin gen-native --features codegen", + "check:codegen": "pnpm codegen && git diff --exit-code schemas/widget-config.v1.json schemas/plugin-config.v1.json schemas/capabilities.json schemas/capabilities.coverage.json guest-js/generated/widget-types.ts swift/Sources/TauriWidgets/Generated/WireCatalog.swift android/src/main/java/render/WireTypes.kt docs/guide/_generated/capability-matrix.md", "cli:tools": "pnpm -C scripts cli", - "docs:generate": "pnpm -C scripts cli docs-generate", - "docs:check": "pnpm -C scripts cli docs-generate --check", - "docs:audit": "pnpm -C scripts cli docs-audit", + "tools": "node dist-cli/cli.mjs tools", + "test:cli": "node --test tests/cli/*.test.mjs", + "docs:generate": "pnpm tools docs-generate", + "docs:check": "pnpm tools docs-generate --check", + "docs:audit": "pnpm tools docs-audit", "docs:dev": "pnpm docs:generate && pnpm -C docs dev", "docs:build": "pnpm docs:generate && pnpm -C docs build", "docs:preview": "pnpm -C docs preview", "test:desktop": "playwright test -c tests/desktop/playwright.config.ts", - "test:desktop:update": "UPDATE_SNAPSHOTS=1 playwright test -c tests/desktop/playwright.config.ts", + "test:desktop:update": "UPDATE_SNAPSHOTS=1 GOLDEN_RECORD=1 GOLDEN_RECORD_ALL=1 playwright test -c tests/desktop/playwright.config.ts", "test:visual:desktop": "playwright test -c tests/desktop/playwright.config.ts tests/desktop/render.spec.ts", "test:events": "playwright test -c tests/desktop/playwright.config.ts tests/desktop/events.spec.ts", "test:events:update": "UPDATE_SNAPSHOTS=1 playwright test -c tests/desktop/playwright.config.ts tests/desktop/events.spec.ts", "test:geometry": "node tests/desktop/geometry-diff.mjs", "test:geometry:update": "pnpm test:desktop:update", - "test:android": "cd android && ./gradlew :testDebugUnitTest", - "test:android:update": "cd android && ./gradlew :testDebugUnitTest -PupdateSnapshots=true", - "test:android:visual": "cd android && ./gradlew :connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=git.s00d.widgets.WidgetRenderTest", + "test:android": "bash scripts/sh/android-gradle.sh :testDebugUnitTest", + "test:android:update": "bash scripts/sh/android-gradle.sh :testDebugUnitTest -PupdateSnapshots=true", + "test:android:visual": "bash scripts/sh/android-gradle.sh :connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=git.s00d.widgets.WidgetRenderTest", + "test:swift": "cd swift && swift test --filter MacRenderTests && swift test --filter RenderTests && swift test --filter TransportTests && swift test --filter DispatcherCoverageTests", + "test:ios:visual": "cd swift && swift test --filter RenderTests", "test:macos:visual": "cd swift && swift test --filter MacRenderTests", "test:macos:transports": "cargo test --lib transport:: && cargo test --test macos_transports && cd swift && swift test --filter TransportTests", "test:macos:pipeline": "bash tests/macos/pipeline.sh", - "triage": "pnpm -C scripts cli triage", - "audit:sheets": "pnpm -C scripts cli audit-sheets", - "ios-up": "pnpm -C scripts cli ios-up", - "android-up": "pnpm -C scripts cli android-up", - "linux-up": "pnpm -C scripts cli linux-up", - "linux-up-wl": "pnpm -C scripts cli linux-up-wl", - "win-up": "pnpm -C scripts cli win-up" + "test:linux": "just test-linux-x11", + "test:windows": "node tests/windows/compare.mjs", + "triage": "pnpm tools triage", + "audit:sheets": "pnpm tools audit-sheets", + "hosts": "pnpm tools hosts", + "shot": "pnpm tools shot", + "cli:test": "pnpm tools test", + "ios-up": "pnpm tools ios-up", + "android-up": "pnpm tools android-up", + "linux-up": "pnpm tools linux-up", + "linux-up-wl": "pnpm tools linux-up-wl", + "win-up": "pnpm tools win-up", + "win-sync": "pnpm tools win-sync", + "win-bootstrap": "pnpm tools win-bootstrap", + "win-pack": "pnpm tools win-pack", + "win-sideload": "pnpm tools win-sideload" }, "dependencies": { "@tauri-apps/api": ">=2.0.0", @@ -77,6 +99,7 @@ "devDependencies": { "@playwright/test": "^1.49.0", "@rollup/plugin-typescript": "^12.0.0", + "@types/node": "^22.10.0", "rollup": "^4.9.6", "tslib": "^2.6.2", "typescript": "^5.3.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037e6c0..baf9d1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@rollup/plugin-typescript': specifier: ^12.0.0 version: 12.3.0(rollup@4.59.0)(tslib@2.8.1)(typescript@5.8.3) + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 rollup: specifier: ^4.9.6 version: 4.59.0 diff --git a/rollup.config.mjs b/rollup.config.mjs index 5d935a9..6c7c109 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,31 +1,107 @@ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { cwd } from 'node:process' -import typescript from '@rollup/plugin-typescript' +import { readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { cwd } from "node:process"; +import typescript from "@rollup/plugin-typescript"; -const pkg = JSON.parse(readFileSync(join(cwd(), 'package.json'), 'utf8')) +const pkg = JSON.parse(readFileSync(join(cwd(), "package.json"), "utf8")); +const runtimeDeps = Object.keys(pkg.dependencies || {}); -export default { - input: 'guest-js/index.ts', - output: [ - { - file: pkg.exports.import, - format: 'esm' - }, - { - file: pkg.exports.require, - format: 'cjs' +/** Guest JS API (existing). */ +const lib = { + input: "guest-js/index.ts", + output: [ + { file: pkg.exports.import, format: "esm" }, + { file: pkg.exports.require, format: "cjs" }, + ], + plugins: [ + typescript({ + declaration: true, + declarationDir: dirname(pkg.exports.import), + }), + ], + external: [ + /^@tauri-apps\/api/, + ...runtimeDeps, + ...Object.keys(pkg.peerDependencies || {}), + ], +}; + +/** Consumer CLI — preserveModules so commands/lib stay importable in tests. */ +const cli = { + input: "src-cli/cli.ts", + output: { + dir: "dist-cli", + format: "esm", + entryFileNames: "[name].mjs", + chunkFileNames: "[name].mjs", + preserveModules: true, + preserveModulesRoot: "src-cli", + }, + plugins: [ + typescript({ + tsconfig: "src-cli/tsconfig.json", + declaration: false, + // Avoid clobbering guest-js emit from the other build in the same rollup run + outputToFilesystem: true, + }), + { + name: "cli-shebang-chmod", + generateBundle(_opts, bundle) { + for (const [fileName, chunk] of Object.entries(bundle)) { + if (chunk.type !== "chunk") continue; + if (fileName === "cli.mjs" || fileName.endsWith("/cli.mjs")) { + if (!chunk.code.startsWith("#!")) { + chunk.code = "#!/usr/bin/env node\n" + chunk.code; + } + } } - ], - plugins: [ - typescript({ - declaration: true, - declarationDir: dirname(pkg.exports.import) - }) - ], - external: [ - /^@tauri-apps\/api/, - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.peerDependencies || {}) - ] -} + }, + writeBundle() { + const main = join(cwd(), "dist-cli", "cli.mjs"); + if (existsSync(main)) chmodSync(main, 0o755); + }, + }, + ], + external: [/^node:/, ...runtimeDeps], +}; + +/** Desktop widget HTML — bundle widget-src/ → widget.html (Rust include_bytes!). */ +const widgetShell = join(cwd(), "widget-src", "shell.html"); +const widgetOut = join(cwd(), "widget.html"); + +const widget = { + input: "widget-src/main.ts", + output: { + file: "widget-src/.bundle.js", + format: "iife", + strict: true, + }, + plugins: [ + typescript({ + tsconfig: "widget-src/tsconfig.json", + declaration: false, + noEmitOnError: true, + // Must share the directory of `output.file` (plugin constraint). + compilerOptions: { + noEmit: false, + outDir: join(cwd(), "widget-src"), + }, + }), + { + name: "widget-html", + writeBundle(_opts, bundle) { + const chunk = Object.values(bundle).find((c) => c.type === "chunk"); + if (!chunk) throw new Error("widget build: no output chunk"); + const shell = readFileSync(widgetShell, "utf8"); + const html = shell.replace( + "", + "" + ); + writeFileSync(widgetOut, html); + }, + }, + ], +}; + +const all = [lib, cli, widget]; +export default process.env.WIDGET_ONLY ? [widget] : all; diff --git a/schemas/capabilities.coverage.json b/schemas/capabilities.coverage.json new file mode 100644 index 0000000..e2c63c1 --- /dev/null +++ b/schemas/capabilities.coverage.json @@ -0,0 +1,175 @@ +{ + "elements": { + "button": { + "android": 5, + "desktop": 5, + "ios": 5, + "linux": 5, + "macos": 5, + "windows": 0 + }, + "canvas": { + "android": 3, + "desktop": 3, + "ios": 3, + "linux": 3, + "macos": 3, + "windows": 0 + }, + "chart": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "container": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "date": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "divider": { + "android": 9, + "desktop": 10, + "ios": 10, + "linux": 9, + "macos": 10, + "windows": 0 + }, + "gauge": { + "android": 4, + "desktop": 4, + "ios": 4, + "linux": 4, + "macos": 4, + "windows": 0 + }, + "grid": { + "android": 6, + "desktop": 7, + "ios": 7, + "linux": 6, + "macos": 7, + "windows": 0 + }, + "hstack": { + "android": 16, + "desktop": 18, + "ios": 18, + "linux": 16, + "macos": 18, + "windows": 0 + }, + "image": { + "android": 6, + "desktop": 7, + "ios": 7, + "linux": 6, + "macos": 7, + "windows": 0 + }, + "label": { + "android": 6, + "desktop": 7, + "ios": 7, + "linux": 6, + "macos": 7, + "windows": 0 + }, + "link": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "list": { + "android": 4, + "desktop": 4, + "ios": 4, + "linux": 4, + "macos": 4, + "windows": 0 + }, + "progress": { + "android": 8, + "desktop": 9, + "ios": 9, + "linux": 8, + "macos": 9, + "windows": 0 + }, + "shape": { + "android": 13, + "desktop": 14, + "ios": 14, + "linux": 13, + "macos": 14, + "windows": 0 + }, + "spacer": { + "android": 10, + "desktop": 11, + "ios": 11, + "linux": 10, + "macos": 11, + "windows": 0 + }, + "text": { + "android": 27, + "desktop": 30, + "ios": 30, + "linux": 27, + "macos": 30, + "windows": 0 + }, + "timer": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "toggle": { + "android": 1, + "desktop": 1, + "ios": 1, + "linux": 1, + "macos": 1, + "windows": 0 + }, + "vstack": { + "android": 30, + "desktop": 33, + "ios": 33, + "linux": 30, + "macos": 33, + "windows": 0 + }, + "zstack": { + "android": 8, + "desktop": 9, + "ios": 9, + "linux": 8, + "macos": 9, + "windows": 0 + } + }, + "note": "Counts of golden PNGs whose fixture contains each element type. Hand matrix remains schemas/capabilities.json.", + "source": "tests/cases + tests/golden", + "version": 1 +} diff --git a/schemas/capabilities.json b/schemas/capabilities.json new file mode 100644 index 0000000..25c2445 --- /dev/null +++ b/schemas/capabilities.json @@ -0,0 +1,611 @@ +{ + "core": [ + "vstack", + "hstack", + "zstack", + "container", + "grid", + "text", + "image", + "spacer", + "divider", + "progress", + "button", + "link", + "shape" + ], + "elements": { + "button": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "canvas": { + "android": { + "note": "bitmap canvas (full SVG path via PathParser)", + "support": "degraded" + }, + "desktop": { + "note": "SVG", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "chart": { + "android": { + "note": "bitmap bar/line/area/pie", + "support": "full" + }, + "desktop": { + "note": "SVG", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "container": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "date": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "divider": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "gauge": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "grid": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "hstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "image": { + "android": { + "note": "url via localPath preprocess; see image.systemName", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "url/data URI; see image.systemName", + "support": "full" + } + }, + "label": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "link": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "list": { + "android": { + "note": "Column chunking; soft cap ~50 items", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards Table", + "support": "degraded" + } + }, + "progress": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "shape": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG", + "support": "degraded" + } + }, + "spacer": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "text": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "timer": { + "android": { + "note": "Chronometer via AndroidRemoteViews", + "support": "full" + }, + "desktop": { + "note": "setInterval", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "provider minute push + static TextBlock", + "support": "degraded" + } + }, + "toggle": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "vstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards 1.5", + "support": "full" + } + }, + "zstack": { + "android": { + "note": "", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "", + "support": "full" + }, + "macos": { + "note": "", + "support": "full" + }, + "windows": { + "note": "rasterized PNG overlay when possible; else flattened Container", + "support": "degraded" + } + } + }, + "extended": [ + "gauge", + "toggle", + "date", + "chart", + "list", + "timer", + "canvas", + "label" + ], + "features": { + "background.gradient": { + "android": { + "note": "baked bitmap at LocalSize / frame", + "support": "full" + }, + "desktop": { + "note": "linear/radial/angular CSS/SVG", + "support": "full" + }, + "ios": { + "note": "linear/radial/angular SwiftUI", + "support": "full" + }, + "macos": { + "note": "linear/radial/angular SwiftUI", + "support": "full" + }, + "windows": { + "note": "rasterized PNG backgroundImage when rasterize enabled", + "support": "degraded" + } + }, + "canvas.path": { + "android": { + "note": "PathParser full SVG path", + "support": "full" + }, + "desktop": { + "note": "SVG path", + "support": "full" + }, + "ios": { + "note": "SVG path grammar", + "support": "full" + }, + "macos": { + "note": "SVG path grammar", + "support": "full" + }, + "windows": { + "note": "rasterized via SVG", + "support": "degraded" + } + }, + "image.systemName": { + "android": { + "note": "SF→Material / emoji map (not SF Symbols)", + "support": "degraded" + }, + "desktop": { + "note": "SF→Material / emoji map (not SF Symbols)", + "support": "degraded" + }, + "ios": { + "note": "SF Symbols", + "support": "full" + }, + "macos": { + "note": "SF Symbols", + "support": "full" + }, + "windows": { + "note": "emoji TextBlock by default; glyph PNG Image with feature rasterize", + "support": "degraded" + } + }, + "image.url": { + "android": { + "note": "preprocess to localPath on setWidgetConfig", + "support": "full" + }, + "desktop": { + "note": "", + "support": "full" + }, + "ios": { + "note": "host prefetch to data URI on setWidgetConfig", + "support": "full" + }, + "macos": { + "note": "host prefetch to data URI on setWidgetConfig", + "support": "full" + }, + "windows": { + "note": "Adaptive Cards Image.url", + "support": "full" + } + }, + "timer.live": { + "android": { + "note": "Chronometer via AndroidRemoteViews", + "support": "full" + }, + "desktop": { + "note": "JS interval", + "support": "full" + }, + "ios": { + "note": "Text(..., .timer)", + "support": "full" + }, + "macos": { + "note": "Text(..., .timer)", + "support": "full" + }, + "windows": { + "note": "provider pushes UpdateWidget ~1/min", + "support": "degraded" + } + } + }, + "platforms": [ + "ios", + "macos", + "android", + "desktop", + "windows" + ], + "version": 1 +} diff --git a/schemas/plugin-config.v1.json b/schemas/plugin-config.v1.json new file mode 100644 index 0000000..a92f496 --- /dev/null +++ b/schemas/plugin-config.v1.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WidgetsPluginConfig", + "description": "`plugins.widgets` object from `tauri.conf.json`.", + "type": "object", + "properties": { + "appGroup": { + "description": "App Group id, e.g. `group.com.example.app`.", + "type": [ + "string", + "null" + ] + }, + "extensionBundleId": { + "description": "Widget extension bundle id (macOS container path). Default: `{app_id}.widgetkit`.", + "type": [ + "string", + "null" + ] + }, + "transport": { + "description": "Host→widget transport driver.", + "allOf": [ + { + "$ref": "#/definitions/TransportKind" + } + ] + } + }, + "definitions": { + "TransportKind": { + "description": "Which Apple host→widget channel to use.\n\nPick this at build time (you know your signing). Do not rely on runtime fan-out.", + "oneOf": [ + { + "description": "App Group shared container file (`widget_data.json`). Needs a real Team ID. Production-safe default; use `auto` explicitly for ad-hoc probing.", + "type": "string", + "enum": [ + "appGroup" + ] + }, + { + "description": "App Group `UserDefaults` suite. Same signing requirements as [`AppGroup`].", + "type": "string", + "enum": [ + "userDefaults" + ] + }, + { + "description": "Widget extension container file. Works with ad-hoc signing; macOS host must not be sandboxed.", + "type": "string", + "enum": [ + "widgetContainer" + ] + }, + { + "description": "One-shot probe at startup (dev only). Prefer pinning an explicit driver in conf.", + "type": "string", + "enum": [ + "auto" + ] + } + ] + } + } +} diff --git a/schemas/widget-config.v1.json b/schemas/widget-config.v1.json index bc79a1d..407345c 100644 --- a/schemas/widget-config.v1.json +++ b/schemas/widget-config.v1.json @@ -50,12 +50,19 @@ "description": "Background: solid color string, adaptive pair, gradient, or material blur.", "anyOf": [ { + "description": "Hex string or semantic color name.", "type": "string" }, { - "$ref": "#/definitions/GradientConfig" + "description": "Linear, radial, or angular gradient.", + "allOf": [ + { + "$ref": "#/definitions/GradientConfig" + } + ] }, { + "description": "Distinct colors for light and dark appearance.", "type": "object", "required": [ "dark", @@ -63,9 +70,11 @@ ], "properties": { "dark": { + "description": "Color used in dark appearance.", "type": "string" }, "light": { + "description": "Color used in light appearance.", "type": "string" } } @@ -73,15 +82,18 @@ ] }, "BorderConfig": { + "description": "Border color and width.", "type": "object", "required": [ "color" ], "properties": { "color": { + "description": "Border color (hex string or semantic name).", "type": "string" }, "width": { + "description": "Border width in points. Default `1.0`.", "default": 1.0, "type": "number", "format": "double" @@ -89,8 +101,10 @@ } }, "CanvasDrawCommand": { + "description": "A single declarative draw command for [`crate::models::CanvasElement`].", "oneOf": [ { + "description": "Filled / stroked circle.", "type": "object", "required": [ "cx", @@ -100,10 +114,12 @@ ], "properties": { "cx": { + "description": "Center x-coordinate.", "type": "number", "format": "double" }, "cy": { + "description": "Center y-coordinate.", "type": "number", "format": "double" }, @@ -114,6 +130,7 @@ ] }, "fill": { + "description": "Fill color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -124,10 +141,12 @@ ] }, "r": { + "description": "Radius.", "type": "number", "format": "double" }, "stroke": { + "description": "Stroke color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -138,6 +157,7 @@ ] }, "strokeWidth": { + "description": "Stroke width in points.", "type": [ "number", "null" @@ -147,6 +167,7 @@ } }, { + "description": "Straight line segment.", "type": "object", "required": [ "draw", @@ -163,12 +184,14 @@ ] }, "lineCap": { + "description": "Line cap style (`\"butt\"`, `\"round\"`, or `\"square\"`).", "type": [ "string", "null" ] }, "stroke": { + "description": "Stroke color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -179,6 +202,7 @@ ] }, "strokeWidth": { + "description": "Stroke width in points.", "type": [ "number", "null" @@ -186,24 +210,29 @@ "format": "double" }, "x1": { + "description": "Start x-coordinate.", "type": "number", "format": "double" }, "x2": { + "description": "End x-coordinate.", "type": "number", "format": "double" }, "y1": { + "description": "Start y-coordinate.", "type": "number", "format": "double" }, "y2": { + "description": "End y-coordinate.", "type": "number", "format": "double" } } }, { + "description": "Filled / stroked rectangle, optionally rounded.", "type": "object", "required": [ "draw", @@ -214,6 +243,7 @@ ], "properties": { "cornerRadius": { + "description": "Corner radius in points.", "type": [ "number", "null" @@ -227,6 +257,7 @@ ] }, "fill": { + "description": "Fill color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -237,10 +268,12 @@ ] }, "height": { + "description": "Rectangle height.", "type": "number", "format": "double" }, "stroke": { + "description": "Stroke color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -251,6 +284,7 @@ ] }, "strokeWidth": { + "description": "Stroke width in points.", "type": [ "number", "null" @@ -258,20 +292,24 @@ "format": "double" }, "width": { + "description": "Rectangle width.", "type": "number", "format": "double" }, "x": { + "description": "Top-left x-coordinate.", "type": "number", "format": "double" }, "y": { + "description": "Top-left y-coordinate.", "type": "number", "format": "double" } } }, { + "description": "Filled / stroked arc.", "type": "object", "required": [ "cx", @@ -283,10 +321,12 @@ ], "properties": { "cx": { + "description": "Center x-coordinate.", "type": "number", "format": "double" }, "cy": { + "description": "Center y-coordinate.", "type": "number", "format": "double" }, @@ -297,10 +337,12 @@ ] }, "endAngle": { + "description": "End angle in degrees.", "type": "number", "format": "double" }, "fill": { + "description": "Fill color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -311,14 +353,17 @@ ] }, "r": { + "description": "Radius.", "type": "number", "format": "double" }, "startAngle": { + "description": "Start angle in degrees.", "type": "number", "format": "double" }, "stroke": { + "description": "Stroke color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -329,6 +374,7 @@ ] }, "strokeWidth": { + "description": "Stroke width in points.", "type": [ "number", "null" @@ -338,6 +384,7 @@ } }, { + "description": "Text label drawn at a point.", "type": "object", "required": [ "content", @@ -347,12 +394,14 @@ ], "properties": { "anchor": { + "description": "Text anchor (`\"start\"`, `\"middle\"`, or `\"end\"`).", "type": [ "string", "null" ] }, "color": { + "description": "Text color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -363,6 +412,7 @@ ] }, "content": { + "description": "Text to draw.", "type": "string" }, "draw": { @@ -372,6 +422,7 @@ ] }, "fontSize": { + "description": "Font size in points.", "type": [ "number", "null" @@ -379,16 +430,19 @@ "format": "double" }, "x": { + "description": "x-coordinate of the anchor point.", "type": "number", "format": "double" }, "y": { + "description": "y-coordinate of the anchor point.", "type": "number", "format": "double" } } }, { + "description": "Arbitrary vector path.", "type": "object", "required": [ "d", @@ -406,6 +460,7 @@ ] }, "fill": { + "description": "Fill color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -416,6 +471,7 @@ ] }, "stroke": { + "description": "Stroke color.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -426,6 +482,7 @@ ] }, "strokeWidth": { + "description": "Stroke width in points.", "type": [ "number", "null" @@ -437,6 +494,7 @@ ] }, "ChartDataPoint": { + "description": "A single labeled data point for [`crate::models::ChartElement`].", "type": "object", "required": [ "label", @@ -444,6 +502,7 @@ ], "properties": { "color": { + "description": "Optional per-point color override.", "anyOf": [ { "$ref": "#/definitions/ColorValue" @@ -454,17 +513,21 @@ ] }, "label": { + "description": "Category / x-axis label.", "type": "string" }, "value": { + "description": "Value plotted for this point.", "type": "number", "format": "double" } } }, "ChartType": { + "description": "Chart kind.", "oneOf": [ { + "description": "`bar`.", "type": "string", "enum": [ "bar" @@ -523,9 +586,11 @@ "description": "Color value — hex string, semantic name, or adaptive `{ light, dark }` pair.\n\nSemantic names: `\"label\"`, `\"secondaryLabel\"`, `\"systemBackground\"`, `\"secondarySystemBackground\"`, `\"accent\"`, `\"separator\"`.", "anyOf": [ { + "description": "Hex string or semantic color name.", "type": "string" }, { + "description": "Distinct colors for light and dark appearance.", "type": "object", "required": [ "dark", @@ -533,9 +598,11 @@ ], "properties": { "dark": { + "description": "Color used in dark appearance.", "type": "string" }, "light": { + "description": "Color used in light appearance.", "type": "string" } } @@ -543,6 +610,7 @@ ] }, "ContentMode": { + "description": "How an image fills its frame.", "oneOf": [ { "description": "`fit`.", @@ -561,6 +629,7 @@ ] }, "DateStyle": { + "description": "Date / relative-time display style.", "oneOf": [ { "description": "`time`.", @@ -600,6 +669,7 @@ ] }, "FontDesign": { + "description": "Font design (default, monospaced, rounded, serif).", "oneOf": [ { "description": "`default`.", @@ -632,6 +702,7 @@ ] }, "FontWeight": { + "description": "Font weight.", "oneOf": [ { "description": "`ultralight`.", @@ -699,9 +770,11 @@ ] }, "FrameConfig": { + "description": "Explicit width / height / max-size constraints for an element.", "type": "object", "properties": { "height": { + "description": "Fixed height in points.", "type": [ "number", "null" @@ -709,6 +782,7 @@ "format": "double" }, "maxHeight": { + "description": "Maximum height — a fixed point value or `\"infinity\"`.", "anyOf": [ { "$ref": "#/definitions/FrameDimension" @@ -719,6 +793,7 @@ ] }, "maxWidth": { + "description": "Maximum width — a fixed point value or `\"infinity\"`.", "anyOf": [ { "$ref": "#/definitions/FrameDimension" @@ -729,6 +804,7 @@ ] }, "width": { + "description": "Fixed width in points.", "type": [ "number", "null" @@ -738,17 +814,24 @@ } }, "FrameDimension": { + "description": "A frame dimension — either a fixed point value or `\"infinity\"`.", "anyOf": [ { + "description": "Fixed point value.", "type": "number", "format": "double" }, { - "type": "string" + "description": "Keyword `\"infinity\"`.", + "type": "string", + "enum": [ + "infinity" + ] } ] }, "GaugeStyle": { + "description": "Gauge visual style.", "oneOf": [ { "description": "`circular`.", @@ -767,6 +850,7 @@ ] }, "GradientConfig": { + "description": "Linear, radial, or angular gradient background.", "type": "object", "required": [ "colors", @@ -774,6 +858,7 @@ ], "properties": { "colors": { + "description": "Stop colors, in order.", "type": "array", "items": { "type": "string" @@ -801,6 +886,7 @@ } }, "GradientDirection": { + "description": "Direction for linear gradients.", "oneOf": [ { "description": "`top to bottom`.", @@ -847,6 +933,7 @@ ] }, "GradientType": { + "description": "Gradient shape.", "oneOf": [ { "description": "`linear`.", @@ -872,6 +959,7 @@ ] }, "HorizontalAlignment": { + "description": "Horizontal alignment of children within a stack.", "oneOf": [ { "description": "`leading`.", @@ -897,44 +985,53 @@ ] }, "ListItem": { + "description": "A single row for [`crate::models::ListElement`].", "type": "object", "required": [ "text" ], "properties": { "action": { + "description": "Action identifier — emits a `widget-action` event when tapped.", "type": [ "string", "null" ] }, "checked": { + "description": "Optional checked/checkbox marker.", "type": [ "boolean", "null" ] }, "payload": { + "description": "Opaque payload forwarded alongside the action event.", "type": [ "string", "null" ] }, "text": { + "description": "Row text.", "type": "string" } } }, "PaddingValue": { + "description": "Inset padding — a single uniform value or per-edge overrides.", "anyOf": [ { + "description": "Same padding on every edge.", "type": "number", "format": "double" }, { + "description": "Independent padding per edge.", "type": "object", "properties": { "bottom": { + "description": "Bottom edge padding.", "type": [ "number", "null" @@ -942,6 +1039,7 @@ "format": "double" }, "leading": { + "description": "Leading (left in LTR) edge padding.", "type": [ "number", "null" @@ -949,6 +1047,7 @@ "format": "double" }, "top": { + "description": "Top edge padding.", "type": [ "number", "null" @@ -956,6 +1055,7 @@ "format": "double" }, "trailing": { + "description": "Trailing (right in LTR) edge padding.", "type": [ "number", "null" @@ -967,6 +1067,7 @@ ] }, "ProgressStyle": { + "description": "Progress indicator style.", "oneOf": [ { "description": "`linear`.", @@ -985,15 +1086,18 @@ ] }, "ShadowConfig": { + "description": "Drop shadow configuration.", "type": "object", "properties": { "color": { + "description": "Shadow color (hex string or semantic name).", "type": [ "string", "null" ] }, "radius": { + "description": "Blur radius in points.", "type": [ "number", "null" @@ -1001,6 +1105,7 @@ "format": "double" }, "x": { + "description": "Horizontal offset in points.", "type": [ "number", "null" @@ -1008,6 +1113,7 @@ "format": "double" }, "y": { + "description": "Vertical offset in points.", "type": [ "number", "null" @@ -1017,8 +1123,10 @@ } }, "ShapeType": { + "description": "Shape kind for [`crate::models::ShapeElement`].", "oneOf": [ { + "description": "`circle`.", "type": "string", "enum": [ "circle" @@ -1041,6 +1149,7 @@ ] }, "TextAlignment": { + "description": "Text alignment within the line.", "oneOf": [ { "description": "`leading`.", @@ -1148,6 +1257,7 @@ ] }, "TimerCounting": { + "description": "Countdown/countup direction for [`crate::models::TimerElement`].", "oneOf": [ { "description": "`up`.", @@ -1166,6 +1276,7 @@ ] }, "VerticalAlignment": { + "description": "Vertical alignment of children within a stack.", "oneOf": [ { "description": "`top`.", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..1b27609 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,47 @@ +# Maintainer scripts + +Citty CLI (`pnpm -C scripts cli …`) plus shell/PowerShell stands. + +## Consumer vs maintainer + +| Audience | Entry | Lives in | +|----------|--------|----------| +| **App authors** | `tauri-widgets` / `npx tauri-widgets` (`dist-cli/cli.mjs`) | published package — `init`, `preview`, `signing`, `validate`, `doctor`, `trace`, `clean` | +| **Plugin maintainers** | `pnpm -C scripts cli …` (root aliases below) | this folder — stands, goldens, docs codegen | + +Do not put consumer-facing commands under `scripts/`; keep them on the published bin. Conversely, visual-stand hosts and golden runners stay here and are not part of the npm API surface. + +## Layout + +| Path | Role | +|------|------| +| `src/` | TypeScript commands (docs, triage, hosts/shot/test, thin wrappers) | +| `sh/` | Bash stands: `*-up`, `shot-linux`, `build-example-artifacts`, `win-{bootstrap,pack,sideload}` | +| `win/` | Windows VM helpers (`sync.sh`, `*.ps1`) | +| `apple.env.example` | Template for local Apple/Android secrets → copy to `apple.env` (gitignored) | + +## Consolidated maintainer commands + +```bash +# Hosts (visual stand) +pnpm hosts up # all: ios android linux linux-wl win +pnpm hosts up linux win # subset +pnpm hosts status # simctl / adb / docker / ssh / widget-probe +pnpm hosts down # docker rm -f wshot wshot-wl + +# One-shot capture +pnpm shot weather.small # linux via scripts/sh/shot-linux.sh (+ size from case JSON) +pnpm shot weather.small desktop # prints just/pnpm commands for other platforms + +# Test suites (alias avoids clashing with package pretest) +pnpm cli:test -- --platform desktop +pnpm cli:test -- --platform geometry --update +pnpm cli:test -- --platform android --case weather.small +``` + +Equivalent: `pnpm -C scripts cli hosts|shot|test …`. + +## Other root aliases + +`pnpm docs:generate`, `pnpm triage`, `pnpm ios-up`, `pnpm build:example`, `pnpm win-pack`, … +Justfile recipes call the same `scripts/sh` / `scripts/win` paths (and still expose `just hosts`). diff --git a/scripts/apple.env.example b/scripts/apple.env.example new file mode 100644 index 0000000..0173f88 --- /dev/null +++ b/scripts/apple.env.example @@ -0,0 +1,21 @@ +# Template — copy to scripts/apple.env (gitignored) and fill in. +# cp scripts/apple.env.example scripts/apple.env +# set -a && source scripts/apple.env && set +a + +export APPLE_DEVELOPMENT_TEAM=YOUR_TEAM_ID +export TAURI_WIDGETS_APP_GROUP=group.com.example.app +export TAURI_WIDGETS_BUNDLE_ID=com.example.app +export TAURI_WIDGETS_WIDGET_BUNDLE_ID=com.example.app.WidgetExtension + +# ASC API key — for portal/automation only. +# Do NOT leave these set when running `pnpm tauri ios build` (dummy codesign). +export APPLE_API_KEY= +export APPLE_API_ISSUER= +export APPLE_API_KEY_PATH= + +# export FASTLANE_USER= +# export FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD= + +# Android: pin JDK ≤21 for AGP +# export JAVA_HOME=$(/usr/libexec/java_home -v 17) +# export ANDROID_HOME=$HOME/Library/Android/sdk diff --git a/scripts/sh/android-gradle.sh b/scripts/sh/android-gradle.sh new file mode 100755 index 0000000..3d71743 --- /dev/null +++ b/scripts/sh/android-gradle.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Pin AGP-compatible JDK (17/21). JDK 26+ makes AGP 8 throw a misleading +# "compileSdkVersion is not specified" while parsing the Java version. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +if [[ -z "${JAVA_HOME:-}" ]] || ! "$JAVA_HOME/bin/java" -version 2>&1 | grep -Eq 'version "(17|21)\.'; then + if command -v /usr/libexec/java_home >/dev/null 2>&1; then + JAVA_HOME="$(/usr/libexec/java_home -v 17 2>/dev/null || /usr/libexec/java_home -v 21 2>/dev/null || true)" + fi +fi +if [[ -z "${JAVA_HOME:-}" || ! -x "${JAVA_HOME}/bin/java" ]]; then + echo "test:android needs JDK 17 or 21 (AGP 8). Set JAVA_HOME." >&2 + exit 1 +fi +export JAVA_HOME +cd "$ROOT/android" +exec ./gradlew "$@" diff --git a/tools/android-up.sh b/scripts/sh/android-up.sh similarity index 98% rename from tools/android-up.sh rename to scripts/sh/android-up.sh index fddc3f9..d92014f 100755 --- a/tools/android-up.sh +++ b/scripts/sh/android-up.sh @@ -18,7 +18,7 @@ PACKAGES_TO_BIND=("$PACKAGE" "git.s00d.widgets" "git.s00d.widgets.test") # Frozen wall clock (UTC): 2026-08-01 12:00:00 FIXED_DATE="${FIXED_DATE:-080112002026.00}" -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" if ! command -v sdkmanager >/dev/null 2>&1; then diff --git a/tools/build-example-artifacts.sh b/scripts/sh/build-example-artifacts.sh similarity index 88% rename from tools/build-example-artifacts.sh rename to scripts/sh/build-example-artifacts.sh index b662548..64d0d35 100755 --- a/tools/build-example-artifacts.sh +++ b/scripts/sh/build-example-artifacts.sh @@ -1,11 +1,19 @@ #!/usr/bin/env bash # Build the example app for available platforms and copy artifacts to /artifacts/. # Usage: -# bash tools/build-example-artifacts.sh # all (skip unavailable) -# bash tools/build-example-artifacts.sh macos ios android +# bash scripts/sh/build-example-artifacts.sh # all (skip unavailable) +# bash scripts/sh/build-example-artifacts.sh macos ios android +# pnpm build:example [platforms…] set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# Optional local secrets (team, App Group, JAVA_HOME, …) +if [[ -f "$ROOT/scripts/apple.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "$ROOT/scripts/apple.env" + set +a +fi EXAMPLE="$ROOT/examples/tauri-plugin-widgets-example" OUT="$ROOT/artifacts" STAMP="$(date -u +%Y%m%dT%H%M%SZ)" @@ -144,12 +152,25 @@ fi # ── Android ──────────────────────────────────────────────────────────────── if want android; then log "Android: tauri android build --apk (aarch64)" + # AGP 8.x does not support JDK 26 — pin 17 (or 21) for Gradle. + if command -v /usr/libexec/java_home >/dev/null 2>&1; then + if JAVA17="$(/usr/libexec/java_home -v 17 2>/dev/null)"; then + export JAVA_HOME="$JAVA17" + export PATH="$JAVA_HOME/bin:$PATH" + log "Android: JAVA_HOME=$JAVA_HOME" + elif JAVA21="$(/usr/libexec/java_home -v 21 2>/dev/null)"; then + export JAVA_HOME="$JAVA21" + export PATH="$JAVA_HOME/bin:$PATH" + log "Android: JAVA_HOME=$JAVA_HOME" + fi + fi export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-}" if [[ -z "${ANDROID_NDK_HOME}" && -d "$ANDROID_HOME/ndk" ]]; then ANDROID_NDK_HOME="$(ls -d "$ANDROID_HOME/ndk"/* 2>/dev/null | sort -V | tail -1 || true)" export ANDROID_NDK_HOME fi + export NDK_HOME="${NDK_HOME:-$ANDROID_NDK_HOME}" if pnpm tauri android build --apk --target aarch64 2>&1 | tee "$OUT/logs/android-${STAMP}.log"; then copy_glob "$OUT/android" \ "src-tauri/gen/android/app/build/outputs/apk/**/*.apk" \ @@ -167,7 +188,7 @@ fi # ── Linux (Docker, native arch) ──────────────────────────────────────────── if want linux; then - if command -v docker >/dev/null 2>&1 && docker image inspect widgets-linux >/dev/null 2>&1; then + if command -v docker >/dev/null 2>&1 && [[ -n "$(docker images -q widgets-linux 2>/dev/null)" ]]; then log "Linux: Docker cargo build --release (binary only; no .deb)" # Host must provide dist/ (image has no Node). if [[ ! -d "$EXAMPLE/dist" ]]; then @@ -207,7 +228,7 @@ EOS fi rm -f "$linux_script" else - log "Linux: SKIP (need docker image widgets-linux — run: bash tools/linux-up.sh once / docker build -t widgets-linux tests/linux)" + log "Linux: SKIP (need docker image widgets-linux — run: bash scripts/sh/linux-up.sh once / docker build -t widgets-linux tests/linux)" fi fi diff --git a/tools/gen-adaptive-snapshots.sh b/scripts/sh/gen-adaptive-snapshots.sh similarity index 95% rename from tools/gen-adaptive-snapshots.sh rename to scripts/sh/gen-adaptive-snapshots.sh index 82b31da..df9ab87 100755 --- a/tools/gen-adaptive-snapshots.sh +++ b/scripts/sh/gen-adaptive-snapshots.sh @@ -3,7 +3,7 @@ # Optional: CASE=weather.small # For chart/canvas/gauge data-URIs: FEATURES=rasterize set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" mkdir -p tests/snapshots/adaptive FEATURES="${FEATURES:-}" diff --git a/tools/ios-up.sh b/scripts/sh/ios-up.sh similarity index 100% rename from tools/ios-up.sh rename to scripts/sh/ios-up.sh diff --git a/tools/linux-up-wl.sh b/scripts/sh/linux-up-wl.sh similarity index 95% rename from tools/linux-up-wl.sh rename to scripts/sh/linux-up-wl.sh index 8039440..be4d770 100755 --- a/tools/linux-up-wl.sh +++ b/scripts/sh/linux-up-wl.sh @@ -2,7 +2,7 @@ # Persistent Wayland/sway container for layer-shell shots (optional second host). set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" NAME="${WSHOT_WL_NAME:-wshot-wl}" diff --git a/tools/linux-up.sh b/scripts/sh/linux-up.sh similarity index 97% rename from tools/linux-up.sh rename to scripts/sh/linux-up.sh index 23efad4..2511c52 100755 --- a/tools/linux-up.sh +++ b/scripts/sh/linux-up.sh @@ -2,7 +2,7 @@ # Build image + start persistent wshot container (Xvfb/openbox/probe via boot.sh). set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" NAME="${WSHOT_NAME:-wshot}" diff --git a/tools/shot-linux.sh b/scripts/sh/shot-linux.sh similarity index 97% rename from tools/shot-linux.sh rename to scripts/sh/shot-linux.sh index 0592a23..b6fef9c 100755 --- a/tools/shot-linux.sh +++ b/scripts/sh/shot-linux.sh @@ -2,7 +2,7 @@ # From Mac host: live widget PNG via Docker (one-shot webview, not SVG goldens). set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" FX="${1:?usage: shot-linux.sh [size]}" diff --git a/scripts/sh/win-bootstrap.sh b/scripts/sh/win-bootstrap.sh new file mode 100755 index 0000000..b4691f4 --- /dev/null +++ b/scripts/sh/win-bootstrap.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Bring up the UTM Windows VM and run bootstrap.ps1 remotely. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +HOST="${UTM_WIN_HOST:-utm-win}" +bash "$ROOT/scripts/sh/win-up.sh" +scp "$ROOT/scripts/win/bootstrap.ps1" "${HOST}:C:/work/bootstrap.ps1" +ssh "$HOST" 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\bootstrap.ps1' diff --git a/scripts/sh/win-pack.sh b/scripts/sh/win-pack.sh new file mode 100755 index 0000000..52d83cc --- /dev/null +++ b/scripts/sh/win-pack.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Sync repo to the UTM Windows VM and run pack.ps1. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +HOST="${UTM_WIN_HOST:-utm-win}" +bash "$ROOT/scripts/sh/win-up.sh" +bash "$ROOT/scripts/win/sync.sh" +scp "$ROOT/scripts/win/pack.ps1" "${HOST}:C:/work/pack.ps1" +ssh "$HOST" 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\pack.ps1' diff --git a/scripts/sh/win-sideload.sh b/scripts/sh/win-sideload.sh new file mode 100755 index 0000000..9c87976 --- /dev/null +++ b/scripts/sh/win-sideload.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Sideload the packed Windows widget package on the UTM VM. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +HOST="${UTM_WIN_HOST:-utm-win}" +scp "$ROOT/scripts/win/sideload.ps1" "${HOST}:C:/work/sideload.ps1" +ssh "$HOST" 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\work\sideload.ps1' diff --git a/tools/win-up.sh b/scripts/sh/win-up.sh similarity index 100% rename from tools/win-up.sh rename to scripts/sh/win-up.sh diff --git a/scripts/src/commands/android-up.ts b/scripts/src/commands/android-up.ts index dd43a8a..71a6ba8 100644 --- a/scripts/src/commands/android-up.ts +++ b/scripts/src/commands/android-up.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const androidUpCommand = defineCommand({ - meta: { name: "android-up", description: "Run tools/android-up.sh" }, + meta: { name: "android-up", description: "Run scripts/sh/android-up.sh" }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/android-up.sh", extra); + runToolScript("scripts/sh/android-up.sh", extra); }, }); diff --git a/scripts/src/commands/build-example.ts b/scripts/src/commands/build-example.ts index cc78cd3..5c2bab7 100644 --- a/scripts/src/commands/build-example.ts +++ b/scripts/src/commands/build-example.ts @@ -4,17 +4,25 @@ import { runToolScript } from "../utils/run-tool-script.js"; export const buildExampleCommand = defineCommand({ meta: { name: "build-example", - description: "Run tools/build-example-artifacts.sh [platforms…]", + description: "Run scripts/sh/build-example-artifacts.sh [platforms…]", }, args: { _: { type: "positional", - description: "Platforms (e.g. macos ios android)", + description: "Platforms (macos ios android linux windows)", required: false, }, }, - run({ args }) { - const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/build-example-artifacts.sh", extra); + run({ args, rawArgs }) { + // citty keeps all positionals on rawArgs; drop the subcommand name when present. + const start = rawArgs[0] === "build-example" ? 1 : 0; + const fromRaw = rawArgs.slice(start).map(String).filter(Boolean); + const fromArgs = Array.isArray(args._) + ? args._.map(String) + : args._ + ? [String(args._)] + : []; + const extra = fromRaw.length > 0 ? fromRaw : fromArgs; + runToolScript("scripts/sh/build-example-artifacts.sh", extra); }, }); diff --git a/scripts/src/commands/gen-adaptive-snapshots.ts b/scripts/src/commands/gen-adaptive-snapshots.ts index e5f4415..912d8dd 100644 --- a/scripts/src/commands/gen-adaptive-snapshots.ts +++ b/scripts/src/commands/gen-adaptive-snapshots.ts @@ -4,13 +4,13 @@ import { runToolScript } from "../utils/run-tool-script.js"; export const genAdaptiveSnapshotsCommand = defineCommand({ meta: { name: "gen-adaptive-snapshots", - description: "Run tools/gen-adaptive-snapshots.sh", + description: "Run scripts/sh/gen-adaptive-snapshots.sh", }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/gen-adaptive-snapshots.sh", extra); + runToolScript("scripts/sh/gen-adaptive-snapshots.sh", extra); }, }); diff --git a/scripts/src/commands/hosts.ts b/scripts/src/commands/hosts.ts new file mode 100644 index 0000000..81ab7d1 --- /dev/null +++ b/scripts/src/commands/hosts.ts @@ -0,0 +1,105 @@ +import { spawnSync } from "node:child_process"; +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; +import { repoRoot } from "../utils/workspace.js"; + +const PLATFORM_SCRIPTS: Record = { + ios: "scripts/sh/ios-up.sh", + android: "scripts/sh/android-up.sh", + linux: "scripts/sh/linux-up.sh", + "linux-wl": "scripts/sh/linux-up-wl.sh", + win: "scripts/sh/win-up.sh", +}; + +const ALL_PLATFORMS = Object.keys(PLATFORM_SCRIPTS); + +function shellOk(command: string): boolean { + const result = spawnSync("bash", ["-c", command], { + cwd: repoRoot(), + stdio: "ignore", + env: process.env, + }); + return (result.status ?? 1) === 0; +} + +function status(): void { + const rows: [string, boolean][] = [ + ["ios ", shellOk("xcrun simctl list devices booted 2>/dev/null | grep -q Booted")], + ["android ", shellOk("adb devices 2>/dev/null | grep -q emulator")], + ["linux ", shellOk("docker ps -q -f name=^/wshot$ | grep -q .")], + ["linux-wl ", shellOk("docker ps -q -f name=^/wshot-wl$ | grep -q .")], + ["windows ", shellOk("ssh -o ConnectTimeout=2 -o BatchMode=yes utm-win exit 2>/dev/null")], + ["desktop ", shellOk("pgrep -f widget-probe >/dev/null 2>&1")], + ]; + for (const [label, ok] of rows) { + console.log(`${label}${ok ? "ok" : "DOWN"}`); + } +} + +function up(platforms: string[]): void { + const selected = platforms.length > 0 ? platforms : ALL_PLATFORMS; + for (const platform of selected) { + const script = PLATFORM_SCRIPTS[platform]; + if (!script) { + console.error( + `hosts up: unknown platform "${platform}". Expected: ${ALL_PLATFORMS.join(", ")}`, + ); + process.exit(2); + } + console.log(`hosts up: ${platform}`); + runToolScript(script); + } +} + +function down(): void { + const result = spawnSync("docker", ["rm", "-f", "wshot", "wshot-wl"], { + cwd: repoRoot(), + stdio: "inherit", + env: process.env, + }); + if (result.error) throw result.error; + // Match Justfile `linux-down`: ignore missing containers. +} + +export const hostsCommand = defineCommand({ + meta: { + name: "hosts", + description: "Bring visual-stand hosts up / show status / tear linux containers down", + }, + args: { + verb: { + type: "positional", + description: "up | status | down (for up: optional platforms ios|android|linux|linux-wl|win)", + required: true, + }, + }, + run({ args, rawArgs }) { + const verb = String(args.verb); + // citty keeps all positionals on rawArgs; platforms follow the verb. + const platforms = rawArgs.slice(1).map(String).filter(Boolean); + + if (verb === "up") { + up(platforms); + return; + } + if (verb === "status") { + if (platforms.length > 0) { + console.error("hosts status: unexpected extra arguments"); + process.exit(2); + } + status(); + return; + } + if (verb === "down") { + if (platforms.length > 0) { + console.error("hosts down: unexpected extra arguments"); + process.exit(2); + } + down(); + return; + } + + console.error(`hosts: unknown verb "${verb}". Use: up | status | down`); + process.exit(2); + }, +}); diff --git a/scripts/src/commands/ios-up.ts b/scripts/src/commands/ios-up.ts index e1ab323..6bd6b80 100644 --- a/scripts/src/commands/ios-up.ts +++ b/scripts/src/commands/ios-up.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const iosUpCommand = defineCommand({ - meta: { name: "ios-up", description: "Run tools/ios-up.sh" }, + meta: { name: "ios-up", description: "Run scripts/sh/ios-up.sh" }, args: { _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/ios-up.sh", extra); + runToolScript("scripts/sh/ios-up.sh", extra); }, }); diff --git a/scripts/src/commands/linux-up-wl.ts b/scripts/src/commands/linux-up-wl.ts index a164fc5..5cdd238 100644 --- a/scripts/src/commands/linux-up-wl.ts +++ b/scripts/src/commands/linux-up-wl.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const linuxUpWlCommand = defineCommand({ - meta: { name: "linux-up-wl", description: "Run tools/linux-up-wl.sh" }, + meta: { name: "linux-up-wl", description: "Run scripts/sh/linux-up-wl.sh" }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/linux-up-wl.sh", extra); + runToolScript("scripts/sh/linux-up-wl.sh", extra); }, }); diff --git a/scripts/src/commands/linux-up.ts b/scripts/src/commands/linux-up.ts index 593967a..ad43afc 100644 --- a/scripts/src/commands/linux-up.ts +++ b/scripts/src/commands/linux-up.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const linuxUpCommand = defineCommand({ - meta: { name: "linux-up", description: "Run tools/linux-up.sh" }, + meta: { name: "linux-up", description: "Run scripts/sh/linux-up.sh" }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/linux-up.sh", extra); + runToolScript("scripts/sh/linux-up.sh", extra); }, }); diff --git a/scripts/src/commands/shot-linux.ts b/scripts/src/commands/shot-linux.ts index 2ebd1bb..8c87f55 100644 --- a/scripts/src/commands/shot-linux.ts +++ b/scripts/src/commands/shot-linux.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const shotLinuxCommand = defineCommand({ - meta: { name: "shot-linux", description: "Run tools/shot-linux.sh" }, + meta: { name: "shot-linux", description: "Run scripts/sh/shot-linux.sh" }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/shot-linux.sh", extra); + runToolScript("scripts/sh/shot-linux.sh", extra); }, }); diff --git a/scripts/src/commands/shot.ts b/scripts/src/commands/shot.ts new file mode 100644 index 0000000..382329d --- /dev/null +++ b/scripts/src/commands/shot.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import { join } from "node:path"; +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; +import { repoRoot } from "../utils/workspace.js"; + +function readCaseSize(caseName: string): string { + const path = join(repoRoot(), "tests/cases", `${caseName}.json`); + if (!fs.existsSync(path)) { + console.error(`shot: case not found: tests/cases/${caseName}.json`); + process.exit(2); + } + try { + const raw = JSON.parse(fs.readFileSync(path, "utf8")) as { size?: string }; + return raw.size || "small"; + } catch (e) { + console.error(`shot: failed to parse ${path}:`, e instanceof Error ? e.message : e); + process.exit(2); + } +} + +function printOtherPlatformHints(caseName: string, platform: string): void { + console.log(`shot: platform "${platform}" is not auto-run from this CLI.`); + console.log("Use one of:"); + switch (platform) { + case "desktop": + console.log(` CASE=${caseName} GOLDEN_RECORD=1 pnpm test:visual:desktop`); + console.log(` just record-desktop ${caseName}`); + break; + case "android": + console.log(` just record-android ${caseName}`); + console.log(` just test-android-visual`); + break; + case "ios": + console.log(` just record-ios ${caseName}`); + console.log(` just test-ios-visual`); + break; + case "macos": + console.log(` just record-macos ${caseName}`); + console.log(` just test-macos-visual`); + break; + case "windows": + case "win": + console.log(` just record-windows ${caseName}`); + console.log(` just test-windows-visual`); + break; + default: + console.log(` just shot-linux ${caseName} # linux live PNG`); + console.log(` just record-desktop ${caseName}`); + console.log(` just record-android ${caseName}`); + console.log(` just record-ios ${caseName}`); + console.log(` just record-macos ${caseName}`); + console.log(` just record-windows ${caseName}`); + break; + } +} + +export const shotCommand = defineCommand({ + meta: { + name: "shot", + description: "Capture a visual case (linux runs shot-linux; others print just/pnpm hints)", + }, + args: { + case: { + type: "positional", + description: "Case id under tests/cases/.json", + required: true, + }, + platform: { + type: "positional", + description: "linux (default) | desktop | android | ios | macos | windows", + required: false, + default: "linux", + }, + }, + run({ args }) { + const caseName = String(args.case); + const platform = String(args.platform || "linux"); + + if (platform === "linux") { + const size = readCaseSize(caseName); + runToolScript("scripts/sh/shot-linux.sh", [caseName, size]); + return; + } + + printOtherPlatformHints(caseName, platform); + }, +}); diff --git a/scripts/src/commands/test-runner.ts b/scripts/src/commands/test-runner.ts new file mode 100644 index 0000000..0de45fb --- /dev/null +++ b/scripts/src/commands/test-runner.ts @@ -0,0 +1,138 @@ +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { defineCommand } from "citty"; +import { repoRoot } from "../utils/workspace.js"; + +const PLATFORMS = ["desktop", "android", "macos", "geometry", "events"] as const; +type Platform = (typeof PLATFORMS)[number]; + +function run( + command: string, + args: string[], + opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}, +): void { + const result = spawnSync(command, args, { + cwd: opts.cwd ?? repoRoot(), + stdio: "inherit", + env: { ...process.env, ...opts.env }, + }); + if (result.error) throw result.error; + const code = result.status ?? 1; + if (code !== 0) process.exit(code); +} + +function caseEnv(caseName: string | undefined): NodeJS.ProcessEnv { + return caseName ? { CASE: caseName } : {}; +} + +function runDesktop(update: boolean, caseName: string | undefined): void { + const env: NodeJS.ProcessEnv = { + ...caseEnv(caseName), + ...(update ? { UPDATE_SNAPSHOTS: "1" } : {}), + }; + run("pnpm", ["exec", "playwright", "test", "-c", "tests/desktop/playwright.config.ts"], { + env, + }); +} + +function runEvents(update: boolean, caseName: string | undefined): void { + const env: NodeJS.ProcessEnv = { + ...caseEnv(caseName), + ...(update ? { UPDATE_SNAPSHOTS: "1" } : {}), + }; + run( + "pnpm", + [ + "exec", + "playwright", + "test", + "-c", + "tests/desktop/playwright.config.ts", + "tests/desktop/events.spec.ts", + ], + { env }, + ); +} + +function runAndroid(update: boolean, caseName: string | undefined): void { + const gradleArgs = [":testDebugUnitTest"]; + if (update) gradleArgs.push("-PupdateSnapshots=true"); + run("./gradlew", gradleArgs, { + cwd: join(repoRoot(), "android"), + env: caseEnv(caseName), + }); +} + +function runMacos(update: boolean, caseName: string | undefined): void { + const env: NodeJS.ProcessEnv = { + ...caseEnv(caseName), + ...(update ? { GOLDEN_RECORD: "1" } : {}), + }; + run("swift", ["test", "--filter", "MacRenderTests"], { + cwd: join(repoRoot(), "swift"), + env, + }); +} + +function runGeometry(_update: boolean, caseName: string | undefined): void { + // Geometry update path is `pnpm test:desktop:update` (see package.json). + if (_update) { + runDesktop(true, caseName); + return; + } + run("node", ["tests/desktop/geometry-diff.mjs"], { env: caseEnv(caseName) }); +} + +export const testCommand = defineCommand({ + meta: { + name: "test", + description: + "Run maintainer test suites (desktop / android / macos / geometry / events)", + }, + args: { + platform: { + type: "string", + description: "desktop | android | macos | geometry | events", + default: "desktop", + }, + case: { + type: "string", + description: "Optional CASE= filter / golden case id", + }, + update: { + type: "boolean", + default: false, + description: "Update snapshots / record goldens where supported", + }, + }, + run({ args }) { + const platform = String(args.platform || "desktop") as Platform; + if (!PLATFORMS.includes(platform)) { + console.error( + `test: unknown --platform "${platform}". Expected: ${PLATFORMS.join(", ")}`, + ); + process.exit(2); + } + + const caseName = args.case ? String(args.case) : undefined; + const update = Boolean(args.update); + + switch (platform) { + case "desktop": + runDesktop(update, caseName); + break; + case "android": + runAndroid(update, caseName); + break; + case "macos": + runMacos(update, caseName); + break; + case "geometry": + runGeometry(update, caseName); + break; + case "events": + runEvents(update, caseName); + break; + } + }, +}); diff --git a/scripts/src/commands/win-bootstrap.ts b/scripts/src/commands/win-bootstrap.ts new file mode 100644 index 0000000..c9cfc13 --- /dev/null +++ b/scripts/src/commands/win-bootstrap.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; + +export const winBootstrapCommand = defineCommand({ + meta: { name: "win-bootstrap", description: "Run scripts/sh/win-bootstrap.sh" }, + args: { + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, + }, + run({ args }) { + const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; + runToolScript("scripts/sh/win-bootstrap.sh", extra); + }, +}); diff --git a/scripts/src/commands/win-pack.ts b/scripts/src/commands/win-pack.ts new file mode 100644 index 0000000..bb60401 --- /dev/null +++ b/scripts/src/commands/win-pack.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; + +export const winPackCommand = defineCommand({ + meta: { name: "win-pack", description: "Run scripts/sh/win-pack.sh" }, + args: { + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, + }, + run({ args }) { + const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; + runToolScript("scripts/sh/win-pack.sh", extra); + }, +}); diff --git a/scripts/src/commands/win-sideload.ts b/scripts/src/commands/win-sideload.ts new file mode 100644 index 0000000..4002b40 --- /dev/null +++ b/scripts/src/commands/win-sideload.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; + +export const winSideloadCommand = defineCommand({ + meta: { name: "win-sideload", description: "Run scripts/sh/win-sideload.sh" }, + args: { + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, + }, + run({ args }) { + const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; + runToolScript("scripts/sh/win-sideload.sh", extra); + }, +}); diff --git a/scripts/src/commands/win-sync.ts b/scripts/src/commands/win-sync.ts new file mode 100644 index 0000000..b14b94b --- /dev/null +++ b/scripts/src/commands/win-sync.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "citty"; +import { runToolScript } from "../utils/run-tool-script.js"; + +export const winSyncCommand = defineCommand({ + meta: { name: "win-sync", description: "Run scripts/win/sync.sh" }, + args: { + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, + }, + run({ args }) { + const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; + runToolScript("scripts/win/sync.sh", extra); + }, +}); diff --git a/scripts/src/commands/win-up.ts b/scripts/src/commands/win-up.ts index 58c4b00..fe78acc 100644 --- a/scripts/src/commands/win-up.ts +++ b/scripts/src/commands/win-up.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty"; import { runToolScript } from "../utils/run-tool-script.js"; export const winUpCommand = defineCommand({ - meta: { name: "win-up", description: "Run tools/win-up.sh" }, + meta: { name: "win-up", description: "Run scripts/sh/win-up.sh" }, args: { - _: { type: "positional", description: "Extra args", required: false }, + _: { type: "positional", description: "Extra args forwarded to the script", required: false }, }, run({ args }) { const extra = Array.isArray(args._) ? args._.map(String) : args._ ? [String(args._)] : []; - runToolScript("tools/win-up.sh", extra); + runToolScript("scripts/sh/win-up.sh", extra); }, }); diff --git a/scripts/src/main.ts b/scripts/src/main.ts index 2c3e453..982a1bb 100644 --- a/scripts/src/main.ts +++ b/scripts/src/main.ts @@ -8,9 +8,16 @@ import { androidUpCommand } from "./commands/android-up.js"; import { linuxUpCommand } from "./commands/linux-up.js"; import { linuxUpWlCommand } from "./commands/linux-up-wl.js"; import { winUpCommand } from "./commands/win-up.js"; +import { winSyncCommand } from "./commands/win-sync.js"; +import { winBootstrapCommand } from "./commands/win-bootstrap.js"; +import { winPackCommand } from "./commands/win-pack.js"; +import { winSideloadCommand } from "./commands/win-sideload.js"; import { buildExampleCommand } from "./commands/build-example.js"; import { shotLinuxCommand } from "./commands/shot-linux.js"; import { genAdaptiveSnapshotsCommand } from "./commands/gen-adaptive-snapshots.js"; +import { hostsCommand } from "./commands/hosts.js"; +import { shotCommand } from "./commands/shot.js"; +import { testCommand } from "./commands/test-runner.js"; export const main = defineCommand({ meta: { @@ -21,7 +28,9 @@ export const main = defineCommand({ "From the repository root:", " pnpm -C scripts cli [flags]", "", - "Or use root aliases: pnpm docs:generate, pnpm triage, …", + "Or use root aliases: pnpm docs:generate, pnpm triage, pnpm hosts, …", + "", + "Shell/PowerShell stands live under scripts/sh/ and scripts/win/.", ].join("\n"), }, subCommands: { @@ -29,11 +38,18 @@ export const main = defineCommand({ "docs-audit": docsAuditCommand, triage: triageCommand, "audit-sheets": auditSheetsCommand, + hosts: hostsCommand, + shot: shotCommand, + test: testCommand, "ios-up": iosUpCommand, "android-up": androidUpCommand, "linux-up": linuxUpCommand, "linux-up-wl": linuxUpWlCommand, "win-up": winUpCommand, + "win-sync": winSyncCommand, + "win-bootstrap": winBootstrapCommand, + "win-pack": winPackCommand, + "win-sideload": winSideloadCommand, "build-example": buildExampleCommand, "shot-linux": shotLinuxCommand, "gen-adaptive-snapshots": genAdaptiveSnapshotsCommand, diff --git a/scripts/src/utils/docs/generate.ts b/scripts/src/utils/docs/generate.ts index 82345e5..2b7732e 100644 --- a/scripts/src/utils/docs/generate.ts +++ b/scripts/src/utils/docs/generate.ts @@ -1,859 +1,29 @@ -// @ts-nocheck — large schema walk; typed gradually -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, relative } from "node:path"; -import { - BLOCK_START, - cell, - code, - replaceBlock, - table, -} from "../markdown.js"; -import { repoRoot } from "../workspace.js"; - -const ROOT = repoRoot(); -export { ROOT }; -export const DOCS = join(ROOT, "docs"); -export const PUBLIC = join(DOCS, "public"); - -export const REQUIRED_PLATFORMS = ["desktop", "ios", "macos", "android", "linux"]; -export const ALL_PLATFORMS = [...REQUIRED_PLATFORMS, "windows"]; - -export const CORE_ELEMENTS = [ - "vstack", - "hstack", - "zstack", - "container", - "grid", - "text", - "image", - "spacer", - "divider", - "progress", - "button", - "link", - "shape", -]; - -export const EXTENDED_ELEMENTS = [ - "gauge", - "toggle", - "date", - "chart", - "list", - "timer", - "canvas", - "label", -]; - -/** @type {Record} */ -export const ELEMENT_GROUPS = { - layout: { - file: "docs/elements/layout.md", - marker: "elements-layout", - types: ["vstack", "hstack", "zstack", "grid", "container"], - }, - text: { - file: "docs/elements/text.md", - marker: "elements-text", - types: ["text", "label", "date", "timer"], - }, - media: { - file: "docs/elements/media.md", - marker: "elements-media", - types: ["image", "shape", "canvas"], - }, - data: { - file: "docs/elements/data.md", - marker: "elements-data", - types: ["progress", "gauge", "chart", "list"], - }, - interactive: { - file: "docs/elements/interactive.md", - marker: "elements-interactive", - types: ["button", "toggle", "link"], - }, - spacing: { - file: "docs/elements/spacing.md", - marker: "elements-spacing", - types: ["spacer", "divider"], - }, -}; - -/** @typedef {{ written: string[], stale: string[] }} Report */ - -export function writeOrCheck(report, absPath, content, check, opts = {}) { - const tracked = opts.tracked === true; - const rel = relative(ROOT, absPath); - mkdirSync(dirname(absPath), { recursive: true }); - if (check && tracked) { - if (!existsSync(absPath) || readFileSync(absPath, "utf8") !== content) { - report.stale.push(rel); - } - return; - } - writeFileSync(absPath, content, "utf8"); - report.written.push(rel); -} - -export function writeBlock(report, relPath, id, body, check) { - const abs = join(ROOT, relPath); - if (!existsSync(abs)) { - throw new Error(`missing shell page ${relPath} (need ${BLOCK_START(id)})`); - } - const next = replaceBlock(readFileSync(abs, "utf8"), id, body); - writeOrCheck(report, abs, next, check, { tracked: true }); -} - -export function listCases() { - const dir = join(ROOT, "tests/cases"); - return readdirSync(dir) - .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(/\.json$/, "")) - .sort(); -} - -export function loadCase(name) { - return JSON.parse(readFileSync(join(ROOT, "tests/cases", `${name}.json`), "utf8")); -} - -export function resolveFixture(fixture) { - const base = join(ROOT, "tests/fixtures"); - const direct = join(base, `${fixture}.json`); - if (existsSync(direct)) return direct; - throw new Error(`fixture not found: ${fixture}`); -} - -export function loadFixture(fixture) { - return JSON.parse(readFileSync(resolveFixture(fixture), "utf8")); -} - -export function walkTypes(node, out = new Set()) { - if (!node || typeof node !== "object") return out; - if (Array.isArray(node)) { - for (const item of node) walkTypes(item, out); - return out; - } - if (typeof node.type === "string") out.add(node.type); - for (const [k, v] of Object.entries(node)) { - if (k === "type") continue; - if (v && typeof v === "object") walkTypes(v, out); - } - return out; -} - -export function collectElementUsage() { - /** @type {Map} */ - const usage = new Map(); - for (const name of listCases()) { - const c = loadCase(name); - const cfg = loadFixture(c.fixture); - for (const t of walkTypes(cfg)) { - const list = usage.get(t) ?? []; - list.push(name); - usage.set(t, list); - } - } - return usage; -} - -export function loadSchema() { - return JSON.parse(readFileSync(join(ROOT, "schemas/widget-config.v1.json"), "utf8")); -} - -/** - * @returns {{ name: string, description: string, properties: Record }[]} - */ -export function extractElementsFromSchema(schema) { - const rootDesc = schema?.definitions?.WidgetElement?.description || ""; - const oneOf = schema?.definitions?.WidgetElement?.oneOf; - if (!Array.isArray(oneOf)) throw new Error("schema missing WidgetElement.oneOf"); - const elements = []; - for (const variant of oneOf) { - const name = variant?.properties?.type?.enum?.[0]; - if (!name) continue; - const description = variant.description || ""; - elements.push({ - name, - description, - rootDesc, - properties: variant.properties || {}, - }); - } - return elements.sort((a, b) => a.name.localeCompare(b.name)); -} - -function schemaTypeLabel(prop) { - if (!prop) return "unknown"; - if (prop.$ref) return prop.$ref.replace("#/definitions/", ""); - if (prop.anyOf) { - const refs = prop.anyOf - .filter((x) => x && x.type !== "null") - .map((x) => schemaTypeLabel(x)); - return refs.filter(Boolean).join(" | ") || "any"; - } - if (Array.isArray(prop.type)) { - return prop.type.filter((t) => t !== "null").join(" | ") || "any"; - } - if (prop.type === "array") { - const items = prop.items ? schemaTypeLabel(prop.items) : "any"; - return `${items}[]`; - } - if (prop.enum) return prop.enum.map((e) => JSON.stringify(e)).join(" | "); - return prop.type || "any"; -} - -function defaultLabel(prop) { - if (!prop || prop.default === undefined) return "—"; - return JSON.stringify(prop.default); -} - -function tierOf(name) { - if (CORE_ELEMENTS.includes(name)) return "core"; - if (EXTENDED_ELEMENTS.includes(name)) return "extended"; - return "other"; -} - -function groupHref(name) { - for (const g of Object.values(ELEMENT_GROUPS)) { - if (g.types.includes(name)) return `/elements/${g.file.split("/").pop().replace(".md", "")}#el-${name}`; - } - return "/elements/"; -} - -function minimalExample(name) { - const examples = { - vstack: { - type: "vstack", - spacing: 8, - padding: 14, - cornerRadius: 16, - background: { light: "#E8F4FD", dark: "#1a1a2e" }, - children: [ - { type: "text", content: "72°", textStyle: "largeTitle", fontWeight: "bold", color: "label" }, - { type: "text", content: "Sunny", fontSize: 14, color: "secondaryLabel" }, - ], - }, - hstack: { - type: "hstack", - spacing: 8, - alignment: "center", - children: [ - { type: "image", systemName: "cloud.sun.fill", size: 28, color: "#ffcc00" }, - { type: "text", content: "72°", fontSize: 28, fontWeight: "bold", color: "label" }, - ], - }, - zstack: { - type: "zstack", - alignment: "center", - children: [ - { type: "shape", shapeType: "circle", fill: "#1e293b", size: 64 }, - { type: "text", content: "OK", fontWeight: "bold", color: "#fff" }, - ], - }, - grid: { - type: "grid", - columns: 2, - spacing: 6, - children: [ - { type: "button", label: "7", action: "digit", payload: "7" }, - { type: "button", label: "8", action: "digit", payload: "8" }, - { type: "button", label: "9", action: "digit", payload: "9" }, - { type: "button", label: "÷", action: "op", payload: "/" }, - ], - }, - container: { - type: "container", - contentAlignment: "center", - padding: 12, - cornerRadius: 14, - background: "#0f172a", - children: [{ type: "text", content: "Badge", color: "#38bdf8", fontWeight: "semibold" }], - }, - text: { - type: "text", - content: "Hello", - fontSize: 16, - fontWeight: "semibold", - color: { light: "#0f172a", dark: "#f8fafc" }, - alignment: "leading", - lineLimit: 2, - }, - label: { - type: "label", - text: "Inbox", - systemName: "envelope.fill", - iconColor: "#38bdf8", - fontSize: 15, - fontWeight: "medium", - spacing: 6, - }, - date: { - type: "date", - date: "2026-03-01T10:00:00Z", - dateStyle: "relative", - fontSize: 14, - color: "secondaryLabel", - }, - timer: { - type: "timer", - targetDate: "2026-12-31T23:59:59Z", - counting: "down", - fontSize: 22, - fontWeight: "bold", - color: "#22c55e", - }, - image: { - type: "image", - systemName: "cloud.sun.fill", - size: 28, - color: "#ffcc00", - contentMode: "fit", - }, - shape: { - type: "shape", - shapeType: "capsule", - fill: "#ef4444", - size: 12, - stroke: "#fff", - strokeWidth: 1, - }, - canvas: { - type: "canvas", - width: 80, - height: 80, - background: "#0f172a", - cornerRadius: 12, - elements: [ - { draw: "circle", cx: 40, cy: 40, r: 28, fill: "#1e293b", stroke: "#38bdf8", strokeWidth: 2 }, - { draw: "text", x: 40, y: 44, content: "72", fontSize: 18, color: "#fff", anchor: "middle" }, - ], - }, - progress: { - type: "progress", - value: 0.7, - total: 1, - tint: "#4CAF50", - color: "secondaryLabel", - label: "Humidity", - barStyle: "linear", - }, - gauge: { - type: "gauge", - value: 0.72, - min: 0, - max: 1, - label: "CPU", - currentValueLabel: "72%", - tint: "#38bdf8", - gaugeStyle: "circular", - }, - chart: { - type: "chart", - chartType: "bar", - tint: "#38bdf8", - chartData: [ - { label: "Mon", value: 120 }, - { label: "Tue", value: 90 }, - { label: "Wed", value: 150 }, - ], - }, - list: { - type: "list", - spacing: 4, - fontSize: 14, - items: [ - { text: "Alpha", checked: true, action: "row", payload: "a" }, - { text: "Beta", checked: false, action: "row", payload: "b" }, - { text: "Gamma" }, - ], - }, - button: { - type: "button", - label: "Refresh", - action: "refresh", - color: "#fff", - backgroundColor: "#0284c7", - fontSize: 14, - cornerRadius: 10, - }, - toggle: { - type: "toggle", - isOn: true, - label: "Dark mode", - tint: "#38bdf8", - action: "toggle-dark", - }, - link: { - type: "link", - action: "open-details", - payload: "item-1", - children: [ - { - type: "hstack", - spacing: 6, - alignment: "center", - children: [ - { type: "text", content: "Details", color: "#38bdf8", fontWeight: "medium" }, - { type: "image", systemName: "chevron.right", size: 12, color: "#38bdf8" }, - ], - }, - ], - }, - spacer: { type: "spacer", minLength: 8 }, - divider: { type: "divider", thickness: 1, color: "separator" }, - }; - return examples[name] ?? { type: name }; -} - -function stripNullDeep(value) { - if (Array.isArray(value)) return value.map(stripNullDeep); - if (!value || typeof value !== "object") return value; - /** @type {Record} */ - const out = {}; - for (const [k, v] of Object.entries(value)) { - if (v === null || v === undefined) continue; - out[k] = stripNullDeep(v); - } - return out; -} - -function findNodesOfType(node, type, out = []) { - if (!node || typeof node !== "object") return out; - if (Array.isArray(node)) { - for (const item of node) findNodesOfType(item, type, out); - return out; - } - if (node.type === type) out.push(node); - for (const [k, v] of Object.entries(node)) { - if (k === "type") continue; - if (v && typeof v === "object") findNodesOfType(v, type, out); - } - return out; -} - -function compactExampleNode(node, type) { - let n = stripNullDeep(structuredClone(node)); - if (type === "list" && Array.isArray(n.items) && n.items.length > 4) { - n.items = n.items.slice(0, 4); - } - if (type === "canvas" && Array.isArray(n.elements) && n.elements.length > 6) { - n.elements = n.elements.slice(0, 6); - } - if (type === "chart" && Array.isArray(n.chartData) && n.chartData.length > 6) { - n.chartData = n.chartData.slice(0, 6); - } - - const trimTree = (el, depth) => { - if (!el || typeof el !== "object" || Array.isArray(el)) return el; - if (!Array.isArray(el.children)) return el; - const max = depth === 0 ? 3 : 2; - const total = el.children.length; - let kids = el.children.slice(0, max).map((c) => trimTree(c, depth + 1)); - if (total > max) { - kids.push({ - type: "text", - content: `… +${total - max} more`, - fontSize: 11, - color: "secondaryLabel", - }); - } - return { ...el, children: kids }; - }; - - if (["vstack", "hstack", "zstack", "grid", "container", "link"].includes(type)) { - n = trimTree(n, 0); - // Hard cap so nested dashboards don't blow up the page. - if (JSON.stringify(n).length > 1600 && Array.isArray(n.children)) { - n = { - ...n, - children: [ - ...n.children.slice(0, 2), - { - type: "text", - content: "… truncated for docs", - fontSize: 11, - color: "secondaryLabel", - }, - ], - }; - } - } - return n; -} - -function signatureOf(node) { - try { - return JSON.stringify(node); - } catch { - return String(node); - } -} - -/** - * Collect real JSON snippets from visual fixtures for an element type. - * @returns {{ title: string, json: unknown }[]} - */ -function fixtureExamples(elementName, cases) { - /** @type {{ title: string, json: unknown, sig: string }[]} */ - const picked = []; - const seen = new Set(); - - const push = (title, node) => { - if (!node) return; - const compact = compactExampleNode(node, elementName); - const sig = signatureOf(compact); - if (seen.has(sig)) return; - seen.add(sig); - picked.push({ title, json: compact, sig }); - }; - - for (const caseName of cases.slice(0, 12)) { - let c; - let cfg; - try { - c = loadCase(caseName); - cfg = loadFixture(c.fixture); - } catch { - continue; - } - const size = c.size || "small"; - const root = cfg[size] || cfg.small || cfg.medium || cfg.large; - if (!root) continue; - - if (root.type === elementName) { - push(`From showcase \`${caseName}\` (size root)`, root); - } else { - const nodes = findNodesOfType(root, elementName); - if (nodes[0]) push(`From showcase \`${caseName}\``, nodes[0]); - } - if (picked.length >= 2) break; - } - - return picked.map(({ title, json }) => ({ title, json })); -} - -function formatExamplesMarkdown(elementName, usageCases) { - /** @type {{ title: string, json: unknown }[]} */ - const blocks = [{ title: "Minimal", json: minimalExample(elementName) }]; - - for (const fx of fixtureExamples(elementName, usageCases)) { - // Skip fixture clone of minimal - if (signatureOf(fx.json) === signatureOf(blocks[0].json)) continue; - blocks.push(fx); - } - - // Always show how it sits in a WidgetConfig size family. - blocks.push({ - title: "Inside a `WidgetConfig`", - json: { small: minimalExample(elementName) }, - }); - - return blocks - .map( - (b) => `#### ${b.title} - -\`\`\`json -${JSON.stringify(b.json, null, 2)} -\`\`\``, - ) - .join("\n\n"); -} - -function firstShotCase(cases) { - return cases?.[0] ?? null; -} - -export function copyShots(report, check) { - const dest = join(PUBLIC, "shots"); - if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }); - mkdirSync(dest, { recursive: true }); - for (const platform of ALL_PLATFORMS) { - const src = join(ROOT, "tests/golden", platform); - if (!existsSync(src)) continue; - for (const file of readdirSync(src)) { - if (!file.endsWith(".png")) continue; - const to = join(dest, platform, file); - mkdirSync(dirname(to), { recursive: true }); - copyFileSync(join(src, file), to); - report.written.push(relative(ROOT, to)); - } - } - void check; -} - -export function copySandbox(report, check) { - const widget = readFileSync(join(ROOT, "widget.html"), "utf8"); - const shim = ``; - - let html = widget.replace( - "`; + + // Match both pre-rollup (`(function(){`) and rollup IIFE (`(function () {`). + const scriptOpen = /`; +} + +export interface StartPreviewServerOptions { + configPath: string; + size?: string; + port?: number; + watchFile?: boolean; + open?: boolean; +} + +export interface PreviewServerHandle { + url: string; + close: () => void; +} + +/** + * Serve widget.html with Tauri shim + live reload for a widget config JSON file. + */ +export function startPreviewServer({ + configPath, + size = "medium", + port = 4177, + watchFile = true, + open = false, +}: StartPreviewServerOptions): Promise { + const abs = resolve(configPath); + if (!existsSync(abs)) { + throw new Error(`config not found: ${abs}`); + } + if (!existsSync(WIDGET_HTML)) { + throw new Error(`widget.html missing at ${WIDGET_HTML}`); + } + + let htmlBase = readFileSync(WIDGET_HTML, "utf-8"); + const clients = new Set(); + + const server: Server = createServer((req, res) => { + const url = new URL(req.url || "/", `http://127.0.0.1`); + if (url.pathname === "/api/config") { + try { + const body = JSON.stringify(loadConfig(abs)); + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(body); + } catch (e) { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(String((e as Error)?.message || e)); + } + return; + } + if (url.pathname === "/api/events") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.write(":\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + if (url.pathname === "/" || url.pathname === "/index.html") { + const injected = htmlBase.replace("", `${shimScript(size)}`); + // Ensure size/group/widgetId query defaults in links — page uses URLSearchParams + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }); + res.end(injected); + return; + } + res.writeHead(404); + res.end("not found"); + }); + + const notify = () => { + for (const res of clients) { + try { + res.write("data: reload\n\n"); + } catch { + clients.delete(res); + } + } + }; + + let watcher: FSWatcher | null = null; + if (watchFile) { + try { + watcher = watch(abs, { persistent: true }, () => { + // Reload widget.html from disk too (in case plugin updates) + try { + htmlBase = readFileSync(WIDGET_HTML, "utf-8"); + } catch { + /* keep */ + } + notify(); + }); + } catch (e) { + console.error("watch failed:", (e as Error).message); + } + } + + return new Promise((resolvePromise, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + const url = `http://127.0.0.1:${port}/?size=${encodeURIComponent(size)}&group=preview&widgetId=preview`; + console.log(`preview ${url}`); + console.log(`config ${abs}${watchFile ? " (watching)" : ""}`); + if (open) { + const opener = + process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + import("node:child_process").then(({ spawn }) => { + spawn(opener, [url], { stdio: "ignore", detached: true }).unref(); + }); + } + resolvePromise({ + url, + close: () => { + if (watcher) watcher.close(); + for (const res of clients) { + try { + res.end(); + } catch { + /* ignore */ + } + } + server.close(); + }, + }); + }); + }); +} + +export { WIDGET_HTML, loadConfig }; diff --git a/src-cli/lib/receipt.ts b/src-cli/lib/receipt.ts new file mode 100644 index 0000000..9bda6ae --- /dev/null +++ b/src-cli/lib/receipt.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Report } from "./report.js"; + +/** Expected receipt schema across Rust / Swift / Kotlin (keep in sync). */ +const EXPECTED_RECEIPT_SCHEMA = 1; + +interface SchemaCheck { + label: string; + path: string; + re: RegExp; +} + +/** Cross-check the render-receipt schema constant across Rust / Swift / Kotlin sources. */ +export function checkReceiptSchemaSkew(pluginRoot: string, report: Report): void { + const checks: SchemaCheck[] = [ + { + label: "src/receipt.rs", + path: join(pluginRoot, "src", "receipt.rs"), + re: /fn default_schema\(\)\s*->\s*u32\s*\{\s*(\d+)/, + }, + { + label: "WidgetDataStore.swift", + path: join(pluginRoot, "swift", "Sources", "TauriWidgets", "WidgetDataStore.swift"), + re: /schema:\s*UInt32\s*=\s*(\d+)/, + }, + { + label: "TauriGlanceWidget.kt", + path: join(pluginRoot, "android", "src", "main", "java", "TauriGlanceWidget.kt"), + re: /\.put\("schema",\s*(\d+)\)/, + }, + ]; + for (const c of checks) { + if (!existsSync(c.path)) { + report.warn(`${c.label}: file missing (skip schema check)`); + continue; + } + const raw = readFileSync(c.path, "utf-8"); + const m = raw.match(c.re); + if (!m) { + report.warn(`${c.label}: could not parse schema default`); + continue; + } + const n = Number(m[1]); + if (n === EXPECTED_RECEIPT_SCHEMA) report.ok(`${c.label} schema=${n}`); + else report.bad(`${c.label} schema=${n} (expected ${EXPECTED_RECEIPT_SCHEMA})`); + } +} diff --git a/src-cli/lib/report.ts b/src-cli/lib/report.ts new file mode 100644 index 0000000..3a640bc --- /dev/null +++ b/src-cli/lib/report.ts @@ -0,0 +1,66 @@ +export type ReportLevel = "ok" | "warn" | "bad"; + +export interface ReportItem { + section: string; + level: ReportLevel; + msg: string; +} + +export interface Report { + items: ReportItem[]; + failures: number; + warnings: number; + /** Start a new named section — subsequent ok/warn/bad calls belong to it. */ + section(name: string): void; + ok(msg: string): void; + warn(msg: string): void; + bad(msg: string): void; + /** Print the report to the console in the classic `doctor` layout. */ + print(): void; +} + +/** + * Collects sectioned check results (✓ / ⚠ / ✗) without printing, so callers + * (tests, `doctor` command) can inspect `failures` / `warnings` / `items` + * before deciding whether/how to print. + */ +export function createReport(): Report { + let current = ""; + const items: ReportItem[] = []; + + const report: Report = { + items, + failures: 0, + warnings: 0, + section(name: string) { + current = name; + }, + ok(msg: string) { + items.push({ section: current, level: "ok", msg }); + }, + warn(msg: string) { + report.warnings++; + items.push({ section: current, level: "warn", msg }); + }, + bad(msg: string) { + report.failures++; + items.push({ section: current, level: "bad", msg }); + }, + print() { + let lastSection: string | null = null; + for (const item of items) { + if (item.section !== lastSection) { + // Section name is printed verbatim — callers embed their own leading + // "\n" (or not) to match the original doctor console output exactly. + console.log(item.section); + lastSection = item.section; + } + const mark = item.level === "ok" ? "✓" : item.level === "warn" ? "⚠" : "✗"; + console.log(` ${mark} ${item.msg}`); + } + console.log(`\n${report.failures ? "FAIL" : "OK"} — ${report.failures} error(s), ${report.warnings} warning(s)`); + }, + }; + + return report; +} diff --git a/src-cli/lib/signing.ts b/src-cli/lib/signing.ts new file mode 100644 index 0000000..7f0baf4 --- /dev/null +++ b/src-cli/lib/signing.ts @@ -0,0 +1,102 @@ +import { spawnSync } from "node:child_process"; + +export type IdentityKind = "distribution" | "development" | "other"; + +export interface Identity { + hash: string; + name: string; + team: string | null; + kind: IdentityKind; +} + +/** Parse `security find-identity -v -p codesigning` (macOS only). */ +export function codesignIdentities(): Identity[] { + if (process.platform !== "darwin") return []; + const r = spawnSync("security", ["find-identity", "-v", "-p", "codesigning"], { + encoding: "utf-8", + }); + const out = `${r.stdout || ""}\n${r.stderr || ""}`; + const re = /\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"/gi; + const found: Identity[] = []; + for (let m: RegExpExecArray | null; (m = re.exec(out)); ) { + const name = m[2]; + const team = (name.match(/\(([A-Z0-9]{10})\)\s*$/) || [])[1] || null; + const kind: IdentityKind = /Developer ID/i.test(name) + ? "distribution" + : /Apple Development|iPhone Developer|Mac Developer/i.test(name) + ? "development" + : "other"; + found.push({ hash: m[1].toUpperCase(), name, team, kind }); + } + return found; +} + +export function identitiesHaveTeam(identities: Identity[]): boolean { + return identities.some((i) => Boolean(i.team)); +} + +/** Recommend host transport from available codesign identities (macOS). */ +export function recommendTransport( + identities: Identity[], + { forIos = false }: { forIos?: boolean } = {}, +): string { + if (forIos) return "appGroup"; + return identitiesHaveTeam(identities) ? "appGroup" : "widgetContainer"; +} + +export function pickPreferredIdentity(identities: Identity[]): Identity | null { + if (!identities.length) return null; + return ( + identities.find((i) => i.kind === "distribution") || + identities.find((i) => i.kind === "development") || + identities[0] + ); +} + +export function codesignIsAdHoc(appPath: string): boolean | null { + const r = spawnSync("codesign", ["-dv", "--verbose=2", appPath], { + encoding: "utf-8", + }); + const text = `${r.stdout || ""}\n${r.stderr || ""}`; + if (/Signature=adhoc|Authority=\(ad hoc\)|flags=0x2\(adhoc\)/i.test(text)) return true; + if (/Authority=|TeamIdentifier=/i.test(text)) return false; + return null; +} + +export function windowsDevCertScript( + publisher: string, + { pfxPath = "dev.pfx", password = "dev" }: { pfxPath?: string; password?: string } = {}, +): string { + return `#Requires -Version 5.1 +# Self-signed cert for MSIX sideload / Widgets Board. +# Subject MUST equal Package.appxmanifest Identity/@Publisher exactly. +# Run elevated PowerShell on Windows. +param( + [string]$Publisher = "${publisher.replace(/"/g, '`"')}", + [string]$PfxPath = "${pfxPath}", + [string]$Password = "${password}", + [string]$MsixPath = "" +) + +$ErrorActionPreference = "Stop" +$cert = New-SelfSignedCertificate -Type Custom -Subject $Publisher \` + -KeyUsage DigitalSignature -CertStoreLocation "Cert:\\CurrentUser\\My" \` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") +$pwd = ConvertTo-SecureString $Password -Force -AsPlainText +Export-PfxCertificate -Cert "Cert:\\CurrentUser\\My\\$($cert.Thumbprint)" -FilePath $PfxPath -Password $pwd | Out-Null +Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\\LocalMachine\\TrustedPeople -Password $pwd | Out-Null +Write-Host "Thumbprint: $($cert.Thumbprint)" +Write-Host "PFX: $PfxPath (password=$Password)" +Write-Host "Imported into LocalMachine\\TrustedPeople" +if ($MsixPath) { + $signtool = Get-Command signtool -ErrorAction SilentlyContinue + if (-not $signtool) { + Write-Error "signtool not on PATH. Install Windows SDK / use Developer Command Prompt." + } + & signtool sign /fd SHA256 /f $PfxPath /p $Password $MsixPath + Write-Host "Signed: $MsixPath" +} else { + Write-Host "Sign later: signtool sign /fd SHA256 /f $PfxPath /p $Password path\\to\\app.msix" +} +`; +} diff --git a/src-cli/lib/tauri-conf.ts b/src-cli/lib/tauri-conf.ts new file mode 100644 index 0000000..46ca403 --- /dev/null +++ b/src-cli/lib/tauri-conf.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface PluginWidgetsConfig { + appGroup?: string; + transport?: string; + extensionBundleId?: string; + [key: string]: unknown; +} + +export interface TauriConfData { + identifier?: string; + productName?: string; + build?: { + beforeBundleCommand?: string; + [key: string]: unknown; + }; + bundle?: { + active?: string[]; + targets?: string[] | "all"; + macOS?: { + entitlements?: string; + files?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + plugins?: { + widgets?: PluginWidgetsConfig; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export interface TauriConf { + path: string; + data: TauriConfData; +} + +export function findTauriConf(cwd: string): string | null { + const candidates = [join(cwd, "src-tauri", "tauri.conf.json"), join(cwd, "tauri.conf.json")]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +export function readTauriConf(cwd: string): TauriConf | null { + const confPath = findTauriConf(cwd); + if (!confPath) return null; + try { + return { path: confPath, data: JSON.parse(readFileSync(confPath, "utf-8")) as TauriConfData }; + } catch { + return null; + } +} + +export function detectTauriIdentifier(conf: TauriConf | null): string | null { + const id = String(conf?.data?.identifier || "").trim(); + return id || null; +} + +export function writeTauriConf(conf: TauriConf): void { + writeFileSync(conf.path, JSON.stringify(conf.data, null, 2) + "\n", "utf-8"); +} + +export interface EnsurePluginsWidgetsOptions { + appGroup?: string; + transport?: string; + extensionBundleId?: string; + force?: boolean; + /** When true, update appGroup even if one is already set (e.g. explicit --app-group). */ + explicitAppGroup?: boolean; +} + +export interface EnsurePluginsWidgetsResult { + wrote: boolean; + transport?: string; + skipped: boolean; +} + +/** + * Ensure plugins.widgets exists with appGroup + transport (+ optional extensionBundleId). + */ +export function ensurePluginsWidgets( + conf: TauriConf | null, + { + appGroup, + transport, + extensionBundleId, + force = false, + explicitAppGroup = false, + }: EnsurePluginsWidgetsOptions = {}, +): EnsurePluginsWidgetsResult { + if (!conf?.data || !conf?.path) return { wrote: false, skipped: true }; + if (!appGroup) return { wrote: false, skipped: true }; + const data = conf.data; + if (!data.plugins) data.plugins = {}; + if (!data.plugins.widgets) data.plugins.widgets = {}; + const w = data.plugins.widgets; + let changed = false; + + if (!w.appGroup || force || explicitAppGroup) { + if (w.appGroup !== appGroup) { + w.appGroup = appGroup; + changed = true; + } + } + if (!w.transport || force) { + if (w.transport !== transport) { + w.transport = transport; + changed = true; + } + } + if (extensionBundleId && (!w.extensionBundleId || force)) { + if (w.extensionBundleId !== extensionBundleId) { + w.extensionBundleId = extensionBundleId; + changed = true; + } + } + + if (changed) { + writeTauriConf(conf); + } + return { wrote: changed, transport: w.transport, skipped: false }; +} diff --git a/src-cli/lib/templates.ts b/src-cli/lib/templates.ts new file mode 100644 index 0000000..e2e368e --- /dev/null +++ b/src-cli/lib/templates.ts @@ -0,0 +1,36 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +export function replaceAll(str: string, search: string, replacement: string): string { + return str.split(search).join(replacement); +} + +export function copyTemplate( + src: string, + dest: string, + replacements: Record, +): void { + let content = readFileSync(src, "utf-8"); + for (const [search, replacement] of Object.entries(replacements)) { + content = replaceAll(content, search, replacement); + } + writeFileSync(dest, content, "utf-8"); +} + +export interface WidgetTemplateOptions { + widgetKind?: string; + widgetId?: string; +} + +export function widgetTemplateReplacements( + appGroup: string, + opts: WidgetTemplateOptions = {}, +): Record { + const widgetKind = opts.widgetKind || "MyTauriWidget"; + const widgetId = opts.widgetId || "default"; + return { + "{{APP_GROUP}}": appGroup, + "{{WIDGET_KIND}}": widgetKind, + "{{WIDGET_ID}}": widgetId, + "group.com.example.myapp": appGroup, + }; +} diff --git a/src-cli/lib/trace.ts b/src-cli/lib/trace.ts new file mode 100644 index 0000000..9c7abd9 --- /dev/null +++ b/src-cli/lib/trace.ts @@ -0,0 +1,202 @@ +import { existsSync, readFileSync, readdirSync, watch } from "node:fs"; +import { join } from "node:path"; + +export interface TraceEvent { + ts: number; + kind?: string; + type?: string; + role?: string; + widgetId?: string; + nonce?: number | string; + trigger?: string; + source?: string; + changed?: unknown; + ok?: boolean; + reason?: + | string + | { + outcome?: string; + Throttled?: boolean; + remainingSecs?: number; + remaining_secs?: number; + remaining?: number; + performed?: unknown; + }; + [key: string]: unknown; +} + +export interface LoadedTrace { + path: string; + events: TraceEvent[]; +} + +export function findFilesNamed(dir: string, name: string, depth = 0, out: string[] = []): string[] { + if (depth > 5 || !existsSync(dir)) return out; + let entries: import("node:fs").Dirent[] = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const p = join(dir, e.name); + if (e.isFile() && e.name === name) out.push(p); + else if (e.isDirectory() && !e.name.startsWith(".")) findFilesNamed(p, name, depth + 1, out); + } + return out; +} + +/** Resolve widget_trace.json candidates (aligned with Rust app_data_dir/widgets/). */ +export function resolveTracePaths(cwd: string, identifier: string | null): string[] { + const candidates: string[] = []; + const home = process.env.HOME || ""; + const local = process.env.LOCALAPPDATA || ""; + const xdg = process.env.XDG_DATA_HOME || (home ? join(home, ".local", "share") : ""); + + if (identifier) { + if (home) { + candidates.push(join(home, "Library", "Application Support", identifier, "widgets", "widget_trace.json")); + } + if (local) { + candidates.push(join(local, identifier, "widgets", "widget_trace.json")); + } + if (xdg) { + candidates.push(join(xdg, identifier, "widgets", "widget_trace.json")); + } + } + // Legacy / mistaken paths still checked by doctor historically + candidates.push( + join(cwd, "src-tauri", "target", "debug", "widgets", "widget_trace.json"), + join(cwd, "src-tauri", "target", "release", "widgets", "widget_trace.json"), + ); + if (home) { + candidates.push(...findFilesNamed(join(home, "Library", "Application Support"), "widget_trace.json").slice(0, 8)); + } + return candidates; +} + +export function loadTraceFile(cwd: string, identifier: string | null): LoadedTrace | null { + for (const c of resolveTracePaths(cwd, identifier)) { + if (!c || !existsSync(c)) continue; + try { + const events = JSON.parse(readFileSync(c, "utf-8")) as unknown; + if (Array.isArray(events)) return { path: c, events: events as TraceEvent[] }; + } catch { + /* ignore */ + } + } + return null; +} + +function fmtTs(ts: number | string): string { + const d = new Date(Number(ts)); + if (Number.isNaN(d.getTime())) return String(ts); + return d.toISOString().slice(11, 19); +} + +export function formatTraceEvent(e: TraceEvent): string { + const t = fmtTs(e.ts); + const kind = e.kind || e.type || "?"; + const role = e.role || (kind === "render" ? "wgt" : "host"); + const bits: string[] = []; + if (e.widgetId) bits.push(e.widgetId); + if (e.nonce != null) bits.push(`nonce=${e.nonce}`); + if (e.trigger) bits.push(`trigger=${e.trigger}`); + if (e.source) bits.push(`source=${e.source}`); + if (e.changed != null) bits.push(`changed=${e.changed}`); + if (e.reason) { + const r = e.reason; + if (typeof r === "object") { + if (r.outcome) bits.push(`outcome=${r.outcome}`); + if (r.Throttled || r.outcome === "throttled") { + const rem = r.remainingSecs ?? r.remaining_secs ?? r.remaining; + bits.push(rem != null ? `throttled remaining=${rem}s` : "throttled"); + } + if (r.performed != null) bits.push(`performed=${r.performed}`); + } else bits.push(String(r)); + } + if (e.ok === false) bits.push("✗"); + if (e.ok === true && kind === "render") bits.push("✓"); + return `${t} ${String(role).padEnd(4)} ${String(kind).padEnd(10)} ${bits.join(" ")}`.trimEnd(); +} + +export interface RunTraceFollowOptions { + cwd: string; + identifier: string | null; + follow?: boolean; + lines?: number; +} + +/** Print last N events; optionally follow file for new events. */ +export async function runTraceFollow({ + cwd, + identifier, + follow = false, + lines = 40, +}: RunTraceFollowOptions): Promise { + const loaded = loadTraceFile(cwd, identifier); + if (!loaded) { + console.error( + "widget_trace.json not found — run the app once (debug) or set WIDGET_DEBUG=1.\n" + + `Looked under Application Support / app data for identifier=${identifier || "(none)"}`, + ); + process.exitCode = 1; + return; + } + console.error(`# ${loaded.path}`); + const slice = loaded.events.slice(-lines); + for (const e of slice) console.log(formatTraceEvent(e)); + + if (!follow) return; + + const eventKey = (e: TraceEvent) => + `${e.ts}:${e.kind ?? e.type ?? ""}:${JSON.stringify(e)}`; + let lastSeenKey = slice.length ? eventKey(slice[slice.length - 1]) : ""; + let lastLen = loaded.events.length; + let raw = readFileSync(loaded.path, "utf-8"); + const tick = () => { + try { + const next = readFileSync(loaded.path, "utf-8"); + if (next === raw) return; + raw = next; + const events = JSON.parse(next) as unknown; + if (!Array.isArray(events)) return; + const typed = events as TraceEvent[]; + + if (typed.length < lastLen) { + for (const e of typed.slice(-lines)) console.log(formatTraceEvent(e)); + lastSeenKey = typed.length ? eventKey(typed[typed.length - 1]) : ""; + lastLen = typed.length; + return; + } + + let startIdx = 0; + if (lastSeenKey) { + const keys = typed.map((e) => eventKey(e)); + const idx = keys.lastIndexOf(lastSeenKey); + if (idx >= 0) { + startIdx = idx + 1; + } else { + for (const e of typed.slice(-lines)) console.log(formatTraceEvent(e)); + lastSeenKey = typed.length ? eventKey(typed[typed.length - 1]) : ""; + lastLen = typed.length; + return; + } + } else { + startIdx = lastLen; + } + if (startIdx < typed.length) { + for (const e of typed.slice(startIdx)) console.log(formatTraceEvent(e)); + lastSeenKey = eventKey(typed[typed.length - 1]); + } + lastLen = typed.length; + } catch { + /* ignore partial writes */ + } + }; + watch(loaded.path, { persistent: true }, tick); + // Also poll — some FS don't emit reliably for atomic replaces + setInterval(tick, 1000); + console.error("# following (Ctrl+C to stop)"); + await new Promise(() => {}); +} diff --git a/src-cli/lib/validate.ts b/src-cli/lib/validate.ts new file mode 100644 index 0000000..abb678d --- /dev/null +++ b/src-cli/lib/validate.ts @@ -0,0 +1,365 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { pluginRoot } from "./paths.js"; + +export interface CapabilityCell { + support: "full" | "degraded" | "unsupported" | string; + note?: string; +} + +export interface Capabilities { + version?: number; + elements?: Record>; + features?: Record>; + [key: string]: unknown; +} + +export interface LoadedCapabilities { + path: string; + data: Capabilities; +} + +export function loadCapabilities(): LoadedCapabilities { + const root = pluginRoot(); + const candidates = [ + join(root, "schemas", "capabilities.json"), + join(root, "docs", "public", "schemas", "capabilities.json"), + ]; + for (const p of candidates) { + if (!existsSync(p)) continue; + return { path: p, data: JSON.parse(readFileSync(p, "utf-8")) as Capabilities }; + } + throw new Error( + "schemas/capabilities.json missing — run: cargo test --lib capabilities::write_docs::capabilities_json_matches", + ); +} + +export interface ConfigFinding { + level: "error" | "warn"; + path: string; + type: string; + platform: string; + support: string; + note?: string; +} + +interface WidgetNode { + type?: string; + url?: string; + children?: WidgetNode[]; + items?: WidgetNode[]; + content?: WidgetNode; + [key: string]: unknown; +} + +function supportCell(caps: Capabilities, type: string, platform: string): CapabilityCell { + const el = caps.elements?.[type] || caps.features?.[type]; + if (!el) return { support: "unsupported", note: "unknown element" }; + return el[platform] || { support: "unsupported", note: "unknown platform" }; +} + +function walk( + node: WidgetNode | null | undefined, + path: string, + platform: string, + caps: Capabilities, + out: ConfigFinding[], +): void { + if (!node || typeof node !== "object") return; + const type = node.type; + if (typeof type === "string") { + const cell = supportCell(caps, type, platform); + if (cell.support === "unsupported") { + out.push({ level: "error", path, type, platform, support: cell.support, note: cell.note }); + } else if (cell.support === "degraded") { + out.push({ level: "warn", path, type, platform, support: cell.support, note: cell.note }); + } + // Feature keys + if (type === "image" && node.url) { + const f = supportCell(caps, "image.url", platform); + if (f.support !== "full") { + out.push({ + level: f.support === "unsupported" ? "error" : "warn", + path: `${path}.url`, + type: "image.url", + platform, + support: f.support, + note: f.note, + }); + } + } + if (type === "timer") { + const f = supportCell(caps, "timer.live", platform); + if (f.support !== "full") { + out.push({ + level: f.support === "unsupported" ? "error" : "warn", + path: `${path} (timer.live)`, + type: "timer.live", + platform, + support: f.support, + note: f.note, + }); + } + } + if (type === "canvas") { + const f = supportCell(caps, "canvas.path", platform); + if (f.support !== "full") { + out.push({ + level: f.support === "unsupported" ? "error" : "warn", + path: `${path} (canvas.path)`, + type: "canvas.path", + platform, + support: f.support, + note: f.note, + }); + } + } + } + // List `items` are data rows ({text,...}), not element nodes — only recurse + // explicit `children`. Matches walkShape's list special-case. + const kids = type === "list" ? node.children || [] : node.children || node.items || []; + if (Array.isArray(kids)) { + kids.forEach((ch, i) => { + const childPath = `${path}/${type || "node"}[${i}]`; + if (!ch || typeof ch !== "object" || Array.isArray(ch)) { + out.push({ + level: "error", + path: childPath, + type: typeof type === "string" ? type : "", + platform, + support: "invalid", + note: "child must be an element object with type", + }); + return; + } + if (typeof ch.type !== "string") { + out.push({ + level: "error", + path: childPath, + type: "", + platform, + support: "invalid", + note: "child must have string type", + }); + return; + } + walk(ch, childPath, platform, caps, out); + }); + } + if (node.content && typeof node.content === "object" && node.content.type) { + walk(node.content, `${path}/content`, platform, caps, out); + } +} + +/** + * Structural validation of widget config shape (sizes, required fields per type). + */ +export function validateWidgetShape(config: unknown): ConfigFinding[] { + const findings: ConfigFinding[] = []; + if (!config || typeof config !== "object") { + return [ + { + level: "error", + path: "", + type: "", + platform: "schema", + support: "invalid", + note: "config is not an object", + }, + ]; + } + const { $schema: _schema, ...body } = config as Record; + const sizes = ["small", "medium", "large"]; + let hasSize = false; + for (const size of sizes) { + if (!body[size]) continue; + hasSize = true; + const node = body[size]; + if (!node || typeof node !== "object" || Array.isArray(node)) { + findings.push({ + level: "error", + path: size, + type: "", + platform: "schema", + support: "invalid", + note: `${size} must be an element object with type`, + }); + continue; + } + if (typeof (node as WidgetNode).type !== "string") { + findings.push({ + level: "error", + path: size, + type: "", + platform: "schema", + support: "invalid", + note: `${size} must have string type`, + }); + continue; + } + walkShape(node as WidgetNode, size, findings); + } + if (!hasSize) { + findings.push({ + level: "error", + path: "", + type: "", + platform: "schema", + support: "invalid", + note: "expected at least one of small/medium/large", + }); + } + return findings; +} + +function walkShape(node: WidgetNode | null | undefined, path: string, out: ConfigFinding[]): void { + if (!node || typeof node !== "object") return; + const type = node.type; + if (typeof type === "string") { + if (type === "text") { + const content = node.content; + if (typeof content !== "string") { + out.push({ + level: "error", + path, + type, + platform: "schema", + support: "invalid", + note: 'type "text" requires string content', + }); + } + } + if (type === "image") { + const hasUrl = typeof node.url === "string" && node.url.length > 0; + const hasSystem = typeof node.systemName === "string" && node.systemName.length > 0; + const hasData = typeof node.data === "string" && node.data.length > 0; + if (!hasUrl && !hasSystem && !hasData) { + out.push({ + level: "error", + path, + type, + platform: "schema", + support: "invalid", + note: 'type "image" requires non-empty string url, systemName, or data', + }); + } + } + if (type === "list") { + if (!Array.isArray(node.items)) { + out.push({ + level: "error", + path, + type, + platform: "schema", + support: "invalid", + note: 'type "list" requires items array', + }); + } else { + node.items.forEach((item, i) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + out.push({ + level: "error", + path: `${path}/items[${i}]`, + type, + platform: "schema", + support: "invalid", + note: "list item must be an object with string text", + }); + } else if (typeof (item as WidgetNode).text !== "string") { + out.push({ + level: "error", + path: `${path}/items[${i}]`, + type, + platform: "schema", + support: "invalid", + note: "list item requires string text", + }); + } + }); + } + } + } + const kids = type === "list" ? node.children || [] : node.children || node.items || []; + if (Array.isArray(kids)) { + kids.forEach((ch, i) => { + const childPath = `${path}/${type || "node"}[${i}]`; + if (!ch || typeof ch !== "object" || Array.isArray(ch)) { + out.push({ + level: "error", + path: childPath, + type: typeof type === "string" ? type : "", + platform: "schema", + support: "invalid", + note: "child must be an element object with type", + }); + return; + } + if (typeof (ch as WidgetNode).type !== "string") { + out.push({ + level: "error", + path: childPath, + type: "", + platform: "schema", + support: "invalid", + note: "child must have string type", + }); + return; + } + walkShape(ch as WidgetNode, childPath, out); + }); + } + if (node.content && typeof node.content === "object" && (node.content as WidgetNode).type) { + walkShape(node.content as WidgetNode, `${path}/content`, out); + } +} + +/** + * Validate widget config against capabilities.json for one or more platforms. + */ +export function validateWidgetConfig( + config: unknown, + platforms: string[], + caps: Capabilities, +): ConfigFinding[] { + const findings: ConfigFinding[] = validateWidgetShape(config); + if (!config || typeof config !== "object") { + return findings; + } + const { $schema: _schema, ...body } = config as Record; + const sizes = ["small", "medium", "large"]; + for (const size of sizes) { + if (!body[size]) continue; + for (const platform of platforms) { + walk(body[size] as WidgetNode, size, platform, caps, findings); + } + } + return findings; +} + +export interface PluginWidgetsFinding { + level: "error" | "warn"; + msg: string; +} + +/** Shallow validate plugins.widgets against known enum / required fields (plugin-config.v1). */ +export function validatePluginWidgets(widgets: unknown): PluginWidgetsFinding[] { + const out: PluginWidgetsFinding[] = []; + if (!widgets || typeof widgets !== "object") { + out.push({ level: "error", msg: "plugins.widgets missing or not an object" }); + return out; + } + const w = widgets as Record; + const transports = new Set(["appGroup", "userDefaults", "widgetContainer", "auto"]); + if (w.transport != null && !transports.has(w.transport as string)) { + out.push({ level: "error", msg: `transport=${String(w.transport)} invalid` }); + } + if (!w.appGroup || typeof w.appGroup !== "string") { + out.push({ level: "error", msg: "appGroup is required (string)" }); + } else if (!w.appGroup.startsWith("group.")) { + out.push({ level: "warn", msg: `appGroup "${w.appGroup}" does not start with group.` }); + } + if (w.extensionBundleId != null && typeof w.extensionBundleId !== "string") { + out.push({ level: "error", msg: "extensionBundleId must be a string" }); + } + return out; +} diff --git a/src-cli/lib/windows.ts b/src-cli/lib/windows.ts new file mode 100644 index 0000000..6acf48f --- /dev/null +++ b/src-cli/lib/windows.ts @@ -0,0 +1,43 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +export interface WindowsPublisher { + publisher: string; + path: string | null; +} + +export function detectWindowsPublisher(cwd: string): WindowsPublisher { + const candidates = [ + join(cwd, "src-tauri", "windows-widget", "Package.appxmanifest"), + join(cwd, "src-tauri", "Package.appxmanifest"), + join(cwd, "Package.appxmanifest"), + ]; + // Also scan windows-widget for any .appxmanifest* + const winDir = join(cwd, "src-tauri", "windows-widget"); + if (existsSync(winDir)) { + try { + for (const e of readdirSync(winDir)) { + if (/\.appxmanifest/i.test(e)) candidates.push(join(winDir, e)); + } + } catch { + /* ignore */ + } + } + for (const p of candidates) { + if (!existsSync(p)) continue; + const raw = readFileSync(p, "utf-8"); + const m = raw.match(/\bPublisher="([^"]+)"/i); + if (m?.[1]) return { publisher: m[1], path: p }; + } + return { publisher: "CN=TauriWidgetsDev", path: null }; +} + +/** RFC4122 v4-ish GUID for COM CLSID placeholders. */ +export function cryptoRandomGuid(): string { + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/src-cli/tsconfig.json b/src-cli/tsconfig.json new file mode 100644 index 0000000..e504dd6 --- /dev/null +++ b/src-cli/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "declaration": false, + "types": ["node"], + "rootDir": ".", + "outDir": "../dist-cli" + }, + "include": ["./**/*.ts"], + "exclude": ["../node_modules", "../dist-cli"] +} diff --git a/src/adaptive_card.rs b/src/adaptive_card.rs deleted file mode 100644 index 77ad39e..0000000 --- a/src/adaptive_card.rs +++ /dev/null @@ -1,1056 +0,0 @@ -//! IR → Adaptive Card 1.5 transpiler for Windows Widgets Board. -//! -//! Pure Rust — runs on any host. Elements Adaptive Cards cannot express -//! (`canvas`, `chart`, `gauge`, `zstack`, …) become empty placeholders and are -//! recorded in [`TranspileResult::skipped`]. - -use crate::models::{ - ColorValue, ElementStyle, FontWeight, TextAlignment, WidgetConfig, WidgetElement, - VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, GaugeElement, ButtonElement, ToggleElement, DividerElement, DateElement, ChartElement, ListElement, LinkElement, ShapeElement, TimerElement, CanvasElement, LabelElement, -}; -use crate::receipt::SkippedElement; -use serde_json::{json, Value}; - -/// Result of transpiling one IR root (plus skipped diagnostics). -#[derive(Debug, Clone)] -pub struct TranspileResult { - pub card: Value, - pub skipped: Vec, -} - -/// Build an Adaptive Card 1.5 document from a root element. -pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult { - let mut skipped = Vec::new(); - let body = el(root, &mut skipped); - let card = json!({ - "type": "AdaptiveCard", - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.5", - "body": [body], - }); - TranspileResult { card, skipped } -} - -/// Pick a size branch from [`WidgetConfig`] and transpile. -pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option { - let root = match size { - "large" => config - .large - .as_ref() - .or(config.medium.as_ref()) - .or(config.small.as_ref()), - "medium" => config - .medium - .as_ref() - .or(config.large.as_ref()) - .or(config.small.as_ref()), - _ => config - .small - .as_ref() - .or(config.medium.as_ref()) - .or(config.large.as_ref()), - }?; - Some(to_adaptive_card(root)) -} - -/// Structural check (no full JSON Schema dependency). -pub fn validate_card_structure(card: &Value) -> Result<(), String> { - let obj = card - .as_object() - .ok_or_else(|| "card must be an object".to_string())?; - if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") { - return Err("type must be AdaptiveCard".into()); - } - if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") { - return Err("version must be 1.5".into()); - } - match obj.get("body") { - Some(Value::Array(_)) => Ok(()), - _ => Err("body must be an array".into()), - } -} - -/// Storage keys written next to the config map for the C# provider. -pub fn ac_template_key(widget_id: &str) -> String { - format!("ac:template:{widget_id}") -} - -pub fn ac_data_key(widget_id: &str) -> String { - format!("ac:data:{widget_id}") -} - -fn el(e: &WidgetElement, skipped: &mut Vec) -> Value { - match e { - WidgetElement::VStack(VStackElement { - children, - style, - alignment, - spacing, - .. - }) => { - let mut obj = json!({ - "type": "Container", - "spacing": "None", - "items": children.iter().map(|c| el(c, skipped)).collect::>(), - }); - apply_container_style(&mut obj, style); - apply_gap_id(&mut obj, *spacing); - // HorizontalAlignment on VStack children (center/leading/trailing). - if let Some(a) = alignment { - match a { - crate::models::HorizontalAlignment::Center => { - obj["horizontalAlignment"] = json!("Center"); - } - crate::models::HorizontalAlignment::Trailing => { - obj["horizontalAlignment"] = json!("Right"); - } - _ => {} - } - } - obj - } - - WidgetElement::HStack(HStackElement { - children, spacing, .. - }) => { - let n = children.len(); - let mut obj = json!({ - "type": "ColumnSet", - "columns": children - .iter() - .enumerate() - .map(|(i, c)| { - let (width, items) = match c { - WidgetElement::Spacer(_) => (json!("stretch"), json!([])), - // Column immediately before a spacer expands (title + Sync pattern). - _ if i + 1 < n - && matches!(children[i + 1], WidgetElement::Spacer(_)) => - { - (json!("stretch"), json!([el(c, skipped)])) - } - _ => (flex_width(c), json!([el(c, skipped)])), - }; - json!({ - "type": "Column", - "width": width, - "items": items, - }) - }) - .collect::>(), - }); - apply_gap_id(&mut obj, *spacing); - obj - } - - WidgetElement::Container(ContainerElement { - children, - style, - content_alignment, - .. - }) => { - let mut obj = json!({ - "type": "Container", - "spacing": "None", - "items": children.iter().map(|c| el(c, skipped)).collect::>(), - }); - apply_container_style(&mut obj, style); - if content_alignment - .as_deref() - .map(|a| a.to_ascii_lowercase().contains("center")) - .unwrap_or(false) - { - obj["horizontalAlignment"] = json!("Center"); - } - obj - } - - WidgetElement::Grid(GridElement { - children, - columns, - spacing, - row_spacing, - .. - }) => { - let cols = (*columns).max(1) as usize; - let mut rows = Vec::new(); - for chunk in children.chunks(cols) { - let columns_json: Vec = chunk - .iter() - .map(|c| { - json!({ - "type": "Column", - "width": "stretch", - "items": [el(c, skipped)], - }) - }) - .collect(); - let mut row = json!({ - "type": "ColumnSet", - "columns": columns_json, - }); - apply_gap_id(&mut row, *spacing); - rows.push(row); - } - let mut obj = json!({ - "type": "Container", - "spacing": "None", - "items": rows, - }); - apply_gap_id(&mut obj, row_spacing.or(*spacing)); - obj - } - - WidgetElement::Text(TextElement { - content, - font_size, - font_weight, - color, - alignment, - line_limit, - .. - }) => { - let wrap = !matches!(line_limit, Some(n) if *n <= 1); - let mut obj = json!({ - "type": "TextBlock", - "text": content, - "wrap": wrap, - "size": size_bucket(*font_size), - "weight": weight_token(font_weight.as_ref()), - }); - if let Some(c) = - ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) - { - obj["color"] = json!(c); - } - // Preserve exact hex for composite (muted slates stay slate, not AC "Light"). - if let Some(hex) = color_hex(color.as_ref()) { - obj["id"] = json!(format!("fg:{hex}")); - } - if let Some(a) = align(alignment.as_ref()) { - obj["horizontalAlignment"] = json!(a); - } - obj - } - - WidgetElement::Image(ImageElement { - url, - data, - system_name, - size, - color, - .. - }) => { - let url_str = url.clone().unwrap_or_else(|| { - let b64 = data.clone().unwrap_or_default(); - if b64.is_empty() { - String::new() - } else if b64.starts_with("data:") { - b64 - } else { - format!("data:image/png;base64,{b64}") - } - }); - if url_str.is_empty() { - // SF Symbol / Material name without bitmap → emoji TextBlock for AC/goldens. - let glyph = system_name.as_deref().map(sf_symbol_glyph).unwrap_or("•"); - let mut obj = json!({ - "type": "TextBlock", - "text": glyph, - "wrap": true, - "size": size_bucket(size.map(|s| s * 0.75)), - "weight": "Bolder", - }); - if let Some(c) = - ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) - { - obj["color"] = json!(c); - } - return obj; - } - json!({ - "type": "Image", - "url": url_str, - "size": img_size_bucket(*size), - }) - } - - WidgetElement::Button(ButtonElement { - label, - action, - url, - background_color, - color, - .. - }) => { - let title = sanitize_button_label(label); - let mut action_json = match action { - Some(a) => json!({ - "type": "Action.Execute", - "title": title, - "verb": a, - }), - None => json!({ - "type": "Action.OpenUrl", - "title": title, - "url": url.clone().unwrap_or_default(), - }), - }; - // Composite / PreviewHost: pass fill+fg via id (AC has no arbitrary action colors). - let mut id_parts = Vec::new(); - if let Some(hex) = color_hex(background_color.as_ref()) { - id_parts.push(format!("bg:{hex}")); - } - if let Some(hex) = color_hex(color.as_ref()) { - id_parts.push(format!("fg:{hex}")); - } - if !id_parts.is_empty() { - action_json["id"] = json!(id_parts.join(";")); - } - json!({ - "type": "ActionSet", - "actions": [action_json], - }) - } - - WidgetElement::Toggle(ToggleElement { - is_on, - label, - action, - .. - }) => { - // Prefer readable TextBlock for Adaptive Cards / goldens (ActionSet is clickable but low-fidelity). - let title = - label - .clone() - .unwrap_or_else(|| if *is_on { "On".into() } else { "Off".into() }); - let mark = if *is_on { "✓" } else { "○" }; - let mut block = json!({ - "type": "TextBlock", - "text": format!("{mark} {title}"), - "wrap": true, - "size": "Default", - }); - if let Some(a) = action { - // Keep toggle tappable on Widgets Board. - // Payload is the scalar next-state string (matches iOS/Android/desktop). - return json!({ - "type": "Container", - "items": [block], - "selectAction": { - "type": "Action.Execute", - "verb": a, - "data": { "payload": (!is_on).to_string() }, - }, - }); - } - let _ = &mut block; - block - } - - WidgetElement::Progress(ProgressElement { - value, - total, - label, - tint, - color, - .. - }) => { - let total = if *total <= 0.0 { 1.0 } else { *total }; - let pct = ((*value / total) * 100.0).clamp(0.0, 100.0) as u32; - let rest = 100u32.saturating_sub(pct); - let mut items = Vec::new(); - if let Some(l) = label { - let mut tb = json!({ - "type": "TextBlock", - "text": l, - "size": "Small", - "wrap": true, - }); - if let Some(c) = ac_color(color.as_ref()) - .or_else(|| ac_color(tint.as_ref())) - .or_else(|| approx_hex_semantic(color.as_ref())) - .or_else(|| approx_hex_semantic(tint.as_ref())) - { - tb["color"] = json!(c); - } - items.push(tb); - } - let fill_hex = color_hex(tint.as_ref()); - let fill_style = approx_hex_semantic(tint.as_ref()) - .unwrap_or("Good") - .to_ascii_lowercase(); - let mut filled = json!({ - "type": "Column", - "width": pct.max(1), - "style": fill_style, - "items": [] - }); - if let Some(ref hex) = fill_hex { - filled["id"] = json!(format!("fill:{hex}")); - } - items.push(json!({ - "type": "ColumnSet", - "columns": [ - filled, - { - "type": "Column", - "width": rest.max(1), - "items": [] - } - ], - })); - json!({ - "type": "Container", - "items": items, - }) - } - - WidgetElement::Divider(DividerElement { color, .. }) => { - let mut obj = json!({ - "type": "Container", - "separator": true, - "spacing": "Medium", - "items": [], - }); - if let Some(hex) = color_hex(color.as_ref()) { - obj["id"] = json!(format!("rule:{hex}")); - } else { - obj["id"] = json!("rule:#334155"); - } - obj - } - - WidgetElement::Spacer(_) => json!({ - "type": "TextBlock", - "text": " ", - "spacing": "Medium", - }), - - WidgetElement::Date(DateElement { date, .. }) => json!({ - "type": "TextBlock", - "text": date, - "wrap": true, - "size": "Default", - }), - - WidgetElement::Timer(TimerElement { target_date, .. }) => json!({ - "type": "TextBlock", - "text": target_date, - "wrap": true, - "size": "Default", - }), - - WidgetElement::Label(LabelElement { - text, - system_name, - color, - .. - }) => { - let prefix = if system_name.is_empty() { - String::new() - } else { - format!("{} ", sf_symbol_glyph(system_name)) - }; - let mut obj = json!({ - "type": "TextBlock", - "text": format!("{prefix}{text}"), - "wrap": true, - "weight": "Bolder", - "size": "Small", - }); - if let Some(c) = - ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) - { - obj["color"] = json!(c); - } else { - obj["color"] = json!("Light"); - } - obj - } - - WidgetElement::Link(LinkElement { - children, - action, - url, - style, - .. - }) => { - let inner: Vec = children.iter().map(|c| el(c, skipped)).collect(); - let mut container = json!({ - "type": "Container", - "items": inner, - "selectAction": match action { - Some(a) => json!({ "type": "Action.Execute", "verb": a }), - None => json!({ - "type": "Action.OpenUrl", - "url": url.clone().unwrap_or_default(), - }), - }, - }); - apply_container_style(&mut container, style); - container - } - - WidgetElement::List(ListElement { items, spacing, .. }) => { - // ColumnSet mark | text keeps a shared left edge regardless of glyph width. - let row_gap = spacing.or(Some(4.0)); - let lines: Vec = items - .iter() - .enumerate() - .map(|(idx, it)| { - let (mark, mark_color) = match it.checked { - Some(true) => ("✓", "Good"), - Some(false) => ("○", "Light"), - // Keep a blank mark slot so labels share a left edge. - None => (" ", "Light"), - }; - let row = json!({ - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "width": "auto", - "id": "mark:col", - "items": [{ - "type": "TextBlock", - "text": mark, - "wrap": false, - "size": "Small", - "color": mark_color, - "horizontalAlignment": "Center", - }] - }, - { - "type": "Column", - "width": "stretch", - "items": [{ - "type": "TextBlock", - "text": it.text, - "wrap": true, - "size": "Small", - "horizontalAlignment": "Left", - "color": "Light", - }] - } - ] - }); - // ColumnSet has no selectAction in AC 1.5 — wrap in Container. - let mut item = if let Some(ref a) = it.action { - json!({ - "type": "Container", - "items": [row], - "selectAction": { - "type": "Action.Execute", - "verb": a, - "data": { "payload": it.payload }, - } - }) - } else { - row - }; - // Spacing between rows (not only outer wrapper). - if idx > 0 { - apply_gap_id(&mut item, row_gap); - } - item - }) - .collect(); - json!({ - "type": "Container", - "horizontalAlignment": "Left", - "spacing": "None", - "items": lines, - }) - } - - WidgetElement::Shape(_) => rasterized_or_skip(e, skipped), - - WidgetElement::ZStack(ZStackElement { - children, - style, - alignment, - .. - }) => { - // Degraded: no true overlay — flatten; keep center hint for composite. - let mut obj = json!({ - "type": "Container", - "spacing": "None", - "items": children.iter().map(|c| el(c, skipped)).collect::>(), - }); - apply_container_style(&mut obj, style); - let centered = alignment - .as_deref() - .map(|a| a.to_ascii_lowercase().contains("center")) - .unwrap_or(false); - if centered { - obj["horizontalAlignment"] = json!("Center"); - } - // Icon badge (shape/image + glyph text): mark for overlay+center in composite. - if let Some(items) = obj.get("items").and_then(|i| i.as_array()) { - if items.len() == 2 { - obj["id"] = json!("badge:overlay"); - } - } - obj - } - - WidgetElement::Gauge(_) - | WidgetElement::Chart(_) - | WidgetElement::Canvas(_) => rasterized_or_skip(e, skipped), - } -} - -fn rasterized_or_skip(e: &WidgetElement, skipped: &mut Vec) -> Value { - let ty = e.type_name(); - match crate::rasterize::element_to_png_data_uri(e) { - Ok(uri) => { - let mut img = json!({ - "type": "Image", - "url": uri, - "size": "Medium", - "horizontalAlignment": "Center", - }); - match e { - WidgetElement::Canvas(_) - | WidgetElement::Chart(_) - | WidgetElement::Gauge(_) => { - img["size"] = json!("Stretch"); - } - WidgetElement::Shape(ShapeElement { size, .. }) => { - img["size"] = json!(img_size_bucket(*size)); - } - _ => {} - } - img - } - Err(err) => { - skipped.push(SkippedElement { - type_name: ty.into(), - reason: format!("rasterize failed: {err}"), - }); - json!({ - "type": "TextBlock", - "text": "", - "spacing": "None", - }) - } - } -} - -fn flex_width(c: &WidgetElement) -> Value { - flex_of(c) - .map(|f| { - if f <= 0.0 { - json!("auto") - } else { - json!((f * 100.0).round() as u64) - } - }) - .unwrap_or(json!("auto")) -} - -fn flex_of(e: &WidgetElement) -> Option { - match e { - WidgetElement::VStack(VStackElement { style, .. }) - | WidgetElement::HStack(HStackElement { style, .. }) - | WidgetElement::ZStack(ZStackElement { style, .. }) - | WidgetElement::Grid(GridElement { style, .. }) - | WidgetElement::Container(ContainerElement { style, .. }) - | WidgetElement::Text(TextElement { style, .. }) - | WidgetElement::Image(ImageElement { style, .. }) - | WidgetElement::Progress(ProgressElement { style, .. }) - | WidgetElement::Gauge(GaugeElement { style, .. }) - | WidgetElement::Button(ButtonElement { style, .. }) - | WidgetElement::Toggle(ToggleElement { style, .. }) - | WidgetElement::Divider(DividerElement { style, .. }) - | WidgetElement::Date(DateElement { style, .. }) - | WidgetElement::Chart(ChartElement { style, .. }) - | WidgetElement::List(ListElement { style, .. }) - | WidgetElement::Link(LinkElement { style, .. }) - | WidgetElement::Shape(ShapeElement { style, .. }) - | WidgetElement::Timer(TimerElement { style, .. }) - | WidgetElement::Canvas(CanvasElement { style, .. }) - | WidgetElement::Label(LabelElement { style, .. }) => style.flex, - WidgetElement::Spacer(_) => None, - } -} - -fn push_id_token(obj: &mut Value, token: &str) { - match obj.get("id").and_then(|v| v.as_str()) { - Some(id) if !id.is_empty() => { - if id.split(';').any(|p| p == token) { - return; - } - obj["id"] = json!(format!("{id};{token}")); - } - _ => { - obj["id"] = json!(token); - } - } -} - -fn apply_gap_id(obj: &mut Value, spacing: Option) { - let Some(s) = spacing else { - return; - }; - if s <= 0.0 { - return; - } - // Adaptive Cards layout spacing (pt buckets) — keep gap id for debug tooling. - let ac = if s < 4.0 { - "Small" - } else if s < 10.0 { - "Default" - } else if s < 16.0 { - "Medium" - } else { - "Large" - }; - obj["spacing"] = json!(ac); - push_id_token(obj, &format!("gap:{}", s.round() as i64)); -} - -fn apply_container_style(obj: &mut Value, style: &ElementStyle) { - if let Some(bg) = &style.background { - // Adaptive Cards only has emphasis / good / attention / warning / accent / default. - obj["style"] = json!("emphasis"); - if let Some(hex) = background_hex(bg) { - push_id_token(obj, &format!("card:{hex}")); - } - } -} - -fn background_hex(bg: &crate::models::BackgroundValue) -> Option { - match bg { - crate::models::BackgroundValue::Solid(s) => { - let s = s.trim(); - if s.starts_with('#') { - Some(s.to_ascii_lowercase()) - } else { - None - } - } - crate::models::BackgroundValue::Adaptive { dark, .. } => { - let s = dark.trim(); - if s.starts_with('#') { - Some(s.to_ascii_lowercase()) - } else { - None - } - } - crate::models::BackgroundValue::Gradient(g) => g - .colors - .first() - .map(|c| c.trim().to_ascii_lowercase()) - .filter(|c| c.starts_with('#')), - } -} - -fn size_bucket(font_size: Option) -> &'static str { - match font_size { - Some(s) if s <= 12.0 => "Small", - Some(s) if s <= 16.0 => "Default", - Some(s) if s <= 22.0 => "Medium", - Some(s) if s <= 32.0 => "Large", - Some(_) => "ExtraLarge", - None => "Default", - } -} - -fn img_size_bucket(size: Option) -> &'static str { - match size { - Some(s) if s <= 24.0 => "Small", - Some(s) if s <= 48.0 => "Medium", - Some(_) => "Large", - None => "Medium", - } -} - -fn weight_token(w: Option<&FontWeight>) -> &'static str { - match w { - Some(FontWeight::Bold) - | Some(FontWeight::Semibold) - | Some(FontWeight::Heavy) - | Some(FontWeight::Black) => "Bolder", - Some(FontWeight::Light) | Some(FontWeight::Thin) | Some(FontWeight::Ultralight) => { - "Lighter" - } - _ => "Default", - } -} - -fn align(a: Option<&TextAlignment>) -> Option<&'static str> { - match a { - Some(TextAlignment::Leading) => Some("Left"), - Some(TextAlignment::Center) => Some("Center"), - Some(TextAlignment::Trailing) => Some("Right"), - None => None, - } -} - -/// Map IR colors to Adaptive Card semantic tokens; hex → omit (caller may approx). -fn ac_color(color: Option<&ColorValue>) -> Option<&'static str> { - let raw = match color? { - ColorValue::Solid(s) => s.as_str(), - ColorValue::Adaptive { light, .. } => light.as_str(), - }; - let lower = raw.to_ascii_lowercase(); - match lower.as_str() { - "accent" => Some("Accent"), - "good" | "success" => Some("Good"), - "warning" => Some("Warning"), - "attention" | "error" | "danger" => Some("Attention"), - // "Dark" text on dark Widgets Board / goldens is invisible — use Default. - "label" | "dark" => Some("Default"), - "secondarylabel" | "light" => Some("Light"), - "default" => Some("Default"), - s if s.starts_with('#') => None, - _ => None, - } -} - -fn color_hex(color: Option<&ColorValue>) -> Option { - let raw = match color? { - ColorValue::Solid(s) => s.as_str(), - ColorValue::Adaptive { light, .. } => light.as_str(), - }; - let s = raw.trim(); - if s.starts_with('#') && (s.len() == 7 || s.len() == 4) { - Some(s.to_ascii_lowercase()) - } else { - None - } -} - -/// Approximate hex → AC semantic (for labels / progress style hints). -fn approx_hex_semantic(color: Option<&ColorValue>) -> Option<&'static str> { - let hex = color_hex(color)?; - let (r, g, b) = parse_hex_rgb(&hex)?; - let max = r.max(g).max(b) as f32; - let min = r.min(g).min(b) as f32; - let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; - let sat = max - min; - // Near-black → light text on dark goldens. - if luma < 60.0 { - return Some("Light"); - } - // Low-saturation slate/gray (e.g. #94a3b8) → Light, not Accent. - if sat < 50.0 { - return Some("Light"); - } - if luma > 200.0 && sat < 30.0 { - return Some("Light"); - } - let (rf, gf, bf) = (r as f32, g as f32, b as f32); - if rf > gf + 30.0 && rf > bf + 30.0 { - return Some("Attention"); - } - if gf > rf + 20.0 && gf > bf + 20.0 { - return Some("Good"); - } - if bf > rf + 20.0 && bf > gf + 10.0 { - return Some("Accent"); - } - if rf > 180.0 && gf > 120.0 && bf < 100.0 { - return Some("Warning"); - } - Some("Default") -} - -fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> { - let h = hex.trim_start_matches('#'); - if h.len() == 3 { - let r = u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?; - let g = u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?; - let b = u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?; - return Some((r, g, b)); - } - if h.len() == 6 { - let r = u8::from_str_radix(&h[0..2], 16).ok()?; - let g = u8::from_str_radix(&h[2..4], 16).ok()?; - let b = u8::from_str_radix(&h[4..6], 16).ok()?; - return Some((r, g, b)); - } - None -} - -/// Media / SF-ish glyphs often missing in SVG fonts → ASCII stand-ins. -fn sanitize_button_label(label: &str) -> String { - let t = label.trim(); - match t { - "⏮" | "⏮️" => "Prev".into(), - "⏭" | "⏭️" => "Next".into(), - "⏸" | "⏸️" => "Pause".into(), - "▶" | "▶️" | "⏯" => "Play".into(), - "⏹" | "⏹️" => "Stop".into(), - "⌫" => "BS".into(), - "±" => "+/-".into(), - "÷" => "/".into(), - "×" => "x".into(), - "−" => "-".into(), - _ => { - let mut s = t.to_string(); - for (from, to) in [ - ("⏮", "Prev"), - ("⏭", "Next"), - ("⏸", "Pause"), - ("▶", "Play"), - ("⌫", "BS"), - ("±", "+/-"), - ("÷", "/"), - ("×", "x"), - ("−", "-"), - ] { - if s.contains(from) { - s = s.replace(from, to); - } - } - s - } - } -} - -fn sf_symbol_glyph(name: &str) -> &'static str { - let n = name.to_ascii_lowercase(); - match n.as_str() { - "gear" | "gearshape" | "gearshape.fill" => "*", - "person" | "person.fill" => "☺", - "checkmark" | "checkmark.circle" | "checkmark.circle.fill" => "✓", - "calendar" | "calendar.badge.clock" | "calendar.circle" => "◷", - "xmark" | "xmark.circle" => "x", - "star" | "star.fill" => "*", - "heart" | "heart.fill" => "+", - "bell" | "bell.fill" => "!", - "house" | "house.fill" => "H", - "magnifyingglass" => "?", - "plus" | "plus.circle" => "+", - "minus" | "minus.circle" => "-", - _ => "•", - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use std::path::PathBuf; - - fn fixtures_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") - } - - fn snapshots_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive") - } - - fn load_config(rel: &str) -> WidgetConfig { - let path = fixtures_dir().join(rel); - let raw = - fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); - // Fixtures may contain explicit nulls — strip via Value first. - let v: Value = serde_json::from_str(&raw).expect("json"); - serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}")) - } - - #[test] - fn weather_small_is_valid_v15() { - let cfg = load_config("presets/weather.json"); - let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout"); - validate_card_structure(&result.card).unwrap(); - assert_eq!(result.card["body"].as_array().unwrap().len(), 1); - assert_eq!(result.card["body"][0]["type"], "Container"); - } - - #[test] - fn unsupported_nodes_are_skipped() { - let cfg: WidgetConfig = serde_json::from_value(json!({ - "version": 1, - "small": { - "type": "vstack", - "children": [ - { "type": "text", "content": "hi" }, - { "type": "canvas", "width": 10, "height": 10, "elements": [] }, - { "type": "zstack", "children": [ - { "type": "text", "content": "a" } - ] } - ] - } - })) - .unwrap(); - let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); - // zstack flattens; canvas rasterizes - let body = serde_json::to_string(&result.card["body"]).unwrap(); - assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock")); - validate_card_structure(&result.card).unwrap(); - } - - #[test] - #[cfg(feature = "rasterize")] - fn chart_becomes_image_data_uri() { - let cfg: WidgetConfig = serde_json::from_value(json!({ - "version": 1, - "small": { - "type": "chart", - "chartType": "bar", - "chartData": [ - { "label": "a", "value": 1 }, - { "label": "b", "value": 2 } - ] - } - })) - .unwrap(); - let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); - assert!(result.skipped.is_empty(), "{:?}", result.skipped); - assert_eq!(result.card["body"][0]["type"], "Image"); - let url = result.card["body"][0]["url"].as_str().unwrap(); - assert!(url.starts_with("data:image/png;base64,")); - } - - #[test] - fn weather_small_matches_golden_or_writes_hint() { - let cfg = load_config("presets/weather.json"); - let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); - let golden_path = snapshots_dir().join("weather.small.json"); - if !golden_path.exists() { - fs::create_dir_all(snapshots_dir()).unwrap(); - let pretty = serde_json::to_string_pretty(&result.card).unwrap(); - fs::write(&golden_path, pretty).unwrap(); - // First run creates golden — still assert structure. - validate_card_structure(&result.card).unwrap(); - return; - } - let expected: Value = - serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); - assert_eq!( - result.card, expected, - "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional" - ); - } - - #[test] - fn cases_transpile_without_panic() { - let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); - let Ok(entries) = fs::read_dir(&cases_dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - let fixture = case["fixture"].as_str().unwrap_or(""); - let size = case["size"].as_str().unwrap_or("small"); - let cfg_path = fixtures_dir().join(format!("{fixture}.json")); - if !cfg_path.exists() { - continue; - } - let cfg = load_config(&format!("{fixture}.json")); - let Some(result) = to_adaptive_card_for_size(&cfg, size) else { - continue; - }; - validate_card_structure(&result.card) - .unwrap_or_else(|e| panic!("{}: {e}", path.display())); - } - } -} diff --git a/src/adaptive_card/data.rs b/src/adaptive_card/data.rs new file mode 100644 index 0000000..7a08f3b --- /dev/null +++ b/src/adaptive_card/data.rs @@ -0,0 +1,144 @@ +//! Data group: progress / gauge / chart / list. + +use crate::models::{ListElement, ProgressElement, ProgressStyle, WidgetElement}; +use crate::receipt::SkippedElement; +use serde_json::{json, Value}; + +use super::media::rasterized_or_skip; +use super::style::{ac_color, approx_hex_semantic, color_hex}; + +pub(super) fn progress( + e: &WidgetElement, + p: &ProgressElement, + skipped: &mut Vec, +) -> Value { + // Adaptive Cards has no circular progress — rasterize to preserve shape. + if matches!(p.bar_style, Some(ProgressStyle::Circular)) { + return rasterized_or_skip(e, skipped); + } + let ProgressElement { + value, + total, + label, + tint, + color, + .. + } = p; + let total = if *total <= 0.0 { 1.0 } else { *total }; + let pct = ((*value / total) * 100.0).clamp(0.0, 100.0) as u32; + let rest = 100u32.saturating_sub(pct); + let mut items = Vec::new(); + if let Some(l) = label { + let mut tb = json!({ + "type": "TextBlock", + "text": l, + "size": "Small", + "wrap": true, + }); + if let Some(c) = ac_color(color.as_ref()) + .or_else(|| ac_color(tint.as_ref())) + .or_else(|| approx_hex_semantic(color.as_ref())) + .or_else(|| approx_hex_semantic(tint.as_ref())) + { + tb["color"] = json!(c); + } + items.push(tb); + } + let fill_hex = color_hex(tint.as_ref()); + let fill_style = approx_hex_semantic(tint.as_ref()) + .unwrap_or("Good") + .to_ascii_lowercase(); + let mut filled = json!({ + "type": "Column", + "width": pct.max(1), + "style": fill_style, + "items": [] + }); + if let Some(ref hex) = fill_hex { + filled["id"] = json!(format!("fill:{hex}")); + } + items.push(json!({ + "type": "ColumnSet", + "columns": [ + filled, + { + "type": "Column", + "width": rest.max(1), + "items": [] + } + ], + })); + json!({ + "type": "Container", + "items": items, + }) +} + +pub(super) fn gauge(e: &WidgetElement, skipped: &mut Vec) -> Value { + rasterized_or_skip(e, skipped) +} + +pub(super) fn chart(e: &WidgetElement, skipped: &mut Vec) -> Value { + rasterized_or_skip(e, skipped) +} + +pub(super) fn list(l: &ListElement) -> Value { + let ListElement { items, .. } = l; + let rows: Vec = items + .iter() + .map(|it| { + let (mark, mark_color) = match it.checked { + Some(true) => ("✓", "Good"), + Some(false) => ("○", "Light"), + None => (" ", "Light"), + }; + let mut mark_cell = json!({ + "type": "TableCell", + "verticalContentAlignment": "Center", + "items": [{ + "type": "TextBlock", + "text": mark, + "wrap": false, + "size": "Small", + "color": mark_color, + "horizontalAlignment": "Center", + }] + }); + let mut text_cell = json!({ + "type": "TableCell", + "verticalContentAlignment": "Center", + "items": [{ + "type": "TextBlock", + "text": it.text, + "wrap": true, + "size": "Small", + "horizontalAlignment": "Left", + "color": "Light", + }] + }); + if let Some(ref a) = it.action { + let action = json!({ + "type": "Action.Execute", + "verb": a, + "data": { "payload": it.payload }, + }); + text_cell["selectAction"] = action.clone(); + mark_cell["selectAction"] = action; + } + json!({ + "type": "TableRow", + "cells": [mark_cell, text_cell], + }) + }) + .collect(); + json!({ + "type": "Table", + "firstRowAsHeaders": false, + "showGridLines": false, + "columns": [ + { "width": 1 }, + { "width": 5 } + ], + "rows": rows, + }) +} diff --git a/src/adaptive_card/interactive.rs b/src/adaptive_card/interactive.rs new file mode 100644 index 0000000..64d0f4c --- /dev/null +++ b/src/adaptive_card/interactive.rs @@ -0,0 +1,106 @@ +//! Interactive group: button / toggle / link. + +use crate::models::{ButtonElement, LinkElement, ToggleElement}; +use crate::receipt::SkippedElement; +use serde_json::{json, Value}; + +use super::el; +use super::style::{apply_container_style, color_hex, sanitize_button_label}; + +pub(super) fn button(b: &ButtonElement) -> Value { + let ButtonElement { + label, + action, + url, + background_color, + color, + .. + } = b; + let title = sanitize_button_label(label); + let mut action_json = match action { + Some(a) => json!({ + "type": "Action.Execute", + "title": title, + "verb": a, + }), + None => json!({ + "type": "Action.OpenUrl", + "title": title, + "url": url.clone().unwrap_or_default(), + }), + }; + // Composite / PreviewHost: pass fill+fg via id (AC has no arbitrary action colors). + let mut id_parts = Vec::new(); + if let Some(hex) = color_hex(background_color.as_ref()) { + id_parts.push(format!("bg:{hex}")); + } + if let Some(hex) = color_hex(color.as_ref()) { + id_parts.push(format!("fg:{hex}")); + } + if !id_parts.is_empty() { + action_json["id"] = json!(id_parts.join(";")); + } + json!({ + "type": "ActionSet", + "actions": [action_json], + }) +} + +pub(super) fn toggle(t: &ToggleElement) -> Value { + let ToggleElement { + is_on, + label, + action, + .. + } = t; + // Prefer readable TextBlock for Adaptive Cards / goldens (ActionSet is clickable but low-fidelity). + let title = label + .clone() + .unwrap_or_else(|| if *is_on { "On".into() } else { "Off".into() }); + let mark = if *is_on { "✓" } else { "○" }; + let mut block = json!({ + "type": "TextBlock", + "text": format!("{mark} {title}"), + "wrap": true, + "size": "Default", + }); + if let Some(a) = action { + // Keep toggle tappable on Widgets Board. + // Payload is the scalar next-state string (matches iOS/Android/desktop). + return json!({ + "type": "Container", + "items": [block], + "selectAction": { + "type": "Action.Execute", + "verb": a, + "data": { "payload": (!is_on).to_string() }, + }, + }); + } + let _ = &mut block; + block +} + +pub(super) fn link(l: &LinkElement, skipped: &mut Vec) -> Value { + let LinkElement { + children, + action, + url, + style, + .. + } = l; + let inner: Vec = children.iter().map(|c| el(c, skipped)).collect(); + let mut container = json!({ + "type": "Container", + "items": inner, + "selectAction": match action { + Some(a) => json!({ "type": "Action.Execute", "verb": a }), + None => json!({ + "type": "Action.OpenUrl", + "url": url.clone().unwrap_or_default(), + }), + }, + }); + apply_container_style(&mut container, style); + container +} diff --git a/src/adaptive_card/layout.rs b/src/adaptive_card/layout.rs new file mode 100644 index 0000000..7fb30b8 --- /dev/null +++ b/src/adaptive_card/layout.rs @@ -0,0 +1,202 @@ +//! Layout group: vstack / hstack / zstack / grid / container. + +use crate::models::{ + ContainerElement, GridElement, HStackElement, VStackElement, WidgetElement, ZStackElement, +}; +use crate::receipt::SkippedElement; +use serde_json::{json, Value}; + +use super::el; +use super::style::{ + apply_container_style, apply_container_style_to_wrapper, apply_gap_id, flex_width, +}; + +pub(super) fn vstack(v: &VStackElement, skipped: &mut Vec) -> Value { + let VStackElement { + children, + style, + alignment, + spacing, + .. + } = v; + let items: Vec = children + .iter() + .enumerate() + .map(|(i, c)| { + let mut item = el(c, skipped); + if i > 0 { + apply_gap_id(&mut item, *spacing); + } + item + }) + .collect(); + let mut obj = json!({ + "type": "Container", + "spacing": "None", + "items": items, + }); + apply_container_style(&mut obj, style); + // HorizontalAlignment on VStack children (center/leading/trailing). + if let Some(a) = alignment { + match a { + crate::models::HorizontalAlignment::Center => { + obj["horizontalAlignment"] = json!("Center"); + } + crate::models::HorizontalAlignment::Trailing => { + obj["horizontalAlignment"] = json!("Right"); + } + _ => {} + } + } + obj +} + +pub(super) fn hstack(h: &HStackElement, skipped: &mut Vec) -> Value { + let HStackElement { + children, spacing, .. + } = h; + let n = children.len(); + json!({ + "type": "ColumnSet", + "spacing": "None", + "columns": children + .iter() + .enumerate() + .map(|(i, c)| { + let (width, items) = match c { + WidgetElement::Spacer(_) => (json!("stretch"), json!([])), + // Column immediately before a spacer expands (title + Sync pattern). + _ if i + 1 < n + && matches!(children[i + 1], WidgetElement::Spacer(_)) => + { + (json!("stretch"), json!([el(c, skipped)])) + } + _ => (flex_width(c), json!([el(c, skipped)])), + }; + let mut col = json!({ + "type": "Column", + "width": width, + "items": items, + }); + // Inter-column gap (ColumnSet.spacing is preceding-element only). + if i > 0 { + apply_gap_id(&mut col, *spacing); + } + col + }) + .collect::>(), + }) +} + +pub(super) fn container(c: &ContainerElement, skipped: &mut Vec) -> Value { + let ContainerElement { + children, + style, + content_alignment, + .. + } = c; + let mut obj = json!({ + "type": "Container", + "spacing": "None", + "items": children.iter().map(|c| el(c, skipped)).collect::>(), + }); + apply_container_style(&mut obj, style); + if content_alignment + .as_deref() + .map(|a| a.to_ascii_lowercase().contains("center")) + .unwrap_or(false) + { + obj["horizontalAlignment"] = json!("Center"); + } + obj +} + +pub(super) fn grid(g: &GridElement, skipped: &mut Vec) -> Value { + let GridElement { + children, + columns, + spacing, + row_spacing, + .. + } = g; + let cols = (*columns).max(1) as usize; + let mut rows = Vec::new(); + for (ri, chunk) in children.chunks(cols).enumerate() { + let columns_json: Vec = chunk + .iter() + .enumerate() + .map(|(ci, c)| { + let mut col = json!({ + "type": "Column", + "width": "stretch", + "items": [el(c, skipped)], + }); + // Horizontal gaps between columns within a row. + if ci > 0 { + apply_gap_id(&mut col, *spacing); + } + col + }) + .collect(); + let mut row = json!({ + "type": "ColumnSet", + "spacing": "None", + "columns": columns_json, + }); + // Vertical gaps between rows. + if ri > 0 { + apply_gap_id(&mut row, row_spacing.or(*spacing)); + } + rows.push(row); + } + json!({ + "type": "Container", + "spacing": "None", + "items": rows, + }) +} + +/// `e` is the original node — rasterizing an overlay needs the whole element. +pub(super) fn zstack( + e: &WidgetElement, + z: &ZStackElement, + skipped: &mut Vec, +) -> Value { + let ZStackElement { + children, + style, + alignment, + .. + } = z; + // Prefer rasterized overlay approximation when `rasterize` works. + if let Ok(uri) = crate::rasterize::element_to_png_data_uri(e) { + let mut img = json!({ + "type": "Image", + "url": uri, + "size": "Stretch", + "horizontalAlignment": "Center", + }); + apply_container_style_to_wrapper(&mut img, style); + return img; + } + // Fallback: no true overlay — flatten; keep center hint for composite. + let mut obj = json!({ + "type": "Container", + "spacing": "None", + "items": children.iter().map(|c| el(c, skipped)).collect::>(), + }); + apply_container_style(&mut obj, style); + let centered = alignment + .as_deref() + .map(|a| a.to_ascii_lowercase().contains("center")) + .unwrap_or(false); + if centered { + obj["horizontalAlignment"] = json!("Center"); + } + if let Some(items) = obj.get("items").and_then(|i| i.as_array()) { + if items.len() == 2 { + obj["id"] = json!("badge:overlay"); + } + } + obj +} diff --git a/src/adaptive_card/media.rs b/src/adaptive_card/media.rs new file mode 100644 index 0000000..7f7bc4c --- /dev/null +++ b/src/adaptive_card/media.rs @@ -0,0 +1,120 @@ +//! Media group: image / shape / canvas (plus the shared rasterize fallback). + +use crate::models::{ImageElement, ShapeElement, WidgetElement}; +use crate::receipt::SkippedElement; +use serde_json::{json, Value}; + +use super::style::{ac_color, approx_hex_semantic, img_size_bucket, size_bucket}; + +pub(super) fn image(i: &ImageElement) -> Value { + let ImageElement { + url, + data, + system_name, + size, + color, + .. + } = i; + let url_str = url.clone().unwrap_or_else(|| { + let b64 = data.clone().unwrap_or_default(); + if b64.is_empty() { + String::new() + } else if b64.starts_with("data:") { + b64 + } else { + format!("data:image/png;base64,{b64}") + } + }); + if url_str.is_empty() { + // Prefer rasterized glyph Image when `rasterize` is on; else emoji TextBlock. + if let Some(name) = system_name.as_deref() { + #[cfg(feature = "rasterize")] + { + let sz = size.unwrap_or(24.0); + let color_hex = + super::style::color_hex(color.as_ref()).unwrap_or_else(|| "#FFFFFF".into()); + if let Ok(uri) = crate::icons::icon_png_data_uri(name, sz, &color_hex) { + return json!({ + "type": "Image", + "url": uri, + "size": img_size_bucket(*size), + }); + } + } + let glyph = crate::icons::sf_symbol_emoji(name); + let mut obj = json!({ + "type": "TextBlock", + "text": glyph, + "wrap": true, + "size": size_bucket(size.map(|s| s * 0.75)), + "weight": "Bolder", + }); + if let Some(c) = + ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) + { + obj["color"] = json!(c); + } + return obj; + } + let mut obj = json!({ + "type": "TextBlock", + "text": "•", + "wrap": true, + "size": size_bucket(size.map(|s| s * 0.75)), + "weight": "Bolder", + }); + if let Some(c) = ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) { + obj["color"] = json!(c); + } + return obj; + } + json!({ + "type": "Image", + "url": url_str, + "size": img_size_bucket(*size), + }) +} + +pub(super) fn shape(e: &WidgetElement, skipped: &mut Vec) -> Value { + rasterized_or_skip(e, skipped) +} + +pub(super) fn canvas(e: &WidgetElement, skipped: &mut Vec) -> Value { + rasterized_or_skip(e, skipped) +} + +/// PNG data URI fallback for nodes Adaptive Cards cannot express natively. +pub(super) fn rasterized_or_skip(e: &WidgetElement, skipped: &mut Vec) -> Value { + let ty = e.type_name(); + match crate::rasterize::element_to_png_data_uri(e) { + Ok(uri) => { + let mut img = json!({ + "type": "Image", + "url": uri, + "size": "Medium", + "horizontalAlignment": "Center", + }); + match e { + WidgetElement::Canvas(_) | WidgetElement::Chart(_) | WidgetElement::Gauge(_) => { + img["size"] = json!("Stretch"); + } + WidgetElement::Shape(ShapeElement { size, .. }) => { + img["size"] = json!(img_size_bucket(*size)); + } + _ => {} + } + img + } + Err(err) => { + skipped.push(SkippedElement { + type_name: ty.into(), + reason: format!("rasterize failed: {err}"), + }); + json!({ + "type": "TextBlock", + "text": "", + "spacing": "None", + }) + } + } +} diff --git a/src/adaptive_card/mod.rs b/src/adaptive_card/mod.rs new file mode 100644 index 0000000..6e3f635 --- /dev/null +++ b/src/adaptive_card/mod.rs @@ -0,0 +1,256 @@ +//! IR → Adaptive Card 1.5 transpiler for Windows Widgets Board. +//! +//! Pure Rust — runs on any host. Elements Adaptive Cards cannot express natively +//! (`canvas`, `chart`, `gauge`, `shape`, `zstack`, gradients) are rasterized to +//! PNG data URIs when the `rasterize` feature is enabled; otherwise they may be +//! skipped / flattened and recorded in [`TranspileResult::skipped`]. +//! +//! Element conversion is split across the six render groups (`layout`, `text`, +//! `media`, `data`, `interactive`, `spacing`) plus shared token helpers in +//! `style`, mirroring the Android/Swift renderers. + +mod data; +mod interactive; +mod layout; +mod media; +mod spacing; +mod style; +mod text; + +use crate::models::{WidgetConfig, WidgetElement}; +use crate::receipt::SkippedElement; +use serde_json::{json, Value}; + +pub(crate) use style::sf_symbol_glyph_public; + +/// Result of transpiling one IR root (plus skipped diagnostics). +#[derive(Debug, Clone)] +pub struct TranspileResult { + pub card: Value, + pub skipped: Vec, +} + +/// Build an Adaptive Card 1.5 document from a root element. +pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult { + let mut skipped = Vec::new(); + let body = el(root, &mut skipped); + let card = json!({ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [body], + }); + TranspileResult { card, skipped } +} + +/// Pick a size branch from [`WidgetConfig`] and transpile. +pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option { + let root = match size { + "large" => config + .large + .as_ref() + .or(config.small.as_ref()) + .or(config.medium.as_ref()), + "medium" => config + .medium + .as_ref() + .or(config.small.as_ref()) + .or(config.large.as_ref()), + _ => config + .small + .as_ref() + .or(config.medium.as_ref()) + .or(config.large.as_ref()), + }?; + Some(to_adaptive_card(root)) +} + +/// Structural check (no full JSON Schema dependency). +pub fn validate_card_structure(card: &Value) -> Result<(), String> { + let obj = card + .as_object() + .ok_or_else(|| "card must be an object".to_string())?; + if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") { + return Err("type must be AdaptiveCard".into()); + } + if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") { + return Err("version must be 1.5".into()); + } + match obj.get("body") { + Some(Value::Array(_)) => Ok(()), + _ => Err("body must be an array".into()), + } +} + +/// Storage keys written next to the config map for the C# provider. +pub fn ac_template_key(widget_id: &str) -> String { + format!("ac:template:{widget_id}") +} + +pub fn ac_data_key(widget_id: &str) -> String { + format!("ac:data:{widget_id}") +} + +/// Dispatch one IR element to its render group. +fn el(e: &WidgetElement, skipped: &mut Vec) -> Value { + match e { + // layout + WidgetElement::VStack(v) => layout::vstack(v, skipped), + WidgetElement::HStack(h) => layout::hstack(h, skipped), + WidgetElement::Container(c) => layout::container(c, skipped), + WidgetElement::Grid(g) => layout::grid(g, skipped), + WidgetElement::ZStack(z) => layout::zstack(e, z, skipped), + + // text + WidgetElement::Text(t) => text::text(t), + WidgetElement::Date(d) => text::date(d), + WidgetElement::Timer(t) => text::timer(t), + WidgetElement::Label(l) => text::label(l), + + // media + WidgetElement::Image(i) => media::image(i), + WidgetElement::Shape(_) => media::shape(e, skipped), + WidgetElement::Canvas(_) => media::canvas(e, skipped), + + // data + WidgetElement::Progress(p) => data::progress(e, p, skipped), + WidgetElement::Gauge(_) => data::gauge(e, skipped), + WidgetElement::Chart(_) => data::chart(e, skipped), + WidgetElement::List(l) => data::list(l), + + // interactive + WidgetElement::Button(b) => interactive::button(b), + WidgetElement::Toggle(t) => interactive::toggle(t), + WidgetElement::Link(l) => interactive::link(l, skipped), + + // spacing + WidgetElement::Spacer(_) => spacing::spacer(), + WidgetElement::Divider(d) => spacing::divider(d), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") + } + + fn snapshots_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive") + } + + fn load_config(rel: &str) -> WidgetConfig { + let path = fixtures_dir().join(rel); + let raw = + fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + // Fixtures may contain explicit nulls — strip via Value first. + let v: Value = serde_json::from_str(&raw).expect("json"); + serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}")) + } + + #[test] + fn weather_small_is_valid_v15() { + let cfg = load_config("presets/weather.json"); + let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout"); + validate_card_structure(&result.card).unwrap(); + assert_eq!(result.card["body"].as_array().unwrap().len(), 1); + assert_eq!(result.card["body"][0]["type"], "Container"); + } + + #[test] + fn unsupported_nodes_are_skipped() { + let cfg: WidgetConfig = serde_json::from_value(json!({ + "version": 1, + "small": { + "type": "vstack", + "children": [ + { "type": "text", "content": "hi" }, + { "type": "canvas", "width": 10, "height": 10, "elements": [] }, + { "type": "zstack", "children": [ + { "type": "text", "content": "a" } + ] } + ] + } + })) + .unwrap(); + let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); + // zstack flattens; canvas rasterizes + let body = serde_json::to_string(&result.card["body"]).unwrap(); + assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock")); + validate_card_structure(&result.card).unwrap(); + } + + #[test] + #[cfg(feature = "rasterize")] + fn chart_becomes_image_data_uri() { + let cfg: WidgetConfig = serde_json::from_value(json!({ + "version": 1, + "small": { + "type": "chart", + "chartType": "bar", + "chartData": [ + { "label": "a", "value": 1 }, + { "label": "b", "value": 2 } + ] + } + })) + .unwrap(); + let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); + assert!(result.skipped.is_empty(), "{:?}", result.skipped); + assert_eq!(result.card["body"][0]["type"], "Image"); + let url = result.card["body"][0]["url"].as_str().unwrap(); + assert!(url.starts_with("data:image/png;base64,")); + } + + #[test] + fn weather_small_matches_golden_or_writes_hint() { + let cfg = load_config("presets/weather.json"); + let result = to_adaptive_card_for_size(&cfg, "small").unwrap(); + let golden_path = snapshots_dir().join("weather.small.json"); + if !golden_path.exists() { + fs::create_dir_all(snapshots_dir()).unwrap(); + let pretty = serde_json::to_string_pretty(&result.card).unwrap(); + fs::write(&golden_path, pretty).unwrap(); + // First run creates golden — still assert structure. + validate_card_structure(&result.card).unwrap(); + return; + } + let expected: Value = + serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); + assert_eq!( + result.card, expected, + "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional" + ); + } + + #[test] + fn cases_transpile_without_panic() { + let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases"); + let Ok(entries) = fs::read_dir(&cases_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let fixture = case["fixture"].as_str().unwrap_or(""); + let size = case["size"].as_str().unwrap_or("small"); + let cfg_path = fixtures_dir().join(format!("{fixture}.json")); + if !cfg_path.exists() { + continue; + } + let cfg = load_config(&format!("{fixture}.json")); + let Some(result) = to_adaptive_card_for_size(&cfg, size) else { + continue; + }; + validate_card_structure(&result.card) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } +} diff --git a/src/adaptive_card/spacing.rs b/src/adaptive_card/spacing.rs new file mode 100644 index 0000000..2da445c --- /dev/null +++ b/src/adaptive_card/spacing.rs @@ -0,0 +1,30 @@ +//! Spacing group: spacer / divider. + +use crate::models::DividerElement; +use serde_json::{json, Value}; + +use super::style::color_hex; + +pub(super) fn spacer() -> Value { + json!({ + "type": "TextBlock", + "text": " ", + "spacing": "Medium", + }) +} + +pub(super) fn divider(d: &DividerElement) -> Value { + let DividerElement { color, .. } = d; + let mut obj = json!({ + "type": "Container", + "separator": true, + "spacing": "Medium", + "items": [], + }); + if let Some(hex) = color_hex(color.as_ref()) { + obj["id"] = json!(format!("rule:{hex}")); + } else { + obj["id"] = json!("rule:#334155"); + } + obj +} diff --git a/src/adaptive_card/style.rs b/src/adaptive_card/style.rs new file mode 100644 index 0000000..2189e3a --- /dev/null +++ b/src/adaptive_card/style.rs @@ -0,0 +1,319 @@ +//! Shared token helpers: container style, size/weight/color buckets, flex widths. + +use crate::models::{ + ButtonElement, CanvasElement, ChartElement, ColorValue, ContainerElement, DateElement, + DividerElement, ElementStyle, FontWeight, GaugeElement, GridElement, HStackElement, + ImageElement, LabelElement, LinkElement, ListElement, ProgressElement, ShapeElement, + TextAlignment, TextElement, TimerElement, ToggleElement, VStackElement, WidgetElement, + ZStackElement, +}; +use serde_json::{json, Value}; + +pub(super) fn flex_width(c: &WidgetElement) -> Value { + flex_of(c) + .map(|f| { + if f <= 0.0 { + json!("auto") + } else { + json!((f * 100.0).round() as u64) + } + }) + .unwrap_or(json!("auto")) +} + +fn flex_of(e: &WidgetElement) -> Option { + match e { + WidgetElement::VStack(VStackElement { style, .. }) + | WidgetElement::HStack(HStackElement { style, .. }) + | WidgetElement::ZStack(ZStackElement { style, .. }) + | WidgetElement::Grid(GridElement { style, .. }) + | WidgetElement::Container(ContainerElement { style, .. }) + | WidgetElement::Text(TextElement { style, .. }) + | WidgetElement::Image(ImageElement { style, .. }) + | WidgetElement::Progress(ProgressElement { style, .. }) + | WidgetElement::Gauge(GaugeElement { style, .. }) + | WidgetElement::Button(ButtonElement { style, .. }) + | WidgetElement::Toggle(ToggleElement { style, .. }) + | WidgetElement::Divider(DividerElement { style, .. }) + | WidgetElement::Date(DateElement { style, .. }) + | WidgetElement::Chart(ChartElement { style, .. }) + | WidgetElement::List(ListElement { style, .. }) + | WidgetElement::Link(LinkElement { style, .. }) + | WidgetElement::Shape(ShapeElement { style, .. }) + | WidgetElement::Timer(TimerElement { style, .. }) + | WidgetElement::Canvas(CanvasElement { style, .. }) + | WidgetElement::Label(LabelElement { style, .. }) => style.flex, + WidgetElement::Spacer(_) => None, + } +} + +fn push_id_token(obj: &mut Value, token: &str) { + match obj.get("id").and_then(|v| v.as_str()) { + Some(id) if !id.is_empty() => { + if id.split(';').any(|p| p == token) { + return; + } + obj["id"] = json!(format!("{id};{token}")); + } + _ => { + obj["id"] = json!(token); + } + } +} + +pub(super) fn apply_gap_id(obj: &mut Value, spacing: Option) { + let Some(s) = spacing else { + return; + }; + if s <= 0.0 { + return; + } + // Adaptive Cards layout spacing (pt buckets) — keep gap id for debug tooling. + let ac = if s < 4.0 { + "Small" + } else if s < 10.0 { + "Default" + } else if s < 16.0 { + "Medium" + } else { + "Large" + }; + obj["spacing"] = json!(ac); + push_id_token(obj, &format!("gap:{}", s.round() as i64)); +} + +pub(super) fn apply_container_style(obj: &mut Value, style: &ElementStyle) { + if let Some(bg) = &style.background { + match bg { + crate::models::BackgroundValue::Gradient(g) => { + if let Ok(uri) = crate::rasterize::gradient_to_png_data_uri(g) { + obj["backgroundImage"] = json!({ + "url": uri, + "fillMode": "cover", + }); + } else { + // Fallback when rasterize is off: emphasis + first stop in id. + obj["style"] = json!("emphasis"); + if let Some(hex) = background_hex(bg) { + push_id_token(obj, &format!("card:{hex}")); + } + } + } + _ => { + // Adaptive Cards only has emphasis / good / attention / warning / accent / default. + obj["style"] = json!("emphasis"); + if let Some(hex) = background_hex(bg) { + push_id_token(obj, &format!("card:{hex}")); + } + } + } + } +} + +pub(super) fn apply_container_style_to_wrapper(obj: &mut Value, style: &ElementStyle) { + // Rasterized zstack Image cannot take Container style; still stamp color id for composites. + if let Some(bg) = &style.background { + if let Some(hex) = background_hex(bg) { + push_id_token(obj, &format!("card:{hex}")); + } + } +} + +fn background_hex(bg: &crate::models::BackgroundValue) -> Option { + match bg { + crate::models::BackgroundValue::Solid(s) => { + let s = s.trim(); + if s.starts_with('#') { + Some(s.to_ascii_lowercase()) + } else { + None + } + } + crate::models::BackgroundValue::Adaptive { dark, .. } => { + let s = dark.trim(); + if s.starts_with('#') { + Some(s.to_ascii_lowercase()) + } else { + None + } + } + crate::models::BackgroundValue::Gradient(g) => g + .colors + .first() + .map(|c| c.trim().to_ascii_lowercase()) + .filter(|c| c.starts_with('#')), + } +} + +pub(super) fn size_bucket(font_size: Option) -> &'static str { + match font_size { + Some(s) if s <= 12.0 => "Small", + Some(s) if s <= 16.0 => "Default", + Some(s) if s <= 22.0 => "Medium", + Some(s) if s <= 32.0 => "Large", + Some(_) => "ExtraLarge", + None => "Default", + } +} + +pub(super) fn img_size_bucket(size: Option) -> &'static str { + match size { + Some(s) if s <= 24.0 => "Small", + Some(s) if s <= 48.0 => "Medium", + Some(_) => "Large", + None => "Medium", + } +} + +pub(super) fn weight_token(w: Option<&FontWeight>) -> &'static str { + match w { + Some(FontWeight::Bold) + | Some(FontWeight::Semibold) + | Some(FontWeight::Heavy) + | Some(FontWeight::Black) => "Bolder", + Some(FontWeight::Light) | Some(FontWeight::Thin) | Some(FontWeight::Ultralight) => { + "Lighter" + } + _ => "Default", + } +} + +pub(super) fn align(a: Option<&TextAlignment>) -> Option<&'static str> { + match a { + Some(TextAlignment::Leading) => Some("Left"), + Some(TextAlignment::Center) => Some("Center"), + Some(TextAlignment::Trailing) => Some("Right"), + None => None, + } +} + +/// Map IR colors to Adaptive Card semantic tokens; hex → omit (caller may approx). +pub(super) fn ac_color(color: Option<&ColorValue>) -> Option<&'static str> { + let raw = match color? { + ColorValue::Solid(s) => s.as_str(), + ColorValue::Adaptive { light, .. } => light.as_str(), + }; + let lower = raw.to_ascii_lowercase(); + match lower.as_str() { + "accent" => Some("Accent"), + "good" | "success" => Some("Good"), + "warning" => Some("Warning"), + "attention" | "error" | "danger" => Some("Attention"), + // "Dark" text on dark Widgets Board / goldens is invisible — use Default. + "label" | "dark" => Some("Default"), + "secondarylabel" | "light" => Some("Light"), + "default" => Some("Default"), + s if s.starts_with('#') => None, + _ => None, + } +} + +pub(super) fn color_hex(color: Option<&ColorValue>) -> Option { + let raw = match color? { + ColorValue::Solid(s) => s.as_str(), + ColorValue::Adaptive { light, .. } => light.as_str(), + }; + let s = raw.trim(); + if s.starts_with('#') && (s.len() == 7 || s.len() == 4) { + Some(s.to_ascii_lowercase()) + } else { + None + } +} + +/// Approximate hex → AC semantic (for labels / progress style hints). +pub(super) fn approx_hex_semantic(color: Option<&ColorValue>) -> Option<&'static str> { + let hex = color_hex(color)?; + let (r, g, b) = parse_hex_rgb(&hex)?; + let max = r.max(g).max(b) as f32; + let min = r.min(g).min(b) as f32; + let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; + let sat = max - min; + // Near-black → light text on dark goldens. + if luma < 60.0 { + return Some("Light"); + } + // Low-saturation slate/gray (e.g. #94a3b8) → Light, not Accent. + if sat < 50.0 { + return Some("Light"); + } + if luma > 200.0 && sat < 30.0 { + return Some("Light"); + } + let (rf, gf, bf) = (r as f32, g as f32, b as f32); + if rf > gf + 30.0 && rf > bf + 30.0 { + return Some("Attention"); + } + if gf > rf + 20.0 && gf > bf + 20.0 { + return Some("Good"); + } + if bf > rf + 20.0 && bf > gf + 10.0 { + return Some("Accent"); + } + if rf > 180.0 && gf > 120.0 && bf < 100.0 { + return Some("Warning"); + } + Some("Default") +} + +fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> { + let h = hex.trim_start_matches('#'); + if h.len() == 3 { + let r = u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?; + return Some((r, g, b)); + } + if h.len() == 6 { + let r = u8::from_str_radix(&h[0..2], 16).ok()?; + let g = u8::from_str_radix(&h[2..4], 16).ok()?; + let b = u8::from_str_radix(&h[4..6], 16).ok()?; + return Some((r, g, b)); + } + None +} + +/// Media / SF-ish glyphs often missing in SVG fonts → ASCII stand-ins. +pub(super) fn sanitize_button_label(label: &str) -> String { + let t = label.trim(); + match t { + "⏮" | "⏮️" => "Prev".into(), + "⏭" | "⏭️" => "Next".into(), + "⏸" | "⏸️" => "Pause".into(), + "▶" | "▶️" | "⏯" => "Play".into(), + "⏹" | "⏹️" => "Stop".into(), + "⌫" => "BS".into(), + "±" => "+/-".into(), + "÷" => "/".into(), + "×" => "x".into(), + "−" => "-".into(), + _ => { + let mut s = t.to_string(); + for (from, to) in [ + ("⏮", "Prev"), + ("⏭", "Next"), + ("⏸", "Pause"), + ("▶", "Play"), + ("⌫", "BS"), + ("±", "+/-"), + ("÷", "/"), + ("×", "x"), + ("−", "-"), + ] { + if s.contains(from) { + s = s.replace(from, to); + } + } + s + } + } +} + +pub(super) fn sf_symbol_glyph(name: &str) -> &'static str { + crate::icons::sf_symbol_emoji(name) +} + +/// Shared SF Symbol → emoji/glyph map for Adaptive Cards and rasterized zstacks. +pub(crate) fn sf_symbol_glyph_public(name: &str) -> &'static str { + crate::icons::sf_symbol_emoji(name) +} diff --git a/src/adaptive_card/text.rs b/src/adaptive_card/text.rs new file mode 100644 index 0000000..d73bb5b --- /dev/null +++ b/src/adaptive_card/text.rs @@ -0,0 +1,86 @@ +//! Text group: text / date / timer / label. + +use crate::models::{DateElement, LabelElement, TextElement, TimerElement}; +use serde_json::{json, Value}; + +use super::style::{ + ac_color, align, approx_hex_semantic, color_hex, sf_symbol_glyph, size_bucket, weight_token, +}; + +pub(super) fn text(t: &TextElement) -> Value { + let TextElement { + content, + font_size, + font_weight, + color, + alignment, + line_limit, + .. + } = t; + let wrap = !matches!(line_limit, Some(n) if *n <= 1); + let mut obj = json!({ + "type": "TextBlock", + "text": content, + "wrap": wrap, + "size": size_bucket(*font_size), + "weight": weight_token(font_weight.as_ref()), + }); + if let Some(c) = ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) { + obj["color"] = json!(c); + } + // Preserve exact hex for composite (muted slates stay slate, not AC "Light"). + if let Some(hex) = color_hex(color.as_ref()) { + obj["id"] = json!(format!("fg:{hex}")); + } + if let Some(a) = align(alignment.as_ref()) { + obj["horizontalAlignment"] = json!(a); + } + obj +} + +pub(super) fn date(d: &DateElement) -> Value { + let DateElement { date, .. } = d; + json!({ + "type": "TextBlock", + "text": date, + "wrap": true, + "size": "Default", + }) +} + +pub(super) fn timer(t: &TimerElement) -> Value { + let TimerElement { target_date, .. } = t; + json!({ + "type": "TextBlock", + "text": target_date, + "wrap": true, + "size": "Default", + }) +} + +pub(super) fn label(l: &LabelElement) -> Value { + let LabelElement { + text, + system_name, + color, + .. + } = l; + let prefix = if system_name.is_empty() { + String::new() + } else { + format!("{} ", sf_symbol_glyph(system_name)) + }; + let mut obj = json!({ + "type": "TextBlock", + "text": format!("{prefix}{text}"), + "wrap": true, + "weight": "Bolder", + "size": "Small", + }); + if let Some(c) = ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref())) { + obj["color"] = json!(c); + } else { + obj["color"] = json!("Light"); + } + obj +} diff --git a/src/apply.rs b/src/apply.rs index 91aa996..6ddbbea 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -13,6 +13,13 @@ pub enum SkipReason { /// Hash of the unchanged config bytes. hash: u64, }, + /// No live widget instances to deliver to. + NoInstances, + /// Selected transport is unavailable. + TransportUnavailable { + /// Transport name that was unavailable. + name: String, + }, } /// Result of a WidgetKit / AppWidget reload attempt. diff --git a/src/bin/gen_native.rs b/src/bin/gen_native.rs new file mode 100644 index 0000000..2e6d493 --- /dev/null +++ b/src/bin/gen_native.rs @@ -0,0 +1,20 @@ +//! Emit Swift / Kotlin wire catalogs from the Rust IR SoT. + +use std::env; +use std::fs; +use std::path::PathBuf; +use tauri_plugin_widgets::codegen::{emit_wire_catalog_kotlin, emit_wire_catalog_swift}; + +fn main() { + let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + let swift_out = root.join("swift/Sources/TauriWidgets/Generated/WireCatalog.swift"); + fs::create_dir_all(swift_out.parent().unwrap()).unwrap(); + fs::write(&swift_out, emit_wire_catalog_swift()).expect("write WireCatalog.swift"); + eprintln!("wrote {}", swift_out.display()); + + let kt_out = root.join("android/src/main/java/render/WireTypes.kt"); + fs::create_dir_all(kt_out.parent().unwrap()).unwrap(); + fs::write(&kt_out, emit_wire_catalog_kotlin()).expect("write WireTypes.kt"); + eprintln!("wrote {}", kt_out.display()); +} diff --git a/src/bin/gen_schema.rs b/src/bin/gen_schema.rs index 6f77496..e71ef0f 100644 --- a/src/bin/gen_schema.rs +++ b/src/bin/gen_schema.rs @@ -1,17 +1,44 @@ -//! Write `schemas/widget-config.v1.json` from Rust models (schemars). +//! Write widget + plugin JSON Schemas from Rust models (schemars). use schemars::schema_for; use std::env; use std::fs; use std::path::PathBuf; +use tauri_plugin_widgets::config::WidgetsPluginConfig; use tauri_plugin_widgets::models::WidgetConfig; fn main() { - let schema = schema_for!(WidgetConfig); - let json = serde_json::to_string_pretty(&schema).expect("serialize schema"); let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let out = root.join("schemas/widget-config.v1.json"); - fs::create_dir_all(out.parent().unwrap()).unwrap(); - fs::write(&out, format!("{json}\n")).expect("write schema"); - eprintln!("wrote {}", out.display()); + let schemas = root.join("schemas"); + fs::create_dir_all(&schemas).unwrap(); + + let widget = schema_for!(WidgetConfig); + let widget_path = schemas.join("widget-config.v1.json"); + fs::write( + &widget_path, + format!( + "{}\n", + serde_json::to_string_pretty(&widget).expect("serialize widget schema") + ), + ) + .expect("write widget schema"); + eprintln!("wrote {}", widget_path.display()); + + let plugin = schema_for!(WidgetsPluginConfig); + let plugin_path = schemas.join("plugin-config.v1.json"); + fs::write( + &plugin_path, + format!( + "{}\n", + serde_json::to_string_pretty(&plugin).expect("serialize plugin schema") + ), + ) + .expect("write plugin schema"); + eprintln!("wrote {}", plugin_path.display()); + + // Keep capabilities.json in sync with the matrix (same SoT as markdown). + let caps = tauri_plugin_widgets::capabilities::render_capabilities_json(); + let caps_path = schemas.join("capabilities.json"); + fs::write(&caps_path, caps).expect("write capabilities.json"); + eprintln!("wrote {}", caps_path.display()); } diff --git a/src/capabilities.rs b/src/capabilities.rs index 8df43f6..ac00fd4 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -184,7 +184,7 @@ pub fn capability_table() -> Vec { "zstack", Windows, Degraded, - "flattened Container, no overlay", + "rasterized PNG overlay when possible; else flattened Container", )); out.extend_from_slice(&apple_full("shape")); @@ -197,28 +197,28 @@ pub fn capability_table() -> Vec { out.push(cell("gauge", Desktop, Full, "")); out.push(cell("gauge", Windows, Degraded, "rasterized PNG")); - // image: systemName / url differ + // image (url/data): Full everywhere — systemName fidelity lives in feature row. out.extend_from_slice(&apple_full("image")); out.push(cell( "image", Android, - Degraded, - "systemName via glyph map; url via localPath preprocess", + Full, + "url via localPath preprocess; see image.systemName", )); out.push(cell("image", Desktop, Full, "")); out.push(cell( "image", Windows, - Degraded, - "url/data URI; systemName unsupported", + Full, + "url/data URI; see image.systemName", )); out.extend_from_slice(&apple_full("chart")); out.push(cell( "chart", Android, - Degraded, - "simplified bar/line rendering", + Full, + "bitmap bar/line/area/pie", )); out.push(cell("chart", Desktop, Full, "SVG")); out.push(cell("chart", Windows, Degraded, "rasterized PNG")); @@ -228,24 +228,24 @@ pub fn capability_table() -> Vec { "list", Android, Full, - "Glance LazyColumn; depth/children limited", + "Column chunking; soft cap ~50 items", )); out.push(cell("list", Desktop, Full, "")); - out.push(cell("list", Windows, Degraded, "flattened TextBlocks")); + out.push(cell("list", Windows, Degraded, "Adaptive Cards Table")); out.extend_from_slice(&apple_full("timer")); out.push(cell( "timer", Android, - Degraded, - "static snapshot, not live Chronometer in all hosts", + Full, + "Chronometer via AndroidRemoteViews", )); out.push(cell("timer", Desktop, Full, "setInterval")); out.push(cell( "timer", Windows, Degraded, - "static TextBlock of targetDate", + "provider minute push + static TextBlock", )); out.extend_from_slice(&apple_full("canvas")); @@ -253,7 +253,7 @@ pub fn capability_table() -> Vec { "canvas", Android, Degraded, - "bitmap canvas; path support limited", + "bitmap canvas (full SVG path via PathParser)", )); out.push(cell("canvas", Desktop, Full, "SVG")); out.push(cell("canvas", Windows, Degraded, "rasterized PNG")); @@ -262,14 +262,14 @@ pub fn capability_table() -> Vec { out.push(cell( "image.url", Ios, - Unsupported, - "prefetch into shared container not wired", + Full, + "host prefetch to data URI on setWidgetConfig", )); out.push(cell( "image.url", Macos, - Unsupported, - "prefetch into shared container not wired", + Full, + "host prefetch to data URI on setWidgetConfig", )); out.push(cell( "image.url", @@ -286,38 +286,38 @@ pub fn capability_table() -> Vec { "image.systemName", Android, Degraded, - "glyph / drawable name map", + "SF→Material / emoji map (not SF Symbols)", )); out.push(cell( "image.systemName", Desktop, Degraded, - "placeholder glyph", + "SF→Material / emoji map (not SF Symbols)", )); out.push(cell( "image.systemName", Windows, - Unsupported, - "no SF Symbols on Adaptive Cards", + Degraded, + "emoji TextBlock by default; glyph PNG Image with feature rasterize", )); out.push(cell( "background.gradient", Ios, - Degraded, - "linear primary; radial/angular limited", + Full, + "linear/radial/angular SwiftUI", )); out.push(cell( "background.gradient", Macos, - Degraded, - "linear primary; radial/angular limited", + Full, + "linear/radial/angular SwiftUI", )); out.push(cell( "background.gradient", Android, - Degraded, - "first color stop only (Glance)", + Full, + "baked bitmap at LocalSize / frame", )); out.push(cell( "background.gradient", @@ -328,17 +328,17 @@ pub fn capability_table() -> Vec { out.push(cell( "background.gradient", Windows, - Unsupported, - "Container style=emphasis only", + Degraded, + "rasterized PNG backgroundImage when rasterize enabled", )); - out.push(cell("canvas.path", Ios, Degraded, "M/L/H/V/Z subset")); - out.push(cell("canvas.path", Macos, Degraded, "M/L/H/V/Z subset")); + out.push(cell("canvas.path", Ios, Full, "SVG path grammar")); + out.push(cell("canvas.path", Macos, Full, "SVG path grammar")); out.push(cell( "canvas.path", Android, - Degraded, - "limited path commands", + Full, + "PathParser full SVG path", )); out.push(cell("canvas.path", Desktop, Full, "SVG path")); out.push(cell("canvas.path", Windows, Degraded, "rasterized via SVG")); @@ -348,11 +348,16 @@ pub fn capability_table() -> Vec { out.push(cell( "timer.live", Android, - Unsupported, - "no live timer in Glance snapshot", + Full, + "Chronometer via AndroidRemoteViews", )); out.push(cell("timer.live", Desktop, Full, "JS interval")); - out.push(cell("timer.live", Windows, Unsupported, "static only")); + out.push(cell( + "timer.live", + Windows, + Degraded, + "provider pushes UpdateWidget ~1/min", + )); out } @@ -539,80 +544,385 @@ pub fn log_capabilities(config: &WidgetConfig) { } } -/// Render markdown capability matrix (elements only, not feature keys). +/// Machine-readable capability matrix for CLI `validate` and docs tooling. +pub fn render_capabilities_json() -> String { + use serde_json::{json, Map, Value}; + + let mut elements = Map::new(); + for el in ELEMENT_TYPES { + let mut platforms = Map::new(); + for p in WidgetPlatform::all() { + let e = support_for(el, p); + platforms.insert( + p.as_str().to_string(), + json!({ + "support": e.support.as_str(), + "note": e.note, + }), + ); + } + elements.insert((*el).to_string(), Value::Object(platforms)); + } + + let mut features = Map::new(); + for feat in FEATURE_KEYS { + let mut platforms = Map::new(); + for p in WidgetPlatform::all() { + let e = support_for(feat, p); + platforms.insert( + p.as_str().to_string(), + json!({ + "support": e.support.as_str(), + "note": e.note, + }), + ); + } + features.insert((*feat).to_string(), Value::Object(platforms)); + } + + let doc = json!({ + "version": 1, + "platforms": WidgetPlatform::all().map(|p| p.as_str()), + "core": CORE_ELEMENTS, + "extended": EXTENDED_ELEMENTS, + "elements": elements, + "features": features, + }); + format!( + "{}\n", + serde_json::to_string_pretty(&doc).expect("serialize capabilities.json") + ) +} + +/// Walk `tests/cases` + `tests/golden/{platform}` and count how many goldens +/// cover each element type on each platform. Used to keep the hand matrix honest. +pub fn measure_element_coverage( + root: &std::path::Path, +) -> std::collections::BTreeMap> { + use serde_json::Value; + use std::collections::{BTreeMap, BTreeSet}; + + let cases_dir = root.join("tests/cases"); + let golden_root = root.join("tests/golden"); + let fixtures_root = root.join("tests/fixtures"); + + fn walk_types(node: &Value, out: &mut BTreeSet) { + match node { + Value::Object(map) => { + if let Some(Value::String(t)) = map.get("type") { + out.insert(t.clone()); + } + for v in map.values() { + walk_types(v, out); + } + } + Value::Array(arr) => { + for v in arr { + walk_types(v, out); + } + } + _ => {} + } + } + + let mut out: BTreeMap> = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(&cases_dir) else { + return out; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let case_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + let Ok(raw) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(case): Result = serde_json::from_str(&raw) else { + continue; + }; + let fixture = case + .get("fixture") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if fixture.is_empty() { + continue; + } + let fixture_path = { + let with_json = fixtures_root.join(format!("{fixture}.json")); + if with_json.exists() { + with_json + } else { + fixtures_root.join(fixture) + } + }; + let Ok(fx_raw) = std::fs::read_to_string(&fixture_path) else { + continue; + }; + let Ok(fx): Result = serde_json::from_str(&fx_raw) else { + continue; + }; + let mut types = BTreeSet::new(); + walk_types(&fx, &mut types); + + let platforms: Vec = if let Some(Value::Array(arr)) = case.get("platforms") { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + } else { + vec![ + "desktop".into(), + "ios".into(), + "macos".into(), + "android".into(), + "linux".into(), + ] + }; + + for platform in platforms { + let png = golden_root.join(&platform).join(format!("{case_name}.png")); + if !png.exists() { + continue; + } + for ty in &types { + *out + .entry(ty.clone()) + .or_default() + .entry(platform.clone()) + .or_default() += 1; + } + } + } + out +} + +/// Measured golden coverage artifact (`schemas/capabilities.coverage.json`). +pub fn render_capabilities_coverage_json() -> String { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let measured = measure_element_coverage(root); + let mut elements = serde_json::Map::new(); + for el in ELEMENT_TYPES { + let mut platforms = serde_json::Map::new(); + let row = measured.get(*el); + for p in ["desktop", "ios", "macos", "android", "linux", "windows"] { + let n = row.and_then(|m| m.get(p)).copied().unwrap_or(0); + platforms.insert(p.to_string(), serde_json::json!(n)); + } + elements.insert((*el).to_string(), serde_json::Value::Object(platforms)); + } + let doc = serde_json::json!({ + "version": 1, + "source": "tests/cases + tests/golden", + "note": "Counts of golden PNGs whose fixture contains each element type. Hand matrix remains schemas/capabilities.json.", + "elements": elements, + }); + format!( + "{}\n", + serde_json::to_string_pretty(&doc).expect("serialize coverage") + ) +} + +/// Render markdown capability matrix for docs (`###` headings — embed under +/// a single `## Capability matrix` on the tiers page). pub fn render_capability_matrix_md() -> String { + let mut notes: Vec = Vec::new(); let mut md = String::from( "# Capability matrix (element × platform)\n\n\ - Generated from `tauri_plugin_widgets::capabilities`. Do not edit by hand.\n\n\ - ## Core (strict snapshot contract)\n\n\ - | Element | iOS | macOS | Android | Desktop | Windows |\n\ - |---------|-----|-------|---------|---------|----------|\n", + Table is authored in `src/capabilities.rs`; this page embeds it automatically.\n\n", ); + md.push_str("### Element lists\n\n"); + md.push_str(&format!( + "**Core** (`CORE_ELEMENTS` in `src/snapshot.rs`): {}\n\n", + linked_element_list(CORE_ELEMENTS) + )); + md.push_str(&format!( + "**Extended** (`EXTENDED_ELEMENTS`): {}\n\n", + linked_element_list(EXTENDED_ELEMENTS) + )); + + md.push_str("### Core\n\n"); + md.push_str(&platform_table_header("Element")); for el in CORE_ELEMENTS { - let ios = support_for(el, WidgetPlatform::Ios); - let mac = support_for(el, WidgetPlatform::Macos); - let and = support_for(el, WidgetPlatform::Android); - let desk = support_for(el, WidgetPlatform::Desktop); - let win = support_for(el, WidgetPlatform::Windows); - md.push_str(&format!( - "| `{el}` | {} | {} | {} | {} | {} |\n", - cell_md(&ios), - cell_md(&mac), - cell_md(&and), - cell_md(&desk), - cell_md(&win), - )); + md.push_str(&element_row(el, &mut notes)); } - md.push_str( - "\n## Extended (best-effort, platform-dependent)\n\n\ - | Element | iOS | macOS | Android | Desktop | Windows |\n\ - |---------|-----|-------|---------|---------|----------|\n", - ); + md.push_str("\n### Extended\n\n"); + md.push_str(&platform_table_header("Element")); for el in EXTENDED_ELEMENTS { - let ios = support_for(el, WidgetPlatform::Ios); - let mac = support_for(el, WidgetPlatform::Macos); - let and = support_for(el, WidgetPlatform::Android); - let desk = support_for(el, WidgetPlatform::Desktop); - let win = support_for(el, WidgetPlatform::Windows); - md.push_str(&format!( - "| `{el}` | {} | {} | {} | {} | {} |\n", - cell_md(&ios), - cell_md(&mac), - cell_md(&and), - cell_md(&desk), - cell_md(&win), - )); + md.push_str(&element_row(el, &mut notes)); } - md.push_str("\n## Feature notes\n\n"); - md.push_str("| Feature | iOS | macOS | Android | Desktop | Windows |\n"); - md.push_str("|---------|-----|-------|---------|---------|----------|\n"); + md.push_str("\n### Feature notes\n\n"); + md.push_str(&platform_table_header("Feature")); for feat in FEATURE_KEYS { - let ios = support_for(feat, WidgetPlatform::Ios); - let mac = support_for(feat, WidgetPlatform::Macos); - let and = support_for(feat, WidgetPlatform::Android); - let desk = support_for(feat, WidgetPlatform::Desktop); - let win = support_for(feat, WidgetPlatform::Windows); - md.push_str(&format!( - "| `{feat}` | {} | {} | {} | {} | {} |\n", - cell_md(&ios), - cell_md(&mac), - cell_md(&and), - cell_md(&desk), - cell_md(&win), - )); + md.push_str(&feature_row(feat, &mut notes)); + } + + md.push_str("\n### Choosing a surface set\n\n"); + md.push_str( + "Pick the platforms you ship, then stay in the **full** set for that profile. \ + Degraded cells still render, but check the [Notes](#notes) and element pages.\n\n", + ); + md.push_str(&profile_block( + "Apple only", + "iOS + macOS", + &[WidgetPlatform::Ios, WidgetPlatform::Macos], + )); + md.push_str(&profile_block( + "Apple + Android", + "iOS + macOS + Android", + &[ + WidgetPlatform::Ios, + WidgetPlatform::Macos, + WidgetPlatform::Android, + ], + )); + md.push_str(&profile_block( + "All five matrix columns", + "iOS + macOS + Android + Desktop + Windows", + &WidgetPlatform::all(), + )); + + if !notes.is_empty() { + md.push_str("\n### Notes {#notes}\n\n"); + for (i, note) in notes.iter().enumerate() { + md.push_str(&format!("{}. {}\n", i + 1, note)); + } } md } -fn cell_md(e: &CapabilityEntry) -> String { +fn platform_table_header(first: &str) -> String { + format!( + "| {first} | iOS | macOS | Android | Desktop | Windows |\n\ + |---------|-----|-------|---------|---------|----------|\n" + ) +} + +fn linked_element_list(els: &[&str]) -> String { + els.iter() + .map(|e| format!("[`{e}`]({})", element_doc_href(e))) + .collect::>() + .join(", ") +} + +fn element_row(el: &str, notes: &mut Vec) -> String { + format!( + "| [`{el}`]({}) | {} | {} | {} | {} | {} |\n", + element_doc_href(el), + cell_md(&support_for(el, WidgetPlatform::Ios), notes), + cell_md(&support_for(el, WidgetPlatform::Macos), notes), + cell_md(&support_for(el, WidgetPlatform::Android), notes), + cell_md(&support_for(el, WidgetPlatform::Desktop), notes), + cell_md(&support_for(el, WidgetPlatform::Windows), notes), + ) +} + +fn feature_row(feat: &str, notes: &mut Vec) -> String { + format!( + "| [`{feat}`]({}) | {} | {} | {} | {} | {} |\n", + element_doc_href(feat), + cell_md(&support_for(feat, WidgetPlatform::Ios), notes), + cell_md(&support_for(feat, WidgetPlatform::Macos), notes), + cell_md(&support_for(feat, WidgetPlatform::Android), notes), + cell_md(&support_for(feat, WidgetPlatform::Desktop), notes), + cell_md(&support_for(feat, WidgetPlatform::Windows), notes), + ) +} + +fn profile_block(title: &str, platforms_label: &str, platforms: &[WidgetPlatform]) -> String { + let (full_core, watch_core) = partition_by_full(CORE_ELEMENTS, platforms); + let (full_ext, watch_ext) = partition_by_full(EXTENDED_ELEMENTS, platforms); + let mut out = format!("#### {title}\n\nPlatforms: **{platforms_label}**.\n\n"); + out.push_str(&format!( + "- **Full core:** {}\n", + if full_core.is_empty() { + "_none_".into() + } else { + linked_element_list(&full_core) + } + )); + if !watch_core.is_empty() { + out.push_str(&format!( + "- **Core with degraded/unsupported cells:** {}\n", + linked_element_list(&watch_core) + )); + } + out.push_str(&format!( + "- **Full extended:** {}\n", + if full_ext.is_empty() { + "_none_".into() + } else { + linked_element_list(&full_ext) + } + )); + if !watch_ext.is_empty() { + out.push_str(&format!( + "- **Extended with degraded/unsupported cells:** {}\n", + linked_element_list(&watch_ext) + )); + } + out.push('\n'); + out +} + +fn partition_by_full<'a>( + els: &[&'a str], + platforms: &[WidgetPlatform], +) -> (Vec<&'a str>, Vec<&'a str>) { + let mut full = Vec::new(); + let mut watch = Vec::new(); + for el in els { + let all_full = platforms.iter().all(|p| { + support_for(el, *p).support == Support::Full + }); + if all_full { + full.push(*el); + } else { + watch.push(*el); + } + } + (full, watch) +} + +/// Docs path for an element or feature key (`image.url` → image page). +fn element_doc_href(key: &str) -> String { + let base = key.split('.').next().unwrap_or(key); + if key == "background.gradient" { + return "/elements/style".into(); + } + let page = match base { + "vstack" | "hstack" | "zstack" | "grid" | "container" => "layout", + "text" | "label" | "date" | "timer" => "text", + "image" | "shape" | "canvas" => "media", + "progress" | "gauge" | "chart" | "list" => "data", + "button" | "toggle" | "link" => "interactive", + "spacer" | "divider" => "spacing", + _ => return "/elements/".into(), + }; + format!("/elements/{page}#el-{base}") +} + +fn cell_md(e: &CapabilityEntry, notes: &mut Vec) -> String { if e.note.is_empty() { - e.support.as_str().to_string() - } else { - format!("{} ({})", e.support.as_str(), e.note) + return e.support.as_str().to_string(); } + let idx = note_index(notes, e.note); + format!("{}{}", e.support.as_str(), idx) +} + +fn note_index(notes: &mut Vec, note: &str) -> usize { + if let Some(i) = notes.iter().position(|n| n == note) { + return i + 1; + } + notes.push(note.to_string()); + notes.len() } #[cfg(test)] @@ -634,7 +944,7 @@ mod tests { } #[test] - fn validate_flags_image_url_on_ios() { + fn validate_flags_image_url_on_ios_is_full_after_prefetch() { let cfg = WidgetConfig { version: 1, small: Some(WidgetElement::Image(ImageElement { @@ -651,11 +961,16 @@ mod tests { }; let warns = validate_config(&cfg, WidgetPlatform::Ios); assert!( - warns + !warns .iter() .any(|w| w.element == "image.url" && w.support == Support::Unsupported), "{warns:?}" ); + // Full cells do not emit warnings. + assert!( + !warns.iter().any(|w| w.element == "image.url"), + "{warns:?}" + ); } #[test] @@ -663,6 +978,11 @@ mod tests { let md = render_capability_matrix_md(); assert!(md.contains("`vstack`")); assert!(md.contains("image.url")); + assert!(md.contains("### Core\n")); + assert!(!md.contains("## Core ")); + assert!(md.contains("/elements/layout#el-vstack")); + assert!(md.contains("### Choosing a surface set")); + assert!(md.contains("")); } #[test] @@ -701,4 +1021,81 @@ mod write_docs { or delete the file and re-run this test" ); } -} + + #[test] + fn capabilities_json_matches() { + let expected = super::render_capabilities_json(); + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/capabilities.json"); + if !path.exists() { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &expected).unwrap(); + return; + } + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + on_disk, expected, + "schemas/capabilities.json drifted — delete it and re-run this test, or:\n\ + cargo test --lib capabilities::write_docs::capabilities_json_matches" + ); + } + + #[test] + fn capabilities_coverage_json_matches() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + // Packaged crates.io tree omits PNG goldens — skip measured drift there. + if !root.join("tests/golden/desktop").is_dir() { + return; + } + let expected = super::render_capabilities_coverage_json(); + let path = root.join("schemas/capabilities.coverage.json"); + if !path.exists() { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &expected).unwrap(); + return; + } + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + on_disk, expected, + "schemas/capabilities.coverage.json drifted — delete it and re-run this test" + ); + } + + #[test] + fn full_cells_have_golden_coverage() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + if !root.join("tests/golden/desktop").is_dir() { + return; + } + let measured = super::measure_element_coverage(root); + // Mature golden stands. Windows is manual / sparse — excluded. + let checks: &[(&str, super::WidgetPlatform)] = &[ + ("desktop", super::WidgetPlatform::Desktop), + ("ios", super::WidgetPlatform::Ios), + ("macos", super::WidgetPlatform::Macos), + ("android", super::WidgetPlatform::Android), + ("linux", super::WidgetPlatform::Desktop), + ]; + let mut missing = Vec::new(); + for el in super::CORE_ELEMENTS { + for (plat_key, platform) in checks { + let entry = super::support_for(el, *platform); + if entry.support != super::Support::Full { + continue; + } + let covered = measured + .get(*el) + .and_then(|m| m.get(*plat_key)) + .copied() + .unwrap_or(0); + if covered == 0 { + missing.push(format!("{el}@{plat_key}")); + } + } + } + assert!( + missing.is_empty(), + "core elements marked full lack golden coverage: {missing:?}" + ); + } +} \ No newline at end of file diff --git a/src/codegen.rs b/src/codegen.rs index 3f593f5..5a6c79e 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -1,94 +1,17 @@ -//! Emit TypeScript IR types from an explicit Rust IR_SPEC (SoT with models.rs). +//! Emit TypeScript IR types from an explicit Rust `IR_ELEMENTS` spec (SoT with models). //! -//! Not `include_str` of a hand-maintained twin — the emitter builds the file. -//! Exhaustiveness is checked via [`WidgetElement`] match in tests / type_name. +//! Static preamble / enums / supporting / footer live in `src/codegen/*.ts` via +//! `include_str!`. The remaining `push_str` calls are intentional: per-element +//! interfaces and the `WidgetElement` union are generated from `IR_ELEMENTS` +//! so exhaustiveness tests stay coupled to [`crate::models::WidgetElement`]. /// Emit `guest-js/generated/widget-types.ts`. pub fn emit_widget_types_ts() -> String { let mut out = String::new(); - out.push_str( - "/**\n\ - * Generated widget IR types — do not edit by hand.\n\ - * Source of truth: Rust `src/models.rs` via `cargo run --bin gen-ts --features codegen`.\n\ - * Emitter: `src/codegen.rs` IR_SPEC.\n\ - */\n\n", - ); - - out.push_str( - "// ── Enums ──\n\n\ - export type FontWeight = \"ultralight\" | \"thin\" | \"light\" | \"regular\" | \"medium\" | \"semibold\" | \"bold\" | \"heavy\" | \"black\";\n\ - export type FontDesign = \"default\" | \"monospaced\" | \"rounded\" | \"serif\";\n\ - export type TextAlignment = \"leading\" | \"center\" | \"trailing\";\n\ - export type HorizontalAlignment = \"leading\" | \"center\" | \"trailing\";\n\ - export type VerticalAlignment = \"top\" | \"center\" | \"bottom\";\n\ - export type ContentMode = \"fit\" | \"fill\";\n\ - export type ProgressStyle = \"linear\" | \"circular\";\n\ - export type GaugeStyle = \"circular\" | \"linear\";\n\ - export type DateStyle = \"time\" | \"date\" | \"relative\" | \"offset\" | \"timer\";\n\ - export type ChartType = \"bar\" | \"line\" | \"area\" | \"pie\";\n\ - export type ShapeType = \"circle\" | \"capsule\" | \"rectangle\";\n\ - export type TimerCounting = \"up\" | \"down\";\n\ - export type ClipShape = \"circle\" | \"capsule\" | \"rectangle\";\n\ - export type TextStyle =\n\ - | \"largeTitle\" | \"title\" | \"title2\" | \"title3\"\n\ - | \"headline\" | \"subheadline\"\n\ - | \"body\" | \"callout\"\n\ - | \"footnote\" | \"caption\" | \"caption2\";\n\ - export type GradientType = \"linear\" | \"radial\" | \"angular\";\n\ - export type GradientDirection =\n\ - | \"topToBottom\" | \"bottomToTop\"\n\ - | \"leadingToTrailing\" | \"trailingToLeading\"\n\ - | \"topLeadingToBottomTrailing\" | \"topTrailingToBottomLeading\";\n\n", - ); - - out.push_str( - "// ── Supporting types ──\n\n\ - export type ColorValue = string | { light: string; dark: string };\n\n\ - export interface ChartDataPoint {\n\ - label: string;\n\ - value: number;\n\ - color?: ColorValue;\n\ - }\n\n\ - export interface FrameConfig {\n\ - width?: number;\n\ - height?: number;\n\ - maxWidth?: number | \"infinity\";\n\ - maxHeight?: number | \"infinity\";\n\ - }\n\n\ - export interface BorderConfig {\n\ - color: string;\n\ - width?: number;\n\ - }\n\n\ - export interface GradientConfig {\n\ - gradientType: GradientType;\n\ - colors: string[];\n\ - direction?: GradientDirection;\n\ - }\n\n\ - export interface ShadowConfig {\n\ - color?: string;\n\ - radius?: number;\n\ - x?: number;\n\ - y?: number;\n\ - }\n\n\ - export type BackgroundValue = string | GradientConfig | { light: string; dark: string };\n\n\ - export type PaddingValue = number | {\n\ - top?: number;\n\ - bottom?: number;\n\ - leading?: number;\n\ - trailing?: number;\n\ - };\n\n\ - export interface ElementStyle {\n\ - padding?: PaddingValue;\n\ - background?: BackgroundValue;\n\ - cornerRadius?: number;\n\ - opacity?: number;\n\ - frame?: FrameConfig;\n\ - border?: BorderConfig;\n\ - shadow?: ShadowConfig;\n\ - clipShape?: ClipShape;\n\ - flex?: number;\n\ - }\n\n", - ); + out.push_str(include_str!("codegen/preamble.ts")); + out.push('\n'); + out.push_str(include_str!("codegen/enums.ts")); + out.push_str(include_str!("codegen/supporting.ts")); // Element interfaces — IR_SPEC mapping (must stay in sync with WidgetElement) for spec in IR_ELEMENTS { @@ -102,52 +25,6 @@ pub fn emit_widget_types_ts() -> String { out.push_str("}\n\n"); } - out.push_str( - "export interface SpacerElement {\n\ - type: \"spacer\";\n\ - minLength?: number;\n\ - }\n\n\ - export interface ListItem {\n\ - text: string;\n\ - checked?: boolean;\n\ - action?: string;\n\ - payload?: string;\n\ - }\n\n\ - export interface CanvasCircle {\n\ - draw: \"circle\";\n\ - cx: number; cy: number; r: number;\n\ - fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number;\n\ - }\n\ - export interface CanvasLine {\n\ - draw: \"line\";\n\ - x1: number; y1: number; x2: number; y2: number;\n\ - stroke?: ColorValue; strokeWidth?: number; lineCap?: \"butt\" | \"round\" | \"square\";\n\ - }\n\ - export interface CanvasRect {\n\ - draw: \"rect\";\n\ - x: number; y: number; width: number; height: number;\n\ - fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; cornerRadius?: number;\n\ - }\n\ - export interface CanvasArc {\n\ - draw: \"arc\";\n\ - cx: number; cy: number; r: number;\n\ - startAngle: number; endAngle: number;\n\ - fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number;\n\ - }\n\ - export interface CanvasText {\n\ - draw: \"text\";\n\ - x: number; y: number; content: string;\n\ - fontSize?: number; color?: ColorValue; anchor?: \"start\" | \"middle\" | \"end\";\n\ - }\n\ - export interface CanvasPath {\n\ - draw: \"path\";\n\ - d: string;\n\ - fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number;\n\ - }\n\ - export type CanvasDrawCommand = CanvasCircle | CanvasLine | CanvasRect | CanvasArc | CanvasText | CanvasPath;\n\n", - ); - - // Remaining elements that need custom bodies (list, canvas, etc. already partially in IR_ELEMENTS) out.push_str("export type WidgetElement =\n"); let names: Vec<&str> = IR_ELEMENTS .iter() @@ -160,14 +37,7 @@ pub fn emit_widget_types_ts() -> String { } out.push('\n'); - out.push_str( - "export interface WidgetConfig {\n\ - version?: number;\n\ - small?: WidgetElement;\n\ - medium?: WidgetElement;\n\ - large?: WidgetElement;\n\ - }\n", - ); + out.push_str(include_str!("codegen/footer.ts")); out } @@ -383,6 +253,75 @@ const IR_ELEMENTS: &[ElementSpec] = &[ }, ]; +/// Wire type strings + field hints for Swift (flat Codable stays in Models.swift). +pub fn emit_wire_catalog_swift() -> String { + let mut out = String::from( + "// AUTO-GENERATED by `cargo run --bin gen-native --features codegen`\n\ + // Do not edit. Source: `src/codegen.rs` IR_ELEMENTS.\n\ + // Runtime decode still uses flat `WidgetElement` in Models.swift.\n\n\ + import Foundation\n\n\ + /// Known `type` wire values for widget IR elements.\n\ + public enum WidgetWireType: String, CaseIterable, Sendable {\n", + ); + for spec in IR_ELEMENTS { + let case = swift_case_name(spec.wire); + out.push_str(&format!(" case {case} = \"{}\"\n", spec.wire)); + } + out.push_str(" case spacer = \"spacer\"\n}\n\n"); + out.push_str("/// Per-element field hints (documentation / drift checks; not Codable).\n"); + out.push_str("public enum WidgetWireFields {\n"); + for spec in IR_ELEMENTS { + let case = swift_case_name(spec.wire); + let fields: Vec = spec + .fields + .iter() + .map(|f| format!("\"{}\"", f.replace('\\', "\\\\").replace('"', "\\\""))) + .collect(); + out.push_str(&format!( + " public static let {case}: [String] = [{}]\n", + fields.join(", ") + )); + } + out.push_str(" public static let spacer: [String] = [\"minLength?: number\"]\n"); + out.push_str("}\n"); + out +} + +/// Wire type constants for Android Glance renderer (JSONObject path stays dynamic). +pub fn emit_wire_catalog_kotlin() -> String { + let mut out = String::from( + "// AUTO-GENERATED by `cargo run --bin gen-native --features codegen`\n\ + // Do not edit. Source: `src/codegen.rs` IR_ELEMENTS.\n\ + // Runtime still uses org.json.JSONObject via render.El.\n\n\ + package git.s00d.widgets\n\n\ + /** Known `type` wire values for widget IR elements. */\n\ + object WireTypes {\n", + ); + for spec in IR_ELEMENTS { + let const_name = kotlin_const_name(spec.wire); + out.push_str(&format!( + " const val {const_name} = \"{}\"\n", + spec.wire + )); + } + out.push_str(" const val SPACER = \"spacer\"\n\n"); + out.push_str(" val ALL: Set = setOf(\n"); + for spec in IR_ELEMENTS { + out.push_str(&format!(" {},\n", kotlin_const_name(spec.wire))); + } + out.push_str(" SPACER,\n )\n}\n"); + out +} + +fn swift_case_name(wire: &str) -> String { + // camelCase identifiers from wire names (already lower). + wire.to_string() +} + +fn kotlin_const_name(wire: &str) -> String { + wire.to_uppercase() +} + #[cfg(test)] mod tests { use super::*; @@ -417,6 +356,28 @@ mod tests { assert!(ts.contains("Emitter: `src/codegen.rs` IR_SPEC")); } + #[test] + fn swift_catalog_covers_all_element_types() { + let swift = emit_wire_catalog_swift(); + for ty in ELEMENT_TYPES { + assert!( + swift.contains(&format!("= \"{ty}\"")), + "Swift catalog missing `{ty}`" + ); + } + } + + #[test] + fn kotlin_catalog_covers_all_element_types() { + let kt = emit_wire_catalog_kotlin(); + for ty in ELEMENT_TYPES { + assert!( + kt.contains(&format!("= \"{ty}\"")), + "Kotlin catalog missing `{ty}`" + ); + } + } + #[test] fn type_name_exhaustive() { // Compiling this match fails if a WidgetElement variant is added without update. diff --git a/src/codegen/enums.ts b/src/codegen/enums.ts new file mode 100644 index 0000000..9679f75 --- /dev/null +++ b/src/codegen/enums.ts @@ -0,0 +1,26 @@ +// ── Enums ── + +export type FontWeight = "ultralight" | "thin" | "light" | "regular" | "medium" | "semibold" | "bold" | "heavy" | "black"; +export type FontDesign = "default" | "monospaced" | "rounded" | "serif"; +export type TextAlignment = "leading" | "center" | "trailing"; +export type HorizontalAlignment = "leading" | "center" | "trailing"; +export type VerticalAlignment = "top" | "center" | "bottom"; +export type ContentMode = "fit" | "fill"; +export type ProgressStyle = "linear" | "circular"; +export type GaugeStyle = "circular" | "linear"; +export type DateStyle = "time" | "date" | "relative" | "offset" | "timer"; +export type ChartType = "bar" | "line" | "area" | "pie"; +export type ShapeType = "circle" | "capsule" | "rectangle"; +export type TimerCounting = "up" | "down"; +export type ClipShape = "circle" | "capsule" | "rectangle"; +export type TextStyle = + | "largeTitle" | "title" | "title2" | "title3" + | "headline" | "subheadline" + | "body" | "callout" + | "footnote" | "caption" | "caption2"; +export type GradientType = "linear" | "radial" | "angular"; +export type GradientDirection = + | "topToBottom" | "bottomToTop" + | "leadingToTrailing" | "trailingToLeading" + | "topLeadingToBottomTrailing" | "topTrailingToBottomLeading"; + diff --git a/src/codegen/footer.ts b/src/codegen/footer.ts new file mode 100644 index 0000000..b5e2e23 --- /dev/null +++ b/src/codegen/footer.ts @@ -0,0 +1,51 @@ +export interface SpacerElement { + type: "spacer"; + minLength?: number; +} + +export interface ListItem { + text: string; + checked?: boolean; + action?: string; + payload?: string; +} + +export interface CanvasCircle { + draw: "circle"; + cx: number; cy: number; r: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export interface CanvasLine { + draw: "line"; + x1: number; y1: number; x2: number; y2: number; + stroke?: ColorValue; strokeWidth?: number; lineCap?: "butt" | "round" | "square"; +} +export interface CanvasRect { + draw: "rect"; + x: number; y: number; width: number; height: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; cornerRadius?: number; +} +export interface CanvasArc { + draw: "arc"; + cx: number; cy: number; r: number; + startAngle: number; endAngle: number; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export interface CanvasText { + draw: "text"; + x: number; y: number; content: string; + fontSize?: number; color?: ColorValue; anchor?: "start" | "middle" | "end"; +} +export interface CanvasPath { + draw: "path"; + d: string; + fill?: ColorValue; stroke?: ColorValue; strokeWidth?: number; +} +export type CanvasDrawCommand = CanvasCircle | CanvasLine | CanvasRect | CanvasArc | CanvasText | CanvasPath; + +export interface WidgetConfig { + version?: number; + small?: WidgetElement; + medium?: WidgetElement; + large?: WidgetElement; +} diff --git a/src/codegen/preamble.ts b/src/codegen/preamble.ts new file mode 100644 index 0000000..b461d18 --- /dev/null +++ b/src/codegen/preamble.ts @@ -0,0 +1,6 @@ +/** + * Generated widget IR types — do not edit by hand. + * Source of truth: Rust `src/models` via `cargo run --bin gen-ts --features codegen`. + * Emitter: `src/codegen.rs` IR_SPEC (+ `src/codegen/preamble.ts`). + */ + diff --git a/src/codegen/supporting.ts b/src/codegen/supporting.ts new file mode 100644 index 0000000..6a9db46 --- /dev/null +++ b/src/codegen/supporting.ts @@ -0,0 +1,56 @@ +// ── Supporting types ── + +export type ColorValue = string | { light: string; dark: string }; + +export interface ChartDataPoint { + label: string; + value: number; + color?: ColorValue; +} + +export interface FrameConfig { + width?: number; + height?: number; + maxWidth?: number | "infinity"; + maxHeight?: number | "infinity"; +} + +export interface BorderConfig { + color: string; + width?: number; +} + +export interface GradientConfig { + gradientType: GradientType; + colors: string[]; + direction?: GradientDirection; +} + +export interface ShadowConfig { + color?: string; + radius?: number; + x?: number; + y?: number; +} + +export type BackgroundValue = string | GradientConfig | { light: string; dark: string }; + +export type PaddingValue = number | { + top?: number; + bottom?: number; + leading?: number; + trailing?: number; +}; + +export interface ElementStyle { + padding?: PaddingValue; + background?: BackgroundValue; + cornerRadius?: number; + opacity?: number; + frame?: FrameConfig; + border?: BorderConfig; + shadow?: ShadowConfig; + clipShape?: ClipShape; + flex?: number; +} + diff --git a/src/config.rs b/src/config.rs index 853839a..3322743 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,13 +2,19 @@ use serde::Deserialize; +#[cfg(feature = "schema")] +use schemars::JsonSchema; + /// Which Apple host→widget channel to use. /// /// Pick this at build time (you know your signing). Do not rely on runtime fan-out. -#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase")] pub enum TransportKind { /// App Group shared container file (`widget_data.json`). Needs a real Team ID. + /// Production-safe default; use `auto` explicitly for ad-hoc probing. + #[default] AppGroup, /// App Group `UserDefaults` suite. Same signing requirements as [`AppGroup`]. UserDefaults, @@ -18,13 +24,6 @@ pub enum TransportKind { Auto, } -impl Default for TransportKind { - fn default() -> Self { - // Production-safe default: shared App Group file. Use `auto` explicitly for ad-hoc probing. - Self::AppGroup - } -} - impl TransportKind { /// Parse `WIDGET_TRANSPORT` / conf string. pub fn parse(s: &str) -> Option { @@ -41,6 +40,7 @@ impl TransportKind { } } + /// Wire / conf string for this kind (`appGroup`, `auto`, …). pub fn as_str(self) -> &'static str { match self { Self::AppGroup => "appGroup", @@ -51,9 +51,12 @@ impl TransportKind { } } +/// `plugins.widgets` object from `tauri.conf.json`. #[derive(Debug, Clone, Deserialize, Default)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] #[serde(rename_all = "camelCase")] pub struct WidgetsPluginConfig { + /// Host→widget transport driver. #[serde(default)] pub transport: TransportKind, /// App Group id, e.g. `group.com.example.app`. @@ -74,3 +77,73 @@ pub fn env_transport_override() -> Option { pub fn effective_transport(cfg: &WidgetsPluginConfig) -> TransportKind { env_transport_override().unwrap_or(cfg.transport) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_accepts_common_spellings() { + let cases = [ + ("appGroup", TransportKind::AppGroup), + ("appgroup", TransportKind::AppGroup), + ("app_group", TransportKind::AppGroup), + ("app-group", TransportKind::AppGroup), + (" App-Group ", TransportKind::AppGroup), + ("userDefaults", TransportKind::UserDefaults), + ("user_defaults", TransportKind::UserDefaults), + ("user-defaults", TransportKind::UserDefaults), + ("defaults", TransportKind::UserDefaults), + ("widgetContainer", TransportKind::WidgetContainer), + ("widget_container", TransportKind::WidgetContainer), + ("widget-container", TransportKind::WidgetContainer), + ("container", TransportKind::WidgetContainer), + ("auto", TransportKind::Auto), + ("AUTO", TransportKind::Auto), + ]; + for (raw, want) in cases { + assert_eq!(TransportKind::parse(raw), Some(want), "parse({raw:?})"); + } + } + + #[test] + fn parse_rejects_unknown() { + assert_eq!(TransportKind::parse(""), None); + assert_eq!(TransportKind::parse("app group"), None); + assert_eq!(TransportKind::parse("fanout"), None); + } + + #[test] + fn as_str_roundtrips_through_parse() { + for kind in [ + TransportKind::AppGroup, + TransportKind::UserDefaults, + TransportKind::WidgetContainer, + TransportKind::Auto, + ] { + assert_eq!(TransportKind::parse(kind.as_str()), Some(kind)); + } + } + + #[test] + fn env_overrides_conf() { + let cfg = WidgetsPluginConfig { + transport: TransportKind::AppGroup, + ..Default::default() + }; + // SAFETY: serial tests; we restore afterwards. + let prev = std::env::var_os("WIDGET_TRANSPORT"); + std::env::set_var("WIDGET_TRANSPORT", "widget-container"); + assert_eq!(effective_transport(&cfg), TransportKind::WidgetContainer); + std::env::set_var("WIDGET_TRANSPORT", ""); + assert_eq!( + effective_transport(&cfg), + TransportKind::AppGroup, + "empty env must not override" + ); + match prev { + Some(v) => std::env::set_var("WIDGET_TRANSPORT", v), + None => std::env::remove_var("WIDGET_TRANSPORT"), + } + } +} diff --git a/src/desktop.rs b/src/desktop.rs index 4ea76da..28863bf 100755 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -8,7 +8,7 @@ use tauri::{ plugin::PluginApi, AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, }; -use crate::apply::{config_content_hash, ApplyOutcome, ReloadOutcome}; +use crate::apply::{config_content_hash, ApplyOutcome, ReloadOutcome, SkipReason}; use crate::config::WidgetsPluginConfig; use crate::error::Error; use crate::models::{WidgetConfig, WidgetWindowConfig}; @@ -16,7 +16,7 @@ use crate::receipt::{receipts_path, ReceiptStore, WidgetRenderReceipt}; use crate::store::{ self, config_key, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY, }; -use crate::trace::{trace_path, TraceEvent, TraceSkipReason, TraceStore, WidgetTrace}; +use crate::trace::{trace_path, TraceEvent, TraceStore, WidgetTrace}; #[cfg(target_os = "macos")] use crate::transport::Transport; @@ -219,14 +219,23 @@ impl Widget { }) } - /// Persist map: one Apple driver on macOS / single file elsewhere. + /// Persist map on macOS: merge pending from all channels, clear leftover + /// maps on **sibling** transports only, then write the configured driver. + /// + /// The primary is never wiped before a successful write (failed write must + /// not erase the last good map). Sibling clear re-merges pending on each + /// wipe attempt so a mid-clear tap is not dropped. fn persist_map(&self, group: &str, map: &DataMap) -> crate::Result<()> { #[cfg(target_os = "macos")] { - self.macos_driver.write(map)?; - // Widget still picks freshest across all transports — keep siblings in sync - // so a stale UserDefaults/App Group snapshot cannot outrank this write. - crate::macos_transport::mirror_to_siblings(self.macos_driver.as_ref(), group, map); + let mut out = map.clone(); + crate::macos_transport::merge_pending_into_map(&mut out, group); + crate::macos_transport::clear_sibling_transports( + group, + self.macos_driver.name(), + &mut out, + ); + self.macos_driver.write(&out)?; } #[cfg(target_os = "windows")] { @@ -452,7 +461,14 @@ impl Widget { } self.remember_group(group); - let json = serde_json::to_string(config) + let mut config = crate::normalize::normalize( + config, + crate::capabilities::WidgetPlatform::current(), + ) + .config; + crate::image_prefetch::prefetch_remote_images(&mut config); + + let json = serde_json::to_string(&config) .map_err(|e| Error::new(format!("serialize config: {e}")))?; let compact: serde_json::Value = serde_json::from_str(&json) .map_err(|e| Error::new(format!("serialize config: {e}")))?; @@ -480,7 +496,7 @@ impl Widget { nonce: 0, bytes: json.len(), changed: false, - skip: Some(TraceSkipReason::Unchanged { hash }), + skip: Some(SkipReason::Unchanged { hash }), }); self.trace.push(TraceEvent::Reload { performed: false, @@ -490,7 +506,7 @@ impl Widget { return Ok(outcome); } - crate::capabilities::log_capabilities(config); + crate::capabilities::log_capabilities(&config); let t0 = std::time::Instant::now(); self.set_items(&key, &json, group)?; let write_ms = t0.elapsed().as_millis() as u32; @@ -523,7 +539,7 @@ impl Widget { // Widgets Board provider reads Adaptive Card blobs from the same store. // Desktop webview (widget.html) remains the fallback outside Widget Board. if let Some(result) = - crate::adaptive_card::to_adaptive_card_for_size(config, "medium") + crate::adaptive_card::to_adaptive_card_for_size(&config, "medium") { let template = serde_json::to_string(&result.card) .map_err(|e| Error::new(format!("serialize adaptive card: {e}")))?; @@ -570,13 +586,8 @@ impl Widget { fn written_transport_names(&self, group: &str) -> Vec { #[cfg(target_os = "macos")] { - let mut names = vec![self.macos_driver.name().to_string()]; - for t in crate::macos_transport::all_transports(group) { - if t.name() != self.macos_driver.name() && t.available() { - names.push(t.name().to_string()); - } - } - names + let _ = group; + vec![self.macos_driver.name().to_string()] } #[cfg(not(target_os = "macos"))] { @@ -663,7 +674,18 @@ impl Widget { *map = freshest; } + #[cfg(target_os = "macos")] + let actions = { + let harvested = crate::macos_transport::harvest_pending_actions(group); + if !harvested.is_empty() { + harvested + } else { + parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())) + } + }; + #[cfg(not(target_os = "macos"))] let actions = parse_pending_actions(map.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + if actions.is_empty() { return Ok(Vec::new()); } @@ -682,6 +704,10 @@ impl Widget { drop(store); self.persist_map(group, &snapshot)?; + #[cfg(target_os = "macos")] + { + crate::macos_transport::clear_pending_actions_everywhere(group, &actions); + } Ok(actions) } diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..ce25602 --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,223 @@ +//! SF Symbol → cross-platform icon resolve (Material ligature + emoji fallback). +//! +//! Not SF Symbols outside Apple — similar glyphs only. Unknown names never fall +//! back to a random first letter (that looked broken); use a neutral bullet. + +use std::sync::OnceLock; + +/// Resolved icon for non-Apple hosts. +#[derive(Debug, Clone, Copy)] +pub struct IconRef { + /// Material Symbols ligature / icon name (e.g. `favorite`). + pub material: &'static str, + /// Emoji / unicode glyph fallback when a font is unavailable. + pub emoji: &'static str, +} + +/// Resolve an SF Symbol-ish name to a Material + emoji pair. +pub fn resolve_sf_symbol(name: &str) -> IconRef { + let n = normalize(name); + if let Some(r) = table().get(n.as_str()).copied() { + return r; + } + // Heuristic: cloud.sun.fill → cloud_sun / favorite-style material names. + let material = heuristic_material(&n); + IconRef { + material: intern_material(material), + emoji: "•", + } +} + +/// Emoji/glyph used by Adaptive Cards TextBlock and Desktop when no PNG bake. +pub fn sf_symbol_emoji(name: &str) -> &'static str { + resolve_sf_symbol(name).emoji +} + +/// Material Symbols ligature name. +pub fn sf_symbol_material(name: &str) -> &'static str { + resolve_sf_symbol(name).material +} + +fn normalize(name: &str) -> String { + name.trim().to_ascii_lowercase() +} + +fn heuristic_material(n: &str) -> String { + let base = n + .trim_end_matches(".fill") + .trim_end_matches(".circle") + .trim_end_matches(".square") + .replace(['.', '-'], "_"); + if base.is_empty() { + "circle".into() + } else { + base + } +} + +fn intern_material(s: String) -> &'static str { + // Leak rare heuristic strings so IconRef stays 'static (widgets are long-lived). + Box::leak(s.into_boxed_str()) +} + +static LOOKUP: OnceLock> = OnceLock::new(); + +fn table() -> &'static std::collections::HashMap<&'static str, IconRef> { + LOOKUP.get_or_init(|| { + let mut m = std::collections::HashMap::new(); + for (sf, material, emoji) in ENTRIES { + m.insert(*sf, IconRef { material, emoji }); + } + m + }) +} + +/// Curated SF → Material Symbols + emoji. Expand as presets need. +const ENTRIES: &[(&str, &str, &str)] = &[ + ("star", "star", "⭐"), + ("star.fill", "star", "⭐"), + ("heart", "favorite", "❤"), + ("heart.fill", "favorite", "❤"), + ("person", "person", "👤"), + ("person.fill", "person", "👤"), + ("person.2.fill", "group", "👥"), + ("person.badge.plus", "person_add", "👤"), + ("gear", "settings", "⚙"), + ("gearshape", "settings", "⚙"), + ("gearshape.fill", "settings", "⚙"), + ("checkmark", "check", "✓"), + ("checkmark.circle", "check_circle", "✓"), + ("checkmark.circle.fill", "check_circle", "✓"), + ("xmark", "close", "✕"), + ("xmark.circle", "cancel", "✕"), + ("plus", "add", "+"), + ("plus.circle", "add_circle", "+"), + ("minus", "remove", "−"), + ("minus.circle", "do_not_disturb_on", "−"), + ("bell", "notifications", "🔔"), + ("bell.fill", "notifications", "🔔"), + ("house", "home", "⌂"), + ("house.fill", "home", "⌂"), + ("magnifyingglass", "search", "🔍"), + ("calendar", "calendar_today", "📅"), + ("calendar.badge.clock", "event", "📅"), + ("calendar.circle", "calendar_today", "📅"), + ("clock", "schedule", "⏱"), + ("clock.fill", "schedule", "⏱"), + ("globe", "public", "🌐"), + ("cloud.fill", "cloud", "☁"), + ("cloud.sun.fill", "partly_cloudy_day", "⛅"), + ("cloud.rain.fill", "rainy", "☔"), + ("cloud.bolt.fill", "thunderstorm", "⚡"), + ("sun.max", "wb_sunny", "☀"), + ("sun.max.fill", "wb_sunny", "☀"), + ("moon.fill", "dark_mode", "☾"), + ("moon.stars.fill", "clear_night", "☾"), + ("bolt", "bolt", "⚡"), + ("bolt.fill", "bolt", "⚡"), + ("location.fill", "location_on", "⌂"), + ("flame.fill", "local_fire_department", "🔥"), + ("drop.fill", "water_drop", "●"), + ("leaf.fill", "eco", "✿"), + ("eye.fill", "visibility", "◉"), + ("bubble.left.fill", "chat_bubble", "◑"), + ("music.note", "music_note", "♪"), + ("music.note.list", "queue_music", "♪"), + ("hourglass", "hourglass_empty", "⏳"), + ("hourglass.bottomhalf.filled", "hourglass_bottom", "⏳"), + ("figure.run", "directions_run", "➤"), + ("paintpalette.fill", "palette", "⚘"), + ("quote.opening", "format_quote", "“"), + ("bitcoinsign.circle.fill", "currency_bitcoin", "₿"), + ("battery.100", "battery_full", "▮"), + ("iphone", "phone_iphone", "▯"), + ("ipad", "tablet_mac", "▭"), + ("applewatch", "watch", "⌚"), + ("airpodspro", "headphones", "○"), + ("desktopcomputer", "desktop_windows", "▣"), + ("internaldrive", "hard_drive", "■"), + ("network", "lan", "≡"), + ("envelope.fill", "mail", "✉"), + ("envelope", "mail", "✉"), + ("phone.fill", "call", "☎"), + ("phone", "call", "☎"), + ("message.fill", "message", "💬"), + ("trash", "delete", "🗑"), + ("trash.fill", "delete", "🗑"), + ("folder", "folder", "📁"), + ("folder.fill", "folder", "📁"), + ("doc", "description", "📄"), + ("doc.fill", "description", "📄"), + ("photo", "photo", "🖼"), + ("photo.fill", "photo", "🖼"), + ("camera", "photo_camera", "📷"), + ("camera.fill", "photo_camera", "📷"), + ("map", "map", "🗺"), + ("map.fill", "map", "🗺"), + ("cart", "shopping_cart", "🛒"), + ("cart.fill", "shopping_cart", "🛒"), + ("creditcard", "credit_card", "💳"), + ("creditcard.fill", "credit_card", "💳"), + ("wifi", "wifi", "📶"), + ("antenna.radiowaves.left.and.right", "cell_tower", "📡"), + ("lock.fill", "lock", "🔒"), + ("lock", "lock", "🔒"), + ("lock.open.fill", "lock_open", "🔓"), + ("key.fill", "key", "🔑"), + ("paperplane.fill", "send", "➤"), + ("arrow.right", "arrow_forward", "→"), + ("arrow.left", "arrow_back", "←"), + ("arrow.up", "arrow_upward", "↑"), + ("arrow.down", "arrow_downward", "↓"), + ("chevron.right", "chevron_right", "›"), + ("chevron.left", "chevron_left", "‹"), + ("chevron.up", "expand_less", "ˆ"), + ("chevron.down", "expand_more", "ˇ"), + ("info.circle", "info", "ℹ"), + ("info.circle.fill", "info", "ℹ"), + ("exclamationmark.triangle", "warning", "⚠"), + ("exclamationmark.triangle.fill", "warning", "⚠"), + ("play.fill", "play_arrow", "▶"), + ("pause.fill", "pause", "⏸"), + ("stop.fill", "stop", "⏹"), + ("forward.fill", "fast_forward", "⏩"), + ("backward.fill", "fast_rewind", "⏪"), +]; + +/// Tiny SVG that draws the emoji/glyph centered (works with resvg for Windows Board). +pub fn icon_svg(name: &str, size: f64, color: &str) -> String { + let glyph = sf_symbol_emoji(name); + let s = size.max(12.0); + let fs = s * 0.72; + format!( + r##" + {glyph} +"## + ) +} + +/// Rasterize an SF-ish name to a PNG `data:` URI (`rasterize` feature). +#[cfg(feature = "rasterize")] +pub fn icon_png_data_uri(name: &str, size: f64, color: &str) -> Result { + crate::rasterize::svg_to_data_uri(&icon_svg(name, size, color)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn heart_maps_to_favorite() { + let r = resolve_sf_symbol("heart.fill"); + assert_eq!(r.material, "favorite"); + assert_ne!(r.emoji, "h"); + } + + #[test] + fn unknown_not_first_letter() { + let r = resolve_sf_symbol("totally.unknown.glyph"); + assert_eq!(r.emoji, "•"); + assert!(!r.material.is_empty()); + } +} diff --git a/src/image_prefetch.rs b/src/image_prefetch.rs new file mode 100644 index 0000000..ad30d70 --- /dev/null +++ b/src/image_prefetch.rs @@ -0,0 +1,420 @@ +//! Prefetch remote `image.url` into `data:` URIs for hosts that cannot fetch +//! (WidgetKit). Android keeps its own `localPath` pipeline — Rust prefetch is a +//! no-op there so we do not inflate SharedPreferences / Binder with base64. +//! +//! Behind the `image-prefetch` feature (`ureq`). In-memory + disk cache by URL +//! with TTL so `startWidgetUpdater` does not hit the network every tick. +//! The command path only reads the cache; network fetches run on a background +//! thread so a slow host cannot stall `set_widget_config`. +//! Successful prefetch **keeps** `url` so Android / Adaptive Cards can still use it. + +use crate::models::WidgetConfig; +#[cfg(all(feature = "image-prefetch", not(target_os = "android")))] +use crate::models::{ImageElement, WidgetElement}; + +/// Walk the config and fill `image.data` from http(s) URLs when needed. +/// +/// - Does **not** clear `url` (consumers choose `data` / `localPath` / `url`). +/// - No-op on Android and when the `image-prefetch` feature is disabled. +pub fn prefetch_remote_images(config: &mut WidgetConfig) { + #[cfg(all(feature = "image-prefetch", not(target_os = "android")))] + { + prefetch_remote_images_inner(config); + } + #[cfg(not(all(feature = "image-prefetch", not(target_os = "android"))))] + { + let _ = config; + } +} + +#[cfg(all(feature = "image-prefetch", not(target_os = "android")))] +mod imp { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + const MAX_BYTES: usize = 3 * 1024 * 1024; + const TIMEOUT: Duration = Duration::from_secs(10); + const CACHE_TTL: Duration = Duration::from_secs(300); + + #[derive(Clone)] + struct CacheEntry { + data_uri: String, + fetched_at: Instant, + } + + static MEMORY: Mutex>> = Mutex::new(None); + static IN_FLIGHT: Mutex>> = Mutex::new(None); + + fn memory() -> std::sync::MutexGuard<'static, Option>> { + MEMORY.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub(super) fn prefetch_remote_images_inner(config: &mut WidgetConfig) { + if let Some(el) = config.small.as_mut() { + walk_el(el); + } + if let Some(el) = config.medium.as_mut() { + walk_el(el); + } + if let Some(el) = config.large.as_mut() { + walk_el(el); + } + } + + fn walk_el(el: &mut WidgetElement) { + match el { + WidgetElement::Image(img) => prefetch_image(img), + WidgetElement::VStack(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::HStack(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::ZStack(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::Grid(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::Container(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::Link(v) => { + for c in &mut v.children { + walk_el(c); + } + } + WidgetElement::List(_) + | WidgetElement::Button(_) + | WidgetElement::Toggle(_) + | WidgetElement::Text(_) + | WidgetElement::Label(_) + | WidgetElement::Progress(_) + | WidgetElement::Gauge(_) + | WidgetElement::Divider(_) + | WidgetElement::Spacer(_) + | WidgetElement::Shape(_) + | WidgetElement::Date(_) + | WidgetElement::Timer(_) + | WidgetElement::Chart(_) + | WidgetElement::Canvas(_) => {} + } + } + + fn prefetch_image(img: &mut ImageElement) { + let Some(url) = img.url.as_deref() else { + return; + }; + let url = url.trim(); + if !(url.starts_with("http://") || url.starts_with("https://")) { + return; + } + if img + .data + .as_deref() + .map(|d| !d.trim().is_empty()) + .unwrap_or(false) + { + return; + } + // Hot path: memory/disk only — never block `set_widget_config` on HTTP. + if let Some(cached) = cache_get(url) { + img.data = Some(cached); + return; + } + spawn_background_fetch(url.to_string()); + } + + fn spawn_background_fetch(url: String) { + { + let mut guard = IN_FLIGHT.lock().unwrap_or_else(|e| e.into_inner()); + let set = guard.get_or_insert_with(std::collections::HashSet::new); + if !set.insert(url.clone()) { + return; + } + } + std::thread::Builder::new() + .name("widget-image-prefetch".into()) + .spawn(move || { + match fetch_as_data_uri(&url) { + Ok(data_uri) => cache_put(&url, data_uri), + Err(err) => log::warn!("image.url prefetch failed for {url}: {err}"), + } + if let Ok(mut guard) = IN_FLIGHT.lock() { + if let Some(set) = guard.as_mut() { + set.remove(&url); + } + } + }) + .ok(); + } + + fn cache_get(url: &str) -> Option { + let mut guard = memory(); + let map = guard.get_or_insert_with(HashMap::new); + if let Some(e) = map.get(url) { + if e.fetched_at.elapsed() < CACHE_TTL { + return Some(e.data_uri.clone()); + } + map.remove(url); + } + disk_cache_get(url).map(|data_uri| { + map.insert( + url.into(), + CacheEntry { + data_uri: data_uri.clone(), + fetched_at: Instant::now(), + }, + ); + data_uri + }) + } + + pub(super) fn cache_put(url: &str, data_uri: String) { + { + let mut guard = memory(); + let map = guard.get_or_insert_with(HashMap::new); + map.insert( + url.into(), + CacheEntry { + data_uri: data_uri.clone(), + fetched_at: Instant::now(), + }, + ); + } + disk_cache_put(url, &data_uri); + } + + fn disk_cache_dir() -> Option { + let base = dirs_next_cache().unwrap_or_else(std::env::temp_dir); + let dir = base.join("tauri-plugin-widgets").join("image-prefetch"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) + } + + fn dirs_next_cache() -> Option { + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME").map(|h| { + std::path::PathBuf::from(h) + .join("Library") + .join("Caches") + }) + } + #[cfg(target_os = "windows")] + { + std::env::var_os("LOCALAPPDATA").map(std::path::PathBuf::from) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache")) + }) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))] + { + None + } + } + + fn cache_key(url: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + url.hash(&mut h); + format!("{:016x}", h.finish()) + } + + fn disk_cache_get(url: &str) -> Option { + let dir = disk_cache_dir()?; + let path = dir.join(cache_key(url)); + let meta_path = path.with_extension("meta"); + let meta = std::fs::read_to_string(&meta_path).ok()?; + let ts: u64 = meta.trim().parse().ok()?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_secs(); + if now.saturating_sub(ts) > CACHE_TTL.as_secs() { + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&meta_path); + return None; + } + std::fs::read_to_string(path).ok() + } + + fn disk_cache_put(url: &str, data_uri: &str) { + let Some(dir) = disk_cache_dir() else { + return; + }; + let path = dir.join(cache_key(url)); + let meta_path = path.with_extension("meta"); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let _ = std::fs::write(&path, data_uri); + let _ = std::fs::write(&meta_path, ts.to_string()); + } + + fn fetch_as_data_uri(url: &str) -> Result { + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + use std::io::Read; + + let resp = ureq::get(url) + .timeout(TIMEOUT) + .call() + .map_err(|e| e.to_string())?; + let content_type = resp + .header("content-type") + .unwrap_or("image/png") + .split(';') + .next() + .unwrap_or("image/png") + .trim() + .to_string(); + if !content_type.starts_with("image/") && content_type != "application/octet-stream" { + return Err(format!("unexpected content-type {content_type}")); + } + let mut buf = Vec::new(); + let mut reader = resp.into_reader(); + reader + .by_ref() + .take(MAX_BYTES as u64 + 1) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.is_empty() { + return Err("empty body".into()); + } + if buf.len() > MAX_BYTES { + return Err("image exceeds 3 MiB limit".into()); + } + let mime = if content_type.starts_with("image/") { + content_type + } else { + guess_mime(url).into() + }; + Ok(format!("data:{mime};base64,{}", B64.encode(&buf))) + } + + fn guess_mime(url: &str) -> &'static str { + let lower = url.to_ascii_lowercase(); + if lower.contains(".jpg") || lower.contains(".jpeg") { + "image/jpeg" + } else if lower.contains(".webp") { + "image/webp" + } else if lower.contains(".gif") { + "image/gif" + } else { + "image/png" + } + } +} + +#[cfg(all(feature = "image-prefetch", not(target_os = "android")))] +use imp::prefetch_remote_images_inner; + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ImageElement; + + #[test] + fn leaves_non_http_url_alone() { + let mut cfg = WidgetConfig { + version: 1, + small: Some(WidgetElement::Image(ImageElement { + system_name: None, + data: None, + url: Some("data:image/png;base64,QQ==".into()), + size: Some(16.0), + color: None, + content_mode: None, + style: Default::default(), + })), + medium: None, + large: None, + }; + prefetch_remote_images(&mut cfg); + match cfg.small.as_ref().unwrap() { + WidgetElement::Image(img) => { + assert_eq!(img.url.as_deref(), Some("data:image/png;base64,QQ==")); + assert!(img.data.is_none()); + } + _ => panic!("expected image"), + } + } + + #[test] + fn skips_when_data_already_set() { + let mut cfg = WidgetConfig { + version: 1, + small: Some(WidgetElement::Image(ImageElement { + system_name: None, + data: Some("data:image/png;base64,QQ==".into()), + url: Some("https://example.com/a.png".into()), + size: None, + color: None, + content_mode: None, + style: Default::default(), + })), + medium: None, + large: None, + }; + prefetch_remote_images(&mut cfg); + match cfg.small.as_ref().unwrap() { + WidgetElement::Image(img) => { + assert_eq!(img.url.as_deref(), Some("https://example.com/a.png")); + assert!(img.data.as_ref().unwrap().starts_with("data:")); + } + _ => panic!("expected image"), + } + } + + #[test] + fn keeps_url_when_serving_from_memory_cache() { + #[cfg(all(feature = "image-prefetch", not(target_os = "android")))] + { + imp::cache_put( + "https://example.com/cached.png", + "data:image/png;base64,QQ==".into(), + ); + let mut cfg = WidgetConfig { + version: 1, + small: Some(WidgetElement::Image(ImageElement { + system_name: None, + data: None, + url: Some("https://example.com/cached.png".into()), + size: None, + color: None, + content_mode: None, + style: Default::default(), + })), + medium: None, + large: None, + }; + prefetch_remote_images(&mut cfg); + match cfg.small.as_ref().unwrap() { + WidgetElement::Image(img) => { + assert_eq!(img.url.as_deref(), Some("https://example.com/cached.png")); + assert_eq!(img.data.as_deref(), Some("data:image/png;base64,QQ==")); + } + _ => panic!("expected image"), + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9f926f1..8fcbf02 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,9 @@ //! A Tauri v2 plugin for building native widgets on Android, iOS, macOS, //! Windows, and Linux from a single JSON UI configuration. //! +//! Empty placeholders for Adaptive Cards are avoided when the `rasterize` +//! feature is enabled (chart/canvas/gauge/shape/zstack/gradients → PNG). +//! //! ## Overview //! //! - **Widget Config API** — send a declarative [`WidgetConfig`](models::WidgetConfig) @@ -112,32 +115,31 @@ pub mod capabilities; pub mod codegen; mod commands; /// Plugin configuration (`plugins.widgets` in `tauri.conf.json`). -#[allow(missing_docs)] pub mod config; /// Plugin error type. pub mod error; +/// Host-side remote image prefetch for WidgetKit / desktop store writes. +pub mod image_prefetch; +/// SF Symbol → Material / emoji resolve for non-Apple hosts. +pub mod icons; /// Widget IR models (`WidgetConfig`, `WidgetElement`, …). /// /// Element structs and their fields carry rustdoc used by `schemars` / docs site. -#[allow(missing_docs)] pub mod models; +/// Host-side IR normalization (`textStyle` → points, semantic colors → adaptive hex). +pub mod normalize; /// SVG / PNG helpers for Adaptive Cards fallbacks. #[allow(missing_docs)] pub mod rasterize; /// Render receipts written by native / desktop surfaces. -#[allow(missing_docs)] pub mod receipt; /// Canonical layout dumps for snapshot tests. -#[allow(missing_docs)] pub mod snapshot; /// Shared key-value store helpers and action envelopes. -#[allow(missing_docs)] pub mod store; /// Host black-box journal (`WIDGET_DEBUG` / debug builds). -#[allow(missing_docs)] pub mod trace; /// macOS / desktop config transport selection. -#[allow(missing_docs)] pub mod transport; #[cfg(target_os = "windows")] diff --git a/src/macos_transport.rs b/src/macos_transport.rs index 4748798..a84942b 100644 --- a/src/macos_transport.rs +++ b/src/macos_transport.rs @@ -324,6 +324,91 @@ pub fn all_transports(group: &str) -> Vec> { out } +/// Wipe leftover maps on every Apple transport **except** `keep` (best-effort). +/// +/// Writes an empty map with **no** meta bump so a cleared sibling cannot win +/// `pick_freshest` by nonce. The configured driver is never wiped here — a +/// failed follow-up write must not erase the last good primary map. +/// +/// Each wipe is `read → merge pending into `into` → write empty → re-read`. +/// A nonempty re-read always folds pending again and retries — never a final +/// blind empty write that could clobber a tap that arrived after the re-read. +pub fn clear_sibling_transports(group: &str, keep: &str, into: &mut DataMap) { + for t in all_transports(group) { + if t.name() == keep || !t.available() { + continue; + } + for _ in 0..5 { + let Some(existing) = t.read() else { break }; + if existing.is_empty() { + break; + } + merge_pending_from_map(into, &existing); + if let Err(e) = t.write(&DataMap::new()) { + log::debug!("clear_sibling_transports({}): {e}", t.name()); + break; + } + match t.read() { + None => break, + Some(after) if after.is_empty() => break, + Some(after) => { + // Something reappeared (likely a tap) — fold in and retry wipe. + merge_pending_from_map(into, &after); + } + } + } + // Attempts exhausted: preserve any residual pending in `into`, do not + // blind-wipe (that race is what drops taps). + if let Some(left) = t.read() { + if !left.is_empty() { + merge_pending_from_map(into, &left); + } + } + } +} + +fn pending_action_key(a: &crate::store::WidgetActionEnvelope) -> String { + // Structured JSON — `|` in action/payload must not collide envelopes. + serde_json::to_string(&(&a.action, &a.widget_id, &a.payload, a.ts)) + .unwrap_or_else(|_| format!("{}:{}:{:?}", a.action, a.widget_id, a.ts)) +} + +fn merge_pending_from_map(into: &mut DataMap, src: &DataMap) { + use crate::store::{encode_pending_actions, parse_pending_actions, PENDING_ACTIONS_KEY}; + let incoming = parse_pending_actions(src.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + if incoming.is_empty() { + return; + } + let mut merged = parse_pending_actions(into.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + let mut seen: std::collections::HashSet = + merged.iter().map(pending_action_key).collect(); + for a in incoming { + if seen.insert(pending_action_key(&a)) { + merged.push(a); + } + } + match encode_pending_actions(&merged) { + Ok(s) => { + into.insert(PENDING_ACTIONS_KEY.into(), s); + } + Err(e) => log::debug!("merge_pending_from_map encode: {e}"), + } +} + +/// Fold `pending_actions` from every readable transport into `map` (deduped). +/// +/// Used by host writes before [`clear_sibling_transports`]. +pub fn merge_pending_into_map(map: &mut DataMap, group: &str) { + for t in all_transports(group) { + if !t.available() { + continue; + } + if let Some(m) = t.read() { + merge_pending_from_map(map, &m); + } + } +} + /// Max `__meta_nonce__` across every readable sibling transport. pub fn max_nonce_across(group: &str) -> u64 { all_transports(group) @@ -334,36 +419,113 @@ pub fn max_nonce_across(group: &str) -> u64 { .unwrap_or(0) } -/// Write `map` to every available transport except `primary` (same bytes / nonce). +/// Collect `pending_actions` from every readable sibling (including primary). /// -/// The widget still multi-reads and picks by nonce. Mirroring keeps inactive -/// channels from serving a stale higher-nonce snapshot. +/// Call on poll — siblings may still hold taps after a driver latch; config +/// writes clear leftover maps via [`clear_sibling_transports`] instead. +pub fn harvest_pending_actions(group: &str) -> Vec { + use crate::store::{parse_pending_actions, PENDING_ACTIONS_KEY}; + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for t in all_transports(group) { + if !t.available() { + continue; + } + let Some(m) = t.read() else { continue }; + for a in parse_pending_actions(m.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())) { + if seen.insert(pending_action_key(&a)) { + out.push(a); + } + } + } + out.sort_by_key(|a| a.ts); + out +} + +/// Clear drained `pending_actions` on every transport after a successful host drain. /// -/// Concurrent enqueues: merge `pending_actions` from the sibling's current map -/// into the outgoing mirror so a host config write cannot wipe a just-appended action. -pub fn mirror_to_siblings(primary: &dyn Transport, group: &str, map: &DataMap) { +/// Re-reads each transport and keeps any actions that arrived after `drained` was +/// harvested (matched by action|widget|payload|ts with multiplicity), so a tap +/// between harvest and clear is not lost. +pub fn clear_pending_actions_everywhere( + group: &str, + drained: &[crate::store::WidgetActionEnvelope], +) { + use crate::store::{ + encode_pending_actions, map_nonce, parse_pending_actions, PENDING_ACTIONS_KEY, + }; + use std::collections::HashMap; + + fn filter_pending( + current: Vec, + drained_counts: &HashMap, + ) -> Vec { + let mut counts = drained_counts.clone(); + current + .into_iter() + .filter(|a| { + let key = pending_action_key(a); + if let Some(c) = counts.get_mut(&key) { + if *c > 0 { + *c -= 1; + return false; + } + } + true + }) + .collect() + } + + let mut drained_counts: HashMap = HashMap::new(); + for a in drained { + *drained_counts.entry(pending_action_key(a)).or_default() += 1; + } + for t in all_transports(group) { - if t.name() == primary.name() || !t.available() { + if !t.available() { continue; } - let mut out = map.clone(); - if let Some(existing) = t.read() { - if let Some(pa) = existing.get(store::PENDING_ACTIONS_KEY) { - // Prefer the longer / non-empty queue when the outgoing map cleared it. - let outgoing_empty = out - .get(store::PENDING_ACTIONS_KEY) - .map(|s| s.trim().is_empty() || s.trim() == "[]") - .unwrap_or(true); - if outgoing_empty && !pa.trim().is_empty() && pa.trim() != "[]" { - out.insert(store::PENDING_ACTIONS_KEY.into(), pa.clone()); - // Sibling queue must outrank the empty primary snapshot. - let floor = store::map_nonce(&existing).max(store::map_nonce(map)); - store::touch_meta_above(&mut out, floor); + for attempt in 0..5 { + let Some(m_at_read) = t.read() else { + break; + }; + let nonce_at_read = map_nonce(&m_at_read); + let pending_snapshot = m_at_read.get(PENDING_ACTIONS_KEY).cloned(); + let current = parse_pending_actions(pending_snapshot.as_deref()); + if current.is_empty() { + break; + } + + let Some(m_disk) = t.read() else { + break; + }; + let disk_nonce = map_nonce(&m_disk); + let disk_pending = m_disk.get(PENDING_ACTIONS_KEY).cloned(); + if (disk_nonce > nonce_at_read || disk_pending != pending_snapshot) && attempt + 1 < 5 + { + continue; + } + + let fresh = parse_pending_actions(m_disk.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + if fresh.is_empty() { + break; + } + let remaining = filter_pending(fresh, &drained_counts); + let encoded = match encode_pending_actions(&remaining) { + Ok(s) => s, + Err(e) => { + log::debug!("clear_pending_actions_everywhere encode({}): {e}", t.name()); + break; } + }; + let mut m = m_disk; + m.insert(PENDING_ACTIONS_KEY.into(), encoded); + // Do NOT touch_meta here — bumping nonce on a probe-only sibling lets it + // win pick_freshest and wipe the live config on the next host poll. + if let Err(e) = t.write(&m) { + log::debug!("clear_pending_actions_everywhere({}): {e}", t.name()); } - } - if let Err(e) = t.write(&out) { - log::debug!("mirror_to_siblings({}): {e}", t.name()); + break; } } } diff --git a/src/mobile.rs b/src/mobile.rs index 825ae10..9111d81 100755 --- a/src/mobile.rs +++ b/src/mobile.rs @@ -11,7 +11,7 @@ use crate::config::WidgetsPluginConfig; use crate::models::{WidgetConfig, WidgetWindowConfig}; use crate::receipt::{ReceiptStore, WidgetRenderReceipt}; use crate::store::config_key; -use crate::trace::{TraceEvent, TraceSkipReason, TraceStore, WidgetTrace}; +use crate::trace::{TraceEvent, TraceStore, WidgetTrace}; use crate::transport::validate_mobile_transport; /// Default minimum interval between WidgetKit reload calls (**iOS/Android host only**). @@ -265,7 +265,14 @@ impl Widget { } self.remember_group(group); - let json = serde_json::to_string(config) + let mut config = crate::normalize::normalize( + config, + crate::capabilities::WidgetPlatform::current(), + ) + .config; + crate::image_prefetch::prefetch_remote_images(&mut config); + + let json = serde_json::to_string(&config) .map_err(|e| crate::Error::new(format!("serialize config: {e}")))?; let hash = config_content_hash(&json); let key = config_key(widget_id); @@ -280,7 +287,7 @@ impl Widget { nonce: 0, bytes: json.len(), changed: false, - skip: Some(TraceSkipReason::Unchanged { hash }), + skip: Some(crate::apply::SkipReason::Unchanged { hash }), }); self.trace.push(TraceEvent::Reload { performed: false, @@ -289,7 +296,7 @@ impl Widget { return Ok(outcome); } - crate::capabilities::log_capabilities(config); + crate::capabilities::log_capabilities(&config); // Prefer native setWidgetConfig (Android image preprocess + Glance sync). // Falls back to set_items with config:{widgetId} key. diff --git a/src/models/data.rs b/src/models/data.rs new file mode 100644 index 0000000..aeb001f --- /dev/null +++ b/src/models/data.rs @@ -0,0 +1,195 @@ +//! Data payloads carried by elements: chart points, list rows, and the +//! declarative canvas draw-command union. + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "schema")] +use schemars::JsonSchema; + +use super::style::ColorValue; + +/// A single labeled data point for [`crate::models::ChartElement`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ChartDataPoint { + /// Category / x-axis label. + pub label: String, + /// Value plotted for this point. + pub value: f64, + /// Optional per-point color override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +/// A single row for [`crate::models::ListElement`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ListItem { + /// Row text. + pub text: String, + /// Optional checked/checkbox marker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checked: Option, + /// Action identifier — emits a `widget-action` event when tapped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action: Option, + /// Opaque payload forwarded alongside the action event. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +/// A single declarative draw command for [`crate::models::CanvasElement`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(tag = "draw", rename_all = "camelCase")] +pub enum CanvasDrawCommand { + /// Filled / stroked circle. + #[serde(rename = "circle")] + Circle { + /// Center x-coordinate. + cx: f64, + /// Center y-coordinate. + cy: f64, + /// Radius. + r: f64, + /// Fill color. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill: Option, + /// Stroke color. + #[serde(default, skip_serializing_if = "Option::is_none")] + stroke: Option, + /// Stroke width in points. + #[serde( + rename = "strokeWidth", + default, + skip_serializing_if = "Option::is_none" + )] + stroke_width: Option, + }, + /// Straight line segment. + #[serde(rename = "line")] + Line { + /// Start x-coordinate. + x1: f64, + /// Start y-coordinate. + y1: f64, + /// End x-coordinate. + x2: f64, + /// End y-coordinate. + y2: f64, + /// Stroke color. + #[serde(default, skip_serializing_if = "Option::is_none")] + stroke: Option, + /// Stroke width in points. + #[serde( + rename = "strokeWidth", + default, + skip_serializing_if = "Option::is_none" + )] + stroke_width: Option, + /// Line cap style (`"butt"`, `"round"`, or `"square"`). + #[serde(rename = "lineCap", default, skip_serializing_if = "Option::is_none")] + line_cap: Option, + }, + /// Filled / stroked rectangle, optionally rounded. + #[serde(rename = "rect")] + Rect { + /// Top-left x-coordinate. + x: f64, + /// Top-left y-coordinate. + y: f64, + /// Rectangle width. + width: f64, + /// Rectangle height. + height: f64, + /// Fill color. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill: Option, + /// Stroke color. + #[serde(default, skip_serializing_if = "Option::is_none")] + stroke: Option, + /// Stroke width in points. + #[serde( + rename = "strokeWidth", + default, + skip_serializing_if = "Option::is_none" + )] + stroke_width: Option, + /// Corner radius in points. + #[serde( + rename = "cornerRadius", + default, + skip_serializing_if = "Option::is_none" + )] + corner_radius: Option, + }, + /// Filled / stroked arc. + #[serde(rename = "arc")] + Arc { + /// Center x-coordinate. + cx: f64, + /// Center y-coordinate. + cy: f64, + /// Radius. + r: f64, + /// Start angle in degrees. + #[serde(rename = "startAngle")] + start_angle: f64, + /// End angle in degrees. + #[serde(rename = "endAngle")] + end_angle: f64, + /// Fill color. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill: Option, + /// Stroke color. + #[serde(default, skip_serializing_if = "Option::is_none")] + stroke: Option, + /// Stroke width in points. + #[serde( + rename = "strokeWidth", + default, + skip_serializing_if = "Option::is_none" + )] + stroke_width: Option, + }, + /// Text label drawn at a point. + #[serde(rename = "text")] + Text { + /// x-coordinate of the anchor point. + x: f64, + /// y-coordinate of the anchor point. + y: f64, + /// Text to draw. + content: String, + /// Font size in points. + #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")] + font_size: Option, + /// Text color. + #[serde(default, skip_serializing_if = "Option::is_none")] + color: Option, + /// Text anchor (`"start"`, `"middle"`, or `"end"`). + #[serde(default, skip_serializing_if = "Option::is_none")] + anchor: Option, + }, + /// Arbitrary vector path. + #[serde(rename = "path")] + Path { + /// SVG path data (e.g. `"M10 10 L90 90"`) + d: String, + /// Fill color. + #[serde(default, skip_serializing_if = "Option::is_none")] + fill: Option, + /// Stroke color. + #[serde(default, skip_serializing_if = "Option::is_none")] + stroke: Option, + /// Stroke width in points. + #[serde( + rename = "strokeWidth", + default, + skip_serializing_if = "Option::is_none" + )] + stroke_width: Option, + }, +} diff --git a/src/models.rs b/src/models/mod.rs similarity index 68% rename from src/models.rs rename to src/models/mod.rs index c335e72..ba47561 100644 --- a/src/models.rs +++ b/src/models/mod.rs @@ -1,4 +1,13 @@ //! Widget IR types: [`WidgetConfig`], [`WidgetElement`], and shared style values. +//! +//! Element structs, [`WidgetConfig`]/[`WidgetElement`], builders, and tests +//! live here; shared style values are in [`style`] and data payloads +//! ([`ChartDataPoint`], [`ListItem`], [`CanvasDrawCommand`]) are in [`data`]. + +mod data; +mod style; +pub use data::*; +pub use style::*; use serde::{Deserialize, Serialize}; @@ -899,553 +908,6 @@ fn default_total() -> f64 { 1.0 } -// ─── Shared style applied to any element ───────────────────────────────────── - -/// Shared visual style applied to any element (padding, background, frame, …). -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ElementStyle { - /// Inset padding (number or per-edge object). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub padding: Option, - /// Solid, adaptive, or gradient background. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub background: Option, - /// Corner radius in points. - #[serde( - rename = "cornerRadius", - default, - skip_serializing_if = "Option::is_none" - )] - pub corner_radius: Option, - /// Opacity from `0` (invisible) to `1` (opaque). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub opacity: Option, - /// Explicit width / height / max constraints. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub frame: Option, - /// Border color and width. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub border: Option, - /// Drop shadow. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub shadow: Option, - /// Clip content to a shape (e.g. circle avatar from square image). - #[serde(rename = "clipShape", default, skip_serializing_if = "Option::is_none")] - pub clip_shape: Option, - /// Layout weight for flexible sizing inside stacks (like Android `layout_weight`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub flex: Option, -} - -/// Color value — hex string, semantic name, or adaptive `{ light, dark }` pair. -/// -/// Semantic names: `"label"`, `"secondaryLabel"`, `"systemBackground"`, -/// `"secondarySystemBackground"`, `"accent"`, `"separator"`. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(untagged)] -pub enum ColorValue { - Solid(String), - Adaptive { light: String, dark: String }, -} - -impl From<&str> for ColorValue { - fn from(value: &str) -> Self { - ColorValue::Solid(value.to_string()) - } -} - -impl From for ColorValue { - fn from(value: String) -> Self { - ColorValue::Solid(value) - } -} - -/// Clip shape for content masking. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum ClipShape { - /// `circle`. - Circle, - /// `capsule`. - Capsule, - /// `rectangle`. - Rectangle, -} - -/// Semantic text style — respects Dynamic Type / accessibility settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TextStyle { - /// `large title`. - LargeTitle, - /// `title`. - Title, - /// `title2`. - Title2, - /// `title3`. - Title3, - /// `headline`. - Headline, - /// `subheadline`. - Subheadline, - /// `body`. - Body, - /// `callout`. - Callout, - /// `footnote`. - Footnote, - /// `caption`. - Caption, - /// `caption2`. - Caption2, -} - -/// Background: solid color string, adaptive pair, gradient, or material blur. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(untagged)] -pub enum BackgroundValue { - Solid(String), - Gradient(GradientConfig), - Adaptive { light: String, dark: String }, -} - -impl From<&str> for BackgroundValue { - fn from(value: &str) -> Self { - BackgroundValue::Solid(value.to_string()) - } -} - -impl From for BackgroundValue { - fn from(value: String) -> Self { - BackgroundValue::Solid(value) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct GradientConfig { - /// `"linear"`, `"radial"`, or `"angular"` - #[serde(rename = "gradientType")] - pub gradient_type: GradientType, - pub colors: Vec, - /// Direction for linear gradients - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direction: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum GradientType { - /// `linear`. - Linear, - /// `radial`. - Radial, - /// `angular`. - Angular, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum GradientDirection { - /// `top to bottom`. - TopToBottom, - /// `bottom to top`. - BottomToTop, - /// `leading to trailing`. - LeadingToTrailing, - /// `trailing to leading`. - TrailingToLeading, - /// `top leading to bottom trailing`. - TopLeadingToBottomTrailing, - /// `top trailing to bottom leading`. - TopTrailingToBottomLeading, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ShadowConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub radius: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub x: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub y: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(untagged)] -pub enum PaddingValue { - Uniform(f64), - Edges { - #[serde(default, skip_serializing_if = "Option::is_none")] - top: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - bottom: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - leading: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - trailing: Option, - }, -} - -impl From for PaddingValue { - fn from(value: f64) -> Self { - PaddingValue::Uniform(value) - } -} - -impl From for PaddingValue { - fn from(value: f32) -> Self { - PaddingValue::Uniform(f64::from(value)) - } -} - -impl From for PaddingValue { - fn from(value: i32) -> Self { - PaddingValue::Uniform(f64::from(value)) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct FrameConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub width: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub height: Option, - #[serde(rename = "maxWidth", default, skip_serializing_if = "Option::is_none")] - pub max_width: Option, - #[serde(rename = "maxHeight", default, skip_serializing_if = "Option::is_none")] - pub max_height: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(untagged)] -pub enum FrameDimension { - Fixed(f64), - Keyword(String), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct BorderConfig { - pub color: String, - #[serde(default = "default_border_width")] - pub width: f64, -} - -fn default_border_width() -> f64 { - 1.0 -} - -// ─── Enums ─────────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum FontWeight { - /// `ultralight`. - Ultralight, - /// `thin`. - Thin, - /// `light`. - Light, - /// `regular`. - Regular, - /// `medium`. - Medium, - /// `semibold`. - Semibold, - /// `bold`. - Bold, - /// `heavy`. - Heavy, - /// `black`. - Black, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum FontDesign { - /// `default`. - Default, - /// `monospaced`. - Monospaced, - /// `rounded`. - Rounded, - /// `serif`. - Serif, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TextAlignment { - /// `leading`. - Leading, - /// `center`. - Center, - /// `trailing`. - Trailing, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum HorizontalAlignment { - /// `leading`. - Leading, - /// `center`. - Center, - /// `trailing`. - Trailing, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum VerticalAlignment { - /// `top`. - Top, - /// `center`. - Center, - /// `bottom`. - Bottom, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum ContentMode { - /// `fit`. - Fit, - /// `fill`. - Fill, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum ProgressStyle { - /// `linear`. - Linear, - /// `circular`. - Circular, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum GaugeStyle { - /// `circular`. - Circular, - /// `linear`. - Linear, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum DateStyle { - /// `time`. - Time, - /// `date`. - Date, - /// `relative`. - Relative, - /// `offset`. - Offset, - /// `timer`. - Timer, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum ChartType { - #[default] - Bar, - /// `line`. - Line, - /// `area`. - Area, - /// `pie`. - Pie, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum ShapeType { - #[default] - Circle, - /// `capsule`. - Capsule, - /// `rectangle`. - Rectangle, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub enum TimerCounting { - /// `up`. - Up, - /// `down`. - Down, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ChartDataPoint { - pub label: String, - pub value: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(rename_all = "camelCase")] -pub struct ListItem { - pub text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub checked: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub action: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub payload: Option, -} - -// ─── Canvas drawing commands ──────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "schema", derive(JsonSchema))] -#[serde(tag = "draw", rename_all = "camelCase")] -pub enum CanvasDrawCommand { - #[serde(rename = "circle")] - Circle { - cx: f64, - cy: f64, - r: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - fill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - stroke: Option, - #[serde( - rename = "strokeWidth", - default, - skip_serializing_if = "Option::is_none" - )] - stroke_width: Option, - }, - #[serde(rename = "line")] - Line { - x1: f64, - y1: f64, - x2: f64, - y2: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - stroke: Option, - #[serde( - rename = "strokeWidth", - default, - skip_serializing_if = "Option::is_none" - )] - stroke_width: Option, - #[serde(rename = "lineCap", default, skip_serializing_if = "Option::is_none")] - line_cap: Option, - }, - #[serde(rename = "rect")] - Rect { - x: f64, - y: f64, - width: f64, - height: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - fill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - stroke: Option, - #[serde( - rename = "strokeWidth", - default, - skip_serializing_if = "Option::is_none" - )] - stroke_width: Option, - #[serde( - rename = "cornerRadius", - default, - skip_serializing_if = "Option::is_none" - )] - corner_radius: Option, - }, - #[serde(rename = "arc")] - Arc { - cx: f64, - cy: f64, - r: f64, - #[serde(rename = "startAngle")] - start_angle: f64, - #[serde(rename = "endAngle")] - end_angle: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - fill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - stroke: Option, - #[serde( - rename = "strokeWidth", - default, - skip_serializing_if = "Option::is_none" - )] - stroke_width: Option, - }, - #[serde(rename = "text")] - Text { - x: f64, - y: f64, - content: String, - #[serde(rename = "fontSize", default, skip_serializing_if = "Option::is_none")] - font_size: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - color: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - anchor: Option, - }, - #[serde(rename = "path")] - Path { - /// SVG path data (e.g. `"M10 10 L90 90"`) - d: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - fill: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - stroke: Option, - #[serde( - rename = "strokeWidth", - default, - skip_serializing_if = "Option::is_none" - )] - stroke_width: Option, - }, -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/models/style.rs b/src/models/style.rs new file mode 100644 index 0000000..cb936f0 --- /dev/null +++ b/src/models/style.rs @@ -0,0 +1,638 @@ +//! Shared style values: colors, backgrounds, gradients, shadows, padding, +//! frame/border config, and the small enums used across element fields. + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "schema")] +use schemars::JsonSchema; + +/// Shared visual style applied to any element (padding, background, frame, …). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ElementStyle { + /// Inset padding (number or per-edge object). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub padding: Option, + /// Solid, adaptive, or gradient background. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + /// Corner radius in points. + #[serde( + rename = "cornerRadius", + default, + skip_serializing_if = "Option::is_none" + )] + pub corner_radius: Option, + /// Opacity from `0` (invisible) to `1` (opaque). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Explicit width / height / max constraints. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub frame: Option, + /// Border color and width. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub border: Option, + /// Drop shadow. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadow: Option, + /// Clip content to a shape (e.g. circle avatar from square image). + #[serde(rename = "clipShape", default, skip_serializing_if = "Option::is_none")] + pub clip_shape: Option, + /// Layout weight for flexible sizing inside stacks (like Android `layout_weight`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flex: Option, +} + +/// Color value — hex string, semantic name, or adaptive `{ light, dark }` pair. +/// +/// Semantic names: `"label"`, `"secondaryLabel"`, `"systemBackground"`, +/// `"secondarySystemBackground"`, `"accent"`, `"separator"`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(untagged)] +pub enum ColorValue { + /// Hex string or semantic color name. + Solid(String), + /// Distinct colors for light and dark appearance. + Adaptive { + /// Color used in light appearance. + light: String, + /// Color used in dark appearance. + dark: String, + }, +} + +impl From<&str> for ColorValue { + fn from(value: &str) -> Self { + ColorValue::Solid(value.to_string()) + } +} + +impl From for ColorValue { + fn from(value: String) -> Self { + ColorValue::Solid(value) + } +} + +/// Clip shape for content masking. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ClipShape { + /// `circle`. + Circle, + /// `capsule`. + Capsule, + /// `rectangle`. + Rectangle, +} + +/// Semantic text style — respects Dynamic Type / accessibility settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TextStyle { + /// `large title`. + LargeTitle, + /// `title`. + Title, + /// `title2`. + Title2, + /// `title3`. + Title3, + /// `headline`. + Headline, + /// `subheadline`. + Subheadline, + /// `body`. + Body, + /// `callout`. + Callout, + /// `footnote`. + Footnote, + /// `caption`. + Caption, + /// `caption2`. + Caption2, +} + +/// Background: solid color string, adaptive pair, gradient, or material blur. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(untagged)] +pub enum BackgroundValue { + /// Hex string or semantic color name. + Solid(String), + /// Linear, radial, or angular gradient. + Gradient(GradientConfig), + /// Distinct colors for light and dark appearance. + Adaptive { + /// Color used in light appearance. + light: String, + /// Color used in dark appearance. + dark: String, + }, +} + +impl From<&str> for BackgroundValue { + fn from(value: &str) -> Self { + BackgroundValue::Solid(value.to_string()) + } +} + +impl From for BackgroundValue { + fn from(value: String) -> Self { + BackgroundValue::Solid(value) + } +} + +/// Linear, radial, or angular gradient background. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct GradientConfig { + /// `"linear"`, `"radial"`, or `"angular"` + #[serde(rename = "gradientType")] + pub gradient_type: GradientType, + /// Stop colors, in order. + pub colors: Vec, + /// Direction for linear gradients + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option, +} + +/// Gradient shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum GradientType { + /// `linear`. + Linear, + /// `radial`. + Radial, + /// `angular`. + Angular, +} + +/// Direction for linear gradients. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum GradientDirection { + /// `top to bottom`. + TopToBottom, + /// `bottom to top`. + BottomToTop, + /// `leading to trailing`. + LeadingToTrailing, + /// `trailing to leading`. + TrailingToLeading, + /// `top leading to bottom trailing`. + TopLeadingToBottomTrailing, + /// `top trailing to bottom leading`. + TopTrailingToBottomLeading, +} + +/// Drop shadow configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct ShadowConfig { + /// Shadow color (hex string or semantic name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + /// Blur radius in points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub radius: Option, + /// Horizontal offset in points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub x: Option, + /// Vertical offset in points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub y: Option, +} + +/// Inset padding — a single uniform value or per-edge overrides. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(untagged)] +pub enum PaddingValue { + /// Same padding on every edge. + Uniform(f64), + /// Independent padding per edge. + Edges { + /// Top edge padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + top: Option, + /// Bottom edge padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + bottom: Option, + /// Leading (left in LTR) edge padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + leading: Option, + /// Trailing (right in LTR) edge padding. + #[serde(default, skip_serializing_if = "Option::is_none")] + trailing: Option, + }, +} + +impl From for PaddingValue { + fn from(value: f64) -> Self { + PaddingValue::Uniform(value) + } +} + +impl From for PaddingValue { + fn from(value: f32) -> Self { + PaddingValue::Uniform(f64::from(value)) + } +} + +impl From for PaddingValue { + fn from(value: i32) -> Self { + PaddingValue::Uniform(f64::from(value)) + } +} + +/// Explicit width / height / max-size constraints for an element. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct FrameConfig { + /// Fixed width in points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + /// Fixed height in points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option, + /// Maximum width — a fixed point value or `"infinity"`. + #[serde(rename = "maxWidth", default, skip_serializing_if = "Option::is_none")] + pub max_width: Option, + /// Maximum height — a fixed point value or `"infinity"`. + #[serde(rename = "maxHeight", default, skip_serializing_if = "Option::is_none")] + pub max_height: Option, +} + +/// A frame dimension — either a fixed point value or `"infinity"`. +#[derive(Debug, Clone)] +pub enum FrameDimension { + /// Fixed point value. + Fixed(f64), + /// `"infinity"`. + Infinity, +} + +#[cfg(feature = "schema")] +impl JsonSchema for FrameDimension { + fn schema_name() -> String { + "FrameDimension".into() + } + + fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::{ + InstanceType, Metadata, Schema, SchemaObject, SingleOrVec, SubschemaValidation, + }; + + Schema::Object(SchemaObject { + metadata: Some(Box::new(Metadata { + description: Some( + "A frame dimension — either a fixed point value or `\"infinity\"`.".into(), + ), + ..Default::default() + })), + subschemas: Some(Box::new(SubschemaValidation { + any_of: Some(vec![ + SchemaObject { + metadata: Some(Box::new(Metadata { + description: Some("Fixed point value.".into()), + ..Default::default() + })), + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Number))), + format: Some("double".into()), + ..Default::default() + } + .into(), + SchemaObject { + metadata: Some(Box::new(Metadata { + description: Some("Keyword `\"infinity\"`.".into()), + ..Default::default() + })), + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec!["infinity".into()]), + ..Default::default() + } + .into(), + ]), + ..Default::default() + })), + ..Default::default() + }) + } +} + +impl Serialize for FrameDimension { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Fixed(v) => serializer.serialize_f64(*v), + Self::Infinity => serializer.serialize_str("infinity"), + } + } +} + +impl<'de> Deserialize<'de> for FrameDimension { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Helper { + Num(f64), + Str(String), + } + match Helper::deserialize(deserializer)? { + Helper::Num(v) => Ok(Self::Fixed(v)), + Helper::Str(s) if s == "infinity" => Ok(Self::Infinity), + Helper::Str(s) => Err(serde::de::Error::custom(format!( + "unknown frame dimension keyword: {s}" + ))), + } + } +} + +/// Border color and width. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct BorderConfig { + /// Border color (hex string or semantic name). + pub color: String, + /// Border width in points. Default `1.0`. + #[serde(default = "default_border_width")] + pub width: f64, +} + +fn default_border_width() -> f64 { + 1.0 +} + +/// Font weight. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum FontWeight { + /// `ultralight`. + Ultralight, + /// `thin`. + Thin, + /// `light`. + Light, + /// `regular`. + Regular, + /// `medium`. + Medium, + /// `semibold`. + Semibold, + /// `bold`. + Bold, + /// `heavy`. + Heavy, + /// `black`. + Black, +} + +/// Font design (default, monospaced, rounded, serif). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum FontDesign { + /// `default`. + Default, + /// `monospaced`. + Monospaced, + /// `rounded`. + Rounded, + /// `serif`. + Serif, +} + +/// Text alignment within the line. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TextAlignment { + /// `leading`. + Leading, + /// `center`. + Center, + /// `trailing`. + Trailing, +} + +/// Horizontal alignment of children within a stack. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum HorizontalAlignment { + /// `leading`. + Leading, + /// `center`. + Center, + /// `trailing`. + Trailing, +} + +/// Vertical alignment of children within a stack. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum VerticalAlignment { + /// `top`. + Top, + /// `center`. + Center, + /// `bottom`. + Bottom, +} + +/// How an image fills its frame. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ContentMode { + /// `fit`. + Fit, + /// `fill`. + Fill, +} + +/// Progress indicator style. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ProgressStyle { + /// `linear`. + Linear, + /// `circular`. + Circular, +} + +/// Gauge visual style. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum GaugeStyle { + /// `circular`. + Circular, + /// `linear`. + Linear, +} + +/// Date / relative-time display style. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum DateStyle { + /// `time`. + Time, + /// `date`. + Date, + /// `relative`. + Relative, + /// `offset`. + Offset, + /// `timer`. + Timer, +} + +/// Chart kind. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + /// `bar`. + #[default] + Bar, + /// `line`. + Line, + /// `area`. + Area, + /// `pie`. + Pie, +} + +/// Shape kind for [`crate::models::ShapeElement`]. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ShapeType { + /// `circle`. + #[default] + Circle, + /// `capsule`. + Capsule, + /// `rectangle`. + Rectangle, +} + +/// Countdown/countup direction for [`crate::models::TimerElement`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum TimerCounting { + /// `up`. + Up, + /// `down`. + Down, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_value_roundtrip_solid_and_adaptive() { + let solid = ColorValue::Solid("#ff0000".into()); + let s = serde_json::to_string(&solid).unwrap(); + let back: ColorValue = serde_json::from_str(&s).unwrap(); + assert!(matches!(back, ColorValue::Solid(ref x) if x == "#ff0000")); + + let adaptive = ColorValue::Adaptive { + light: "#fff".into(), + dark: "#000".into(), + }; + let s = serde_json::to_string(&adaptive).unwrap(); + let back: ColorValue = serde_json::from_str(&s).unwrap(); + assert!(matches!( + back, + ColorValue::Adaptive { + ref light, + ref dark + } if light == "#fff" && dark == "#000" + )); + } + + #[test] + fn padding_value_roundtrip() { + let u = PaddingValue::Uniform(8.0); + let s = serde_json::to_string(&u).unwrap(); + assert_eq!(s, "8.0"); + let back: PaddingValue = serde_json::from_str(&s).unwrap(); + assert!(matches!(back, PaddingValue::Uniform(v) if (v - 8.0).abs() < f64::EPSILON)); + + let edges = PaddingValue::Edges { + top: Some(1.0), + bottom: Some(2.0), + leading: Some(3.0), + trailing: None, + }; + let s = serde_json::to_string(&edges).unwrap(); + let back: PaddingValue = serde_json::from_str(&s).unwrap(); + assert!(matches!(back, PaddingValue::Edges { .. })); + } + + #[test] + fn element_style_default_is_empty_object() { + let s = serde_json::to_string(&ElementStyle::default()).unwrap(); + assert_eq!(s, "{}"); + } + + #[test] + fn border_config_default_width() { + let b: BorderConfig = + serde_json::from_str(r##"{"color":"#ffffff"}"##).unwrap(); + assert!((b.width - 1.0).abs() < f64::EPSILON); + assert_eq!(b.color, "#ffffff"); + } + + #[test] + fn chart_shape_defaults() { + assert!(matches!(ChartType::default(), ChartType::Bar)); + assert!(matches!(ShapeType::default(), ShapeType::Circle)); + } + + #[test] + fn enums_deserialize_camel_case() { + let w: FontWeight = serde_json::from_str(r#""semibold""#).unwrap(); + assert!(matches!(w, FontWeight::Semibold)); + let a: TextAlignment = serde_json::from_str(r#""trailing""#).unwrap(); + assert!(matches!(a, TextAlignment::Trailing)); + let t: TimerCounting = serde_json::from_str(r#""down""#).unwrap(); + assert!(matches!(t, TimerCounting::Down)); + } + + #[test] + fn frame_dimension_accepts_infinity() { + let d: FrameDimension = serde_json::from_str(r#""infinity""#).unwrap(); + assert!(matches!(d, FrameDimension::Infinity)); + } + + #[test] + fn frame_dimension_rejects_unknown_keyword() { + assert!(serde_json::from_str::(r#""auto""#).is_err()); + } +} diff --git a/src/normalize.rs b/src/normalize.rs new file mode 100644 index 0000000..31a553c --- /dev/null +++ b/src/normalize.rs @@ -0,0 +1,364 @@ +//! Host-side IR normalization before store write. +//! +//! Renders must not guess defaults: `textStyle` becomes concrete `fontSize`, +//! semantic colors become adaptive hex pairs, stack `spacing` is explicit. +//! Layout (flex, Dynamic Type reflow) stays on the native renderer. + +use crate::capabilities::{support_for, Support, WidgetPlatform}; +use crate::models::{ + ColorValue, FontWeight, TextElement, TextStyle, WidgetConfig, WidgetElement, +}; + +/// Result of [`normalize`]: config with resolved defaults + recorded degradations. +#[derive(Debug, Clone)] +pub struct NormalizedConfig { + /// Config ready to serialize into the store. + pub config: WidgetConfig, + /// Capability degradations applied / noted (`canvas.path`, `image.url`, …). + pub degraded: Vec, +} + +/// Expand semantic typography / colors / spacing so renderers do not guess. +pub fn normalize(cfg: &WidgetConfig, target: WidgetPlatform) -> NormalizedConfig { + let mut degraded = Vec::new(); + let mut config = cfg.clone(); + if let Some(el) = config.small.as_mut() { + walk_element(el, target, &mut degraded); + } + if let Some(el) = config.medium.as_mut() { + walk_element(el, target, &mut degraded); + } + if let Some(el) = config.large.as_mut() { + walk_element(el, target, &mut degraded); + } + degraded.sort(); + degraded.dedup(); + NormalizedConfig { config, degraded } +} + +fn walk_element(el: &mut WidgetElement, target: WidgetPlatform, degraded: &mut Vec) { + let type_name = el.type_name(); + match support_for(type_name, target).support { + Support::Unsupported => { + degraded.push(format!("{type_name}:unsupported")); + } + Support::Degraded => { + degraded.push(format!("{type_name}:degraded")); + } + Support::Full => {} + } + + match el { + WidgetElement::VStack(v) => { + if v.spacing.is_none() { + v.spacing = Some(0.0); + } + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::HStack(v) => { + if v.spacing.is_none() { + v.spacing = Some(0.0); + } + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::ZStack(v) => { + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::Grid(v) => { + if v.spacing.is_none() { + v.spacing = Some(0.0); + } + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::Container(v) => { + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::Link(v) => { + for c in &mut v.children { + walk_element(c, target, degraded); + } + } + WidgetElement::Text(t) => normalize_text(t), + WidgetElement::Date(d) => { + if let Some(ref mut c) = d.color { + *c = expand_semantic_color(c); + } + } + WidgetElement::Timer(t) => { + if let Some(ref mut c) = t.color { + *c = expand_semantic_color(c); + } + } + WidgetElement::Label(l) => { + if let Some(ref mut c) = l.color { + *c = expand_semantic_color(c); + } + if let Some(ref mut c) = l.icon_color { + *c = expand_semantic_color(c); + } + } + WidgetElement::Image(i) => { + if let Some(ref mut c) = i.color { + *c = expand_semantic_color(c); + } + if i.url.is_some() && matches!(target, WidgetPlatform::Ios | WidgetPlatform::Macos) { + // URL images are prefetched host-side; note residual risk if data empty. + if i.data.as_ref().map(|s| s.is_empty()).unwrap_or(true) { + degraded.push("image.url:prefetch-required".into()); + } + } + } + WidgetElement::Button(b) => { + if let Some(ref mut c) = b.color { + *c = expand_semantic_color(c); + } + if let Some(ref mut c) = b.background_color { + *c = expand_semantic_color(c); + } + } + WidgetElement::Toggle(t) => { + if let Some(ref mut c) = t.tint { + *c = expand_semantic_color(c); + } + } + WidgetElement::Progress(p) => { + if let Some(ref mut c) = p.color { + *c = expand_semantic_color(c); + } + if let Some(ref mut c) = p.tint { + *c = expand_semantic_color(c); + } + } + WidgetElement::Gauge(g) => { + if let Some(ref mut c) = g.color { + *c = expand_semantic_color(c); + } + if let Some(ref mut c) = g.tint { + *c = expand_semantic_color(c); + } + } + WidgetElement::Divider(d) => { + if let Some(ref mut c) = d.color { + *c = expand_semantic_color(c); + } + } + WidgetElement::List(l) => { + if let Some(ref mut c) = l.color { + *c = expand_semantic_color(c); + } + if l.spacing.is_none() { + l.spacing = Some(4.0); + } + } + WidgetElement::Chart(c) => { + if let Some(ref mut t) = c.tint { + *t = expand_semantic_color(t); + } + } + WidgetElement::Shape(s) => { + if let Some(ref mut c) = s.fill { + *c = expand_semantic_color(c); + } + if let Some(ref mut c) = s.stroke { + *c = expand_semantic_color(c); + } + } + WidgetElement::Canvas(_) | WidgetElement::Spacer(_) => {} + } + + // Shared ElementStyle colors on every variant that flattens style. + expand_style_colors(el); +} + +fn expand_style_colors(el: &mut WidgetElement) { + let style = match el { + WidgetElement::VStack(v) => Some(&mut v.style), + WidgetElement::HStack(v) => Some(&mut v.style), + WidgetElement::ZStack(v) => Some(&mut v.style), + WidgetElement::Grid(v) => Some(&mut v.style), + WidgetElement::Container(v) => Some(&mut v.style), + WidgetElement::Text(v) => Some(&mut v.style), + WidgetElement::Image(v) => Some(&mut v.style), + WidgetElement::Progress(v) => Some(&mut v.style), + WidgetElement::Gauge(v) => Some(&mut v.style), + WidgetElement::Button(v) => Some(&mut v.style), + WidgetElement::Toggle(v) => Some(&mut v.style), + WidgetElement::Divider(v) => Some(&mut v.style), + WidgetElement::Spacer(_) => None, + WidgetElement::Date(v) => Some(&mut v.style), + WidgetElement::Chart(v) => Some(&mut v.style), + WidgetElement::List(v) => Some(&mut v.style), + WidgetElement::Link(v) => Some(&mut v.style), + WidgetElement::Shape(v) => Some(&mut v.style), + WidgetElement::Timer(v) => Some(&mut v.style), + WidgetElement::Label(v) => Some(&mut v.style), + WidgetElement::Canvas(v) => Some(&mut v.style), + }; + if let Some(style) = style { + if let Some(ref mut bg) = style.background { + if let crate::models::BackgroundValue::Solid(s) = bg { + if let Some(pair) = semantic_pair(s) { + *bg = crate::models::BackgroundValue::Adaptive { + light: pair.0.into(), + dark: pair.1.into(), + }; + } + } + } + if let Some(ref mut border) = style.border { + if let Some((light, dark)) = semantic_pair(&border.color) { + // Borders are single-color on wire; pick dark-friendly default hex. + let _ = light; + border.color = dark.to_string(); + } + } + } +} + +fn normalize_text(t: &mut TextElement) { + if let Some(ts) = t.text_style.take() { + let (size, weight) = text_style_metrics(&ts); + if t.font_size.is_none() { + t.font_size = Some(size); + } + if t.font_weight.is_none() { + t.font_weight = weight; + } + } + if let Some(ref mut c) = t.color { + *c = expand_semantic_color(c); + } +} + +/// Approximate Dynamic Type → points (body = 17), matching Apple defaults. +fn text_style_metrics(ts: &TextStyle) -> (f64, Option) { + match ts { + TextStyle::LargeTitle => (34.0, Some(FontWeight::Regular)), + TextStyle::Title => (28.0, Some(FontWeight::Regular)), + TextStyle::Title2 => (22.0, Some(FontWeight::Regular)), + TextStyle::Title3 => (20.0, Some(FontWeight::Regular)), + TextStyle::Headline => (17.0, Some(FontWeight::Semibold)), + TextStyle::Body => (17.0, Some(FontWeight::Regular)), + TextStyle::Callout => (16.0, Some(FontWeight::Regular)), + TextStyle::Subheadline => (15.0, Some(FontWeight::Regular)), + TextStyle::Footnote => (13.0, Some(FontWeight::Regular)), + TextStyle::Caption => (12.0, Some(FontWeight::Regular)), + TextStyle::Caption2 => (11.0, Some(FontWeight::Regular)), + } +} + +fn expand_semantic_color(c: &ColorValue) -> ColorValue { + match c { + ColorValue::Solid(s) => { + if let Some((light, dark)) = semantic_pair(s) { + ColorValue::Adaptive { + light: light.to_string(), + dark: dark.to_string(), + } + } else { + c.clone() + } + } + ColorValue::Adaptive { .. } => c.clone(), + } +} + +fn semantic_pair(name: &str) -> Option<(&'static str, &'static str)> { + match name { + "label" => Some(("#000000", "#FFFFFF")), + "secondaryLabel" => Some(("#3C3C43", "#EBEBF5")), + "systemBackground" => Some(("#FFFFFF", "#000000")), + "secondarySystemBackground" => Some(("#F2F2F7", "#1C1C1E")), + "accent" => Some(("#007AFF", "#0A84FF")), + "separator" => Some(("#C6C6C8", "#545458")), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{text, vstack, FontWeight, TextStyle, WidgetConfig}; + use std::fs; + use std::path::PathBuf; + + #[test] + fn text_style_becomes_font_size() { + let mut el = text("Hi"); + el.text_style = Some(TextStyle::Title); + el.font_size = None; + let cfg = WidgetConfig::small(el); + let out = normalize(&cfg, WidgetPlatform::Desktop); + match out.config.small.unwrap() { + WidgetElement::Text(t) => { + assert!(t.text_style.is_none()); + assert_eq!(t.font_size, Some(28.0)); + assert!(matches!(t.font_weight, Some(FontWeight::Regular))); + } + other => panic!("expected text, got {other:?}"), + } + } + + #[test] + fn stack_spacing_defaults_to_zero() { + let cfg = WidgetConfig::small(vstack(vec![text("a").into(), text("b").into()])); + let out = normalize(&cfg, WidgetPlatform::Ios); + match out.config.small.unwrap() { + WidgetElement::VStack(v) => assert_eq!(v.spacing, Some(0.0)), + other => panic!("expected vstack, got {other:?}"), + } + } + + #[test] + fn semantic_label_becomes_adaptive() { + let mut el = text("Hi"); + el.color = Some(ColorValue::Solid("label".into())); + let out = normalize(&WidgetConfig::small(el), WidgetPlatform::Desktop); + match out.config.small.unwrap() { + WidgetElement::Text(t) => match t.color.unwrap() { + ColorValue::Adaptive { light, dark } => { + assert_eq!(light, "#000000"); + assert_eq!(dark, "#FFFFFF"); + } + other => panic!("expected adaptive, got {other:?}"), + }, + other => panic!("expected text, got {other:?}"), + } + } + + #[test] + fn fixtures_normalize_without_panic() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let mut n = 0; + for entry in fs::read_dir(&root).unwrap() { + let entry = entry.unwrap(); + if entry.path().is_dir() { + for f in fs::read_dir(entry.path()).unwrap() { + let f = f.unwrap().path(); + if f.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let raw = fs::read_to_string(&f).unwrap(); + let cfg: WidgetConfig = match serde_json::from_str(&raw) { + Ok(c) => c, + Err(_) => continue, // event before/after blobs etc. + }; + let _ = normalize(&cfg, WidgetPlatform::Desktop); + let _ = normalize(&cfg, WidgetPlatform::Windows); + n += 1; + } + } + } + assert!(n > 10, "expected to normalize many fixtures, got {n}"); + } +} diff --git a/src/rasterize.rs b/src/rasterize.rs index ab2ee0d..de7a22b 100644 --- a/src/rasterize.rs +++ b/src/rasterize.rs @@ -1,9 +1,13 @@ //! SVG builders + optional PNG rasterize for Adaptive Cards Image data URIs. //! -//! Used for `chart` / `canvas` / `gauge` nodes that Adaptive Cards cannot express natively. +//! Used for `chart` / `canvas` / `gauge` / `shape` / `zstack` / gradient +//! backgrounds that Adaptive Cards cannot express natively. use crate::models::{ - CanvasDrawCommand, ChartDataPoint, ChartType, ColorValue, GaugeStyle, ShapeType, WidgetElement, GaugeElement, ChartElement, ShapeElement, CanvasElement, + CanvasDrawCommand, ChartDataPoint, ChartType, ColorValue, ElementStyle, GaugeStyle, + GradientConfig, GradientDirection, GradientType, ProgressElement, ProgressStyle, ShapeType, + WidgetElement, GaugeElement, ChartElement, ShapeElement, CanvasElement, TextElement, + ImageElement, ZStackElement, BackgroundValue, }; use std::f64::consts::PI; @@ -45,6 +49,22 @@ pub fn element_to_svg(el: &WidgetElement) -> Option { current_value_label.as_deref(), label.as_deref(), )), + WidgetElement::Progress(ProgressElement { + value, + total, + tint, + label, + bar_style, + .. + }) if matches!(bar_style, Some(ProgressStyle::Circular)) => Some(gauge_svg( + *value, + 0.0, + if *total <= 0.0 { 1.0 } else { *total }, + tint.as_ref(), + Some(&GaugeStyle::Circular), + None, + label.as_deref(), + )), WidgetElement::Shape(ShapeElement { shape_type, fill, @@ -59,10 +79,193 @@ pub fn element_to_svg(el: &WidgetElement) -> Option { stroke_width.unwrap_or(1.0), size.unwrap_or(24.0), )), + WidgetElement::ZStack(ZStackElement { + children, style, .. + }) => zstack_svg(children, style), _ => None, } } +/// Build an SVG gradient fill for Adaptive Cards `backgroundImage` baking. +pub fn gradient_to_svg(g: &GradientConfig, width: f64, height: f64) -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static GRAD_ID: AtomicU64 = AtomicU64::new(1); + let id = format!("g{}", GRAD_ID.fetch_add(1, Ordering::Relaxed)); + let w = width.max(8.0); + let h = height.max(8.0); + let colors = if g.colors.is_empty() { + vec!["#6366f1".into(), "#a855f7".into()] + } else { + g.colors.clone() + }; + let stops: String = colors + .iter() + .enumerate() + .map(|(i, c)| { + let off = if colors.len() == 1 { + 0.0 + } else { + i as f64 / (colors.len() - 1) as f64 + }; + format!( + r#""#, + off * 100.0, + esc(c) + ) + }) + .collect(); + match g.gradient_type { + GradientType::Radial => format!( + r#"{stops}"# + ), + GradientType::Angular => { + // Approximate angular as a linear sweep — good enough for AC bake. + format!( + r#"{stops}"# + ) + } + GradientType::Linear => { + let (x1, y1, x2, y2) = match g.direction.as_ref() { + Some(GradientDirection::LeadingToTrailing) => ("0%", "0%", "100%", "0%"), + Some(GradientDirection::TrailingToLeading) => ("100%", "0%", "0%", "0%"), + Some(GradientDirection::BottomToTop) => ("0%", "100%", "0%", "0%"), + Some(GradientDirection::TopLeadingToBottomTrailing) => ("0%", "0%", "100%", "100%"), + Some(GradientDirection::TopTrailingToBottomLeading) => ("100%", "0%", "0%", "100%"), + _ => ("0%", "0%", "0%", "100%"), // topToBottom default + }; + format!( + r#"{stops}"# + ) + } + } +} + +/// Rasterize a gradient background to a PNG data URI when `rasterize` is enabled. +pub fn gradient_to_png_data_uri(g: &GradientConfig) -> Result { + svg_to_data_uri(&gradient_to_svg(g, 320.0, 200.0)) +} + +fn zstack_svg(children: &[WidgetElement], style: &ElementStyle) -> Option { + const W: f64 = 160.0; + const H: f64 = 160.0; + let mut layers = String::new(); + // Bake background so AC Image isn't transparent when style.background is set. + if let Some(bg) = &style.background { + match bg { + BackgroundValue::Solid(s) => { + let fill = normalize_color(s); + layers.push_str(&format!( + r#""# + )); + } + BackgroundValue::Adaptive { light, .. } => { + let fill = normalize_color(light); + layers.push_str(&format!( + r#""# + )); + } + BackgroundValue::Gradient(g) => { + let grad_svg = gradient_to_svg(g, W, H); + let inner = strip_outer_svg(&grad_svg); + layers.push_str(&format!( + r#"{inner}"# + )); + } + } + } + let mut drew = 0usize; + for child in children { + match child { + WidgetElement::Text(TextElement { + content, + font_size, + color, + .. + }) => { + let size = font_size.unwrap_or(16.0); + let fill = color_str(color.as_ref(), "#ffffff"); + layers.push_str(&format!( + r#"{}"#, + W / 2.0, + H / 2.0, + esc(content) + )); + drew += 1; + } + WidgetElement::Image(ImageElement { + system_name, + size, + color, + .. + }) => { + let glyph = system_name + .as_deref() + .map(|n| crate::adaptive_card::sf_symbol_glyph_public(n)) + .unwrap_or("•"); + let sz = size.unwrap_or(28.0); + let fill = color_str(color.as_ref(), "#ffffff"); + layers.push_str(&format!( + r#"{}"#, + W / 2.0, + H / 2.0, + esc(glyph) + )); + drew += 1; + } + other => { + let Some(inner) = element_to_svg(other) else { + // Any unrenderable child → fall back so AC can render the full tree. + return None; + }; + let (vbx, vby, vbw, vbh) = extract_viewbox(&inner).unwrap_or((0.0, 0.0, W, H)); + layers.push_str(&format!( + r#"{inner_body}"#, + inner_body = strip_outer_svg(&inner) + )); + drew += 1; + } + } + } + if drew == 0 { + return None; + } + Some(format!( + r#"{layers}"# + )) +} + +fn extract_viewbox(svg: &str) -> Option<(f64, f64, f64, f64)> { + let re = regex_lite_viewbox(svg)?; + Some(re) +} + +/// Tiny viewBox extractor — avoids a regex crate dependency. +fn regex_lite_viewbox(svg: &str) -> Option<(f64, f64, f64, f64)> { + let key = "viewBox=\""; + let start = svg.find(key)? + key.len(); + let end = svg[start..].find('"')? + start; + let parts: Vec = svg[start..end] + .split_whitespace() + .filter_map(|p| p.parse().ok()) + .collect(); + if parts.len() == 4 { + Some((parts[0], parts[1], parts[2], parts[3])) + } else { + None + } +} + +fn strip_outer_svg(svg: &str) -> String { + let s = svg.trim(); + if let Some(start) = s.find('>') { + let inner = &s[start + 1..]; + if let Some(end) = inner.rfind("") { + return inner[..end].to_string(); + } + } + s.to_string() +} + /// Rasterize SVG to `data:image/png;base64,…` when the `rasterize` feature is on. pub fn svg_to_data_uri(svg: &str) -> Result { #[cfg(feature = "rasterize")] diff --git a/src/receipt.rs b/src/receipt.rs index e1f2779..e5446db 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -16,14 +16,18 @@ use crate::store::now_ms; /// Prefs / file basename for the receipt bag (outside config DataMap). pub const RECEIPTS_STORE_NAME: &str = "__tauri_widget_receipts__"; +/// On-disk filename under the app data / widgets dir. pub const RECEIPTS_FILE_NAME: &str = "widget_receipts.json"; const HISTORY_CAP: usize = 32; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +/// Element the renderer could not paint (capability / parse). pub struct SkippedElement { + /// IR type name (`chart`, `canvas`, …). #[serde(rename = "type")] pub type_name: String, + /// Why it was skipped. pub reason: String, } @@ -31,14 +35,18 @@ pub struct SkippedElement { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WidgetRenderReceipt { + /// Logical widget id from JS / IR. pub widget_id: String, + /// App Group / prefs group. pub group: String, /// `appWidgetId` | WidgetFamily | window label pub instance: String, /// Config-map nonce that was rendered (0 if unknown). pub nonce: u64, + /// Family / slot size label when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub size: Option, + /// Light / dark when reported. #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, /// IR schema version the renderer understands. @@ -49,10 +57,13 @@ pub struct WidgetRenderReceipt { /// Why this paint ran: `reload` | `timeline` | `action` | `added` | `resize` | `snapshot`. #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger: Option, + /// IR type names that painted successfully. #[serde(default)] pub rendered: Vec, + /// Elements skipped with reasons. #[serde(default)] pub skipped: Vec, + /// Unix ms when this receipt was written. pub ts: u64, } @@ -61,6 +72,7 @@ fn default_schema() -> u32 { } impl WidgetRenderReceipt { + /// Fill `ts` with [`now_ms`] if still zero. pub fn touch_ts(mut self) -> Self { if self.ts == 0 { self.ts = now_ms(); @@ -76,10 +88,12 @@ pub struct ReceiptStore { } impl ReceiptStore { + /// Empty in-memory store. pub fn new() -> Self { Self::default() } + /// Append a receipt to the per-instance history ring. pub fn upsert(&self, receipt: WidgetRenderReceipt) { let receipt = receipt.touch_ts(); let mut guard = self.by_group.lock().unwrap(); @@ -122,6 +136,7 @@ impl ReceiptStore { .unwrap_or_default() } + /// Instance ids with a recent receipt for `widget_id` (age ≤ `max_age_ms`). pub fn live_instances(&self, group: &str, widget_id: &str, max_age_ms: u64) -> Vec { let now = now_ms(); self.list(group) @@ -131,6 +146,7 @@ impl ReceiptStore { .collect() } + /// Persist all groups to JSON at `path`. pub fn save_to_path(&self, path: &Path) -> crate::Result<()> { let all: HashMap> = self .by_group @@ -153,6 +169,7 @@ impl ReceiptStore { Ok(()) } + /// Load receipts from disk (best-effort; ignores corrupt files). pub fn load_from_path(&self, path: &Path) { let Ok(raw) = fs::read_to_string(path) else { return; diff --git a/src/snapshot.rs b/src/snapshot.rs index b656a09..6967907 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -3,7 +3,9 @@ use crate::models::{WidgetConfig, WidgetElement, VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, ButtonElement, DividerElement, SpacerElement, LinkElement, ShapeElement}; use serde_json::{json, Value}; -/// Core elements with a strict cross-platform snapshot contract. +/// Core elements present on every platform with preserved IR semantics. +/// Pixel-identical rendering is not guaranteed — see degraded cells in the +/// capability matrix (`image.systemName`, Windows `zstack` / `shape`, …). pub const CORE_ELEMENTS: &[&str] = &[ "vstack", "hstack", @@ -25,6 +27,7 @@ pub const EXTENDED_ELEMENTS: &[&str] = &[ "gauge", "toggle", "date", "chart", "list", "timer", "canvas", "label", ]; +/// `true` when `ty` is in [`CORE_ELEMENTS`]. pub fn is_core_element(ty: &str) -> bool { CORE_ELEMENTS.contains(&ty) } diff --git a/src/store.rs b/src/store.rs index a4aecad..f0f3d41 100644 --- a/src/store.rs +++ b/src/store.rs @@ -18,6 +18,7 @@ pub const META_NONCE_KEY: &str = "__meta_nonce__"; /// Unix epoch milliseconds of last map write. pub const META_UPDATED_AT_KEY: &str = "__meta_updated_at__"; +/// String key/value bag persisted by transports. pub type DataMap = HashMap; /// Build the storage key for a widget UI config. @@ -67,16 +68,33 @@ pub fn touch_meta_above(map: &mut DataMap, floor: u64) { map.insert(META_UPDATED_AT_KEY.into(), now_ms().to_string()); } -/// Pick the freshest map among candidates (max nonce, then max updatedAt). +/// Whether this map carries at least one `config:{widgetId}` payload. +pub fn map_has_config(map: &DataMap) -> bool { + map.keys().any(|k| k.starts_with(CONFIG_KEY_PREFIX)) +} + +/// Pick the freshest map among candidates. +/// +/// Maps that carry a `config:*` key always beat probe-only / empty siblings, +/// even when the empty sibling has a higher nonce (pending-clear bumps). +/// Among equals on that axis: max nonce, then max updatedAt. pub fn pick_freshest(maps: impl IntoIterator) -> DataMap { let mut best: Option = None; + let mut best_has_config = false; let mut best_nonce = 0u64; let mut best_ts = 0u64; for map in maps { + let has_config = map_has_config(&map); let n = map_nonce(&map); let t = map_updated_at(&map); - let better = best.is_none() || n > best_nonce || (n == best_nonce && t > best_ts); + let better = match &best { + None => true, + Some(_) if has_config && !best_has_config => true, + Some(_) if !has_config && best_has_config => false, + Some(_) => n > best_nonce || (n == best_nonce && t > best_ts), + }; if better { + best_has_config = has_config; best_nonce = n; best_ts = t; best = Some(map); @@ -89,16 +107,21 @@ pub fn pick_freshest(maps: impl IntoIterator) -> DataMap { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WidgetActionEnvelope { + /// Action verb (button / toggle / list row). pub action: String, + /// Optional opaque payload string. #[serde(default)] pub payload: Option, /// Unix epoch milliseconds when the action was enqueued. pub ts: u64, + /// Widget id that emitted the action. pub widget_id: String, + /// App Group / prefs group key. pub group: String, } impl WidgetActionEnvelope { + /// Build an envelope with `ts = now`. pub fn new( action: impl Into, payload: Option, @@ -141,4 +164,24 @@ mod tests { touch_meta_above(&mut map, 65); assert_eq!(map_nonce(&map), 66); } + + #[test] + fn pick_freshest_prefers_config_over_higher_nonce_probe() { + let mut probe = DataMap::new(); + probe.insert("__probe__".into(), "1".into()); + probe.insert(META_NONCE_KEY.into(), "99".into()); + probe.insert(META_UPDATED_AT_KEY.into(), "999".into()); + + let mut live = DataMap::new(); + live.insert("config:example".into(), r#"{"version":1}"#.into()); + live.insert(META_NONCE_KEY.into(), "4".into()); + live.insert(META_UPDATED_AT_KEY.into(), "100".into()); + + let picked = pick_freshest([probe, live]); + assert!( + picked.contains_key("config:example"), + "probe-only sibling must not wipe live config" + ); + assert_eq!(map_nonce(&picked), 4); + } } diff --git a/src/trace.rs b/src/trace.rs index fe9b2e1..03f1462 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -18,6 +18,7 @@ use crate::store::now_ms; const RING_CAP: usize = 200; const FLUSH_INTERVAL: Duration = Duration::from_secs(10); +/// On-disk journal filename under the widgets data dir. pub const TRACE_FILE_NAME: &str = "widget_trace.json"; /// Whether the in-memory / disk journal is active. @@ -31,63 +32,72 @@ pub fn trace_enabled() -> bool { ) } -/// Why a config write was skipped (trace mirror of apply types). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "reason", rename_all = "camelCase")] -pub enum TraceSkipReason { - Unchanged { hash: u64 }, - NoInstances, - TransportUnavailable { name: String }, -} - -impl From for TraceSkipReason { - fn from(s: SkipReason) -> Self { - match s { - SkipReason::Unchanged { hash } => Self::Unchanged { hash }, - } - } -} - /// One journal entry. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum TraceEvent { + /// Host applied (or skipped) a config write. ConfigSet { + /// Widget id. widget_id: String, + /// Map nonce after the write attempt. nonce: u64, + /// Serialized config size. bytes: usize, + /// `true` when store bytes changed. changed: bool, + /// Present when the write was skipped. #[serde(default, skip_serializing_if = "Option::is_none")] - skip: Option, + skip: Option, }, + /// A transport `write` finished. Write { + /// Driver name. transport: String, + /// Success flag. ok: bool, + /// Wall time of the write. duration_ms: u32, + /// Error text when `ok` is false. #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, }, + /// Native / WidgetKit reload attempt. Reload { + /// Whether reload was invoked. performed: bool, + /// Outcome detail. reason: ReloadOutcome, }, + /// Host drained pending actions. Poll { + /// Actions returned this poll. count: usize, }, + /// Native / desktop surface painted. Render { + /// Instance id (family / appWidgetId / window). instance: String, + /// Config nonce observed. nonce: u64, + /// Transport / prefs source label. source: String, + /// Why paint ran (`reload`, `timeline`, …). trigger: String, + /// Host write → paint lag. lag_ms: u64, + /// Elements skipped this paint. skipped: Vec, }, } +/// Timestamped [`TraceEvent`] in the ring. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TraceEntry { + /// Unix ms. pub ts: u64, + /// Event payload (flattened in JSON). #[serde(flatten)] pub event: TraceEvent, } @@ -96,8 +106,11 @@ pub struct TraceEntry { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WidgetTrace { + /// Whether journaling is active in this process. pub enabled: bool, + /// Ring buffer contents (oldest → newest). pub events: Vec, + /// Latest receipts from the companion store. pub receipts: Vec, } @@ -116,10 +129,12 @@ pub struct TraceStore { } impl TraceStore { + /// Empty store. pub fn new() -> Self { Self::default() } + /// Append an event when tracing is enabled. pub fn push(&self, event: TraceEvent) { if !trace_enabled() { return; @@ -135,6 +150,7 @@ impl TraceStore { g.dirty = true; } + /// Events with `ts >= since_ms` (or all when `None`). pub fn list_since(&self, since_ms: Option) -> Vec { let g = self.inner.lock().unwrap(); g.events @@ -144,6 +160,7 @@ impl TraceStore { .collect() } + /// Write the ring to disk if dirty. pub fn flush_to_path(&self, path: &Path) -> crate::Result<()> { if !trace_enabled() { return Ok(()); @@ -168,6 +185,7 @@ impl TraceStore { Ok(()) } + /// Replace the ring from a previous flush (best-effort). pub fn load_from_path(&self, path: &Path) { if !trace_enabled() { return; @@ -186,6 +204,7 @@ impl TraceStore { g.dirty = false; } + /// `true` when dirty and the flush interval has elapsed. pub fn needs_timed_flush(&self) -> bool { if !trace_enabled() { return false; diff --git a/src/transport.rs b/src/transport.rs index 72dfb50..ff872e0 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -14,8 +14,11 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +/// Driver name written into widget receipts (`appgroup` file transport). pub const NAME_APPGROUP: &str = "appgroup"; +/// Driver name for App Group `UserDefaults` suite. pub const NAME_DEFAULTS: &str = "defaults"; +/// Driver name for widget extension container file. pub const NAME_CONTAINER: &str = "container"; /// Widget-side ack that it rendered a config from a specific transport. @@ -32,13 +35,19 @@ pub struct Receipt { pub ts: u64, } +/// Host-side read/write channel to the widget extension. pub trait Transport: Send + Sync { + /// Stable driver id (`appgroup` / `defaults` / `container`). fn name(&self) -> &'static str; /// Cheap local probe — must NOT be treated as delivery proof by itself. fn available(&self) -> bool; + /// Read the config map if present. fn read(&self) -> Option; + /// Persist the config map (bumps meta via caller). fn write(&self, map: &DataMap) -> crate::Result<()>; + /// Widget-side ack for the last painted nonce. fn read_receipt(&self) -> Option; + /// Host-written ack (rare; mostly widget writes receipts). fn write_receipt(&self, receipt: &Receipt) -> crate::Result<()>; } @@ -173,7 +182,7 @@ fn probe_once(cfg: &WidgetsPluginConfig) -> crate::Result> { let mut probe = candidates .iter() .filter_map(|t| t.read()) - .max_by_key(|m| map_nonce(m)) + .max_by_key(map_nonce) .unwrap_or_default(); let floor = candidates .iter() @@ -262,21 +271,25 @@ pub fn probe_once_among( .ok_or_else(|| Error::new("probe fallback missing")) } +/// Thin alias of [`store::pick_freshest`] for transport callers. pub fn pick_freshest_maps(maps: Vec) -> DataMap { store::pick_freshest(maps) } // ─── Fake transport (unit tests) ───────────────────────────────────────────── +/// In-memory transport for unit tests. pub struct FakeTransport { name: &'static str, available: AtomicBool, map: Mutex>, receipt: Mutex>, + /// How many successful `write` calls occurred. pub writes: AtomicU64, } impl FakeTransport { + /// Available transport with empty map/receipt. pub fn new(name: &'static str) -> Arc { Arc::new(Self { name, @@ -287,14 +300,17 @@ impl FakeTransport { }) } + /// Toggle [`Transport::available`]. pub fn set_available(&self, v: bool) { self.available.store(v, Ordering::Relaxed); } + /// Inject a widget receipt without going through write_receipt. pub fn plant_receipt(&self, receipt: Receipt) { *self.receipt.lock().unwrap() = Some(receipt); } + /// Snapshot of [`Self::writes`]. pub fn write_count(&self) -> u64 { self.writes.load(Ordering::Relaxed) } diff --git a/swift/Sources/TauriWidgets/Colors.swift b/swift/Sources/TauriWidgets/Colors.swift new file mode 100644 index 0000000..f75d0f7 --- /dev/null +++ b/swift/Sources/TauriWidgets/Colors.swift @@ -0,0 +1,75 @@ +import SwiftUI + +// MARK: - Color helpers + +extension Color { + public init(hex: String) { + var h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + if h.count == 3 || h.count == 4 { + h = h.map { "\($0)\($0)" }.joined() + } + var rgb: UInt64 = 0; Scanner(string: h).scanHexInt64(&rgb) + let r, g, b, a: Double + switch h.count { + case 6: + r = Double((rgb >> 16) & 0xFF) / 255 + g = Double((rgb >> 8) & 0xFF) / 255 + b = Double(rgb & 0xFF) / 255; a = 1 + case 8: + r = Double((rgb >> 24) & 0xFF) / 255 + g = Double((rgb >> 16) & 0xFF) / 255 + b = Double((rgb >> 8) & 0xFF) / 255 + a = Double(rgb & 0xFF) / 255 + default: + r = 0; g = 0; b = 0; a = 1 + } + self.init(red: r, green: g, blue: b, opacity: a) + } + + static func adaptive(light: String, dark: String) -> Color { + #if canImport(UIKit) + return Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(Color(hex: dark)) + : UIColor(Color(hex: light)) + }) + #elseif canImport(AppKit) + return Color(NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? NSColor(Color(hex: dark)) + : NSColor(Color(hex: light)) + }) + #else + return Color(hex: light) + #endif + } + + static func semantic(_ name: String) -> Color? { + switch name { + case "label": return .primary + case "secondaryLabel": return .secondary + case "accent": return .accentColor + #if canImport(UIKit) + case "systemBackground": return Color(UIColor.systemBackground) + case "secondarySystemBackground": return Color(UIColor.secondarySystemBackground) + case "separator": return Color(UIColor.separator) + #elseif canImport(AppKit) + case "systemBackground": return Color(NSColor.windowBackgroundColor) + case "secondarySystemBackground": return Color(NSColor.controlBackgroundColor) + case "separator": return Color(NSColor.separatorColor) + #endif + default: return nil + } + } +} + +func resolveColor(_ cv: ColorValue?) -> Color? { + guard let cv = cv else { return nil } + switch cv { + case .solid(let s): + if let sem = Color.semantic(s) { return sem } + return Color(hex: s) + case .adaptive(let l, let d): + return Color.adaptive(light: l, dark: d) + } +} diff --git a/swift/Sources/TauriWidgets/DynamicElementView.swift b/swift/Sources/TauriWidgets/DynamicElementView.swift index 46f35c5..13e5e25 100644 --- a/swift/Sources/TauriWidgets/DynamicElementView.swift +++ b/swift/Sources/TauriWidgets/DynamicElementView.swift @@ -1,79 +1,5 @@ import SwiftUI -// MARK: - Color helpers - -extension Color { - public init(hex: String) { - var h = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) - if h.count == 3 || h.count == 4 { - h = h.map { "\($0)\($0)" }.joined() - } - var rgb: UInt64 = 0; Scanner(string: h).scanHexInt64(&rgb) - let r, g, b, a: Double - switch h.count { - case 6: - r = Double((rgb >> 16) & 0xFF) / 255 - g = Double((rgb >> 8) & 0xFF) / 255 - b = Double(rgb & 0xFF) / 255; a = 1 - case 8: - r = Double((rgb >> 24) & 0xFF) / 255 - g = Double((rgb >> 16) & 0xFF) / 255 - b = Double((rgb >> 8) & 0xFF) / 255 - a = Double(rgb & 0xFF) / 255 - default: - r = 0; g = 0; b = 0; a = 1 - } - self.init(red: r, green: g, blue: b, opacity: a) - } - - static func adaptive(light: String, dark: String) -> Color { - #if canImport(UIKit) - return Color(UIColor { traits in - traits.userInterfaceStyle == .dark - ? UIColor(Color(hex: dark)) - : UIColor(Color(hex: light)) - }) - #elseif canImport(AppKit) - return Color(NSColor(name: nil) { appearance in - appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - ? NSColor(Color(hex: dark)) - : NSColor(Color(hex: light)) - }) - #else - return Color(hex: light) - #endif - } - - static func semantic(_ name: String) -> Color? { - switch name { - case "label": return .primary - case "secondaryLabel": return .secondary - case "accent": return .accentColor - #if canImport(UIKit) - case "systemBackground": return Color(UIColor.systemBackground) - case "secondarySystemBackground": return Color(UIColor.secondarySystemBackground) - case "separator": return Color(UIColor.separator) - #elseif canImport(AppKit) - case "systemBackground": return Color(NSColor.windowBackgroundColor) - case "secondarySystemBackground": return Color(NSColor.controlBackgroundColor) - case "separator": return Color(NSColor.separatorColor) - #endif - default: return nil - } - } -} - -func resolveColor(_ cv: ColorValue?) -> Color? { - guard let cv = cv else { return nil } - switch cv { - case .solid(let s): - if let sem = Color.semantic(s) { return sem } - return Color(hex: s) - case .adaptive(let l, let d): - return Color.adaptive(light: l, dark: d) - } -} - // MARK: - Recursive Renderer public struct DynamicElementView: View { @@ -102,660 +28,21 @@ public struct DynamicElementView: View { @ViewBuilder private func renderElement() -> some View { switch element.type { - case "vstack": renderVStack() - case "hstack": renderHStack() - case "zstack": renderZStack() - case "grid": renderGrid() - case "container": renderContainer() - case "text": renderText() - case "image": renderImage() - case "progress": renderProgress() - case "gauge": renderGauge() - case "button": renderButton() - case "toggle": renderToggle() - case "divider": renderDivider() - case "spacer": renderSpacer() - case "date": renderDate() - case "chart": renderChart() - case "list": renderList() - case "link": renderLink() - case "shape": renderShape() - case "timer": renderTimer() - case "label": renderLabel() - case "canvas": renderCanvas() - default: EmptyView() - } - } - - // MARK: Containers - - @ViewBuilder private func renderVStack() -> some View { - let align: HorizontalAlignment = element.alignment == "leading" ? .leading - : element.alignment == "trailing" ? .trailing : .center - VStack(alignment: align, spacing: element.spacing ?? 0) { renderChildren(axis: .vertical) } - } - - @ViewBuilder private func renderHStack() -> some View { - let align: VerticalAlignment = element.alignment == "top" ? .top - : element.alignment == "bottom" ? .bottom : .center - HStack(alignment: align, spacing: element.spacing ?? 0) { renderChildren(axis: .horizontal) } - } - - @ViewBuilder private func renderZStack() -> some View { - ZStack(alignment: parseAlignment(element.alignment)) { - if let children = element.children { - ForEach(children.indices, id: \.self) { idx in - DynamicElementView(element: children[idx], inZStack: true) - } - } - } - } - - @ViewBuilder private func renderGrid() -> some View { - let cols = element.columns ?? 2; let sp = element.spacing ?? 4 - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: sp), count: Int(cols)), - spacing: element.rowSpacing ?? sp - ) { renderChildren() } - } - - @ViewBuilder private func renderContainer() -> some View { - let a = parseAlignment(element.contentAlignment) - if let children = element.children, !children.isEmpty { - ZStack(alignment: a) { - ForEach(children.indices, id: \.self) { idx in - DynamicElementView(element: children[idx]) - } - } - // Expand so contentAlignment centers inside frame/padding, not intrinsic hug. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: a) - } else { - EmptyView() - } - } - - @ViewBuilder private func renderChildren(axis: Axis? = nil) -> some View { - if let children = element.children { - ForEach(children.indices, id: \.self) { - DynamicElementView(element: children[$0], parentAxis: axis) - } - } - } - - // MARK: Text - - @ViewBuilder private func renderText() -> some View { - let txt: Text = { - let t = Text(element.content ?? "") - if let ts = textStyleFont(element.textStyle) { - return t.font(ts.weight(fontWeight(element.fontWeight))) - } - return t.font(.system(size: element.fontSize ?? 14, - weight: fontWeight(element.fontWeight), - design: fontDesign(element.fontDesign))) - }() - let colored = txt.foregroundColor(resolveColor(element.color) ?? .primary) - // Circle / fixed box badges (e.g. avatar initials) center by default — leading + - // maxWidth:.infinity pinned "AK" to the top-left of the clip. - let hasFixedBox = element.frame?.width != nil && element.frame?.height != nil - let defaultAlign = (element.clipShape == "circle" || hasFixedBox) ? "center" : "leading" - let alignRaw = (element.alignment ?? defaultAlign).lowercased() - let textAlign: TextAlignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing - : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) - let frameAlign: Alignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing - : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) - let base = colored.multilineTextAlignment(textAlign) - Group { - if let limit = element.lineLimit { - base.lineLimit(Int(limit)) - } else { - base - } - } - .fixedSize(horizontal: inZStack || hasFixedBox, vertical: hasFixedBox) - .frame(maxWidth: (inZStack || hasFixedBox) ? nil : .infinity, alignment: frameAlign) - } - - // MARK: Image - - @ViewBuilder private func renderImage() -> some View { - let s = element.size ?? 24 - if let sn = element.systemName { - let img = Image(systemName: sn).resizable() - .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) - .frame(width: s, height: s) - if let c = resolveColor(element.color) { img.foregroundColor(c) } else { img } - } else if let decoded = decodeImageData(element.data) { - decodedImageView(decoded, size: s) - } else if let url = element.url, let decoded = decodeImageData(url) { - decodedImageView(decoded, size: s) - } else { - placeholderImage(s) - } - } - - private func decodeImageData(_ raw: String?) -> Data? { - guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return nil } - if s.hasPrefix("data:"), let comma = s.firstIndex(of: ",") { - s = String(s[s.index(after: comma)...]) - } - return Data(base64Encoded: s) - } - - @ViewBuilder private func decodedImageView(_ raw: Data, size: CGFloat) -> some View { - #if canImport(UIKit) - if let uiImage = UIImage(data: raw) { - Image(uiImage: uiImage).resizable() - .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) - .frame(width: size, height: size) - } else { placeholderImage(size) } - #elseif canImport(AppKit) - if let nsImage = NSImage(data: raw) { - Image(nsImage: nsImage).resizable() - .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) - .frame(width: size, height: size) - } else { placeholderImage(size) } - #endif - } - - @ViewBuilder private func placeholderImage(_ s: CGFloat) -> some View { - Image(systemName: "questionmark.square.dashed").resizable() - .frame(width: s, height: s).foregroundColor(.gray) - } - - // MARK: Progress - - @ViewBuilder private func renderProgress() -> some View { - let v = element.value ?? 0; let t = max(element.total ?? 1, 0.0001) - let tc = resolveColor(element.tint) ?? Color.accentColor - let frac = min(max(v / t, 0), 1) - if element.barStyle == "circular" { - // Custom ring — ProgressView breaks under ImageRenderer / macOS snapshots. - ZStack { - Circle().stroke(tc.opacity(0.25), lineWidth: 4) - Circle() - .trim(from: 0, to: frac) - .stroke(tc, style: StrokeStyle(lineWidth: 4, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - .frame(width: 40, height: 40) - } else { - // Avoid bare GeometryReader in unconstrained HStacks — it steals height and - // collapses into a tall thin strip (Tasks medium sidebar). - VStack(alignment: .leading, spacing: 2) { - if let lbl = element.label { - Text(lbl).font(.caption2) - .lineLimit(1) - .minimumScaleFactor(0.75) - .foregroundColor( - resolveColor(element.color) - ?? resolveColor(element.tint) - ?? .secondary - ) - } - Capsule() - .fill(tc.opacity(0.25)) - .frame(height: 6) - .frame(maxWidth: .infinity) - .overlay(alignment: .leading) { - GeometryReader { geo in - Capsule() - .fill(tc) - .frame(width: max(geo.size.width * CGFloat(frac), 2), height: 6) - } - } - } - .frame(minWidth: 72, maxWidth: .infinity, alignment: .leading) - } - } - - // MARK: Gauge - - @ViewBuilder private func renderGauge() -> some View { - let v = element.value ?? 0; let lo = element.min ?? 0; let hi = element.max ?? 1 - let tc = resolveColor(element.tint) ?? Color.accentColor - let ink = resolveColor(element.color) ?? .primary - let span = max(hi - lo, 0.0001) - let frac = min(max((v - lo) / span, 0), 1) - if element.gaugeStyle == "linear" { - // Match desktop/HTML: label row + capsule bar (not accessoryLinear slider chrome). - VStack(alignment: .leading, spacing: 2) { - HStack { - if let lbl = element.label { - Text(lbl).font(.caption2).foregroundColor(ink.opacity(0.7)) - } - Spacer(minLength: 0) - if let cvl = element.currentValueLabel { - Text(cvl).font(.caption).fontWeight(.semibold).foregroundColor(ink) - } - } - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(tc.opacity(0.25)) - Capsule().fill(tc).frame(width: max(geo.size.width * frac, 2)) - } - } - .frame(height: 6) - } - } else { - // Custom ring — accessoryCircular is blank/misaligned under ImageRenderer. - VStack(spacing: 4) { - ZStack { - Circle().stroke(tc.opacity(0.25), lineWidth: 5) - Circle() - .trim(from: 0, to: frac) - .stroke(tc, style: StrokeStyle(lineWidth: 5, lineCap: .round)) - .rotationEffect(.degrees(-90)) - if let cvl = element.currentValueLabel { - Text(cvl).font(.caption).fontWeight(.semibold).foregroundColor(ink) - } - } - .frame(width: 56, height: 56) - if let lbl = element.label { - Text(lbl).font(.caption2).foregroundColor(ink) - } - } - } - } - - // MARK: Button - - @ViewBuilder private func renderButton() -> some View { - let lbl = element.label ?? element.content ?? "" - let alignRaw = (element.textAlignment ?? element.alignment ?? "center").lowercased() - let textAlign: TextAlignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) - let frameAlign: Alignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) - let hasExplicitAlign = element.textAlignment != nil || element.alignment != nil - let fs = element.fontSize ?? 14 - // Prefer explicit `padding` as content insets; otherwise scale with fontSize so - // keypad grids (Calculator small/medium) fit WidgetKit family height. - let insets: EdgeInsets = { - if let p = element.padding { - switch p { - case .uniform(let v): - return EdgeInsets(top: v, leading: v, bottom: v, trailing: v) - case .edges(let t, let b, let l, let tr): - return EdgeInsets(top: t ?? 0, leading: l ?? 0, bottom: b ?? 0, trailing: tr ?? 0) - } - } - let h = min(12, max(3, fs * 0.45)) - let v = min(6, max(1.5, fs * 0.22)) - return EdgeInsets(top: v, leading: h, bottom: v, trailing: h) - }() - let baseText = Text(lbl) - .font(.system(size: fs, weight: .medium)) - .foregroundColor(resolveColor(element.color) ?? .white) - .multilineTextAlignment(textAlign) - let alignedText: AnyView = hasExplicitAlign - ? AnyView(baseText.frame(maxWidth: .infinity, alignment: frameAlign)) - : AnyView(baseText) - let btnContent = alignedText - .padding(insets) - .frame(maxWidth: .infinity) - .background(resolveColor(element.backgroundColor) ?? Color.accentColor) - .cornerRadius(element.cornerRadius ?? 8) - if let act = element.action, !act.isEmpty { - Button(intent: WidgetActionIntent(actionName: act)) { btnContent }.buttonStyle(.plain) - } else if let u = element.url, !u.isEmpty, let url = URL(string: u) { - Link(destination: url) { btnContent } - } else { - btnContent - } - } - - // MARK: Toggle - - @ViewBuilder private func renderToggle() -> some View { - let isOn = element.isOn ?? false - let content = HStack(spacing: 6) { - Image(systemName: isOn ? "checkmark.circle.fill" : "circle") - .foregroundColor(isOn ? (resolveColor(element.tint) ?? .green) : .gray) - .font(.system(size: 18)) - if let lbl = element.label { - Text(lbl).font(.system(size: 14)) - .foregroundColor(resolveColor(element.color) ?? .primary) - } - } - if let act = element.action, !act.isEmpty { - Button(intent: WidgetActionIntent(actionName: act, payload: isOn ? "false" : "true")) { - content - }.buttonStyle(.plain) - } else { - content - } - } - - // MARK: Divider / Spacer - - @ViewBuilder private func renderDivider() -> some View { - let fill = Rectangle().fill(resolveColor(element.color) ?? Color.gray.opacity(0.3)) - let thickness = element.thickness ?? 1 - if parentAxis == .horizontal { - fill.frame(width: thickness) - } else { - fill.frame(height: thickness) - .padding(.vertical, 4) - } - } - - @ViewBuilder private func renderSpacer() -> some View { - if let ml = element.minLength { Spacer(minLength: ml) } else { Spacer() } - } - - // MARK: Date - - @ViewBuilder private func renderDate() -> some View { - let d: Date = { - guard let ds = element.date else { return Date() } - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let parsed = f.date(from: ds) { return parsed } - f.formatOptions = [.withInternetDateTime] - return f.date(from: ds) ?? Date() - }() - let tv: Text = { - switch element.dateStyle { - case "time": return Text(d, style: .time) - case "date": return Text(d, style: .date) - case "relative": return Text(d, style: .relative) - case "offset": return Text(d, style: .offset) - case "timer": return Text(d, style: .timer) - default: return Text(d, style: .time) - } - }() - tv.font(.system(size: element.fontSize ?? 14)) - .foregroundColor(resolveColor(element.color) ?? .primary) - } - - // MARK: Chart - - @ViewBuilder private func renderChart() -> some View { - let pts = element.chartData ?? []; let maxV = pts.map(\.value).max() ?? 1 - let tc = resolveColor(element.tint) ?? Color.accentColor - switch element.chartType { - case "line": renderLineChart(pts: pts, maxV: maxV, tc: tc) - case "area": renderAreaChart(pts: pts, maxV: maxV, tc: tc) - case "pie": renderPieChart(pts: pts, tc: tc) - default: renderBarChart(pts: pts, maxV: maxV, tc: tc) - } - } - - @ViewBuilder private func renderList() -> some View { - let rows = element.items ?? [] - let anyCheckbox = rows.contains { $0.checked != nil } - VStack(alignment: .leading, spacing: element.spacing ?? 4) { - ForEach(rows.indices, id: \.self) { i in - let row = rows[i] - let hasCheckbox = row.checked != nil - let rowView = HStack(spacing: 6) { - if hasCheckbox { - Image(systemName: (row.checked ?? false) ? "checkmark.circle.fill" : "circle") - .foregroundColor((row.checked ?? false) ? .green : .gray) - .font(.system(size: 12)) - .frame(width: 14) - } else if anyCheckbox { - Color.clear.frame(width: 14, height: 12) - } - Text(row.text) - .font(.system(size: element.fontSize ?? 13)) - .lineLimit(1) - .foregroundColor(resolveColor(element.color) ?? .primary) - } - if let action = row.action, !action.isEmpty { - Button(intent: WidgetActionIntent(actionName: action)) { rowView } - .buttonStyle(.plain) - } else { - rowView - } - } - } - } - - @ViewBuilder private func renderBarChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { - HStack(alignment: .bottom, spacing: 4) { - ForEach(pts.indices, id: \.self) { i in - let pt = pts[i]; let h = maxV > 0 ? CGFloat(pt.value / maxV) : 0 - VStack(spacing: 2) { - RoundedRectangle(cornerRadius: 2) - .fill(resolveColor(pt.color) ?? tc).frame(height: Swift.max(h * 60, 2)) - Text(pt.label).font(.system(size: 8)).foregroundColor(.white.opacity(0.7)).lineLimit(1) - } - } - } - } - - @ViewBuilder private func renderLineChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { - GeometryReader { geo in - let w = geo.size.width; let h = geo.size.height; let cnt = CGFloat(Swift.max(pts.count - 1, 1)) - Path { p in - for (i, pt) in pts.enumerated() { - let x = (CGFloat(i) / cnt) * w - let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) - if i == 0 { p.move(to: CGPoint(x: x, y: y)) } - else { p.addLine(to: CGPoint(x: x, y: y)) } - } - }.stroke(tc, lineWidth: 2) - }.frame(height: 60) - } - - @ViewBuilder private func renderAreaChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { - GeometryReader { geo in - let w = geo.size.width; let h = geo.size.height; let cnt = CGFloat(Swift.max(pts.count - 1, 1)) - ZStack { - Path { p in - p.move(to: CGPoint(x: 0, y: h)) - for (i, pt) in pts.enumerated() { - let x = (CGFloat(i) / cnt) * w - let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) - p.addLine(to: CGPoint(x: x, y: y)) - } - p.addLine(to: CGPoint(x: w, y: h)); p.closeSubpath() - }.fill(tc.opacity(0.3)) - Path { p in - for (i, pt) in pts.enumerated() { - let x = (CGFloat(i) / cnt) * w - let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) - if i == 0 { p.move(to: CGPoint(x: x, y: y)) } - else { p.addLine(to: CGPoint(x: x, y: y)) } - } - }.stroke(tc, lineWidth: 2) - } - }.frame(height: 60) - } - - @ViewBuilder private func renderPieChart(pts: [ChartDataPoint], tc: Color) -> some View { - let total = pts.map(\.value).reduce(0, +) - let defColors: [Color] = [.blue, .green, .orange, .red, .purple, .yellow, .pink, .teal] - GeometryReader { geo in - let s = Swift.min(geo.size.width, geo.size.height) - let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2); let radius = s / 2 - ZStack { - ForEach(pts.indices, id: \.self) { i in - let sf = pts.prefix(i).map(\.value).reduce(0, +) / Swift.max(total, 1) - let ef = sf + pts[i].value / Swift.max(total, 1) - let sc = resolveColor(pts[i].color) ?? (i < defColors.count ? defColors[i] : tc) - Path { p in - p.move(to: center) - p.addArc(center: center, radius: radius, - startAngle: .degrees(sf * 360 - 90), - endAngle: .degrees(ef * 360 - 90), clockwise: false) - p.closeSubpath() - }.fill(sc) - } - } - }.aspectRatio(1, contentMode: .fit).frame(height: 80) - } - - // MARK: Link - - @ViewBuilder private func renderLink() -> some View { - let content = VStack(spacing: 0) { renderChildren() } - if let act = element.action, !act.isEmpty { - Button(intent: WidgetActionIntent(actionName: act)) { content }.buttonStyle(.plain) - } else if let u = element.url, !u.isEmpty, let url = URL(string: u) { - Link(destination: url) { content } - } else { content } - } - - // MARK: Shape - - @ViewBuilder private func renderShape() -> some View { - let s = element.size ?? 24 - let fc = resolveColor(element.fill) ?? Color.accentColor - let sc = resolveColor(element.stroke); let sw = element.strokeWidth ?? 1 - switch element.shapeType { - case "circle": - ZStack { - Circle().fill(fc).frame(width: s, height: s) - if let c = sc { Circle().stroke(c, lineWidth: sw).frame(width: s, height: s) } - } - case "capsule": - ZStack { - Capsule().fill(fc).frame(width: s * 2, height: s) - if let c = sc { Capsule().stroke(c, lineWidth: sw).frame(width: s * 2, height: s) } - } + case "vstack", "hstack", "zstack", "grid", "container": + renderLayout() + case "text", "label", "date", "timer": + renderTextGroup() + case "image", "shape", "canvas": + renderMedia() + case "progress", "gauge", "chart", "list": + renderData() + case "button", "toggle", "link": + renderInteractive() + case "spacer", "divider": + renderSpacing() default: - let cr = element.cornerRadius ?? 0 - ZStack { - RoundedRectangle(cornerRadius: cr).fill(fc).frame(width: s, height: s) - if let c = sc { RoundedRectangle(cornerRadius: cr).stroke(c, lineWidth: sw).frame(width: s, height: s) } - } - } - } - - // MARK: Timer - - @ViewBuilder private func renderTimer() -> some View { - let target: Date = { - guard let ds = element.targetDate else { return Date() } - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let parsed = f.date(from: ds) { return parsed } - f.formatOptions = [.withInternetDateTime] - return f.date(from: ds) ?? Date() - }() - let countingUp = (element.counting ?? "down").lowercased() == "up" - // WidgetKit keeps Text(timerInterval:) live without a process-resident TimelineView. - let style = Font.system(size: element.fontSize ?? 14, weight: fontWeight(element.fontWeight)).monospacedDigit() - let color = resolveColor(element.color) ?? Color.primary - if countingUp { - Text(timerInterval: target...Date.distantFuture, countsDown: false) - .font(style) - .foregroundColor(color) - .lineLimit(1) - .minimumScaleFactor(0.55) - .allowsTightening(true) - } else { - Text(timerInterval: Date.now...target, countsDown: true) - .font(style) - .foregroundColor(color) - .lineLimit(1) - .minimumScaleFactor(0.55) - .allowsTightening(true) - } - } - - // MARK: Label - - @ViewBuilder private func renderLabel() -> some View { - let sp = element.spacing ?? 4 - HStack(spacing: sp) { - if let sn = element.systemName { - let isz = (element.fontSize ?? 14) * 1.1 - Image(systemName: sn).resizable().aspectRatio(contentMode: .fit) - .frame(width: isz, height: isz) - .foregroundColor(resolveColor(element.iconColor) - ?? resolveColor(element.color) ?? .primary) - } - Text(element.text ?? element.content ?? "") - .font(.system(size: element.fontSize ?? 14, weight: fontWeight(element.fontWeight))) - .foregroundColor(resolveColor(element.color) ?? .primary) - } - } - - // MARK: Canvas - - @ViewBuilder private func renderCanvas() -> some View { - let cw = max(element.width ?? 100, 1) - let ch = max(element.height ?? 100, 1) - let commands = element.elements ?? [] - // Uniform scale + letterbox — independent sx/sy stretches circles into ovals - // and splits clock faces when the view is flex-resized (Analog Clock large). - let canvasView = SwiftUI.Canvas { context, size in - let s = Swift.min(size.width / cw, size.height / ch) - let ox = (size.width - cw * s) / 2 - let oy = (size.height - ch * s) / 2 - func X(_ v: CGFloat) -> CGFloat { ox + v * s } - func Y(_ v: CGFloat) -> CGFloat { oy + v * s } - for cmd in commands { - switch cmd.draw { - case "circle": - let cx = X(cmd.cx ?? 0); let cy = Y(cmd.cy ?? 0); let r = (cmd.r ?? 10) * s - let rect = CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2) - if let f = resolveColor(cmd.fill) { context.fill(SwiftUI.Path(ellipseIn: rect), with: .color(f)) } - if let st = resolveColor(cmd.stroke) { - context.stroke(SwiftUI.Path(ellipseIn: rect), with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) - } - case "line": - var path = SwiftUI.Path() - path.move(to: CGPoint(x: X(cmd.x1 ?? 0), y: Y(cmd.y1 ?? 0))) - path.addLine(to: CGPoint(x: X(cmd.x2 ?? 0), y: Y(cmd.y2 ?? 0))) - var style = StrokeStyle(lineWidth: (cmd.strokeWidth ?? 1) * s) - if cmd.lineCap == "round" { style.lineCap = .round } - context.stroke(path, with: .color(resolveColor(cmd.stroke) ?? Color(hex: "#ffffff")), style: style) - case "rect": - let rect = CGRect( - x: X(cmd.x ?? 0), y: Y(cmd.y ?? 0), - width: (cmd.width ?? 10) * s, height: (cmd.height ?? 10) * s - ) - let cr = (cmd.cornerRadius ?? 0) * s - let p = SwiftUI.Path(roundedRect: rect, cornerRadius: cr) - if let f = resolveColor(cmd.fill) { context.fill(p, with: .color(f)) } - if let st = resolveColor(cmd.stroke) { - context.stroke(p, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) - } - case "arc": - let cx = X(cmd.cx ?? 0); let cy = Y(cmd.cy ?? 0); let r = (cmd.r ?? 10) * s - var path = SwiftUI.Path() - if cmd.fill != nil { path.move(to: CGPoint(x: cx, y: cy)) } - path.addArc( - center: CGPoint(x: cx, y: cy), radius: r, - startAngle: .degrees(Double(cmd.startAngle ?? 0)), - endAngle: .degrees(Double(cmd.endAngle ?? 360)), clockwise: false - ) - if cmd.fill != nil { path.closeSubpath() } - if let f = resolveColor(cmd.fill) { context.fill(path, with: .color(f)) } - if let st = resolveColor(cmd.stroke) { - context.stroke(path, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) - } - case "text": - let fs = (cmd.fontSize ?? 12) * s - let txt = SwiftUI.Text(cmd.content ?? "").font(.system(size: fs)) - .foregroundColor(resolveColor(cmd.color) ?? .white) - let pt = CGPoint(x: X(cmd.x ?? 0), y: Y(cmd.y ?? 0)) - let anchor: UnitPoint = cmd.anchor == "end" ? .trailing : cmd.anchor == "middle" ? .center : .leading - context.draw(context.resolve(txt), at: pt, anchor: anchor) - case "path": - if let d = cmd.d { - var path = parseSVGPath(d) - path = path.applying( - CGAffineTransform(a: s, b: 0, c: 0, d: s, tx: ox, ty: oy) - ) - if let f = resolveColor(cmd.fill) { context.fill(path, with: .color(f)) } - if let st = resolveColor(cmd.stroke) { - context.stroke(path, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) - } - } - default: break - } - } + EmptyView() } - // Fit into offered space; never force a fixed pt size larger than the family. - canvasView - .aspectRatio(cw / ch, contentMode: .fit) - .frame(maxWidth: cw, maxHeight: ch) - .frame(maxWidth: (element.flex ?? 0) > 0 ? .infinity : nil, - maxHeight: (element.flex ?? 0) > 0 ? .infinity : nil) } // MARK: - Style Application @@ -785,7 +72,7 @@ public struct DynamicElementView: View { // MARK: - Helpers - private func fontWeight(_ w: String?) -> Font.Weight { + func fontWeight(_ w: String?) -> Font.Weight { switch w { case "ultralight": return .ultraLight; case "thin": return .thin; case "light": return .light case "medium": return .medium; case "semibold": return .semibold; case "bold": return .bold @@ -793,14 +80,14 @@ public struct DynamicElementView: View { } } - private func fontDesign(_ d: String?) -> Font.Design { + func fontDesign(_ d: String?) -> Font.Design { switch d { case "monospaced": return .monospaced; case "rounded": return .rounded case "serif": return .serif; default: return .default } } - private func textStyleFont(_ style: String?) -> Font? { + func textStyleFont(_ style: String?) -> Font? { switch style { case "largeTitle": return .largeTitle case "title": return .title @@ -817,7 +104,7 @@ public struct DynamicElementView: View { } } - private func parseAlignment(_ a: String?) -> Alignment { + func parseAlignment(_ a: String?) -> Alignment { switch a { case "topLeading": return .topLeading case "top": return .top @@ -831,172 +118,3 @@ public struct DynamicElementView: View { } } } - -// MARK: - View Modifiers - -private struct PaddingMod: ViewModifier { - let p: PaddingValue? - func body(content: Content) -> some View { - switch p { - case .uniform(let v): content.padding(v) - case .edges(let t, let b, let l, let tr): - content.padding(EdgeInsets(top: t ?? 0, leading: l ?? 0, bottom: b ?? 0, trailing: tr ?? 0)) - case nil: content - } - } -} - -private struct BgMod: ViewModifier { - let bg: BackgroundValue?; let cr: CGFloat? - func body(content: Content) -> some View { - switch bg { - case .solid(let hex): - let c = Color.semantic(hex) ?? Color(hex: hex) - content.background(RoundedRectangle(cornerRadius: cr ?? 0).fill(c)) - case .gradient(let g): - let colors = g.colors.map { Color(hex: $0) } - let (s, e): (UnitPoint, UnitPoint) = { - switch g.direction { - case "bottomToTop": return (.bottom, .top) - case "leadingToTrailing": return (.leading, .trailing) - case "trailingToLeading": return (.trailing, .leading) - case "topLeadingToBottomTrailing": return (.topLeading, .bottomTrailing) - case "topTrailingToBottomLeading": return (.topTrailing, .bottomLeading) - default: return (.top, .bottom) - } - }() - content.background(RoundedRectangle(cornerRadius: cr ?? 0) - .fill(LinearGradient(colors: colors, startPoint: s, endPoint: e))) - case .adaptive(let l, let d): - content.background(RoundedRectangle(cornerRadius: cr ?? 0) - .fill(Color.adaptive(light: l, dark: d))) - case nil: content - } - } -} - -private struct ShadowMod: ViewModifier { - let s: ShadowConfig? - func body(content: Content) -> some View { - if let s = s { - content.shadow(color: s.color.map { Color(hex: $0) } ?? Color.black.opacity(0.3), - radius: s.radius ?? 4, x: s.x ?? 0, y: s.y ?? 2) - } else { content } - } -} - -private struct FrameMod: ViewModifier { - let f: FrameConfig? - func body(content: Content) -> some View { - if let f = f { - // Explicit center so badge/avatar content isn't left/top-biased inside the box. - content.frame(width: f.width, height: f.height, alignment: .center) - .frame(maxWidth: f.maxWidth?.cgFloat, maxHeight: f.maxHeight?.cgFloat) - } else { content } - } -} - -private struct BorderMod: ViewModifier { - let b: BorderConfig?; let cr: CGFloat? - func body(content: Content) -> some View { - if let b = b { - content.overlay(RoundedRectangle(cornerRadius: cr ?? 0) - .stroke(Color(hex: b.color ?? "#888"), lineWidth: b.width ?? 1)) - } else { content } - } -} - -private struct ClipShapeMod: ViewModifier { - let shape: String?; let cr: CGFloat? - func body(content: Content) -> some View { - switch shape { - case "circle": AnyView(content.clipShape(Circle())) - case "capsule": AnyView(content.clipShape(Capsule())) - case "rectangle": AnyView(content.clipShape(RoundedRectangle(cornerRadius: cr ?? 0))) - default: AnyView(content) - } - } -} - -private struct FlexMod: ViewModifier { - let flex: CGFloat? - func body(content: Content) -> some View { - if let f = flex, f > 0 { - content.frame(maxWidth: .infinity).layoutPriority(Double(f)) - } else { content } - } -} - -private struct OpacityMod: ViewModifier { - let o: Double? - func body(content: Content) -> some View { - if let o = o { content.opacity(o) } else { content } - } -} - -/// Minimal SVG path parser (M/m L/l H/h V/v Z/z). Enough for simple canvas paths. -/// Accepts compact syntax like `M10 10L20 20` (command letter glued to first number). -private func parseSVGPath(_ data: String) -> Path { - var path = Path() - var tokens: [String] = [] - var num = "" - func flushNum() { - if !num.isEmpty { tokens.append(num); num = "" } - } - for ch in data.replacingOccurrences(of: ",", with: " ") { - if ch.isLetter { - flushNum() - tokens.append(String(ch)) - } else if ch.isWhitespace { - flushNum() - } else if ch == "-" || ch == "+" || ch == "." || ch.isNumber { - // Start a new number when sign follows a digit/dot (e.g. 10-5 → 10, -5). - if (ch == "-" || ch == "+"), !num.isEmpty { - flushNum() - } - num.append(ch) - } else { - flushNum() - } - } - flushNum() - var i = 0 - var cx: CGFloat = 0 - var cy: CGFloat = 0 - func nextNumber() -> CGFloat? { - guard i < tokens.count, let v = Double(tokens[i]) else { return nil } - i += 1 - return CGFloat(v) - } - while i < tokens.count { - let cmd = tokens[i] - if cmd.count == 1, let c = cmd.first, c.isLetter { - i += 1 - switch c { - case "M": - if let x = nextNumber(), let y = nextNumber() { cx = x; cy = y; path.move(to: CGPoint(x: x, y: y)) } - case "m": - if let x = nextNumber(), let y = nextNumber() { cx += x; cy += y; path.move(to: CGPoint(x: cx, y: cy)) } - case "L": - if let x = nextNumber(), let y = nextNumber() { cx = x; cy = y; path.addLine(to: CGPoint(x: x, y: y)) } - case "l": - if let x = nextNumber(), let y = nextNumber() { cx += x; cy += y; path.addLine(to: CGPoint(x: cx, y: cy)) } - case "H": - if let x = nextNumber() { cx = x; path.addLine(to: CGPoint(x: cx, y: cy)) } - case "h": - if let x = nextNumber() { cx += x; path.addLine(to: CGPoint(x: cx, y: cy)) } - case "V": - if let y = nextNumber() { cy = y; path.addLine(to: CGPoint(x: cx, y: cy)) } - case "v": - if let y = nextNumber() { cy += y; path.addLine(to: CGPoint(x: cx, y: cy)) } - case "Z", "z": - path.closeSubpath() - default: - break - } - } else { - i += 1 - } - } - return path -} diff --git a/swift/Sources/TauriWidgets/Generated/WireCatalog.swift b/swift/Sources/TauriWidgets/Generated/WireCatalog.swift new file mode 100644 index 0000000..cee4eb6 --- /dev/null +++ b/swift/Sources/TauriWidgets/Generated/WireCatalog.swift @@ -0,0 +1,55 @@ +// AUTO-GENERATED by `cargo run --bin gen-native --features codegen` +// Do not edit. Source: `src/codegen.rs` IR_ELEMENTS. +// Runtime decode still uses flat `WidgetElement` in Models.swift. + +import Foundation + +/// Known `type` wire values for widget IR elements. +public enum WidgetWireType: String, CaseIterable, Sendable { + case vstack = "vstack" + case hstack = "hstack" + case zstack = "zstack" + case grid = "grid" + case container = "container" + case text = "text" + case image = "image" + case progress = "progress" + case gauge = "gauge" + case button = "button" + case toggle = "toggle" + case divider = "divider" + case date = "date" + case chart = "chart" + case list = "list" + case link = "link" + case shape = "shape" + case timer = "timer" + case canvas = "canvas" + case label = "label" + case spacer = "spacer" +} + +/// Per-element field hints (documentation / drift checks; not Codable). +public enum WidgetWireFields { + public static let vstack: [String] = ["children: WidgetElement[]", "spacing?: number", "alignment?: HorizontalAlignment"] + public static let hstack: [String] = ["children: WidgetElement[]", "spacing?: number", "alignment?: VerticalAlignment"] + public static let zstack: [String] = ["children: WidgetElement[]", "alignment?: string"] + public static let grid: [String] = ["children: WidgetElement[]", "columns?: number", "spacing?: number", "rowSpacing?: number"] + public static let container: [String] = ["children?: WidgetElement[]", "contentAlignment?: string"] + public static let text: [String] = ["content: string", "fontSize?: number", "fontWeight?: FontWeight", "fontDesign?: FontDesign", "textStyle?: TextStyle", "color?: ColorValue", "alignment?: TextAlignment", "lineLimit?: number"] + public static let image: [String] = ["systemName?: string", "data?: string", "url?: string", "size?: number", "color?: ColorValue", "contentMode?: ContentMode"] + public static let progress: [String] = ["value: number", "total?: number", "label?: string", "tint?: ColorValue", "color?: ColorValue", "barStyle?: ProgressStyle"] + public static let gauge: [String] = ["value: number", "min?: number", "max?: number", "label?: string", "currentValueLabel?: string", "tint?: ColorValue", "color?: ColorValue", "gaugeStyle?: GaugeStyle"] + public static let button: [String] = ["label: string", "url?: string", "action?: string", "color?: ColorValue", "backgroundColor?: ColorValue", "fontSize?: number", "textAlignment?: TextAlignment"] + public static let toggle: [String] = ["isOn: boolean", "label?: string", "tint?: ColorValue", "action?: string"] + public static let divider: [String] = ["color?: ColorValue", "thickness?: number"] + public static let date: [String] = ["date: string", "dateStyle?: DateStyle", "fontSize?: number", "color?: ColorValue"] + public static let chart: [String] = ["chartType: ChartType", "chartData: ChartDataPoint[]", "tint?: ColorValue"] + public static let list: [String] = ["items: ListItem[]", "spacing?: number", "fontSize?: number", "color?: ColorValue"] + public static let link: [String] = ["children: WidgetElement[]", "url?: string", "action?: string"] + public static let shape: [String] = ["shapeType: ShapeType", "fill?: ColorValue", "stroke?: ColorValue", "strokeWidth?: number", "size?: number"] + public static let timer: [String] = ["targetDate: string", "counting?: TimerCounting", "fontSize?: number", "fontWeight?: FontWeight", "color?: ColorValue"] + public static let canvas: [String] = ["width: number", "height: number", "elements: CanvasDrawCommand[]"] + public static let label: [String] = ["text: string", "systemName: string", "iconColor?: ColorValue", "fontSize?: number", "fontWeight?: FontWeight", "color?: ColorValue", "spacing?: number"] + public static let spacer: [String] = ["minLength?: number"] +} diff --git a/swift/Sources/TauriWidgets/Models.swift b/swift/Sources/TauriWidgets/Models.swift index 196a87e..bef4d4a 100644 --- a/swift/Sources/TauriWidgets/Models.swift +++ b/swift/Sources/TauriWidgets/Models.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI // MARK: - Top-level config +// Wire `type` enum catalog: Generated/WireCatalog.swift (`pnpm codegen`). public struct WidgetUIConfig: Codable { public let version: Int? @@ -168,6 +169,55 @@ public struct GradientConfig: Codable { public let gradientType: String public let colors: [String] public let direction: String? + + public var linearStart: UnitPoint { + switch direction { + case "bottomToTop": return .bottom + case "leadingToTrailing": return .leading + case "trailingToLeading": return .trailing + case "topLeadingToBottomTrailing": return .topLeading + case "topTrailingToBottomLeading": return .topTrailing + default: return .top + } + } + + public var linearEnd: UnitPoint { + switch direction { + case "bottomToTop": return .top + case "leadingToTrailing": return .trailing + case "trailingToLeading": return .leading + case "topLeadingToBottomTrailing": return .bottomTrailing + case "topTrailingToBottomLeading": return .bottomLeading + default: return .bottom + } + } + + @ViewBuilder + public func asView() -> some View { + let stops = colors.map { Color(hex: $0) } + switch gradientType.lowercased() { + case "radial": + RadialGradient(colors: stops, center: .center, startRadius: 0, endRadius: 160) + case "angular": + AngularGradient(colors: stops, center: .center) + default: + LinearGradient(colors: stops, startPoint: linearStart, endPoint: linearEnd) + } + } + + @ViewBuilder + public func filledShape(cornerRadius: CGFloat) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius) + let stops = colors.map { Color(hex: $0) } + switch gradientType.lowercased() { + case "radial": + shape.fill(RadialGradient(colors: stops, center: .center, startRadius: 0, endRadius: 160)) + case "angular": + shape.fill(AngularGradient(colors: stops, center: .center)) + default: + shape.fill(LinearGradient(colors: stops, startPoint: linearStart, endPoint: linearEnd)) + } + } } public struct ShadowConfig: Codable { diff --git a/swift/Sources/TauriWidgets/Modifiers.swift b/swift/Sources/TauriWidgets/Modifiers.swift new file mode 100644 index 0000000..6e7fc0f --- /dev/null +++ b/swift/Sources/TauriWidgets/Modifiers.swift @@ -0,0 +1,91 @@ +import SwiftUI + +// MARK: - View Modifiers + +struct PaddingMod: ViewModifier { + let p: PaddingValue? + func body(content: Content) -> some View { + switch p { + case .uniform(let v): content.padding(v) + case .edges(let t, let b, let l, let tr): + content.padding(EdgeInsets(top: t ?? 0, leading: l ?? 0, bottom: b ?? 0, trailing: tr ?? 0)) + case nil: content + } + } +} + +struct BgMod: ViewModifier { + let bg: BackgroundValue?; let cr: CGFloat? + func body(content: Content) -> some View { + switch bg { + case .solid(let hex): + let c = Color.semantic(hex) ?? Color(hex: hex) + content.background(RoundedRectangle(cornerRadius: cr ?? 0).fill(c)) + case .gradient(let g): + content.background(g.filledShape(cornerRadius: cr ?? 0)) + case .adaptive(let l, let d): + content.background(RoundedRectangle(cornerRadius: cr ?? 0) + .fill(Color.adaptive(light: l, dark: d))) + case nil: content + } + } +} + +struct ShadowMod: ViewModifier { + let s: ShadowConfig? + func body(content: Content) -> some View { + if let s = s { + content.shadow(color: s.color.map { Color(hex: $0) } ?? Color.black.opacity(0.3), + radius: s.radius ?? 4, x: s.x ?? 0, y: s.y ?? 2) + } else { content } + } +} + +struct FrameMod: ViewModifier { + let f: FrameConfig? + func body(content: Content) -> some View { + if let f = f { + // Explicit center so badge/avatar content isn't left/top-biased inside the box. + content.frame(width: f.width, height: f.height, alignment: .center) + .frame(maxWidth: f.maxWidth?.cgFloat, maxHeight: f.maxHeight?.cgFloat) + } else { content } + } +} + +struct BorderMod: ViewModifier { + let b: BorderConfig?; let cr: CGFloat? + func body(content: Content) -> some View { + if let b = b { + content.overlay(RoundedRectangle(cornerRadius: cr ?? 0) + .stroke(Color(hex: b.color ?? "#888"), lineWidth: b.width ?? 1)) + } else { content } + } +} + +struct ClipShapeMod: ViewModifier { + let shape: String?; let cr: CGFloat? + func body(content: Content) -> some View { + switch shape { + case "circle": AnyView(content.clipShape(Circle())) + case "capsule": AnyView(content.clipShape(Capsule())) + case "rectangle": AnyView(content.clipShape(RoundedRectangle(cornerRadius: cr ?? 0))) + default: AnyView(content) + } + } +} + +struct FlexMod: ViewModifier { + let flex: CGFloat? + func body(content: Content) -> some View { + if let f = flex, f > 0 { + content.frame(maxWidth: .infinity).layoutPriority(Double(f)) + } else { content } + } +} + +struct OpacityMod: ViewModifier { + let o: Double? + func body(content: Content) -> some View { + if let o = o { content.opacity(o) } else { content } + } +} diff --git a/swift/Sources/TauriWidgets/SVGPath.swift b/swift/Sources/TauriWidgets/SVGPath.swift new file mode 100644 index 0000000..0732141 --- /dev/null +++ b/swift/Sources/TauriWidgets/SVGPath.swift @@ -0,0 +1,246 @@ +import SwiftUI + +/// Minimal SVG path parser (M/m L/l H/h V/v C/c Q/q A/a Z/z). +/// Accepts compact syntax like `M10 10L20 20` (command letter glued to first number). +func parseSVGPath(_ data: String) -> Path { + var path = Path() + var tokens: [String] = [] + var num = "" + func flushNum() { + if !num.isEmpty { tokens.append(num); num = "" } + } + for ch in data.replacingOccurrences(of: ",", with: " ") { + if ch.isLetter { + flushNum() + tokens.append(String(ch)) + } else if ch.isWhitespace { + flushNum() + } else if ch == "-" || ch == "+" || ch == "." || ch.isNumber { + // Start a new number when sign follows a digit/dot (e.g. 10-5 → 10, -5). + if (ch == "-" || ch == "+"), !num.isEmpty { + flushNum() + } + num.append(ch) + } else { + flushNum() + } + } + flushNum() + var i = 0 + var cx: CGFloat = 0 + var cy: CGFloat = 0 + var lastCubicCtrl: CGPoint? + var lastQuadCtrl: CGPoint? + func nextNumber() -> CGFloat? { + guard i < tokens.count, let v = Double(tokens[i]) else { return nil } + i += 1 + return CGFloat(v) + } + while i < tokens.count { + let cmd = tokens[i] + if cmd.count == 1, let c = cmd.first, c.isLetter { + i += 1 + switch c { + case "M": + lastCubicCtrl = nil; lastQuadCtrl = nil + var firstMove = true + while let x = nextNumber(), let y = nextNumber() { + if firstMove { + cx = x; cy = y; path.move(to: CGPoint(x: x, y: y)) + firstMove = false + } else { + cx = x; cy = y; path.addLine(to: CGPoint(x: x, y: y)) + } + } + case "m": + lastCubicCtrl = nil; lastQuadCtrl = nil + var firstMove = true + while let x = nextNumber(), let y = nextNumber() { + if firstMove { + cx += x; cy += y; path.move(to: CGPoint(x: cx, y: cy)) + firstMove = false + } else { + cx += x; cy += y; path.addLine(to: CGPoint(x: cx, y: cy)) + } + } + case "L": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let x = nextNumber(), let y = nextNumber() { + cx = x; cy = y; path.addLine(to: CGPoint(x: x, y: y)) + } + case "l": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let x = nextNumber(), let y = nextNumber() { + cx += x; cy += y; path.addLine(to: CGPoint(x: cx, y: cy)) + } + case "H": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let x = nextNumber() { cx = x; path.addLine(to: CGPoint(x: cx, y: cy)) } + case "h": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let x = nextNumber() { cx += x; path.addLine(to: CGPoint(x: cx, y: cy)) } + case "V": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let y = nextNumber() { cy = y; path.addLine(to: CGPoint(x: cx, y: cy)) } + case "v": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let y = nextNumber() { cy += y; path.addLine(to: CGPoint(x: cx, y: cy)) } + case "Z", "z": + lastCubicCtrl = nil; lastQuadCtrl = nil + path.closeSubpath() + case "C": + while let x1 = nextNumber(), let y1 = nextNumber(), + let x2 = nextNumber(), let y2 = nextNumber(), + let x = nextNumber(), let y = nextNumber() { + path.addCurve( + to: CGPoint(x: x, y: y), + control1: CGPoint(x: x1, y: y1), + control2: CGPoint(x: x2, y: y2) + ) + lastCubicCtrl = CGPoint(x: x2, y: y2) + lastQuadCtrl = nil + cx = x; cy = y + } + case "c": + while let dx1 = nextNumber(), let dy1 = nextNumber(), + let dx2 = nextNumber(), let dy2 = nextNumber(), + let dx = nextNumber(), let dy = nextNumber() { + let x1 = cx + dx1; let y1 = cy + dy1 + let x2 = cx + dx2; let y2 = cy + dy2 + let x = cx + dx; let y = cy + dy + path.addCurve( + to: CGPoint(x: x, y: y), + control1: CGPoint(x: x1, y: y1), + control2: CGPoint(x: x2, y: y2) + ) + lastCubicCtrl = CGPoint(x: x2, y: y2) + lastQuadCtrl = nil + cx = x; cy = y + } + case "S": + while let x2 = nextNumber(), let y2 = nextNumber(), + let x = nextNumber(), let y = nextNumber() { + let x1 = lastCubicCtrl.map { 2 * cx - $0.x } ?? cx + let y1 = lastCubicCtrl.map { 2 * cy - $0.y } ?? cy + path.addCurve( + to: CGPoint(x: x, y: y), + control1: CGPoint(x: x1, y: y1), + control2: CGPoint(x: x2, y: y2) + ) + lastCubicCtrl = CGPoint(x: x2, y: y2) + lastQuadCtrl = nil + cx = x; cy = y + } + case "s": + while let dx2 = nextNumber(), let dy2 = nextNumber(), + let dx = nextNumber(), let dy = nextNumber() { + let x1 = lastCubicCtrl.map { 2 * cx - $0.x } ?? cx + let y1 = lastCubicCtrl.map { 2 * cy - $0.y } ?? cy + let x2 = cx + dx2; let y2 = cy + dy2 + let x = cx + dx; let y = cy + dy + path.addCurve( + to: CGPoint(x: x, y: y), + control1: CGPoint(x: x1, y: y1), + control2: CGPoint(x: x2, y: y2) + ) + lastCubicCtrl = CGPoint(x: x2, y: y2) + lastQuadCtrl = nil + cx = x; cy = y + } + case "Q": + while let x1 = nextNumber(), let y1 = nextNumber(), + let x = nextNumber(), let y = nextNumber() { + path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: x1, y: y1)) + lastQuadCtrl = CGPoint(x: x1, y: y1) + lastCubicCtrl = nil + cx = x; cy = y + } + case "q": + while let dx1 = nextNumber(), let dy1 = nextNumber(), + let dx = nextNumber(), let dy = nextNumber() { + let x1 = cx + dx1; let y1 = cy + dy1 + let x = cx + dx; let y = cy + dy + path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: x1, y: y1)) + lastQuadCtrl = CGPoint(x: x1, y: y1) + lastCubicCtrl = nil + cx = x; cy = y + } + case "T": + while let x = nextNumber(), let y = nextNumber() { + let x1 = lastQuadCtrl.map { 2 * cx - $0.x } ?? cx + let y1 = lastQuadCtrl.map { 2 * cy - $0.y } ?? cy + path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: x1, y: y1)) + lastQuadCtrl = CGPoint(x: x1, y: y1) + lastCubicCtrl = nil + cx = x; cy = y + } + case "t": + while let dx = nextNumber(), let dy = nextNumber() { + let x1 = lastQuadCtrl.map { 2 * cx - $0.x } ?? cx + let y1 = lastQuadCtrl.map { 2 * cy - $0.y } ?? cy + let x = cx + dx; let y = cy + dy + path.addQuadCurve(to: CGPoint(x: x, y: y), control: CGPoint(x: x1, y: y1)) + lastQuadCtrl = CGPoint(x: x1, y: y1) + lastCubicCtrl = nil + cx = x; cy = y + } + case "A": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let rx = nextNumber(), let ry = nextNumber(), + let rot = nextNumber(), let large = nextNumber(), let sweep = nextNumber(), + let x = nextNumber(), let y = nextNumber() { + addSVGArc(&path, &cx, &cy, rx: rx, ry: ry, rotation: rot, + largeArc: large != 0, sweep: sweep != 0, end: CGPoint(x: x, y: y)) + } + case "a": + lastCubicCtrl = nil; lastQuadCtrl = nil + while let rx = nextNumber(), let ry = nextNumber(), + let rot = nextNumber(), let large = nextNumber(), let sweep = nextNumber(), + let dx = nextNumber(), let dy = nextNumber() { + let end = CGPoint(x: cx + dx, y: cy + dy) + addSVGArc(&path, &cx, &cy, rx: rx, ry: ry, rotation: rot, + largeArc: large != 0, sweep: sweep != 0, end: end) + } + default: + break + } + } else { + i += 1 + } + } + return path +} + +/// Approximate SVG elliptical arc as a cubic (good enough for widget canvas glyphs). +func addSVGArc( + _ path: inout Path, + _ cx: inout CGFloat, + _ cy: inout CGFloat, + rx: CGFloat, + ry: CGFloat, + rotation: CGFloat, + largeArc: Bool, + sweep: Bool, + end: CGPoint +) { + let start = CGPoint(x: cx, y: cy) + let rrx = abs(rx) < 0.001 ? 0.001 : abs(rx) + let rry = abs(ry) < 0.001 ? 0.001 : abs(ry) + // Degenerate: line + if abs(start.x - end.x) < 0.001, abs(start.y - end.y) < 0.001 { + return + } + // Midpoint-ish control using radii (not a full SVG arc convert — widget fidelity). + let mid = CGPoint(x: (start.x + end.x) / 2, y: (start.y + end.y) / 2) + let dx = end.x - start.x + let dy = end.y - start.y + let len = max(hypot(dx, dy), 0.001) + let nx = -dy / len + let ny = dx / len + let bulge = (largeArc ? 1.0 : 0.35) * min(rrx, rry) * (sweep ? 1.0 : -1.0) + let _ = rotation // rotation ignored in this approximation + let ctrl = CGPoint(x: mid.x + nx * bulge, y: mid.y + ny * bulge) + path.addQuadCurve(to: end, control: ctrl) + cx = end.x + cy = end.y +} diff --git a/swift/Sources/TauriWidgets/WidgetChrome.swift b/swift/Sources/TauriWidgets/WidgetChrome.swift index d20e30e..2c579f7 100644 --- a/swift/Sources/TauriWidgets/WidgetChrome.swift +++ b/swift/Sources/TauriWidgets/WidgetChrome.swift @@ -10,18 +10,7 @@ public enum WidgetChrome { case .solid(let hex): if let sem = Color.semantic(hex) { sem } else { Color(hex: hex) } case .gradient(let g): - let colors = g.colors.map { Color(hex: $0) } - let (s, e): (UnitPoint, UnitPoint) = { - switch g.direction { - case "bottomToTop": return (.bottom, .top) - case "leadingToTrailing": return (.leading, .trailing) - case "trailingToLeading": return (.trailing, .leading) - case "topLeadingToBottomTrailing": return (.topLeading, .bottomTrailing) - case "topTrailingToBottomLeading": return (.topTrailing, .bottomLeading) - default: return (.top, .bottom) - } - }() - LinearGradient(colors: colors, startPoint: s, endPoint: e) + g.asView() case .adaptive(let light, let dark): Color.adaptive(light: light, dark: dark) case nil: diff --git a/swift/Sources/TauriWidgets/render/RenderData.swift b/swift/Sources/TauriWidgets/render/RenderData.swift new file mode 100644 index 0000000..0ce0b82 --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderData.swift @@ -0,0 +1,233 @@ +import SwiftUI + +// MARK: - Progress / Gauge / Chart / List + +extension DynamicElementView { + @ViewBuilder func renderData() -> some View { + switch element.type { + case "progress": renderProgress() + case "gauge": renderGauge() + case "chart": renderChart() + case "list": renderList() + default: EmptyView() + } + } + + // MARK: Progress + + @ViewBuilder func renderProgress() -> some View { + let v = element.value ?? 0; let t = max(element.total ?? 1, 0.0001) + let tc = resolveColor(element.tint) ?? Color.accentColor + let frac = min(max(v / t, 0), 1) + if element.barStyle == "circular" { + // Custom ring — ProgressView breaks under ImageRenderer / macOS snapshots. + ZStack { + Circle().stroke(tc.opacity(0.25), lineWidth: 4) + Circle() + .trim(from: 0, to: frac) + .stroke(tc, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 40, height: 40) + } else { + // Avoid bare GeometryReader in unconstrained HStacks — it steals height and + // collapses into a tall thin strip (Tasks medium sidebar). + VStack(alignment: .leading, spacing: 2) { + if let lbl = element.label { + Text(lbl).font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.75) + .foregroundColor( + resolveColor(element.color) + ?? resolveColor(element.tint) + ?? .secondary + ) + } + Capsule() + .fill(tc.opacity(0.25)) + .frame(height: 6) + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + GeometryReader { geo in + Capsule() + .fill(tc) + .frame(width: max(geo.size.width * CGFloat(frac), 2), height: 6) + } + } + } + .frame(minWidth: 72, maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: Gauge + + @ViewBuilder func renderGauge() -> some View { + let v = element.value ?? 0; let lo = element.min ?? 0; let hi = element.max ?? 1 + let tc = resolveColor(element.tint) ?? Color.accentColor + let ink = resolveColor(element.color) ?? .primary + let span = max(hi - lo, 0.0001) + let frac = min(max((v - lo) / span, 0), 1) + if element.gaugeStyle == "linear" { + // Match desktop/HTML: label row + capsule bar (not accessoryLinear slider chrome). + VStack(alignment: .leading, spacing: 2) { + HStack { + if let lbl = element.label { + Text(lbl).font(.caption2).foregroundColor(ink.opacity(0.7)) + } + Spacer(minLength: 0) + if let cvl = element.currentValueLabel { + Text(cvl).font(.caption).fontWeight(.semibold).foregroundColor(ink) + } + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(tc.opacity(0.25)) + Capsule().fill(tc).frame(width: max(geo.size.width * frac, 2)) + } + } + .frame(height: 6) + } + } else { + // Custom ring — accessoryCircular is blank/misaligned under ImageRenderer. + VStack(spacing: 4) { + ZStack { + Circle().stroke(tc.opacity(0.25), lineWidth: 5) + Circle() + .trim(from: 0, to: frac) + .stroke(tc, style: StrokeStyle(lineWidth: 5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + if let cvl = element.currentValueLabel { + Text(cvl).font(.caption).fontWeight(.semibold).foregroundColor(ink) + } + } + .frame(width: 56, height: 56) + if let lbl = element.label { + Text(lbl).font(.caption2).foregroundColor(ink) + } + } + } + } + + // MARK: Chart + + @ViewBuilder func renderChart() -> some View { + let pts = element.chartData ?? []; let maxV = pts.map(\.value).max() ?? 1 + let tc = resolveColor(element.tint) ?? Color.accentColor + switch element.chartType { + case "line": renderLineChart(pts: pts, maxV: maxV, tc: tc) + case "area": renderAreaChart(pts: pts, maxV: maxV, tc: tc) + case "pie": renderPieChart(pts: pts, tc: tc) + default: renderBarChart(pts: pts, maxV: maxV, tc: tc) + } + } + + // MARK: List + + @ViewBuilder func renderList() -> some View { + let rows = element.items ?? [] + let anyCheckbox = rows.contains { $0.checked != nil } + VStack(alignment: .leading, spacing: element.spacing ?? 4) { + ForEach(rows.indices, id: \.self) { i in + let row = rows[i] + let hasCheckbox = row.checked != nil + let rowView = HStack(spacing: 6) { + if hasCheckbox { + Image(systemName: (row.checked ?? false) ? "checkmark.circle.fill" : "circle") + .foregroundColor((row.checked ?? false) ? .green : .gray) + .font(.system(size: 12)) + .frame(width: 14) + } else if anyCheckbox { + Color.clear.frame(width: 14, height: 12) + } + Text(row.text) + .font(.system(size: element.fontSize ?? 13)) + .lineLimit(1) + .foregroundColor(resolveColor(element.color) ?? .primary) + } + if let action = row.action, !action.isEmpty { + Button(intent: WidgetActionIntent(actionName: action, payload: row.payload)) { rowView } + .buttonStyle(.plain) + } else { + rowView + } + } + } + } + + // MARK: Chart sub-renderers + + @ViewBuilder func renderBarChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { + HStack(alignment: .bottom, spacing: 4) { + ForEach(pts.indices, id: \.self) { i in + let pt = pts[i]; let h = maxV > 0 ? CGFloat(pt.value / maxV) : 0 + VStack(spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(resolveColor(pt.color) ?? tc).frame(height: Swift.max(h * 60, 2)) + Text(pt.label).font(.system(size: 8)).foregroundColor(.white.opacity(0.7)).lineLimit(1) + } + } + } + } + + @ViewBuilder func renderLineChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { + GeometryReader { geo in + let w = geo.size.width; let h = geo.size.height; let cnt = CGFloat(Swift.max(pts.count - 1, 1)) + Path { p in + for (i, pt) in pts.enumerated() { + let x = (CGFloat(i) / cnt) * w + let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) + if i == 0 { p.move(to: CGPoint(x: x, y: y)) } + else { p.addLine(to: CGPoint(x: x, y: y)) } + } + }.stroke(tc, lineWidth: 2) + }.frame(height: 60) + } + + @ViewBuilder func renderAreaChart(pts: [ChartDataPoint], maxV: Double, tc: Color) -> some View { + GeometryReader { geo in + let w = geo.size.width; let h = geo.size.height; let cnt = CGFloat(Swift.max(pts.count - 1, 1)) + ZStack { + Path { p in + p.move(to: CGPoint(x: 0, y: h)) + for (i, pt) in pts.enumerated() { + let x = (CGFloat(i) / cnt) * w + let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) + p.addLine(to: CGPoint(x: x, y: y)) + } + p.addLine(to: CGPoint(x: w, y: h)); p.closeSubpath() + }.fill(tc.opacity(0.3)) + Path { p in + for (i, pt) in pts.enumerated() { + let x = (CGFloat(i) / cnt) * w + let y = h - (maxV > 0 ? CGFloat(pt.value / maxV) * h : 0) + if i == 0 { p.move(to: CGPoint(x: x, y: y)) } + else { p.addLine(to: CGPoint(x: x, y: y)) } + } + }.stroke(tc, lineWidth: 2) + } + }.frame(height: 60) + } + + @ViewBuilder func renderPieChart(pts: [ChartDataPoint], tc: Color) -> some View { + let total = pts.map(\.value).reduce(0, +) + let defColors: [Color] = [.blue, .green, .orange, .red, .purple, .yellow, .pink, .teal] + GeometryReader { geo in + let s = Swift.min(geo.size.width, geo.size.height) + let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2); let radius = s / 2 + ZStack { + ForEach(pts.indices, id: \.self) { i in + let sf = pts.prefix(i).map(\.value).reduce(0, +) / Swift.max(total, 1) + let ef = sf + pts[i].value / Swift.max(total, 1) + let sc = resolveColor(pts[i].color) ?? (i < defColors.count ? defColors[i] : tc) + Path { p in + p.move(to: center) + p.addArc(center: center, radius: radius, + startAngle: .degrees(sf * 360 - 90), + endAngle: .degrees(ef * 360 - 90), clockwise: false) + p.closeSubpath() + }.fill(sc) + } + } + }.aspectRatio(1, contentMode: .fit).frame(height: 80) + } +} diff --git a/swift/Sources/TauriWidgets/render/RenderInteractive.swift b/swift/Sources/TauriWidgets/render/RenderInteractive.swift new file mode 100644 index 0000000..fd1125d --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderInteractive.swift @@ -0,0 +1,92 @@ +import SwiftUI + +// MARK: - Button / Toggle / Link + +extension DynamicElementView { + @ViewBuilder func renderInteractive() -> some View { + switch element.type { + case "button": renderButton() + case "toggle": renderToggle() + case "link": renderLink() + default: EmptyView() + } + } + + // MARK: Button + + @ViewBuilder func renderButton() -> some View { + let lbl = element.label ?? element.content ?? "" + let alignRaw = (element.textAlignment ?? element.alignment ?? "center").lowercased() + let textAlign: TextAlignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) + let frameAlign: Alignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) + let hasExplicitAlign = element.textAlignment != nil || element.alignment != nil + let fs = element.fontSize ?? 14 + // Prefer explicit `padding` as content insets; otherwise scale with fontSize so + // keypad grids (Calculator small/medium) fit WidgetKit family height. + let insets: EdgeInsets = { + if let p = element.padding { + switch p { + case .uniform(let v): + return EdgeInsets(top: v, leading: v, bottom: v, trailing: v) + case .edges(let t, let b, let l, let tr): + return EdgeInsets(top: t ?? 0, leading: l ?? 0, bottom: b ?? 0, trailing: tr ?? 0) + } + } + let h = min(12, max(3, fs * 0.45)) + let v = min(6, max(1.5, fs * 0.22)) + return EdgeInsets(top: v, leading: h, bottom: v, trailing: h) + }() + let baseText = Text(lbl) + .font(.system(size: fs, weight: .medium)) + .foregroundColor(resolveColor(element.color) ?? .white) + .multilineTextAlignment(textAlign) + let alignedText: AnyView = hasExplicitAlign + ? AnyView(baseText.frame(maxWidth: .infinity, alignment: frameAlign)) + : AnyView(baseText) + let btnContent = alignedText + .padding(insets) + .frame(maxWidth: .infinity) + .background(resolveColor(element.backgroundColor) ?? Color.accentColor) + .cornerRadius(element.cornerRadius ?? 8) + if let act = element.action, !act.isEmpty { + Button(intent: WidgetActionIntent(actionName: act)) { btnContent }.buttonStyle(.plain) + } else if let u = element.url, !u.isEmpty, let url = URL(string: u) { + Link(destination: url) { btnContent } + } else { + btnContent + } + } + + // MARK: Toggle + + @ViewBuilder func renderToggle() -> some View { + let isOn = element.isOn ?? false + let content = HStack(spacing: 6) { + Image(systemName: isOn ? "checkmark.circle.fill" : "circle") + .foregroundColor(isOn ? (resolveColor(element.tint) ?? .green) : .gray) + .font(.system(size: 18)) + if let lbl = element.label { + Text(lbl).font(.system(size: 14)) + .foregroundColor(resolveColor(element.color) ?? .primary) + } + } + if let act = element.action, !act.isEmpty { + Button(intent: WidgetActionIntent(actionName: act, payload: isOn ? "false" : "true")) { + content + }.buttonStyle(.plain) + } else { + content + } + } + + // MARK: Link + + @ViewBuilder func renderLink() -> some View { + let content = VStack(spacing: 0) { renderChildren() } + if let act = element.action, !act.isEmpty { + Button(intent: WidgetActionIntent(actionName: act)) { content }.buttonStyle(.plain) + } else if let u = element.url, !u.isEmpty, let url = URL(string: u) { + Link(destination: url) { content } + } else { content } + } +} diff --git a/swift/Sources/TauriWidgets/render/RenderLayout.swift b/swift/Sources/TauriWidgets/render/RenderLayout.swift new file mode 100644 index 0000000..0cc999b --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderLayout.swift @@ -0,0 +1,69 @@ +import SwiftUI + +// MARK: - Containers + +extension DynamicElementView { + @ViewBuilder func renderLayout() -> some View { + switch element.type { + case "vstack": renderVStack() + case "hstack": renderHStack() + case "zstack": renderZStack() + case "grid": renderGrid() + case "container": renderContainer() + default: EmptyView() + } + } + + @ViewBuilder func renderVStack() -> some View { + let align: HorizontalAlignment = element.alignment == "leading" ? .leading + : element.alignment == "trailing" ? .trailing : .center + VStack(alignment: align, spacing: element.spacing ?? 0) { renderChildren(axis: .vertical) } + } + + @ViewBuilder func renderHStack() -> some View { + let align: VerticalAlignment = element.alignment == "top" ? .top + : element.alignment == "bottom" ? .bottom : .center + HStack(alignment: align, spacing: element.spacing ?? 0) { renderChildren(axis: .horizontal) } + } + + @ViewBuilder func renderZStack() -> some View { + ZStack(alignment: parseAlignment(element.alignment)) { + if let children = element.children { + ForEach(children.indices, id: \.self) { idx in + DynamicElementView(element: children[idx], inZStack: true) + } + } + } + } + + @ViewBuilder func renderGrid() -> some View { + let cols = element.columns ?? 2; let sp = element.spacing ?? 4 + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: sp), count: max(1, Int(cols))), + spacing: element.rowSpacing ?? sp + ) { renderChildren() } + } + + @ViewBuilder func renderContainer() -> some View { + let a = parseAlignment(element.contentAlignment) + if let children = element.children, !children.isEmpty { + ZStack(alignment: a) { + ForEach(children.indices, id: \.self) { idx in + DynamicElementView(element: children[idx]) + } + } + // Expand so contentAlignment centers inside frame/padding, not intrinsic hug. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: a) + } else { + EmptyView() + } + } + + @ViewBuilder func renderChildren(axis: Axis? = nil) -> some View { + if let children = element.children { + ForEach(children.indices, id: \.self) { + DynamicElementView(element: children[$0], parentAxis: axis) + } + } + } +} diff --git a/swift/Sources/TauriWidgets/render/RenderMedia.swift b/swift/Sources/TauriWidgets/render/RenderMedia.swift new file mode 100644 index 0000000..3e275ef --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderMedia.swift @@ -0,0 +1,172 @@ +import SwiftUI + +// MARK: - Image / Shape / Canvas + +extension DynamicElementView { + @ViewBuilder func renderMedia() -> some View { + switch element.type { + case "image": renderImage() + case "shape": renderShape() + case "canvas": renderCanvas() + default: EmptyView() + } + } + + // MARK: Image + + @ViewBuilder func renderImage() -> some View { + let s = element.size ?? 24 + if let sn = element.systemName { + let img = Image(systemName: sn).resizable() + .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) + .frame(width: s, height: s) + if let c = resolveColor(element.color) { img.foregroundColor(c) } else { img } + } else if let decoded = decodeImageData(element.data) { + decodedImageView(decoded, size: s) + } else if let url = element.url, let decoded = decodeImageData(url) { + decodedImageView(decoded, size: s) + } else { + placeholderImage(s) + } + } + + func decodeImageData(_ raw: String?) -> Data? { + guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return nil } + if s.hasPrefix("data:"), let comma = s.firstIndex(of: ",") { + s = String(s[s.index(after: comma)...]) + } + return Data(base64Encoded: s) + } + + @ViewBuilder func decodedImageView(_ raw: Data, size: CGFloat) -> some View { + #if canImport(UIKit) + if let uiImage = UIImage(data: raw) { + Image(uiImage: uiImage).resizable() + .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) + .frame(width: size, height: size) + } else { placeholderImage(size) } + #elseif canImport(AppKit) + if let nsImage = NSImage(data: raw) { + Image(nsImage: nsImage).resizable() + .aspectRatio(contentMode: element.contentMode == "fill" ? .fill : .fit) + .frame(width: size, height: size) + } else { placeholderImage(size) } + #endif + } + + @ViewBuilder func placeholderImage(_ s: CGFloat) -> some View { + Image(systemName: "questionmark.square.dashed").resizable() + .frame(width: s, height: s).foregroundColor(.gray) + } + + // MARK: Shape + + @ViewBuilder func renderShape() -> some View { + let s = element.size ?? 24 + let fc = resolveColor(element.fill) ?? Color.accentColor + let sc = resolveColor(element.stroke); let sw = element.strokeWidth ?? 1 + switch element.shapeType { + case "circle": + ZStack { + Circle().fill(fc).frame(width: s, height: s) + if let c = sc { Circle().stroke(c, lineWidth: sw).frame(width: s, height: s) } + } + case "capsule": + ZStack { + Capsule().fill(fc).frame(width: s * 2, height: s) + if let c = sc { Capsule().stroke(c, lineWidth: sw).frame(width: s * 2, height: s) } + } + default: + let cr = element.cornerRadius ?? 0 + ZStack { + RoundedRectangle(cornerRadius: cr).fill(fc).frame(width: s, height: s) + if let c = sc { RoundedRectangle(cornerRadius: cr).stroke(c, lineWidth: sw).frame(width: s, height: s) } + } + } + } + + // MARK: Canvas + + @ViewBuilder func renderCanvas() -> some View { + let cw = max(element.width ?? 100, 1) + let ch = max(element.height ?? 100, 1) + let commands = element.elements ?? [] + // Uniform scale + letterbox — independent sx/sy stretches circles into ovals + // and splits clock faces when the view is flex-resized (Analog Clock large). + let canvasView = SwiftUI.Canvas { context, size in + let s = Swift.min(size.width / cw, size.height / ch) + let ox = (size.width - cw * s) / 2 + let oy = (size.height - ch * s) / 2 + func X(_ v: CGFloat) -> CGFloat { ox + v * s } + func Y(_ v: CGFloat) -> CGFloat { oy + v * s } + for cmd in commands { + switch cmd.draw { + case "circle": + let cx = X(cmd.cx ?? 0); let cy = Y(cmd.cy ?? 0); let r = (cmd.r ?? 10) * s + let rect = CGRect(x: cx - r, y: cy - r, width: r * 2, height: r * 2) + if let f = resolveColor(cmd.fill) { context.fill(SwiftUI.Path(ellipseIn: rect), with: .color(f)) } + if let st = resolveColor(cmd.stroke) { + context.stroke(SwiftUI.Path(ellipseIn: rect), with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) + } + case "line": + var path = SwiftUI.Path() + path.move(to: CGPoint(x: X(cmd.x1 ?? 0), y: Y(cmd.y1 ?? 0))) + path.addLine(to: CGPoint(x: X(cmd.x2 ?? 0), y: Y(cmd.y2 ?? 0))) + var style = StrokeStyle(lineWidth: (cmd.strokeWidth ?? 1) * s) + if cmd.lineCap == "round" { style.lineCap = .round } + context.stroke(path, with: .color(resolveColor(cmd.stroke) ?? Color(hex: "#ffffff")), style: style) + case "rect": + let rect = CGRect( + x: X(cmd.x ?? 0), y: Y(cmd.y ?? 0), + width: (cmd.width ?? 10) * s, height: (cmd.height ?? 10) * s + ) + let cr = (cmd.cornerRadius ?? 0) * s + let p = SwiftUI.Path(roundedRect: rect, cornerRadius: cr) + if let f = resolveColor(cmd.fill) { context.fill(p, with: .color(f)) } + if let st = resolveColor(cmd.stroke) { + context.stroke(p, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) + } + case "arc": + let cx = X(cmd.cx ?? 0); let cy = Y(cmd.cy ?? 0); let r = (cmd.r ?? 10) * s + var path = SwiftUI.Path() + if cmd.fill != nil { path.move(to: CGPoint(x: cx, y: cy)) } + path.addArc( + center: CGPoint(x: cx, y: cy), radius: r, + startAngle: .degrees(Double(cmd.startAngle ?? 0)), + endAngle: .degrees(Double(cmd.endAngle ?? 360)), clockwise: false + ) + if cmd.fill != nil { path.closeSubpath() } + if let f = resolveColor(cmd.fill) { context.fill(path, with: .color(f)) } + if let st = resolveColor(cmd.stroke) { + context.stroke(path, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) + } + case "text": + let fs = (cmd.fontSize ?? 12) * s + let txt = SwiftUI.Text(cmd.content ?? "").font(.system(size: fs)) + .foregroundColor(resolveColor(cmd.color) ?? .white) + let pt = CGPoint(x: X(cmd.x ?? 0), y: Y(cmd.y ?? 0)) + let anchor: UnitPoint = cmd.anchor == "end" ? .trailing : cmd.anchor == "middle" ? .center : .leading + context.draw(context.resolve(txt), at: pt, anchor: anchor) + case "path": + if let d = cmd.d { + var path = parseSVGPath(d) + path = path.applying( + CGAffineTransform(a: s, b: 0, c: 0, d: s, tx: ox, ty: oy) + ) + if let f = resolveColor(cmd.fill) { context.fill(path, with: .color(f)) } + if let st = resolveColor(cmd.stroke) { + context.stroke(path, with: .color(st), lineWidth: (cmd.strokeWidth ?? 1) * s) + } + } + default: break + } + } + } + // Fit into offered space; never force a fixed pt size larger than the family. + canvasView + .aspectRatio(cw / ch, contentMode: .fit) + .frame(maxWidth: cw, maxHeight: ch) + .frame(maxWidth: (element.flex ?? 0) > 0 ? .infinity : nil, + maxHeight: (element.flex ?? 0) > 0 ? .infinity : nil) + } +} diff --git a/swift/Sources/TauriWidgets/render/RenderSpacing.swift b/swift/Sources/TauriWidgets/render/RenderSpacing.swift new file mode 100644 index 0000000..a1cb114 --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderSpacing.swift @@ -0,0 +1,28 @@ +import SwiftUI + +// MARK: - Divider / Spacer + +extension DynamicElementView { + @ViewBuilder func renderSpacing() -> some View { + switch element.type { + case "spacer": renderSpacer() + case "divider": renderDivider() + default: EmptyView() + } + } + + @ViewBuilder func renderDivider() -> some View { + let fill = Rectangle().fill(resolveColor(element.color) ?? Color.gray.opacity(0.3)) + let thickness = element.thickness ?? 1 + if parentAxis == .horizontal { + fill.frame(width: thickness) + } else { + fill.frame(height: thickness) + .padding(.vertical, 4) + } + } + + @ViewBuilder func renderSpacer() -> some View { + if let ml = element.minLength { Spacer(minLength: ml) } else { Spacer() } + } +} diff --git a/swift/Sources/TauriWidgets/render/RenderText.swift b/swift/Sources/TauriWidgets/render/RenderText.swift new file mode 100644 index 0000000..d4c695d --- /dev/null +++ b/swift/Sources/TauriWidgets/render/RenderText.swift @@ -0,0 +1,124 @@ +import SwiftUI + +// MARK: - Text / Date / Timer / Label + +extension DynamicElementView { + @ViewBuilder func renderTextGroup() -> some View { + switch element.type { + case "text": renderText() + case "label": renderLabel() + case "date": renderDate() + case "timer": renderTimer() + default: EmptyView() + } + } + + // MARK: Text + + @ViewBuilder func renderText() -> some View { + let txt: Text = { + let t = Text(element.content ?? "") + if let ts = textStyleFont(element.textStyle) { + return t.font(ts.weight(fontWeight(element.fontWeight))) + } + return t.font(.system(size: element.fontSize ?? 14, + weight: fontWeight(element.fontWeight), + design: fontDesign(element.fontDesign))) + }() + let colored = txt.foregroundColor(resolveColor(element.color) ?? .primary) + // Circle / fixed box badges (e.g. avatar initials) center by default — leading + + // maxWidth:.infinity pinned "AK" to the top-left of the clip. + let hasFixedBox = element.frame?.width != nil && element.frame?.height != nil + let defaultAlign = (element.clipShape == "circle" || hasFixedBox) ? "center" : "leading" + let alignRaw = (element.alignment ?? defaultAlign).lowercased() + let textAlign: TextAlignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing + : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) + let frameAlign: Alignment = alignRaw == "trailing" || alignRaw == "right" || alignRaw == "end" ? .trailing + : (alignRaw == "center" || alignRaw == "middle" ? .center : .leading) + let base = colored.multilineTextAlignment(textAlign) + Group { + if let limit = element.lineLimit { + base.lineLimit(Int(limit)) + } else { + base + } + } + .fixedSize(horizontal: inZStack || hasFixedBox, vertical: hasFixedBox) + .frame(maxWidth: (inZStack || hasFixedBox) ? nil : .infinity, alignment: frameAlign) + } + + // MARK: Date + + @ViewBuilder func renderDate() -> some View { + let d: Date = { + guard let ds = element.date else { return Date() } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = f.date(from: ds) { return parsed } + f.formatOptions = [.withInternetDateTime] + return f.date(from: ds) ?? Date() + }() + let tv: Text = { + switch element.dateStyle { + case "time": return Text(d, style: .time) + case "date": return Text(d, style: .date) + case "relative": return Text(d, style: .relative) + case "offset": return Text(d, style: .offset) + case "timer": return Text(d, style: .timer) + default: return Text(d, style: .time) + } + }() + tv.font(.system(size: element.fontSize ?? 14)) + .foregroundColor(resolveColor(element.color) ?? .primary) + } + + // MARK: Timer + + @ViewBuilder func renderTimer() -> some View { + let target: Date = { + guard let ds = element.targetDate else { return Date() } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = f.date(from: ds) { return parsed } + f.formatOptions = [.withInternetDateTime] + return f.date(from: ds) ?? Date() + }() + let countingUp = (element.counting ?? "down").lowercased() == "up" + // WidgetKit keeps Text(timerInterval:) live without a process-resident TimelineView. + let style = Font.system(size: element.fontSize ?? 14, weight: fontWeight(element.fontWeight)).monospacedDigit() + let color = resolveColor(element.color) ?? Color.primary + if countingUp { + Text(timerInterval: target...Date.distantFuture, countsDown: false) + .font(style) + .foregroundColor(color) + .lineLimit(1) + .minimumScaleFactor(0.55) + .allowsTightening(true) + } else { + Text(timerInterval: Date.now...max(Date.now, target), countsDown: true) + .font(style) + .foregroundColor(color) + .lineLimit(1) + .minimumScaleFactor(0.55) + .allowsTightening(true) + } + } + + // MARK: Label + + @ViewBuilder func renderLabel() -> some View { + let sp = element.spacing ?? 4 + HStack(spacing: sp) { + if let sn = element.systemName { + let isz = (element.fontSize ?? 14) * 1.1 + Image(systemName: sn).resizable().aspectRatio(contentMode: .fit) + .frame(width: isz, height: isz) + .foregroundColor(resolveColor(element.iconColor) + ?? resolveColor(element.color) ?? .primary) + } + Text(element.text ?? element.content ?? "") + .font(.system(size: element.fontSize ?? 14, weight: fontWeight(element.fontWeight))) + .foregroundColor(resolveColor(element.color) ?? .primary) + } + } +} diff --git a/swift/Tests/TauriWidgetsTests/DispatcherCoverageTests.swift b/swift/Tests/TauriWidgetsTests/DispatcherCoverageTests.swift new file mode 100644 index 0000000..c671b30 --- /dev/null +++ b/swift/Tests/TauriWidgetsTests/DispatcherCoverageTests.swift @@ -0,0 +1,46 @@ +import XCTest +import SwiftUI +@testable import TauriWidgets + +/// Structural guard for the six-group dispatcher. +/// Catches "forgot to wire a type into a group" without depending on PNG goldens. +final class DispatcherCoverageTests: XCTestCase { + /// One minimal element per docs group — must decode and evaluate body. + private static let samples: [(group: String, json: String)] = [ + ("layout", ##"{"type":"vstack","spacing":4,"children":[{"type":"text","content":"L"}]}"##), + ("text", ##"{"type":"text","content":"hello","fontSize":14}"##), + ("media", ##"{"type":"shape","shapeType":"circle","size":16,"fill":"#ff0000"}"##), + ("data", ##"{"type":"progress","value":0.5,"total":1,"barStyle":"linear"}"##), + ("interactive", ##"{"type":"button","label":"Go","action":"tap"}"##), + ("spacing", ##"{"type":"spacer","minLength":8}"##), + ] + + func testEveryDocsGroupRenders() throws { + for (group, json) in Self.samples { + let data = Data(json.utf8) + let el: WidgetElement + do { + el = try JSONDecoder().decode(WidgetElement.self, from: data) + } catch { + return XCTFail("\(group): decode failed — \(error)") + } + XCTAssertFalse(el.type.isEmpty, "\(group): empty type") + + let view = DynamicElementView(element: el) + _ = view.body + } + } + + /// Dispatcher groups must stay aligned with docs/guide/elements/*.md names. + func testKnownTypesAreAssignedToAGroup() { + let layout: Set = ["vstack", "hstack", "zstack", "grid", "container"] + let text: Set = ["text", "label", "date", "timer"] + let media: Set = ["image", "shape", "canvas"] + let data: Set = ["progress", "gauge", "chart", "list"] + let interactive: Set = ["button", "toggle", "link"] + let spacing: Set = ["spacer", "divider"] + let all = layout.union(text).union(media).union(data).union(interactive).union(spacing) + // 21 IR element types — if Models grow a new type, this fails until a group claims it. + XCTAssertEqual(all.count, 21, "expected 21 element types across six docs groups, got \(all.count)") + } +} diff --git a/templates/macos-widget/build-widget.sh b/templates/macos-widget/build-widget.sh index 57d31b5..09ff609 100755 --- a/templates/macos-widget/build-widget.sh +++ b/templates/macos-widget/build-widget.sh @@ -10,9 +10,69 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIGURATION="${1:-Release}" DERIVED_DATA="$SCRIPT_DIR/build" -ENTITLEMENTS="$SCRIPT_DIR/TauriWidgetExtension.entitlements" +HOST_ENTS="$SCRIPT_DIR/App.entitlements" +EXT_ENTS="$SCRIPT_DIR/TauriWidgetExtension.entitlements" IDENTITY="${WIDGET_SIGN_IDENTITY:-${APPLE_SIGNING_IDENTITY:--}}" +# ─── Regenerate entitlements from plugins.widgets.appGroup ─────────────────── +# Single source of truth in tauri.conf.json — avoids host/extension drift. + +CONF="$SCRIPT_DIR/../tauri.conf.json" +if [[ ! -f "$CONF" ]]; then + CONF="$(cd "$SCRIPT_DIR/../.." && pwd)/src-tauri/tauri.conf.json" +fi + +gen_entitlements() { + local dest="$1" + local group="$2" + local sandbox="$3" + { + echo '' + echo '' + echo '' + echo '' + if [[ "$sandbox" == "1" ]]; then + echo ' com.apple.security.app-sandbox' + echo ' ' + fi + echo ' com.apple.security.application-groups' + echo ' ' + echo " ${group}" + echo ' ' + echo '' + echo '' + } >"$dest" +} + +if [[ -f "$CONF" ]] && command -v node >/dev/null 2>&1; then + GROUP="$(node -e " + const fs = require('fs'); + const conf = JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); + const g = conf.plugins && conf.plugins.widgets && conf.plugins.widgets.appGroup; + if (!g) { console.error('ERROR: plugins.widgets.appGroup missing in', process.argv[1]); process.exit(1); } + process.stdout.write(String(g)); + " "$CONF")" + if [[ ! "$GROUP" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "ERROR: invalid appGroup identifier: $GROUP" >&2 + exit 1 + fi + gen_entitlements "$HOST_ENTS" "$GROUP" 0 + gen_entitlements "$EXT_ENTS" "$GROUP" 1 + echo "[widget] Regenerated entitlements for App Group: $GROUP" + for swift in "$SCRIPT_DIR"/Sources/*.swift "$SCRIPT_DIR"/MyWidget.swift; do + [[ -f "$swift" ]] || continue + if grep -q 'let appGroup = "' "$swift" 2>/dev/null; then + sed -i '' "s/let appGroup = \"[^\"]*\"/let appGroup = \"${GROUP}\"/" "$swift" + fi + done + echo "[widget] Synced appGroup in Swift sources" +elif [[ -f "$EXT_ENTS" ]]; then + echo "[widget] WARNING: could not read plugins.widgets.appGroup — using existing entitlements" +else + echo "ERROR: need plugins.widgets.appGroup in tauri.conf.json (and node on PATH) to generate entitlements" >&2 + exit 1 +fi + # ─── Generate Xcode project ───────────────────────────────────────────────── if command -v xcodegen &>/dev/null; then @@ -58,13 +118,13 @@ rm -rf "$APPEX_PATH/Contents/Frameworks" # ─── Sign .appex with WidgetKit entitlements ───────────────────────────────── -if [ -f "$ENTITLEMENTS" ]; then +if [ -f "$EXT_ENTS" ]; then codesign --force --options runtime --sign "$IDENTITY" \ - --entitlements "$ENTITLEMENTS" \ + --entitlements "$EXT_ENTS" \ "$APPEX_PATH" echo "[widget] Signed .appex with entitlements ($IDENTITY)" else - echo "WARNING: Entitlements not found at $ENTITLEMENTS — signing without them" + echo "WARNING: Entitlements not found at $EXT_ENTS — signing without them" codesign --force --options runtime --sign "$IDENTITY" "$APPEX_PATH" fi diff --git a/templates/starter/README.md b/templates/starter/README.md new file mode 100644 index 0000000..b511e73 --- /dev/null +++ b/templates/starter/README.md @@ -0,0 +1,26 @@ +# Starter — preview a widget without a native build + +```bash +npx degit s00d/tauri-plugin-widgets/templates/starter my-widget +cd my-widget +# from a checkout of the plugin, or after: pnpm add -g tauri-plugin-widgets-api +npx tauri-widgets preview ./widget.json --size medium --watch --open +``` + +Edit `widget.json` and the browser reloads. + +Validate before wiring into an app: + +```bash +npx tauri-widgets validate ./widget.json --platforms ios,android,desktop +``` + +## Next: real Tauri app + +1. `pnpm create tauri-app` (or add the plugin to an existing app) +2. `pnpm tauri add tauri-plugin-widgets` +3. `npx tauri-widgets init` +4. `npx tauri-widgets signing --apply` +5. Call `setWidgetConfig` with this JSON (keep `$schema` for the editor) + +Docs: https://s00d.github.io/tauri-plugin-widgets/ diff --git a/templates/starter/widget.json b/templates/starter/widget.json new file mode 100644 index 0000000..72c39f3 --- /dev/null +++ b/templates/starter/widget.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", + "small": { + "type": "vstack", + "padding": 12, + "background": "#1a1a2e", + "spacing": 8, + "children": [ + { + "type": "text", + "content": "72°", + "fontSize": 36, + "fontWeight": "bold", + "color": "#ffffff" + }, + { + "type": "text", + "content": "Partly cloudy", + "fontSize": 14, + "color": "#a0a0b0" + }, + { + "type": "progress", + "value": 0.7, + "tint": "#4CAF50", + "label": "Humidity" + } + ] + }, + "medium": { + "type": "hstack", + "padding": 16, + "background": "#1a1a2e", + "spacing": 16, + "children": [ + { + "type": "vstack", + "spacing": 4, + "children": [ + { + "type": "text", + "content": "72°", + "fontSize": 42, + "fontWeight": "bold", + "color": "#ffffff" + }, + { + "type": "text", + "content": "San Francisco", + "fontSize": 13, + "color": "#a0a0b0" + } + ] + }, + { + "type": "vstack", + "spacing": 8, + "children": [ + { + "type": "progress", + "value": 0.7, + "tint": "#4CAF50", + "label": "Humidity" + }, + { + "type": "progress", + "value": 0.4, + "tint": "#2196F3", + "label": "Wind" + } + ] + } + ] + } +} diff --git a/templates/windows-widget/DevCert.ps1 b/templates/windows-widget/DevCert.ps1 new file mode 100644 index 0000000..5846e25 --- /dev/null +++ b/templates/windows-widget/DevCert.ps1 @@ -0,0 +1,31 @@ +#Requires -Version 5.1 +# Self-signed cert for MSIX sideload / Widgets Board. +# Subject MUST equal Package.appxmanifest Identity/@Publisher exactly. +# Prefer regenerating via: npx tauri-widgets signing --dev-cert +param( + [string]$Publisher = "CN=TauriWidgetsDev", + [string]$PfxPath = "dev.pfx", + [string]$Password = "dev", + [string]$MsixPath = "" +) + +$ErrorActionPreference = "Stop" +$cert = New-SelfSignedCertificate -Type Custom -Subject $Publisher ` + -KeyUsage DigitalSignature -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}") +$pwd = ConvertTo-SecureString $Password -Force -AsPlainText +Export-PfxCertificate -Cert "Cert:\CurrentUser\My\$($cert.Thumbprint)" -FilePath $PfxPath -Password $pwd | Out-Null +Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\LocalMachine\TrustedPeople -Password $pwd | Out-Null +Write-Host "Thumbprint: $($cert.Thumbprint)" +Write-Host "PFX: $PfxPath (password=$Password)" +Write-Host "Imported into LocalMachine\TrustedPeople" +if ($MsixPath) { + $signtool = Get-Command signtool -ErrorAction SilentlyContinue + if (-not $signtool) { + Write-Error "signtool not on PATH. Install Windows SDK / use Developer Command Prompt." + } + & signtool sign /fd SHA256 /f $PfxPath /p $Password $MsixPath + Write-Host "Signed: $MsixPath" +} else { + Write-Host "Sign later: signtool sign /fd SHA256 /f $PfxPath /p $Password path\to\app.msix" +} diff --git a/templates/windows-widget/README.md b/templates/windows-widget/README.md index b446e15..f2a9d46 100644 --- a/templates/windows-widget/README.md +++ b/templates/windows-widget/README.md @@ -43,7 +43,21 @@ dotnet build WidgetProvider/WidgetProvider.csproj -c Release -p:Smoke=false dotnet build PreviewHost/PreviewHost.csproj -c Release -p:Smoke=true # Full Adaptive Cards renderer (Widgets Board parity goldens): dotnet build PreviewHost/PreviewHost.csproj -c Release -p:Smoke=false -# Capture: just test-windows-visual / tools/win/shot.ps1 -Mode visual +# Capture: just test-windows-visual / scripts/win/shot.ps1 -Mode visual ``` -Pack / sideload helpers live in the plugin repo: `tools/win/pack.ps1`, `tools/win/sideload.ps1` (`just win-pack`, `just win-sideload`). +Pack / sideload helpers live in the plugin repo: `scripts/win/pack.ps1`, `scripts/win/sideload.ps1` (`just win-pack` / `pnpm win-pack`, same for sideload). + +## Dev certificate + +`Identity/@Publisher` in your MSIX manifest **must** match the cert Subject. Scaffold includes `DevCert.ps1`; regenerate after setting Publisher: + +```bash +npx tauri-widgets signing --dev-cert +# → prints script + writes src-tauri/windows-widget/DevCert.ps1 +``` + +```powershell +# elevated +powershell -ExecutionPolicy Bypass -File src-tauri/windows-widget/DevCert.ps1 -MsixPath path\to\app.msix +``` diff --git a/templates/windows-widget/WidgetProvider/Provider.cs b/templates/windows-widget/WidgetProvider/Provider.cs index 2cffd08..b1f7a05 100644 --- a/templates/windows-widget/WidgetProvider/Provider.cs +++ b/templates/windows-widget/WidgetProvider/Provider.cs @@ -19,6 +19,22 @@ public sealed class WidgetProvider { private static readonly WidgetStore Store = new(); private static readonly ConcurrentWidgetSet Running = new(); + /// + /// Minute tick so countdown/timer cards refresh without a live client clock. + /// Not second-level live — Adaptive Cards have no client JS. + /// + private static readonly System.Threading.Timer RefreshTimer = new( + _ => + { + foreach (var id in Running.Ids) + { + try { Push(id); } + catch { /* widget may have been removed */ } + } + }, + null, + dueTime: TimeSpan.FromSeconds(15), + period: TimeSpan.FromMinutes(1)); static WidgetProvider() { @@ -36,6 +52,8 @@ static WidgetProvider() } } }; + // Keep the timer rooted for the process lifetime. + GC.KeepAlive(RefreshTimer); } #if !WIDGET_SMOKE diff --git a/tests/cases/style-chrome.light.small.json b/tests/cases/style-chrome.light.small.json new file mode 100644 index 0000000..8d433a5 --- /dev/null +++ b/tests/cases/style-chrome.light.small.json @@ -0,0 +1,11 @@ +{ + "fixture": "core/style-chrome", + "size": "small", + "theme": "light", + "locale": "en_US", + "platforms": ["desktop", "ios", "macos"], + "macos": { + "width": 158, + "height": 158 + } +} diff --git a/tests/cases/upcoming-payments.light.small.json b/tests/cases/upcoming-payments.light.small.json new file mode 100644 index 0000000..8f70b97 --- /dev/null +++ b/tests/cases/upcoming-payments.light.small.json @@ -0,0 +1,11 @@ +{ + "fixture": "presets/upcoming-payments", + "size": "small", + "theme": "light", + "locale": "en_US", + "platforms": ["desktop", "ios", "macos"], + "macos": { + "width": 158, + "height": 158 + } +} diff --git a/tests/cases/weather.light.small.json b/tests/cases/weather.light.small.json new file mode 100644 index 0000000..5ed8bb2 --- /dev/null +++ b/tests/cases/weather.light.small.json @@ -0,0 +1,11 @@ +{ + "fixture": "presets/weather", + "size": "small", + "theme": "light", + "locale": "en_US", + "platforms": ["desktop", "ios", "macos"], + "macos": { + "width": 158, + "height": 158 + } +} diff --git a/tests/cli/consumer.test.mjs b/tests/cli/consumer.test.mjs new file mode 100644 index 0000000..d5f5ad4 --- /dev/null +++ b/tests/cli/consumer.test.mjs @@ -0,0 +1,141 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { validateWidgetConfig, loadCapabilities, validatePluginWidgets } from "../../dist-cli/lib/validate.mjs"; +import { formatTraceEvent } from "../../dist-cli/lib/trace.mjs"; +import { prefetchCacheDir } from "../../dist-cli/lib/clean.mjs"; +import { startPreviewServer, loadConfig } from "../../dist-cli/lib/preview.mjs"; +import { pluginRoot } from "../../dist-cli/lib/paths.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const CLI = join(ROOT, "dist-cli/cli.mjs"); + +function cli(...args) { + return spawnSync(process.execPath, [CLI, ...args], { + encoding: "utf8", + cwd: ROOT, + }); +} + +describe("consumer CLI surface", () => { + it("lists consumer commands", () => { + const r = cli("--help"); + assert.equal(r.status, 0); + for (const cmd of ["init", "init-ios", "init-macos", "preview", "validate", "signing", "doctor", "trace", "clean"]) { + assert.match(r.stdout, new RegExp(cmd)); + } + }); + + it("signing --help mentions apply and dev-cert", () => { + const r = cli("signing", "--help"); + assert.equal(r.status, 0); + assert.match(r.stdout, /apply/); + assert.match(r.stdout, /dev-cert/); + }); + + it("pluginRoot resolves templates from dist-cli", () => { + assert.ok(existsSync(join(pluginRoot(), "templates/macos-widget/project.yml"))); + }); +}); + +describe("validate", () => { + it("loads capabilities.json", () => { + const { data } = loadCapabilities(); + assert.equal(data.version, 1); + assert.ok(data.elements.vstack); + assert.ok(data.elements.timer); + }); + + it("flags degraded canvas on android", () => { + const { data } = loadCapabilities(); + const findings = validateWidgetConfig( + { + small: { type: "canvas", draws: [] }, + }, + ["android"], + data, + ); + assert.ok(findings.some((f) => f.type === "canvas" && f.support === "degraded")); + }); + + it("cli validate exits 0 on starter widget", () => { + const r = cli("validate", join(ROOT, "templates/starter/widget.json"), "--platforms", "desktop,ios"); + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.match(r.stdout, /OK|✓/); + }); + + it("validatePluginWidgets requires appGroup", () => { + const bad = validatePluginWidgets({ transport: "appGroup" }); + assert.ok(bad.some((f) => f.level === "error")); + const good = validatePluginWidgets({ transport: "widgetContainer", appGroup: "group.com.x" }); + assert.equal(good.filter((f) => f.level === "error").length, 0); + }); +}); + +describe("preview", () => { + it("serves config JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "tpw-prev-")); + const cfg = join(dir, "w.json"); + writeFileSync( + cfg, + JSON.stringify({ + $schema: "https://example.com/schema.json", + small: { type: "text", content: "hi", color: "#fff" }, + }), + ); + const stripped = loadConfig(cfg); + assert.equal(stripped.$schema, undefined); + assert.equal(stripped.small.type, "text"); + + const srv = await startPreviewServer({ + configPath: cfg, + size: "small", + port: 41789, + watchFile: false, + }); + try { + const res = await fetch("http://127.0.0.1:41789/api/config"); + assert.equal(res.status, 200); + const j = await res.json(); + assert.equal(j.small.content, "hi"); + const html = await fetch(srv.url); + assert.equal(html.status, 200); + const body = await html.text(); + assert.match(body, /__TAURI_INTERNALS__/); + } finally { + srv.close(); + } + }); +}); + +describe("trace format", () => { + it("formats reload throttle", () => { + const line = formatTraceEvent({ + ts: Date.parse("2026-08-03T12:05:31Z"), + kind: "reload", + reason: { outcome: "throttled", remainingSecs: 840 }, + }); + assert.match(line, /throttled/); + assert.match(line, /840/); + }); +}); + +describe("clean", () => { + it("exposes prefetch cache path", () => { + assert.match(prefetchCacheDir(), /tauri-plugin-widgets/); + assert.match(prefetchCacheDir(), /image-prefetch/); + }); +}); + +describe("schemas published", () => { + it("has plugin-config and capabilities", () => { + assert.ok(existsSync(join(ROOT, "schemas/plugin-config.v1.json"))); + assert.ok(existsSync(join(ROOT, "schemas/capabilities.json"))); + const caps = JSON.parse(readFileSync(join(ROOT, "schemas/capabilities.json"), "utf8")); + assert.equal(caps.version, 1); + }); +}); diff --git a/tests/cli/doctor.test.mjs b/tests/cli/doctor.test.mjs new file mode 100644 index 0000000..b5efe0d --- /dev/null +++ b/tests/cli/doctor.test.mjs @@ -0,0 +1,55 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { runDoctor } from "../../dist-cli/commands/doctor.mjs"; +import { pluginRoot } from "../../dist-cli/lib/paths.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const EXAMPLE = join(ROOT, "examples/tauri-plugin-widgets-example"); + +describe("runDoctor", () => { + it("flags missing tauri.conf without printing", async () => { + const dir = mkdtempSync(join(tmpdir(), "tpw-doctor-")); + const r = await runDoctor(dir); + assert.ok(r.failures >= 1); + assert.ok( + r.items.some((i) => i.level === "bad" && /tauri\.conf\.json/.test(i.msg)), + JSON.stringify(r.items.filter((i) => i.level === "bad"), null, 2), + ); + }); + + it("flags empty appGroup when conf exists", async () => { + const dir = mkdtempSync(join(tmpdir(), "tpw-doctor-ag-")); + const st = join(dir, "src-tauri"); + mkdirSync(st, { recursive: true }); + writeFileSync( + join(st, "tauri.conf.json"), + JSON.stringify({ + identifier: "com.test.empty-ag", + plugins: { widgets: { transport: "auto", appGroup: "" } }, + }), + ); + const r = await runDoctor(dir); + assert.ok( + r.items.some((i) => i.level === "bad" && /appGroup is empty/.test(i.msg)), + JSON.stringify(r.items.filter((i) => i.section === "transport"), null, 2), + ); + }); + + it("returns structured items for the example app", async () => { + assert.ok(existsSync(EXAMPLE)); + const r = await runDoctor(EXAMPLE); + assert.ok(r.items.length > 0); + assert.ok(r.items.some((i) => i.level === "ok")); + // Must not throw / print — failures is a number we can assert on + assert.equal(typeof r.failures, "number"); + assert.equal(typeof r.print, "function"); + }); + + it("pluginRoot from doctor module tree finds package templates", () => { + assert.ok(existsSync(join(pluginRoot(), "templates/macos-widget/project.yml"))); + }); +}); diff --git a/tests/cli/entitlements.test.mjs b/tests/cli/entitlements.test.mjs new file mode 100644 index 0000000..4a4cb51 --- /dev/null +++ b/tests/cli/entitlements.test.mjs @@ -0,0 +1,42 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + entitlementsPlist, + mergeAppGroupIntoEntitlements, + xmlEscapePlist, +} from "../../dist-cli/lib/entitlements.mjs"; + +describe("entitlements XML helpers", () => { + it("xmlEscapePlist escapes markup delimiters", () => { + assert.equal(xmlEscapePlist(`a&b<"'>`), "a&b<"'>"); + }); + + it("rejects control characters in appGroup", () => { + assert.throws(() => entitlementsPlist({ appGroup: "group.bad\u0001id" }), /XML-unsafe/); + }); + + it("merge into self-closing array keeps $1 literally", () => { + const dir = mkdtempSync(join(tmpdir(), "tpw-ents-")); + const path = join(dir, "App.entitlements"); + writeFileSync( + path, + ` + + + + com.apple.security.application-groups + + + +`, + ); + // Dollar tokens must not be interpreted as JS replacement patterns. + mergeAppGroupIntoEntitlements(path, "group.$1.example"); + const raw = readFileSync(path, "utf-8"); + assert.match(raw, /group\.\$1\.example<\/string>/); + assert.doesNotMatch(raw, /group\./); + }); +}); diff --git a/tests/cli/validate.test.mjs b/tests/cli/validate.test.mjs new file mode 100644 index 0000000..3561226 --- /dev/null +++ b/tests/cli/validate.test.mjs @@ -0,0 +1,67 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + loadCapabilities, + validateWidgetConfig, + validateWidgetShape, +} from "../../dist-cli/lib/validate.mjs"; + +describe("validateWidgetShape", () => { + it("rejects object-valued image data", () => { + const findings = validateWidgetShape({ + small: { type: "image", data: { nested: true } }, + }); + assert.ok( + findings.some((f) => f.level === "error" && /image/.test(f.note || "")), + JSON.stringify(findings), + ); + }); + + it("rejects nested children without string type", () => { + const findings = validateWidgetShape({ + small: { + type: "vstack", + children: [{ content: "no type" }, "bare-string"], + }, + }); + assert.ok( + findings.some((f) => f.level === "error" && /type/.test(f.note || "")), + JSON.stringify(findings), + ); + }); + + it("accepts list items without element type", () => { + const findings = validateWidgetShape({ + small: { + type: "list", + items: [{ text: "One", checked: false, action: "toggle", payload: "0" }], + }, + }); + assert.equal( + findings.filter((f) => f.level === "error").length, + 0, + JSON.stringify(findings), + ); + }); +}); + +describe("validateWidgetConfig list rows", () => { + it("does not treat list items as element children", () => { + const { data: caps } = loadCapabilities(); + const findings = validateWidgetConfig( + { + small: { + type: "list", + items: [{ text: "One", checked: false, action: "toggle", payload: "0" }], + }, + }, + ["android"], + caps, + ); + assert.equal( + findings.filter((f) => /child must have string type/.test(f.note || "")).length, + 0, + JSON.stringify(findings), + ); + }); +}); diff --git a/tests/desktop/helpers.ts b/tests/desktop/helpers.ts index c9d6bc9..4a55684 100644 --- a/tests/desktop/helpers.ts +++ b/tests/desktop/helpers.ts @@ -44,9 +44,11 @@ export function listFixtures(): Array<{ id: string; absPath: string; sizes: stri return out; } -export function widgetHtmlUrl(size: string): string { +export function widgetHtmlUrl(size: string, theme?: string): string { const base = pathToFileURL(WIDGET_HTML).href; - return `${base}?group=g&widgetId=w&size=${encodeURIComponent(size)}`; + const q = new URLSearchParams({ group: "g", widgetId: "w", size }); + if (theme === "light" || theme === "dark") q.set("theme", theme); + return `${base}?${q.toString()}`; } export function geometryPath(id: string, size: string, platform: "desktop" | "android" = "desktop"): string { diff --git a/tests/desktop/render.spec.ts b/tests/desktop/render.spec.ts index 72252a9..2eab5fc 100644 --- a/tests/desktop/render.spec.ts +++ b/tests/desktop/render.spec.ts @@ -189,7 +189,7 @@ for (const c of cases) { const vp = SIZE_VIEWPORTS[c.size] ?? SIZE_VIEWPORTS.small; await page.setViewportSize(vp); await stubTauri(page, fixture, c.theme); - await page.goto(widgetHtmlUrl(c.size)); + await page.goto(widgetHtmlUrl(c.size, c.theme)); await waitForRender(page); const raw = await dumpTree(page); diff --git a/tests/expected/geometry/core__canvas-draws.small.desktop.json b/tests/expected/geometry/core__canvas-draws.small.desktop.json index bccc4af..6e54a53 100644 --- a/tests/expected/geometry/core__canvas-draws.small.desktop.json +++ b/tests/expected/geometry/core__canvas-draws.small.desktop.json @@ -62,7 +62,7 @@ ], "text": "draw", "px": 12, - "color": "#000000" + "color": "#E5E7EB" } ] } diff --git a/tests/expected/geometry/core__hstack-divider.medium.desktop.json b/tests/expected/geometry/core__hstack-divider.medium.desktop.json index abdd4ad..dad6b6a 100644 --- a/tests/expected/geometry/core__hstack-divider.medium.desktop.json +++ b/tests/expected/geometry/core__hstack-divider.medium.desktop.json @@ -21,7 +21,7 @@ "rect": [ 12, 12, - 336, + 8, 146 ], "text": "L", @@ -31,7 +31,7 @@ { "kind": "hr", "rect": [ - 356, + 28, 12, 2, 146 @@ -40,9 +40,9 @@ { "kind": "span", "rect": [ - 366, + 38, 12, - 336, + 10, 146 ], "text": "R", diff --git a/tests/expected/geometry/core__link-chip.small.desktop.json b/tests/expected/geometry/core__link-chip.small.desktop.json index 52c5fbd..435037a 100644 --- a/tests/expected/geometry/core__link-chip.small.desktop.json +++ b/tests/expected/geometry/core__link-chip.small.desktop.json @@ -51,7 +51,7 @@ "rect": [ 69, 22, - 71, + 51, 16 ], "text": "Settings", diff --git a/tests/expected/geometry/core__style-chrome.small.desktop.json b/tests/expected/geometry/core__style-chrome.small.desktop.json index b4a822a..6b6bec2 100644 --- a/tests/expected/geometry/core__style-chrome.small.desktop.json +++ b/tests/expected/geometry/core__style-chrome.small.desktop.json @@ -33,7 +33,7 @@ 36, 36 ], - "text": "👤", + "text": "὆4", "px": 36, "color": "#FFFFFF" }, @@ -41,13 +41,13 @@ "kind": "span", "rect": [ 60, - 14, - 142, - 36 + 23, + 56, + 18 ], "text": "Chrome", "px": 15, - "color": "#F8FAFC" + "color": "#000000" } ] } diff --git a/tests/expected/geometry/presets__android-list.large.desktop.json b/tests/expected/geometry/presets__android-list.large.desktop.json index 0e6ef6e..e0ee189 100644 --- a/tests/expected/geometry/presets__android-list.large.desktop.json +++ b/tests/expected/geometry/presets__android-list.large.desktop.json @@ -47,7 +47,7 @@ ], "text": "Alpha", "px": 14, - "color": "#000000" + "color": "#E5E7EB" } ] }, @@ -82,7 +82,7 @@ ], "text": "Beta", "px": 14, - "color": "#000000" + "color": "#E5E7EB" } ] }, @@ -117,7 +117,7 @@ ], "text": "Gamma", "px": 14, - "color": "#000000" + "color": "#E5E7EB" } ] } diff --git a/tests/expected/geometry/presets__calculator.small.desktop.json b/tests/expected/geometry/presets__calculator.small.desktop.json index cf5706a..70a53d8 100644 --- a/tests/expected/geometry/presets__calculator.small.desktop.json +++ b/tests/expected/geometry/presets__calculator.small.desktop.json @@ -17,22 +17,42 @@ ], "children": [ { - "kind": "span", + "kind": "div", "rect": [ 6, 6, 158, - 26 + 23 ], - "text": "0", - "px": 22, - "color": "#FFFFFF" + "children": [ + { + "kind": "div", + "rect": [ + 10, + 17, + 136, + 0 + ] + }, + { + "kind": "span", + "rect": [ + 150, + 6, + 13, + 23 + ], + "text": "0", + "px": 20, + "color": "#FFFFFF" + } + ] }, { "kind": "hr", "rect": [ 6, - 39, + 35, 158, 1 ] @@ -41,202 +61,250 @@ "kind": "div", "rect": [ 6, - 47, + 42, 158, - 112 + 82 ], "children": [ { "kind": "div", "rect": [ 6, - 47, + 42, 38, - 25 + 14 ], "text": "C", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 46, - 47, + 42, 38, - 25 + 14 ], "text": "±", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 86, - 47, + 42, 38, - 25 + 14 ], "text": "⌫", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 126, - 47, + 42, 38, - 25 + 14 ], "text": "÷", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 6, - 74, + 58, 38, - 27 + 15 ], "text": "7", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 46, - 74, + 58, 38, - 27 + 15 ], "text": "8", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 86, - 74, + 58, 38, - 27 + 15 ], "text": "9", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 126, - 75, + 58, 38, - 25 + 14 ], "text": "×", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 6, - 103, + 75, 38, - 27 + 15 ], "text": "4", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 46, - 103, + 75, 38, - 27 + 15 ], "text": "5", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 86, - 103, + 75, 38, - 27 + 15 ], "text": "6", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 126, - 104, + 75, 38, - 25 + 14 ], "text": "−", - "px": 11, + "px": 10, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 6, - 132, + 92, 38, - 27 + 15 ], "text": "1", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 46, - 132, + 92, 38, - 27 + 15 ], "text": "2", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 86, - 132, + 92, 38, - 27 + 15 ], "text": "3", - "px": 12, + "px": 11, "color": "#FFFFFF" }, { "kind": "div", "rect": [ 126, - 133, + 92, 38, - 25 + 14 ], "text": "+", + "px": 10, + "color": "#FFFFFF" + }, + { + "kind": "div", + "rect": [ + 6, + 109, + 38, + 14 + ], + "text": "%", + "px": 10, + "color": "#FFFFFF" + }, + { + "kind": "div", + "rect": [ + 46, + 109, + 38, + 15 + ], + "text": "0", "px": 11, "color": "#FFFFFF" + }, + { + "kind": "div", + "rect": [ + 86, + 109, + 38, + 15 + ], + "text": ".", + "px": 11, + "color": "#FFFFFF" + }, + { + "kind": "div", + "rect": [ + 126, + 109, + 38, + 14 + ], + "text": "=", + "px": 10, + "color": "#FFFFFF" } ] } diff --git a/tests/expected/geometry/presets__tasks.small.desktop.json b/tests/expected/geometry/presets__tasks.small.desktop.json index 6fceca2..8e7caf2 100644 --- a/tests/expected/geometry/presets__tasks.small.desktop.json +++ b/tests/expected/geometry/presets__tasks.small.desktop.json @@ -26,7 +26,7 @@ ], "text": "Tasks", "px": 18, - "color": "#000000" + "color": "#E5E7EB" }, { "kind": "div", @@ -68,7 +68,7 @@ ], "text": "Buy milk", "px": 13, - "color": "#000000" + "color": "#E5E7EB" } ] }, @@ -103,7 +103,7 @@ ], "text": "Ship 0.4", "px": 13, - "color": "#000000" + "color": "#E5E7EB" } ] } diff --git a/tests/expected/geometry/presets__upcoming-payments-empty.large.desktop.json b/tests/expected/geometry/presets__upcoming-payments-empty.large.desktop.json index b450e67..1eddc42 100644 --- a/tests/expected/geometry/presets__upcoming-payments-empty.large.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments-empty.large.desktop.json @@ -106,7 +106,7 @@ 30, 30 ], - "text": "C", + "text": "✓", "px": 30, "color": "#3878FA" } diff --git a/tests/expected/geometry/presets__upcoming-payments-empty.medium.desktop.json b/tests/expected/geometry/presets__upcoming-payments-empty.medium.desktop.json index e824f63..9810fe4 100644 --- a/tests/expected/geometry/presets__upcoming-payments-empty.medium.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments-empty.medium.desktop.json @@ -106,7 +106,7 @@ 22, 22 ], - "text": "C", + "text": "✓", "px": 22, "color": "#3878FA" } diff --git a/tests/expected/geometry/presets__upcoming-payments-empty.small.desktop.json b/tests/expected/geometry/presets__upcoming-payments-empty.small.desktop.json index 881e012..bb5ff3f 100644 --- a/tests/expected/geometry/presets__upcoming-payments-empty.small.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments-empty.small.desktop.json @@ -71,7 +71,7 @@ 28, 28 ], - "text": "C", + "text": "✓", "px": 28, "color": "#3878FA" } diff --git a/tests/expected/geometry/presets__upcoming-payments.large.desktop.json b/tests/expected/geometry/presets__upcoming-payments.large.desktop.json index 17fc089..59b0a3b 100644 --- a/tests/expected/geometry/presets__upcoming-payments.large.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments.large.desktop.json @@ -128,7 +128,7 @@ "rect": [ 42, 31, - 0, + 88, 24 ], "children": [ @@ -137,7 +137,7 @@ "rect": [ 42, 31, - 0, + 88, 13 ], "text": "Streaming", @@ -149,7 +149,7 @@ "rect": [ 42, 44, - 0, + 88, 11 ], "text": "Jun 1", @@ -161,10 +161,10 @@ { "kind": "span", "rect": [ - 48, - 31, - 163, - 24 + 136, + 36, + 38, + 13 ], "text": "$12.50", "px": 11, @@ -240,7 +240,7 @@ "rect": [ 215, 31, - 0, + 93, 24 ], "children": [ @@ -249,7 +249,7 @@ "rect": [ 215, 31, - 0, + 93, 13 ], "text": "Cloud Pro", @@ -261,7 +261,7 @@ "rect": [ 215, 44, - 0, + 93, 11 ], "text": "Today", @@ -273,10 +273,10 @@ { "kind": "span", "rect": [ - 221, - 31, - 163, - 24 + 314, + 36, + 33, + 13 ], "text": "$9.99", "px": 11, @@ -352,7 +352,7 @@ "rect": [ 42, 63, - 0, + 93, 24 ], "children": [ @@ -361,7 +361,7 @@ "rect": [ 42, 63, - 0, + 93, 13 ], "text": "Music Plus", @@ -373,7 +373,7 @@ "rect": [ 42, 76, - 0, + 93, 11 ], "text": "Tomorrow", @@ -385,10 +385,10 @@ { "kind": "span", "rect": [ - 48, - 63, - 163, - 24 + 141, + 68, + 33, + 13 ], "text": "$4.99", "px": 11, @@ -464,7 +464,7 @@ "rect": [ 215, 63, - 0, + 93, 24 ], "children": [ @@ -473,7 +473,7 @@ "rect": [ 215, 63, - 0, + 93, 13 ], "text": "News Daily", @@ -485,7 +485,7 @@ "rect": [ 215, 76, - 0, + 93, 11 ], "text": "Jun 5", @@ -497,10 +497,10 @@ { "kind": "span", "rect": [ - 221, - 63, - 163, - 24 + 314, + 68, + 33, + 13 ], "text": "$2.99", "px": 11, @@ -576,7 +576,7 @@ "rect": [ 42, 95, - 0, + 86, 24 ], "children": [ @@ -585,7 +585,7 @@ "rect": [ 42, 95, - 0, + 86, 13 ], "text": "Gym Club", @@ -597,7 +597,7 @@ "rect": [ 42, 108, - 0, + 86, 11 ], "text": "Jun 8", @@ -609,10 +609,10 @@ { "kind": "span", "rect": [ - 48, - 95, - 163, - 24 + 134, + 100, + 40, + 13 ], "text": "$29.00", "px": 11, @@ -688,7 +688,7 @@ "rect": [ 215, 95, - 0, + 93, 24 ], "children": [ @@ -697,7 +697,7 @@ "rect": [ 215, 95, - 0, + 93, 13 ], "text": "VPN Secure", @@ -709,7 +709,7 @@ "rect": [ 215, 108, - 0, + 93, 11 ], "text": "Jun 12", @@ -721,10 +721,10 @@ { "kind": "span", "rect": [ - 221, - 95, - 163, - 24 + 314, + 100, + 33, + 13 ], "text": "$6.49", "px": 11, diff --git a/tests/expected/geometry/presets__upcoming-payments.medium.desktop.json b/tests/expected/geometry/presets__upcoming-payments.medium.desktop.json index 417261d..007bb6a 100644 --- a/tests/expected/geometry/presets__upcoming-payments.medium.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments.medium.desktop.json @@ -128,7 +128,7 @@ "rect": [ 36, 29, - 0, + 98, 22 ], "children": [ @@ -137,7 +137,7 @@ "rect": [ 36, 29, - 0, + 98, 12 ], "text": "Streaming", @@ -149,7 +149,7 @@ "rect": [ 36, 41, - 0, + 98, 10 ], "text": "Jun 1", @@ -161,10 +161,10 @@ { "kind": "span", "rect": [ - 42, - 29, - 166, - 22 + 140, + 34, + 35, + 12 ], "text": "$12.50", "px": 10, @@ -240,7 +240,7 @@ "rect": [ 210, 29, - 0, + 103, 22 ], "children": [ @@ -249,7 +249,7 @@ "rect": [ 210, 29, - 0, + 103, 12 ], "text": "Cloud Pro", @@ -261,7 +261,7 @@ "rect": [ 210, 41, - 0, + 103, 10 ], "text": "Today", @@ -273,10 +273,10 @@ { "kind": "span", "rect": [ - 216, - 29, - 166, - 22 + 319, + 34, + 30, + 12 ], "text": "$9.99", "px": 10, @@ -352,7 +352,7 @@ "rect": [ 36, 57, - 0, + 103, 22 ], "children": [ @@ -361,7 +361,7 @@ "rect": [ 36, 57, - 0, + 103, 12 ], "text": "Music Plus", @@ -373,7 +373,7 @@ "rect": [ 36, 69, - 0, + 103, 10 ], "text": "Tomorrow", @@ -385,10 +385,10 @@ { "kind": "span", "rect": [ - 42, - 57, - 166, - 22 + 145, + 62, + 30, + 12 ], "text": "$4.99", "px": 10, @@ -464,7 +464,7 @@ "rect": [ 210, 57, - 0, + 103, 22 ], "children": [ @@ -473,7 +473,7 @@ "rect": [ 210, 57, - 0, + 103, 12 ], "text": "News Daily", @@ -485,7 +485,7 @@ "rect": [ 210, 69, - 0, + 103, 10 ], "text": "Jun 5", @@ -497,10 +497,10 @@ { "kind": "span", "rect": [ - 216, - 57, - 166, - 22 + 319, + 62, + 30, + 12 ], "text": "$2.99", "px": 10, diff --git a/tests/expected/geometry/presets__upcoming-payments.small.desktop.json b/tests/expected/geometry/presets__upcoming-payments.small.desktop.json index 0ab3027..5266436 100644 --- a/tests/expected/geometry/presets__upcoming-payments.small.desktop.json +++ b/tests/expected/geometry/presets__upcoming-payments.small.desktop.json @@ -107,7 +107,7 @@ ], "text": "Streaming", "px": 15, - "color": "#FFFFFF" + "color": "#000000" }, { "kind": "span", @@ -119,7 +119,7 @@ ], "text": "Jun 1", "px": 11, - "color": "#EBEBF5" + "color": "#3C3C43" } ] } diff --git a/tests/expected/pixels/desktop/events/toggle-task.after.png b/tests/expected/pixels/desktop/events/toggle-task.after.png index 00a0cb7..4cb8bf5 100644 Binary files a/tests/expected/pixels/desktop/events/toggle-task.after.png and b/tests/expected/pixels/desktop/events/toggle-task.after.png differ diff --git a/tests/expected/pixels/desktop/events/toggle-task.before.png b/tests/expected/pixels/desktop/events/toggle-task.before.png index 5adc4b5..9e4ed75 100644 Binary files a/tests/expected/pixels/desktop/events/toggle-task.before.png and b/tests/expected/pixels/desktop/events/toggle-task.before.png differ diff --git a/tests/fixtures/android-list.json b/tests/fixtures/android-list.json index 3650371..88a8b53 100644 --- a/tests/fixtures/android-list.json +++ b/tests/fixtures/android-list.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": null, "medium": null, @@ -24,8 +25,8 @@ "payload": null } ], - "spacing": 2.0, - "fontSize": 14.0, + "spacing": 2, + "fontSize": 14, "color": null, "padding": null, "background": null, diff --git a/tests/fixtures/bugs/canvas-scale.json b/tests/fixtures/bugs/canvas-scale.json index c6f8012..6c21c0a 100644 --- a/tests/fixtures/bugs/canvas-scale.json +++ b/tests/fixtures/bugs/canvas-scale.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -11,8 +12,24 @@ "width": 60, "height": 60, "elements": [ - { "draw": "circle", "cx": 30, "cy": 30, "r": 25, "fill": "#1e40af", "stroke": "#fff", "strokeWidth": 2 }, - { "draw": "line", "x1": 30, "y1": 30, "x2": 30, "y2": 10, "stroke": "#fff", "strokeWidth": 2 } + { + "draw": "circle", + "cx": 30, + "cy": 30, + "r": 25, + "fill": "#1e40af", + "stroke": "#fff", + "strokeWidth": 2 + }, + { + "draw": "line", + "x1": 30, + "y1": 30, + "x2": 30, + "y2": 10, + "stroke": "#fff", + "strokeWidth": 2 + } ] } ] diff --git a/tests/fixtures/bugs/gauge-square.json b/tests/fixtures/bugs/gauge-square.json index f31601b..86ee8fc 100644 --- a/tests/fixtures/bugs/gauge-square.json +++ b/tests/fixtures/bugs/gauge-square.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", diff --git a/tests/fixtures/bugs/list-20-items.json b/tests/fixtures/bugs/list-20-items.json index 86e9d20..d8277c8 100644 --- a/tests/fixtures/bugs/list-20-items.json +++ b/tests/fixtures/bugs/list-20-items.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "large": { "type": "list", @@ -8,26 +9,68 @@ "background": "#0f172a", "padding": 8, "items": [ - { "text": "Item 01", "checked": false }, - { "text": "Item 02", "checked": true }, - { "text": "Item 03" }, - { "text": "Item 04" }, - { "text": "Item 05" }, - { "text": "Item 06" }, - { "text": "Item 07" }, - { "text": "Item 08" }, - { "text": "Item 09" }, - { "text": "Item 10" }, - { "text": "Item 11" }, - { "text": "Item 12" }, - { "text": "Item 13" }, - { "text": "Item 14" }, - { "text": "Item 15" }, - { "text": "Item 16" }, - { "text": "Item 17" }, - { "text": "Item 18" }, - { "text": "Item 19" }, - { "text": "Item 20" } + { + "text": "Item 01", + "checked": false + }, + { + "text": "Item 02", + "checked": true + }, + { + "text": "Item 03" + }, + { + "text": "Item 04" + }, + { + "text": "Item 05" + }, + { + "text": "Item 06" + }, + { + "text": "Item 07" + }, + { + "text": "Item 08" + }, + { + "text": "Item 09" + }, + { + "text": "Item 10" + }, + { + "text": "Item 11" + }, + { + "text": "Item 12" + }, + { + "text": "Item 13" + }, + { + "text": "Item 14" + }, + { + "text": "Item 15" + }, + { + "text": "Item 16" + }, + { + "text": "Item 17" + }, + { + "text": "Item 18" + }, + { + "text": "Item 19" + }, + { + "text": "Item 20" + } ] } } diff --git a/tests/fixtures/bugs/null-fields.json b/tests/fixtures/bugs/null-fields.json index 9656fe6..c82f3b6 100644 --- a/tests/fixtures/bugs/null-fields.json +++ b/tests/fixtures/bugs/null-fields.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", diff --git a/tests/fixtures/canvas.json b/tests/fixtures/canvas.json index c531440..1346dce 100644 --- a/tests/fixtures/canvas.json +++ b/tests/fixtures/canvas.json @@ -1,18 +1,19 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "canvas", - "width": 120.0, - "height": 120.0, + "width": 120, + "height": 120, "elements": [ { "draw": "circle", - "cx": 60.0, - "cy": 60.0, - "r": 40.0, + "cx": 60, + "cy": 60, + "r": 40, "fill": "#1a1a2e", "stroke": "#fff", - "strokeWidth": 2.0 + "strokeWidth": 2 } ], "padding": null, diff --git a/tests/fixtures/core/canvas-draws.json b/tests/fixtures/core/canvas-draws.json index 31145f3..5a409a8 100644 --- a/tests/fixtures/core/canvas-draws.json +++ b/tests/fixtures/core/canvas-draws.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "canvas", diff --git a/tests/fixtures/core/chart-mix.json b/tests/fixtures/core/chart-mix.json index 0d7f4ac..a659660 100644 --- a/tests/fixtures/core/chart-mix.json +++ b/tests/fixtures/core/chart-mix.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -24,10 +25,26 @@ "chartType": "bar", "tint": "#22c55e", "chartData": [ - { "label": "M", "value": 3, "color": "#22c55e" }, - { "label": "T", "value": 5, "color": "#3b82f6" }, - { "label": "W", "value": 2, "color": "#f59e0b" }, - { "label": "T", "value": 7, "color": "#ef4444" } + { + "label": "M", + "value": 3, + "color": "#22c55e" + }, + { + "label": "T", + "value": 5, + "color": "#3b82f6" + }, + { + "label": "W", + "value": 2, + "color": "#f59e0b" + }, + { + "label": "T", + "value": 7, + "color": "#ef4444" + } ] } ] @@ -48,11 +65,26 @@ "chartType": "line", "tint": "#38bdf8", "chartData": [ - { "label": "1", "value": 2 }, - { "label": "2", "value": 4 }, - { "label": "3", "value": 3 }, - { "label": "4", "value": 6 }, - { "label": "5", "value": 5 } + { + "label": "1", + "value": 2 + }, + { + "label": "2", + "value": 4 + }, + { + "label": "3", + "value": 3 + }, + { + "label": "4", + "value": 6 + }, + { + "label": "5", + "value": 5 + } ] } ] @@ -73,9 +105,21 @@ "type": "chart", "chartType": "pie", "chartData": [ - { "label": "A", "value": 40, "color": "#3b82f6" }, - { "label": "B", "value": 35, "color": "#22c55e" }, - { "label": "C", "value": 25, "color": "#f97316" } + { + "label": "A", + "value": 40, + "color": "#3b82f6" + }, + { + "label": "B", + "value": 35, + "color": "#22c55e" + }, + { + "label": "C", + "value": 25, + "color": "#f97316" + } ] } ] diff --git a/tests/fixtures/core/container-card.json b/tests/fixtures/core/container-card.json index 31bac2d..e87e3ee 100644 --- a/tests/fixtures/core/container-card.json +++ b/tests/fixtures/core/container-card.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "container", @@ -6,14 +7,22 @@ "padding": 12, "cornerRadius": 16, "background": "#1C1C1E", - "frame": { "width": 146, "height": 146 }, + "frame": { + "width": 146, + "height": 146 + }, "children": [ { "type": "vstack", "spacing": 8, "alignment": "center", "children": [ - { "type": "shape", "shapeType": "circle", "size": 36, "fill": "#3b82f6" }, + { + "type": "shape", + "shapeType": "circle", + "size": 36, + "fill": "#3b82f6" + }, { "type": "text", "content": "Card", diff --git a/tests/fixtures/core/date-timer.json b/tests/fixtures/core/date-timer.json index e46044f..ffa13ae 100644 --- a/tests/fixtures/core/date-timer.json +++ b/tests/fixtures/core/date-timer.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "vstack", diff --git a/tests/fixtures/core/flex-stroke-grid.json b/tests/fixtures/core/flex-stroke-grid.json index 4deb2ae..fe82620 100644 --- a/tests/fixtures/core/flex-stroke-grid.json +++ b/tests/fixtures/core/flex-stroke-grid.json @@ -1,8 +1,14 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "vstack", - "padding": { "top": 10, "leading": 12, "bottom": 10, "trailing": 12 }, + "padding": { + "top": 10, + "leading": 12, + "bottom": 10, + "trailing": 12 + }, "spacing": 8, "cornerRadius": 14, "background": "#0f172a", @@ -13,7 +19,9 @@ "fontSize": 13, "fontWeight": "semibold", "color": "#f8fafc", - "frame": { "maxWidth": 200 } + "frame": { + "maxWidth": 200 + } }, { "type": "grid", diff --git a/tests/fixtures/core/hstack-divider.json b/tests/fixtures/core/hstack-divider.json index b02b38c..10fdf6c 100644 --- a/tests/fixtures/core/hstack-divider.json +++ b/tests/fixtures/core/hstack-divider.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -6,9 +7,23 @@ "padding": 12, "background": "#0f172a", "children": [ - { "type": "text", "content": "L", "fontSize": 16, "color": "#fff" }, - { "type": "divider", "thickness": 2, "color": "#64748b" }, - { "type": "text", "content": "R", "fontSize": 16, "color": "#fff" } + { + "type": "text", + "content": "L", + "fontSize": 16, + "color": "#fff" + }, + { + "type": "divider", + "thickness": 2, + "color": "#64748b" + }, + { + "type": "text", + "content": "R", + "fontSize": 16, + "color": "#fff" + } ] } } diff --git a/tests/fixtures/core/image-sources.json b/tests/fixtures/core/image-sources.json index 8dc7087..9925105 100644 --- a/tests/fixtures/core/image-sources.json +++ b/tests/fixtures/core/image-sources.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", diff --git a/tests/fixtures/core/layout.json b/tests/fixtures/core/layout.json index f8bca87..264b497 100644 --- a/tests/fixtures/core/layout.json +++ b/tests/fixtures/core/layout.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,14 +7,45 @@ "padding": 12, "background": "#1a1a2e", "children": [ - { "type": "text", "content": "Hello", "fontSize": 18, "fontWeight": "bold", "color": "#fff", "alignment": "center" }, - { "type": "hstack", "spacing": 4, "children": [ - { "type": "shape", "shapeType": "capsule", "size": 8, "fill": "#4CAF50" }, - { "type": "divider" }, - { "type": "spacer" }, - { "type": "progress", "value": 0.5, "tint": "#4CAF50", "label": "OK" } - ]}, - { "type": "button", "label": "Go", "action": "go", "backgroundColor": "#2196F3", "color": "#fff" } + { + "type": "text", + "content": "Hello", + "fontSize": 18, + "fontWeight": "bold", + "color": "#fff", + "alignment": "center" + }, + { + "type": "hstack", + "spacing": 4, + "children": [ + { + "type": "shape", + "shapeType": "capsule", + "size": 8, + "fill": "#4CAF50" + }, + { + "type": "divider" + }, + { + "type": "spacer" + }, + { + "type": "progress", + "value": 0.5, + "tint": "#4CAF50", + "label": "OK" + } + ] + }, + { + "type": "button", + "label": "Go", + "action": "go", + "backgroundColor": "#2196F3", + "color": "#fff" + } ] } -} \ No newline at end of file +} diff --git a/tests/fixtures/core/link-chip.json b/tests/fixtures/core/link-chip.json index 448576a..71dbced 100644 --- a/tests/fixtures/core/link-chip.json +++ b/tests/fixtures/core/link-chip.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", diff --git a/tests/fixtures/core/nested-dashboard.json b/tests/fixtures/core/nested-dashboard.json index 088d243..bf46dad 100644 --- a/tests/fixtures/core/nested-dashboard.json +++ b/tests/fixtures/core/nested-dashboard.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "large": { "type": "vstack", @@ -16,8 +17,19 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "circle", "fill": "#334155", "size": 44 }, - { "type": "text", "content": "42", "fontSize": 16, "fontWeight": "bold", "color": "#fff" } + { + "type": "shape", + "shapeType": "circle", + "fill": "#334155", + "size": 44 + }, + { + "type": "text", + "content": "42", + "fontSize": 16, + "fontWeight": "bold", + "color": "#fff" + } ] }, { @@ -25,11 +37,26 @@ "spacing": 2, "alignment": "leading", "children": [ - { "type": "text", "content": "Nested Dashboard", "fontSize": 16, "fontWeight": "bold", "color": "#f8fafc", "lineLimit": 1 }, - { "type": "text", "content": "hstack → zstack + grid + list", "fontSize": 11, "color": "#94a3b8", "lineLimit": 1 } + { + "type": "text", + "content": "Nested Dashboard", + "fontSize": 16, + "fontWeight": "bold", + "color": "#f8fafc", + "lineLimit": 1 + }, + { + "type": "text", + "content": "hstack → zstack + grid + list", + "fontSize": 11, + "color": "#94a3b8", + "lineLimit": 1 + } ] }, - { "type": "spacer" }, + { + "type": "spacer" + }, { "type": "button", "label": "Sync", @@ -41,7 +68,10 @@ } ] }, - { "type": "divider", "color": "#334155" }, + { + "type": "divider", + "color": "#334155" + }, { "type": "grid", "columns": 2, @@ -55,9 +85,24 @@ "cornerRadius": 10, "background": "#1e293b", "children": [ - { "type": "text", "content": "CPU", "fontSize": 11, "color": "#94a3b8" }, - { "type": "text", "content": "38%", "fontSize": 22, "fontWeight": "bold", "color": "#22c55e" }, - { "type": "progress", "value": 0.38, "tint": "#22c55e" } + { + "type": "text", + "content": "CPU", + "fontSize": 11, + "color": "#94a3b8" + }, + { + "type": "text", + "content": "38%", + "fontSize": 22, + "fontWeight": "bold", + "color": "#22c55e" + }, + { + "type": "progress", + "value": 0.38, + "tint": "#22c55e" + } ] }, { @@ -67,9 +112,24 @@ "cornerRadius": 10, "background": "#1e293b", "children": [ - { "type": "text", "content": "RAM", "fontSize": 11, "color": "#94a3b8" }, - { "type": "text", "content": "62%", "fontSize": 22, "fontWeight": "bold", "color": "#f59e0b" }, - { "type": "progress", "value": 0.62, "tint": "#f59e0b" } + { + "type": "text", + "content": "RAM", + "fontSize": 11, + "color": "#94a3b8" + }, + { + "type": "text", + "content": "62%", + "fontSize": 22, + "fontWeight": "bold", + "color": "#f59e0b" + }, + { + "type": "progress", + "value": 0.62, + "tint": "#f59e0b" + } ] }, { @@ -79,9 +139,24 @@ "cornerRadius": 10, "background": "#1e293b", "children": [ - { "type": "text", "content": "Disk", "fontSize": 11, "color": "#94a3b8" }, - { "type": "text", "content": "81%", "fontSize": 22, "fontWeight": "bold", "color": "#ef4444" }, - { "type": "progress", "value": 0.81, "tint": "#ef4444" } + { + "type": "text", + "content": "Disk", + "fontSize": 11, + "color": "#94a3b8" + }, + { + "type": "text", + "content": "81%", + "fontSize": 22, + "fontWeight": "bold", + "color": "#ef4444" + }, + { + "type": "progress", + "value": 0.81, + "tint": "#ef4444" + } ] }, { @@ -91,24 +166,53 @@ "cornerRadius": 10, "background": "#1e293b", "children": [ - { "type": "text", "content": "Net", "fontSize": 11, "color": "#94a3b8" }, - { "type": "text", "content": "12MB/s", "fontSize": 18, "fontWeight": "bold", "color": "#38bdf8" }, - { "type": "progress", "value": 0.24, "tint": "#38bdf8" } + { + "type": "text", + "content": "Net", + "fontSize": 11, + "color": "#94a3b8" + }, + { + "type": "text", + "content": "12MB/s", + "fontSize": 18, + "fontWeight": "bold", + "color": "#38bdf8" + }, + { + "type": "progress", + "value": 0.24, + "tint": "#38bdf8" + } ] } ] }, - { "type": "divider", "color": "#334155" }, + { + "type": "divider", + "color": "#334155" + }, { "type": "list", "spacing": 3, "fontSize": 12, "color": "#e2e8f0", "items": [ - { "text": "api-gateway", "checked": true }, - { "text": "worker-pool", "checked": true }, - { "text": "cache-redis", "checked": false }, - { "text": "queue-amqp" } + { + "text": "api-gateway", + "checked": true + }, + { + "text": "worker-pool", + "checked": true + }, + { + "text": "cache-redis", + "checked": false + }, + { + "text": "queue-amqp" + } ] } ] diff --git a/tests/fixtures/core/progress-gauge-variants.json b/tests/fixtures/core/progress-gauge-variants.json index fcad98e..10a80f0 100644 --- a/tests/fixtures/core/progress-gauge-variants.json +++ b/tests/fixtures/core/progress-gauge-variants.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", diff --git a/tests/fixtures/core/shape-capsule.json b/tests/fixtures/core/shape-capsule.json index 02da887..8cfe018 100644 --- a/tests/fixtures/core/shape-capsule.json +++ b/tests/fixtures/core/shape-capsule.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,8 +7,18 @@ "background": "#0a0a0a", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "capsule", "size": 12, "fill": "#22c55e" }, - { "type": "shape", "shapeType": "circle", "size": 24, "fill": "#3b82f6" } + { + "type": "shape", + "shapeType": "capsule", + "size": 12, + "fill": "#22c55e" + }, + { + "type": "shape", + "shapeType": "circle", + "size": 24, + "fill": "#3b82f6" + } ] } } diff --git a/tests/fixtures/core/style-chrome.json b/tests/fixtures/core/style-chrome.json index 85fb334..8d3d606 100644 --- a/tests/fixtures/core/style-chrome.json +++ b/tests/fixtures/core/style-chrome.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,12 +7,19 @@ "spacing": 10, "cornerRadius": 16, "background": { - "gradientType": "linear", - "colors": ["#0f172a", "#312e81"], - "direction": "topLeadingToBottomTrailing" + "light": "#EEF2FF", + "dark": "#0f172a" + }, + "border": { + "color": "#6366f1", + "width": 2 + }, + "shadow": { + "color": "rgba(99,102,241,0.45)", + "radius": 10, + "x": 0, + "y": 4 }, - "border": { "color": "#6366f1", "width": 2 }, - "shadow": { "color": "rgba(99,102,241,0.45)", "radius": 10, "x": 0, "y": 4 }, "children": [ { "type": "hstack", @@ -25,14 +33,17 @@ "color": "#fff", "clipShape": "circle", "background": "#6366f1", - "frame": { "width": 36, "height": 36 } + "frame": { + "width": 36, + "height": 36 + } }, { "type": "text", "content": "Chrome", "fontSize": 15, "fontWeight": "semibold", - "color": "#f8fafc", + "color": "label", "opacity": 0.85 } ] diff --git a/tests/fixtures/core/text-align.json b/tests/fixtures/core/text-align.json index a07eb02..df33174 100644 --- a/tests/fixtures/core/text-align.json +++ b/tests/fixtures/core/text-align.json @@ -1,13 +1,32 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", "padding": 8, "background": "#111", "children": [ - { "type": "text", "content": "Leading", "fontSize": 14, "color": "#fff", "alignment": "leading" }, - { "type": "text", "content": "Center", "fontSize": 14, "color": "#fff", "alignment": "center" }, - { "type": "text", "content": "Trailing", "fontSize": 14, "color": "#fff", "alignment": "trailing" } + { + "type": "text", + "content": "Leading", + "fontSize": 14, + "color": "#fff", + "alignment": "leading" + }, + { + "type": "text", + "content": "Center", + "fontSize": 14, + "color": "#fff", + "alignment": "center" + }, + { + "type": "text", + "content": "Trailing", + "fontSize": 14, + "color": "#fff", + "alignment": "trailing" + } ] } } diff --git a/tests/fixtures/core/toggle-row.json b/tests/fixtures/core/toggle-row.json index 306550f..9c290a9 100644 --- a/tests/fixtures/core/toggle-row.json +++ b/tests/fixtures/core/toggle-row.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", diff --git a/tests/fixtures/core/type-styles.json b/tests/fixtures/core/type-styles.json index c0b6053..6d39dba 100644 --- a/tests/fixtures/core/type-styles.json +++ b/tests/fixtures/core/type-styles.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -12,7 +13,10 @@ "content": "Headline", "textStyle": "headline", "fontWeight": "semibold", - "color": { "light": "#0f172a", "dark": "#f8fafc" } + "color": { + "light": "#0f172a", + "dark": "#f8fafc" + } }, { "type": "text", diff --git a/tests/fixtures/core/vstack-spacer.json b/tests/fixtures/core/vstack-spacer.json index b2c1330..5abedc2 100644 --- a/tests/fixtures/core/vstack-spacer.json +++ b/tests/fixtures/core/vstack-spacer.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,9 +7,21 @@ "padding": 8, "background": "#111827", "children": [ - { "type": "text", "content": "Top", "fontSize": 14, "color": "#fff" }, - { "type": "spacer" }, - { "type": "text", "content": "Bottom", "fontSize": 14, "color": "#fff" } + { + "type": "text", + "content": "Top", + "fontSize": 14, + "color": "#fff" + }, + { + "type": "spacer" + }, + { + "type": "text", + "content": "Bottom", + "fontSize": 14, + "color": "#fff" + } ] } } diff --git a/tests/fixtures/core/zstack-align.json b/tests/fixtures/core/zstack-align.json index 21f68e7..8c43069 100644 --- a/tests/fixtures/core/zstack-align.json +++ b/tests/fixtures/core/zstack-align.json @@ -1,12 +1,24 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "zstack", "alignment": "center", "background": "#1e293b", "children": [ - { "type": "shape", "shapeType": "circle", "size": 40, "fill": "#334155" }, - { "type": "text", "content": "Z", "fontSize": 18, "color": "#fff", "alignment": "center" } + { + "type": "shape", + "shapeType": "circle", + "size": 40, + "fill": "#334155" + }, + { + "type": "text", + "content": "Z", + "fontSize": 18, + "color": "#fff", + "alignment": "center" + } ] } } diff --git a/tests/fixtures/presets/android-list.json b/tests/fixtures/presets/android-list.json index 3650371..88a8b53 100644 --- a/tests/fixtures/presets/android-list.json +++ b/tests/fixtures/presets/android-list.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": null, "medium": null, @@ -24,8 +25,8 @@ "payload": null } ], - "spacing": 2.0, - "fontSize": 14.0, + "spacing": 2, + "fontSize": 14, "color": null, "padding": null, "background": null, diff --git a/tests/fixtures/presets/calculator.json b/tests/fixtures/presets/calculator.json index 1208135..580c007 100644 --- a/tests/fixtures/presets/calculator.json +++ b/tests/fixtures/presets/calculator.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -12,38 +13,339 @@ "spacing": 4, "alignment": "center", "children": [ - { "type": "text", "content": " ", "fontSize": 10, "color": "#f09a36", "alignment": "leading", "lineLimit": 1 }, - { "type": "spacer" }, - { "type": "text", "content": "0", "fontSize": 20, "fontWeight": "bold", "color": "#ffffff", "alignment": "trailing", "lineLimit": 1 } + { + "type": "text", + "content": " ", + "fontSize": 10, + "color": "#f09a36", + "alignment": "leading", + "lineLimit": 1 + }, + { + "type": "spacer" + }, + { + "type": "text", + "content": "0", + "fontSize": 20, + "fontWeight": "bold", + "color": "#ffffff", + "alignment": "trailing", + "lineLimit": 1 + } ] }, - { "type": "divider", "color": "#3a3a3c", "thickness": 1 }, + { + "type": "divider", + "color": "#3a3a3c", + "thickness": 1 + }, { "type": "grid", "columns": 4, "spacing": 2, "rowSpacing": 2, "children": [ - { "type": "button", "label": "C", "action": "calc:C", "backgroundColor": "#636366", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "±", "action": "calc:+-", "backgroundColor": "#636366", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "⌫", "action": "calc:BS", "backgroundColor": "#636366", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "÷", "action": "calc:/", "backgroundColor": "#f09a36", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "7", "action": "calc:7", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "8", "action": "calc:8", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "9", "action": "calc:9", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "×", "action": "calc:*", "backgroundColor": "#f09a36", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "4", "action": "calc:4", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "5", "action": "calc:5", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "6", "action": "calc:6", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "−", "action": "calc:-", "backgroundColor": "#f09a36", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "1", "action": "calc:1", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "2", "action": "calc:2", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "3", "action": "calc:3", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "+", "action": "calc:+", "backgroundColor": "#f09a36", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "%", "action": "calc:%", "backgroundColor": "#636366", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "0", "action": "calc:0", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": ".", "action": "calc:.", "backgroundColor": "#3a3a3c", "color": "#fff", "fontSize": 11, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } }, - { "type": "button", "label": "=", "action": "calc:=", "backgroundColor": "#f09a36", "color": "#fff", "fontSize": 10, "cornerRadius": 6, "padding": { "top": 1, "bottom": 1, "leading": 2, "trailing": 2 } } + { + "type": "button", + "label": "C", + "action": "calc:C", + "backgroundColor": "#636366", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "±", + "action": "calc:+-", + "backgroundColor": "#636366", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "⌫", + "action": "calc:BS", + "backgroundColor": "#636366", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "÷", + "action": "calc:/", + "backgroundColor": "#f09a36", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "7", + "action": "calc:7", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "8", + "action": "calc:8", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "9", + "action": "calc:9", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "×", + "action": "calc:*", + "backgroundColor": "#f09a36", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "4", + "action": "calc:4", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "5", + "action": "calc:5", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "6", + "action": "calc:6", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "−", + "action": "calc:-", + "backgroundColor": "#f09a36", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "1", + "action": "calc:1", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "2", + "action": "calc:2", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "3", + "action": "calc:3", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "+", + "action": "calc:+", + "backgroundColor": "#f09a36", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "%", + "action": "calc:%", + "backgroundColor": "#636366", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "0", + "action": "calc:0", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": ".", + "action": "calc:.", + "backgroundColor": "#3a3a3c", + "color": "#fff", + "fontSize": 11, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + }, + { + "type": "button", + "label": "=", + "action": "calc:=", + "backgroundColor": "#f09a36", + "color": "#fff", + "fontSize": 10, + "cornerRadius": 6, + "padding": { + "top": 1, + "bottom": 1, + "leading": 2, + "trailing": 2 + } + } ] } ] diff --git a/tests/fixtures/presets/canvas.json b/tests/fixtures/presets/canvas.json index c531440..1346dce 100644 --- a/tests/fixtures/presets/canvas.json +++ b/tests/fixtures/presets/canvas.json @@ -1,18 +1,19 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "canvas", - "width": 120.0, - "height": 120.0, + "width": 120, + "height": 120, "elements": [ { "draw": "circle", - "cx": 60.0, - "cy": 60.0, - "r": 40.0, + "cx": 60, + "cy": 60, + "r": 40, "fill": "#1a1a2e", "stroke": "#fff", - "strokeWidth": 2.0 + "strokeWidth": 2 } ], "padding": null, diff --git a/tests/fixtures/presets/fitness.json b/tests/fixtures/presets/fitness.json index 969b174..24f973f 100644 --- a/tests/fixtures/presets/fitness.json +++ b/tests/fixtures/presets/fitness.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -7,7 +8,13 @@ "cornerRadius": 16, "background": "#0d1117", "children": [ - { "type": "text", "content": "Activity", "fontSize": 15, "fontWeight": "semibold", "color": "#fff" }, + { + "type": "text", + "content": "Activity", + "fontSize": 15, + "fontWeight": "semibold", + "color": "#fff" + }, { "type": "gauge", "value": 0.72, @@ -22,8 +29,20 @@ "type": "hstack", "spacing": 8, "children": [ - { "type": "progress", "value": 0.45, "tint": "#4ecdc4", "label": "Cal", "color": "#94a3b8" }, - { "type": "progress", "value": 0.88, "tint": "#ffe66d", "label": "H2O", "color": "#94a3b8" } + { + "type": "progress", + "value": 0.45, + "tint": "#4ecdc4", + "label": "Cal", + "color": "#94a3b8" + }, + { + "type": "progress", + "value": 0.88, + "tint": "#ffe66d", + "label": "H2O", + "color": "#94a3b8" + } ] } ] @@ -50,17 +69,43 @@ "gaugeStyle": "circular", "tint": "#ff6b6b" }, - { "type": "text", "content": "7,200 / 10k", "fontSize": 11, "color": "#94a3b8" } + { + "type": "text", + "content": "7,200 / 10k", + "fontSize": 11, + "color": "#94a3b8" + } ] }, - { "type": "divider", "color": "#334155" }, + { + "type": "divider", + "color": "#334155" + }, { "type": "vstack", "spacing": 6, "children": [ - { "type": "progress", "value": 0.45, "tint": "#4ecdc4", "label": "Calories", "color": "#94a3b8" }, - { "type": "progress", "value": 0.88, "tint": "#ffe66d", "label": "Water", "color": "#94a3b8" }, - { "type": "progress", "value": 0.33, "tint": "#ff6b6b", "label": "Exercise", "color": "#94a3b8" } + { + "type": "progress", + "value": 0.45, + "tint": "#4ecdc4", + "label": "Calories", + "color": "#94a3b8" + }, + { + "type": "progress", + "value": 0.88, + "tint": "#ffe66d", + "label": "Water", + "color": "#94a3b8" + }, + { + "type": "progress", + "value": 0.33, + "tint": "#ff6b6b", + "label": "Exercise", + "color": "#94a3b8" + } ] } ] diff --git a/tests/fixtures/presets/music.json b/tests/fixtures/presets/music.json index 839a759..9e8a691 100644 --- a/tests/fixtures/presets/music.json +++ b/tests/fixtures/presets/music.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "medium": { "type": "hstack", @@ -18,9 +19,27 @@ "spacing": 6, "alignment": "leading", "children": [ - { "type": "text", "content": "Bohemian Rhapsody", "fontSize": 17, "fontWeight": "semibold", "color": "#fff", "lineLimit": 1 }, - { "type": "text", "content": "Queen • A Night at the Opera", "fontSize": 12, "color": "#94a3b8" }, - { "type": "progress", "value": 0.625, "tint": "#e94560", "label": "3:42 / 5:55", "color": "#94a3b8" }, + { + "type": "text", + "content": "Bohemian Rhapsody", + "fontSize": 17, + "fontWeight": "semibold", + "color": "#fff", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Queen • A Night at the Opera", + "fontSize": 12, + "color": "#94a3b8" + }, + { + "type": "progress", + "value": 0.625, + "tint": "#e94560", + "label": "3:42 / 5:55", + "color": "#94a3b8" + }, { "type": "hstack", "spacing": 20, diff --git a/tests/fixtures/presets/tasks.json b/tests/fixtures/presets/tasks.json index 0afa0a9..a637d3b 100644 --- a/tests/fixtures/presets/tasks.json +++ b/tests/fixtures/presets/tasks.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,7 +7,7 @@ { "type": "text", "content": "Tasks", - "fontSize": 18.0, + "fontSize": 18, "fontWeight": "bold", "fontDesign": null, "textStyle": null, @@ -39,7 +40,7 @@ "payload": null } ], - "spacing": 4.0, + "spacing": 4, "fontSize": null, "color": null, "padding": null, @@ -53,9 +54,9 @@ "flex": null } ], - "spacing": 6.0, + "spacing": 6, "alignment": null, - "padding": 10.0, + "padding": 10, "background": null, "cornerRadius": null, "opacity": null, diff --git a/tests/fixtures/presets/upcoming-payments-empty.json b/tests/fixtures/presets/upcoming-payments-empty.json index 3ef5fd3..ba7826d 100644 --- a/tests/fixtures/presets/upcoming-payments-empty.json +++ b/tests/fixtures/presets/upcoming-payments-empty.json @@ -1,13 +1,19 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", "padding": 14, "spacing": 10, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ - { "type": "spacer" }, + { + "type": "spacer" + }, { "type": "zstack", "alignment": "center", @@ -36,7 +42,9 @@ "alignment": "center", "lineLimit": 2 }, - { "type": "spacer" } + { + "type": "spacer" + } ] }, "medium": { @@ -44,7 +52,10 @@ "padding": 12, "spacing": 8, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { "type": "label", @@ -56,13 +67,25 @@ "iconColor": "#3878FA", "spacing": 4 }, - { "type": "spacer" }, + { + "type": "spacer" + }, { "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "circle", "fill": "#263878FA", "size": 40 }, - { "type": "image", "systemName": "checkmark.circle.fill", "size": 22, "color": "#3878FA" } + { + "type": "shape", + "shapeType": "circle", + "fill": "#263878FA", + "size": 40 + }, + { + "type": "image", + "systemName": "checkmark.circle.fill", + "size": 22, + "color": "#3878FA" + } ] }, { @@ -74,7 +97,9 @@ "alignment": "center", "lineLimit": 2 }, - { "type": "spacer" } + { + "type": "spacer" + } ] }, "large": { @@ -82,7 +107,10 @@ "padding": 14, "spacing": 10, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { "type": "label", @@ -94,13 +122,25 @@ "iconColor": "#3878FA", "spacing": 4 }, - { "type": "spacer" }, + { + "type": "spacer" + }, { "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "circle", "fill": "#263878FA", "size": 52 }, - { "type": "image", "systemName": "checkmark.circle.fill", "size": 30, "color": "#3878FA" } + { + "type": "shape", + "shapeType": "circle", + "fill": "#263878FA", + "size": 52 + }, + { + "type": "image", + "systemName": "checkmark.circle.fill", + "size": 30, + "color": "#3878FA" + } ] }, { @@ -113,7 +153,9 @@ "alignment": "center", "lineLimit": 2 }, - { "type": "spacer" } + { + "type": "spacer" + } ] } } diff --git a/tests/fixtures/presets/upcoming-payments.json b/tests/fixtures/presets/upcoming-payments.json index a989dcf..99a6e12 100644 --- a/tests/fixtures/presets/upcoming-payments.json +++ b/tests/fixtures/presets/upcoming-payments.json @@ -1,13 +1,21 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", "padding": 14, "spacing": 10, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ - { "type": "divider", "color": "#3878FA", "thickness": 4 }, + { + "type": "divider", + "color": "#3878FA", + "thickness": 4 + }, { "type": "hstack", "spacing": 10, @@ -61,7 +69,9 @@ } ] }, - { "type": "spacer" }, + { + "type": "spacer" + }, { "type": "text", "content": "$12.50", @@ -79,7 +89,10 @@ "padding": 10, "spacing": 4, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { "type": "label", @@ -106,8 +119,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 20, "cornerRadius": 5 }, - { "type": "text", "content": "S", "fontSize": 9, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "S", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -116,11 +141,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Streaming", "fontSize": 10, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 1", "fontSize": 8, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Streaming", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 1", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$12.50", "fontSize": 10, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$12.50", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -132,8 +177,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 20, "cornerRadius": 5 }, - { "type": "text", "content": "C", "fontSize": 9, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "C", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -142,11 +199,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Cloud Pro", "fontSize": 10, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Today", "fontSize": 8, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Cloud Pro", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Today", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$9.99", "fontSize": 10, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$9.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -158,8 +235,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 20, "cornerRadius": 5 }, - { "type": "text", "content": "M", "fontSize": 9, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "M", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -168,11 +257,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Music Plus", "fontSize": 10, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Tomorrow", "fontSize": 8, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Music Plus", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Tomorrow", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$4.99", "fontSize": 10, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$4.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -184,8 +293,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 20, "cornerRadius": 5 }, - { "type": "text", "content": "N", "fontSize": 9, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 20, + "cornerRadius": 5 + }, + { + "type": "text", + "content": "N", + "fontSize": 9, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -194,16 +315,39 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "News Daily", "fontSize": 10, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 5", "fontSize": 8, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "News Daily", + "fontSize": 10, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 5", + "fontSize": 8, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$2.99", "fontSize": 10, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$2.99", + "fontSize": 10, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] } ] }, - { "type": "spacer", "minLength": 2 }, + { + "type": "spacer", + "minLength": 2 + }, { "type": "text", "content": "+2 more", @@ -219,7 +363,10 @@ "padding": 12, "spacing": 4, "cornerRadius": 16, - "background": { "light": "#F2F2F7", "dark": "#1C1C1E" }, + "background": { + "light": "#F2F2F7", + "dark": "#1C1C1E" + }, "children": [ { "type": "label", @@ -246,8 +393,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "S", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "S", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -256,11 +415,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Streaming", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 1", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Streaming", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 1", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$12.50", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$12.50", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -272,8 +451,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "C", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "C", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -282,11 +473,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Cloud Pro", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Today", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Cloud Pro", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Today", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$9.99", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$9.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -298,8 +509,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "M", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "M", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -308,11 +531,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Music Plus", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Tomorrow", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Music Plus", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Tomorrow", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$4.99", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$4.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -324,8 +567,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "N", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "N", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -334,11 +589,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "News Daily", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 5", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "News Daily", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 5", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$2.99", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$2.99", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -350,8 +625,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "G", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "G", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -360,11 +647,31 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "Gym Club", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 8", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "Gym Club", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 8", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$29.00", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$29.00", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] }, { @@ -376,8 +683,20 @@ "type": "zstack", "alignment": "center", "children": [ - { "type": "shape", "shapeType": "rectangle", "fill": "#1F59E0", "size": 24, "cornerRadius": 6 }, - { "type": "text", "content": "V", "fontSize": 11, "fontWeight": "bold", "color": "#FFFFFF" } + { + "type": "shape", + "shapeType": "rectangle", + "fill": "#1F59E0", + "size": 24, + "cornerRadius": 6 + }, + { + "type": "text", + "content": "V", + "fontSize": 11, + "fontWeight": "bold", + "color": "#FFFFFF" + } ] }, { @@ -386,16 +705,39 @@ "alignment": "leading", "flex": 1, "children": [ - { "type": "text", "content": "VPN Secure", "fontSize": 11, "fontWeight": "semibold", "color": "label", "lineLimit": 1 }, - { "type": "text", "content": "Jun 12", "fontSize": 9, "color": "secondaryLabel", "lineLimit": 1 } + { + "type": "text", + "content": "VPN Secure", + "fontSize": 11, + "fontWeight": "semibold", + "color": "label", + "lineLimit": 1 + }, + { + "type": "text", + "content": "Jun 12", + "fontSize": 9, + "color": "secondaryLabel", + "lineLimit": 1 + } ] }, - { "type": "text", "content": "$6.49", "fontSize": 11, "fontWeight": "bold", "color": "#3878FA", "lineLimit": 1 } + { + "type": "text", + "content": "$6.49", + "fontSize": 11, + "fontWeight": "bold", + "color": "#3878FA", + "lineLimit": 1 + } ] } ] }, - { "type": "spacer", "minLength": 4 }, + { + "type": "spacer", + "minLength": 4 + }, { "type": "text", "content": "+4 more", diff --git a/tests/fixtures/presets/weather.json b/tests/fixtures/presets/weather.json index c4ffef6..afaf955 100644 --- a/tests/fixtures/presets/weather.json +++ b/tests/fixtures/presets/weather.json @@ -1,12 +1,13 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", "children": [ { "type": "text", - "content": "72°", - "fontSize": 36.0, + "content": "72\u00b0", + "fontSize": 36, "fontWeight": "bold", "fontDesign": null, "textStyle": null, @@ -26,7 +27,7 @@ { "type": "progress", "value": 0.7, - "total": 1.0, + "total": 1, "label": "Humidity", "tint": "#4CAF50", "color": null, @@ -42,11 +43,14 @@ "flex": null } ], - "spacing": 8.0, + "spacing": 8, "alignment": null, - "padding": 12.0, - "background": "#1a1a2e", - "cornerRadius": 16.0, + "padding": 12, + "background": { + "light": "#F2F2F7", + "dark": "#1a1a2e" + }, + "cornerRadius": 16, "opacity": null, "frame": null, "border": null, diff --git a/tests/fixtures/tasks.json b/tests/fixtures/tasks.json index 0afa0a9..a637d3b 100644 --- a/tests/fixtures/tasks.json +++ b/tests/fixtures/tasks.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,7 +7,7 @@ { "type": "text", "content": "Tasks", - "fontSize": 18.0, + "fontSize": 18, "fontWeight": "bold", "fontDesign": null, "textStyle": null, @@ -39,7 +40,7 @@ "payload": null } ], - "spacing": 4.0, + "spacing": 4, "fontSize": null, "color": null, "padding": null, @@ -53,9 +54,9 @@ "flex": null } ], - "spacing": 6.0, + "spacing": 6, "alignment": null, - "padding": 10.0, + "padding": 10, "background": null, "cornerRadius": null, "opacity": null, diff --git a/tests/fixtures/weather.json b/tests/fixtures/weather.json index c4ffef6..373475b 100644 --- a/tests/fixtures/weather.json +++ b/tests/fixtures/weather.json @@ -1,4 +1,5 @@ { + "$schema": "https://s00d.github.io/tauri-plugin-widgets/schemas/widget-config.v1.json", "version": 1, "small": { "type": "vstack", @@ -6,7 +7,7 @@ { "type": "text", "content": "72°", - "fontSize": 36.0, + "fontSize": 36, "fontWeight": "bold", "fontDesign": null, "textStyle": null, @@ -26,7 +27,7 @@ { "type": "progress", "value": 0.7, - "total": 1.0, + "total": 1, "label": "Humidity", "tint": "#4CAF50", "color": null, @@ -42,11 +43,11 @@ "flex": null } ], - "spacing": 8.0, + "spacing": 8, "alignment": null, - "padding": 12.0, + "padding": 12, "background": "#1a1a2e", - "cornerRadius": 16.0, + "cornerRadius": 16, "opacity": null, "frame": null, "border": null, diff --git a/tests/golden/desktop/android-list.large.png b/tests/golden/desktop/android-list.large.png index 062b27f..cd323a6 100644 Binary files a/tests/golden/desktop/android-list.large.png and b/tests/golden/desktop/android-list.large.png differ diff --git a/tests/golden/desktop/calculator.small.png b/tests/golden/desktop/calculator.small.png index 6ff7d5c..59417d2 100644 Binary files a/tests/golden/desktop/calculator.small.png and b/tests/golden/desktop/calculator.small.png differ diff --git a/tests/golden/desktop/canvas.small.png b/tests/golden/desktop/canvas.small.png index 7a28111..c506bf3 100644 Binary files a/tests/golden/desktop/canvas.small.png and b/tests/golden/desktop/canvas.small.png differ diff --git a/tests/golden/desktop/hstack-divider.medium.png b/tests/golden/desktop/hstack-divider.medium.png index dbdbeed..cd85183 100644 Binary files a/tests/golden/desktop/hstack-divider.medium.png and b/tests/golden/desktop/hstack-divider.medium.png differ diff --git a/tests/golden/desktop/style-chrome.light.small.png b/tests/golden/desktop/style-chrome.light.small.png new file mode 100644 index 0000000..853bffd Binary files /dev/null and b/tests/golden/desktop/style-chrome.light.small.png differ diff --git a/tests/golden/desktop/style-chrome.small.png b/tests/golden/desktop/style-chrome.small.png index f1ff644..d742131 100644 Binary files a/tests/golden/desktop/style-chrome.small.png and b/tests/golden/desktop/style-chrome.small.png differ diff --git a/tests/golden/desktop/tasks.small.png b/tests/golden/desktop/tasks.small.png index bbc80c9..4498a01 100644 Binary files a/tests/golden/desktop/tasks.small.png and b/tests/golden/desktop/tasks.small.png differ diff --git a/tests/golden/desktop/upcoming-payments-empty.large.png b/tests/golden/desktop/upcoming-payments-empty.large.png index e02b514..88c71e9 100644 Binary files a/tests/golden/desktop/upcoming-payments-empty.large.png and b/tests/golden/desktop/upcoming-payments-empty.large.png differ diff --git a/tests/golden/desktop/upcoming-payments-empty.medium.png b/tests/golden/desktop/upcoming-payments-empty.medium.png index 2508f05..a8d7ab2 100644 Binary files a/tests/golden/desktop/upcoming-payments-empty.medium.png and b/tests/golden/desktop/upcoming-payments-empty.medium.png differ diff --git a/tests/golden/desktop/upcoming-payments-empty.small.png b/tests/golden/desktop/upcoming-payments-empty.small.png index 9842cb4..11a323a 100644 Binary files a/tests/golden/desktop/upcoming-payments-empty.small.png and b/tests/golden/desktop/upcoming-payments-empty.small.png differ diff --git a/tests/golden/desktop/upcoming-payments.large.png b/tests/golden/desktop/upcoming-payments.large.png index 65bf5ca..d0edeb1 100644 Binary files a/tests/golden/desktop/upcoming-payments.large.png and b/tests/golden/desktop/upcoming-payments.large.png differ diff --git a/tests/golden/desktop/upcoming-payments.light.small.png b/tests/golden/desktop/upcoming-payments.light.small.png new file mode 100644 index 0000000..91263eb Binary files /dev/null and b/tests/golden/desktop/upcoming-payments.light.small.png differ diff --git a/tests/golden/desktop/upcoming-payments.medium.png b/tests/golden/desktop/upcoming-payments.medium.png index 711a3ce..3b2edf9 100644 Binary files a/tests/golden/desktop/upcoming-payments.medium.png and b/tests/golden/desktop/upcoming-payments.medium.png differ diff --git a/tests/golden/desktop/weather.light.small.png b/tests/golden/desktop/weather.light.small.png new file mode 100644 index 0000000..6717f31 Binary files /dev/null and b/tests/golden/desktop/weather.light.small.png differ diff --git a/tests/golden/ios/calculator.small.png b/tests/golden/ios/calculator.small.png index bb677b8..06071b7 100644 Binary files a/tests/golden/ios/calculator.small.png and b/tests/golden/ios/calculator.small.png differ diff --git a/tests/golden/ios/canvas-draws.small.png b/tests/golden/ios/canvas-draws.small.png index ec856d5..4dbcf53 100644 Binary files a/tests/golden/ios/canvas-draws.small.png and b/tests/golden/ios/canvas-draws.small.png differ diff --git a/tests/golden/ios/layout.small.png b/tests/golden/ios/layout.small.png index 6814f24..04b1ebb 100644 Binary files a/tests/golden/ios/layout.small.png and b/tests/golden/ios/layout.small.png differ diff --git a/tests/golden/ios/null-fields.small.png b/tests/golden/ios/null-fields.small.png index d50782d..29b89b7 100644 Binary files a/tests/golden/ios/null-fields.small.png and b/tests/golden/ios/null-fields.small.png differ diff --git a/tests/golden/ios/style-chrome.light.small.png b/tests/golden/ios/style-chrome.light.small.png new file mode 100644 index 0000000..54ca63c Binary files /dev/null and b/tests/golden/ios/style-chrome.light.small.png differ diff --git a/tests/golden/ios/style-chrome.small.png b/tests/golden/ios/style-chrome.small.png index 0834589..779a7fe 100644 Binary files a/tests/golden/ios/style-chrome.small.png and b/tests/golden/ios/style-chrome.small.png differ diff --git a/tests/golden/ios/upcoming-payments.light.small.png b/tests/golden/ios/upcoming-payments.light.small.png new file mode 100644 index 0000000..60658c6 Binary files /dev/null and b/tests/golden/ios/upcoming-payments.light.small.png differ diff --git a/tests/golden/ios/upcoming-payments.small.png b/tests/golden/ios/upcoming-payments.small.png index 182c9b8..e35bffa 100644 Binary files a/tests/golden/ios/upcoming-payments.small.png and b/tests/golden/ios/upcoming-payments.small.png differ diff --git a/tests/golden/ios/weather.light.small.png b/tests/golden/ios/weather.light.small.png new file mode 100644 index 0000000..4d72777 Binary files /dev/null and b/tests/golden/ios/weather.light.small.png differ diff --git a/tests/golden/ios/weather.small.png b/tests/golden/ios/weather.small.png index ab16c0b..f085685 100644 Binary files a/tests/golden/ios/weather.small.png and b/tests/golden/ios/weather.small.png differ diff --git a/tests/golden/macos/canvas-draws.small.png b/tests/golden/macos/canvas-draws.small.png index 484ffbc..90c7985 100644 Binary files a/tests/golden/macos/canvas-draws.small.png and b/tests/golden/macos/canvas-draws.small.png differ diff --git a/tests/golden/macos/style-chrome.light.small.png b/tests/golden/macos/style-chrome.light.small.png new file mode 100644 index 0000000..8cc9543 Binary files /dev/null and b/tests/golden/macos/style-chrome.light.small.png differ diff --git a/tests/golden/macos/style-chrome.small.png b/tests/golden/macos/style-chrome.small.png index a0a6351..fc80747 100644 Binary files a/tests/golden/macos/style-chrome.small.png and b/tests/golden/macos/style-chrome.small.png differ diff --git a/tests/golden/macos/upcoming-payments.light.small.png b/tests/golden/macos/upcoming-payments.light.small.png new file mode 100644 index 0000000..b50ab85 Binary files /dev/null and b/tests/golden/macos/upcoming-payments.light.small.png differ diff --git a/tests/golden/macos/weather.light.small.png b/tests/golden/macos/weather.light.small.png new file mode 100644 index 0000000..8518dfd Binary files /dev/null and b/tests/golden/macos/weather.light.small.png differ diff --git a/tests/golden/windows/README.md b/tests/golden/windows/README.md index 80b1288..0445a17 100644 --- a/tests/golden/windows/README.md +++ b/tests/golden/windows/README.md @@ -4,7 +4,7 @@ Record via PreviewHost on a Windows VM (`AdaptiveCards.Rendering.Wpf`, `Smoke=fa ```bash just record-windows weather.small -# or: tools/win/shot.ps1 -Mode visual -Record +# or: scripts/win/shot.ps1 -Mode visual -Record ``` Smoke walker (`Smoke=true`) is only for bootstrap / fallback — visual goldens must use the full WPF renderer. diff --git a/tests/macos/pipeline.sh b/tests/macos/pipeline.sh index e935131..82c8dbc 100755 --- a/tests/macos/pipeline.sh +++ b/tests/macos/pipeline.sh @@ -14,7 +14,7 @@ cd "$TMP/app" # Drop pre-existing macos-widget so init-macos --force regenerates from templates. rm -rf src-tauri/macos-widget -node "$REPO/bin/cli.mjs" init-macos \ +node "$REPO/dist-cli/cli.mjs" init-macos \ --force \ --app-group group.test.app \ --bundle-id test.app.widgetkit diff --git a/tests/macos_transports.rs b/tests/macos_transports.rs index 839f288..7efbd21 100644 --- a/tests/macos_transports.rs +++ b/tests/macos_transports.rs @@ -120,6 +120,110 @@ fn action_roundtrip() { ); } +#[test] +fn clear_pending_everywhere_keeps_undrained_and_skips_meta_bump() { + use tauri_plugin_widgets::store::{ + encode_pending_actions, map_nonce, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY, + WidgetActionEnvelope, + }; + + let _lock = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let app_group_file = tmp.path().join("appgroup/widget_data.json"); + let _env = EnvGuard::apply(&[ + ( + "WIDGET_CONTAINER_ROOT", + tmp.path().to_string_lossy().into_owned(), + ), + ("WIDGET_EXTENSION_BUNDLE", "test.app.widgetkit".into()), + ( + "WIDGET_APP_GROUP_DATA_FILE", + app_group_file.to_string_lossy().into_owned(), + ), + ]); + + const GROUP: &str = "group.test.clear-pending"; + let drained = WidgetActionEnvelope::new("tap", Some("a".into()), "w", GROUP); + let keep = WidgetActionEnvelope::new("tap", Some("b".into()), "w", GROUP); + + let mut map = DataMap::new(); + map.insert( + PENDING_ACTIONS_KEY.into(), + encode_pending_actions(&[drained.clone(), keep.clone()]).unwrap(), + ); + map.insert("__meta_nonce__".into(), "7".into()); + write_map_file(&app_group_file, &map).unwrap(); + write_sandbox_map(GROUP, &map).unwrap(); + + mt::clear_pending_actions_everywhere(GROUP, &[drained]); + + for m in mt::read_file_transports(GROUP, Some(&app_group_file)) { + let left = parse_pending_actions(m.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + assert_eq!(left.len(), 1, "only undrained action remains"); + assert_eq!(left[0].payload.as_deref(), Some("b")); + assert_eq!(map_nonce(&m), 7, "clear must not bump nonce"); + } +} + +#[test] +fn merge_pending_then_clear_leftover_preserves_sibling_taps() { + use tauri_plugin_widgets::store::{ + encode_pending_actions, map_has_config, parse_pending_actions, DataMap, PENDING_ACTIONS_KEY, + WidgetActionEnvelope, + }; + + let _lock = env_lock().lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let app_group_file = tmp.path().join("appgroup/widget_data.json"); + let _env = EnvGuard::apply(&[ + ( + "WIDGET_CONTAINER_ROOT", + tmp.path().to_string_lossy().into_owned(), + ), + ("WIDGET_EXTENSION_BUNDLE", "test.app.widgetkit".into()), + ( + "WIDGET_APP_GROUP_DATA_FILE", + app_group_file.to_string_lossy().into_owned(), + ), + ]); + + const GROUP: &str = "group.test.clear-leftover"; + const WIDGET: &str = "probe"; + + // Stale config + pending tap on App Group file. + let mut stale = DataMap::new(); + put_config_in_map(&mut stale, WIDGET, &cfg_v1()).unwrap(); + stale.insert( + PENDING_ACTIONS_KEY.into(), + encode_pending_actions(&[WidgetActionEnvelope::new( + "tap", + Some("1".into()), + WIDGET, + GROUP, + )]) + .unwrap(), + ); + write_map_file(&app_group_file, &stale).unwrap(); + + // Host write path: merge taps → wipe sibling leftovers → write live map. + let mut live = map_with_nonce(50, WIDGET, &cfg_v2()).unwrap(); + mt::merge_pending_into_map(&mut live, GROUP); + mt::clear_sibling_transports(GROUP, "container", &mut live); + write_sandbox_map(GROUP, &live).unwrap(); + + let pending = parse_pending_actions(live.get(PENDING_ACTIONS_KEY).map(|s| s.as_str())); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].payload.as_deref(), Some("1")); + + // App Group leftover must be empty (no stale config). Primary was not wiped. + let wiped = std::fs::read_to_string(&app_group_file).unwrap_or_default(); + let wiped_map: DataMap = serde_json::from_str(&wiped).unwrap_or_default(); + assert!( + !map_has_config(&wiped_map), + "clear_sibling must wipe non-primary config" + ); +} + #[test] fn container_root_override_not_home() { let _lock = env_lock().lock().unwrap(); diff --git a/tests/snapshots/adaptive/weather.small.json b/tests/snapshots/adaptive/weather.small.json index e3f1eac..cb2b6be 100644 --- a/tests/snapshots/adaptive/weather.small.json +++ b/tests/snapshots/adaptive/weather.small.json @@ -2,7 +2,7 @@ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "body": [ { - "id": "card:#1a1a2e;gap:8", + "id": "card:#1a1a2e", "items": [ { "color": "Light", @@ -14,6 +14,7 @@ "wrap": true }, { + "id": "gap:8", "items": [ { "color": "Good", @@ -40,14 +41,15 @@ "type": "ColumnSet" } ], + "spacing": "Default", "type": "Container" } ], - "spacing": "Default", + "spacing": "None", "style": "emphasis", "type": "Container" } ], "type": "AdaptiveCard", "version": "1.5" -} \ No newline at end of file +} diff --git a/tests/windows/compare.mjs b/tests/windows/compare.mjs index d22d9c3..bf3e0b9 100644 --- a/tests/windows/compare.mjs +++ b/tests/windows/compare.mjs @@ -1,11 +1,12 @@ /** * Compare out/windows/*.png against tests/golden/windows/*.png (same tol as desktop). * Usage: node tests/windows/compare.mjs [caseName] + * + * If no goldens exist, exits 0 with a manual-track note (WinUI3 VM required). */ -import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import sharp from "sharp"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, "../.."); @@ -14,6 +15,25 @@ const outDir = join(root, "out/windows"); const TOL = 0.02; const CHANNEL = 8; +const only = process.argv[2]; +const names = only + ? [only] + : ["weather.small", "chart-mix.medium", "canvas.small"]; + +mkdirSync(outDir, { recursive: true }); + +const goldenFiles = existsSync(goldenDir) + ? readdirSync(goldenDir).filter((f) => f.endsWith(".png")) + : []; +if (goldenFiles.length === 0) { + console.log( + "windows: no PNG goldens under tests/golden/windows/ — track is manual (WinUI3 VM). See docs/contributing/windows-surfaces.md", + ); + process.exit(0); +} + +const { default: sharp } = await import("sharp"); + async function mismatchFrac(aBuf, bBuf) { const a = await sharp(aBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); const b = await sharp(bBuf).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); @@ -33,21 +53,11 @@ async function mismatchFrac(aBuf, bBuf) { return bad / n; } -const only = process.argv[2]; -const names = only - ? [only] - : ["weather.small", "chart-mix.medium", "canvas.small"]; - -mkdirSync(outDir, { recursive: true }); +const available = names.filter((name) => existsSync(join(goldenDir, `${name}.png`))); let failed = 0; -for (const name of names) { +for (const name of available) { const g = join(goldenDir, `${name}.png`); const a = join(outDir, `${name}.png`); - if (!existsSync(g)) { - console.error(`missing golden ${g}`); - failed++; - continue; - } if (!existsSync(a)) { console.error(`missing actual ${a} — run UTM shot -Mode visual or copy PNG to out/windows/`); failed++; @@ -60,4 +70,8 @@ for (const name of names) { writeFileSync(join(outDir, `${name}.fail.txt`), `frac=${frac}\n`); } } +if (only && available.length === 0) { + console.error(`missing golden ${join(goldenDir, `${only}.png`)}`); + failed++; +} process.exit(failed ? 1 : 0); diff --git a/widget-src/ctx.ts b/widget-src/ctx.ts new file mode 100644 index 0000000..7f8a570 --- /dev/null +++ b/widget-src/ctx.ts @@ -0,0 +1,61 @@ +function tauri(): NonNullable { + const t = window.__TAURI_INTERNALS__; + if (!t) throw new Error("Tauri internals unavailable"); + return t; +} + +export function invoke( + cmd: string, + args?: Record, +): Promise { + return tauri().invoke(cmd, args || {}); +} + +export function listen( + event: string, + handler: (ev: { payload: unknown }) => void, +): Promise { + const id = tauri().transformCallback((e) => { + handler(e); + }); + const ch = { + id, + __TAURI_CHANNEL_MARKER__: true as const, + toJSON() { + return `__CHANNEL__:${id}`; + }, + }; + return invoke("plugin:event|listen", { + event, + target: { kind: "Any" }, + handler: ch, + }); +} + +const params = new URLSearchParams(window.location.search); +export const GROUP = params.get("group") || "default"; +export const SIZE = params.get("size") || ""; +export const WIDGET_ID = params.get("widgetId") || "default"; +export const THEME = (params.get("theme") || "").toLowerCase(); +export const root = document.getElementById("root") as HTMLElement; + +export function emitAction( + action: string, + payload?: string | null, +): Promise { + return invoke("plugin:widgets|widget_action", { + action, + payload: payload == null ? null : payload, + widgetId: WIDGET_ID, + group: GROUP, + }).catch(console.error); +} + +export let timerIds: ReturnType[] = []; + +export function clearTimers(): void { + timerIds.forEach((id) => { + clearInterval(id); + }); + timerIds = []; +} diff --git a/widget-src/globals.d.ts b/widget-src/globals.d.ts new file mode 100644 index 0000000..332ca1a --- /dev/null +++ b/widget-src/globals.d.ts @@ -0,0 +1,14 @@ +/** Minimal Tauri bridge used by the desktop widget webview. */ +interface TauriInternals { + invoke: (cmd: string, args?: Record) => Promise; + transformCallback: (cb: (event: { payload: unknown }) => void) => number; + metadata?: { + currentWindow?: { + label?: string; + }; + }; +} + +interface Window { + __TAURI_INTERNALS__?: TauriInternals; +} diff --git a/widget-src/main.ts b/widget-src/main.ts new file mode 100644 index 0000000..5636451 --- /dev/null +++ b/widget-src/main.ts @@ -0,0 +1,197 @@ +import type { ElNode, SkippedElement, WidgetConfig } from "./types"; +import { + invoke, + listen, + GROUP, + SIZE, + WIDGET_ID, + root, + clearTimers, +} from "./ctx"; +import { isDark } from "./style"; +import { renderEl } from "./render/element"; + +function collectTrace(node: unknown, rendered: string[], skipped: SkippedElement[]) { + if (!node || typeof node !== "object") return; + const n = node as ElNode; + var t = n.type; + if (typeof t === "string" && t) { + rendered.push(t); + if (t === "canvas" && !(window as unknown as { Path2D?: unknown }).Path2D) { + skipped.push({ type: t, reason: "unsupported" }); + } + } + var kids = n.children; + if (Array.isArray(kids)) + kids.forEach(function (c: unknown) { + collectTrace(c, rendered, skipped); + }); + if (Array.isArray(n.items)) + n.items.forEach(function (it: ElNode) { + if (it && it.children) collectTrace(it, rendered, skipped); + }); +} + +function reportReceipt(cfg: WidgetConfig | null | undefined, source: string, trigger?: string, nonceHint?: unknown) { + try { + var label = + (window.__TAURI_INTERNALS__ && + window.__TAURI_INTERNALS__.metadata && + window.__TAURI_INTERNALS__.metadata.currentWindow && + window.__TAURI_INTERNALS__.metadata.currentWindow.label) || + "desktop"; + var rendered: string[] = [], + skipped: SkippedElement[] = []; + var branch = SIZE + ? cfg && ((cfg[SIZE] as ElNode | undefined) || cfg.small || cfg.medium || cfg.large) + : cfg && (cfg.small || cfg.medium || cfg.large); + collectTrace(branch, rendered, skipped); + var nonce = 0; + if (nonceHint != null) nonce = Number(nonceHint) || 0; + else if (cfg && cfg.__nonce != null) nonce = Number(cfg.__nonce) || 0; + var trig = trigger || (source === "push" ? "reload" : "timeline"); + invoke("plugin:widgets|report_receipt", { + receipt: { + widgetId: WIDGET_ID, + group: GROUP, + instance: label, + nonce: nonce, + size: SIZE || "small", + theme: document.documentElement.getAttribute("data-theme") || undefined, + schema: 1, + source: source || "pull", + trigger: trig, + rendered: rendered, + skipped: skipped, + ts: Date.now(), + }, + }).catch(function () {}); + } catch (_) {} +} + +function render(cfg: WidgetConfig | null | undefined, source?: string, nonceHint?: unknown) { + clearTimers(); + if (!cfg) { + root.innerHTML = + '
📌
No widget config
'; + return; + } + var data; + if (SIZE) data = (cfg[SIZE] as ElNode | undefined) || cfg.small || cfg.medium || cfg.large; + else data = cfg.small || cfg.medium || cfg.large; + if (!data) { + root.innerHTML = + '
📌
No config for size "' + + SIZE + + '"
'; + return; + } + + root.innerHTML = ""; + // Readable defaults when fixtures leave color/background null (avoids black-on-black + // and WebKit leftover Loading purple under transparent surfaces). + var ink = isDark() ? "#e5e7eb" : "#111827"; + var surface = isDark() ? "#0f172a" : "#f8fafc"; + document.documentElement.style.color = ink; + document.body.style.color = ink; + var wrapper = document.createElement("div"); + wrapper.style.cssText = + "width:100%;height:100%;overflow:hidden;position:relative"; + + var content = renderEl(data as ElNode); + content.style.width = "100%"; + content.style.height = "100%"; + if (!content.style.background && !content.style.backgroundColor) { + content.style.background = surface; + } + wrapper.appendChild(content); + + var drag = document.createElement("div"); + drag.id = "drag-handle"; + drag.setAttribute("data-tauri-drag-region", ""); + wrapper.appendChild(drag); + + var cls = document.createElement("button"); + cls.id = "close-btn"; + cls.innerHTML = "✕"; + cls.onclick = function () { + try { + var label = window.__TAURI_INTERNALS__?.metadata?.currentWindow?.label; + if (!label) { window.close(); return; } + invoke("plugin:widgets|close_widget_window", { label: label }).catch( + function () {} + ); + } catch (_) { + window.close(); + } + }; + wrapper.appendChild(cls); + root.appendChild(wrapper); + reportReceipt( + cfg, + source || "pull", + source === "push" ? "reload" : "timeline", + nonceHint + ); +} + +function loadConfig() { + Promise.all([ + invoke("plugin:widgets|get_widget_config", { + group: GROUP, + widgetId: WIDGET_ID, + }), + invoke("plugin:widgets|get_items", { + key: "__meta_nonce__", + group: GROUP, + }).catch(function () { + return null; + }), + ]) + .then(function (res) { + var cfg = res[0] as WidgetConfig | null; + var nonce = res[1]; + if (cfg && nonce != null) cfg.__nonce = Number(nonce) || 0; + render(cfg, "pull", nonce); + }) + .catch(function (e) { + clearTimers(); + root.innerHTML = '
' + String(e) + "
"; + }); +} + +function init() { + if (!window.__TAURI_INTERNALS__) { + setTimeout(init, 50); + return; + } + // Listeners BEFORE loadConfig — otherwise a push between the two is dropped. + listen("widget-config-push", function (ev) { + type ConfigPush = { + config?: WidgetConfig; + widgetId?: string; + group?: string; + }; + const raw = ev.payload; + if (raw && typeof raw === "object" && "config" in raw) { + const p = raw as ConfigPush; + if (!p.config) return; + if (p.widgetId && p.widgetId !== WIDGET_ID) return; + if (p.group && p.group !== GROUP) return; + render(p.config, "push"); + } else { + render(raw as WidgetConfig | null, "push"); + } + }); + listen("widget-update", function () { + loadConfig(); + }); + listen("widget-reload", function () { + loadConfig(); + }); + loadConfig(); +} + +if (document.readyState === "complete" || document.readyState === "interactive") + init(); +else document.addEventListener("DOMContentLoaded", init); diff --git a/widget-src/render/data.ts b/widget-src/render/data.ts new file mode 100644 index 0000000..032d0cd --- /dev/null +++ b/widget-src/render/data.ts @@ -0,0 +1,321 @@ +import { applyStyle, resolveColor, svg, tintTrack } from "../style"; +import { emitAction } from "../ctx"; +import type { ElNode } from "../types"; + +export function renderProgress(d: ElNode): HTMLElement | SVGElement { + var pct = ((d.value / (d.total || 1)) * 100).toFixed(1); + var tint = d.tint ? resolveColor(d.tint) : "#4CAF50"; + var track = tintTrack(d.tint); + if (d.barStyle === "circular") { + var w = document.createElement("div"); + w.style.cssText = "width:40px;height:40px;position:relative"; + var s = svg("svg", { viewBox: "0 0 36 36" }); + s.style.cssText = "width:100%;height:100%"; + s.appendChild( + svg("path", { + d: "M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831", + fill: "none", + stroke: track, + "stroke-width": "3", + }) + ); + s.appendChild( + svg("path", { + d: "M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831", + fill: "none", + stroke: tint, + "stroke-width": "3", + "stroke-dasharray": pct + ", 100", + "stroke-linecap": "round", + }) + ); + w.appendChild(s); + applyStyle(w, d); + return w; + } + var e = document.createElement("div"); + e.style.cssText = + "flex:1 1 auto;width:100%;min-width:48px;align-self:stretch;box-sizing:border-box"; + if (d.label) { + var l = document.createElement("div"); + l.textContent = d.label; + l.style.cssText = "font-size:10px;margin-bottom:2px"; + l.style.color = d.color ? resolveColor(d.color) : tint; + e.appendChild(l); + } + var tr = document.createElement("div"); + tr.style.cssText = + "height:6px;border-radius:3px;overflow:hidden;width:100%"; + tr.style.background = track; + var f = document.createElement("div"); + f.style.cssText = "height:100%;border-radius:3px"; + f.style.width = pct + "%"; + f.style.background = tint; + tr.appendChild(f); + e.appendChild(tr); + applyStyle(e, d); + return e; +} + +export function renderGauge(d: ElNode): HTMLElement | SVGElement { + var v = d.value || 0, + lo = d.min || 0, + hi = d.max || 1; + var pct = ((v - lo) / Math.max(hi - lo, 0.0001)) * 100; + var tint = d.tint ? resolveColor(d.tint) : "#4CAF50"; + var track = tintTrack(d.tint); + var clr = d.color ? resolveColor(d.color) : tint; + if (d.gaugeStyle === "linear") { + var e = document.createElement("div"); + e.style.cssText = + "flex:1 1 auto;width:100%;min-width:48px;align-self:stretch;box-sizing:border-box"; + var top = document.createElement("div"); + top.style.cssText = + "display:flex;justify-content:space-between;align-items:baseline;gap:6px;margin-bottom:2px"; + if (d.label) { + var l = document.createElement("div"); + l.textContent = d.label; + l.style.cssText = "font-size:10px;opacity:0.7"; + l.style.color = clr; + top.appendChild(l); + } + if (d.currentValueLabel) { + var cv = document.createElement("div"); + cv.textContent = d.currentValueLabel; + cv.style.cssText = "font-size:11px;font-weight:600"; + cv.style.color = clr; + top.appendChild(cv); + } + if (top.childNodes.length) e.appendChild(top); + var tr = document.createElement("div"); + tr.style.cssText = + "height:6px;border-radius:3px;overflow:hidden;width:100%"; + tr.style.background = track; + var f = document.createElement("div"); + f.style.cssText = "height:100%;border-radius:3px"; + f.style.width = Math.max(Math.min(pct, 100), 0) + "%"; + f.style.background = tint; + tr.appendChild(f); + e.appendChild(tr); + applyStyle(e, d); + return e; + } + var wr = document.createElement("div"); + wr.style.textAlign = "center"; + var sw = document.createElement("div"); + sw.style.cssText = "width:56px;height:56px;position:relative;margin:0 auto"; + var s = svg("svg", { viewBox: "0 0 36 36" }); + s.style.cssText = "width:100%;height:100%"; + s.appendChild( + svg("path", { + d: "M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831", + fill: "none", + stroke: track, + "stroke-width": "4", + }) + ); + s.appendChild( + svg("path", { + d: "M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831", + fill: "none", + stroke: tint, + "stroke-width": "4", + "stroke-dasharray": pct.toFixed(1) + ", 100", + "stroke-linecap": "round", + }) + ); + sw.appendChild(s); + if (d.currentValueLabel) { + var cv = document.createElement("div"); + cv.style.cssText = + "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600"; + cv.style.color = clr; + cv.textContent = d.currentValueLabel; + sw.appendChild(cv); + } + wr.appendChild(sw); + if (d.label) { + var l = document.createElement("div"); + l.style.cssText = "font-size:10px;margin-top:2px;opacity:0.7"; + l.style.color = clr; + l.textContent = d.label; + wr.appendChild(l); + } + applyStyle(wr, d); + return wr; +} + +export function renderChart(d: ElNode): HTMLElement | SVGElement { + var pts = d.chartData || []; + var maxV = Math.max.apply( + null, + pts.map(function (p: ElNode) { + return p.value; + }).concat([1]) + ); + var tint = d.tint ? resolveColor(d.tint) : "#4CAF50"; + if (d.chartType === "line" || d.chartType === "area") { + var w = 200, + h = 60; + var s = svg("svg", { viewBox: "0 0 " + w + " " + h }); + s.style.cssText = "width:100%;height:60px"; + var pathD = pts + .map(function (p: ElNode, i: number) { + var x = (i / Math.max(pts.length - 1, 1)) * w; + var y = h - (p.value / maxV) * h; + return (i === 0 ? "M" : "L") + x + "," + y; + }) + .join(" "); + if (d.chartType === "area") { + var aD = + "M0," + + h + + " " + + pts + .map(function (p: ElNode, i: number) { + return ( + "L" + + (i / Math.max(pts.length - 1, 1)) * w + + "," + + (h - (p.value / maxV) * h) + ); + }) + .join(" ") + + " L" + + w + + "," + + h + + " Z"; + s.appendChild(svg("path", { d: aD, fill: tint, opacity: "0.3" })); + } + s.appendChild( + svg("path", { + d: pathD, + fill: "none", + stroke: tint, + "stroke-width": "2", + }) + ); + applyStyle(s, d); + return s; + } + if (d.chartType === "pie") { + var total = pts.reduce(function (s: number, p: ElNode) { + return s + p.value; + }, 0); + var dc = [ + "#3b82f6", + "#22c55e", + "#f97316", + "#ef4444", + "#a855f7", + "#eab308", + "#ec4899", + "#14b8a6", + ]; + var r = 40, + cx = 50, + cy = 50, + ca = -90; + var s = svg("svg", { viewBox: "0 0 100 100" }); + s.style.cssText = "width:80px;height:80px"; + pts.forEach(function (p: ElNode, i: number) { + var angle = (p.value / Math.max(total, 1)) * 360; + var sr = (ca * Math.PI) / 180, + er = ((ca + angle) * Math.PI) / 180; + var x1 = cx + r * Math.cos(sr), + y1 = cy + r * Math.sin(sr); + var x2 = cx + r * Math.cos(er), + y2 = cy + r * Math.sin(er); + var lf = angle > 180 ? 1 : 0; + s.appendChild( + svg("path", { + d: + "M" + + cx + + "," + + cy + + " L" + + x1 + + "," + + y1 + + " A" + + r + + "," + + r + + " 0 " + + lf + + ",1 " + + x2 + + "," + + y2 + + " Z", + fill: p.color ? resolveColor(p.color) : dc[i % dc.length], + }) + ); + ca += angle; + }); + applyStyle(s, d); + return s; + } + var e = document.createElement("div"); + e.style.cssText = "display:flex;align-items:flex-end;gap:4px;height:70px"; + pts.forEach(function (p: ElNode) { + var col = document.createElement("div"); + col.style.cssText = + "flex:1;display:flex;flex-direction:column;align-items:center;gap:2px"; + var bar = document.createElement("div"); + bar.style.width = "100%"; + bar.style.height = Math.max((p.value / maxV) * 60, 2) + "px"; + bar.style.background = p.color ? resolveColor(p.color) : tint; + bar.style.borderRadius = "2px"; + col.appendChild(bar); + var lb = document.createElement("span"); + lb.textContent = p.label; + lb.style.cssText = "font-size:8px;color:#999"; + col.appendChild(lb); + e.appendChild(col); + }); + applyStyle(e, d); + return e; +} + +export function renderList(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.cssText = + "display:flex;flex-direction:column;gap:" + (d.spacing || 4) + "px"; + var items = d.items || []; + var anyCheck = items.some(function (it: ElNode) { + return Object.prototype.hasOwnProperty.call(it, "checked"); + }); + items.forEach(function (it: ElNode) { + var row = document.createElement("div"); + row.style.cssText = "display:flex;align-items:center;gap:6px;min-width:0"; + var hasCheck = Object.prototype.hasOwnProperty.call(it, "checked"); + if (hasCheck || anyCheck) { + var check = document.createElement("span"); + check.style.cssText = + "font-size:12px;width:14px;text-align:center;flex-shrink:0;color:" + + (hasCheck ? (it.checked ? "#22c55e" : "#9ca3af") : "transparent"); + check.textContent = hasCheck ? (it.checked ? "\u2713" : "\u25cb") : "\u00a0"; + row.appendChild(check); + } + var txt = document.createElement("span"); + txt.textContent = it.text || ""; + txt.style.cssText = + "font-size:" + + (d.fontSize || 13) + + "px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:1"; + txt.style.color = d.color ? resolveColor(d.color) : "inherit"; + row.appendChild(txt); + if (it.action) { + row.style.cursor = "pointer"; + row.onclick = function () { + emitAction(it.action, it.payload); + }; + } + e.appendChild(row); + }); + applyStyle(e, d); + return e; +} diff --git a/widget-src/render/element.ts b/widget-src/render/element.ts new file mode 100644 index 0000000..8a1f98b --- /dev/null +++ b/widget-src/render/element.ts @@ -0,0 +1,61 @@ +import { + renderStack, + renderZStack, + renderGrid, + renderContainer, +} from "./layout"; +import { renderText, renderDate, renderTimer, renderLabel } from "./text"; +import { renderImage, renderShape, renderCanvas } from "./media"; +import { renderProgress, renderGauge, renderChart, renderList } from "./data"; +import { renderButton, renderToggle, renderLink } from "./interactive"; +import { renderDivider, renderSpacer } from "./spacing"; +import type { ElNode, ParentAxis } from "../types"; + +export function renderEl(d: ElNode, parentAxis?: ParentAxis): HTMLElement | SVGElement { + switch (d.type) { + case "vstack": + return renderStack(d, "column"); + case "hstack": + return renderStack(d, "row"); + case "zstack": + return renderZStack(d); + case "grid": + return renderGrid(d); + case "container": + return renderContainer(d); + case "text": + return renderText(d, parentAxis); + case "image": + return renderImage(d); + case "progress": + return renderProgress(d); + case "gauge": + return renderGauge(d); + case "button": + return renderButton(d); + case "toggle": + return renderToggle(d); + case "divider": + return renderDivider(d, parentAxis); + case "spacer": + return renderSpacer(d); + case "date": + return renderDate(d); + case "chart": + return renderChart(d); + case "list": + return renderList(d); + case "link": + return renderLink(d); + case "shape": + return renderShape(d); + case "timer": + return renderTimer(d); + case "label": + return renderLabel(d); + case "canvas": + return renderCanvas(d); + default: + return document.createElement("span"); + } +} diff --git a/widget-src/render/interactive.ts b/widget-src/render/interactive.ts new file mode 100644 index 0000000..a229514 --- /dev/null +++ b/widget-src/render/interactive.ts @@ -0,0 +1,130 @@ +import { applyStyle, resolveColor } from "../style"; +import { emitAction } from "../ctx"; +import { renderEl } from "./element"; +import type { ElNode } from "../types"; + +export function renderButton(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.textContent = d.label || ""; + var align = (d.textAlignment || d.alignment || "center").toLowerCase(); + if (align === "middle") align = "center"; + if (align === "end" || align === "trailing") align = "right"; + if (align === "start" || align === "leading") align = "left"; + // Intrinsic height + centered label — avoid stretch in default hstack (tall empty pill, label stuck to top). + var padCss = "6px 12px"; + if (d.padding != null) { + if (typeof d.padding === "number") padCss = d.padding + "px"; + else if (typeof d.padding === "object") { + var pt = + d.padding.top != null + ? d.padding.top + : d.padding.vertical != null + ? d.padding.vertical + : 6; + var pb = + d.padding.bottom != null + ? d.padding.bottom + : d.padding.vertical != null + ? d.padding.vertical + : 6; + var pl = + d.padding.leading != null + ? d.padding.leading + : d.padding.left != null + ? d.padding.left + : d.padding.horizontal != null + ? d.padding.horizontal + : 12; + var pr = + d.padding.trailing != null + ? d.padding.trailing + : d.padding.right != null + ? d.padding.right + : d.padding.horizontal != null + ? d.padding.horizontal + : 12; + padCss = pt + "px " + pr + "px " + pb + "px " + pl + "px"; + } + } + e.style.cssText = + "display:inline-flex;align-items:center;justify-content:" + + (align === "left" + ? "flex-start" + : align === "right" + ? "flex-end" + : "center") + + ";align-self:center;flex-shrink:0;padding:" + + padCss + + ";text-align:" + + align + + ";cursor:pointer;font-weight:500;transition:filter .1s;box-sizing:border-box"; + e.style.fontSize = (d.fontSize || 14) + "px"; + e.style.background = d.backgroundColor + ? resolveColor(d.backgroundColor) + : "#2196F3"; + e.style.color = d.color ? resolveColor(d.color) : "#fff"; + e.style.borderRadius = (d.cornerRadius || 8) + "px"; + e.onmousedown = function () { + e.style.filter = "brightness(0.85)"; + }; + e.onmouseup = e.onmouseleave = function () { + e.style.filter = ""; + }; + e.onclick = function () { + if (d.action) emitAction(d.action); + else if (d.url) window.open(d.url, "_blank"); + }; + // applyStyle would clobber the padding we just set — skip padding there for buttons. + var padSave = d.padding; + d.padding = undefined; + applyStyle(e, d); + d.padding = padSave; + return e; +} + +export function renderToggle(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.cssText = "display:flex;align-items:center;gap:6px"; + var tint = d.tint ? resolveColor(d.tint) : "#4CAF50"; + var c = document.createElement("div"); + c.style.cssText = + "width:18px;height:18px;border-radius:9px;display:flex;align-items:center;justify-content:center;flex-shrink:0"; + c.style.border = "2px solid " + (d.isOn ? tint : "#999"); + c.style.background = d.isOn ? tint : "transparent"; + if (d.isOn) { + var m = document.createElement("span"); + m.textContent = "\u2713"; + m.style.cssText = "color:#fff;font-size:12px"; + c.appendChild(m); + } + e.appendChild(c); + if (d.label) { + var l = document.createElement("span"); + l.textContent = d.label; + l.style.fontSize = "14px"; + l.style.color = d.color ? resolveColor(d.color) : "inherit"; + e.appendChild(l); + } + if (d.action) { + e.style.cursor = "pointer"; + e.onclick = function () { + emitAction(d.action, String(!d.isOn)); + }; + } + applyStyle(e, d); + return e; +} + +export function renderLink(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.cursor = "pointer"; + e.onclick = function () { + if (d.action) emitAction(d.action); + else if (d.url) window.open(d.url, "_blank"); + }; + applyStyle(e, d); + (d.children || []).forEach(function (c: ElNode) { + e.appendChild(renderEl(c)); + }); + return e; +} diff --git a/widget-src/render/layout.ts b/widget-src/render/layout.ts new file mode 100644 index 0000000..f0c5530 --- /dev/null +++ b/widget-src/render/layout.ts @@ -0,0 +1,104 @@ +import { applyStyle } from "../style"; +import { renderEl } from "./element"; +import type { ElNode, ParentAxis } from "../types"; + +export function renderStack(d: ElNode, dir: "column" | "row"): HTMLElement { + var e = document.createElement("div"); + e.style.display = "flex"; + e.style.flexDirection = dir; + e.style.gap = (d.spacing || 0) + "px"; + // Default stretch (SwiftUI/Glance-like). Explicit alignment only when set. + if (dir === "column") { + e.style.alignItems = + d.alignment === "trailing" + ? "flex-end" + : d.alignment === "leading" + ? "flex-start" + : d.alignment === "center" + ? "center" + : "stretch"; + } else { + e.style.alignItems = + d.alignment === "top" + ? "flex-start" + : d.alignment === "bottom" + ? "flex-end" + : d.alignment === "center" + ? "center" + : "stretch"; + } + applyStyle(e, d); + var axis: ParentAxis = dir === "row" ? "horizontal" : "vertical"; + (d.children || []).forEach(function (c: ElNode) { + e.appendChild(renderEl(c, axis)); + }); + return e; +} + +export function renderZStack(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.display = "grid"; + var a = (d.alignment || "center").toLowerCase(); + var place: Record = { + center: "center", + top: "start center", + bottom: "end center", + leading: "center start", + trailing: "center end", + topleading: "start start", + toptrailing: "start end", + bottomleading: "end start", + bottomtrailing: "end end", + }; + e.style.placeItems = place[a] || "center"; + applyStyle(e, d); + (d.children || []).forEach(function (c: ElNode) { + var w = document.createElement("div"); + w.style.gridArea = "1 / 1"; + w.appendChild(renderEl(c)); + e.appendChild(w); + }); + return e; +} + +export function renderGrid(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.display = "grid"; + e.style.gridTemplateColumns = "repeat(" + (d.columns || 2) + ", 1fr)"; + e.style.columnGap = (d.spacing || 4) + "px"; + e.style.rowGap = (d.rowSpacing || d.spacing || 4) + "px"; + applyStyle(e, d); + (d.children || []).forEach(function (c: ElNode) { + e.appendChild(renderEl(c)); + }); + return e; +} + +export function renderContainer(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.display = "flex"; + var a = d.contentAlignment || "center"; + var vert: Record = { + top: "flex-start", + bottom: "flex-end", + topLeading: "flex-start", + topTrailing: "flex-start", + bottomLeading: "flex-end", + bottomTrailing: "flex-end", + }; + var horiz: Record = { + leading: "flex-start", + trailing: "flex-end", + topLeading: "flex-start", + topTrailing: "flex-end", + bottomLeading: "flex-start", + bottomTrailing: "flex-end", + }; + e.style.alignItems = vert[a] || "center"; + e.style.justifyContent = horiz[a] || "center"; + applyStyle(e, d); + (d.children || []).forEach(function (ch: ElNode) { + e.appendChild(renderEl(ch)); + }); + return e; +} diff --git a/widget-src/render/media.ts b/widget-src/render/media.ts new file mode 100644 index 0000000..2bc491f --- /dev/null +++ b/widget-src/render/media.ts @@ -0,0 +1,325 @@ +import { applyStyle, resolveColor, svg } from "../style"; +import type { ElNode } from "../types"; + +export function systemGlyph(name: string | null | undefined): string { + var n = String(name || "").toLowerCase(); + var map: Record = { + star: "\u2b50", + "star.fill": "\u2b50", + heart: "\u2764", + "heart.fill": "\u2764", + person: "\u1f464", + "person.fill": "\u1f464", + "person.2.fill": "\u1f465", + "person.badge.plus": "\u1f464", + gear: "\u2699", + gearshape: "\u2699", + "gearshape.fill": "\u2699", + checkmark: "\u2713", + "checkmark.circle": "\u2713", + "checkmark.circle.fill": "\u2713", + xmark: "\u2715", + "xmark.circle": "\u2715", + plus: "+", + "plus.circle": "+", + minus: "\u2212", + "minus.circle": "\u2212", + bell: "\u1f514", + "bell.fill": "\u1f514", + house: "\u2302", + "house.fill": "\u2302", + magnifyingglass: "\u1f50d", + calendar: "\u1f4c5", + "calendar.badge.clock": "\u1f4c5", + "calendar.circle": "\u1f4c5", + clock: "\u23f1", + "clock.fill": "\u23f1", + globe: "\u1f310", + "cloud.fill": "\u2601", + "cloud.sun.fill": "\u26c5", + "cloud.rain.fill": "\u2614", + "cloud.bolt.fill": "\u26a1", + "sun.max": "\u2600", + "sun.max.fill": "\u2600", + "moon.fill": "\u263e", + "moon.stars.fill": "\u263e", + bolt: "\u26a1", + "bolt.fill": "\u26a1", + "location.fill": "\u2302", + "flame.fill": "\u1f525", + "drop.fill": "\u25cf", + "leaf.fill": "\u273f", + "eye.fill": "\u25c9", + "bubble.left.fill": "\u25d1", + "music.note": "\u266a", + "music.note.list": "\u266a", + hourglass: "\u23f3", + "hourglass.bottomhalf.filled": "\u23f3", + "figure.run": "\u27a4", + "paintpalette.fill": "\u2698", + "quote.opening": "\u201c", + "bitcoinsign.circle.fill": "\u20bf", + "battery.100": "\u25ae", + iphone: "\u25af", + ipad: "\u25ad", + applewatch: "\u231a", + airpodspro: "\u25cb", + desktopcomputer: "\u25a3", + internaldrive: "\u25a0", + network: "\u2261", + "envelope.fill": "\u2709", + envelope: "\u2709", + "phone.fill": "\u260e", + phone: "\u260e", + "message.fill": "\u1f4ac", + trash: "\u1f5d1", + "trash.fill": "\u1f5d1", + folder: "\u1f4c1", + "folder.fill": "\u1f4c1", + doc: "\u1f4c4", + "doc.fill": "\u1f4c4", + photo: "\u1f5bc", + "photo.fill": "\u1f5bc", + camera: "\u1f4f7", + "camera.fill": "\u1f4f7", + map: "\u1f5fa", + "map.fill": "\u1f5fa", + cart: "\u1f6d2", + "cart.fill": "\u1f6d2", + creditcard: "\u1f4b3", + "creditcard.fill": "\u1f4b3", + wifi: "\u1f4f6", + "antenna.radiowaves.left.and.right": "\u1f4e1", + "lock.fill": "\u1f512", + lock: "\u1f512", + "lock.open.fill": "\u1f513", + "key.fill": "\u1f511", + "paperplane.fill": "\u27a4", + "arrow.right": "\u2192", + "arrow.left": "\u2190", + "arrow.up": "\u2191", + "arrow.down": "\u2193", + "chevron.right": "\u203a", + "chevron.left": "\u2039", + "chevron.up": "\u02c6", + "chevron.down": "\u02c7", + "info.circle": "\u2139", + "info.circle.fill": "\u2139", + "exclamationmark.triangle": "\u26a0", + "exclamationmark.triangle.fill": "\u26a0", + "play.fill": "\u25b6", + "pause.fill": "\u23f8", + "stop.fill": "\u23f9", + "forward.fill": "\u23e9", + "backward.fill": "\u23ea", + }; + if (map[n]) return map[n]; + // Never first-letter fallback — looks like a bug. + return "\u25CF"; +} + +export function renderImage(d: ElNode): HTMLElement { + var e = document.createElement("span"); + e.style.display = "inline-flex"; + e.style.alignItems = "center"; + e.style.justifyContent = "center"; + if (d.data) { + var img = document.createElement("img"); + img.src = + String(d.data).indexOf("data:") === 0 + ? d.data + : "data:image/png;base64," + d.data; + img.width = d.size || 24; + img.height = d.size || 24; + img.style.objectFit = d.contentMode === "fill" ? "cover" : "contain"; + e.appendChild(img); + } else if (d.url) { + var img = document.createElement("img"); + img.src = d.url; + img.width = d.size || 24; + img.height = d.size || 24; + img.style.objectFit = d.contentMode === "fill" ? "cover" : "contain"; + e.appendChild(img); + } else if (d.systemName) { + var sz = d.size || 24; + e.textContent = systemGlyph(d.systemName); + e.style.fontSize = sz + "px"; + e.style.lineHeight = "1"; + e.style.width = sz + "px"; + e.style.height = sz + "px"; + e.style.minWidth = sz + "px"; + e.style.flexShrink = "0"; + e.style.boxSizing = "border-box"; + e.style.color = d.color ? resolveColor(d.color) : "inherit"; + } else { + e.textContent = "\u25CF"; + e.style.fontSize = (d.size || 24) + "px"; + e.style.color = d.color ? resolveColor(d.color) : "inherit"; + } + applyStyle(e, d); + return e; +} + +export function renderShape(d: ElNode): HTMLElement { + var sz = d.size || 24, + fill = d.fill ? resolveColor(d.fill) : "#4CAF50"; + var stroke = d.stroke ? resolveColor(d.stroke) : undefined, + sw = d.strokeWidth || 1; + var e = document.createElement("div"); + e.style.background = fill; + e.style.flexShrink = "0"; + if (stroke) e.style.border = sw + "px solid " + stroke; + if (d.shapeType === "circle") { + e.style.width = sz + "px"; + e.style.height = sz + "px"; + e.style.borderRadius = "50%"; + } else if (d.shapeType === "capsule") { + e.style.width = sz * 2 + "px"; + e.style.height = sz + "px"; + e.style.borderRadius = sz / 2 + "px"; + } else { + e.style.width = sz + "px"; + e.style.height = sz + "px"; + if (d.cornerRadius) e.style.borderRadius = d.cornerRadius + "px"; + } + applyStyle(e, d); + return e; +} + +export function renderCanvas(d: ElNode): SVGElement { + var cw = d.width || 100, + ch = d.height || 100; + var s = svg("svg", { + width: cw, + height: ch, + viewBox: "0 0 " + cw + " " + ch, + }); + (d.elements || []).forEach(function (cmd: ElNode) { + switch (cmd.draw) { + case "circle": + s.appendChild( + svg("circle", { + cx: cmd.cx, + cy: cmd.cy, + r: cmd.r, + fill: cmd.fill ? resolveColor(cmd.fill) : "none", + stroke: cmd.stroke ? resolveColor(cmd.stroke) : "none", + "stroke-width": cmd.strokeWidth || 1, + }) + ); + break; + case "line": + s.appendChild( + svg("line", { + x1: cmd.x1, + y1: cmd.y1, + x2: cmd.x2, + y2: cmd.y2, + stroke: cmd.stroke ? resolveColor(cmd.stroke) : "#fff", + "stroke-width": cmd.strokeWidth || 1, + "stroke-linecap": cmd.lineCap || "butt", + }) + ); + break; + case "rect": + s.appendChild( + svg("rect", { + x: cmd.x, + y: cmd.y, + width: cmd.width, + height: cmd.height, + rx: cmd.cornerRadius || 0, + ry: cmd.cornerRadius || 0, + fill: cmd.fill ? resolveColor(cmd.fill) : "none", + stroke: cmd.stroke ? resolveColor(cmd.stroke) : "none", + "stroke-width": cmd.strokeWidth || 1, + }) + ); + break; + case "arc": { + var cx2 = cmd.cx || 0, + cy2 = cmd.cy || 0, + r2 = cmd.r || 10; + var sa = ((cmd.startAngle || 0) * Math.PI) / 180, + ea = ((cmd.endAngle || 360) * Math.PI) / 180; + var sx = cx2 + r2 * Math.cos(sa), + sy = cy2 + r2 * Math.sin(sa); + var ex = cx2 + r2 * Math.cos(ea), + ey = cy2 + r2 * Math.sin(ea); + var lf = ea - sa > Math.PI ? 1 : 0; + var pd = cmd.fill + ? "M" + + cx2 + + "," + + cy2 + + " L" + + sx + + "," + + sy + + " A" + + r2 + + "," + + r2 + + " 0 " + + lf + + " 1 " + + ex + + "," + + ey + + " Z" + : "M" + + sx + + "," + + sy + + " A" + + r2 + + "," + + r2 + + " 0 " + + lf + + " 1 " + + ex + + "," + + ey; + s.appendChild( + svg("path", { + d: pd, + fill: cmd.fill ? resolveColor(cmd.fill) : "none", + stroke: cmd.stroke ? resolveColor(cmd.stroke) : "none", + "stroke-width": cmd.strokeWidth || 1, + }) + ); + break; + } + case "text": { + var t = svg("text", { + x: cmd.x, + y: cmd.y, + "font-size": cmd.fontSize || 12, + fill: cmd.color ? resolveColor(cmd.color) : "#fff", + "text-anchor": + cmd.anchor === "middle" + ? "middle" + : cmd.anchor === "end" + ? "end" + : "start", + }); + t.textContent = cmd.content || ""; + s.appendChild(t); + break; + } + case "path": + s.appendChild( + svg("path", { + d: cmd.d, + fill: cmd.fill ? resolveColor(cmd.fill) : "none", + stroke: cmd.stroke ? resolveColor(cmd.stroke) : "none", + "stroke-width": cmd.strokeWidth || 1, + }) + ); + break; + } + }); + applyStyle(s, d); + return s; +} diff --git a/widget-src/render/spacing.ts b/widget-src/render/spacing.ts new file mode 100644 index 0000000..1eba2c2 --- /dev/null +++ b/widget-src/render/spacing.ts @@ -0,0 +1,30 @@ +import { applyStyle, resolveColor } from "../style"; +import type { ElNode, ParentAxis } from "../types"; + +export function renderDivider(d: ElNode, parentAxis?: ParentAxis): HTMLElement { + var e = document.createElement("hr"); + e.style.border = "none"; + var t = d.thickness || 1; + e.style.background = d.color ? resolveColor(d.color) : "#e0e0e0"; + e.style.flexShrink = "0"; + if (parentAxis === "horizontal") { + e.style.width = t + "px"; + e.style.height = "auto"; + e.style.alignSelf = "stretch"; + } else { + e.style.width = "100%"; + e.style.height = t + "px"; + // Match Android section breath around horizontal rules. + e.style.marginTop = "4px"; + e.style.marginBottom = "4px"; + } + applyStyle(e, d); + return e; +} + +export function renderSpacer(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.flex = "1"; + e.style.minHeight = (d.minLength || 0) + "px"; + return e; +} diff --git a/widget-src/render/text.ts b/widget-src/render/text.ts new file mode 100644 index 0000000..215e38c --- /dev/null +++ b/widget-src/render/text.ts @@ -0,0 +1,176 @@ +import { + applyStyle, + fw, + resolveColor, + TEXT_STYLE_BOLD, + TEXT_STYLE_SIZES, +} from "../style"; +import { timerIds } from "../ctx"; +import type { ElNode, ParentAxis } from "../types"; + +export function formatRelativeDate(date: Date): string { + var diffMs = date.getTime() - Date.now(); + var abs = Math.abs(diffMs); + var sec = Math.round(abs / 1000), + min = Math.round(sec / 60), + hr = Math.round(min / 60); + var day = Math.round(hr / 24), + mon = Math.round(day / 30), + yr = Math.round(day / 365); + var unit: Intl.RelativeTimeFormatUnit = "second", + val = sec; + if (yr >= 1) { + unit = "year"; + val = yr; + } else if (mon >= 1) { + unit = "month"; + val = mon; + } else if (day >= 1) { + unit = "day"; + val = day; + } else if (hr >= 1) { + unit = "hour"; + val = hr; + } else if (min >= 1) { + unit = "minute"; + val = min; + } + var signed = diffMs < 0 ? -val : val; + try { + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format( + signed, + unit + ); + } catch (_) { + return ( + (signed < 0 ? "" : "+") + + signed + + " " + + unit + + (Math.abs(signed) === 1 ? "" : "s") + ); + } +} + +export function renderText(d: ElNode, parentAxis?: ParentAxis): HTMLElement { + var e = document.createElement("span"); + e.textContent = d.content || ""; + var ts = d.textStyle && TEXT_STYLE_SIZES[d.textStyle]; + e.style.fontSize = (ts || d.fontSize || 14) + "px"; + e.style.fontWeight = String( + d.textStyle && TEXT_STYLE_BOLD[d.textStyle] ? 700 : fw(d.fontWeight)); + e.style.color = d.color ? resolveColor(d.color) : "inherit"; + e.style.textAlign = + d.alignment === "center" + ? "center" + : d.alignment === "trailing" + ? "right" + : "left"; + e.style.display = d.lineLimit ? "-webkit-box" : "block"; + e.style.boxSizing = "border-box"; + e.style.flexShrink = "0"; + // In hstack hug content width so siblings (divider / trailing text) stay visible. + // In vstack (or unknown axis) stretch full width so textAlign leading/center/trailing works. + if (parentAxis === "horizontal") { + e.style.width = "auto"; + } else { + e.style.width = "100%"; + e.style.alignSelf = "stretch"; + } + if (d.fontDesign === "monospaced") e.style.fontFamily = "monospace"; + else if (d.fontDesign === "serif") e.style.fontFamily = "serif"; + else if (d.fontDesign === "rounded") + e.style.fontFamily = + 'ui-rounded, "SF Pro Rounded", system-ui, sans-serif'; + if (d.lineLimit) { + (e.style as CSSStyleDeclaration & { webkitLineClamp?: string; webkitBoxOrient?: string }).webkitLineClamp = String(d.lineLimit); + (e.style as CSSStyleDeclaration & { webkitBoxOrient?: string }).webkitBoxOrient = "vertical"; + e.style.overflow = "hidden"; + } + applyStyle(e, d); + return e; +} + +export function renderDate(d: ElNode): HTMLElement { + var e = document.createElement("span"); + var date = new Date(d.date); + var style = (d.dateStyle || "").toLowerCase(); + if (style === "time") + e.textContent = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + else if (style === "date") + e.textContent = date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + else if (style === "relative" || style === "offset") + e.textContent = formatRelativeDate(date); + else if (style === "timer") { + var diff = Math.abs(date.getTime() - Date.now()); + var h = Math.floor(diff / 3600000), + m = Math.floor((diff % 3600000) / 60000), + s = Math.floor((diff % 60000) / 1000); + e.textContent = + String(h).padStart(2, "0") + + ":" + + String(m).padStart(2, "0") + + ":" + + String(s).padStart(2, "0"); + } else e.textContent = date.toLocaleString(); + e.style.fontSize = (d.fontSize || 14) + "px"; + e.style.color = d.color ? resolveColor(d.color) : "inherit"; + applyStyle(e, d); + return e; +} + +export function renderTimer(d: ElNode): HTMLElement { + var e = document.createElement("span"); + e.style.fontSize = (d.fontSize || 14) + "px"; + e.style.fontWeight = String(fw(d.fontWeight)); + e.style.color = d.color ? resolveColor(d.color) : "inherit"; + e.style.fontVariantNumeric = "tabular-nums"; + function tick() { + var target = new Date(d.targetDate).getTime(), + now = Date.now(), + diff = Math.abs(target - now); + var h = Math.floor(diff / 3600000), + m = Math.floor((diff % 3600000) / 60000), + s = Math.floor((diff % 60000) / 1000); + e.textContent = + String(h).padStart(2, "0") + + ":" + + String(m).padStart(2, "0") + + ":" + + String(s).padStart(2, "0"); + } + tick(); + timerIds.push(setInterval(tick, 1000)); + applyStyle(e, d); + return e; +} + +export function renderLabel(d: ElNode): HTMLElement { + var e = document.createElement("div"); + e.style.cssText = + "display:flex;align-items:center;gap:" + (d.spacing || 4) + "px"; + var ic = document.createElement("span"); + ic.textContent = "\u25CF"; + ic.style.fontSize = (d.fontSize || 14) * 1.1 + "px"; + ic.style.color = d.iconColor + ? resolveColor(d.iconColor) + : d.color + ? resolveColor(d.color) + : "inherit"; + e.appendChild(ic); + var t = document.createElement("span"); + t.textContent = d.text || ""; + t.style.fontSize = (d.fontSize || 14) + "px"; + t.style.fontWeight = String(fw(d.fontWeight)); + t.style.color = d.color ? resolveColor(d.color) : "inherit"; + e.appendChild(t); + applyStyle(e, d); + return e; +} diff --git a/widget-src/shell.html b/widget-src/shell.html new file mode 100644 index 0000000..3550cf6 --- /dev/null +++ b/widget-src/shell.html @@ -0,0 +1,37 @@ + + + + + + Widget + + + +
+
+
📌
+
Loading…
+
+
+ + + diff --git a/widget-src/style.ts b/widget-src/style.ts new file mode 100644 index 0000000..0f204a8 --- /dev/null +++ b/widget-src/style.ts @@ -0,0 +1,197 @@ +import { THEME } from "./ctx"; +import type { ColorValue, ElNode } from "./types"; + +export function expandHex(h: string): string { + if (h.length === 3) return h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + if (h.length === 4) + return h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3]; + return h; +} + +export function isDark(): boolean { + // Prefer explicit theme: URL ?theme= → data-theme → OS preference. + const t = + THEME || + document.documentElement.getAttribute("data-theme") || + document.documentElement.dataset?.theme || + ""; + if (t === "light") return false; + if (t === "dark") return true; + return !!( + window.matchMedia && + window.matchMedia("(prefers-color-scheme:dark)").matches + ); +} + +const SEMANTIC_COLORS: Record = { + label: ["#000000", "#FFFFFF"], + secondaryLabel: ["#3C3C43", "#EBEBF5"], + systemBackground: ["#FFFFFF", "#000000"], + secondarySystemBackground: ["#F2F2F7", "#1C1C1E"], + accent: ["#007AFF", "#0A84FF"], + separator: ["#C6C6C8", "#545458"], +}; + +export function hex(c: string | null | undefined): string { + if (!c) return "inherit"; + if (SEMANTIC_COLORS[c]) { + return isDark() ? SEMANTIC_COLORS[c][1] : SEMANTIC_COLORS[c][0]; + } + const h = expandHex(c.replace("#", "")); + if (h.length === 8) + return ( + "rgba(" + + parseInt(h.slice(0, 2), 16) + + "," + + parseInt(h.slice(2, 4), 16) + + "," + + parseInt(h.slice(4, 6), 16) + + "," + + (parseInt(h.slice(6, 8), 16) / 255).toFixed(2) + + ")" + ); + return "#" + h; +} + +export function resolveColor(cv: ColorValue | null | undefined): string { + if (!cv) return "inherit"; + if (typeof cv === "string") return hex(cv); + if (cv.light && cv.dark) return isDark() ? hex(cv.dark) : hex(cv.light); + return "inherit"; +} + +export function tintTrack(tint: ColorValue | null | undefined): string { + const resolved = tint ? resolveColor(tint) : null; + if (!resolved || resolved === "inherit") return "rgba(120,120,128,0.2)"; + const h = expandHex(resolved.replace("#", "")); + if (h.length >= 6) { + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return "rgba(" + r + "," + g + "," + b + ",0.2)"; + } + return "rgba(120,120,128,0.2)"; +} + +export const TEXT_STYLE_SIZES: Record = { + largeTitle: 34, + title: 28, + title2: 22, + title3: 20, + headline: 17, + subheadline: 15, + body: 17, + callout: 16, + footnote: 13, + caption: 12, + caption2: 11, +}; +export const TEXT_STYLE_BOLD: Record = { headline: true }; + +export function applyStyle(el: HTMLElement | SVGElement, d: ElNode): void { + if (d.padding != null) { + if (typeof d.padding === "number") el.style.padding = d.padding + "px"; + else { + el.style.paddingTop = (d.padding.top || 0) + "px"; + el.style.paddingBottom = (d.padding.bottom || 0) + "px"; + el.style.paddingLeft = (d.padding.leading || 0) + "px"; + el.style.paddingRight = (d.padding.trailing || 0) + "px"; + } + } + if (d.background) { + if (typeof d.background === "string") el.style.background = hex(d.background); + else if (d.background.colors) { + const g = d.background; + const cols = g.colors.map(hex).join(", "); + const dirs: Record = { + topToBottom: "to bottom", + bottomToTop: "to top", + leadingToTrailing: "to right", + trailingToLeading: "to left", + topLeadingToBottomTrailing: "to bottom right", + topTrailingToBottomLeading: "to bottom left", + }; + if (g.gradientType === "radial") + el.style.background = "radial-gradient(circle, " + cols + ")"; + else if (g.gradientType === "angular") + el.style.background = "conic-gradient(" + cols + ")"; + else + el.style.background = + "linear-gradient(" + (dirs[g.direction] || "to bottom") + ", " + cols + ")"; + } else if (d.background.light && d.background.dark) { + el.style.background = isDark() + ? hex(d.background.dark) + : hex(d.background.light); + } + } + if (d.cornerRadius) { + el.style.borderRadius = d.cornerRadius + "px"; + el.style.overflow = "hidden"; + } + if (d.opacity != null) el.style.opacity = String(d.opacity); + if (d.border) + el.style.border = + (d.border.width || 1) + "px solid " + hex(d.border.color); + if (d.shadow) { + const s = d.shadow; + el.style.boxShadow = + (s.x || 0) + + "px " + + (s.y || 2) + + "px " + + (s.radius || 4) + + "px " + + (s.color ? hex(s.color) : "rgba(0,0,0,.3)"); + } + if (d.frame) { + if (d.frame.width) { + el.style.width = d.frame.width + "px"; + el.style.minWidth = d.frame.width + "px"; + el.style.flexShrink = "0"; + } + if (d.frame.height) { + el.style.height = d.frame.height + "px"; + el.style.minHeight = d.frame.height + "px"; + } + if (d.frame.maxWidth === "infinity") el.style.maxWidth = "100%"; + else if (d.frame.maxWidth) el.style.maxWidth = d.frame.maxWidth + "px"; + } + if (d.clipShape) { + el.style.overflow = "hidden"; + if (d.clipShape === "circle") el.style.borderRadius = "50%"; + else if (d.clipShape === "capsule") el.style.borderRadius = "9999px"; + else if (d.clipShape === "rectangle" && d.cornerRadius) + el.style.borderRadius = d.cornerRadius + "px"; + } + if (d.flex && d.flex > 0) { + el.style.flex = String(d.flex); + el.style.minWidth = "0"; + } +} + +export function fw(w: string | null | undefined): number { + return ( + ( + { + ultralight: 100, + thin: 200, + light: 300, + regular: 400, + medium: 500, + semibold: 600, + bold: 700, + heavy: 800, + black: 900, + } as Record + )[w || ""] || 400 + ); +} + +export function svg( + tag: string, + a: Record, +): SVGElement { + const e = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const k in a) if (a[k] != null) e.setAttribute(k, String(a[k])); + return e; +} diff --git a/widget-src/tsconfig.json b/widget-src/tsconfig.json new file mode 100644 index 0000000..d5f2c58 --- /dev/null +++ b/widget-src/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noImplicitAny": true, + "declaration": false, + "rootDir": ".", + "outDir": ".", + "noEmit": true + }, + "include": ["./**/*.ts", "./**/*.d.ts"], + "exclude": [".bundle.js", "node_modules"] +} diff --git a/widget-src/types.ts b/widget-src/types.ts new file mode 100644 index 0000000..39d716d --- /dev/null +++ b/widget-src/types.ts @@ -0,0 +1,28 @@ +/** Dynamic widget IR node from `setWidgetConfig` JSON. */ +export type ElNode = { + type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +export type ParentAxis = "horizontal" | "vertical" | undefined; + +export type ColorValue = + | string + | { + light?: string; + dark?: string; + colors?: string[]; + gradientType?: string; + direction?: string; + }; + +export type WidgetConfig = { + small?: ElNode; + medium?: ElNode; + large?: ElNode; + __nonce?: number; + [size: string]: ElNode | number | undefined; +}; + +export type SkippedElement = { type: string; reason: string }; diff --git a/widget.html b/widget.html index 59757ba..00ebf0b 100644 --- a/widget.html +++ b/widget.html @@ -33,699 +33,1487 @@