chore: add svelte component layer with monaco editor
This commit is contained in:
1 parent
8071085e94
commit
54f7f81160
55 files changed
+20563
-2043
No files matched your search
@@ -24,8 +24,8 @@ It enhances the osu! experience with quality-of-life features and integration sp
|
||||
- [x] Discord Rich Presence
|
||||
- [x] Performance Display Overlay (currently in experimental patcher release stream)
|
||||
- [x] Launcher Theming (API 1.0 bundles and API 2.0 declarative themes)
|
||||
- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, multi-select and snapping, layer locks, draft recovery, validation and export
|
||||
- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes
|
||||
- [x] Advanced theme editor in its own window with freeform text/image/video/shape layers, custom Svelte 5 component layers with Monaco editing and inline compiler diagnostics, gradient/outline/shadow text effects, multi-select and snapping, a drag-and-drop layer tree with moves into and out of nested groups, layer/group transforms with independent X/Y scale and skew, hover group breadcrumbs, on-canvas layer/group rotate handles plus group move and scale handles, layer/group-linked WHEN/IF/THEN logic with quick navigation, safe advanced rule scripts with expressions, branches and loops, draft recovery, validation and export
|
||||
- [x] Theme update backups with one-click rollback, plus a trust prompt before running executable API 1.0 themes or API 2.0 themes with custom Svelte layers
|
||||
- [x] In-app diagnostics for osu!, Wine, the patcher, and the experimental stream's .NET 8 runtime, plus a log-bundle viewer
|
||||
|
||||
---
|
||||
@@ -141,8 +141,23 @@ in two ways:
|
||||
|
||||
- Use the built-in **advanced editor** on the Themes screen. It opens in its own
|
||||
resizable window with a full launcher home canvas, freeform text/image/video/shape
|
||||
layers, WebM support, `@fontsource` fonts and custom font imports. It creates API 2.0
|
||||
themes and can export them as shareable `.ezpplauncher-theme` files.
|
||||
layers, custom Svelte 5 components with a Monaco editor, rich text effects
|
||||
(gradient fills, outlines, shadows, decoration and case), WebM support,
|
||||
`@fontsource` fonts and custom font imports. Themes can switch the launcher
|
||||
chrome between the bundled Lucide, Phosphor and Tabler icon families. Hover a
|
||||
layer and click a crumb in its group path (or double-click it), then use the
|
||||
on-canvas frame or the inspector to move, rotate, scale or skew the group.
|
||||
Logic rules can be linked to layers or groups and edited from their inspectors.
|
||||
Any rule can switch from the visual builder to a sandboxed JavaScript script
|
||||
with variables, expressions, `if/else`, `for`/`while`, visibility actions and
|
||||
navigation; scripts open in a Monaco editor with syntax highlighting,
|
||||
completion, hover docs and inline errors, and can expand to fullscreen. Text
|
||||
layers can display live variables with `{name}` placeholders and theme-defined
|
||||
starting values, plus read-only clock values such as `{time}` and `{hour}` that
|
||||
refresh every second. Scripts are compiled into a safe instruction set and run by
|
||||
the launcher without `eval`.
|
||||
It creates API 2.0 themes and can export them as shareable
|
||||
`.ezpplauncher-theme` files.
|
||||
- Use the [create-ezpplauncher-theme](https://git.ez-pp.farm/EZPPFarm/create-ezpplauncher-theme)
|
||||
CLI. It supports both API 1.0 Svelte themes and API 2.0 declarative themes.
|
||||
|
||||
|
||||
+1025
-52
File diff suppressed because it is too large.
Load diff
@@ -19,10 +19,13 @@
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@types/node": "^25.9.1",
|
||||
"acorn": "^8.19.0",
|
||||
"animejs": "^4.4.1",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"semver": "^7.8.1",
|
||||
@@ -35,6 +38,9 @@
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@iconify-json/lucide": "^1.2.149",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/tabler": "^1.2.43",
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
@@ -142,6 +148,14 @@
|
||||
|
||||
"@iarna/toml": ["@iarna/[email protected]", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="],
|
||||
|
||||
"@iconify-json/lucide": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-5NG9YdbtRS03hG1A+EIE6nLhhdAYsf0mKu1ZRi6kLMlCG8DxmDsRM6DAhwlNMLiSiQKFbHpKTJ2VddZdwUkUyw=="],
|
||||
|
||||
"@iconify-json/ph": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw=="],
|
||||
|
||||
"@iconify-json/tabler": ["@iconify-json/[email protected]", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-EKhyOj6nASi25ZjuRpEhPL1fwiKVtg/e/VX6v6X3FPKpjG9og26ypW8ItnWYSTn6fdNuQe3VPeyuThWlP6QuWw=="],
|
||||
|
||||
"@iconify/types": ["@iconify/[email protected]", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -346,7 +360,7 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="],
|
||||
|
||||
"acorn": ["[email protected]6.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["[email protected]9.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
|
||||
|
||||
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -402,6 +416,8 @@
|
||||
|
||||
"devalue": ["[email protected]", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
||||
|
||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
@@ -544,14 +560,20 @@
|
||||
|
||||
"lodash-es": ["[email protected]", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"lottie-web": ["[email protected]", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
|
||||
|
||||
"lz-string": ["[email protected]", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"monaco-editor": ["[email protected]", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||
|
||||
"motion": ["[email protected]", "", { "dependencies": { "framer-motion": "^12.34.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N9RVNGn/NSo85OgHX1wGaUWHvReuQ7dZUwuQRhHyzY2wfVOvY3cEgn0Mw4NXOsXMHL/y7EYuzA+b59PYI6EejA=="],
|
||||
|
||||
"motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-SC7ZC5dRcGwku2g7EsPvI4q/EzHumUbqsDNumBmZTLFg+goBO5LTJvDu9MAxx+0mtX4IA78B2be/A3aRjY0jnw=="],
|
||||
@@ -778,6 +800,8 @@
|
||||
|
||||
"@parcel/watcher/detect-libc": ["[email protected]", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
|
||||
|
||||
"@sveltejs/kit/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -810,10 +834,14 @@
|
||||
|
||||
"eslint-plugin-svelte/globals": ["[email protected]", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
|
||||
|
||||
"espree/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"sass/chokidar": ["[email protected]", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"svelte/acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"svelte-eslint-parser/eslint-scope": ["[email protected]", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"svelte-eslint-parser/eslint-visitor-keys": ["[email protected]", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"tauri:build": "tauri build",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"icons:build": "bun ./scripts/generate-theme-icons.ts",
|
||||
"tauri": "tauri",
|
||||
"tauri:update-logo": "bun run tauri icon ./static/logo.png",
|
||||
"format": "prettier --write .",
|
||||
@@ -32,10 +33,13 @@
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@types/node": "^25.9.1",
|
||||
"acorn": "^8.19.0",
|
||||
"animejs": "^4.4.1",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lottie-web": "^5.13.0",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"semver": "^7.8.1",
|
||||
@@ -48,6 +52,9 @@
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iarna/toml": "2.2.5",
|
||||
"@iconify-json/lucide": "^1.2.149",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/tabler": "^1.2.43",
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { THEME_ICON_NAMES, type ThemeIconName, type ThemeIconSet } from '../src/lib/themeIcons';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type IconifyCollection = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
icons: Record<string, { body: string; width?: number; height?: number }>;
|
||||
aliases?: Record<string, { parent: string }>;
|
||||
};
|
||||
|
||||
type IconBody = {
|
||||
width: number;
|
||||
height: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Semantic name -> Iconify icon id per family. Keeping this explicit means a
|
||||
* family swap can never pick a random similarly named icon from another set.
|
||||
*/
|
||||
const ICON_IDS: Record<ThemeIconName, Record<ThemeIconSet, string>> = {
|
||||
home: { lucide: 'house', phosphor: 'house', tabler: 'home' },
|
||||
themes: { lucide: 'palette', phosphor: 'palette', tabler: 'palette' },
|
||||
settings: {
|
||||
lucide: 'sliders-horizontal',
|
||||
phosphor: 'sliders-horizontal',
|
||||
tabler: 'adjustments-horizontal',
|
||||
},
|
||||
launch: { lucide: 'play', phosphor: 'play', tabler: 'player-play' },
|
||||
user: { lucide: 'user', phosphor: 'user', tabler: 'user' },
|
||||
music: { lucide: 'music', phosphor: 'music-notes', tabler: 'music' },
|
||||
brush: { lucide: 'brush', phosphor: 'paint-brush', tabler: 'brush' },
|
||||
wifi: { lucide: 'wifi', phosphor: 'wifi-high', tabler: 'wifi' },
|
||||
'wifi-off': { lucide: 'wifi-off', phosphor: 'wifi-slash', tabler: 'wifi-off' },
|
||||
gameplay: { lucide: 'gamepad-2', phosphor: 'game-controller', tabler: 'device-gamepad-2' },
|
||||
integrations: { lucide: 'plug', phosphor: 'plugs', tabler: 'plug' },
|
||||
installation: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
|
||||
installed: { lucide: 'hard-drive', phosphor: 'hard-drives', tabler: 'server' },
|
||||
diagnostics: { lucide: 'shield-check', phosphor: 'shield-check', tabler: 'shield-check' },
|
||||
logs: { lucide: 'scroll-text', phosphor: 'scroll', tabler: 'file-text' },
|
||||
'all-themes': { lucide: 'layout-grid', phosphor: 'squares-four', tabler: 'layout-grid' },
|
||||
available: {
|
||||
lucide: 'cloud-download',
|
||||
phosphor: 'cloud-arrow-down',
|
||||
tabler: 'cloud-download',
|
||||
},
|
||||
updates: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
|
||||
search: { lucide: 'search', phosphor: 'magnifying-glass', tabler: 'search' },
|
||||
folder: { lucide: 'folder-open', phosphor: 'folder-open', tabler: 'folder-open' },
|
||||
plus: { lucide: 'plus', phosphor: 'plus', tabler: 'plus' },
|
||||
'osu-standard': { lucide: 'circle', phosphor: 'circle', tabler: 'circle' },
|
||||
'osu-taiko': { lucide: 'drum', phosphor: 'music-notes', tabler: 'music' },
|
||||
'osu-catch': { lucide: 'cherry', phosphor: 'orange-slice', tabler: 'cherry' },
|
||||
'osu-mania': { lucide: 'piano', phosphor: 'piano-keys', tabler: 'piano' },
|
||||
login: { lucide: 'log-in', phosphor: 'sign-in', tabler: 'login' },
|
||||
logout: { lucide: 'log-out', phosphor: 'sign-out', tabler: 'logout' },
|
||||
warning: { lucide: 'triangle-alert', phosphor: 'warning', tabler: 'alert-triangle' },
|
||||
info: { lucide: 'info', phosphor: 'info', tabler: 'info-circle' },
|
||||
loader: { lucide: 'loader-circle', phosphor: 'circle-notch', tabler: 'loader' },
|
||||
ellipsis: { lucide: 'ellipsis', phosphor: 'dots-three', tabler: 'dots' },
|
||||
check: { lucide: 'check', phosphor: 'check', tabler: 'check' },
|
||||
trash: { lucide: 'trash', phosphor: 'trash', tabler: 'trash' },
|
||||
edit: { lucide: 'pencil', phosphor: 'pencil-simple', tabler: 'pencil' },
|
||||
history: { lucide: 'history', phosphor: 'clock-counter-clockwise', tabler: 'history' },
|
||||
refresh: { lucide: 'refresh-cw', phosphor: 'arrows-clockwise', tabler: 'refresh' },
|
||||
download: { lucide: 'cloud-download', phosphor: 'cloud-arrow-down', tabler: 'cloud-download' },
|
||||
import: { lucide: 'import', phosphor: 'upload-simple', tabler: 'upload' },
|
||||
'arrow-right': { lucide: 'arrow-right', phosphor: 'arrow-right', tabler: 'arrow-right' },
|
||||
eye: { lucide: 'eye', phosphor: 'eye', tabler: 'eye' },
|
||||
'eye-off': { lucide: 'eye-off', phosphor: 'eye-slash', tabler: 'eye-off' },
|
||||
wrench: { lucide: 'wrench', phosphor: 'wrench', tabler: 'tool' },
|
||||
rotate: { lucide: 'rotate-cw', phosphor: 'arrows-clockwise', tabler: 'rotate-clockwise' },
|
||||
volume: { lucide: 'volume-2', phosphor: 'speaker-high', tabler: 'volume' },
|
||||
'volume-low': { lucide: 'volume-1', phosphor: 'speaker-low', tabler: 'volume-2' },
|
||||
'volume-off': { lucide: 'volume-x', phosphor: 'speaker-slash', tabler: 'volume-off' },
|
||||
};
|
||||
|
||||
const collectionPaths: Record<ThemeIconSet, string> = {
|
||||
lucide: '../node_modules/@iconify-json/lucide/icons.json',
|
||||
phosphor: '../node_modules/@iconify-json/ph/icons.json',
|
||||
tabler: '../node_modules/@iconify-json/tabler/icons.json',
|
||||
};
|
||||
|
||||
const loadCollection = (set: ThemeIconSet): IconifyCollection =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(collectionPaths[set], import.meta.url)), 'utf8'));
|
||||
|
||||
const resolveIcon = (collection: IconifyCollection, id: string): IconBody | undefined => {
|
||||
const direct = collection.icons[id];
|
||||
if (direct) {
|
||||
return {
|
||||
width: direct.width ?? collection.width ?? 24,
|
||||
height: direct.height ?? collection.height ?? 24,
|
||||
body: direct.body,
|
||||
};
|
||||
}
|
||||
|
||||
const alias = collection.aliases?.[id];
|
||||
if (!alias) return undefined;
|
||||
const parent = collection.icons[alias.parent];
|
||||
if (!parent) return undefined;
|
||||
return {
|
||||
width: parent.width ?? collection.width ?? 24,
|
||||
height: parent.height ?? collection.height ?? 24,
|
||||
body: parent.body,
|
||||
};
|
||||
};
|
||||
|
||||
const collections = Object.fromEntries(
|
||||
(Object.keys(collectionPaths) as ThemeIconSet[]).map((set) => [set, loadCollection(set)])
|
||||
) as Record<ThemeIconSet, IconifyCollection>;
|
||||
|
||||
const data = {} as Record<ThemeIconSet, Record<ThemeIconName, IconBody>>;
|
||||
for (const set of Object.keys(collectionPaths) as ThemeIconSet[]) {
|
||||
data[set] = {} as Record<ThemeIconName, IconBody>;
|
||||
for (const name of THEME_ICON_NAMES) {
|
||||
const id = ICON_IDS[name]?.[set];
|
||||
if (!id) throw new Error(`No ${set} icon mapped for semantic name "${name}".`);
|
||||
const icon = resolveIcon(collections[set], id);
|
||||
if (!icon) throw new Error(`Icon "${id}" from ${set} was not found for "${name}".`);
|
||||
data[set][name] = icon;
|
||||
}
|
||||
}
|
||||
|
||||
const output = `// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
|
||||
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
|
||||
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
|
||||
|
||||
export type ThemeIconBody = { width: number; height: number; body: string };
|
||||
|
||||
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = ${JSON.stringify(
|
||||
data,
|
||||
null,
|
||||
2
|
||||
)};
|
||||
`;
|
||||
|
||||
const outputPath = fileURLToPath(new URL('../src/lib/themeIconData.ts', import.meta.url));
|
||||
writeFileSync(outputPath, output);
|
||||
console.log(
|
||||
`Generated ${THEME_ICON_NAMES.length} semantic icons for ${Object.keys(collectionPaths).length} sets.`
|
||||
);
|
||||
+96
-14
@@ -15,6 +15,7 @@ use tauri::Manager;
|
||||
use tauri::State;
|
||||
use tauri::WebviewUrl;
|
||||
use tauri::WebviewWindowBuilder;
|
||||
use tauri::WindowEvent;
|
||||
use tauri_plugin_fs::FsExt;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -1599,12 +1600,14 @@ pub fn theme_preview_url(path: String) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a hardware-decodable copy of a large VP9/AV1 theme video.
|
||||
/// Return a hardware-decodable copy of a VP9/AV1 theme video.
|
||||
///
|
||||
/// WebKit on macOS decodes VP9 in software. A 1080p 10 Mbps WebM can use
|
||||
/// enough CPU to starve the audio decoder, while H.264 is decoded by the
|
||||
/// media engine. The first playback of a large theme video is transcoded to a
|
||||
/// cached 720p H.264/AAC file; later loads return the cached URL directly.
|
||||
/// WebKit on macOS decodes VP9/AV1 in software. A 1080p WebM can use enough
|
||||
/// CPU to starve the audio decoder, while H.264 is decoded by the media
|
||||
/// engine. The first playback of an installed theme video or a video picked
|
||||
/// in the editor is transcoded to a cached 720p H.264/AAC file; later loads
|
||||
/// return the cached URL directly. Videos already encoded as H.264/HEVC, and
|
||||
/// very small clips, are left untouched.
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ThemeMediaProgress {
|
||||
@@ -1615,6 +1618,12 @@ pub struct ThemeMediaProgress {
|
||||
#[cfg(target_os = "macos")]
|
||||
const FFMPEG_VERSION: &str = "9.0.2";
|
||||
|
||||
/// Small clips are usually short stickers; transcoding them would cost more
|
||||
/// time and disk than it saves. Anything larger may be a 1080p VP9 stream that
|
||||
/// WebKit cannot decode smoothly in software.
|
||||
#[cfg(target_os = "macos")]
|
||||
const THEME_VIDEO_OPTIMIZE_MIN_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
struct FfmpegBuild {
|
||||
url: &'static str,
|
||||
@@ -1659,7 +1668,13 @@ pub async fn optimize_theme_media(app: AppHandle, source_url: String) -> Result<
|
||||
return Ok(source_url);
|
||||
}
|
||||
};
|
||||
if metadata.len() < 20 * 1024 * 1024 {
|
||||
if metadata.len() < THEME_VIDEO_OPTIMIZE_MIN_BYTES {
|
||||
log::debug!(
|
||||
target: "launcher",
|
||||
"Skipping theme video optimization for {} ({} bytes)",
|
||||
source.display(),
|
||||
metadata.len()
|
||||
);
|
||||
return Ok(source_url);
|
||||
}
|
||||
|
||||
@@ -2155,17 +2170,34 @@ pub async fn open_theme_editor(app: AppHandle, theme_folder: Option<String>) ->
|
||||
let _ = window.set_focus();
|
||||
app.emit_to("theme-editor", "theme-editor-context", ())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _ = app.emit_to("main", "theme-editor-state", true);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into()))
|
||||
.title("EZPPLauncher Theme Editor")
|
||||
.inner_size(1440.0, 900.0)
|
||||
.min_inner_size(1100.0, 720.0)
|
||||
.resizable(true)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| format!("Could not open the theme editor: {error}"))?;
|
||||
let window =
|
||||
WebviewWindowBuilder::new(&app, "theme-editor", WebviewUrl::App("index.html".into()))
|
||||
.title("EZPPLauncher Theme Editor")
|
||||
.inner_size(1440.0, 900.0)
|
||||
.min_inner_size(1100.0, 720.0)
|
||||
.resizable(true)
|
||||
// Tauri's native file-drop handler intercepts HTML5 drag events on
|
||||
// macOS/WebKit. The editor only needs HTML5 drag and drop for its layer
|
||||
// tree, while the main launcher keeps native theme-file dropping.
|
||||
.disable_drag_drop_handler()
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|error| format!("Could not open the theme editor: {error}"))?;
|
||||
|
||||
// Pause the launcher's own theme media while the editor window exists. The
|
||||
// editor is a separate WebView, so the launcher needs an explicit signal
|
||||
// rather than relying on focus/blur events.
|
||||
let main_window = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if matches!(event, WindowEvent::Destroyed) {
|
||||
let _ = main_window.emit_to("main", "theme-editor-state", false);
|
||||
}
|
||||
});
|
||||
let _ = app.emit_to("main", "theme-editor-state", true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2531,6 +2563,49 @@ fn collect_theme_asset_refs(value: &Value, assets: &mut HashSet<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_svelte_sources(value: &Value, sources: &mut Vec<String>) {
|
||||
let Some(elements) = value.get("elements").and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
for element in elements {
|
||||
if element.get("type").and_then(Value::as_str) != Some("svelte") {
|
||||
continue;
|
||||
}
|
||||
if let Some(source) = element.get("source").and_then(Value::as_str) {
|
||||
sources.push(source.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom Svelte layers load assets through `asset("path")`, which is not a
|
||||
/// `src` field in theme.json. Keep any asset whose relative path appears in a
|
||||
/// component source so exports do not drop files used by custom layers.
|
||||
fn collect_svelte_asset_refs(assets_root: &Path, sources: &[String], assets: &mut HashSet<String>) {
|
||||
if sources.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(assets_root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_svelte_asset_refs(&path, sources, assets);
|
||||
continue;
|
||||
}
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(relative) = path.strip_prefix(assets_root) else {
|
||||
continue;
|
||||
};
|
||||
let key = relative.to_string_lossy().replace('\\', "/");
|
||||
if sources.iter().any(|source| source.contains(&key)) {
|
||||
assets.insert(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For API 2.0 packages, return the assets referenced by `theme.json` so an
|
||||
/// export can omit orphaned files left behind by older edits. API 1.0 themes
|
||||
/// can load assets dynamically from their bundle, so they are exported whole.
|
||||
@@ -2547,6 +2622,13 @@ fn referenced_theme_assets(theme_json: &Path) -> Option<HashSet<String>> {
|
||||
// `preview` is omitted from theme.json.
|
||||
assets.insert("preview.png".to_string());
|
||||
collect_theme_asset_refs(&parsed, &mut assets);
|
||||
|
||||
if let Some(folder) = theme_json.parent() {
|
||||
let assets_root = folder.join("assets");
|
||||
let mut sources = Vec::new();
|
||||
collect_svelte_sources(&parsed, &mut sources);
|
||||
collect_svelte_asset_refs(&assets_root, &sources, &mut assets);
|
||||
}
|
||||
Some(assets)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,8 +134,9 @@ pub fn media_root() -> Result<PathBuf, String> {
|
||||
.map_err(|error| format!("Failed to resolve the theme media cache: {error}"))
|
||||
}
|
||||
|
||||
/// Map a loopback theme URL back to the file it serves. Only paths inside the
|
||||
/// configured themes folder are accepted.
|
||||
/// Map a loopback theme URL back to the file it serves. Installed theme files
|
||||
/// are restricted to the themes folder; editor preview URLs must match a token
|
||||
/// registered through `preview_url`.
|
||||
pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
|
||||
let server = SERVER.get()?;
|
||||
let url = url::Url::parse(value).ok()?;
|
||||
@@ -149,8 +150,16 @@ pub fn resolve_theme_url(value: &str) -> Option<PathBuf> {
|
||||
let path = percent_encoding::percent_decode_str(url.path())
|
||||
.decode_utf8_lossy()
|
||||
.to_string();
|
||||
let relative = path.strip_prefix("/themes/")?;
|
||||
safe_path(&server.root, relative)
|
||||
if let Some(relative) = path.strip_prefix("/themes/") {
|
||||
return safe_path(&server.root, relative);
|
||||
}
|
||||
|
||||
let token = path.strip_prefix("/preview/")?;
|
||||
server
|
||||
.previews
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|previews| previews.get(token).cloned())
|
||||
}
|
||||
|
||||
/// Public URL for a file in the optimized media cache.
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"security": {
|
||||
"csp": {
|
||||
"default-src": "'self' tauri: customprotocol: asset:",
|
||||
"script-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://analytics.ez-pp.farm",
|
||||
"worker-src": ["'self'", "blob:"],
|
||||
"style-src": "'self' 'unsafe-inline' asset: http://asset.localhost https://asset.localhost http://127.0.0.1:*",
|
||||
"img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http://127.0.0.1:* https://assets.ppy.sh https://a.ez-pp.farm https://git.ez-pp.farm https://osu.direct",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost http://127.0.0.1:* https://ez-pp.farm https://api.ez-pp.farm https://c.ez-pp.farm https://analytics.ez-pp.farm https://git.ez-pp.farm https://osu.direct https://osu.ppy.sh https://api.fontsource.org https://cdn.jsdelivr.net",
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!visible || !src) return;
|
||||
if (!visible) return;
|
||||
if (!src) {
|
||||
renderedFamily = family;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const previewFamily = `EZPPSourcePreview_${previewCount++}`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,764 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
analyzeThemeSvelteSource,
|
||||
type ThemeSvelteAnalysis,
|
||||
type ThemeSvelteDiagnostic,
|
||||
} from '$lib/themeSvelte';
|
||||
import {
|
||||
THEME_SVELTE_COMPONENT_PLACEHOLDER,
|
||||
THEME_SVELTE_COMPONENT_SKELETON,
|
||||
} from '$lib/themeSvelteSnippets';
|
||||
import { Component, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
height = 320,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-svelte';
|
||||
const THEME_ID = 'ezpp-theme-svelte-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-svelte';
|
||||
// Keep these split so the Svelte parser cannot mistake a string literal for
|
||||
// the end of this component's own script block.
|
||||
const SCRIPT_OPEN = '<scr' + 'ipt>';
|
||||
const SCRIPT_CLOSE = '</scr' + 'ipt>';
|
||||
const STYLE_OPEN = '<sty' + 'le>';
|
||||
const STYLE_CLOSE = '</sty' + 'le>';
|
||||
|
||||
const propDocs: { label: string; documentation: string; insert: string }[] = [
|
||||
{
|
||||
label: '$props',
|
||||
insert:
|
||||
'let { variables = {}, layer = {}, theme = {}, asset = (path) => path, screen = "home" } = $props();',
|
||||
documentation:
|
||||
'Receive live theme props: `variables`, `layer`, `theme`, `screen` and the `asset(path)` helper.',
|
||||
},
|
||||
{
|
||||
label: 'variables',
|
||||
insert: 'variables.${1:name}',
|
||||
documentation: 'Live rule variables. They update when a Logic rule changes a variable.',
|
||||
},
|
||||
{
|
||||
label: 'layer',
|
||||
insert: 'layer.${1:width}',
|
||||
documentation:
|
||||
'The layer bounds: `layer.id`, `layer.name`, `layer.width` and `layer.height`.',
|
||||
},
|
||||
{
|
||||
label: 'theme',
|
||||
insert: 'theme.${1:name}',
|
||||
documentation: 'Theme metadata: `theme.name`, `theme.author` and `theme.accentColor`.',
|
||||
},
|
||||
{
|
||||
label: 'asset',
|
||||
insert: 'asset("${1:image.png}")',
|
||||
documentation:
|
||||
'Resolve a file inside the theme `assets/` folder. Works in the launcher and editor preview.',
|
||||
},
|
||||
{
|
||||
label: 'screen',
|
||||
insert: 'screen',
|
||||
documentation: 'The current launcher screen. Custom layers only render on Home today.',
|
||||
},
|
||||
{
|
||||
label: '$state',
|
||||
insert: 'let ${1:value} = $state(${2:0});',
|
||||
documentation: 'Create reactive component state.',
|
||||
},
|
||||
{
|
||||
label: '$derived',
|
||||
insert: 'const ${1:value} = $derived(${2:expression});',
|
||||
documentation: 'Create a value derived from other reactive state.',
|
||||
},
|
||||
{
|
||||
label: '$effect',
|
||||
insert: '$effect(() => {\n\t$0\n});',
|
||||
documentation: 'Run a side effect when reactive values change.',
|
||||
},
|
||||
{
|
||||
label: 'onMount',
|
||||
insert: 'import { onMount } from "svelte";\n\nonMount(() => {\n\t$0\n});',
|
||||
documentation: 'Run code after the component mounts. Import it from `svelte`.',
|
||||
},
|
||||
{
|
||||
label: 'onDestroy',
|
||||
insert: 'import { onDestroy } from "svelte";\n\nonDestroy(() => {\n\t$0\n});',
|
||||
documentation: 'Clean up timers, listeners and observers before the layer unmounts.',
|
||||
},
|
||||
{
|
||||
label: 'tick',
|
||||
insert: 'import { tick } from "svelte";\n\nawait tick();',
|
||||
documentation: 'Wait for pending DOM updates.',
|
||||
},
|
||||
{
|
||||
label: 'import transition',
|
||||
insert: 'import { ${1:fade} } from "svelte/transition";',
|
||||
documentation:
|
||||
'Import transitions such as `fade`, `fly`, `slide`, `scale`, `blur` and `draw` from `svelte/transition`.',
|
||||
},
|
||||
{
|
||||
label: 'import store',
|
||||
insert: 'import { ${1:writable} } from "svelte/store";',
|
||||
documentation: 'Svelte stores are available from `svelte/store`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteMap',
|
||||
insert: 'import { SvelteMap } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteMap();',
|
||||
documentation: 'Reactive Map from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: 'SvelteSet',
|
||||
insert: 'import { SvelteSet } from "svelte/reactivity";\n\nlet ${1:items} = new SvelteSet();',
|
||||
documentation: 'Reactive Set from `svelte/reactivity`.',
|
||||
},
|
||||
{
|
||||
label: '{#if}',
|
||||
insert: '{#if ${1:condition}}\n\t$0\n{/if}',
|
||||
documentation: 'Conditionally render markup.',
|
||||
},
|
||||
{
|
||||
label: '{#each}',
|
||||
insert: '{#each ${1:items} as ${2:item}}\n\t$0\n{/each}',
|
||||
documentation: 'Render a list. Add `(${3:item.id})` for a keyed each block.',
|
||||
},
|
||||
{
|
||||
label: '{#snippet}',
|
||||
insert: '{#snippet ${1:name}(${2:argument})}\n\t$0\n{/snippet}',
|
||||
documentation: 'Declare a reusable snippet and render it with `{@render name(argument)}`.',
|
||||
},
|
||||
{
|
||||
label: '{@render}',
|
||||
insert: '{@render ${1:name}(${2:argument})}',
|
||||
documentation: 'Render a snippet declared in this component.',
|
||||
},
|
||||
{
|
||||
label: SCRIPT_OPEN,
|
||||
insert: `${SCRIPT_OPEN}\n\t$0\n${SCRIPT_CLOSE}`,
|
||||
documentation: 'Plain JavaScript only. TypeScript is not preprocessed in theme layers.',
|
||||
},
|
||||
{
|
||||
label: STYLE_OPEN,
|
||||
insert: `${STYLE_OPEN}\n\t$0\n${STYLE_CLOSE}`,
|
||||
documentation: 'Scoped CSS for this component.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let warningCount = $state(0);
|
||||
let checking = $state(false);
|
||||
let diagnostics = $state<ThemeSvelteDiagnostic[]>([]);
|
||||
let currentValue = '';
|
||||
let validateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let validationToken = 0;
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { blockComment: ['<!--', '-->'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /<[^/!][^>]*>\s*$|\{[#:@][^}]*$/,
|
||||
decreaseIndentPattern: /^\s*<\/|\{\/|\{:else/,
|
||||
},
|
||||
});
|
||||
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/<!--/, { token: 'comment', next: '@comment' }],
|
||||
[/<scr(?:ipt)\b[^>]*>/, { token: 'tag', next: '@script' }],
|
||||
[/<sty(?:le)\b[^>]*>/, { token: 'tag', next: '@style' }],
|
||||
[/<\/?[\w:-]+/, 'tag'],
|
||||
[/[a-zA-Z_][\w-]*(?=\s*=)/, 'attribute.name'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/\{[#:/@][^}]*\}/, 'keyword'],
|
||||
[/\{[^}]*\}/, 'expression'],
|
||||
[/[<>/=]/, 'delimiter'],
|
||||
[/[^<{]+/, 'text'],
|
||||
[/[<{]/, 'delimiter'],
|
||||
],
|
||||
comment: [
|
||||
[/[^-]+/, 'comment'],
|
||||
[/-->/, { token: 'comment', next: '@pop' }],
|
||||
[/./, 'comment'],
|
||||
],
|
||||
script: [
|
||||
[/<\/script\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[
|
||||
/\b(?:import|from|let|const|var|if|else|for|while|return|function|async|await|new|typeof|export|class|extends|try|catch|finally|throw|break|continue|switch|case|default|of|in)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\$(?:state|derived|effect|props|bindable|inspect)(?:\.\w+)?/, 'predefined'],
|
||||
[
|
||||
/\b(?:onMount|onDestroy|tick|mount|unmount|fade|fly|slide|scale|blur|draw|writable|readable|derived|SvelteMap|SvelteSet|Math|Number|String|Boolean|JSON)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_$][\w$]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\].,;]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
blockComment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
style: [
|
||||
[/<\/style\s*>/, { token: 'tag', next: '@pop' }],
|
||||
[/\/\*/, 'comment', '@blockComment'],
|
||||
[/[.#][\w-]+/, 'attribute.name'],
|
||||
[
|
||||
/\b(?:var|calc|rgb|rgba|linear-gradient|radial-gradient|url|blur|clamp|min|max)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/[a-zA-Z-]+(?=\s*:)/, 'type'],
|
||||
[/#[0-9a-fA-F]{3,8}\b/, 'number.hex'],
|
||||
[/\b\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|deg|s|ms)?\b/, 'number'],
|
||||
[/"[^"]*"|'[^']*'/, 'string'],
|
||||
[/[{}();:,]/, 'delimiter'],
|
||||
[/[^}\s]/, 'text'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'tag', foreground: 'ff79c6' },
|
||||
{ token: 'attribute.name', foreground: '50fa7b' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'expression', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'number.hex', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'type', foreground: '8be9fd' },
|
||||
{ token: 'text', foreground: 'f8f8f2' },
|
||||
{ token: 'delimiter', foreground: 'a1a1aa' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorWarning.foreground': '#fbbf24',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'editorOverviewRuler.warningForeground': '#fbbf24',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const completionPrefixes = ['variables.', 'layer.', 'theme.'];
|
||||
|
||||
// When the user has already typed `variables.`, Monaco replaces only the
|
||||
// word after the dot. Strip the prefix from the insertion so the completion
|
||||
// does not produce `variables.variables.score`.
|
||||
const stripTypedPrefix = (
|
||||
insertText: string,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
range: Monaco.Range
|
||||
) => {
|
||||
const typed = currentModel
|
||||
.getLineContent(range.startLineNumber)
|
||||
.slice(0, range.startColumn - 1);
|
||||
for (const prefix of completionPrefixes) {
|
||||
if (typed.endsWith(prefix) && insertText.startsWith(prefix)) {
|
||||
return insertText.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return insertText;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['.', '{', '#', '"', "'", ':', '<'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
for (const entry of propDocs) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind:
|
||||
entry.label.charCodeAt(0) === 36 || entry.label.startsWith('{')
|
||||
? api.languages.CompletionItemKind.Keyword
|
||||
: entry.label.startsWith('import')
|
||||
? api.languages.CompletionItemKind.Module
|
||||
: api.languages.CompletionItemKind.Property,
|
||||
insertText: stripTypedPrefix(entry.insert, currentModel, range),
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
label: 'Svelte component skeleton',
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: THEME_SVELTE_COMPONENT_SKELETON,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: 'A ready-to-edit layer with props, markup and scoped styles.',
|
||||
sortText: '0_skeleton',
|
||||
});
|
||||
|
||||
for (const variable of variables) {
|
||||
suggestions.push({
|
||||
label: `variables.${variable}`,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: stripTypedPrefix(`variables.${variable}`, currentModel, range),
|
||||
range,
|
||||
documentation: `Live rule variable \`${variable}\`. Change it from a Logic rule.`,
|
||||
sortText: `0_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
const entry = propDocs.find(
|
||||
(candidate) => candidate.label === word.word || candidate.label === `${word.word}`
|
||||
);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `**${entry.label}**\n\n${entry.documentation}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (analysis: ThemeSvelteAnalysis) => {
|
||||
if (!monaco || !model) return;
|
||||
const all: ThemeSvelteDiagnostic[] = analysis.ok
|
||||
? analysis.warnings
|
||||
: [...analysis.errors, ...analysis.warnings];
|
||||
const markers: Monaco.editor.IMarkerData[] = all.map((diagnostic) => {
|
||||
const lineNumber = Math.min(Math.max(1, diagnostic.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, diagnostic.column), maxColumn);
|
||||
const endLine = Math.min(
|
||||
Math.max(lineNumber, diagnostic.endLine ?? lineNumber),
|
||||
model!.getLineCount()
|
||||
);
|
||||
const endMaxColumn = model!.getLineMaxColumn(endLine);
|
||||
const endColumn = Math.min(
|
||||
Math.max(startColumn, diagnostic.endColumn ?? startColumn + 1),
|
||||
endMaxColumn
|
||||
);
|
||||
return {
|
||||
severity:
|
||||
diagnostic.severity === 'error'
|
||||
? monaco!.MarkerSeverity.Error
|
||||
: monaco!.MarkerSeverity.Warning,
|
||||
message: diagnostic.message,
|
||||
source: 'EZPP Svelte layer',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: endLine,
|
||||
endColumn: Math.max(startColumn + 1, endColumn),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = all.filter((diagnostic) => diagnostic.severity === 'error').length;
|
||||
warningCount = all.filter((diagnostic) => diagnostic.severity === 'warning').length;
|
||||
diagnostics = all;
|
||||
};
|
||||
|
||||
const validate = async (source: string) => {
|
||||
const token = ++validationToken;
|
||||
checking = true;
|
||||
const analysis = await analyzeThemeSvelteSource(source, 'ThemeLayer.svelte');
|
||||
if (token !== validationToken) return;
|
||||
checking = false;
|
||||
syncDiagnostics(analysis);
|
||||
};
|
||||
|
||||
const scheduleValidation = (source: string, immediate = false) => {
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
checking = true;
|
||||
validateTimer = setTimeout(
|
||||
() => {
|
||||
void validate(source);
|
||||
},
|
||||
immediate ? 0 : 350
|
||||
);
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment ??= {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Custom Svelte component source',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 18,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: true },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
scheduleValidation(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
ready = true;
|
||||
scheduleValidation(currentValue, true);
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco Svelte editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
scheduleValidation(value, true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded component editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Component class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Svelte component
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if checking}
|
||||
<span class="text-[8px] text-muted-foreground">Checking…</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={THEME_SVELTE_COMPONENT_PLACEHOLDER}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
scheduleValidation(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if diagnostics.length > 0}
|
||||
<div class="max-h-24 shrink-0 overflow-y-auto border-t border-theme-800 bg-theme-950/70">
|
||||
{#each diagnostics.slice(0, 8) as diagnostic, index (index)}
|
||||
<div
|
||||
class="flex items-start gap-1.5 border-b border-theme-800/60 px-2 py-1 last:border-b-0"
|
||||
>
|
||||
<CircleAlert
|
||||
class="mt-px size-2.5 shrink-0 {diagnostic.severity === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-amber-300'}"
|
||||
/>
|
||||
<p class="min-w-0 flex-1 text-[8px] leading-relaxed text-theme-100">
|
||||
{diagnostic.message}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-mono text-[8px] text-muted-foreground hover:text-primary"
|
||||
title="Jump to this line"
|
||||
onclick={() => {
|
||||
editor?.setPosition({ lineNumber: diagnostic.line, column: diagnostic.column });
|
||||
editor?.revealLineInCenter(diagnostic.line);
|
||||
editor?.focus();
|
||||
}}
|
||||
>
|
||||
{diagnostic.line}:{diagnostic.column}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if checking}
|
||||
Checking…
|
||||
{:else if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{#if warningCount > 0}
|
||||
<span class="text-amber-300">
|
||||
· {warningCount} warning{warningCount === 1 ? '' : 's'}</span
|
||||
>
|
||||
{/if}
|
||||
{:else if warningCount > 0}
|
||||
<span class="text-amber-300">{warningCount} warning{warningCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { themeClassTokens } from '$lib/themeV2';
|
||||
import { Code2, RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
idPrefix,
|
||||
value,
|
||||
onchange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
value?: string;
|
||||
onchange: (value: string | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const invalidTokens = $derived(
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.filter((token) => themeClassTokens(token).length === 0)
|
||||
: []
|
||||
);
|
||||
|
||||
function setValue(raw: string) {
|
||||
onchange(raw.trim() ? raw : undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet resetAction()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={!value}
|
||||
onclick={() => onchange(undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="CSS classes"
|
||||
icon={Code2}
|
||||
open={false}
|
||||
actions={resetAction}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label for={`${idPrefix}-css-classes`} class={label}>Classes</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-css-classes`}
|
||||
class="h-8 border-theme-800 bg-theme-950 font-mono text-xs text-theme-50"
|
||||
value={value ?? ''}
|
||||
placeholder="glow-card hero-title"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
oninput={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Separate multiple classes with spaces and style them from the theme's Custom CSS, for example
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">.glow-card</code>.
|
||||
</p>
|
||||
|
||||
{#if invalidTokens.length > 0}
|
||||
<p class="text-[10px] leading-relaxed text-amber-400">
|
||||
Ignored: <span class="font-mono">{invalidTokens.join(' ')}</span>. Use letters, numbers,
|
||||
dashes and underscores.
|
||||
</p>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { compileThemeSvelte } from '$lib/themeSvelte';
|
||||
import { themeAssetUrl, type ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { TriangleAlert } from '@lucide/svelte';
|
||||
import { mount, unmount } from 'svelte';
|
||||
|
||||
let {
|
||||
source,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
variables = {},
|
||||
screen = 'home',
|
||||
layerId,
|
||||
layerName,
|
||||
width,
|
||||
height,
|
||||
themeName = '',
|
||||
themeAuthor = '',
|
||||
accentColor = '#ff006e',
|
||||
interactive = false,
|
||||
}: {
|
||||
source: string;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
screen?: string;
|
||||
layerId: string;
|
||||
layerName?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
themeName?: string;
|
||||
themeAuthor?: string;
|
||||
accentColor?: string;
|
||||
interactive?: boolean;
|
||||
} = $props();
|
||||
|
||||
type ComponentInstance = ReturnType<typeof mount>;
|
||||
|
||||
let host = $state<HTMLDivElement>();
|
||||
let error = $state('');
|
||||
let loading = $state(true);
|
||||
let instance: ComponentInstance | undefined;
|
||||
let generation = 0;
|
||||
|
||||
// A $state proxy can be passed to mount() so $props() in the custom
|
||||
// component remains reactive when a logic rule changes a variable.
|
||||
const componentProps = $state<Record<string, unknown>>({
|
||||
variables: {},
|
||||
screen: 'home',
|
||||
layer: { id: '', name: undefined, width: 0, height: 0 },
|
||||
theme: { name: '', author: '', accentColor: '#ff006e' },
|
||||
asset: (path: string) => path,
|
||||
});
|
||||
|
||||
const errorMessage = (value: unknown) =>
|
||||
value instanceof Error ? `${value.name}: ${value.message}` : String(value);
|
||||
|
||||
$effect(() => {
|
||||
componentProps.variables = { ...variables };
|
||||
componentProps.screen = screen;
|
||||
componentProps.layer = { id: layerId, name: layerName, width, height };
|
||||
componentProps.theme = { name: themeName, author: themeAuthor, accentColor };
|
||||
componentProps.asset = (path: string) => assetOverrides[path] ?? themeAssetUrl(assets, path);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const current = ++generation;
|
||||
const currentSource = source;
|
||||
let mounted: ComponentInstance | undefined;
|
||||
loading = true;
|
||||
|
||||
void (async () => {
|
||||
const result = await compileThemeSvelte(currentSource, `layer-${layerId}.svelte`);
|
||||
if (current !== generation || !host) return;
|
||||
|
||||
if (!result.component) {
|
||||
error = result.errors[0]?.message ?? 'The custom component could not be compiled.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
mounted = mount(result.component, { target: host, props: componentProps });
|
||||
instance = mounted;
|
||||
error = '';
|
||||
} catch (mountError) {
|
||||
error = errorMessage(mountError);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
// Invalidate a compile that is still in flight so it cannot mount into
|
||||
// a host that has already been removed with this layer.
|
||||
generation += 1;
|
||||
loading = false;
|
||||
if (mounted) {
|
||||
void unmount(mounted, { outro: false });
|
||||
if (instance === mounted) instance = undefined;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!interactive || !error) return;
|
||||
console.error(`[theme] Svelte layer ${layerId} failed`, error);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<div bind:this={host} class="h-full w-full"></div>
|
||||
|
||||
{#if interactive && loading}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border border-dashed border-theme-700 bg-theme-950/65 text-[10px] text-muted-foreground"
|
||||
>
|
||||
Compiling Svelte…
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if interactive && error}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden rounded-md border border-destructive/50 bg-destructive/12 p-3 text-center"
|
||||
>
|
||||
<TriangleAlert class="size-4 shrink-0 text-destructive" />
|
||||
<p class="text-[10px] font-semibold text-destructive">Svelte layer failed</p>
|
||||
<p class="line-clamp-4 font-mono text-[9px] leading-relaxed text-destructive/90">{error}</p>
|
||||
<p class="text-[8px] text-muted-foreground">Fix the error in the layer inspector.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import type { ThemeV2Filters } from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
filters,
|
||||
onchange,
|
||||
}: {
|
||||
filters?: ThemeV2Filters;
|
||||
onchange: (filters: ThemeV2Filters) => void;
|
||||
} = $props();
|
||||
|
||||
type Control = {
|
||||
key: keyof ThemeV2Filters;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
fallback: number;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
const controls: Control[] = [
|
||||
{ key: 'blur', label: 'Blur', min: 0, max: 40, step: 1, fallback: 0, unit: 'px' },
|
||||
{ key: 'brightness', label: 'Brightness', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'contrast', label: 'Contrast', min: 0, max: 3, step: 0.05, fallback: 1, unit: '' },
|
||||
{ key: 'saturate', label: 'Saturation', min: 0, max: 4, step: 0.05, fallback: 1, unit: '' },
|
||||
{
|
||||
key: 'hueRotate',
|
||||
label: 'Hue rotate',
|
||||
min: -360,
|
||||
max: 360,
|
||||
step: 1,
|
||||
fallback: 0,
|
||||
unit: 'deg',
|
||||
},
|
||||
{ key: 'grayscale', label: 'Grayscale', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'sepia', label: 'Sepia', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
{ key: 'invert', label: 'Invert', min: 0, max: 1, step: 0.01, fallback: 0, unit: '' },
|
||||
];
|
||||
|
||||
const valueOf = (control: Control) => filters?.[control.key] ?? control.fallback;
|
||||
const display = (control: Control) => {
|
||||
const value = valueOf(control);
|
||||
return `${control.step < 1 ? value.toFixed(2).replace(/\.?0+$/, '') : Math.round(value)}${control.unit}`;
|
||||
};
|
||||
const isDefault = $derived(controls.every((control) => valueOf(control) === control.fallback));
|
||||
|
||||
function setValue(key: keyof ThemeV2Filters, value: number) {
|
||||
onchange({ ...(filters ?? {}), [key]: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet filterActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-[10px] text-muted-foreground"
|
||||
disabled={isDefault}
|
||||
onclick={() => onchange({})}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Filters"
|
||||
open={false}
|
||||
actions={filterActions}
|
||||
contentClass="space-y-2.5 pt-2.5"
|
||||
>
|
||||
<div class="space-y-2.5">
|
||||
{#each controls as control (control.key)}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] text-muted-foreground">{control.label}</span>
|
||||
<span class="font-mono text-[9px] text-theme-100">{display(control)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={valueOf(control)}
|
||||
onValueChange={(value) => setValue(control.key, value)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import ThemeCssClasses from '$lib/components/theme/ThemeCssClasses.svelte';
|
||||
import ThemeFilterControls from '$lib/components/theme/ThemeFilterControls.svelte';
|
||||
import ThemeLinkedLogic from '$lib/components/theme/ThemeLinkedLogic.svelte';
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import ThemeTransformControls from '$lib/components/theme/ThemeTransformControls.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import {
|
||||
type ThemeV2Behavior,
|
||||
type ThemeV2BehaviorTarget,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2MaskShape,
|
||||
} from '$lib/themeV2';
|
||||
import { Boxes, RefreshCw, Ungroup } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
group,
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onungroup,
|
||||
onpickmask,
|
||||
onaddbehavior,
|
||||
ondeleterule,
|
||||
}: {
|
||||
group: ThemeV2Group;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onungroup: () => void;
|
||||
onpickmask: () => void;
|
||||
onaddbehavior: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondeleterule: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
</script>
|
||||
|
||||
<div class="space-y-3 p-3">
|
||||
<div class="flex items-center gap-2 border-b border-theme-800 pb-2.5">
|
||||
<div class="flex size-8 items-center justify-center rounded-md bg-primary/20 text-primary">
|
||||
<Boxes class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-semibold text-theme-50">Group</p>
|
||||
<p class="truncate font-mono text-[10px] text-muted-foreground">{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeSection title="Group" bordered={false} contentClass="space-y-3 pt-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-name" class={label}>Group name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
class={field}
|
||||
value={group.name ?? ''}
|
||||
placeholder="Group name"
|
||||
oninput={(event) => (group.name = (event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Opacity</Label>
|
||||
<span class="text-[10px] text-muted-foreground"
|
||||
>{Math.round((group.opacity ?? 1) * 100)}%</span
|
||||
>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={group.opacity ?? 1}
|
||||
onValueChange={(value) => (group.opacity = value)}
|
||||
/>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeTransformControls
|
||||
transform={group}
|
||||
idPrefix="group"
|
||||
includeTranslate
|
||||
onchange={(patch) => Object.assign(group, patch)}
|
||||
/>
|
||||
|
||||
<ThemeSection
|
||||
title="Group state"
|
||||
contentClass="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-hidden"
|
||||
checked={group.hidden === true}
|
||||
onCheckedChange={(checked) => (group.hidden = checked === true)}
|
||||
/>
|
||||
<Label for="group-hidden" class="text-[10px] font-normal text-muted-foreground">Hidden</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id="group-locked"
|
||||
checked={group.locked === true}
|
||||
onCheckedChange={(checked) => (group.locked = checked === true)}
|
||||
/>
|
||||
<Label for="group-locked" class="text-[10px] font-normal text-muted-foreground">Locked</Label>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeCssClasses
|
||||
idPrefix="group"
|
||||
value={group.cssClasses}
|
||||
onchange={(value) => (group.cssClasses = value)}
|
||||
/>
|
||||
|
||||
<ThemeLinkedLogic
|
||||
target={{ type: 'group', id: group.id }}
|
||||
{behaviors}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
{focusToken}
|
||||
onadd={onaddbehavior}
|
||||
ondelete={ondeleterule}
|
||||
/>
|
||||
|
||||
<ThemeSection title="Mask" contentClass="space-y-3 pt-2.5">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-shape" class={label}>Clip shape</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={group.mask?.shape ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
if (value === 'none') {
|
||||
group.mask = undefined;
|
||||
return;
|
||||
}
|
||||
group.mask = {
|
||||
shape: value as ThemeV2MaskShape,
|
||||
radius: group.mask?.radius ?? 24,
|
||||
sides: group.mask?.sides ?? 6,
|
||||
innerRadius: group.mask?.innerRadius ?? 0.5,
|
||||
src: group.mask?.src,
|
||||
fit: group.mask?.fit ?? 'cover',
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="group-mask-shape"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{group.mask?.shape ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="none" label="None" />
|
||||
<Select.Item value="rectangle" label="Rectangle" />
|
||||
<Select.Item value="ellipse" label="Ellipse" />
|
||||
<Select.Item value="polygon" label="Polygon" />
|
||||
<Select.Item value="star" label="Star" />
|
||||
<Select.Item value="image" label="Image alpha" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{#if group.mask?.shape === 'polygon' || group.mask?.shape === 'star'}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-sides" class={label}>Points</Label>
|
||||
<Input
|
||||
id="group-mask-sides"
|
||||
class={field}
|
||||
type="number"
|
||||
min="3"
|
||||
max="24"
|
||||
value={group.mask.sides ?? 6}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.sides = Number((event.currentTarget as HTMLInputElement).value) || 3;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if group.mask.shape === 'star'}
|
||||
<div class="space-y-1">
|
||||
<Label for="group-mask-inner" class={label}>Inner radius</Label>
|
||||
<Input
|
||||
id="group-mask-inner"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="0.95"
|
||||
step="0.05"
|
||||
value={group.mask.innerRadius ?? 0.5}
|
||||
oninput={(event) => {
|
||||
if (group.mask) {
|
||||
group.mask.innerRadius =
|
||||
Number((event.currentTarget as HTMLInputElement).value) || 0.5;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if group.mask?.shape === 'image'}
|
||||
<div class="space-y-2 rounded-md border border-theme-800 bg-black/20 p-2">
|
||||
<p class="truncate font-mono text-[10px] text-theme-100">
|
||||
{group.mask.src ?? 'No mask image yet'}
|
||||
</p>
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onpickmask}>
|
||||
<RefreshCw class="size-3.5" />
|
||||
Choose mask image
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-[9px] leading-relaxed text-muted-foreground">
|
||||
The mask clips every layer in this group. Image masks use the picture's alpha channel.
|
||||
</p>
|
||||
</ThemeSection>
|
||||
|
||||
<ThemeFilterControls filters={group.filters} onchange={(filters) => (group.filters = filters)} />
|
||||
|
||||
<Button variant="secondary" class="h-8 w-full gap-2 text-xs" onclick={onungroup}>
|
||||
<Ungroup class="size-3.5" />
|
||||
Ungroup group
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { THEME_ICON_BODIES } from '$lib/themeIconData';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconName, type ThemeIconSet } from '$lib/themeIcons';
|
||||
|
||||
let {
|
||||
name,
|
||||
set = DEFAULT_THEME_ICON_SET,
|
||||
size = '1em',
|
||||
class: className = '',
|
||||
}: {
|
||||
name: ThemeIconName;
|
||||
set?: ThemeIconSet;
|
||||
size?: number | string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const icon = $derived(
|
||||
THEME_ICON_BODIES[set]?.[name] ?? THEME_ICON_BODIES[DEFAULT_THEME_ICON_SET][name]
|
||||
);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${icon.width} ${icon.height}`}
|
||||
width={size}
|
||||
height={size}
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html icon.body}
|
||||
</svg>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import ThemeBehaviorInspector from '$lib/components/theme/ThemeBehaviorInspector.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import { themeBehaviorSummary, themeGroupInteractiveCount } from '$lib/themeLogic';
|
||||
import type {
|
||||
ThemeV2Behavior,
|
||||
ThemeV2BehaviorTarget,
|
||||
ThemeV2Element,
|
||||
ThemeV2Group,
|
||||
} from '$lib/themeV2';
|
||||
import { AlertTriangle, ChevronDown, Plus, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
target,
|
||||
behaviors,
|
||||
elements,
|
||||
groups,
|
||||
variables = [],
|
||||
focusToken = 0,
|
||||
onadd,
|
||||
ondelete,
|
||||
}: {
|
||||
target: ThemeV2BehaviorTarget;
|
||||
behaviors: ThemeV2Behavior[];
|
||||
elements: ThemeV2Element[];
|
||||
groups: ThemeV2Group[];
|
||||
variables?: string[];
|
||||
focusToken?: number;
|
||||
onadd: (target: ThemeV2BehaviorTarget) => ThemeV2Behavior | undefined;
|
||||
ondelete: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let selectedId = $state<string | undefined>(undefined);
|
||||
let section = $state<HTMLElement>();
|
||||
let lastFocusToken = 0;
|
||||
|
||||
const linkedBehaviors = $derived(
|
||||
behaviors.filter(
|
||||
(behavior) => behavior.target?.type === target.type && behavior.target?.id === target.id
|
||||
)
|
||||
);
|
||||
const pointerRuleCount = $derived(
|
||||
linkedBehaviors.filter(
|
||||
(behavior) =>
|
||||
behavior.trigger.type === 'click' ||
|
||||
behavior.trigger.type === 'hover' ||
|
||||
behavior.trigger.type === 'leave'
|
||||
).length
|
||||
);
|
||||
const interactiveTargets = $derived(
|
||||
target.type === 'group'
|
||||
? themeGroupInteractiveCount(groups, elements, target.id)
|
||||
: elements.find((element) => element.id === target.id)?.interactive
|
||||
? 1
|
||||
: 0
|
||||
);
|
||||
const needsInteractive = $derived(pointerRuleCount > 0 && interactiveTargets === 0);
|
||||
|
||||
$effect(() => {
|
||||
if (!focusToken || focusToken === lastFocusToken) return;
|
||||
lastFocusToken = focusToken;
|
||||
open = true;
|
||||
if (linkedBehaviors.length === 1) selectedId = linkedBehaviors[0].id;
|
||||
requestAnimationFrame(() => section?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
||||
});
|
||||
|
||||
function addRule() {
|
||||
const behavior = onadd(target);
|
||||
open = true;
|
||||
if (behavior) selectedId = behavior.id;
|
||||
}
|
||||
|
||||
function deleteRule(id: string) {
|
||||
ondelete(id);
|
||||
if (selectedId === id) selectedId = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section bind:this={section} class="border-t border-theme-800 pt-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Zap class="size-3.5 shrink-0 text-primary" />
|
||||
<span class="text-[11px] font-semibold text-theme-100">Logic</span>
|
||||
{#if linkedBehaviors.length > 0}
|
||||
<span class="rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||
{linkedBehaviors.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if needsInteractive}
|
||||
<span title="A linked pointer rule needs an interactive layer">
|
||||
<AlertTriangle class="size-3 text-amber-300" />
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3 shrink-0 text-muted-foreground transition-transform {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 gap-1 px-1.5 text-[9px]"
|
||||
title="Add a rule linked to this layer or group"
|
||||
onclick={addRule}
|
||||
>
|
||||
<Plus class="size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="mt-1.5 space-y-1.5">
|
||||
{#if linkedBehaviors.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 px-2 py-1.5 text-[9px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
No rules linked yet. Add one to react to clicks, hovers, timers or screen changes.
|
||||
</p>
|
||||
{:else}
|
||||
{#each linkedBehaviors as behavior (behavior.id)}
|
||||
<div class="overflow-hidden rounded-md border border-theme-800 bg-black/20">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left transition-colors hover:bg-theme-900/60"
|
||||
aria-expanded={selectedId === behavior.id}
|
||||
onclick={() => (selectedId = selectedId === behavior.id ? undefined : behavior.id)}
|
||||
>
|
||||
<span
|
||||
class="size-1.5 shrink-0 rounded-full {behavior.enabled === false
|
||||
? 'bg-theme-600'
|
||||
: 'bg-emerald-400'}"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="truncate text-[10px] font-medium text-theme-100">
|
||||
{behavior.name?.trim() || 'Rule'}
|
||||
</span>
|
||||
{#if behavior.once}
|
||||
<span class="rounded bg-theme-900 px-1 py-0.5 text-[8px] text-muted-foreground">
|
||||
once
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="mt-0.5 block truncate text-[9px] text-muted-foreground">
|
||||
{themeBehaviorSummary(behavior, elements, groups)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="size-3 shrink-0 text-muted-foreground transition-transform {selectedId ===
|
||||
behavior.id
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if selectedId === behavior.id}
|
||||
<div class="border-t border-theme-800 bg-theme-950/60">
|
||||
<ThemeBehaviorInspector
|
||||
{behavior}
|
||||
{elements}
|
||||
{groups}
|
||||
{variables}
|
||||
embedded
|
||||
ondelete={() => deleteRule(behavior.id)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if needsInteractive}
|
||||
<div
|
||||
class="flex items-start gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/5 p-1.5"
|
||||
>
|
||||
<AlertTriangle class="mt-0.5 size-3 shrink-0 text-amber-300" />
|
||||
<p class="text-[9px] leading-relaxed text-amber-100/80">
|
||||
{#if target.type === 'group'}
|
||||
Mark at least one layer in this group as <strong>Interactive</strong>.
|
||||
{:else}
|
||||
Enable <strong>Interactive</strong> for this layer.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { AlertTriangle, Zap } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
count,
|
||||
warning = false,
|
||||
onopen,
|
||||
}: {
|
||||
count: number;
|
||||
warning?: boolean;
|
||||
onopen: () => void;
|
||||
} = $props();
|
||||
|
||||
const title = $derived(
|
||||
warning
|
||||
? `${count} linked logic rule${count === 1 ? '' : 's'} · needs an interactive layer · Open in inspector`
|
||||
: `${count} linked logic rule${count === 1 ? '' : 's'} · Open in inspector`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="-m-0.5 shrink-0 cursor-pointer rounded-full p-0.5 transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
{title}
|
||||
aria-label={title}
|
||||
onclick={(event) => {
|
||||
event.stopPropagation();
|
||||
onopen();
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="pointer-events-none h-4 gap-0.5 border px-1 py-0 text-[8px] font-semibold leading-none {warning
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
: 'border-primary/25 bg-primary/10 text-primary'}"
|
||||
>
|
||||
{#if warning}
|
||||
<AlertTriangle class="size-2.5" />
|
||||
{:else}
|
||||
<Zap class="size-2.5" />
|
||||
{/if}
|
||||
{count}
|
||||
</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import lottie, { type AnimationItem } from 'lottie-web';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
let {
|
||||
src,
|
||||
loop = true,
|
||||
autoplay = true,
|
||||
speed = 1,
|
||||
}: {
|
||||
src: string;
|
||||
loop?: boolean;
|
||||
autoplay?: boolean;
|
||||
speed?: number;
|
||||
} = $props();
|
||||
|
||||
let container = $state<HTMLDivElement | undefined>(undefined);
|
||||
let animation: AnimationItem | undefined;
|
||||
let reducedMotion = false;
|
||||
|
||||
onMount(() => {
|
||||
if (!container || !src) return;
|
||||
reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
let cancelled = false;
|
||||
|
||||
const start = (animationData?: unknown) => {
|
||||
if (cancelled || !container) return;
|
||||
try {
|
||||
animation = lottie.loadAnimation({
|
||||
container,
|
||||
renderer: 'svg',
|
||||
loop,
|
||||
autoplay: autoplay && !reducedMotion,
|
||||
...(animationData ? { animationData } : { path: src }),
|
||||
rendererSettings: { preserveAspectRatio: 'xMidYMid meet' },
|
||||
});
|
||||
animation.setSpeed(speed);
|
||||
if (reducedMotion) {
|
||||
animation.addEventListener('DOMLoaded', () => animation?.goToAndStop(0, true));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load the Lottie layer', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetching the JSON ourselves works with Tauri's asset protocols, where
|
||||
// lottie-web's internal XHR can be blocked. Fall back to its own loader.
|
||||
void fetch(src)
|
||||
.then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
|
||||
.then((data) => start(data))
|
||||
.catch(() => start());
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!animation) return;
|
||||
animation.loop = loop;
|
||||
animation.setSpeed(speed);
|
||||
if (autoplay && !reducedMotion) animation.play();
|
||||
else animation.pause();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
animation?.destroy();
|
||||
animation = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="h-full w-full" aria-hidden="true"></div>
|
||||
@@ -0,0 +1,890 @@
|
||||
<script lang="ts">
|
||||
import { isThemeClockVariable, THEME_CLOCK_VARIABLE_NAMES } from '$lib/themeClock';
|
||||
import { parseThemeScript } from '$lib/themeScriptLang';
|
||||
import type { ThemeV2Element, ThemeV2Group, ThemeV2ScriptScreen } from '$lib/themeV2';
|
||||
import { Braces, CircleAlert, Maximize2, Minimize2, X } from '@lucide/svelte';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type MonacoApi = typeof import('monaco-editor');
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onchange,
|
||||
variables = [],
|
||||
elements = [],
|
||||
groups = [],
|
||||
height = 280,
|
||||
}: {
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
variables?: string[];
|
||||
elements?: ThemeV2Element[];
|
||||
groups?: ThemeV2Group[];
|
||||
height?: number;
|
||||
} = $props();
|
||||
|
||||
const LANGUAGE_ID = 'ezpp-theme-script';
|
||||
const THEME_ID = 'ezpp-theme-script-dark';
|
||||
const MARKER_OWNER = 'ezpp-theme-script';
|
||||
|
||||
const screens: ThemeV2ScriptScreen[] = ['home', 'themes', 'settings', 'login'];
|
||||
|
||||
const statementDocs: Record<string, string> = {
|
||||
let: 'Declare a theme variable.\n\n`let score = 0;`',
|
||||
const: 'Declare a value that will not be reassigned.\n\n`const bonus = 10;`',
|
||||
var: 'Declare a theme variable using the older JavaScript keyword.\n\n`var score = 0;`',
|
||||
if: 'Run a block only when a condition is true.\n\n`if (score >= 10) { ... }`',
|
||||
else: 'Run a block when the `if` condition is false.\n\n`else { ... }`',
|
||||
for: 'Run a bounded loop.\n\n`for (let i = 0; i < 3; i++) { ... }`',
|
||||
while: 'Run while a condition stays true. Loops stop after 1,000 iterations.',
|
||||
true: 'Boolean true.',
|
||||
false: 'Boolean false.',
|
||||
screen: 'The current launcher screen, such as `"home"` or `"themes"`.',
|
||||
Math: 'JavaScript math helpers such as `Math.floor(value)` and `Math.random()`.',
|
||||
};
|
||||
|
||||
const actionFunctions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'showLayer',
|
||||
label: 'showLayer(id)',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideLayer',
|
||||
label: 'hideLayer(id)',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
name: 'toggleLayer',
|
||||
label: 'toggleLayer(id)',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'showGroup',
|
||||
label: 'showGroup(id)',
|
||||
insert: 'showGroup(${1:"group_id"});',
|
||||
documentation: 'Make a group visible.',
|
||||
},
|
||||
{
|
||||
name: 'hideGroup',
|
||||
label: 'hideGroup(id)',
|
||||
insert: 'hideGroup(${1:"group_id"});',
|
||||
documentation: 'Hide a group.',
|
||||
},
|
||||
{
|
||||
name: 'toggleGroup',
|
||||
label: 'toggleGroup(id)',
|
||||
insert: 'toggleGroup(${1:"group_id"});',
|
||||
documentation: 'Flip a group between visible and hidden.',
|
||||
},
|
||||
{
|
||||
name: 'goTo',
|
||||
label: 'goTo(screen)',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to `home`, `themes`, `settings` or `login`.',
|
||||
},
|
||||
{
|
||||
name: 'navigate',
|
||||
label: 'navigate(screen)',
|
||||
insert: 'navigate(${1:"themes"});',
|
||||
documentation: 'Alias for `goTo`.',
|
||||
},
|
||||
{
|
||||
name: 'openUrl',
|
||||
label: 'openUrl(url)',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'launch',
|
||||
label: 'launch()',
|
||||
insert: 'launch();',
|
||||
documentation: 'Start the normal osu! launch flow. Click triggers only.',
|
||||
},
|
||||
{
|
||||
name: 'stop',
|
||||
label: 'stop()',
|
||||
insert: 'stop();',
|
||||
documentation: 'End the current script immediately.',
|
||||
},
|
||||
];
|
||||
|
||||
const functions: {
|
||||
name: string;
|
||||
label: string;
|
||||
insert: string;
|
||||
documentation: string;
|
||||
}[] = [
|
||||
{
|
||||
name: 'min',
|
||||
label: 'min(a, b, ...)',
|
||||
insert: 'min(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the smallest number.',
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
label: 'max(a, b, ...)',
|
||||
insert: 'max(${1:a}, ${2:b})',
|
||||
documentation: 'Returns the largest number.',
|
||||
},
|
||||
{
|
||||
name: 'clamp',
|
||||
label: 'clamp(value, min, max)',
|
||||
insert: 'clamp(${1:value}, ${2:0}, ${3:100})',
|
||||
documentation: 'Keeps a number inside a range.',
|
||||
},
|
||||
{
|
||||
name: 'round',
|
||||
label: 'round(value)',
|
||||
insert: 'round(${1:value})',
|
||||
documentation: 'Rounds to the nearest whole number.',
|
||||
},
|
||||
{
|
||||
name: 'floor',
|
||||
label: 'floor(value)',
|
||||
insert: 'floor(${1:value})',
|
||||
documentation: 'Rounds down.',
|
||||
},
|
||||
{
|
||||
name: 'ceil',
|
||||
label: 'ceil(value)',
|
||||
insert: 'ceil(${1:value})',
|
||||
documentation: 'Rounds up.',
|
||||
},
|
||||
{
|
||||
name: 'abs',
|
||||
label: 'abs(value)',
|
||||
insert: 'abs(${1:value})',
|
||||
documentation: 'Returns the absolute value.',
|
||||
},
|
||||
{
|
||||
name: 'random',
|
||||
label: 'random()',
|
||||
insert: 'random()',
|
||||
documentation: 'Returns a random number between 0 and 1.',
|
||||
},
|
||||
{
|
||||
name: 'chance',
|
||||
label: 'chance(percent)',
|
||||
insert: 'chance(${1:25})',
|
||||
documentation: 'Returns true with the given percentage chance.',
|
||||
},
|
||||
{
|
||||
name: 'visible',
|
||||
label: 'visible(layerId)',
|
||||
insert: 'visible(${1:"layer_id"})',
|
||||
documentation: 'Whether a layer is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'groupVisible',
|
||||
label: 'groupVisible(groupId)',
|
||||
insert: 'groupVisible(${1:"group_id"})',
|
||||
documentation: 'Whether a group is currently visible.',
|
||||
},
|
||||
{
|
||||
name: 'contains',
|
||||
label: 'contains(text, search)',
|
||||
insert: 'contains(${1:text}, ${2:"search"})',
|
||||
documentation: 'Whether text contains a smaller string.',
|
||||
},
|
||||
{
|
||||
name: 'length',
|
||||
label: 'length(text)',
|
||||
insert: 'length(${1:text})',
|
||||
documentation: 'Returns the number of characters in text.',
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
label: 'number(value)',
|
||||
insert: 'number(${1:value})',
|
||||
documentation: 'Converts a value to a number.',
|
||||
},
|
||||
{
|
||||
name: 'string',
|
||||
label: 'string(value)',
|
||||
insert: 'string(${1:value})',
|
||||
documentation: 'Converts a value to text.',
|
||||
},
|
||||
{
|
||||
name: 'boolean',
|
||||
label: 'boolean(value)',
|
||||
insert: 'boolean(${1:value})',
|
||||
documentation: 'Converts a value to true or false.',
|
||||
},
|
||||
];
|
||||
|
||||
const snippets: { label: string; insert: string; documentation: string }[] = [
|
||||
{
|
||||
label: 'let … = …;',
|
||||
insert: 'let ${1:name} = ${2:0};',
|
||||
documentation: 'Declare a theme variable.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run statements when a condition matches.',
|
||||
},
|
||||
{
|
||||
label: 'if (…) { … } else { … }',
|
||||
insert: 'if (${1:condition}) {\n\t$0\n} else {\n\t\n}',
|
||||
documentation: 'Run one of two blocks.',
|
||||
},
|
||||
{
|
||||
label: 'for (…) { … }',
|
||||
insert: 'for (let ${1:i} = 0; ${1:i} < ${2:3}; ${1:i}++) {\n\t$0\n}',
|
||||
documentation: 'Run a bounded loop.',
|
||||
},
|
||||
{
|
||||
label: 'while (…) { … }',
|
||||
insert: 'while (${1:condition}) {\n\t$0\n}',
|
||||
documentation: 'Run while a condition stays true.',
|
||||
},
|
||||
{
|
||||
label: 'clicks += 1;',
|
||||
insert: '${1:clicks} += ${2:1};',
|
||||
documentation: 'Add to a number variable.',
|
||||
},
|
||||
{
|
||||
label: 'showLayer(…);',
|
||||
insert: 'showLayer(${1:"layer_id"});',
|
||||
documentation: 'Make a layer visible.',
|
||||
},
|
||||
{
|
||||
label: 'hideLayer(…);',
|
||||
insert: 'hideLayer(${1:"layer_id"});',
|
||||
documentation: 'Hide a layer.',
|
||||
},
|
||||
{
|
||||
label: 'toggleLayer(…);',
|
||||
insert: 'toggleLayer(${1:"layer_id"});',
|
||||
documentation: 'Flip a layer between visible and hidden.',
|
||||
},
|
||||
{
|
||||
label: 'goTo(…);',
|
||||
insert: 'goTo(${1:"themes"});',
|
||||
documentation: 'Navigate to another launcher screen.',
|
||||
},
|
||||
{
|
||||
label: 'openUrl(…);',
|
||||
insert: 'openUrl(${1:"https://example.com"});',
|
||||
documentation: 'Open a website after a confirmation dialog.',
|
||||
},
|
||||
];
|
||||
|
||||
let host = $state<HTMLDivElement | undefined>(undefined);
|
||||
let monaco: MonacoApi | undefined;
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let model: Monaco.editor.ITextModel | undefined;
|
||||
let ready = $state(false);
|
||||
let loadError = $state('');
|
||||
let expanded = $state(false);
|
||||
let lineCount = $state(1);
|
||||
let cursorLine = $state(1);
|
||||
let cursorColumn = $state(1);
|
||||
let errorCount = $state(0);
|
||||
let currentValue = '';
|
||||
|
||||
const ensureLanguage = (api: MonacoApi) => {
|
||||
if (!api.languages.getLanguages().some((language) => language.id === LANGUAGE_ID)) {
|
||||
api.languages.register({ id: LANGUAGE_ID });
|
||||
}
|
||||
|
||||
api.languages.setLanguageConfiguration(LANGUAGE_ID, {
|
||||
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
{ open: '`', close: '`' },
|
||||
],
|
||||
indentationRules: {
|
||||
increaseIndentPattern: /\{[^}"']*$/,
|
||||
decreaseIndentPattern: /^\s*\}/,
|
||||
},
|
||||
});
|
||||
api.languages.setMonarchTokensProvider(LANGUAGE_ID, {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\s+/, 'white'],
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/\/\*/, { token: 'comment', next: '@comment' }],
|
||||
[/"/, { token: 'string.quote', bracket: '@open', next: '@string' }],
|
||||
[/'/, { token: 'string.quote', bracket: '@open', next: '@singleString' }],
|
||||
[/`/, { token: 'string.quote', bracket: '@open', next: '@templateString' }],
|
||||
[
|
||||
/\b(?:let|const|var|if|else|for|while|do|function|return|break|continue|switch|case|default|new|delete|typeof|instanceof|in|of|class|try|catch|finally|throw|this)\b/,
|
||||
'keyword',
|
||||
],
|
||||
[/\b(?:true|false|null|undefined)\b/, 'keyword'],
|
||||
[
|
||||
/\b(?:min|max|clamp|round|floor|ceil|abs|random|chance|visible|groupVisible|contains|length|number|string|boolean|Number|String|Boolean|showLayer|hideLayer|toggleLayer|showGroup|hideGroup|toggleGroup|goTo|navigate|openUrl|launch|stop|Math)\b/,
|
||||
'predefined',
|
||||
],
|
||||
[/\b(?:screen)\b/, 'constant'],
|
||||
[/\b\d+(?:\.\d+)?\b/, 'number'],
|
||||
[/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'],
|
||||
[/===|!==|=>|==|!=|<=|>=|&&|\|\||\+\+|--|\*\*|[=!<>+\-*/%?:]/, 'operator'],
|
||||
[/[{}()[\]]/, '@brackets'],
|
||||
[/[,;]/, 'delimiter'],
|
||||
[/[^\s]/, 'invalid'],
|
||||
],
|
||||
string: [
|
||||
[/[^"\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
singleString: [
|
||||
[/[^'\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
templateString: [
|
||||
[/[^`\\]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[/`/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
|
||||
],
|
||||
comment: [
|
||||
[/[^*/]+/, 'comment'],
|
||||
[/\*\//, { token: 'comment', next: '@pop' }],
|
||||
[/[*/]/, 'comment'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
api.editor.defineTheme(THEME_ID, {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: 'ff79c6', fontStyle: 'bold' },
|
||||
{ token: 'predefined', foreground: '8be9fd' },
|
||||
{ token: 'constant', foreground: 'ffb86c' },
|
||||
{ token: 'identifier', foreground: 'f8f8f2' },
|
||||
{ token: 'number', foreground: 'bd93f9' },
|
||||
{ token: 'string', foreground: 'f1fa8c' },
|
||||
{ token: 'string.escape', foreground: 'ffb86c' },
|
||||
{ token: 'comment', foreground: '6272a4', fontStyle: 'italic' },
|
||||
{ token: 'operator', foreground: 'ff79c6' },
|
||||
{ token: 'invalid', foreground: 'ff5555' },
|
||||
],
|
||||
colors: {
|
||||
'editor.background': '#08080c',
|
||||
'editor.foreground': '#f4f4f5',
|
||||
'editorLineNumber.foreground': '#52525b',
|
||||
'editorLineNumber.activeForeground': '#e4e4e7',
|
||||
'editor.lineHighlightBackground': '#ffffff08',
|
||||
'editor.selectionBackground': '#ff006e33',
|
||||
'editor.inactiveSelectionBackground': '#ff006e22',
|
||||
'editorCursor.foreground': '#ff006e',
|
||||
'editorIndentGuide.background1': '#27272a',
|
||||
'editorIndentGuide.activeBackground1': '#52525b',
|
||||
'editorBracketHighlight.foreground1': '#ff79c6',
|
||||
'editorBracketHighlight.foreground2': '#8be9fd',
|
||||
'editorBracketHighlight.foreground3': '#f1fa8c',
|
||||
'editorGutter.background': '#08080c',
|
||||
'editorWidget.background': '#121218',
|
||||
'editorWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.background': '#121218',
|
||||
'editorSuggestWidget.border': '#3f3f46',
|
||||
'editorSuggestWidget.selectedBackground': '#ff006e26',
|
||||
'editorSuggestWidget.highlightForeground': '#ff79c6',
|
||||
'editorHoverWidget.background': '#121218',
|
||||
'editorHoverWidget.border': '#3f3f46',
|
||||
'editorError.foreground': '#f87171',
|
||||
'editorOverviewRuler.errorForeground': '#f87171',
|
||||
'scrollbarSlider.background': '#52525b55',
|
||||
'scrollbarSlider.hoverBackground': '#71717a88',
|
||||
'scrollbarSlider.activeBackground': '#a1a1aa99',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const completionRange = (
|
||||
api: MonacoApi,
|
||||
currentModel: Monaco.editor.ITextModel,
|
||||
position: Monaco.Position
|
||||
) => {
|
||||
const word = currentModel.getWordUntilPosition(position);
|
||||
return new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
);
|
||||
};
|
||||
|
||||
const insideString = (text: string) => {
|
||||
let inside = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
if (text[index] !== '"') continue;
|
||||
let backslashes = 0;
|
||||
for (let check = index - 1; check >= 0 && text[check] === '\\'; check -= 1) {
|
||||
backslashes += 1;
|
||||
}
|
||||
if (backslashes % 2 === 0) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
const registerIntellisense = (api: MonacoApi) => {
|
||||
const completion = api.languages.registerCompletionItemProvider(LANGUAGE_ID, {
|
||||
triggerCharacters: ['"', '.'],
|
||||
provideCompletionItems(currentModel, position) {
|
||||
const range = completionRange(api, currentModel, position);
|
||||
const line = currentModel.getLineContent(position.lineNumber);
|
||||
const before = line.slice(0, position.column - 1);
|
||||
const suggestions: Monaco.languages.CompletionItem[] = [];
|
||||
|
||||
if (insideString(before)) {
|
||||
const prefix = before.slice(0, before.lastIndexOf('"')).toLowerCase();
|
||||
const wantsScreen = /(?:goTo|navigate)\s*\(\s*$/.test(prefix);
|
||||
const wantsGroup = /(?:showGroup|hideGroup|toggleGroup|groupVisible)\s*\(\s*$/.test(
|
||||
prefix
|
||||
);
|
||||
const wantsLayer = /(?:showLayer|hideLayer|toggleLayer|visible)\s*\(\s*$/.test(prefix);
|
||||
|
||||
if (wantsScreen) {
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: screen,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: screen,
|
||||
range,
|
||||
documentation: `Go to the ${screen} screen.`,
|
||||
sortText: `0_${screen}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsGroup) {
|
||||
for (const group of groups) {
|
||||
suggestions.push({
|
||||
label: group.name?.trim() || group.id,
|
||||
detail: group.id,
|
||||
kind: api.languages.CompletionItemKind.Struct,
|
||||
insertText: group.id,
|
||||
range,
|
||||
documentation: `Group id: \`${group.id}\``,
|
||||
sortText: `0_${group.id}`,
|
||||
});
|
||||
}
|
||||
} else if (wantsLayer) {
|
||||
for (const element of elements) {
|
||||
suggestions.push({
|
||||
label: element.name?.trim() || element.id,
|
||||
detail: element.id,
|
||||
kind: api.languages.CompletionItemKind.Field,
|
||||
insertText: element.id,
|
||||
range,
|
||||
documentation: `${element.type} layer id: \`${element.id}\``,
|
||||
sortText: `0_${element.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
}
|
||||
|
||||
for (const snippet of snippets) {
|
||||
suggestions.push({
|
||||
label: snippet.label,
|
||||
kind: api.languages.CompletionItemKind.Snippet,
|
||||
insertText: snippet.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: snippet.documentation,
|
||||
sortText: `0_${snippet.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of Object.entries(statementDocs)) {
|
||||
suggestions.push({
|
||||
label: entry[0],
|
||||
kind: api.languages.CompletionItemKind.Keyword,
|
||||
insertText: entry[0],
|
||||
range,
|
||||
documentation: entry[1],
|
||||
sortText: `1_${entry[0]}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const variable of new Set([...THEME_CLOCK_VARIABLE_NAMES, ...variables])) {
|
||||
const clock = isThemeClockVariable(variable);
|
||||
suggestions.push({
|
||||
label: variable,
|
||||
kind: api.languages.CompletionItemKind.Variable,
|
||||
insertText: variable,
|
||||
range,
|
||||
documentation: clock
|
||||
? `Built-in clock value, read-only and refreshed every second. Display it in text layers with \`{${variable}}\`.`
|
||||
: `Theme variable. Assign it with \`${variable} += 1;\` and display it in text layers with \`{${variable}}\`.`,
|
||||
sortText: `${clock ? '1' : '2'}_${variable}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const screen of screens) {
|
||||
suggestions.push({
|
||||
label: `"${screen}"`,
|
||||
kind: api.languages.CompletionItemKind.EnumMember,
|
||||
insertText: `"${screen}"`,
|
||||
range,
|
||||
documentation: 'Screen name for `goTo()` and `screen`.',
|
||||
sortText: `3_${screen}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of actionFunctions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of functions) {
|
||||
suggestions.push({
|
||||
label: entry.label,
|
||||
kind: api.languages.CompletionItemKind.Function,
|
||||
insertText: entry.insert,
|
||||
insertTextRules: api.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
documentation: entry.documentation,
|
||||
sortText: `1_${entry.name}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { suggestions, incomplete: false };
|
||||
},
|
||||
});
|
||||
|
||||
const hover = api.languages.registerHoverProvider(LANGUAGE_ID, {
|
||||
provideHover(currentModel, position) {
|
||||
const word = currentModel.getWordAtPosition(position);
|
||||
if (!word) return null;
|
||||
|
||||
let contents = statementDocs[word.word];
|
||||
let kind = 'Keyword';
|
||||
if (!contents) {
|
||||
const entry = [...functions, ...actionFunctions].find(
|
||||
(candidate) => candidate.name === word.word
|
||||
);
|
||||
if (entry) {
|
||||
contents = `**${entry.label}**\n\n${entry.documentation}`;
|
||||
kind = 'Function';
|
||||
}
|
||||
}
|
||||
if (!contents && (isThemeClockVariable(word.word) || variables.includes(word.word))) {
|
||||
contents = isThemeClockVariable(word.word)
|
||||
? `**${word.word}** — built-in clock value\n\nRefreshes every second. Use it in text layers as \`{${word.word}}\` or read it directly in logic.`
|
||||
: `**${word.word}** — theme variable\n\nUse it in text layers as \`{${word.word}}\`.`;
|
||||
kind = 'Variable';
|
||||
}
|
||||
if (!contents) {
|
||||
const element = elements.find((candidate) => candidate.id === word.word);
|
||||
if (element) {
|
||||
contents = `**${element.name?.trim() || element.type}**\n\nLayer id: \`${element.id}\``;
|
||||
kind = 'Layer';
|
||||
}
|
||||
}
|
||||
if (!contents) {
|
||||
const group = groups.find((candidate) => candidate.id === word.word);
|
||||
if (group) {
|
||||
contents = `**${group.name?.trim() || 'Group'}**\n\nGroup id: \`${group.id}\``;
|
||||
kind = 'Group';
|
||||
}
|
||||
}
|
||||
if (!contents) return null;
|
||||
|
||||
return {
|
||||
range: new api.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn
|
||||
),
|
||||
contents: [{ value: `_${kind}_\n\n${contents}` }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
completion.dispose();
|
||||
hover.dispose();
|
||||
};
|
||||
};
|
||||
|
||||
const syncDiagnostics = (source: string) => {
|
||||
if (!monaco || !model) return;
|
||||
const result = parseThemeScript(source);
|
||||
const markers: Monaco.editor.IMarkerData[] = result.errors.map((error) => {
|
||||
const lineNumber = Math.min(Math.max(1, error.line), model!.getLineCount());
|
||||
const maxColumn = model!.getLineMaxColumn(lineNumber);
|
||||
const startColumn = Math.min(Math.max(1, error.column), maxColumn);
|
||||
return {
|
||||
severity: monaco!.MarkerSeverity.Error,
|
||||
message: error.message,
|
||||
source: 'EZPP theme script',
|
||||
startLineNumber: lineNumber,
|
||||
startColumn,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: Math.min(maxColumn, startColumn + 1),
|
||||
};
|
||||
});
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
errorCount = markers.length;
|
||||
};
|
||||
|
||||
const updateStats = (source: string) => {
|
||||
lineCount = Math.max(1, source.split('\n').length);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const next = value;
|
||||
if (!model || !monaco || next === currentValue) return;
|
||||
currentValue = next;
|
||||
model.setValue(next);
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!expanded) return;
|
||||
requestAnimationFrame(() => editor?.layout());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let disposeIntellisense: (() => void) | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { default: EditorWorker } =
|
||||
await import('monaco-editor/editor/editor.worker.js?worker');
|
||||
if (disposed) return;
|
||||
|
||||
type WorkerHost = typeof globalThis & {
|
||||
MonacoEnvironment?: { getWorker: () => Worker };
|
||||
};
|
||||
(globalThis as WorkerHost).MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
};
|
||||
|
||||
const api = await import('monaco-editor');
|
||||
if (disposed) return;
|
||||
|
||||
monaco = api;
|
||||
ensureLanguage(api);
|
||||
if (!host) return;
|
||||
|
||||
currentValue = value;
|
||||
updateStats(currentValue);
|
||||
model = api.editor.createModel(currentValue, LANGUAGE_ID);
|
||||
editor = api.editor.create(host, {
|
||||
model,
|
||||
theme: THEME_ID,
|
||||
ariaLabel: 'Advanced theme rule script',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 19,
|
||||
fontLigatures: true,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 3,
|
||||
glyphMargin: true,
|
||||
folding: true,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
scrollBeyondLastLine: false,
|
||||
smoothScrolling: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'line',
|
||||
fixedOverflowWidgets: true,
|
||||
quickSuggestions: { other: true, comments: false, strings: false },
|
||||
suggestOnTriggerCharacters: true,
|
||||
suggest: { showWords: false, preview: true },
|
||||
wordBasedSuggestions: 'off',
|
||||
bracketPairColorization: { enabled: true },
|
||||
guides: { bracketPairs: true, indentation: true },
|
||||
scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 },
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
const next = model?.getValue() ?? '';
|
||||
currentValue = next;
|
||||
updateStats(next);
|
||||
syncDiagnostics(next);
|
||||
onchange?.(next);
|
||||
});
|
||||
editor.onDidChangeCursorPosition((event) => {
|
||||
cursorLine = event.position.lineNumber;
|
||||
cursorColumn = event.position.column;
|
||||
});
|
||||
|
||||
disposeIntellisense = registerIntellisense(api);
|
||||
syncDiagnostics(currentValue);
|
||||
updateStats(currentValue);
|
||||
ready = true;
|
||||
editor.focus();
|
||||
} catch (error) {
|
||||
console.error('Could not load the Monaco script editor', error);
|
||||
loadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeIntellisense?.();
|
||||
editor?.dispose();
|
||||
model?.dispose();
|
||||
editor = undefined;
|
||||
model = undefined;
|
||||
monaco = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
requestAnimationFrame(() => {
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape' && expanded) {
|
||||
event.preventDefault();
|
||||
expanded = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-[100] cursor-default bg-black/75 backdrop-blur-sm"
|
||||
aria-label="Close expanded script editor"
|
||||
onclick={() => (expanded = false)}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden border border-theme-800 bg-[#08080c] {expanded
|
||||
? 'fixed inset-4 z-[101] rounded-xl shadow-2xl'
|
||||
: 'relative rounded-md'}"
|
||||
style={expanded ? '' : `height:${height}px`}
|
||||
>
|
||||
<div
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 border-b border-theme-800 bg-theme-950/80 px-2"
|
||||
>
|
||||
<Braces class="size-3 shrink-0 text-primary" />
|
||||
<span class="text-[9px] font-semibold uppercase tracking-wide text-theme-100">
|
||||
Advanced script
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-1.5">
|
||||
{#if loadError}
|
||||
<span class="text-[8px] text-destructive">Editor unavailable</span>
|
||||
{:else if errorCount > 0}
|
||||
<span class="flex items-center gap-0.5 text-[8px] text-destructive">
|
||||
<CircleAlert class="size-2.5" />
|
||||
{errorCount}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[8px] text-emerald-400">Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-theme-800 hover:text-theme-50"
|
||||
title={expanded ? 'Exit fullscreen' : 'Open fullscreen editor'}
|
||||
aria-label={expanded ? 'Exit fullscreen editor' : 'Open fullscreen editor'}
|
||||
onclick={toggleExpanded}
|
||||
>
|
||||
{#if expanded}
|
||||
<Minimize2 class="size-3" />
|
||||
{:else}
|
||||
<Maximize2 class="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Close editor"
|
||||
aria-label="Close expanded editor"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<X class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<textarea
|
||||
class="min-h-0 flex-1 resize-none border-0 bg-[#08080c] p-2 font-mono text-[11px] leading-relaxed text-theme-50 outline-none"
|
||||
spellcheck={false}
|
||||
{value}
|
||||
placeholder={'score += 1;\nif (score >= 3) {\n showLayer("secret");\n}'}
|
||||
oninput={(event) => {
|
||||
const next = (event.currentTarget as HTMLTextAreaElement).value;
|
||||
onchange?.(next);
|
||||
}}
|
||||
></textarea>
|
||||
<p class="border-t border-theme-800 px-2 py-1 text-[8px] text-destructive">
|
||||
Monaco could not load: {loadError}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div bind:this={host} class="absolute inset-0"></div>
|
||||
{#if !ready}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-[#08080c] text-[9px] text-muted-foreground"
|
||||
>
|
||||
Loading editor…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-5 shrink-0 items-center gap-3 border-t border-theme-800 px-2 text-[8px] text-muted-foreground"
|
||||
>
|
||||
<span>{lineCount} line{lineCount === 1 ? '' : 's'}</span>
|
||||
<span>Ln {cursorLine}, Col {cursorColumn}</span>
|
||||
<span class="ml-auto">
|
||||
{#if errorCount > 0}
|
||||
<span class="text-destructive">{errorCount} error{errorCount === 1 ? '' : 's'}</span>
|
||||
{:else}
|
||||
No errors
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from '@lucide/svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
open = $bindable(true),
|
||||
icon: Icon,
|
||||
badge,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
class: className = '',
|
||||
contentClass = 'space-y-3 pt-2.5',
|
||||
headingClass = 'text-xs font-semibold text-theme-100',
|
||||
bordered = true,
|
||||
}: {
|
||||
title: string;
|
||||
open?: boolean;
|
||||
icon?: Component<{ class?: string }>;
|
||||
badge?: string | number;
|
||||
description?: string;
|
||||
actions?: Snippet;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
headingClass?: string;
|
||||
bordered?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class="{bordered ? 'border-t border-theme-800 pt-3 ' : ''}{className}">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex min-w-0 flex-1 items-center gap-1.5 rounded-md py-0.5 text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon class="size-3.5 shrink-0 text-primary" />
|
||||
{/if}
|
||||
<span class="truncate {headingClass}">{title}</span>
|
||||
{#if badge !== undefined && badge !== ''}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-theme-900 px-1.5 py-0.5 text-[9px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
<ChevronDown
|
||||
class="ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-200 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if actions}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class={contentClass}>
|
||||
{#if description}
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,41 +1,61 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$assets/logo.png';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import ThemeV2Background from '$lib/components/theme/ThemeV2Background.svelte';
|
||||
import ThemeV2Elements from '$lib/components/theme/ThemeV2Elements.svelte';
|
||||
import { fontStack } from '$lib/themeFonts';
|
||||
import { readableTextColor, themeFontFaceCss, type ThemeV2Definition } from '$lib/themeV2';
|
||||
import { Brush, House, Music, Paintbrush, Play, Settings, User, Wifi } from '@lucide/svelte';
|
||||
import { scopeThemeCss } from '$lib/themeCssScope';
|
||||
import { fontStack, monoFontStack } from '$lib/themeFonts';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import {
|
||||
readableTextColor,
|
||||
themeFontFaceCss,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ScriptValue,
|
||||
} from '$lib/themeV2';
|
||||
|
||||
let {
|
||||
definition,
|
||||
assets = '',
|
||||
assetOverrides = {},
|
||||
selectedIds = [],
|
||||
selectedGroupId,
|
||||
interactive = false,
|
||||
scale = 1,
|
||||
variables = {},
|
||||
onselect,
|
||||
onselectmany,
|
||||
onselectgroup,
|
||||
onmove,
|
||||
onpatch,
|
||||
onpatchgroup,
|
||||
oncontext,
|
||||
oncontextgroup,
|
||||
}: {
|
||||
definition: ThemeV2Definition;
|
||||
assets?: string;
|
||||
assetOverrides?: Record<string, string>;
|
||||
selectedIds?: string[];
|
||||
selectedGroupId?: string;
|
||||
interactive?: boolean;
|
||||
scale?: number;
|
||||
variables?: Record<string, ThemeV2ScriptValue>;
|
||||
onselect?: (id: string | undefined, additive?: boolean) => void;
|
||||
onselectmany?: (ids: string[], additive?: boolean) => void;
|
||||
onselectgroup?: (id: string) => void;
|
||||
onmove?: (updates: { id: string; x: number; y: number }[]) => void;
|
||||
onpatch?: (id: string, patch: Record<string, unknown>) => void;
|
||||
onpatchgroup?: (id: string, patch: Partial<ThemeV2Group>) => void;
|
||||
oncontext?: (id: string | undefined, event: MouseEvent) => void;
|
||||
oncontextgroup?: (id: string, event: MouseEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const bodyFont = $derived(definition.typography?.body);
|
||||
const headingFont = $derived(definition.typography?.heading);
|
||||
const monoFont = $derived(definition.typography?.mono);
|
||||
const accent = $derived(definition.accentColor?.trim() || '#ff006e');
|
||||
const accentText = $derived(readableTextColor(accent));
|
||||
const iconSet = $derived(definition.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
const stageStyle = $derived(
|
||||
[
|
||||
`font-family:${fontStack(bodyFont)}`,
|
||||
@@ -46,17 +66,19 @@
|
||||
`--color-primary-foreground:${accentText}`,
|
||||
`--color-primary-500:${accent}`,
|
||||
`--color-ring:${accent}`,
|
||||
`--font-mono:${monoFontStack(monoFont)}`,
|
||||
].join(';')
|
||||
);
|
||||
const fontFaces = $derived(themeFontFaceCss(definition, assets, assetOverrides));
|
||||
const customCss = $derived(scopeThemeCss(definition.css, '.theme-stage'));
|
||||
const elements = $derived(definition.elements ?? []);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<style>${fontFaces}</style>`}
|
||||
{@html `<style>${fontFaces}\n${customCss}</style>`}
|
||||
|
||||
<div
|
||||
class="theme-stage relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
class="theme-stage dark relative h-[650px] w-[1000px] overflow-hidden bg-theme-950 text-theme-50"
|
||||
style={stageStyle}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -75,24 +97,24 @@
|
||||
3.2.7
|
||||
</span>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary/50 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] bg-primary p-3 text-primary-foreground ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<House class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="home" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Paintbrush class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="themes" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 ring-1 ring-inset ring-white/15"
|
||||
class="mt-3 flex size-12 items-center justify-center rounded-[0.85rem] border border-black/20 bg-black/20 p-3 text-theme-200 ring-1 ring-inset ring-white/15"
|
||||
>
|
||||
<Settings class="size-5 text-theme-200" />
|
||||
<ThemeIcon name="settings" set={iconSet} class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
class="mt-auto flex size-10 items-center justify-center overflow-hidden rounded-full bg-theme-900"
|
||||
>
|
||||
<User class="size-5 text-muted-foreground" />
|
||||
<ThemeIcon name="user" set={iconSet} class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -139,15 +161,15 @@
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Music class="size-3.5 text-blue-400" />
|
||||
<ThemeIcon name="music" set={iconSet} class="size-3.5 text-blue-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">1.2k</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Brush class="size-3.5 text-amber-400" />
|
||||
<ThemeIcon name="brush" set={iconSet} class="size-3.5 text-amber-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">42</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Wifi class="size-3.5 text-emerald-400" />
|
||||
<ThemeIcon name="wifi" set={iconSet} class="size-3.5 text-emerald-400" />
|
||||
<span class="text-[11px] font-medium text-white/70">19ms</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-white/10"></div>
|
||||
@@ -172,7 +194,11 @@
|
||||
<div
|
||||
class="relative z-10 flex size-9 items-center justify-center rounded-full bg-black/30"
|
||||
>
|
||||
<Play class="ml-0.5 size-4 fill-current text-primary-foreground" />
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={iconSet}
|
||||
class="ml-0.5 size-4 fill-current text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-52 pl-1 pr-6 text-center">
|
||||
@@ -185,15 +211,26 @@
|
||||
|
||||
<ThemeV2Elements
|
||||
{elements}
|
||||
groups={definition.groups}
|
||||
{assets}
|
||||
{assetOverrides}
|
||||
themeName={definition.name}
|
||||
themeAuthor={definition.author}
|
||||
themeVersion={definition.version}
|
||||
accentColor={definition.accentColor}
|
||||
{iconSet}
|
||||
{interactive}
|
||||
{selectedIds}
|
||||
{selectedGroupId}
|
||||
{scale}
|
||||
{variables}
|
||||
{onselect}
|
||||
{onselectmany}
|
||||
{onselectgroup}
|
||||
{onmove}
|
||||
{onpatch}
|
||||
{onpatchgroup}
|
||||
{oncontext}
|
||||
{oncontextgroup}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type {
|
||||
ThemeV2TextDecoration,
|
||||
ThemeV2TextStyle,
|
||||
ThemeV2TextTransform,
|
||||
} from '$lib/themeV2';
|
||||
import { Bold, Italic, RotateCcw, Strikethrough, Underline } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
element,
|
||||
onchange,
|
||||
}: {
|
||||
element: ThemeV2TextStyle;
|
||||
onchange: (patch: Partial<ThemeV2TextStyle>) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
const colorField = 'h-8 w-10 shrink-0 cursor-pointer border-theme-800 bg-theme-950 p-0.5';
|
||||
|
||||
const bold = $derived((element.fontWeight ?? 600) >= 700);
|
||||
const italic = $derived(element.fontStyle === 'italic');
|
||||
const underline = $derived((element.textDecoration ?? 'none').includes('underline'));
|
||||
const strike = $derived((element.textDecoration ?? 'none').includes('line-through'));
|
||||
const gradient = $derived(element.fillMode === 'gradient');
|
||||
|
||||
const transformOptions: { value: ThemeV2TextTransform; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'uppercase', label: 'UPPERCASE' },
|
||||
{ value: 'lowercase', label: 'lowercase' },
|
||||
{ value: 'capitalize', label: 'Capitalize' },
|
||||
];
|
||||
|
||||
const inputValue = (event: Event) => (event.currentTarget as HTMLInputElement).value;
|
||||
|
||||
function toggleDecoration(nextUnderline: boolean, nextStrike: boolean) {
|
||||
const parts: string[] = [];
|
||||
if (nextUnderline) parts.push('underline');
|
||||
if (nextStrike) parts.push('line-through');
|
||||
onchange({ textDecoration: (parts.join(' ') || 'none') as ThemeV2TextDecoration });
|
||||
}
|
||||
|
||||
function setFillMode(value: ThemeV2TextStyle['fillMode']) {
|
||||
const fillMode = value === 'gradient' ? 'gradient' : 'solid';
|
||||
onchange({
|
||||
fillMode,
|
||||
...(fillMode === 'gradient'
|
||||
? {
|
||||
gradientFrom: element.gradientFrom ?? '#ff006e',
|
||||
gradientTo: element.gradientTo ?? '#7c3aed',
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resetTextStyle() {
|
||||
onchange({
|
||||
fontWeight: 600,
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textTransform: 'none',
|
||||
fillMode: 'solid',
|
||||
gradientFrom: '#ff006e',
|
||||
gradientTo: '#7c3aed',
|
||||
gradientAngle: 90,
|
||||
outlineWidth: 0,
|
||||
outlineColor: '#000000',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
shadowColor: '#000000',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet textStyleActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title="Reset bold, italic, decoration, case, fill, outline and shadow"
|
||||
onclick={resetTextStyle}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Text style"
|
||||
open={false}
|
||||
actions={textStyleActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-1">
|
||||
<Button
|
||||
variant={bold ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Bold"
|
||||
aria-label="Bold"
|
||||
aria-pressed={bold}
|
||||
onclick={() => onchange({ fontWeight: bold ? 400 : 700 })}
|
||||
>
|
||||
<Bold class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={italic ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Italic"
|
||||
aria-label="Italic"
|
||||
aria-pressed={italic}
|
||||
onclick={() => onchange({ fontStyle: italic ? 'normal' : 'italic' })}
|
||||
>
|
||||
<Italic class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={underline ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Underline"
|
||||
aria-label="Underline"
|
||||
aria-pressed={underline}
|
||||
onclick={() => toggleDecoration(!underline, strike)}
|
||||
>
|
||||
<Underline class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={strike ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-8 w-full"
|
||||
title="Strikethrough"
|
||||
aria-label="Strikethrough"
|
||||
aria-pressed={strike}
|
||||
onclick={() => toggleDecoration(underline, !strike)}
|
||||
>
|
||||
<Strikethrough class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="element-text-transform" class={label}>Case</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.textTransform ?? 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value) onchange({ textTransform: value as ThemeV2TextTransform });
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-text-transform"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{transformOptions.find((option) => option.value === (element.textTransform ?? 'none'))
|
||||
?.label ?? 'None'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
{#each transformOptions as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} />
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-fill-mode" class={label}>Fill</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={element.fillMode ?? 'solid'}
|
||||
onValueChange={(value) => setFillMode(value as ThemeV2TextStyle['fillMode'])}
|
||||
>
|
||||
<Select.Trigger
|
||||
id="element-fill-mode"
|
||||
class="h-8 border-theme-800 bg-theme-950 text-xs text-theme-50"
|
||||
>
|
||||
{element.fillMode === 'gradient' ? 'Gradient' : 'Solid'}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="border-theme-800 bg-theme-950">
|
||||
<Select.Item value="solid" label="Solid" />
|
||||
<Select.Item value="gradient" label="Gradient" />
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if gradient}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient from</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100"
|
||||
>{element.gradientFrom ?? '#ff006e'}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientFrom ?? '#ff006e'}
|
||||
oninput={(event) => onchange({ gradientFrom: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Gradient to</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.gradientTo ?? '#7c3aed'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.gradientTo ?? '#7c3aed'}
|
||||
oninput={(event) => onchange({ gradientTo: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="element-gradient-angle" class={label}>Gradient angle</Label>
|
||||
<Input
|
||||
id="element-gradient-angle"
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="360"
|
||||
step="1"
|
||||
value={element.gradientAngle ?? 90}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
gradientAngle: Math.min(360, Math.max(0, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class={label}>Color</Label>
|
||||
<span class="font-mono text-[9px] text-theme-100">{element.color ?? '#ffffff'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
value={element.color ?? '#ffffff'}
|
||||
oninput={(event) => onchange({ color: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Outline</Label>
|
||||
<div class="grid grid-cols-[72px_40px_1fr] gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
aria-label="Outline width"
|
||||
title="Outline width in pixels; 0 disables it"
|
||||
value={element.outlineWidth ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ outlineWidth: Math.min(20, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Outline color"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} min-w-0 font-mono"
|
||||
aria-label="Outline color hex"
|
||||
value={element.outlineColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ outlineColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label class={label}>Shadow</Label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow blur"
|
||||
title="Shadow blur"
|
||||
value={element.shadowBlur ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({ shadowBlur: Math.min(40, Math.max(0, Number(inputValue(event)) || 0)) })}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset X"
|
||||
title="Shadow offset X"
|
||||
value={element.shadowOffsetX ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetX: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
class={field}
|
||||
type="number"
|
||||
min="-40"
|
||||
max="40"
|
||||
step="1"
|
||||
aria-label="Shadow offset Y"
|
||||
title="Shadow offset Y"
|
||||
value={element.shadowOffsetY ?? 0}
|
||||
onchange={(event) =>
|
||||
onchange({
|
||||
shadowOffsetY: Math.min(40, Math.max(-40, Number(inputValue(event)) || 0)),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
class={colorField}
|
||||
type="color"
|
||||
aria-label="Shadow color"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
<Input
|
||||
class="{field} flex-1 font-mono"
|
||||
aria-label="Shadow color hex"
|
||||
value={element.shadowColor ?? '#000000'}
|
||||
oninput={(event) => onchange({ shadowColor: inputValue(event) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import type { ThemeV2Transform } from '$lib/themeV2';
|
||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
||||
|
||||
type TransformTarget = ThemeV2Transform & { offsetX?: number; offsetY?: number };
|
||||
type TransformPatch = Partial<TransformTarget>;
|
||||
|
||||
let {
|
||||
transform,
|
||||
idPrefix,
|
||||
includeTranslate = false,
|
||||
onchange,
|
||||
}: {
|
||||
transform: TransformTarget;
|
||||
idPrefix: string;
|
||||
includeTranslate?: boolean;
|
||||
onchange: (patch: TransformPatch) => void;
|
||||
} = $props();
|
||||
|
||||
const field = 'h-8 border-theme-800 bg-theme-950 text-xs text-theme-50';
|
||||
const label = 'text-[10px] font-medium uppercase tracking-wide text-muted-foreground';
|
||||
|
||||
const scaleX = $derived(transform.scaleX ?? transform.scale ?? 1);
|
||||
const scaleY = $derived(transform.scaleY ?? transform.scale ?? 1);
|
||||
const isDefault = $derived(
|
||||
(transform.rotation ?? 0) === 0 &&
|
||||
scaleX === 1 &&
|
||||
scaleY === 1 &&
|
||||
(transform.skewX ?? 0) === 0 &&
|
||||
(transform.skewY ?? 0) === 0 &&
|
||||
!transform.flipX &&
|
||||
!transform.flipY &&
|
||||
(!includeTranslate || ((transform.offsetX ?? 0) === 0 && (transform.offsetY ?? 0) === 0))
|
||||
);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
function numberValue(value: string): number | undefined {
|
||||
if (value.trim() === '') return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function setScale(axis: 'scaleX' | 'scaleY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const scale = clamp(parsed / 100, 0.05, 10);
|
||||
onchange(axis === 'scaleX' ? { scaleX: scale } : { scaleY: scale });
|
||||
}
|
||||
|
||||
function setSkew(axis: 'skewX' | 'skewY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const skew = clamp(parsed, -89, 89);
|
||||
onchange(axis === 'skewX' ? { skewX: skew } : { skewY: skew });
|
||||
}
|
||||
|
||||
function setRotation(value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
onchange({ rotation: clamp(parsed, -360, 360) });
|
||||
}
|
||||
|
||||
function rotateBy(delta: number) {
|
||||
const next = (transform.rotation ?? 0) + delta;
|
||||
onchange({ rotation: ((((next + 180) % 360) + 360) % 360) - 180 });
|
||||
}
|
||||
|
||||
function setOffset(axis: 'offsetX' | 'offsetY', value: string) {
|
||||
const parsed = numberValue(value);
|
||||
if (parsed === undefined) return;
|
||||
const offset = clamp(parsed, -2000, 2000);
|
||||
onchange(axis === 'offsetX' ? { offsetX: offset } : { offsetY: offset });
|
||||
}
|
||||
|
||||
function resetTransform() {
|
||||
onchange({
|
||||
rotation: 0,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
...(includeTranslate ? { offsetX: 0, offsetY: 0 } : {}),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet transformActions()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 gap-1 px-1.5 text-[9px] text-muted-foreground"
|
||||
title={includeTranslate
|
||||
? 'Reset translation, rotation, scale, skew and flips'
|
||||
: 'Reset rotation, scale, skew and flips'}
|
||||
disabled={isDefault}
|
||||
onclick={resetTransform}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
Reset
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<ThemeSection
|
||||
title="Transform"
|
||||
open={false}
|
||||
actions={transformActions}
|
||||
contentClass="space-y-2 pt-2.5"
|
||||
>
|
||||
{#if includeTranslate}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-x" class={label}>Translate X</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetX ?? 0}
|
||||
onchange={(event) => setOffset('offsetX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-translate-y" class={label}>Translate Y</Label>
|
||||
<Input
|
||||
id="{idPrefix}-translate-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-2000"
|
||||
max="2000"
|
||||
value={transform.offsetY ?? 0}
|
||||
onchange={(event) => setOffset('offsetY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-x" class={label}>Scale X %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleX * 100)}
|
||||
onchange={(event) => setScale('scaleX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-scale-y" class={label}>Scale Y %</Label>
|
||||
<Input
|
||||
id="{idPrefix}-scale-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="5"
|
||||
max="1000"
|
||||
step="5"
|
||||
value={Math.round(scaleY * 100)}
|
||||
onchange={(event) => setScale('scaleY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-x" class={label}>Skew X °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-x"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewX ?? 0}
|
||||
onchange={(event) => setSkew('skewX', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="{idPrefix}-skew-y" class={label}>Skew Y °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-skew-y"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-89"
|
||||
max="89"
|
||||
step="1"
|
||||
value={transform.skewY ?? 0}
|
||||
onchange={(event) => setSkew('skewY', event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<Label for="{idPrefix}-rotation" class={label}>Rotation °</Label>
|
||||
<Input
|
||||
id="{idPrefix}-rotation"
|
||||
class={field}
|
||||
type="number"
|
||||
min="-360"
|
||||
max="360"
|
||||
step="1"
|
||||
value={transform.rotation ?? 0}
|
||||
onchange={(event) => setRotation(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant={transform.flipX ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip horizontally"
|
||||
aria-label="Flip horizontally"
|
||||
aria-pressed={transform.flipX === true}
|
||||
onclick={() => onchange({ flipX: !transform.flipX })}
|
||||
>
|
||||
<FlipHorizontal2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={transform.flipY ? 'default' : 'secondary'}
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Flip vertically"
|
||||
aria-label="Flip vertically"
|
||||
aria-pressed={transform.flipY === true}
|
||||
onclick={() => onchange({ flipY: !transform.flipY })}
|
||||
>
|
||||
<FlipVertical2 class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate left 90°"
|
||||
aria-label="Rotate left 90 degrees"
|
||||
onclick={() => rotateBy(-90)}
|
||||
>
|
||||
<RotateCcw class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-7"
|
||||
title="Rotate right 90°"
|
||||
aria-label="Rotate right 90 degrees"
|
||||
onclick={() => rotateBy(90)}
|
||||
>
|
||||
<RotateCw class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeSection>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import ThemeSection from '$lib/components/theme/ThemeSection.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
import type { ThemeV2ScriptValue } from '$lib/themeV2';
|
||||
import { RotateCcw } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
variables,
|
||||
defaults,
|
||||
onchange,
|
||||
}: {
|
||||
variables: string[];
|
||||
defaults: Record<string, ThemeV2ScriptValue>;
|
||||
onchange: (name: string, value: ThemeV2ScriptValue | undefined) => void;
|
||||
} = $props();
|
||||
|
||||
const hasDefault = (name: string) => Object.prototype.hasOwnProperty.call(defaults, name);
|
||||
|
||||
const valueLabel = (value: ThemeV2ScriptValue) =>
|
||||
typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text';
|
||||
|
||||
const displayValue = (name: string) => {
|
||||
if (!hasDefault(name)) return '';
|
||||
const value = defaults[name];
|
||||
return typeof value === 'string' ? value : String(value);
|
||||
};
|
||||
|
||||
const parseValue = (raw: string): ThemeV2ScriptValue | undefined => {
|
||||
if (!raw.trim()) return undefined;
|
||||
if (raw.trim() === 'true') return true;
|
||||
if (raw.trim() === 'false') return false;
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(raw.trim())) {
|
||||
const number = Number(raw.trim());
|
||||
if (Number.isFinite(number)) return number;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ThemeSection title="Variables" badge={variables.length} contentClass="space-y-2 pt-2">
|
||||
<p class="text-[10px] leading-relaxed text-muted-foreground">
|
||||
Starting values for
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{name}'}</code>
|
||||
placeholders and logic variables. Leave a field empty to start at
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">0</code>.
|
||||
</p>
|
||||
|
||||
{#if variables.length === 0}
|
||||
<p
|
||||
class="rounded-md border border-dashed border-theme-800 p-2.5 text-[10px] leading-relaxed text-muted-foreground"
|
||||
>
|
||||
Add a
|
||||
<code class="rounded bg-theme-900 px-1 py-0.5 text-theme-100">{'{placeholder}'}</code>
|
||||
to a text layer or use a variable in a rule to see it here.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each variables as name (name)}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md border border-theme-800 bg-theme-950 px-2 py-1.5"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-mono text-[10px] text-theme-100" title={`{${name}}`}>
|
||||
{name}
|
||||
</div>
|
||||
<div class="text-[8px] leading-tight text-muted-foreground">
|
||||
{hasDefault(name) ? valueLabel(defaults[name]) : 'no default · starts at 0'}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
class="h-7 w-24 shrink-0 border-theme-800 bg-theme-950 px-2 font-mono text-[10px] text-theme-50"
|
||||
value={displayValue(name)}
|
||||
placeholder="0"
|
||||
spellcheck="false"
|
||||
aria-label={`Default value for ${name}`}
|
||||
onchange={(event) => onchange(name, parseValue(event.currentTarget.value))}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-theme-100"
|
||||
title={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
aria-label={hasDefault(name) ? `Reset ${name} to 0` : `${name} already starts at 0`}
|
||||
disabled={!hasDefault(name)}
|
||||
onclick={() => onchange(name, undefined)}
|
||||
>
|
||||
<RotateCcw class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</ThemeSection>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Ellipsis from '@lucide/svelte/icons/ellipsis';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import { DEFAULT_THEME_ICON_SET, type ThemeIconSet } from '$lib/themeIcons';
|
||||
|
||||
const prop: {
|
||||
downloading: boolean;
|
||||
@@ -8,6 +8,7 @@
|
||||
text: string;
|
||||
subtext?: string;
|
||||
disabled?: boolean;
|
||||
iconSet?: ThemeIconSet;
|
||||
onClick?: () => void;
|
||||
} = $props();
|
||||
|
||||
@@ -66,14 +67,22 @@
|
||||
<div class="size-9 rounded-full bg-black/30 flex items-center justify-center relative z-10">
|
||||
{#if prop.downloading}
|
||||
{#if prop.progress === -1}
|
||||
<Ellipsis class="size-4 text-primary-foreground fill-current animate-pulse" />
|
||||
<ThemeIcon
|
||||
name="ellipsis"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current animate-pulse"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-[10px] font-bold text-primary-foreground">
|
||||
{prop.progress.toFixed()}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<Play class="size-4 text-primary-foreground fill-current ml-0.5" />
|
||||
<ThemeIcon
|
||||
name="launch"
|
||||
set={prop.iconSet ?? DEFAULT_THEME_ICON_SET}
|
||||
class="size-4 text-primary-foreground fill-current ml-0.5"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ThemeIcon from '$lib/components/theme/ThemeIcon.svelte';
|
||||
import { config } from '$lib/config';
|
||||
import { custom_theme_audio_playing, custom_theme_volume, platform } from '$lib/global';
|
||||
import {
|
||||
active_custom_theme,
|
||||
custom_theme_audio_playing,
|
||||
custom_theme_volume,
|
||||
platform,
|
||||
} from '$lib/global';
|
||||
import { DEFAULT_THEME_ICON_SET } from '$lib/themeIcons';
|
||||
import { reduceAnimations } from '$lib/userSettings';
|
||||
import { Volume, Volume1, VolumeOff } from '@lucide/svelte';
|
||||
import Minimize from '@lucide/svelte/icons/minus';
|
||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||
import Close from '@lucide/svelte/icons/x';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET);
|
||||
|
||||
onMount(() => {
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
@@ -33,13 +40,13 @@
|
||||
></div>
|
||||
|
||||
{#if $custom_theme_volume > 0.7}
|
||||
<Volume2 class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else if $custom_theme_volume >= 0.15}
|
||||
<Volume1 class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else if $custom_theme_volume > 0}
|
||||
<Volume class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-low" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{:else}
|
||||
<VolumeOff class="z-10 pointer-events-none" size={14} />
|
||||
<ThemeIcon name="volume-off" set={iconSet} class="z-10 pointer-events-none" size={14} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +69,7 @@
|
||||
max="1"
|
||||
step="0.01"
|
||||
bind:value={$custom_theme_volume}
|
||||
on:input={() => {
|
||||
oninput={() => {
|
||||
config.value<number>('volume').set($custom_theme_volume);
|
||||
}}
|
||||
class="volume-slider"
|
||||
|
||||
@@ -30,6 +30,8 @@ export const launching = writable<boolean>(false);
|
||||
export const custom_themes = writable<Theme[]>([]);
|
||||
export const custom_theme_container = writable<HTMLElement | undefined>(undefined);
|
||||
export const active_custom_theme = writable<Theme | undefined>(undefined);
|
||||
/** Incremented to remount theme media after reloading the active theme from disk. */
|
||||
export const theme_reload_token = writable(0);
|
||||
export const custom_theme_audio_playing = writable<boolean>(false);
|
||||
export const custom_theme_volume = writable<number>(0.15);
|
||||
custom_theme_volume.subscribe((val) => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ThemeV2ScriptValue } from './themeV2';
|
||||
|
||||
/**
|
||||
* Built-in clock values available to theme text layers and logic. They are
|
||||
* refreshed by the theme script runtime and are read-only for theme authors.
|
||||
*/
|
||||
export const THEME_CLOCK_VARIABLE_NAMES = [
|
||||
'time',
|
||||
'time12',
|
||||
'timeSeconds',
|
||||
'hour',
|
||||
'minute',
|
||||
'second',
|
||||
'dayPeriod',
|
||||
] as const;
|
||||
|
||||
export type ThemeClockVariableName = (typeof THEME_CLOCK_VARIABLE_NAMES)[number];
|
||||
|
||||
const CLOCK_VARIABLE_SET = new Set<string>(THEME_CLOCK_VARIABLE_NAMES);
|
||||
|
||||
export const isThemeClockVariable = (name: string): name is ThemeClockVariableName =>
|
||||
CLOCK_VARIABLE_SET.has(name);
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
|
||||
/** Local clock values, using the viewer's locale-independent 24h/12h formats. */
|
||||
export const themeClockValues = (now: Date = new Date()): Record<string, ThemeV2ScriptValue> => {
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const second = now.getSeconds();
|
||||
const time = `${pad(hour)}:${pad(minute)}`;
|
||||
const dayPeriod = hour < 12 ? 'AM' : 'PM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
|
||||
return {
|
||||
time,
|
||||
time12: `${hour12}:${pad(minute)} ${dayPeriod}`,
|
||||
timeSeconds: `${time}:${pad(second)}`,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
dayPeriod,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Theme custom CSS is global in the launcher. The advanced editor renders a
|
||||
* single 1000 x 650 canvas at a time, so previews are scoped to that canvas
|
||||
* instead of restyling the editor's own chrome.
|
||||
*/
|
||||
|
||||
const ROOT_SELECTORS = /(^|[\s>+~,(])(?::root|html|body)(?=$|[\s>+~.#:[\],)])/g;
|
||||
|
||||
const splitSelectorList = (selectorText: string): string[] => {
|
||||
const selectors: string[] = [];
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < selectorText.length; index += 1) {
|
||||
const character = selectorText[index];
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (character === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === '(' || character === '[') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if ((character === ')' || character === ']') && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
if (character === ',' && depth === 0) {
|
||||
selectors.push(selectorText.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
selectors.push(selectorText.slice(start));
|
||||
return selectors;
|
||||
};
|
||||
|
||||
const scopeSelector = (selector: string, scope: string): string => {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed || trimmed.startsWith(scope)) return trimmed;
|
||||
|
||||
const rooted = trimmed.replace(
|
||||
ROOT_SELECTORS,
|
||||
(_match, boundary: string) => `${boundary}${scope}`
|
||||
);
|
||||
if (rooted.startsWith(scope)) return rooted;
|
||||
return `${scope} ${rooted}`;
|
||||
};
|
||||
|
||||
/** Prefix every selector in a comma-separated list with the editor canvas scope. */
|
||||
export const scopeThemeSelectors = (selectorText: string, scope: string): string =>
|
||||
splitSelectorList(selectorText)
|
||||
.map((selector) => scopeSelector(selector, scope))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const scopeRule = (rule: CSSRule, scope: string) => {
|
||||
if (rule.type === 1) {
|
||||
const styleRule = rule as CSSStyleRule;
|
||||
styleRule.selectorText = scopeThemeSelectors(styleRule.selectorText, scope);
|
||||
return;
|
||||
}
|
||||
|
||||
const grouping = rule as CSSGroupingRule;
|
||||
if (grouping.cssRules) {
|
||||
for (const child of Array.from(grouping.cssRules)) scopeRule(child, scope);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse custom CSS with the browser and scope its selectors to the preview
|
||||
* canvas. `@font-face`, keyframes and at-rule blocks are preserved.
|
||||
*/
|
||||
export const scopeThemeCss = (css: string | undefined, scope: string): string => {
|
||||
const source = css?.trim();
|
||||
if (!source || typeof document === 'undefined') return '';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.media = 'not all';
|
||||
style.textContent = source;
|
||||
document.head.appendChild(style);
|
||||
|
||||
try {
|
||||
const sheet = style.sheet;
|
||||
if (!sheet) return '';
|
||||
const rules = Array.from(sheet.cssRules);
|
||||
for (const rule of rules) scopeRule(rule, scope);
|
||||
return rules.map((rule) => rule.cssText).join('\n');
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
style.remove();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { currentUserInfo } from './data';
|
||||
import { launcherVersion, onlineFriends, osuBuild, osuStream, serverPing } from './global';
|
||||
import { themeClockValues } from './themeClock';
|
||||
import type { ThemeV2DataField, ThemeV2DataMode } from './themeV2';
|
||||
import { currentUser } from './userAuthentication';
|
||||
import { derived } from 'svelte/store';
|
||||
|
||||
export type ThemeDataStats = {
|
||||
pp: number;
|
||||
rank: number;
|
||||
plays: number;
|
||||
accuracy: number;
|
||||
playtime: number;
|
||||
};
|
||||
|
||||
/** Live launcher values shared by every data layer. */
|
||||
export type ThemeGlobalData = {
|
||||
user: {
|
||||
name: string;
|
||||
country: string;
|
||||
stats: Record<ThemeV2DataMode, ThemeDataStats | undefined>;
|
||||
};
|
||||
launcher: { version: string; friends: number | undefined };
|
||||
osu: { build: string | undefined; stream: string | undefined };
|
||||
server: { ping: number | undefined };
|
||||
};
|
||||
|
||||
export type ThemeDataContext = {
|
||||
global: ThemeGlobalData;
|
||||
theme: { name: string; author: string; version: string };
|
||||
screen: string;
|
||||
mode?: ThemeV2DataMode;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
const MODE_KEYS: Record<ThemeV2DataMode, number> = {
|
||||
osu: 0,
|
||||
taiko: 1,
|
||||
catch: 2,
|
||||
mania: 3,
|
||||
};
|
||||
|
||||
const number = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
const emptyStats = (): Record<ThemeV2DataMode, ThemeDataStats | undefined> => ({
|
||||
osu: undefined,
|
||||
taiko: undefined,
|
||||
catch: undefined,
|
||||
mania: undefined,
|
||||
});
|
||||
|
||||
export const themeGlobalData = derived(
|
||||
[currentUser, currentUserInfo, launcherVersion, osuBuild, osuStream, serverPing, onlineFriends],
|
||||
([$user, $info, $version, $build, $stream, $ping, $friends]): ThemeGlobalData => {
|
||||
const stats = emptyStats();
|
||||
for (const mode of Object.keys(MODE_KEYS) as ThemeV2DataMode[]) {
|
||||
const raw = $info?.stats?.[MODE_KEYS[mode]];
|
||||
if (!raw) continue;
|
||||
stats[mode] = {
|
||||
pp: number(raw.pp) ?? 0,
|
||||
rank: number(raw.rank) ?? 0,
|
||||
plays: number(raw.plays) ?? 0,
|
||||
accuracy: number(raw.acc) ?? 0,
|
||||
playtime: number(raw.playtime) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
name: $user?.name ?? $info?.info.name ?? '',
|
||||
country: $info?.info.country ?? '',
|
||||
stats,
|
||||
},
|
||||
launcher: {
|
||||
version: $version ?? '',
|
||||
friends: $friends,
|
||||
},
|
||||
osu: { build: $build, stream: $stream },
|
||||
server: { ping: $ping },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const formatInteger = (value: number) => Math.round(value).toLocaleString('en-US');
|
||||
|
||||
const formatAccuracy = (value: number) =>
|
||||
`${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
||||
|
||||
const formatPlaytime = (seconds: number) => {
|
||||
const totalMinutes = Math.max(0, Math.round(seconds / 60));
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours > 0) return `${formatInteger(hours)}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date);
|
||||
|
||||
/** Current value for a data layer field. Missing values render as an empty string. */
|
||||
export const resolveThemeDataValue = (field: ThemeV2DataField, context: ThemeDataContext) => {
|
||||
const { global, theme, screen, mode = 'osu', now = new Date() } = context;
|
||||
const stats = global.user.stats[mode];
|
||||
|
||||
switch (field) {
|
||||
case 'user.name':
|
||||
return global.user.name;
|
||||
case 'user.country':
|
||||
return global.user.country.toUpperCase();
|
||||
case 'user.pp':
|
||||
return stats ? formatInteger(stats.pp) : '';
|
||||
case 'user.rank':
|
||||
return stats && stats.rank > 0 ? formatInteger(stats.rank) : '';
|
||||
case 'user.plays':
|
||||
return stats ? formatInteger(stats.plays) : '';
|
||||
case 'user.accuracy':
|
||||
return stats ? formatAccuracy(stats.accuracy) : '';
|
||||
case 'user.playtime':
|
||||
return stats ? formatPlaytime(stats.playtime) : '';
|
||||
case 'launcher.version':
|
||||
return global.launcher.version;
|
||||
case 'server.friends':
|
||||
return global.launcher.friends === undefined ? '' : formatInteger(global.launcher.friends);
|
||||
case 'osu.build':
|
||||
return global.osu.build ?? '';
|
||||
case 'osu.stream':
|
||||
return global.osu.stream ?? '';
|
||||
case 'server.ping':
|
||||
return global.server.ping === undefined ? '' : `${Math.round(global.server.ping)} ms`;
|
||||
case 'theme.name':
|
||||
return theme.name;
|
||||
case 'theme.author':
|
||||
return theme.author;
|
||||
case 'theme.version':
|
||||
return theme.version;
|
||||
case 'screen':
|
||||
return screen ? screen.charAt(0).toUpperCase() + screen.slice(1) : '';
|
||||
case 'clock.time':
|
||||
return String(themeClockValues(now).time ?? '');
|
||||
case 'clock.time12':
|
||||
return String(themeClockValues(now).time12 ?? '');
|
||||
case 'clock.date':
|
||||
return formatDate(now);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
+22
-1
@@ -14,5 +14,26 @@ export const BUNDLED_THEME_FONTS: ThemeFontChoice[] = [
|
||||
{ label: 'JetBrains Mono', family: 'JetBrains Mono Variable', category: 'Mono' },
|
||||
];
|
||||
|
||||
const quoteFamily = (family: string) => `"${family.replace(/["\\]/g, '\\$&')}"`;
|
||||
|
||||
export const fontStack = (family: string | undefined, fallback = 'Sora Variable') =>
|
||||
`"${(family || fallback).replace(/["\\]/g, '\\$&')}", ui-sans-serif, system-ui, sans-serif`;
|
||||
`${quoteFamily(family || fallback)}, ui-sans-serif, system-ui, sans-serif`;
|
||||
|
||||
export const monoFontStack = (family: string | undefined, fallback = 'Space Mono') =>
|
||||
`${quoteFamily(family || fallback)}, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`;
|
||||
|
||||
const normalizeFamily = (family: string) =>
|
||||
family
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+variable$/, '');
|
||||
|
||||
/** Resolve a Fontsource family name to the family the launcher already ships. */
|
||||
export const bundledThemeFontFamily = (family: string | undefined) => {
|
||||
if (!family) return undefined;
|
||||
const normalized = normalizeFamily(family);
|
||||
return BUNDLED_THEME_FONTS.find(
|
||||
(font) =>
|
||||
normalizeFamily(font.family) === normalized || normalizeFamily(font.label) === normalized
|
||||
)?.family;
|
||||
};
|
||||
@@ -0,0 +1,704 @@
|
||||
// Generated by scripts/generate-theme-icons.ts. Do not edit by hand.
|
||||
// Icon artwork: Lucide (ISC), Phosphor (MIT), Tabler (MIT).
|
||||
import type { ThemeIconName, ThemeIconSet } from './themeIcons';
|
||||
|
||||
export type ThemeIconBody = { width: number; height: number; body: string };
|
||||
|
||||
export const THEME_ICON_BODIES: Record<ThemeIconSet, Record<ThemeIconName, ThemeIconBody>> = {
|
||||
lucide: {
|
||||
home: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></g>',
|
||||
},
|
||||
themes: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 22a1 1 0 0 1 0-20a10 9 0 0 1 10 9a5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></g>',
|
||||
},
|
||||
settings: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 5H3m9 14H3M14 3v4m2 10v4m5-9h-9m9 7h-5m5-14h-7m-6 5v4m0-2H3"/>',
|
||||
},
|
||||
launch: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>',
|
||||
},
|
||||
user: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></g>',
|
||||
},
|
||||
music: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></g>',
|
||||
},
|
||||
brush: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"/><path d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"/></g>',
|
||||
},
|
||||
wifi: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h.01M2 8.82a15 15 0 0 1 20 0M5 12.859a10 10 0 0 1 14 0m-10.5 3.57a5 5 0 0 1 7 0"/>',
|
||||
},
|
||||
'wifi-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h.01M8.5 16.429a5 5 0 0 1 7 0M5 12.859a10 10 0 0 1 5.17-2.69m8.83 2.69a10 10 0 0 0-2.007-1.523M2 8.82a15 15 0 0 1 4.177-2.643M22 8.82a15 15 0 0 0-11.288-3.764M2 2l20 20"/>',
|
||||
},
|
||||
gameplay: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 11h4M8 9v4m7-1h.01M18 10h.01m-.69-5H6.68a4 4 0 0 0-3.978 3.59l-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258q-.01-.075-.017-.151A4 4 0 0 0 17.32 5"/>',
|
||||
},
|
||||
integrations: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 22v-5m3-9V2m2 6a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1zM9 8V2"/>',
|
||||
},
|
||||
installation: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 16h.01m-7.798-4.423a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11zm19.734.436H2.054M6 16h.01"/>',
|
||||
},
|
||||
installed: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 16h.01m-7.798-4.423a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11zm19.734.436H2.054M6 16h.01"/>',
|
||||
},
|
||||
diagnostics: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12l2 2l4-4"/></g>',
|
||||
},
|
||||
logs: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 12h-5m5-4h-5m9 9V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></g>',
|
||||
},
|
||||
'all-themes': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></g>',
|
||||
},
|
||||
available: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 13v8l-4-4m4 4l4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/></g>',
|
||||
},
|
||||
updates: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9a9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5m5 4a9 9 0 0 1-9 9a9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></g>',
|
||||
},
|
||||
search: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m21 21l-4.34-4.34"/><circle cx="11" cy="11" r="8"/></g>',
|
||||
},
|
||||
folder: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 14l1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/>',
|
||||
},
|
||||
plus: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14"/>',
|
||||
},
|
||||
'osu-standard': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>',
|
||||
},
|
||||
'osu-taiko': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m2 2l8 8m12-8l-8 8"/><ellipse cx="12" cy="9" rx="10" ry="5"/><path d="M7 13.4v7.9m5-7.3v8m5-8.6v7.9M2 9v8a10 5 0 0 0 20 0V9"/></g>',
|
||||
},
|
||||
'osu-catch': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2 17a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3c-2.5-2-5 .24-5 3m10 0a5 5 0 0 0 10 0c0-2.76-2.5-5-5-3c-2.5-2-5 .24-5 3"/><path d="M7 14c3.22-2.91 4.29-8.75 5-12c1.66 2.38 4.94 9 5 12"/><path d="M22 9c-4.29 0-7.14-2.33-10-7c5.71 0 10 4.67 10 7"/></g>',
|
||||
},
|
||||
'osu-mania': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 13v4m4-4v4m4-4v4M2 13h20m0-1.5A3.5 3.5 0 0 0 18.5 8a3.52 3.52 0 0 1-3.173-2A7 7 0 0 0 2 9v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2zM6 13v4"/>',
|
||||
},
|
||||
login: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m10 17l5-5l-5-5m5 5H3m12-9h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>',
|
||||
},
|
||||
logout: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
|
||||
},
|
||||
warning: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/>',
|
||||
},
|
||||
info: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></g>',
|
||||
},
|
||||
loader: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
||||
},
|
||||
ellipsis: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></g>',
|
||||
},
|
||||
check: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5"/>',
|
||||
},
|
||||
trash: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
},
|
||||
edit: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497zM15 5l4 4"/>',
|
||||
},
|
||||
history: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m4-1v5l4 2"/></g>',
|
||||
},
|
||||
refresh: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9a9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5m5 4a9 9 0 0 1-9 9a9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></g>',
|
||||
},
|
||||
download: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 13v8l-4-4m4 4l4-4"/><path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284"/></g>',
|
||||
},
|
||||
import: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3v12m-4-4l4 4l4-4"/><path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4"/></g>',
|
||||
},
|
||||
'arrow-right': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7l7 7l-7 7"/>',
|
||||
},
|
||||
eye: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></g>',
|
||||
},
|
||||
'eye-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575a1 1 0 0 1 0 .696a10.8 10.8 0 0 1-1.444 2.49m-6.41-.679a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 4.446-5.143M2 2l20 20"/></g>',
|
||||
},
|
||||
wrench: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>',
|
||||
},
|
||||
rotate: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></g>',
|
||||
},
|
||||
volume: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6m3.364 3.364a9 9 0 0 0 0-12.728"/>',
|
||||
},
|
||||
'volume-low': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298zM16 9a5 5 0 0 1 0 6"/>',
|
||||
},
|
||||
'volume-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4.702a.7.7 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.7.7 0 0 0 11 19.298zm5.5 9.798l5-5m-5 0l5 5"/>',
|
||||
},
|
||||
},
|
||||
phosphor: {
|
||||
home: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/>',
|
||||
},
|
||||
themes: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M200.77 53.89A103.27 103.27 0 0 0 128 24h-1.07A104 104 0 0 0 24 128c0 43 26.58 79.06 69.36 94.17A32 32 0 0 0 136 192a16 16 0 0 1 16-16h46.21a31.81 31.81 0 0 0 31.2-24.88a104.4 104.4 0 0 0 2.59-24a103.28 103.28 0 0 0-31.23-73.23m13 93.71a15.89 15.89 0 0 1-15.56 12.4H152a32 32 0 0 0-32 32a16 16 0 0 1-21.31 15.07C62.49 194.3 40 164 40 128a88 88 0 0 1 87.09-88h.9a88.35 88.35 0 0 1 88 87.25a89 89 0 0 1-2.18 20.35ZM140 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-44 24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 56a12 12 0 1 1-12-12a12 12 0 0 1 12 12m88-56a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
|
||||
},
|
||||
settings: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M40 88h33a32 32 0 0 0 62 0h81a8 8 0 0 0 0-16h-81a32 32 0 0 0-62 0H40a8 8 0 0 0 0 16m64-24a16 16 0 1 1-16 16a16 16 0 0 1 16-16m112 104h-17a32 32 0 0 0-62 0H40a8 8 0 0 0 0 16h97a32 32 0 0 0 62 0h17a8 8 0 0 0 0-16m-48 24a16 16 0 1 1 16-16a16 16 0 0 1-16 16"/>',
|
||||
},
|
||||
launch: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/>',
|
||||
},
|
||||
user: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/>',
|
||||
},
|
||||
music: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/>',
|
||||
},
|
||||
brush: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M232 32a8 8 0 0 0-8-8c-44.08 0-89.31 49.71-114.43 82.63A60 60 0 0 0 32 164c0 30.88-19.54 44.73-20.47 45.37A8 8 0 0 0 16 224h76a60 60 0 0 0 57.37-77.57C182.3 121.31 232 76.08 232 32M92 208H34.63C41.38 198.41 48 183.92 48 164a44 44 0 1 1 44 44m32.42-94.45q5.14-6.66 10.09-12.55A76.2 76.2 0 0 1 155 121.49q-5.9 4.94-12.55 10.09a60.5 60.5 0 0 0-18.03-18.03m42.7-2.68a92.6 92.6 0 0 0-22-22c31.78-34.53 55.75-45 69.9-47.91c-2.85 14.16-13.37 38.13-47.9 69.91"/>',
|
||||
},
|
||||
wifi: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M140 204a12 12 0 1 1-12-12a12 12 0 0 1 12 12m97.08-117a172 172 0 0 0-218.16 0a8 8 0 0 0 10.16 12.37a156 156 0 0 1 197.84 0A8 8 0 0 0 237.08 87M205 122.77a124 124 0 0 0-153.94 0A8 8 0 0 0 61 135.31a108 108 0 0 1 134.06 0a8 8 0 0 0 11.24-1.3a8 8 0 0 0-1.3-11.24m-32.26 35.76a76.05 76.05 0 0 0-89.42 0a8 8 0 0 0 9.42 12.94a60 60 0 0 1 70.58 0a8 8 0 1 0 9.42-12.94"/>',
|
||||
},
|
||||
'wifi-off': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M213.92 210.62a8 8 0 1 1-11.84 10.76l-52-57.15a60 60 0 0 0-57.41 7.24a8 8 0 1 1-9.42-12.93A75.43 75.43 0 0 1 128 144c1.28 0 2.55 0 3.82.1l-26.92-29.61A108 108 0 0 0 61 135.31A8 8 0 0 1 49.73 134A8 8 0 0 1 51 122.77a124.3 124.3 0 0 1 41.71-21.66L69.37 75.4a155.4 155.4 0 0 0-40.29 24A8 8 0 0 1 18.92 87A172 172 0 0 1 58 62.86L42.08 45.38a8 8 0 1 1 11.84-10.76ZM128 192a12 12 0 1 0 12 12a12 12 0 0 0-12-12M237.08 87A172.3 172.3 0 0 0 106 49.4a8 8 0 1 0 2 15.87A158 158 0 0 1 128 64a156.25 156.25 0 0 1 98.92 35.37A8 8 0 0 0 237.08 87M195 135.31a8 8 0 0 0 11.24-1.3a8 8 0 0 0-1.3-11.24a124.25 124.25 0 0 0-51.73-24.2a8 8 0 1 0-3.21 15.67a108.1 108.1 0 0 1 45 21.07"/>',
|
||||
},
|
||||
gameplay: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M176 112h-24a8 8 0 0 1 0-16h24a8 8 0 0 1 0 16m-72-16h-8v-8a8 8 0 0 0-16 0v8h-8a8 8 0 0 0 0 16h8v8a8 8 0 0 0 16 0v-8h8a8 8 0 0 0 0-16m137.48 104.65a36 36 0 0 1-54.94 4.81c-.12-.12-.24-.24-.35-.37L146.48 160h-37l-39.67 45.09l-.35.37A36.08 36.08 0 0 1 44 216a36 36 0 0 1-35.44-42.25a.7.7 0 0 1 0-.14l16.37-84.09A59.88 59.88 0 0 1 83.89 40H172a60.08 60.08 0 0 1 59 49.25v.18l16.37 84.17a.7.7 0 0 1 0 .14a35.74 35.74 0 0 1-5.89 26.91M172 144a44 44 0 0 0 0-88H83.89a43.9 43.9 0 0 0-43.21 36.37v.13L24.3 176.59A20 20 0 0 0 58 194.3l41.92-47.59a8 8 0 0 1 6-2.71Zm59.7 32.59l-8.74-45A60 60 0 0 1 172 160h-4.2l30.2 34.31a20.09 20.09 0 0 0 17.46 5.39a20 20 0 0 0 16.23-23.11Z"/>',
|
||||
},
|
||||
integrations: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M149.66 138.34a8 8 0 0 0-11.32 0L120 156.69L99.31 136l18.35-18.34a8 8 0 0 0-11.32-11.32L88 124.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l6.35 6.34l-23.32 23.31a32 32 0 0 0 0 45.26l5.38 5.37l-28.41 28.4a8 8 0 0 0 11.32 11.32l28.4-28.41l5.37 5.38a32 32 0 0 0 45.26 0L132 191.31l6.34 6.35a8 8 0 0 0 11.32-11.32L131.31 168l18.35-18.34a8 8 0 0 0 0-11.32m-52.29 65a16 16 0 0 1-22.62 0l-22.06-22.09a16 16 0 0 1 0-22.62L76 135.31L120.69 180Zm140.29-185a8 8 0 0 0-11.32 0l-28.4 28.41l-5.37-5.38a32.05 32.05 0 0 0-45.26 0L124 64.69l-6.34-6.35a8 8 0 0 0-11.32 11.32l80 80a8 8 0 0 0 11.32-11.32l-6.35-6.34l23.32-23.31a32 32 0 0 0 0-45.26l-5.38-5.37l28.41-28.4a8 8 0 0 0 0-11.32m-34.35 79L180 120.69L135.31 76l23.32-23.31a16 16 0 0 1 22.62 0l22.06 22a16 16 0 0 1 0 22.68Z"/>',
|
||||
},
|
||||
installation: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
|
||||
},
|
||||
installed: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M208 136H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H48v-48h160zm0-160H48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H48V56h160zm-16-24a12 12 0 1 1-12-12a12 12 0 0 1 12 12m0 96a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
|
||||
},
|
||||
diagnostics: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-21.81C61.82 179.51 48 149.3 48 112V56h160ZM82.34 141.66a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 11.32l-56 56a8 8 0 0 1-11.32 0Z"/>',
|
||||
},
|
||||
logs: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M96 104a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16h-64a8 8 0 0 1-8-8m8 40h64a8 8 0 0 0 0-16h-64a8 8 0 0 0 0 16m128 48a32 32 0 0 1-32 32H88a32 32 0 0 1-32-32V64a16 16 0 0 0-32 0c0 5.74 4.83 9.62 4.88 9.66A8 8 0 0 1 24 88a7.9 7.9 0 0 1-4.79-1.61C18.05 85.54 8 77.61 8 64a32 32 0 0 1 32-32h136a32 32 0 0 1 32 32v104h8a8 8 0 0 1 4.8 1.6c1.2.86 11.2 8.79 11.2 22.4M96.26 173.48A8.07 8.07 0 0 1 104 168h88V64a16 16 0 0 0-16-16H67.69A31.7 31.7 0 0 1 72 64v128a16 16 0 0 0 32 0c0-5.74-4.83-9.62-4.88-9.66a7.82 7.82 0 0 1-2.86-8.86M216 192a12.58 12.58 0 0 0-3.23-8h-94a27 27 0 0 1 1.21 8a31.8 31.8 0 0 1-4.29 16H200a16 16 0 0 0 16-16"/>',
|
||||
},
|
||||
'all-themes': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M104 40H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64H56V56h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 64h-48V56h48zm-96 32H56a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64H56v-48h48zm96-64h-48a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h48a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16m0 64h-48v-48h48z"/>',
|
||||
},
|
||||
available: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32"/>',
|
||||
},
|
||||
updates: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
|
||||
},
|
||||
search: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m229.66 218.34l-50.07-50.06a88.11 88.11 0 1 0-11.31 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/>',
|
||||
},
|
||||
folder: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/>',
|
||||
},
|
||||
plus: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/>',
|
||||
},
|
||||
'osu-standard': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88"/>',
|
||||
},
|
||||
'osu-taiko': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M212.92 17.69a8 8 0 0 0-6.86-1.45l-128 32A8 8 0 0 0 72 56v110.08A36 36 0 1 0 88 196v-85.75l112-28v51.83A36 36 0 1 0 216 164V24a8 8 0 0 0-3.08-6.31M52 216a20 20 0 1 1 20-20a20 20 0 0 1-20 20M88 93.75v-31.5l112-28v31.5ZM180 184a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/>',
|
||||
},
|
||||
'osu-catch': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M248 80H8a8 8 0 0 0-8 8a128 128 0 0 0 256 0a8 8 0 0 0-8-8M77.4 149.91l42.6-42.6v60.29a79.6 79.6 0 0 1-42.6-17.69M66.09 138.6A79.6 79.6 0 0 1 48.4 96h60.29ZM136 107.31l42.6 42.6A79.6 79.6 0 0 1 136 167.6Zm53.91 31.29L147.31 96h60.29a79.6 79.6 0 0 1-17.69 42.6M128 200A112.15 112.15 0 0 1 16.28 96h16.06a96 96 0 0 0 191.32 0h16.06A112.15 112.15 0 0 1 128 200"/>',
|
||||
},
|
||||
'osu-mania': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M208 32H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M80 48h24v88H80Zm32 104a8 8 0 0 0 8-8V48h16v96a8 8 0 0 0 8 8h8v56h-48v-56Zm40-16V48h24v88ZM48 48h16v96a8 8 0 0 0 8 8h16v56H48Zm160 160h-40v-56h16a8 8 0 0 0 8-8V48h16z"/>',
|
||||
},
|
||||
login: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m141.66 133.66l-40 40a8 8 0 0 1-11.32-11.32L116.69 136H24a8 8 0 0 1 0-16h92.69L90.34 93.66a8 8 0 0 1 11.32-11.32l40 40a8 8 0 0 1 0 11.32M200 32h-64a8 8 0 0 0 0 16h56v160h-56a8 8 0 0 0 0 16h64a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8"/>',
|
||||
},
|
||||
logout: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M120 216a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V40a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16H56v160h56a8 8 0 0 1 8 8m109.66-93.66l-40-40a8 8 0 0 0-11.32 11.32L204.69 120H112a8 8 0 0 0 0 16h92.69l-26.35 26.34a8 8 0 0 0 11.32 11.32l40-40a8 8 0 0 0 0-11.32"/>',
|
||||
},
|
||||
warning: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/>',
|
||||
},
|
||||
info: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m16-40a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m-32-92a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/>',
|
||||
},
|
||||
loader: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/>',
|
||||
},
|
||||
ellipsis: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m56-12a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-136 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/>',
|
||||
},
|
||||
check: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m229.66 77.66l-128 128a8 8 0 0 1-11.32 0l-56-56a8 8 0 0 1 11.32-11.32L96 188.69L218.34 66.34a8 8 0 0 1 11.32 11.32"/>',
|
||||
},
|
||||
trash: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/>',
|
||||
},
|
||||
edit: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/>',
|
||||
},
|
||||
history: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/>',
|
||||
},
|
||||
refresh: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
|
||||
},
|
||||
download: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32"/>',
|
||||
},
|
||||
import: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/>',
|
||||
},
|
||||
'arrow-right': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="m221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/>',
|
||||
},
|
||||
eye: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/>',
|
||||
},
|
||||
'eye-off': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M53.92 34.62a8 8 0 1 0-11.84 10.76l19.24 21.17C25 88.84 9.38 123.2 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208a127.1 127.1 0 0 0 52.07-10.83l22 24.21a8 8 0 1 0 11.84-10.76Zm47.33 75.84l41.67 45.85a32 32 0 0 1-41.67-45.85M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.2 133.2 0 0 1 25 128c4.69-8.79 19.66-33.39 47.35-49.38l18 19.75a48 48 0 0 0 63.66 70l14.73 16.2A112 112 0 0 1 128 192m6-95.43a8 8 0 0 1 3-15.72a48.16 48.16 0 0 1 38.77 42.64a8 8 0 0 1-7.22 8.71a6 6 0 0 1-.75 0a8 8 0 0 1-8-7.26A32.09 32.09 0 0 0 134 96.57m113.28 34.69c-.42.94-10.55 23.37-33.36 43.8a8 8 0 1 1-10.67-11.92a132.8 132.8 0 0 0 27.8-35.14a133.2 133.2 0 0 0-23.12-30.77C185.67 75.19 158.78 64 128 64a118.4 118.4 0 0 0-19.36 1.57A8 8 0 1 1 106 49.79A134 134 0 0 1 128 48c34.88 0 66.57 13.26 91.66 38.35c18.83 18.83 27.3 37.62 27.65 38.41a8 8 0 0 1 0 6.5Z"/>',
|
||||
},
|
||||
wrench: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M226.76 69a8 8 0 0 0-12.84-2.88l-40.3 37.19l-17.23-3.7l-3.7-17.23l37.19-40.3A8 8 0 0 0 187 29.24A72 72 0 0 0 88 96a72.3 72.3 0 0 0 6 28.94L33.79 177c-.15.12-.29.26-.43.39a32 32 0 0 0 45.26 45.26c.13-.13.27-.28.39-.42L131.06 162A72 72 0 0 0 232 96a71.6 71.6 0 0 0-5.24-27M160 152a56.14 56.14 0 0 1-27.07-7a8 8 0 0 0-9.92 1.77l-55.9 64.74a16 16 0 0 1-22.62-22.62L109.18 133a8 8 0 0 0 1.77-9.93a56 56 0 0 1 58.36-82.31l-31.2 33.81a8 8 0 0 0-1.94 7.1l5.66 26.33a8 8 0 0 0 6.14 6.14l26.35 5.66a8 8 0 0 0 7.1-1.94l33.81-31.2A56.06 56.06 0 0 1 160 152"/>',
|
||||
},
|
||||
rotate: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M224 48v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h28.69l-14.63-14.63a79.56 79.56 0 0 0-56.13-23.43h-.45a79.52 79.52 0 0 0-55.89 22.77a8 8 0 0 1-11.18-11.44a96 96 0 0 1 135 .79L208 76.69V48a8 8 0 0 1 16 0m-37.59 135.29a80 80 0 0 1-112.47-.66L59.31 168H88a8 8 0 0 0 0-16H40a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-28.69l14.63 14.63A95.43 95.43 0 0 0 130 222.06h.53a95.36 95.36 0 0 0 67.07-27.33a8 8 0 0 0-11.18-11.44Z"/>',
|
||||
},
|
||||
volume: {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/>',
|
||||
},
|
||||
'volume-low': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128"/>',
|
||||
},
|
||||
'volume-off': {
|
||||
width: 256,
|
||||
height: 256,
|
||||
body: '<path fill="currentColor" d="M53.92 34.62a8 8 0 1 0-11.84 10.76L73.55 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224v-48.91l42.08 46.29a8 8 0 1 0 11.84-10.76ZM32 96h40v64H32Zm112 111.64l-56-43.55v-68.2l56 61.6Zm42-63.77a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.57a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.59m-80.16-76a8 8 0 0 1 1.4-11.23l39.85-31A8 8 0 0 1 160 32v74.83a8 8 0 0 1-16 0V48.36l-26.94 21a8 8 0 0 1-11.22-1.45ZM248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/>',
|
||||
},
|
||||
},
|
||||
tabler: {
|
||||
home: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M5 12H3l9-9l9 9h-2M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/><path d="M9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6"/></g>',
|
||||
},
|
||||
themes: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 21a9 9 0 0 1 0-18c4.97 0 9 3.582 9 8c0 1.06-.474 2.078-1.318 2.828S17.693 15 16.5 15H14a2 2 0 0 0-1 3.75A1.3 1.3 0 0 1 12 21"/><path d="M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0-2 0m4-3a1 1 0 1 0 2 0a1 1 0 1 0-2 0m4 3a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/></g>',
|
||||
},
|
||||
settings: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 6h8m4 0h4M6 12a2 2 0 1 0 4 0a2 2 0 1 0-4 0m-2 0h2m4 0h10m-5 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0M4 18h11m4 0h1"/>',
|
||||
},
|
||||
launch: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16l13-8z"/>',
|
||||
},
|
||||
user: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>',
|
||||
},
|
||||
music: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0-6 0m10 0a3 3 0 1 0 6 0a3 3 0 0 0-6 0"/><path d="M9 17V4h10v13M9 8h10"/></g>',
|
||||
},
|
||||
brush: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 21v-4a4 4 0 1 1 4 4z"/><path d="M21 3A16 16 0 0 0 8.2 13.2M21 3a16 16 0 0 1-10.2 12.8"/><path d="M10.6 9a9 9 0 0 1 4.4 4.4"/></g>',
|
||||
},
|
||||
wifi: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 18h.01m-2.838-2.828a4 4 0 0 1 5.656 0m-8.485-2.829a8 8 0 0 1 11.314 0"/><path d="M3.515 9.515c4.686-4.687 12.284-4.687 17 0"/></g>',
|
||||
},
|
||||
'wifi-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01m-2.838-2.828a4 4 0 0 1 5.656 0m-8.485-2.829a7.96 7.96 0 0 1 3.864-2.14m4.163.155a8 8 0 0 1 3.287 2M3.515 9.515A12 12 0 0 1 7.059 7.06m3.101-.92a12 12 0 0 1 10.325 3.374M3 3l18 18"/>',
|
||||
},
|
||||
gameplay: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 5h3.5a5 5 0 0 1 0 10H10l-4.015 4.227a2.3 2.3 0 0 1-3.923-2.035l1.634-8.173A5 5 0 0 1 8.6 5z"/><path d="m14 15l4.07 4.284a2.3 2.3 0 0 0 3.925-2.023l-1.6-8.232M8 9v2m-1-1h2m5 0h2"/></g>',
|
||||
},
|
||||
integrations: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.785 6L18 14.215l-2.054 2.054a5.81 5.81 0 1 1-8.215-8.215zM4 20l3.5-3.5M15 4l-3.5 3.5M20 9l-3.5 3.5"/>',
|
||||
},
|
||||
installation: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm4-7v.01M7 16v.01"/>',
|
||||
},
|
||||
installed: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm4-7v.01M7 16v.01"/>',
|
||||
},
|
||||
diagnostics: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.46 20.846A12 12 0 0 1 3.5 6A12 12 0 0 0 12 3a12 12 0 0 0 8.5 3a12 12 0 0 1-.09 7.06M15 19l2 2l4-4"/>',
|
||||
},
|
||||
logs: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2M9 9h1m-1 4h6m-6 4h6"/></g>',
|
||||
},
|
||||
'all-themes': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"/>',
|
||||
},
|
||||
available: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 18a3.5 3.5 0 0 0 0-7h-1A5 4.5 0 0 0 7 9a4.6 4.4 0 0 0-2.1 8.4M12 13v9m-3-3l3 3l3-3"/>',
|
||||
},
|
||||
updates: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
|
||||
},
|
||||
search: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10a7 7 0 1 0 14 0a7 7 0 1 0-14 0m18 11l-6-6"/>',
|
||||
},
|
||||
folder: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 19l2.757-7.351A1 1 0 0 1 8.693 11H21a1 1 0 0 1 .986 1.164l-.996 5.211A2 2 0 0 1 19.026 19za2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v2"/>',
|
||||
},
|
||||
plus: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14"/>',
|
||||
},
|
||||
'osu-standard': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0"/>',
|
||||
},
|
||||
'osu-taiko': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 17a3 3 0 1 0 6 0a3 3 0 0 0-6 0m10 0a3 3 0 1 0 6 0a3 3 0 0 0-6 0"/><path d="M9 17V4h10v13M9 8h10"/></g>',
|
||||
},
|
||||
'osu-catch': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0M14 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0"/><path d="M9 13c.366-2 1.866-3.873 4.5-5.6M17 15c-1.333-2.333-2.333-5.333-1-9"/><path d="M5 6q5.5-4 11 0q-5.5 4-11 0"/></g>',
|
||||
},
|
||||
'osu-mania': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm6 12v-6"/><path d="M8 5v8h2V5m5 14v-6m-1-8v8h2V5"/></g>',
|
||||
},
|
||||
login: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M21 12H8l3-3m0 6l-3-3"/></g>',
|
||||
},
|
||||
logout: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M14 8V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M9 12h12l-3-3m0 6l3-3"/></g>',
|
||||
},
|
||||
warning: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m-1.637-9.409L2.257 17.125a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636-2.87L13.637 3.59a1.914 1.914 0 0 0-3.274 0M12 16h.01"/>',
|
||||
},
|
||||
info: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0-18 0m9-3h.01"/><path d="M11 12h1v4h1"/></g>',
|
||||
},
|
||||
loader: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V3m4.25 4.75L18.4 5.6M18 12h3m-4.75 4.25l2.15 2.15M12 18v3m-4.25-4.75L5.6 18.4M6 12H3m4.75-4.25L5.6 5.6"/>',
|
||||
},
|
||||
ellipsis: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0m7 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0"/>',
|
||||
},
|
||||
check: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m5 12l5 5L20 7"/>',
|
||||
},
|
||||
trash: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16m-10 4v6m4-6v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"/>',
|
||||
},
|
||||
edit: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 20h4L18.5 9.5a2.828 2.828 0 1 0-4-4L4 16zm9.5-13.5l4 4"/>',
|
||||
},
|
||||
history: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 8v4l2 2"/><path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5"/></g>',
|
||||
},
|
||||
refresh: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4m-4 4a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>',
|
||||
},
|
||||
download: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 18a3.5 3.5 0 0 0 0-7h-1A5 4.5 0 0 0 7 9a4.6 4.4 0 0 0-2.1 8.4M12 13v9m-3-3l3 3l3-3"/>',
|
||||
},
|
||||
import: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 9l5-5l5 5m-5-5v12"/>',
|
||||
},
|
||||
'arrow-right': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-6 6l6-6m-6-6l6 6"/>',
|
||||
},
|
||||
eye: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/><path d="M21 12q-3.6 6-9 6t-9-6q3.6-6 9-6t9 6"/></g>',
|
||||
},
|
||||
'eye-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673A8.7 8.7 0 0 1 12 18q-5.4 0-9-6q1.908-3.18 4.32-4.674m2.86-1.146A9 9 0 0 1 12 6q5.4 0 9 6q-1 1.665-2.138 2.87M3 3l18 18"/></g>',
|
||||
},
|
||||
wrench: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 10h3V7L6.5 3.5a6 6 0 0 1 8 8l6 6a2 2 0 0 1-3 3l-6-6a6 6 0 0 1-8-8z"/>',
|
||||
},
|
||||
rotate: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.05 11a8 8 0 1 1 .5 4m-.5 5v-5h5"/>',
|
||||
},
|
||||
volume: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 0 8m2.7-11a9 9 0 0 1 0 14M6 15H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l3.5-4.5A.8.8 0 0 1 11 5v14a.8.8 0 0 1-1.5.5z"/>',
|
||||
},
|
||||
'volume-low': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 0 8m-9-1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l3.5-4.5A.8.8 0 0 1 11 5v14a.8.8 0 0 1-1.5.5z"/>',
|
||||
},
|
||||
'volume-off': {
|
||||
width: 24,
|
||||
height: 24,
|
||||
body: '<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 8a5 5 0 0 1 1.912 4.934m-1.377 2.602A5 5 0 0 1 15 16m2.7-11a9 9 0 0 1 2.362 11.086m-1.676 2.299A9 9 0 0 1 17.7 19M9.069 5.054L9.5 4.5A.8.8 0 0 1 11 5v2m0 4v8a.8.8 0 0 1-1.5.5L6 15H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2l1.294-1.664M3 3l18 18"/>',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
export const THEME_ICON_SETS = [
|
||||
{
|
||||
value: 'lucide',
|
||||
label: 'Lucide',
|
||||
description: 'Clean geometric outlines. The launcher default.',
|
||||
},
|
||||
{
|
||||
value: 'phosphor',
|
||||
label: 'Phosphor',
|
||||
description: 'Soft, rounded shapes with a friendly feel.',
|
||||
},
|
||||
{
|
||||
value: 'tabler',
|
||||
label: 'Tabler',
|
||||
description: 'Crisp, technical outlines with a larger family.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ThemeIconSet = (typeof THEME_ICON_SETS)[number]['value'];
|
||||
|
||||
export const DEFAULT_THEME_ICON_SET: ThemeIconSet = 'lucide';
|
||||
|
||||
export const isThemeIconSet = (value: unknown): value is ThemeIconSet =>
|
||||
THEME_ICON_SETS.some((set) => set.value === value);
|
||||
|
||||
/**
|
||||
* Semantic icon names used by launcher chrome. Themes choose a family, never a
|
||||
* component import, so the launcher can swap families without changing layout.
|
||||
*/
|
||||
export const THEME_ICON_NAMES = [
|
||||
'home',
|
||||
'themes',
|
||||
'settings',
|
||||
'launch',
|
||||
'user',
|
||||
'music',
|
||||
'brush',
|
||||
'wifi',
|
||||
'wifi-off',
|
||||
'gameplay',
|
||||
'integrations',
|
||||
'installation',
|
||||
'installed',
|
||||
'diagnostics',
|
||||
'logs',
|
||||
'all-themes',
|
||||
'available',
|
||||
'updates',
|
||||
'search',
|
||||
'folder',
|
||||
'plus',
|
||||
'osu-standard',
|
||||
'osu-taiko',
|
||||
'osu-catch',
|
||||
'osu-mania',
|
||||
'login',
|
||||
'logout',
|
||||
'warning',
|
||||
'info',
|
||||
'loader',
|
||||
'ellipsis',
|
||||
'check',
|
||||
'trash',
|
||||
'edit',
|
||||
'history',
|
||||
'refresh',
|
||||
'download',
|
||||
'import',
|
||||
'arrow-right',
|
||||
'eye',
|
||||
'eye-off',
|
||||
'wrench',
|
||||
'rotate',
|
||||
'volume',
|
||||
'volume-low',
|
||||
'volume-off',
|
||||
] as const;
|
||||
|
||||
export type ThemeIconName = (typeof THEME_ICON_NAMES)[number];
|
||||
|
||||
const ICON_NAME_SET = new Set<string>(THEME_ICON_NAMES);
|
||||
|
||||
export const isThemeIconName = (value: unknown): value is ThemeIconName =>
|
||||
typeof value === 'string' && ICON_NAME_SET.has(value);
|
||||
|
||||
/** A small representative row used by the editor's live icon preview. */
|
||||
export const THEME_ICON_PREVIEW_NAMES: ThemeIconName[] = [
|
||||
'home',
|
||||
'themes',
|
||||
'settings',
|
||||
'launch',
|
||||
'user',
|
||||
];
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
ThemeV2ClockElement,
|
||||
ThemeV2CountdownElement,
|
||||
ThemeV2ParticlesElement,
|
||||
} from './themeV2';
|
||||
|
||||
/**
|
||||
* Shared helpers for the dynamic API 2.0 layer types. The DOM renderer and the
|
||||
* canvas thumbnail renderer both use these so a layer looks the same in the
|
||||
* launcher, the editor canvas and the exported preview image.
|
||||
*/
|
||||
|
||||
const pad = (value: number) => String(Math.max(0, Math.floor(value))).padStart(2, '0');
|
||||
|
||||
export const themeClockTime = (element: ThemeV2ClockElement, now: Date): string => {
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const showSeconds = element.showSeconds !== false;
|
||||
if (element.hour12) {
|
||||
const hour = hours % 12 || 12;
|
||||
return `${hour}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''} ${hours < 12 ? 'AM' : 'PM'}`;
|
||||
}
|
||||
return `${pad(hours)}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''}`;
|
||||
};
|
||||
|
||||
export const themeClockDate = (now: Date): string =>
|
||||
now.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
|
||||
export type ThemeCountdownParts = {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
/** True when the target is a valid date that has already passed. */
|
||||
finished: boolean;
|
||||
/** False for empty or unparsable targets; the layer then shows zeroes. */
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
export const themeCountdownParts = (target: string, now: Date): ThemeCountdownParts => {
|
||||
const timestamp = target.trim() ? Date.parse(target) : Number.NaN;
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0, finished: false, valid: false };
|
||||
}
|
||||
const remaining = Math.max(0, timestamp - now.getTime());
|
||||
const totalSeconds = Math.floor(remaining / 1000);
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86_400),
|
||||
hours: Math.floor((totalSeconds % 86_400) / 3_600),
|
||||
minutes: Math.floor((totalSeconds % 3_600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
finished: remaining <= 0,
|
||||
valid: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const themeCountdownText = (element: ThemeV2CountdownElement, now: Date): string => {
|
||||
const parts = themeCountdownParts(element.target, now);
|
||||
if (parts.finished) return element.finishedText?.trim() || 'The wait is over';
|
||||
const template = element.text?.trim() ? element.text : '{days}d {hours}h {minutes}m {seconds}s';
|
||||
return template
|
||||
.replaceAll('{days}', String(parts.days))
|
||||
.replaceAll('{hours}', pad(parts.hours))
|
||||
.replaceAll('{minutes}', pad(parts.minutes))
|
||||
.replaceAll('{seconds}', pad(parts.seconds));
|
||||
};
|
||||
|
||||
export type ThemeParticleSeed = {
|
||||
/** Horizontal position in percent of the layer. */
|
||||
left: number;
|
||||
/** Vertical offset in percent of the layer. */
|
||||
top: number;
|
||||
/** Full animation duration in seconds. */
|
||||
duration: number;
|
||||
/** Negative delay so particles start already spread along their path. */
|
||||
delay: number;
|
||||
/** Particle size multiplier, 0.45–1.4. */
|
||||
size: number;
|
||||
/** Horizontal drift in percent of the layer width. */
|
||||
drift: number;
|
||||
/** Total rotation in degrees. */
|
||||
spin: number;
|
||||
/** Use the secondary color. */
|
||||
alternate: boolean;
|
||||
/** Peak opacity, 0.35–1. */
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
const seeded = (seed: number) => {
|
||||
let state = seed || 1;
|
||||
return () => {
|
||||
state = (state * 9301 + 49297) % 233_280;
|
||||
return state / 233_280;
|
||||
};
|
||||
};
|
||||
|
||||
export const themeParticleSeeds = (element: ThemeV2ParticlesElement): ThemeParticleSeed[] => {
|
||||
const density = Math.max(4, Math.min(120, Math.round(element.density ?? 28)));
|
||||
const speed = Math.max(0.1, Math.min(3, element.speed ?? 1));
|
||||
const wind = Math.max(-1, Math.min(1, element.wind ?? 0));
|
||||
const random = seeded(density * 7919 + 17);
|
||||
return Array.from({ length: density }, () => {
|
||||
const duration = (6 + random() * 10) / speed;
|
||||
return {
|
||||
left: random() * 100,
|
||||
top: random() * 100,
|
||||
duration,
|
||||
delay: -duration * random(),
|
||||
size: 0.45 + random() * 0.95,
|
||||
drift: (random() * 2 - 1) * 10 + wind * 16,
|
||||
spin: (random() * 2 - 1) * 720,
|
||||
alternate: random() > 0.5,
|
||||
opacity: 0.35 + random() * 0.65,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** SVG linear-gradient vector for an angle, in 0–1 gradient units. */
|
||||
export const themeSvgGradientVector = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const x = Math.sin(radians);
|
||||
const y = -Math.cos(radians);
|
||||
return {
|
||||
x1: 0.5 - x / 2,
|
||||
y1: 0.5 - y / 2,
|
||||
x2: 0.5 + x / 2,
|
||||
y2: 0.5 + y / 2,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Arc geometry in a 0–100 view box. Angles start at 12 o'clock and run
|
||||
* clockwise; the stroke is inset so it never clips at the layer edge.
|
||||
*/
|
||||
export const themeArcGeometry = (
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
thickness: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const minSide = Math.max(1, Math.min(width, height));
|
||||
const strokeWidth = Math.max(0.5, Math.min(60, (thickness / minSide) * 100));
|
||||
const radius = Math.max(1, 50 - strokeWidth / 2);
|
||||
const start = ((startAngle % 360) + 360) % 360;
|
||||
let end = ((endAngle % 360) + 360) % 360;
|
||||
if (end <= start) end += 360;
|
||||
const span = Math.min(360, end - start);
|
||||
|
||||
const point = (angle: number) => {
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
return {
|
||||
x: 50 + radius * Math.sin(radians),
|
||||
y: 50 - radius * Math.cos(radians),
|
||||
};
|
||||
};
|
||||
|
||||
const from = point(start);
|
||||
if (span >= 359.99) {
|
||||
const to = point(start + 180);
|
||||
return {
|
||||
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 1 1 ${to.x} ${to.y} A ${radius} ${radius} 0 1 1 ${from.x} ${from.y}`,
|
||||
strokeWidth,
|
||||
};
|
||||
}
|
||||
|
||||
const to = point(start + span);
|
||||
const largeArc = span > 180 ? 1 : 0;
|
||||
return {
|
||||
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`,
|
||||
strokeWidth,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
themeGroupAncestors,
|
||||
type ThemeV2Behavior,
|
||||
type ThemeV2BehaviorAction,
|
||||
type ThemeV2BehaviorTarget,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
} from './themeV2';
|
||||
|
||||
export const themeElementLabel = (element: ThemeV2Element): string => {
|
||||
if (element.name?.trim()) return element.name.trim();
|
||||
if (element.type === 'text') return element.text.trim().slice(0, 32) || 'Text layer';
|
||||
if (element.type === 'data') return element.field;
|
||||
if (element.type === 'shape') return element.shape;
|
||||
if (element.type === 'icon') return `${element.icon} icon`;
|
||||
if (element.type === 'ticker') return element.text.trim().slice(0, 32) || 'Ticker';
|
||||
if (element.type === 'gauge') return `${element.mode ?? 'bar'} gauge`;
|
||||
if (element.type === 'button') return element.label.trim() || 'Button';
|
||||
if (element.type === 'repeater') {
|
||||
return element.source === 'image' ? 'Image repeater' : `${element.icon ?? 'Icon'} repeater`;
|
||||
}
|
||||
if (element.type === 'lottie') return element.src.split('/').pop() || 'Lottie animation';
|
||||
if (element.type === 'path') return 'Vector path';
|
||||
if (element.type === 'particles') return `${element.preset} particles`;
|
||||
if (element.type === 'clock') return `${element.mode ?? 'digital'} clock`;
|
||||
if (element.type === 'countdown')
|
||||
return element.target ? `Countdown · ${element.target}` : 'Countdown';
|
||||
if (element.type === 'svelte') return 'Svelte component';
|
||||
return element.src.split('/').pop() || element.type;
|
||||
};
|
||||
|
||||
export const themeGroupLabel = (group: ThemeV2Group): string =>
|
||||
group.name?.trim() || 'Untitled group';
|
||||
|
||||
export const themeGroupContainsElement = (
|
||||
groups: ThemeV2Group[],
|
||||
groupId: string,
|
||||
element: ThemeV2Element
|
||||
) => themeGroupAncestors(groups, element).some((group) => group.id === groupId);
|
||||
|
||||
export const themeGroupInteractiveCount = (
|
||||
groups: ThemeV2Group[],
|
||||
elements: ThemeV2Element[],
|
||||
groupId: string
|
||||
) =>
|
||||
elements.filter(
|
||||
(element) => element.interactive === true && themeGroupContainsElement(groups, groupId, element)
|
||||
).length;
|
||||
|
||||
export const themeBehaviorTargetLabel = (
|
||||
target: ThemeV2BehaviorTarget | undefined,
|
||||
elements: ThemeV2Element[],
|
||||
groups: ThemeV2Group[]
|
||||
): string => {
|
||||
if (!target) return 'Global';
|
||||
if (target.type === 'element') {
|
||||
const element = elements.find((candidate) => candidate.id === target.id);
|
||||
return element ? `Layer · ${themeElementLabel(element)}` : 'Layer · missing';
|
||||
}
|
||||
const group = groups.find((candidate) => candidate.id === target.id);
|
||||
return group ? `Group · ${themeGroupLabel(group)}` : 'Group · missing';
|
||||
};
|
||||
|
||||
const pointerVerb = (triggerType: 'click' | 'hover' | 'leave') => {
|
||||
if (triggerType === 'hover') return 'Hover';
|
||||
if (triggerType === 'leave') return 'Leave';
|
||||
return 'Click';
|
||||
};
|
||||
|
||||
export const themeBehaviorTriggerLabel = (
|
||||
behavior: ThemeV2Behavior,
|
||||
elements: ThemeV2Element[],
|
||||
groups: ThemeV2Group[]
|
||||
): string => {
|
||||
const trigger = behavior.trigger;
|
||||
if (trigger.type === 'load') return 'Theme loads';
|
||||
if (trigger.type === 'screen') {
|
||||
return `On ${trigger.screen[0].toUpperCase()}${trigger.screen.slice(1)} screen`;
|
||||
}
|
||||
if (trigger.type === 'timer') return `Every ${trigger.interval}s`;
|
||||
|
||||
const verb = pointerVerb(trigger.type);
|
||||
if (behavior.target?.type === 'element') {
|
||||
const element = elements.find((candidate) => candidate.id === behavior.target?.id);
|
||||
return `${verb} · ${element ? themeElementLabel(element) : 'linked layer'}`;
|
||||
}
|
||||
if (behavior.target?.type === 'group') {
|
||||
const group = groups.find((candidate) => candidate.id === behavior.target?.id);
|
||||
return `${verb} · inside ${group ? themeGroupLabel(group) : 'linked group'}`;
|
||||
}
|
||||
if (trigger.elementId) {
|
||||
const element = elements.find((candidate) => candidate.id === trigger.elementId);
|
||||
return `${verb} · ${element ? themeElementLabel(element) : 'missing layer'}`;
|
||||
}
|
||||
return `${verb} · any interactive layer`;
|
||||
};
|
||||
|
||||
export const themeBehaviorActionLabel = (
|
||||
action: ThemeV2BehaviorAction,
|
||||
elements: ThemeV2Element[],
|
||||
groups: ThemeV2Group[]
|
||||
): string => {
|
||||
const elementLabel = (id: string) => {
|
||||
const element = elements.find((candidate) => candidate.id === id);
|
||||
return element ? themeElementLabel(element) : 'missing layer';
|
||||
};
|
||||
const groupLabel = (id: string) => {
|
||||
const group = groups.find((candidate) => candidate.id === id);
|
||||
return group ? themeGroupLabel(group) : 'missing group';
|
||||
};
|
||||
|
||||
switch (action.type) {
|
||||
case 'setVariable':
|
||||
return `Set ${action.variable}`;
|
||||
case 'changeVariable':
|
||||
return `${action.amount >= 0 ? 'Add' : 'Subtract'} ${Math.abs(action.amount)} · ${action.variable}`;
|
||||
case 'toggleVariable':
|
||||
return `Toggle ${action.variable}`;
|
||||
case 'showElement':
|
||||
return `Show ${elementLabel(action.elementId)}`;
|
||||
case 'hideElement':
|
||||
return `Hide ${elementLabel(action.elementId)}`;
|
||||
case 'toggleElement':
|
||||
return `Toggle ${elementLabel(action.elementId)}`;
|
||||
case 'showGroup':
|
||||
return `Show group · ${groupLabel(action.groupId)}`;
|
||||
case 'hideGroup':
|
||||
return `Hide group · ${groupLabel(action.groupId)}`;
|
||||
case 'toggleGroup':
|
||||
return `Toggle group · ${groupLabel(action.groupId)}`;
|
||||
case 'navigate':
|
||||
return `Go to ${action.screen}`;
|
||||
case 'launch':
|
||||
return 'Launch osu!';
|
||||
case 'openUrl':
|
||||
return 'Open URL';
|
||||
}
|
||||
};
|
||||
|
||||
export const themeBehaviorSummary = (
|
||||
behavior: ThemeV2Behavior,
|
||||
elements: ThemeV2Element[],
|
||||
groups: ThemeV2Group[],
|
||||
limit = 2
|
||||
): string => {
|
||||
const trigger = themeBehaviorTriggerLabel(behavior, elements, groups);
|
||||
if (behavior.script !== undefined) {
|
||||
const lines = behavior.script.split('\n').filter((line) => line.trim()).length;
|
||||
return `${trigger} → Advanced script · ${lines} line${lines === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (behavior.actions.length === 0) return `${trigger} · no action`;
|
||||
const actions = behavior.actions.map((action) =>
|
||||
themeBehaviorActionLabel(action, elements, groups)
|
||||
);
|
||||
const visible = actions.slice(0, limit);
|
||||
const remaining = actions.length - visible.length;
|
||||
return `${trigger} → ${visible.join(' · ')}${remaining > 0 ? ` +${remaining}` : ''}`;
|
||||
};
|
||||
+236
-14
@@ -8,6 +8,10 @@
|
||||
* one-byte ranges, which can starve the audio decoder. A blob avoids that, but
|
||||
* WebKit's blob-backed media path starts stuttering past roughly 30 MB, so
|
||||
* large files are left on the direct URL instead.
|
||||
*
|
||||
* WebKit also decodes VP9/AV1 video in software on macOS. Large installed
|
||||
* theme videos and videos picked in the editor (served as /preview/ URLs) are
|
||||
* transcoded to a cached H.264 copy by the Rust backend before playback.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
@@ -16,10 +20,12 @@ import { writable } from 'svelte/store';
|
||||
type MediaCleanup = () => void;
|
||||
|
||||
const assetPattern = /^(?:asset:|https?:\/\/asset\.localhost(?:\/|$))/i;
|
||||
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/themes\//i;
|
||||
const videoFilePattern = /\.(?:webm|mp4|m4v|mov|ogv|mkv)(?:[?#]|$)/i;
|
||||
const loopbackThemePattern = /^http:\/\/127\.0\.0\.1:\d+\/(?:themes|preview)\//i;
|
||||
const MAX_BLOB_BYTES = 24 * 1024 * 1024;
|
||||
const OPTIMIZE_AFTER_BYTES = 20 * 1024 * 1024;
|
||||
const OPTIMIZE_AFTER_BYTES = 2 * 1024 * 1024;
|
||||
const FADE_OUT_MS = 260;
|
||||
const FADE_IN_MS = 420;
|
||||
const FADE_STEP_MS = 16;
|
||||
|
||||
// The request-storm problem is specific to WKWebView. Keep the native
|
||||
// streaming path everywhere else, where the asset protocol behaves correctly
|
||||
@@ -31,6 +37,8 @@ const useBlobMedia = isMac;
|
||||
export const themeMediaPreparing = writable(false);
|
||||
/** Human-readable detail for the preparation overlay. */
|
||||
export const themeMediaStatus = writable('');
|
||||
/** True while the launcher's theme media should stay paused, e.g. in the editor. */
|
||||
export const themeMediaSuspended = writable(false);
|
||||
|
||||
type ThemeMediaProgress = {
|
||||
stage: 'download' | 'extract' | 'optimize';
|
||||
@@ -78,16 +86,15 @@ let cacheGeneration = 0;
|
||||
export const isThemeAssetUrl = (url: string | undefined | null): url is string =>
|
||||
!!url && assetPattern.test(url);
|
||||
|
||||
const isLoopbackThemeVideo = (source: string) =>
|
||||
isMac && loopbackThemePattern.test(source) && videoFilePattern.test(source);
|
||||
|
||||
/**
|
||||
* WebKit's VP9 decoder is software-only on macOS, and a large 1080p theme
|
||||
* video can starve the audio decoder. Ask the launcher for a cached 720p
|
||||
* WebKit's VP9/AV1 decoders are software-only on macOS, and even a short 1080p
|
||||
* theme video can starve the audio decoder. Ask the launcher for a cached 720p
|
||||
* H.264/AAC version, which the macOS media engine decodes in hardware.
|
||||
* `video` avoids transcoding audio-only media and lets editor /preview/ URLs
|
||||
* participate even though their token has no file extension.
|
||||
*/
|
||||
const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
|
||||
if (!isLoopbackThemeVideo(source)) return source;
|
||||
const optimizedThemeMediaUrl = async (source: string, video: boolean): Promise<string> => {
|
||||
if (!isMac || !video || !loopbackThemePattern.test(source)) return source;
|
||||
|
||||
const cached = optimizedUrlPromises.get(source);
|
||||
if (cached) return cached;
|
||||
@@ -122,8 +129,8 @@ const optimizedThemeMediaUrl = async (source: string): Promise<string> => {
|
||||
};
|
||||
|
||||
/** Resolve a theme asset to a blob URL, falling back to the original URL. */
|
||||
export const themeMediaUrl = async (source: string): Promise<string> => {
|
||||
const optimized = await optimizedThemeMediaUrl(source);
|
||||
export const themeMediaUrl = async (source: string, video = false): Promise<string> => {
|
||||
const optimized = await optimizedThemeMediaUrl(source, video);
|
||||
if (optimized !== source) return optimized;
|
||||
|
||||
if (!useBlobMedia || !isThemeAssetUrl(source)) return source;
|
||||
@@ -219,13 +226,14 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
||||
|
||||
const previousTime = Number.isFinite(node.currentTime) ? node.currentTime : 0;
|
||||
const wasPlaying = !node.paused;
|
||||
const needsPreparation = node instanceof HTMLVideoElement && isLoopbackThemeVideo(source);
|
||||
const needsPreparation =
|
||||
node instanceof HTMLVideoElement && isMac && loopbackThemePattern.test(source);
|
||||
|
||||
// Keep the last frame visible while the first H.264 encode runs instead of
|
||||
// letting WebKit struggle through the software VP9 track.
|
||||
if (needsPreparation) node.pause();
|
||||
|
||||
void themeMediaUrl(source).then((url) => {
|
||||
void themeMediaUrl(source, node instanceof HTMLVideoElement).then((url) => {
|
||||
if (mediaTokens.get(node) !== token || !node.isConnected) return;
|
||||
|
||||
if (node.src === url) {
|
||||
@@ -275,6 +283,220 @@ const loadMediaSource = (node: HTMLMediaElement, source: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const easeInOut = (progress: number) => 0.5 - Math.cos(Math.PI * progress) / 2;
|
||||
|
||||
/**
|
||||
* Ramp a media element's volume to `target`. Returns a stop function that
|
||||
* leaves the current volume untouched, so a caller can continue from where the
|
||||
* ramp ended (for example when the editor is reopened mid fade-in).
|
||||
*/
|
||||
const fadeMediaVolume = (
|
||||
media: HTMLMediaElement,
|
||||
target: number,
|
||||
duration: number,
|
||||
done: () => void
|
||||
): (() => void) => {
|
||||
const from = media.volume;
|
||||
const to = Math.min(1, Math.max(0, target));
|
||||
let frame: ReturnType<typeof setInterval> | undefined;
|
||||
let guard: ReturnType<typeof setTimeout> | undefined;
|
||||
let finished = false;
|
||||
|
||||
const stopTimers = () => {
|
||||
if (frame) clearInterval(frame);
|
||||
if (guard) clearTimeout(guard);
|
||||
frame = undefined;
|
||||
guard = undefined;
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
stopTimers();
|
||||
media.volume = to;
|
||||
done();
|
||||
};
|
||||
|
||||
if (duration <= 0 || Math.abs(from - to) < 0.005) {
|
||||
finish();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
frame = setInterval(() => {
|
||||
const progress = Math.min(1, (performance.now() - startedAt) / duration);
|
||||
media.volume = Math.min(1, Math.max(0, from + (to - from) * easeInOut(progress)));
|
||||
if (progress >= 1) finish();
|
||||
}, FADE_STEP_MS);
|
||||
|
||||
// The editor takes focus while its window opens, which can throttle timers in
|
||||
// the launcher. Never leave a track stuck at a partial volume because the
|
||||
// ramp stalled.
|
||||
guard = setTimeout(finish, duration + 150);
|
||||
|
||||
return () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
stopTimers();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pause theme media while the advanced editor window is open. The editor has
|
||||
* its own preview with audio and video, so leaving the launcher's background
|
||||
* track playing would make both overlap. The launcher fades its volume down
|
||||
* before pausing and back up on return, and only media that was actually
|
||||
* playing when the editor opened is resumed when it closes.
|
||||
*/
|
||||
export function themeMediaSuspension(node: HTMLElement) {
|
||||
/** Paused media, with the volume to restore on resume. */
|
||||
const pausedBySuspension = new Map<HTMLMediaElement, number>();
|
||||
/** In-flight volume ramps, so a quick open/close can reverse cleanly. */
|
||||
const ramps = new Map<HTMLMediaElement, () => void>();
|
||||
let suspended = false;
|
||||
|
||||
const mediaIn = (root: ParentNode) =>
|
||||
Array.from(root.querySelectorAll<HTMLMediaElement>('audio, video'));
|
||||
|
||||
const clamp = (volume: number) => Math.min(1, Math.max(0, volume));
|
||||
|
||||
const cancelRamp = (media: HTMLMediaElement) => {
|
||||
ramps.get(media)?.();
|
||||
ramps.delete(media);
|
||||
};
|
||||
|
||||
/** Fade a playing track down, then pause it and restore its volume. */
|
||||
const suspendMedia = (media: HTMLMediaElement, fade: boolean) => {
|
||||
if (media.paused || media.ended) return;
|
||||
|
||||
// Keep the original listening level, even if a previous fade-in is still
|
||||
// running when the editor is reopened.
|
||||
const restore = pausedBySuspension.get(media) ?? media.volume;
|
||||
cancelRamp(media);
|
||||
pausedBySuspension.set(media, restore);
|
||||
|
||||
if (!fade || media.muted || media.volume === 0) {
|
||||
media.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
ramps.set(
|
||||
media,
|
||||
fadeMediaVolume(media, 0, FADE_OUT_MS, () => {
|
||||
ramps.delete(media);
|
||||
media.pause();
|
||||
// Keep the element's own volume intact while it is paused; resuming
|
||||
// restores the listening level explicitly.
|
||||
media.volume = restore;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/** Start playback and fade the volume up to `volume`. */
|
||||
const fadeIn = (media: HTMLMediaElement, volume: number) => {
|
||||
const target = clamp(volume);
|
||||
cancelRamp(media);
|
||||
|
||||
if (!media.isConnected) {
|
||||
pausedBySuspension.delete(media);
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.muted || target === 0) {
|
||||
media.volume = target;
|
||||
pausedBySuspension.delete(media);
|
||||
void media.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
media.volume = 0;
|
||||
void media
|
||||
.play()
|
||||
.then(() => {
|
||||
// The editor was reopened before the first frame of the fade-in.
|
||||
if (suspended) {
|
||||
suspendMedia(media, true);
|
||||
return;
|
||||
}
|
||||
ramps.set(
|
||||
media,
|
||||
fadeMediaVolume(media, target, FADE_IN_MS, () => {
|
||||
ramps.delete(media);
|
||||
pausedBySuspension.delete(media);
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
media.volume = target;
|
||||
pausedBySuspension.delete(media);
|
||||
});
|
||||
};
|
||||
|
||||
const suspendAll = (root: ParentNode = node) => {
|
||||
for (const media of mediaIn(root)) suspendMedia(media, true);
|
||||
};
|
||||
|
||||
const resumeAll = () => {
|
||||
for (const [media, volume] of pausedBySuspension) {
|
||||
if (!media.isConnected) {
|
||||
pausedBySuspension.delete(media);
|
||||
continue;
|
||||
}
|
||||
if (!media.paused) {
|
||||
// A very quick open/close reversed the fade-out before it paused:
|
||||
// bring the level back up on the track that never stopped.
|
||||
cancelRamp(media);
|
||||
media.volume = clamp(volume);
|
||||
pausedBySuspension.delete(media);
|
||||
continue;
|
||||
}
|
||||
fadeIn(media, volume);
|
||||
}
|
||||
};
|
||||
|
||||
// A theme can remount its media (for example after the active theme is
|
||||
// reloaded) while the editor is open. Catch those autoplaying elements too.
|
||||
const onPlay = (event: Event) => {
|
||||
if (suspended && event.target instanceof HTMLMediaElement) suspendMedia(event.target, false);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (!suspended) return;
|
||||
for (const mutation of mutations) {
|
||||
for (const added of mutation.addedNodes) {
|
||||
if (added instanceof HTMLMediaElement) suspendMedia(added, false);
|
||||
else if (added instanceof HTMLElement) suspendAll(added);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
node.addEventListener('play', onPlay, true);
|
||||
|
||||
const unsubscribe = themeMediaSuspended.subscribe((value) => {
|
||||
if (value === suspended) return;
|
||||
suspended = value;
|
||||
if (suspended) {
|
||||
observer.observe(node, { childList: true, subtree: true });
|
||||
suspendAll();
|
||||
} else {
|
||||
observer.disconnect();
|
||||
resumeAll();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
observer.disconnect();
|
||||
node.removeEventListener('play', onPlay, true);
|
||||
for (const cancel of ramps.values()) cancel();
|
||||
ramps.clear();
|
||||
for (const [media, volume] of pausedBySuspension) media.volume = volume;
|
||||
pausedBySuspension.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause background videos while the launcher is hidden or unfocused, then
|
||||
* resume only the videos this helper paused itself. This keeps muted theme
|
||||
|
||||
+1079
-52
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,504 @@
|
||||
import { isThemeClockVariable, themeClockValues } from './themeClock';
|
||||
import {
|
||||
executeThemeScript,
|
||||
formatThemeScriptError,
|
||||
parseThemeScript,
|
||||
themeScriptSourceVariables,
|
||||
type ThemeScriptProgram,
|
||||
} from './themeScriptLang';
|
||||
import { themeTextVariableNames } from './themeTextVariables';
|
||||
import {
|
||||
themeGroupAncestors,
|
||||
type ThemeV2Behavior,
|
||||
type ThemeV2BehaviorAction,
|
||||
type ThemeV2BehaviorCondition,
|
||||
type ThemeV2BehaviorOperator,
|
||||
type ThemeV2BehaviorTrigger,
|
||||
type ThemeV2Definition,
|
||||
type ThemeV2Element,
|
||||
type ThemeV2Group,
|
||||
type ThemeV2ScriptScreen,
|
||||
type ThemeV2ScriptValue,
|
||||
} from './themeV2';
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
export type ThemeScriptState = {
|
||||
variables: Record<string, ThemeV2ScriptValue>;
|
||||
/** Per-element visibility overrides set by rules. */
|
||||
visibility: Record<string, boolean>;
|
||||
/** Per-group visibility overrides set by rules. */
|
||||
groupVisibility: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type ThemeScriptHandlers = {
|
||||
navigate?: (screen: ThemeV2ScriptScreen) => void;
|
||||
launch?: () => void;
|
||||
openUrl?: (url: string) => void;
|
||||
};
|
||||
|
||||
export type ThemeScriptEvalContext = {
|
||||
variables: Record<string, ThemeV2ScriptValue>;
|
||||
screen: ThemeV2ScriptScreen;
|
||||
isVisible: (elementId: string) => boolean;
|
||||
isGroupVisible: (groupId: string) => boolean;
|
||||
random: () => number;
|
||||
};
|
||||
|
||||
const emptyState = (): ThemeScriptState => ({
|
||||
variables: {},
|
||||
visibility: {},
|
||||
groupVisibility: {},
|
||||
});
|
||||
|
||||
export const themeScriptState = writable<ThemeScriptState>(emptyState());
|
||||
|
||||
let definition: ThemeV2Definition | undefined;
|
||||
let currentScreen: ThemeV2ScriptScreen = 'home';
|
||||
let handlers: ThemeScriptHandlers = {};
|
||||
let state = emptyState();
|
||||
let firedRules = new Set<string>();
|
||||
const timerHandles = new Map<string, ReturnType<typeof setInterval>>();
|
||||
let compiledScripts = new Map<string, ThemeScriptProgram>();
|
||||
let clockHandle: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const publish = () => {
|
||||
themeScriptState.set({
|
||||
variables: { ...state.variables },
|
||||
visibility: { ...state.visibility },
|
||||
groupVisibility: { ...state.groupVisibility },
|
||||
});
|
||||
};
|
||||
|
||||
const elementById = (elementId: string): ThemeV2Element | undefined =>
|
||||
definition?.elements?.find((element) => element.id === elementId);
|
||||
|
||||
const baseHidden = (element: ThemeV2Element) =>
|
||||
element.hidden === true ||
|
||||
themeGroupAncestors(definition?.groups, element).some((group) => group.hidden === true);
|
||||
|
||||
const groupChain = (groupId: string): ThemeV2Group[] => {
|
||||
const byId = new Map((definition?.groups ?? []).map((group) => [group.id, group]));
|
||||
const chain: ThemeV2Group[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(groupId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
chain.push(current);
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
export const themeScriptGroupVisible = (groupId: string): boolean => {
|
||||
const chain = groupChain(groupId);
|
||||
if (chain.length === 0) return false;
|
||||
if (chain.some((group) => state.groupVisibility[group.id] === false)) return false;
|
||||
const own = state.groupVisibility[groupId];
|
||||
if (own !== undefined) return own;
|
||||
if (chain.some((group) => state.groupVisibility[group.id] === true)) return true;
|
||||
return !chain.some((group) => group.hidden === true);
|
||||
};
|
||||
|
||||
export const themeScriptElementVisible = (elementId: string): boolean => {
|
||||
const element = elementById(elementId);
|
||||
if (!element) return false;
|
||||
const ancestors = themeGroupAncestors(definition?.groups, element);
|
||||
if (ancestors.some((group) => state.groupVisibility[group.id] === false)) return false;
|
||||
const override = state.visibility[elementId];
|
||||
if (override !== undefined) return override;
|
||||
if (ancestors.some((group) => state.groupVisibility[group.id] === true)) return true;
|
||||
return !baseHidden(element);
|
||||
};
|
||||
|
||||
export const themeScriptVariableNames = (theme: ThemeV2Definition | undefined): string[] => {
|
||||
const names = new Set<string>();
|
||||
if (!theme) return [];
|
||||
// Include variables that only declare a starting value so they stay visible
|
||||
// in the editor and can be reset instead of disappearing silently. Clock
|
||||
// values are supplied by the runtime and are not editable defaults.
|
||||
for (const name of Object.keys(theme.variables ?? {})) {
|
||||
if (!isThemeClockVariable(name)) names.add(name);
|
||||
}
|
||||
for (const element of theme.elements ?? []) {
|
||||
if (element.type === 'text') {
|
||||
for (const name of themeTextVariableNames(element.text)) {
|
||||
if (!isThemeClockVariable(name)) names.add(name);
|
||||
}
|
||||
} else if (element.type === 'svelte') {
|
||||
// Custom layers read variables as props. Include those names so the
|
||||
// variable defaults panel and IntelliSense know about them.
|
||||
for (const match of element.source.matchAll(/\bvariables\.([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
||||
if (!isThemeClockVariable(match[1])) names.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const behavior of theme.behaviors ?? []) {
|
||||
for (const condition of behavior.conditions ?? []) {
|
||||
if (condition.type === 'variable' && !isThemeClockVariable(condition.variable)) {
|
||||
names.add(condition.variable);
|
||||
}
|
||||
}
|
||||
for (const action of behavior.actions) {
|
||||
if (
|
||||
(action.type === 'setVariable' ||
|
||||
action.type === 'changeVariable' ||
|
||||
action.type === 'toggleVariable') &&
|
||||
!isThemeClockVariable(action.variable)
|
||||
) {
|
||||
names.add(action.variable);
|
||||
}
|
||||
}
|
||||
if (behavior.script !== undefined) {
|
||||
for (const name of themeScriptSourceVariables(behavior.script)) {
|
||||
if (!isThemeClockVariable(name)) names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
};
|
||||
|
||||
/** All variables a theme starts with; rule triggers and text layers add more. */
|
||||
export const themeScriptInitialVariables = (
|
||||
theme: ThemeV2Definition | undefined
|
||||
): Record<string, ThemeV2ScriptValue> => {
|
||||
const defaults = theme?.variables ?? {};
|
||||
const values = Object.fromEntries(
|
||||
themeScriptVariableNames(theme).map((name) => [
|
||||
name,
|
||||
Object.prototype.hasOwnProperty.call(defaults, name) ? defaults[name] : 0,
|
||||
])
|
||||
);
|
||||
// Clock values are always current and override any same-named defaults.
|
||||
return { ...values, ...themeClockValues() };
|
||||
};
|
||||
|
||||
const sameValue = (actual: ThemeV2ScriptValue | undefined, expected: ThemeV2ScriptValue) => {
|
||||
if (typeof expected === 'number') return Number(actual) === expected;
|
||||
if (typeof expected === 'boolean') return Boolean(actual) === expected;
|
||||
return String(actual ?? '') === expected;
|
||||
};
|
||||
|
||||
const compareValues = (
|
||||
actual: ThemeV2ScriptValue | undefined,
|
||||
expected: ThemeV2ScriptValue,
|
||||
operator: ThemeV2BehaviorOperator
|
||||
) => {
|
||||
switch (operator) {
|
||||
case 'notEquals':
|
||||
return !sameValue(actual, expected);
|
||||
case 'greaterThan':
|
||||
return Number(actual) > Number(expected);
|
||||
case 'lessThan':
|
||||
return Number(actual) < Number(expected);
|
||||
case 'greaterOrEqual':
|
||||
return Number(actual) >= Number(expected);
|
||||
case 'lessOrEqual':
|
||||
return Number(actual) <= Number(expected);
|
||||
default:
|
||||
return sameValue(actual, expected);
|
||||
}
|
||||
};
|
||||
|
||||
export const themeScriptConditionMatches = (
|
||||
condition: ThemeV2BehaviorCondition,
|
||||
context: ThemeScriptEvalContext
|
||||
): boolean => {
|
||||
switch (condition.type) {
|
||||
case 'variable':
|
||||
return compareValues(
|
||||
context.variables[condition.variable],
|
||||
condition.value,
|
||||
condition.operator
|
||||
);
|
||||
case 'visible':
|
||||
return context.isVisible(condition.elementId) === condition.value;
|
||||
case 'groupVisible':
|
||||
return context.isGroupVisible(condition.groupId) === condition.value;
|
||||
case 'screen':
|
||||
return context.screen === condition.screen;
|
||||
case 'chance':
|
||||
return context.random() * 100 < condition.percent;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const themeScriptConditionsMatch = (
|
||||
conditions: ThemeV2BehaviorCondition[] | undefined,
|
||||
mode: 'all' | 'any',
|
||||
context: ThemeScriptEvalContext
|
||||
): boolean => {
|
||||
if (!conditions || conditions.length === 0) return true;
|
||||
if (mode === 'any')
|
||||
return conditions.some((condition) => themeScriptConditionMatches(condition, context));
|
||||
return conditions.every((condition) => themeScriptConditionMatches(condition, context));
|
||||
};
|
||||
|
||||
const triggerMatches = (
|
||||
trigger: ThemeV2BehaviorTrigger,
|
||||
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
|
||||
detail: { elementId?: string; screen?: ThemeV2ScriptScreen }
|
||||
) => {
|
||||
if (trigger.type !== type) return false;
|
||||
if (trigger.type === 'screen') return trigger.screen === detail.screen;
|
||||
if (trigger.type === 'click' || trigger.type === 'hover' || trigger.type === 'leave') {
|
||||
return !trigger.elementId || trigger.elementId === detail.elementId;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const context = (): ThemeScriptEvalContext => ({
|
||||
variables: state.variables,
|
||||
screen: currentScreen,
|
||||
isVisible: themeScriptElementVisible,
|
||||
isGroupVisible: themeScriptGroupVisible,
|
||||
random: Math.random,
|
||||
});
|
||||
|
||||
const applyActions = (actions: ThemeV2BehaviorAction[], allowExternal: boolean) => {
|
||||
if (actions.length === 0) return;
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case 'setVariable':
|
||||
if (isThemeClockVariable(action.variable)) break;
|
||||
state.variables[action.variable] = action.value;
|
||||
break;
|
||||
case 'changeVariable':
|
||||
if (isThemeClockVariable(action.variable)) break;
|
||||
state.variables[action.variable] =
|
||||
Number(state.variables[action.variable] ?? 0) + action.amount;
|
||||
break;
|
||||
case 'toggleVariable':
|
||||
if (isThemeClockVariable(action.variable)) break;
|
||||
state.variables[action.variable] = !state.variables[action.variable];
|
||||
break;
|
||||
case 'showElement':
|
||||
state.visibility[action.elementId] = true;
|
||||
break;
|
||||
case 'hideElement':
|
||||
state.visibility[action.elementId] = false;
|
||||
break;
|
||||
case 'toggleElement':
|
||||
state.visibility[action.elementId] = !themeScriptElementVisible(action.elementId);
|
||||
break;
|
||||
case 'showGroup':
|
||||
state.groupVisibility[action.groupId] = true;
|
||||
break;
|
||||
case 'hideGroup':
|
||||
state.groupVisibility[action.groupId] = false;
|
||||
break;
|
||||
case 'toggleGroup':
|
||||
state.groupVisibility[action.groupId] = !themeScriptGroupVisible(action.groupId);
|
||||
break;
|
||||
case 'navigate':
|
||||
handlers.navigate?.(action.screen);
|
||||
break;
|
||||
case 'launch':
|
||||
// Leaving the launcher is only allowed from an explicit click.
|
||||
if (allowExternal) handlers.launch?.();
|
||||
break;
|
||||
case 'openUrl':
|
||||
if (allowExternal && /^https?:\/\//i.test(action.url)) handlers.openUrl?.(action.url);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
publish();
|
||||
};
|
||||
|
||||
const compileThemeScripts = (theme: ThemeV2Definition | undefined) => {
|
||||
compiledScripts = new Map<string, ThemeScriptProgram>();
|
||||
for (const rule of theme?.behaviors ?? []) {
|
||||
if (rule.script === undefined) continue;
|
||||
const result = parseThemeScript(rule.script);
|
||||
if (result.errors.length > 0) {
|
||||
console.warn(
|
||||
`Theme rule "${rule.name ?? rule.id}" has an invalid advanced script: ${formatThemeScriptError(
|
||||
result.errors[0]
|
||||
)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
compiledScripts.set(rule.id, result.program);
|
||||
}
|
||||
};
|
||||
|
||||
const runCompiledScript = (program: ThemeScriptProgram, allowExternal: boolean) => {
|
||||
executeThemeScript(program, {
|
||||
variables: state.variables,
|
||||
screen: currentScreen,
|
||||
random: Math.random,
|
||||
isVisible: themeScriptElementVisible,
|
||||
isGroupVisible: themeScriptGroupVisible,
|
||||
setElementVisible: (elementId, visible) => {
|
||||
state.visibility[elementId] = visible;
|
||||
},
|
||||
setGroupVisible: (groupId, visible) => {
|
||||
state.groupVisibility[groupId] = visible;
|
||||
},
|
||||
navigate: (screen) => handlers.navigate?.(screen),
|
||||
openUrl: (url) => handlers.openUrl?.(url),
|
||||
launch: () => handlers.launch?.(),
|
||||
allowExternal,
|
||||
});
|
||||
publish();
|
||||
};
|
||||
|
||||
const stopRuleTimer = (ruleId: string) => {
|
||||
const handle = timerHandles.get(ruleId);
|
||||
if (handle !== undefined) {
|
||||
clearInterval(handle);
|
||||
timerHandles.delete(ruleId);
|
||||
}
|
||||
};
|
||||
|
||||
const behaviorTargetMatches = (rule: ThemeV2Behavior, detail: { elementId?: string }): boolean => {
|
||||
if (!rule.target) return true;
|
||||
if (
|
||||
rule.trigger.type !== 'click' &&
|
||||
rule.trigger.type !== 'hover' &&
|
||||
rule.trigger.type !== 'leave'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!detail.elementId) return false;
|
||||
if (rule.target.type === 'element') return rule.target.id === detail.elementId;
|
||||
|
||||
const element = elementById(detail.elementId);
|
||||
if (!element) return false;
|
||||
return themeGroupAncestors(definition?.groups, element).some(
|
||||
(group) => group.id === rule.target?.id
|
||||
);
|
||||
};
|
||||
|
||||
const runRule = (
|
||||
rule: ThemeV2Behavior,
|
||||
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
|
||||
detail: { elementId?: string; screen?: ThemeV2ScriptScreen } = {}
|
||||
) => {
|
||||
if (rule.enabled === false) return;
|
||||
if (rule.once && firedRules.has(rule.id)) return;
|
||||
if (!triggerMatches(rule.trigger, type, detail)) return;
|
||||
if (!behaviorTargetMatches(rule, detail)) return;
|
||||
if (!themeScriptConditionsMatch(rule.conditions, rule.conditionMode ?? 'all', context())) return;
|
||||
if (rule.once) {
|
||||
firedRules.add(rule.id);
|
||||
stopRuleTimer(rule.id);
|
||||
}
|
||||
|
||||
if (rule.script !== undefined) {
|
||||
const program = compiledScripts.get(rule.id);
|
||||
if (program) runCompiledScript(program, type === 'click');
|
||||
return;
|
||||
}
|
||||
|
||||
applyActions(rule.actions, type === 'click');
|
||||
};
|
||||
|
||||
const runRules = (
|
||||
type: 'load' | 'screen' | 'click' | 'hover' | 'leave' | 'timer',
|
||||
detail: { elementId?: string; screen?: ThemeV2ScriptScreen } = {}
|
||||
) => {
|
||||
if (!definition) return;
|
||||
for (const rule of definition.behaviors ?? []) {
|
||||
runRule(rule, type, detail);
|
||||
}
|
||||
};
|
||||
|
||||
const updateClock = () => {
|
||||
if (!definition) return;
|
||||
Object.assign(state.variables, themeClockValues());
|
||||
publish();
|
||||
};
|
||||
|
||||
const stopClock = () => {
|
||||
if (clockHandle !== undefined) {
|
||||
clearInterval(clockHandle);
|
||||
clockHandle = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startClock = () => {
|
||||
stopClock();
|
||||
updateClock();
|
||||
clockHandle = setInterval(updateClock, 1000);
|
||||
};
|
||||
|
||||
const startTimers = () => {
|
||||
for (const rule of definition?.behaviors ?? []) {
|
||||
if (rule.enabled === false || rule.trigger.type !== 'timer') continue;
|
||||
const milliseconds = Math.max(500, rule.trigger.interval * 1000);
|
||||
const handle = setInterval(() => runRule(rule, 'timer'), milliseconds);
|
||||
timerHandles.set(rule.id, handle);
|
||||
}
|
||||
};
|
||||
|
||||
const stopTimers = () => {
|
||||
for (const handle of timerHandles.values()) clearInterval(handle);
|
||||
timerHandles.clear();
|
||||
};
|
||||
|
||||
export const setThemeScriptHandlers = (next: ThemeScriptHandlers) => {
|
||||
handlers = { ...handlers, ...next };
|
||||
};
|
||||
|
||||
/** Load a theme's rules, reset its variables and start timer triggers. */
|
||||
export const loadThemeScript = (next: ThemeV2Definition | undefined) => {
|
||||
stopTimers();
|
||||
stopClock();
|
||||
definition = next;
|
||||
state = emptyState();
|
||||
firedRules = new Set<string>();
|
||||
compileThemeScripts(next);
|
||||
state.variables = themeScriptInitialVariables(next);
|
||||
publish();
|
||||
if (!next) return;
|
||||
startClock();
|
||||
startTimers();
|
||||
runRules('load');
|
||||
runRules('screen', { screen: currentScreen });
|
||||
};
|
||||
|
||||
export const stopThemeScript = () => {
|
||||
stopTimers();
|
||||
stopClock();
|
||||
definition = undefined;
|
||||
state = emptyState();
|
||||
firedRules = new Set<string>();
|
||||
compiledScripts = new Map<string, ThemeScriptProgram>();
|
||||
publish();
|
||||
};
|
||||
|
||||
export const setThemeScriptScreen = (screen: ThemeV2ScriptScreen) => {
|
||||
if (currentScreen === screen) return;
|
||||
currentScreen = screen;
|
||||
runRules('screen', { screen });
|
||||
};
|
||||
|
||||
export const triggerThemeScriptElement = (
|
||||
elementId: string,
|
||||
interaction: 'click' | 'hover' | 'leave'
|
||||
) => {
|
||||
runRules(interaction, { elementId });
|
||||
};
|
||||
|
||||
/** Exposed for editor previews and tests. */
|
||||
export const evaluateThemeScriptConditions = (
|
||||
conditions: ThemeV2BehaviorCondition[] | undefined,
|
||||
mode: 'all' | 'any',
|
||||
variables: Record<string, ThemeV2ScriptValue>,
|
||||
screen: ThemeV2ScriptScreen,
|
||||
isVisible: (elementId: string) => boolean,
|
||||
random: () => number = Math.random,
|
||||
isGroupVisible: (groupId: string) => boolean = () => true
|
||||
) =>
|
||||
themeScriptConditionsMatch(conditions, mode, {
|
||||
variables,
|
||||
screen,
|
||||
isVisible,
|
||||
isGroupVisible,
|
||||
random,
|
||||
});
|
||||
|
||||
export const getThemeScriptState = () => get(themeScriptState);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,490 @@
|
||||
/* eslint-disable svelte/no-svelte-internal -- Svelte has no public constructor for components compiled at runtime; theme layers must use the client runtime that `svelte/compiler` emits. */
|
||||
import { parse as parseAcorn } from 'acorn';
|
||||
import type {
|
||||
ExportDefaultDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
ImportDeclaration,
|
||||
Literal,
|
||||
Node as AcornNode,
|
||||
Program,
|
||||
} from 'acorn';
|
||||
import type { Component } from 'svelte';
|
||||
import * as SvelteRuntime from 'svelte';
|
||||
import * as SvelteAnimate from 'svelte/animate';
|
||||
import * as SvelteEasing from 'svelte/easing';
|
||||
import * as SvelteClient from 'svelte/internal/client';
|
||||
import * as SvelteLegacy from 'svelte/legacy';
|
||||
import * as SvelteMotion from 'svelte/motion';
|
||||
import * as SvelteReactivity from 'svelte/reactivity';
|
||||
import * as SvelteStore from 'svelte/store';
|
||||
import * as SvelteTransition from 'svelte/transition';
|
||||
|
||||
export type ThemeSvelteDiagnostic = {
|
||||
message: string;
|
||||
line: number;
|
||||
column: number;
|
||||
endLine?: number;
|
||||
endColumn?: number;
|
||||
severity: 'error' | 'warning';
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export type ThemeSvelteAnalysis =
|
||||
| {
|
||||
ok: true;
|
||||
/** Compiled module rewritten to run through the launcher's runtime map. */
|
||||
code: string;
|
||||
/** Side-effect runtime flags the component needs before it is mounted. */
|
||||
flags?: string[];
|
||||
warnings: ThemeSvelteDiagnostic[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
errors: ThemeSvelteDiagnostic[];
|
||||
warnings: ThemeSvelteDiagnostic[];
|
||||
};
|
||||
|
||||
export type ThemeSvelteComponent = Component<Record<string, unknown>>;
|
||||
|
||||
export type ThemeSvelteCompileResult = {
|
||||
component?: ThemeSvelteComponent;
|
||||
errors: ThemeSvelteDiagnostic[];
|
||||
warnings: ThemeSvelteDiagnostic[];
|
||||
};
|
||||
|
||||
/** Public Svelte modules a theme component may import. */
|
||||
const runtimeModules: Record<string, Record<string, unknown>> = {
|
||||
svelte: SvelteRuntime as unknown as Record<string, unknown>,
|
||||
'svelte/internal/client': SvelteClient as unknown as Record<string, unknown>,
|
||||
'svelte/internal/disclose-version': {},
|
||||
'svelte/animate': SvelteAnimate as unknown as Record<string, unknown>,
|
||||
'svelte/easing': SvelteEasing as unknown as Record<string, unknown>,
|
||||
'svelte/legacy': SvelteLegacy as unknown as Record<string, unknown>,
|
||||
'svelte/motion': SvelteMotion as unknown as Record<string, unknown>,
|
||||
'svelte/reactivity': SvelteReactivity as unknown as Record<string, unknown>,
|
||||
'svelte/store': SvelteStore as unknown as Record<string, unknown>,
|
||||
'svelte/transition': SvelteTransition as unknown as Record<string, unknown>,
|
||||
};
|
||||
|
||||
const LEGACY_FLAG_MODULE = 'svelte/internal/flags/legacy';
|
||||
runtimeModules['svelte/internal/flags/async'] = {};
|
||||
runtimeModules['svelte/internal/flags/legacy'] = {};
|
||||
runtimeModules['svelte/internal/flags/tracing'] = {};
|
||||
const SUPPORTED_IMPORTS = [
|
||||
'svelte',
|
||||
'svelte/animate',
|
||||
'svelte/easing',
|
||||
'svelte/motion',
|
||||
'svelte/reactivity',
|
||||
'svelte/store',
|
||||
'svelte/transition',
|
||||
].join(', ');
|
||||
const MAX_CACHED_PROGRAMS = 24;
|
||||
const analysisCache = new Map<string, Promise<ThemeSvelteAnalysis>>();
|
||||
|
||||
type Replacement = { start: number; end: number; text: string };
|
||||
|
||||
const applyReplacements = (code: string, replacements: Replacement[]) => {
|
||||
let next = code;
|
||||
for (const replacement of [...replacements].sort((a, b) => b.start - a.start)) {
|
||||
next = `${next.slice(0, replacement.start)}${replacement.text}${next.slice(replacement.end)}`;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const nodeValue = (node: AcornNode | null | undefined): Record<string, unknown> | undefined =>
|
||||
node ? (node as unknown as Record<string, unknown>) : undefined;
|
||||
|
||||
const literalValue = (node: unknown): string | undefined => {
|
||||
const value = nodeValue(node as AcornNode | undefined)?.value;
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
const scriptStartLine = (source: string) => {
|
||||
const index = source.indexOf('<script');
|
||||
if (index < 0) return 1;
|
||||
return source.slice(0, index).split('\n').length;
|
||||
};
|
||||
|
||||
const unsupportedModuleDiagnostic = (
|
||||
source: string,
|
||||
moduleName: string
|
||||
): ThemeSvelteDiagnostic => ({
|
||||
message: `Importing "${moduleName}" is not available inside a theme layer. Theme components can import ${SUPPORTED_IMPORTS}.`,
|
||||
line: scriptStartLine(source),
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
const dynamicImportDiagnostic = (source: string): ThemeSvelteDiagnostic => ({
|
||||
message:
|
||||
'Dynamic import() is not available inside a theme layer. Import from the Svelte runtime at the top of the component instead.',
|
||||
line: scriptStartLine(source),
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
const importMetaDiagnostic = (source: string): ThemeSvelteDiagnostic => ({
|
||||
message:
|
||||
'import.meta is not available inside a theme layer. Theme components run from a local WebView module, not a bundled file.',
|
||||
line: scriptStartLine(source),
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
const importReplacement = (node: ImportDeclaration): Replacement | undefined => {
|
||||
const moduleName = literalValue(node.source);
|
||||
if (!moduleName || !runtimeModules[moduleName]) return undefined;
|
||||
if (node.specifiers.length === 0) {
|
||||
return { start: node.start, end: node.end, text: '' };
|
||||
}
|
||||
|
||||
const namespaceLines: string[] = [];
|
||||
const defaultLines: string[] = [];
|
||||
const namedSpecifiers: string[] = [];
|
||||
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.type === 'ImportDefaultSpecifier') {
|
||||
defaultLines.push(
|
||||
`const ${specifier.local.name} = __themeModules[${JSON.stringify(moduleName)}].default;`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (specifier.type === 'ImportNamespaceSpecifier') {
|
||||
namespaceLines.push(
|
||||
`const ${specifier.local.name} = __themeModules[${JSON.stringify(moduleName)}];`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const imported =
|
||||
specifier.imported.type === 'Identifier'
|
||||
? specifier.imported.name
|
||||
: String((specifier.imported as Literal).value);
|
||||
namedSpecifiers.push(
|
||||
imported === specifier.local.name
|
||||
? imported
|
||||
: `${JSON.stringify(imported)}: ${specifier.local.name}`
|
||||
);
|
||||
}
|
||||
|
||||
if (namedSpecifiers.length > 0) {
|
||||
namespaceLines.push(
|
||||
`const { ${namedSpecifiers.join(', ')} } = __themeModules[${JSON.stringify(moduleName)}];`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
start: node.start,
|
||||
end: node.end,
|
||||
text: [...namespaceLines, ...defaultLines].join('\n'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-shape Svelte's ESM output into a function body. The compiler emits only
|
||||
* a default export and imports from Svelte's runtime; evaluating that module
|
||||
* directly would require a blob URL, which the Tauri CSP intentionally blocks.
|
||||
* Keeping the transform deterministic also lets the editor show import errors
|
||||
* without mounting anything.
|
||||
*/
|
||||
export const rewriteCompiledSvelteModule = (
|
||||
compiled: string,
|
||||
source = ''
|
||||
): { code?: string; errors: ThemeSvelteDiagnostic[]; flags?: string[] } => {
|
||||
let program: Program;
|
||||
try {
|
||||
program = parseAcorn(compiled, {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
}) as unknown as Program;
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `The compiled Svelte module could not be parsed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const errors: ThemeSvelteDiagnostic[] = [];
|
||||
const replacements: Replacement[] = [];
|
||||
const flags: string[] = [];
|
||||
let defaultExportName: string | undefined;
|
||||
let hasReturnedDefault = false;
|
||||
|
||||
for (const node of program.body) {
|
||||
if (node.type === 'ImportDeclaration') {
|
||||
const declaration = node as ImportDeclaration;
|
||||
const moduleName = literalValue(declaration.source);
|
||||
if (!moduleName || !runtimeModules[moduleName]) {
|
||||
errors.push(unsupportedModuleDiagnostic(source, moduleName ?? 'unknown module'));
|
||||
continue;
|
||||
}
|
||||
if (moduleName === LEGACY_FLAG_MODULE && flags.length === 0) {
|
||||
flags.push(LEGACY_FLAG_MODULE);
|
||||
}
|
||||
const replacement = importReplacement(declaration);
|
||||
if (replacement) replacements.push(replacement);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'ExportDefaultDeclaration') {
|
||||
const declaration = node as ExportDefaultDeclaration;
|
||||
const exported = declaration.declaration;
|
||||
const exportedNode = nodeValue(exported as AcornNode);
|
||||
const exportedName =
|
||||
exportedNode?.type === 'FunctionDeclaration' || exportedNode?.type === 'ClassDeclaration'
|
||||
? (nodeValue(exportedNode.id as AcornNode)?.name as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (exportedName) {
|
||||
defaultExportName = exportedName;
|
||||
replacements.push({ start: declaration.start, end: exported.start, text: '' });
|
||||
} else {
|
||||
hasReturnedDefault = true;
|
||||
replacements.push({ start: declaration.start, end: exported.start, text: 'return ' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'ExportNamedDeclaration') {
|
||||
const declaration = node as ExportNamedDeclaration;
|
||||
if (declaration.source) {
|
||||
errors.push({
|
||||
message: 'Re-exporting from another module is not available inside a theme layer.',
|
||||
line: scriptStartLine(source),
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (declaration.declaration) {
|
||||
replacements.push({
|
||||
start: declaration.start,
|
||||
end: declaration.declaration.start,
|
||||
text: '',
|
||||
});
|
||||
} else {
|
||||
replacements.push({ start: declaration.start, end: declaration.end, text: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const walk = (value: unknown) => {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) walk(item);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === 'ImportExpression') {
|
||||
errors.push(dynamicImportDiagnostic(source));
|
||||
return;
|
||||
}
|
||||
if (record.type === 'MetaProperty') {
|
||||
const meta = nodeValue(record.meta as AcornNode);
|
||||
if (meta?.name === 'import') {
|
||||
errors.push(importMetaDiagnostic(source));
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (key === 'start' || key === 'end' || key === 'loc') continue;
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(program);
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { errors: dedupeDiagnostics(errors) };
|
||||
}
|
||||
if (!defaultExportName && !hasReturnedDefault) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'A custom Svelte layer needs a component. Add markup to the file; Svelte emits the default component automatically.',
|
||||
line: scriptStartLine(source),
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let code = applyReplacements(compiled, replacements);
|
||||
if (defaultExportName) code += `\nreturn ${defaultExportName};`;
|
||||
return { code, errors: [], flags };
|
||||
};
|
||||
|
||||
const dedupeDiagnostics = (diagnostics: ThemeSvelteDiagnostic[]) => {
|
||||
const seen = new Set<string>();
|
||||
return diagnostics.filter((diagnostic) => {
|
||||
const key = `${diagnostic.message}:${diagnostic.line}:${diagnostic.column}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const compilerDiagnostic = (
|
||||
value: unknown,
|
||||
severity: 'error' | 'warning'
|
||||
): ThemeSvelteDiagnostic => {
|
||||
const error = (value ?? {}) as {
|
||||
message?: unknown;
|
||||
code?: unknown;
|
||||
start?: { line?: number; column?: number };
|
||||
end?: { line?: number; column?: number };
|
||||
};
|
||||
const line = Math.max(1, Number(error.start?.line ?? 1));
|
||||
const column = Math.max(1, Number(error.start?.column ?? 0) + 1);
|
||||
const endLine = Math.max(line, Number(error.end?.line ?? line));
|
||||
const endColumn = Math.max(1, Number(error.end?.column ?? 0) + 1);
|
||||
return {
|
||||
message: typeof error.message === 'string' ? error.message : String(value),
|
||||
line,
|
||||
column,
|
||||
endLine,
|
||||
endColumn,
|
||||
severity,
|
||||
code: typeof error.code === 'string' ? error.code : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const analyzeUncached = async (source: string, filename: string): Promise<ThemeSvelteAnalysis> => {
|
||||
let compiler: typeof import('svelte/compiler');
|
||||
try {
|
||||
compiler = await import('svelte/compiler');
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
message: `The Svelte compiler could not be loaded: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = compiler.compile(source, {
|
||||
generate: 'client',
|
||||
filename,
|
||||
css: 'injected',
|
||||
dev: false,
|
||||
hmr: false,
|
||||
preserveComments: true,
|
||||
});
|
||||
const warnings = (result.warnings ?? []).map((warning) =>
|
||||
compilerDiagnostic(warning, 'warning')
|
||||
);
|
||||
const rewritten = rewriteCompiledSvelteModule(result.js.code, source);
|
||||
if (!rewritten.code) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: dedupeDiagnostics(rewritten.errors),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
return { ok: true, code: rewritten.code, flags: rewritten.flags, warnings };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [compilerDiagnostic(error, 'error')],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/** Compile/inspect a layer's source. Shared by the editor and the renderer. */
|
||||
export const analyzeThemeSvelteSource = (
|
||||
source: string,
|
||||
filename = 'ThemeLayer.svelte'
|
||||
): Promise<ThemeSvelteAnalysis> => {
|
||||
const normalized = source;
|
||||
const key = `${filename}\u0000${normalized}`;
|
||||
const cached = analysisCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const analysis = analyzeUncached(normalized, filename);
|
||||
analysisCache.set(key, analysis);
|
||||
if (analysisCache.size > MAX_CACHED_PROGRAMS) {
|
||||
const oldest = analysisCache.keys().next().value;
|
||||
if (oldest) analysisCache.delete(oldest);
|
||||
}
|
||||
return analysis;
|
||||
};
|
||||
|
||||
const runtimeErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}: ${error.message}`;
|
||||
}
|
||||
return String(error);
|
||||
};
|
||||
|
||||
/** Compile and instantiate the component body, ready for Svelte's mount(). */
|
||||
export const compileThemeSvelte = async (
|
||||
source: string,
|
||||
filename = 'ThemeLayer.svelte'
|
||||
): Promise<ThemeSvelteCompileResult> => {
|
||||
const analysis = await analyzeThemeSvelteSource(source, filename);
|
||||
if (!analysis.ok) {
|
||||
return { errors: analysis.errors, warnings: analysis.warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
if (analysis.flags?.includes(LEGACY_FLAG_MODULE)) {
|
||||
// Svelte enables legacy component semantics through a side-effect module
|
||||
// that the compiler emits for imports and `export let` props.
|
||||
await import('svelte/internal/flags/legacy');
|
||||
}
|
||||
const factory = new Function('__themeModules', analysis.code) as (
|
||||
modules: Record<string, Record<string, unknown>>
|
||||
) => unknown;
|
||||
const component = factory(runtimeModules);
|
||||
if (typeof component !== 'function') {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: 'The component did not export a mountable Svelte component.',
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
warnings: analysis.warnings,
|
||||
};
|
||||
}
|
||||
return {
|
||||
component: component as ThemeSvelteComponent,
|
||||
errors: [],
|
||||
warnings: analysis.warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `The component could not be evaluated: ${runtimeErrorMessage(error)}`,
|
||||
line: 1,
|
||||
column: 1,
|
||||
severity: 'error',
|
||||
},
|
||||
],
|
||||
warnings: analysis.warnings,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Starter source for new custom Svelte layers. Keep this plain JavaScript:
|
||||
* theme components are compiled in the WebView, without a TypeScript pass.
|
||||
*/
|
||||
export const DEFAULT_THEME_SVELTE_SOURCE = `<script>
|
||||
let { variables = {}, layer = {} } = $props();
|
||||
|
||||
const clicks = $derived(Number(variables.clicks ?? 0));
|
||||
const player = $derived(String(variables.player ?? 'osu!'));
|
||||
</script>
|
||||
|
||||
<div class="custom-layer">
|
||||
<span class="glow"></span>
|
||||
<p class="eyebrow">CUSTOM SVELTE LAYER</p>
|
||||
<h3>Hello, {player}</h3>
|
||||
<p class="hint">{Math.round(layer.width ?? 0)} × {Math.round(layer.height ?? 0)} · {clicks} clicks</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-layer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 22px 26px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 0.14);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgb(255 0 110 / 0.24), rgb(10 10 16 / 0.82));
|
||||
box-shadow: 0 18px 50px rgb(0 0 0 / 0.35);
|
||||
color: #fff;
|
||||
font-family: 'Sora Variable', sans-serif;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
top: -40%;
|
||||
right: -10%;
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
border-radius: 999px;
|
||||
background: #ff006e;
|
||||
filter: blur(60px);
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #ff9ac4;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
font-weight: 650;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
color: rgb(255 255 255 / 0.62);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Editor-only snippets for custom Svelte layers. */
|
||||
export const THEME_SVELTE_COMPONENT_SKELETON = `<script>
|
||||
let { variables = {}, layer = {}, asset } = $props();
|
||||
|
||||
const score = $derived(Number(variables.score ?? 0));
|
||||
</script>
|
||||
|
||||
<div class="custom-layer">
|
||||
<p>Score: {score}</p>
|
||||
<p>{Math.round(layer.width)} × {Math.round(layer.height)}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.custom-layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 18px 22px;
|
||||
border: 1px solid rgb(255 255 255 / 0.14);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgb(255 0 110 / 0.22), rgb(10 10 16 / 0.82));
|
||||
color: #fff;
|
||||
font-family: 'Sora Variable', sans-serif;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
export const THEME_SVELTE_COMPONENT_PLACEHOLDER = `<script>
|
||||
let { variables = {} } = $props();
|
||||
</script>
|
||||
|
||||
<div>{variables.score}</div>`;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ThemeV2ScriptValue } from './themeV2';
|
||||
|
||||
/**
|
||||
* Text layers can display rule variables with `{name}` placeholders. The
|
||||
* pattern intentionally matches script identifiers only, so ordinary braces in
|
||||
* copy are left alone and every reference can also be offered in the editor.
|
||||
*/
|
||||
const VARIABLE_PATTERN = /\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}/g;
|
||||
const BLOCKED_NAMES = new Set(['__proto__', 'prototype', 'constructor', 'screen']);
|
||||
|
||||
const variableName = (value: string): string | undefined => {
|
||||
if (!value || BLOCKED_NAMES.has(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Variable names referenced by a text layer, in first-use order. */
|
||||
export const themeTextVariableNames = (text: string): string[] => {
|
||||
const names = new Set<string>();
|
||||
for (const match of text.matchAll(VARIABLE_PATTERN)) {
|
||||
const name = variableName(match[1]);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
return [...names];
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace `{variable}` placeholders with their current rule values. Unknown
|
||||
* names stay visible instead of silently disappearing, which makes typos easy
|
||||
* to spot in the editor and in the launcher.
|
||||
*/
|
||||
export const interpolateThemeText = (
|
||||
text: string,
|
||||
variables: Record<string, ThemeV2ScriptValue>
|
||||
): string => {
|
||||
if (!text.includes('{')) return text;
|
||||
return text.replace(VARIABLE_PATTERN, (placeholder, rawName: string) => {
|
||||
const name = variableName(rawName);
|
||||
if (!name || !Object.prototype.hasOwnProperty.call(variables, name)) return placeholder;
|
||||
const value = variables[name];
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '';
|
||||
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
||||
});
|
||||
};
|
||||
+17
-4
@@ -2,13 +2,26 @@ import type { Theme } from '$lib/themes';
|
||||
|
||||
/**
|
||||
* API 1.0 themes are executable bundles: loading one imports its entry script
|
||||
* into the launcher window. API 2.0 themes are declarative data and do not
|
||||
* need a trust decision.
|
||||
* into the launcher window. API 2.0 themes are declarative by default, but an
|
||||
* opt-in custom Svelte layer compiles and runs theme-authored JavaScript too.
|
||||
*/
|
||||
const TRUSTED_THEMES_KEY = 'ezpplauncher.trusted-v1-themes';
|
||||
|
||||
export const themeRunsCode = (theme: Pick<Theme, 'apiVersion' | 'scriptUrl'>) =>
|
||||
theme.apiVersion.split('.')[0] === '1' && theme.scriptUrl.length > 0;
|
||||
export type ThemeCodeKind = 'none' | 'v1' | 'v2-svelte';
|
||||
|
||||
export const themeCodeKind = (
|
||||
theme: Pick<Theme, 'apiVersion' | 'scriptUrl' | 'definition'>
|
||||
): ThemeCodeKind => {
|
||||
const major = theme.apiVersion.split('.')[0];
|
||||
if (major === '1') return theme.scriptUrl.length > 0 ? 'v1' : 'none';
|
||||
if (major !== '2') return 'none';
|
||||
return theme.definition?.elements?.some((element) => element.type === 'svelte')
|
||||
? 'v2-svelte'
|
||||
: 'none';
|
||||
};
|
||||
|
||||
export const themeRunsCode = (theme: Pick<Theme, 'apiVersion' | 'scriptUrl' | 'definition'>) =>
|
||||
themeCodeKind(theme) !== 'none';
|
||||
|
||||
export const themeTrustKey = (theme: Pick<Theme, 'folder_name' | 'version'>) =>
|
||||
`${theme.folder_name}@${theme.version}`;
|
||||
|
||||
+1750
-23
File diff suppressed because it is too large.
Load diff
+11
-11
@@ -457,18 +457,18 @@ export const deleteTheme = async (themeToUninstall: Theme) => {
|
||||
return false;
|
||||
}
|
||||
patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'deleting' }));
|
||||
await fs.remove(themeFolder, { recursive: true });
|
||||
try {
|
||||
await fs.remove(themeFolder, { recursive: true });
|
||||
} catch (error) {
|
||||
patchTheme(themeToUninstall.name, (current) => ({ ...current, status: 'installed' }));
|
||||
throw error;
|
||||
}
|
||||
|
||||
patchTheme(themeToUninstall.name, (current) => ({
|
||||
...current,
|
||||
scriptUrl: '',
|
||||
cssStyle: '',
|
||||
assets: '',
|
||||
definition: undefined,
|
||||
preview: `${THEME_REPOSITORY}/raw/branch/main/previews/${toSafeName(themeToUninstall.name)}.png`,
|
||||
status: 'not-installed',
|
||||
updateAvailable: false,
|
||||
}));
|
||||
// Remove the local card entirely, then let reloadThemes add it back as a
|
||||
// downloadable theme only when the repository actually offers it. Marking
|
||||
// local-only themes as "not-installed" left them stuck in the gallery with
|
||||
// an unusable Download Theme button.
|
||||
custom_themes.update((themes) => themes.filter((theme) => theme.name !== themeToUninstall.name));
|
||||
|
||||
await reloadThemes();
|
||||
return true;
|
||||
|
||||
@@ -25,12 +25,19 @@
|
||||
platform,
|
||||
presenceLoading,
|
||||
setupValues,
|
||||
theme_reload_token,
|
||||
trackingEnabled,
|
||||
} from '$lib/global';
|
||||
import { initLogging } from '$lib/logging';
|
||||
import { exit, getLauncherVersion, getPlatform, setLoggingEnabled } from '$lib/osuUtil';
|
||||
import * as presence from '$lib/presence';
|
||||
import { smoothThemeMedia, themeMediaPreparing, themeMediaStatus } from '$lib/themeMedia';
|
||||
import {
|
||||
smoothThemeMedia,
|
||||
themeMediaPreparing,
|
||||
themeMediaStatus,
|
||||
themeMediaSuspended,
|
||||
themeMediaSuspension,
|
||||
} from '$lib/themeMedia';
|
||||
import { isThemeTrusted } from '$lib/themeTrust';
|
||||
import { themeV2Css } from '$lib/themeV2';
|
||||
import { getDownloadableThemes, getThemes, loadTheme } from '$lib/themes';
|
||||
@@ -43,15 +50,20 @@
|
||||
} from '$lib/userSettings';
|
||||
import { trackMediaContainer } from '$lib/utils';
|
||||
import '../app.css';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Buffer } from 'buffer';
|
||||
import { SemVer } from 'semver';
|
||||
import { sileo } from 'sileo';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let unlistenEditorState: (() => void) | undefined;
|
||||
onDestroy(() => unlistenEditorState?.());
|
||||
|
||||
if (browser) initLogging();
|
||||
|
||||
const editorWindow = (() => {
|
||||
@@ -110,6 +122,19 @@
|
||||
onMount(async () => {
|
||||
if (editorWindow) return;
|
||||
|
||||
// The editor is a separate WebView, so the launcher cannot observe its
|
||||
// focus directly. Native code emits this state when the window is created
|
||||
// or destroyed, and the launcher pauses its own theme media while it is
|
||||
// open so both previews cannot play over each other.
|
||||
try {
|
||||
unlistenEditorState = await listen<boolean>('theme-editor-state', (event) => {
|
||||
themeMediaSuspended.set(event.payload === true);
|
||||
});
|
||||
if (await WebviewWindow.getByLabel('theme-editor')) themeMediaSuspended.set(true);
|
||||
} catch (error) {
|
||||
console.warn('Could not sync the theme editor media state', error);
|
||||
}
|
||||
|
||||
window.Buffer = Buffer;
|
||||
|
||||
disableReload();
|
||||
@@ -325,7 +350,7 @@
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<main>
|
||||
<main use:themeMediaSuspension>
|
||||
<div class:hidden={!!$active_custom_theme?.definition}>
|
||||
<div class="opacity-30">
|
||||
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
|
||||
@@ -345,10 +370,12 @@
|
||||
</div>
|
||||
{#if $active_custom_theme?.definition}
|
||||
<div class="absolute inset-0 z-0">
|
||||
<ThemeV2Background
|
||||
definition={$active_custom_theme.definition}
|
||||
assets={$active_custom_theme.assets}
|
||||
/>
|
||||
{#key $theme_reload_token}
|
||||
<ThemeV2Background
|
||||
definition={$active_custom_theme.definition}
|
||||
assets={$active_custom_theme.assets}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
{@render children()}
|
||||
|
||||
+1197
-1048
File diff suppressed because it is too large.
Load diff
@@ -566,9 +566,9 @@
|
||||
<div class="min-w-0">
|
||||
<Label class="text-[13px]" for="wizard-patch">Patching</Label>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
|
||||
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}{' '}Stable
|
||||
patching needs no extra runtime; the experimental stream needs .NET 8 in
|
||||
your Wine prefix.{/if}
|
||||
Shows misses in Relax and Autopilot.{#if $platform === 'macos'}
|
||||
Stable patching needs no extra runtime; the experimental stream needs
|
||||
.NET 8 in your Wine prefix.{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
// The internal client module is intentionally untyped in Svelte's package
|
||||
// exports. Theme components only need its namespace at runtime, so treat it as
|
||||
// an opaque module rather than importing private implementation types.
|
||||
declare module 'svelte/internal/client';
|
||||
declare module 'svelte/internal/flags/legacy';
|
||||
Reference in new issue
Block a user