diff --git a/.gitignore b/.gitignore index 6f3f46fe4..d4e1b54bd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ build out bin .DS_Store -.codegpt \ No newline at end of file +.codegpt + +# Ignore declaration files accidentally emitted into source when aliasing from kando-svelte +/src/common/**/*.d.ts +/src/menu-renderer/**/*.d.ts \ No newline at end of file diff --git a/kando-svelte/.gitignore b/kando-svelte/.gitignore new file mode 100644 index 000000000..47c6be13e --- /dev/null +++ b/kando-svelte/.gitignore @@ -0,0 +1,25 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build +/dist +temp + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/kando-svelte/.npmrc b/kando-svelte/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/kando-svelte/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/kando-svelte/.prettierignore b/kando-svelte/.prettierignore new file mode 100644 index 000000000..7d74fe246 --- /dev/null +++ b/kando-svelte/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/kando-svelte/.prettierrc b/kando-svelte/.prettierrc new file mode 100644 index 000000000..3f7802c37 --- /dev/null +++ b/kando-svelte/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/kando-svelte/README.md b/kando-svelte/README.md new file mode 100644 index 000000000..fcf7336f8 --- /dev/null +++ b/kando-svelte/README.md @@ -0,0 +1,78 @@ +# kando-svelte (library) + +Svelte 5 library that renders Kando pie menus in web/SvelteKit apps. + +Goals +- Be compatible with Kando data, themes and algorithms. +- Reuse Kando source directly (math, types, schemata) where practical. +- Keep rendering and event wiring idiomatic Svelte 5. + +What it is +- A Svelte 5 library exporting components and helpers: + - `PieMenu.svelte`: render a Kando menu tree; emits `select`, `cancel`, `hover`, `unhover` events. + - `PieItem.svelte`, `SelectionWedges.svelte`, `WedgeSeparators.svelte`: building blocks. + - `Vendor`: convenient URLs for a bundled default theme and a minimal "none" sound theme. + - `theme-loader`: utilities to load JSON5 theme metadata, inject theme.css, and apply color overrides. + - `validation`: re-exports Kando zod schemata and simple parse helpers (optional to use). + +What it is not +- It does not perform file system discovery, snapshot management, or OS integrations. +- It does not implement execution of platform-specific item actions (command/file/hotkey/macro/settings). +- It does not bundle icon fonts/CSS; the host app should include Material Symbols / Simple Icons if used by the chosen theme. + +Compatibility +- Types: re-exported from Kando (`@kando/common`, `@kando/schemata/*`). +- Math: imported from Kando (`src/common/math`). +- Themes: accepts a `MenuThemeDescription` object or can load from a theme directory via `themeDirUrl` + `themeId`. + +Install & build (library) +```bash +# from the library folder +npm install +npm run build # builds .svelte-kit and dist via svelte-package +``` + +Usage (consumer app) +```svelte + + + console.log('select', e.detail)} /> +``` + +Theme loading +- `PieMenu` props: + - `theme` (object) OR `themeDirUrl` + `themeId` (string). + - When loading by URL, `theme-loader` fetches `theme.json5`, injects `theme.css`, and applies colors. +- You can also use the exported `Vendor.defaultThemeCss` and `Vendor.defaultThemeJson` URLs for a built-in default theme. + +Sound themes +- The library bundles a minimal "none" sound theme JSON (`Vendor.noneSoundThemeJson`). +- For real sound themes, load them in your app and use Howler in client-only lifecycle hooks. + +Icons +- Include the icon CSS your themes expect in the app (e.g. in `app.html`): + - Material Symbols Rounded CSS (Google Fonts) or the `material-symbols` npm package. + - `simple-icons-font` if you use Simple Icons. + +Svelte 5 +- Internals use Svelte 5 runes for state/derived/effect where appropriate. +- Public API remains plain props/events for maximum compatibility. + +Notes on path aliases +- This repo uses `kit.alias` in `svelte.config.js` to reference Kando sources during development. +- If you publish this library to npm, either bundle those sources or depend on a separate `kando-core` package. + +License & attribution +- Kando is MIT; themes and font assets have their own licenses (e.g., CC0-1.0 for the default theme). +- Preserve SPDX headers and attributions when copying code. diff --git a/kando-svelte/eslint.config.js b/kando-svelte/eslint.config.js new file mode 100644 index 000000000..e02473646 --- /dev/null +++ b/kando-svelte/eslint.config.js @@ -0,0 +1,4 @@ +import prettier from 'eslint-config-prettier'; +import svelte from 'eslint-plugin-svelte'; + +export default [prettier, ...svelte.configs.prettier]; diff --git a/kando-svelte/menus-orig.json b/kando-svelte/menus-orig.json new file mode 100644 index 000000000..5551330fd --- /dev/null +++ b/kando-svelte/menus-orig.json @@ -0,0 +1,350 @@ +{ + "menus": [ + { + "root": { + "type": "submenu", + "name": "Example Menu", + "icon": "award_star", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "submenu", + "name": "Apps", + "icon": "apps", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "open -a Safari" + }, + "name": "Safari", + "icon": "safari", + "iconTheme": "simple-icons" + }, + { + "type": "command", + "data": { + "command": "open -a Mail" + }, + "name": "E-Mail", + "icon": "mail", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open -a Music" + }, + "name": "Music", + "icon": "itunes", + "iconTheme": "simple-icons" + }, + { + "type": "command", + "data": { + "command": "open -a Finder" + }, + "name": "Finder", + "icon": "folder_shared", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open -a Terminal" + }, + "name": "Terminal", + "icon": "terminal", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "submenu", + "name": "Web Links", + "icon": "public", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "uri", + "data": { + "uri": "https://www.google.com" + }, + "name": "Google", + "icon": "google", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://github.com/kando-menu/kando" + }, + "name": "Kando on GitHub", + "icon": "github", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://ko-fi.com/schneegans" + }, + "name": "Kando on Ko-fi", + "icon": "kofi", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://www.youtube.com/@simonschneegans" + }, + "name": "Kando on YouTube", + "icon": "youtube", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://discord.gg/hZwbVSDkhy" + }, + "name": "Kando on Discord", + "icon": "discord", + "iconTheme": "simple-icons" + } + ] + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 124 using control down'" + }, + "name": "Next Workspace", + "icon": "arrow_forward", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "submenu", + "name": "Clipboard", + "icon": "assignment", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyV", + "delayed": true + }, + "name": "Paste", + "icon": "content_paste_go", + "iconTheme": "material-symbols-rounded", + "angle": 90 + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyC", + "delayed": true + }, + "name": "Copy", + "icon": "content_copy", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyX", + "delayed": true + }, + "name": "Cut", + "icon": "cut", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "submenu", + "name": "Audio", + "icon": "play_circle", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to next track'" + }, + "name": "Next Track", + "icon": "skip_next", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to playpause'" + }, + "name": "Play / Pause", + "icon": "play_pause", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to previous track'" + }, + "name": "Previous Track", + "icon": "skip_previous", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "submenu", + "name": "Windows", + "icon": "select_window", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 126 using control down'" + }, + "name": "Mission Control", + "icon": "select_window", + "iconTheme": "material-symbols-rounded", + "angle": 0 + }, + { + "type": "hotkey", + "data": { + "hotkey": "ControlLeft+AltLeft+ArrowRight", + "delayed": true + }, + "name": "Tile Right", + "icon": "text_select_jump_to_end", + "iconTheme": "material-symbols-rounded", + "angle": 90 + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyW", + "delayed": true + }, + "name": "Close Window", + "icon": "cancel_presentation", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "hotkey", + "data": { + "hotkey": "ControlLeft+AltLeft+ArrowLeft", + "delayed": true + }, + "name": "Tile Left", + "icon": "text_select_jump_to_beginning", + "iconTheme": "material-symbols-rounded", + "angle": 270 + } + ] + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 123 using control down'" + }, + "name": "Previous Workspace", + "icon": "arrow_back", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "submenu", + "name": "Bookmarks", + "icon": "folder_special", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to downloads folder as text)'" + }, + "name": "Downloads", + "icon": "download", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to movies folder as text)'" + }, + "name": "Videos", + "icon": "video_camera_front", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to pictures folder as text)'" + }, + "name": "Pictures", + "icon": "imagesmode", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to documents folder as text)'" + }, + "name": "Docuexample-menu.bookmarks.documentsments", + "icon": "text_ad", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to desktop folder as text)'" + }, + "name": "Desktop", + "icon": "desktop_windows", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open $HOME" + }, + "name": "Home", + "icon": "home", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to music folder as text)'" + }, + "name": "Music", + "icon": "music_note", + "iconTheme": "material-symbols-rounded" + } + ] + } + ] + }, + "shortcut": "Control+Space", + "shortcutID": "example-menu", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + } + ], + "collections": [ + { + "name": "Favorites", + "icon": "favorite", + "iconTheme": "material-symbols-rounded", + "tags": [ + "favs" + ] + } + ], + "version": "2.1.0-beta.1" +} diff --git a/kando-svelte/notes/aquery.md b/kando-svelte/notes/aquery.md new file mode 100644 index 000000000..52f8b8fc5 --- /dev/null +++ b/kando-svelte/notes/aquery.md @@ -0,0 +1,386 @@ +## aQuery: A Cross‑Platform Accessibility and Window API atop Kando + +This proposal defines “aQuery” — a high‑level, cross‑platform TypeScript API (à la jQuery/Cordova) that abstracts platform accessibility (a11y), window management, and input simulation behind one contract. Kando provides the execution substrate (Electron main/renderer, native backends) and the permission UX; aQuery provides a stable JS interface and selector semantics. + +### Rationale +- Fragmented OS capabilities (AX on macOS, UIA on Windows, AT‑SPI on Linux; Wayland vs X11) complicate app automations and accessibility tooling. +- Kando already ships native backends and a robust input pipeline; aQuery extends this into a reusable, documented API for first‑party features (triggers, editor helpers) and third‑party integrations. + +### Design Principles +- Cross‑platform contract first; graceful capability detection (`supports.*`) and fallbacks. +- Explicit, per‑feature permission prompts (and persistent user consent) with clear failure modes. +- Asynchronous, promise‑first API; event‑driven subscriptions. +- Strong typing; no platform enums leaked through public surface. +- No busy loops; everything cancellable; observable streams where appropriate. + +--- + +## Top‑Level API Surface (TypeScript) + +```ts +namespace aquery { + // Core & permissions + function supports(): Promise<{ + a11y: boolean; window: boolean; input: boolean; screen: boolean; + triggers: boolean; clipboard: boolean; app: boolean; gamepad: boolean; + }>; + function requestPermissions(opts: { + a11y?: boolean; input?: boolean; screen?: boolean; + }): Promise<{ granted: string[]; denied: string[] }>; + + // App / Window + namespace app { + function getForeground(): Promise<{ id: string; name: string }>; + function listInstalled(): Promise>; + } + namespace window { + type Win = { id: string; appId: string; title: string; bounds: { x:number; y:number; w:number; h:number } }; + function getActive(): Promise; + function list(opts?: { appId?: string }): Promise; + function activate(id: string): Promise; + function moveResize(id: string, bounds: Partial): Promise; + } + + // Input (synthetic) + namespace input { + type Button = 'left'|'middle'|'right'|'x1'|'x2'; + function mouse(event: { + type: 'move'|'down'|'up'|'click'|'dblclick'|'scroll'; + button?: Button; x?: number; y?: number; dx?: number; dy?: number; + scrollX?: number; scrollY?: number; + modifiers?: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean }; + }): Promise; + function key(event: { code: string; down: boolean }): Promise; + } + + // Accessibility queries + namespace a11y { + type Node = { role: string; name?: string; enabled?: boolean; focused?: boolean; rect?: { x:number;y:number;w:number;h:number } }; + function query(selector: string, opts?: { root?: 'system'|string; timeoutMs?: number }): Promise; + function action(selector: string, act: 'press'|'expand'|'collapse'|'focus'|'setValue', value?: unknown): Promise; + } + + // Screen / clipboard + namespace screen { + function displays(): Promise>; + // High‑performance capture primitives (prefer GPU/zero‑copy where available) + function captureRegion(rect: { x:number;y:number;w:number;h:number }, opts?: { format?: 'png'|'jpeg'|'raw'; displayId?: string }): Promise; + function captureWindow(id: string, opts?: { format?: 'png'|'jpeg'|'raw' }): Promise; + function captureDisplay(displayId: string, opts?: { format?: 'png'|'jpeg'|'raw' }): Promise; + // Tracking utilities (pixel‑level, optional ML backends) + function trackRegion(init: { rect: { x:number;y:number;w:number;h:number }, strategy?: 'template'|'feature'|'ocr' }): AsyncGenerator<{ rect: { x:number;y:number;w:number;h:number }, confidence: number }>; + } + namespace clipboard { + function read(): Promise; + function write(text: string): Promise; + } + + // Vision/LLM (pluggable providers: local VLMs or cloud) + namespace llm { + type Provider = 'auto'|'openai'|'ollama'|'local'; + function describe(image: ImageBitmap | ArrayBuffer, prompt?: string, opts?: { provider?: Provider }): Promise; + function locate(image: ImageBitmap | ArrayBuffer, query: string, opts?: { provider?: Provider }): Promise>; + function plan(image: ImageBitmap | ArrayBuffer, goal: string, opts?: { provider?: Provider }): Promise>; + } + + // Triggers (ties into Kando’s unified triggers[] model) + namespace triggers { + type MouseTrigger = { button: 'left'|'middle'|'right'|'x1'|'x2', mods?: string[] }; + type GamepadTrigger = { button: number; stick?: 'left'|'right'; tiltThreshold?: number }; + function register(menuName: string, trigger: { kind: 'mouse'; value: MouseTrigger } | { kind: 'gamepad'; value: GamepadTrigger }): Promise; + function unregister(menuName: string): Promise; + } +} +``` + +### Selector Grammar (a11y) +- CSS‑inspired for accessibility trees across AX/UIA/AT‑SPI: + - Role: `button`, `menuitem`, `textbox` + - Name: `[name="OK"]`, regex: `[name~/^Save/]` + - State: `:enabled`, `:focused`, `:visible` + - Hierarchy: `app[name="Safari"] > window > button[name="OK"]` + - Short alias: `a$(selector)` returns first match; `a$All(selector)` returns all. + +--- + +## Platform Adapters + +- macOS: AX API (AXUIElement), CGEventTap (global hooks), CGEvent post (synthetic), NSWorkspace notifications (active app), Quartz Display Services (screens). Existing Kando native code already provides: pointer warp, simulateKey, active window, app listing — extend with: simulateMouse, event tap, AX queries/actions. +- Windows: UI Automation (COM), WH_MOUSE_LL / Raw Input, SendInput, GetForegroundWindow/EnumWindows, Graphics Capture API (DXGI Desktop Duplication / Windows.Graphics.Capture). +- Linux X11: AT‑SPI2 (D‑Bus), XTest/XI2, EWMH for windows. Wayland: portals (GlobalShortcuts keyboard only, RemoteDesktop/VirtualPointer/VirtualKeyboard with permissions), AT‑SPI via accessibility stack; many features are compositor‑dependent — expose via `supports()`. + +IPC and isolation: +- Electron main performs privileged calls; renderer uses `contextBridge` APIs. +- All methods return Promises; cancellation via AbortSignal for long queries. + +Permissions UX: +- `aquery.requestPermissions({ a11y: true, input: true })` triggers per‑OS guidance (e.g., macOS Accessibility & Input Monitoring; Screen Recording when capturing) +- Store consent in settings; re‑prompt on denial; surface clear error codes. + +--- + +## Integration with Kando + +- Triggers: the `triggers` registry is a thin facade over Kando’s unified `triggers[]` model (see button‑trigger‑support.md). Mouse/gamepad opener logic flows through Kando’s existing conditions matcher and `MenuWindow.showMenu`. +- Gesture pipeline: after open, existing `PointerInput`/`GamepadInput` continue to produce `InputState` (angle/distance), so selection/marking/turbo remain unchanged. +- Editor: add pickers for MouseTrigger and GamepadTrigger; reuse conditions UI. + +--- + +## Svelte / Browser Variant + +In `kando-svelte`, implement the same TS surface using browser capabilities: +- Mouse triggers: DOM pointer events and `contextmenu` suppression. +- Gamepad triggers: Web Gamepad API. +- a11y/window: limited; expose `supports().a11y = false` and provide no‑op or portal‑based fallbacks; the contract remains the same so apps can feature‑detect. +- Screen capture: `html2canvas`/`OffscreenCanvas` for demo purposes only; do not rely on for privacy‑sensitive features. In Electron renderer, prefer native capture bridges. + +--- + +## Roadmap +1) Spec + types + `supports()`; Svelte demo polyfill for triggers +2) macOS adapter: simulateMouse + event tap + basic AX queries (role/name) and window list/activate +3) Windows adapter: hooks + SendInput + UIA minimal + Graphics Capture +4) Linux X11 adapter; Wayland: document limitations and portals where feasible +5) Editor pickers and unified `triggers[]` schema migration +6) Expanded AX/UIA actions and robust query engine (performance + timeouts) + +Testing: +- Contract tests per method with platform‑specific expected capability matrices +- Golden tests for selector resolution on synthetic accessibility trees + +Security: +- Only elevate when requested; never run hidden; log every privileged action origin (menu/editor) for audit when dev tools enabled. + +License & contribution: +- MIT (inherit from Kando); adapters reside under platform folders; contributors can add new backends behind the same TS interface. + +--- + +## Background, Prior Art, and References (aQuery vision) + +The aQuery idea ("like jQuery for accessibility") predates Kando and draws on decades of HCI and accessibility research. Key motivations and inspirations include: + +- Combine accessibility APIs with pixel‑based screen analysis to overcome each method’s limitations; use both to robustly select, recognize, and control UI elements. +- Treat desktop UIs as augmentable spaces: overlay guidance, implement target‑aware pointing (e.g., Bubble Cursor), sideviews, previews, and task‑specific controllers without modifying apps. +- Build a community library of high‑level, cross‑app widgets (e.g., a generic “video player” control) that adapt to VLC, QuickTime, browsers, etc., akin to jQuery UI widgets spanning browser differences. + +Selected sources and quotes (lightly edited for clarity): + +> "Screen scraping techniques are very powerful, but have limitations. Accessibility APIs are very powerful, but have different limitations. Using both approaches together, and tightly integrating with JavaScript, enables a much wider range of possibilities." — HN post by Don Hopkins (2016) + +> "Prefab realizes this vision using only the pixels of everyday interfaces… add functionality to applications like Photoshop, iTunes, and WMP… first step toward a future where anybody can modify any interface." — Morgan Dixon & James Fogarty, CHI 2010–2012 + +Core references: +- Morgan Dixon et al., Prefab and target‑aware pointing (CHI ’10–’12) + - Video: Prefab: What if We Could Modify Any Interface? + - Video: Content and Hierarchy in Prefab + - Paper: Prefab; Bubble Cursor; Target‑Aware Pointing +- Potter, Shneiderman, Bederson: Pixel Data Access & Triggers (1999) +- Speech control ecosystems (e.g., Dragonfly Python modules) for command repositories + +How it maps to aQuery: +- a11y: selector engine over AX/UIA/AT‑SPI nodes (role/name/state), event binding, actions +- screen: capture/track (template/feature/OCR), compositing overlays +- input: synthetic mouse/keyboard; timing control for “press‑tilt‑release” and gesture playback +- llm: describe/locate/plan actions from snapshots; drive `input.*` with verifiable, sandboxed plans + +--- + +## Snapshotting & LLM Scenarios + +1) Visual targeting fallback: if `a11y.query('button[name="Play"]')` returns empty, use `screen.captureWindow()` + `llm.locate(…, 'play button')` to get a bounding box; click center via `input.mouse({ type:'click', button:'left', x, y })`. +2) Robust selectors: combine `a11y` and `screen` features: match role/name, verify icon pixels via `trackRegion` or LLM scoring. +3) Task agents: capture screen, `llm.plan('increase playback speed to 1.5x')`, vet and execute the plan with safety checks (bounds, foreground window) and reversible steps. + +Privacy & safety: +- Favor on‑device VLMs where possible; redact PII regions; require explicit user consent for cloud processing; log actions when dev mode is on. + + + + +## Quick Start + +### Check capabilities and request permissions + +```ts +// Feature-detect what the current platform supports +const caps = await aquery.supports(); + +// Ask only for what you need right now +await aquery.requestPermissions({ + a11y: caps.a11y, + input: caps.input, + screen: false +}); +``` + +### Focus an app window and press a button by accessible name + +```ts +// Bring the target app window forward +const active = await aquery.window.getActive(); +if (!active) { + const safari = (await aquery.app.listInstalled()).find(a => a.name === 'Safari'); + if (safari) { + const wins = await aquery.window.list({ appId: safari.id }); + if (wins[0]) await aquery.window.activate(wins[0].id); + } +} + +// Press a visible OK button +const ok = await aquery.a11y.query('window > button[name="OK"]:enabled:visible', { timeoutMs: 1000 }); +if (ok[0]) { + await aquery.a11y.action('window > button[name="OK"]', 'press'); +} +``` + +### Fallback to vision when accessibility lookup fails + +```ts +const match = await aquery.a11y.query('button[name~=/Play|▶/]:enabled', { timeoutMs: 500 }); +if (!match[0] && (await aquery.supports()).screen) { + const win = await aquery.window.getActive(); + if (win) { + const img = await aquery.screen.captureWindow(win.id, { format: 'png' }); + const boxes = await aquery.llm.locate(img, 'play button'); + const best = boxes.sort((a, b) => b.score - a.score)[0]; + if (best) { + const cx = best.rect.x + Math.floor(best.rect.w / 2); + const cy = best.rect.y + Math.floor(best.rect.h / 2); + await aquery.input.mouse({ type: 'move', x: cx, y: cy }); + await aquery.input.mouse({ type: 'click', button: 'left' }); + } + } +} +``` + +--- + +## Selector Cookbook + +- **By role**: `menuitem`, `button`, `textbox`, `checkbox` +- **By name (exact)**: `button[name="Save"]` +- **By name (regex)**: `button[name~/^Save (As|All)/]` +- **By state**: `:enabled`, `:focused`, `:visible` +- **By ancestry**: `app[name="Safari"] > window > toolbar > button[name="Reload"]` +- **Any of names**: `button[name~/^(OK|Yes|Continue)$/]` +- **First match helper**: `a$("button[name='OK']")` +- **All matches helper**: `a$All("menuitem:visible")` + +Tips: +- Prefer stable identifiers (role + name) first; use vision as a verifier. +- Scope queries by app/window when possible for performance. +- Use `timeoutMs` conservatively to avoid long hangs; prefer retries with backoff. + +--- + +## Capability Matrix (indicative) + +| Feature | macOS (AX) | Windows (UIA) | Linux X11 (AT‑SPI) | Wayland | +| ------------- | ---------- | ------------- | ------------------ | ------- | +| a11y query | Yes | Yes | Yes | Varies | +| a11y actions | Yes | Yes | Yes | Varies | +| window list | Yes | Yes | Yes | Yes | +| window focus | Yes | Yes | Yes | Varies | +| input synth | Yes | Yes | Yes (XTest/XI2) | Portals | +| screen capture| Yes | Yes | Yes | Portals | + +Notes: +- Wayland features depend on compositor and portals; expose via `supports()`. +- Some features require explicit OS permissions; see next section. + +--- + +## Permissions Guide + +### macOS +- **Accessibility**: required for a11y queries/actions and some input simulation. +- **Input Monitoring**: required for global input hooks. +- **Screen Recording**: required for display/window capture. +- Use `aquery.requestPermissions({ a11y: true, input: true })` to guide users. + +### Windows +- UIA and SendInput generally work without special prompts; ensure the app has appropriate privileges when interacting with elevated windows. +- Graphics Capture may require enabling OS features on older builds. + +### Linux +- **X11**: broad access via AT‑SPI and XTest/XI2; no prompts. +- **Wayland**: use portals for virtual keyboard/mouse and remote‑desktop capture; availability varies by desktop environment. + +--- + +## Svelte / Browser Polyfill Notes + +In `kando-svelte`, mirror the TS surface where possible so code can feature‑detect and degrade gracefully. + +```ts +// Example: simple mouse trigger polyfill in the browser +const supports = await aquery.supports(); +if (!supports.triggers) { + // Fallback: listen to DOM events to open a demo menu + window.addEventListener('contextmenu', (e) => { + e.preventDefault(); + // show demo menu component at e.clientX/Y + }); +} +``` + +Prefer native bridges when running under Electron for capture and input. + +--- + +## Prefab, HyperLook/HyperCard, and Design Inspiration + +### Prefab (Dixon & Fogarty) +- Use pixel‑level recognition to identify widgets and verify targets. +- Combine with a11y selectors for robust, cross‑app interactions. + +### HyperLook / HyperCard‑style Augmentation +- Treat desktop UIs as canvases you can annotate, overlay, and script. +- Compose higher‑level widgets (e.g., a generic media controller) that adapt to many apps. + +### Window Management +- Expose predictable operations: focus, move/resize, enumerate, tile/snap. +- Build user scripts that arrange workspaces and then bind them to triggers. + +### Pie Menus +- Integrate with Kando’s triggers and gesture pipeline. +- Use aQuery to query context (focused app/window/element) and tailor menu entries. + +### Tabbed / Panel Workflows +- Script workflows that switch tabs, raise panels, and confirm dialogs by role/name. + +--- + +## Eventing, Timeouts, and Cancellation + +- Long queries should accept `timeoutMs` and `AbortSignal` to stay responsive. +- Emit progress or discovery events where supported (future roadmap) to enable live UIs. + +```ts +const controller = new AbortController(); +const timer = setTimeout(() => controller.abort(), 800); +try { + const nodes = await aquery.a11y.query('textbox:focused', { timeoutMs: 750 /*, signal: controller.signal */ }); + // ... +} finally { + clearTimeout(timer); +} +``` + +--- + +## Error Handling Patterns + +- Always check `supports()` before calling feature APIs. +- Prefer idempotent scripts; verify window focus and bounds before input. +- Layer fallbacks: a11y → vision verify → pure vision; fail fast with clear messages. + +--- + +## Contributing Adapters + +- Keep platform specifics inside adapter folders; conform to the TS interface. +- Add contract tests per method and capability matrices per OS. +- Document any limitations behind `supports()` feature flags. diff --git a/kando-svelte/notes/button-trigger-support.md b/kando-svelte/notes/button-trigger-support.md new file mode 100644 index 000000000..7a7898294 --- /dev/null +++ b/kando-svelte/notes/button-trigger-support.md @@ -0,0 +1,286 @@ +## Button and Gamepad Trigger Support — Design and Rationale + +This document specifies how to add “open menu on button” triggers to Kando, covering mouse buttons and gamepads, with per‑app/window/region conditions, double‑click passthrough for RMB, and tight integration with the existing gesture pipeline (marking, turbo, hover, fixed‑stroke). + +Although authored inside `kando-svelte`, the primary scope is the main Kando application (Electron + native backends). The Svelte variant can implement the same TypeScript surface using browser APIs (Gamepad API, DOM pointer events) without native hooks. + +### Goals +- Support opening a menu via: + - Keyboard (existing) + - Mouse buttons (RMB, MMB, X1/X2, optionally LMB) + - Gamepad buttons and optional “press‑tilt‑release” selection flow +- Reuse existing per‑menu `conditions` (app/window/region) for scoping +- Offer double‑right‑click passthrough to forward a native RMB click if user cancels quickly +- Integrate with the gesture pipeline so state machines remain consistent (jitter, dead‑zone, marking/turbo/hover) +- Cross‑platform shape with platform‑specific implementations + +### Non‑Goals +- Provide global mouse hooks on Wayland (not feasible without compositor/portal support) +- Replace the existing keyboard shortcut flow (we remain backward‑compatible) + +--- + +## Configuration Model + +We keep `shortcut`/`shortcutID` for backwards compatibility and (long‑term) add a unified `triggers` array. Each entry is one of `keyboard | mouse | gamepad`. + +Example (JSON excerpt from `menus.json`): + +```json +{ + "root": { "type": "submenu", "name": "Apps", "icon": "apps", "iconTheme": "material-symbols-rounded" }, + "shortcut": "Control+Space", + "triggers": [ + { "kind": "keyboard", "shortcut": "Control+Space" }, + { "kind": "mouse", "button": "right", "mods": [], "when": "matching-conditions", "doubleClickPassthrough": "on-cancel" }, + { "kind": "gamepad", "button": 0, "stick": "left", "tiltThreshold": 0.35 } + ], + "conditions": { "appName": "^com.apple.Safari$" } +} +``` + +Schema sketch (TypeScript/Zod intent): + +```ts +type KeyboardTrigger = { + kind: 'keyboard'; + shortcut?: string; // Electron accelerator + id?: string; // fallback ID for DE/portal bindings +}; + +type MouseTrigger = { + kind: 'mouse'; + button: 'left'|'middle'|'right'|'x1'|'x2'; + mods?: Array<'ctrl'|'alt'|'shift'|'meta'>; + when?: 'matching-conditions'|'always'; + doubleClickPassthrough?: 'on-cancel'|'never'|'always'; +}; + +type GamepadTrigger = { + kind: 'gamepad'; + button: number; // W3C remapped indices + stick?: 'left'|'right'; + tiltThreshold?: number; // 0..1, default 0.3 +}; +``` + +Backward compatibility: if `triggers` is absent, existing `shortcut`/`shortcutID` is treated as a single keyboard trigger. + +### Why not piggyback on `shortcut`? +- Electron’s accelerators are keyboard‑only; mouse buttons are not supported and would require native hooks anyway. Keeping distinct trigger kinds avoids fragile overloading and keeps the editor UX clear. + +--- + +## Update (MVP implemented now): Layered mouse bindings and V1‑compatible field + +To ship incremental support without bumping settings version, we added a simple, V1‑compatible field and layered it through the backend so pie menus can use it today, while other features can hook into the same layer later. + +- Schema (V1‑compatible): + - `menu.mouseBindings: string[]` (default: `[]`) + - Strings like `right`, `middle`, `left`, `x1`, `x2` and with modifiers: `ctrl+right`, `alt+right`, `shift+right`, `meta+right`. +- Editor UI: root menu shows a TagInput for “Mouse bindings” with a tooltip explaining the syntax (no capture UI yet). +- Backend layering: + - macOS native addon exposes `startMouseHook/stopMouseHook` via CGEventTap (listen‑only for now) and emits down/up with button/coords/mods. + - The macOS Backend normalizes a binding (e.g., `ctrl+right`) and emits a high‑level `mouseBinding` event. This is intentionally independent from keyboard shortcuts so other subsystems can consume it later (not just pie menus). + - The app listens for `mouseBinding` and currently routes it to `showMenu({ trigger: binding })` so menus can bind to these strings. +- Menu selection matching: + - `chooseMenu()` now matches incoming triggers against `shortcut`, `shortcutID`, and `mouseBindings`. + - Base‑only fallback: a binding `right` will also match an incoming `ctrl+right`. +- Robustness / migration: missing `mouseBindings` is treated as `[]` (no version bump). +- Future: we can switch the tap to intercept mode (swallow RMB) with a passthrough policy; and add non‑menu consumers of `mouseBinding` (e.g., macro layers, context tools). + +This MVP aligns with the long‑term `triggers[]` design while remaining backward compatible today. + +--- + +## Integration with Conditions and Menu Selection + +We reuse the existing `conditions` matcher (app name, window title, screen region). On any trigger event, Kando computes the “best matching” menu exactly as today. If the selected menu contains a trigger matching the event (kind + details), we open it. + +This supports per‑app RMB bindings naturally: put `mouse` triggers on menus and scope them with `conditions`. + +--- + +## Event Flow and State Machines + +### Mouse (open flow) +1) Global hook sees mouse event (e.g., RightDown + modifiers) +2) Resolve `WMInfo` (app/window/region), pick best menu by `conditions` +3) If a matching `mouse` trigger exists: swallow the OS event and `showMenu({ centered/anchored/hover })` +4) Pointer input continues as today (dead‑zone, jitter, marking/turbo/hover) + +### Double‑RMB Passthrough +- Policy `doubleClickPassthrough`: + - `on-cancel` (default): if the menu closes “quickly” (≤ system double‑click interval) without a selection, synthesize RMB (Down+Up) and close + - `always`: synthesize RMB on quick close regardless of selection + - `never`: never synthesize + +### Gamepad (open + browse) +1) Renderer polls Gamepad API (already implemented for in‑menu browsing) +2) If a configured `gamepad` trigger button becomes down (and optional `tiltThreshold` satisfied), notify main to `showMenu` using current WM info and menu selection rules +3) In‑menu browsing uses existing GamepadInput: analog stick → hover; buttons → select/back/close +4) Optional mode: “press‑tilt‑release” — arm on button down, commit selection on button up (setting `gamepadSelectOnButtonUp`) + +--- + +## Native Backends (platform support) + +### macOS (first target) +- Global capture: CGEventTap (kCGHIDEventTap) for Right/Other buttons, with Accessibility permission +- Swallowing: return `nullptr` to prevent OS delivery when opening Kando +- Synthetic events: `CGEventCreateMouseEvent` + `CGEventPost`, support: move, down, up, click, dblclick (set click state), scroll, with modifiers +- Permissions: reuse current accessibility prompt; show guidance if access denied + +### Windows (next) +- Global capture: `SetWindowsHookEx(WH_MOUSE_LL)` +- Synthetic events: `SendInput` for mouse + +### Linux +- X11: XI2 + XTest (best effort) +- Wayland: no global mouse hooks; disable mouse triggers and recommend DE/portal bindings (keyboard only) + +--- + +## Public Native API (cross‑platform shape) + +- `startMouseHook({ buttons: string[], intercept: boolean }): void` +- `stopMouseHook(): void` +- `simulateMouse(event: { + type: 'move'|'down'|'up'|'click'|'dblclick'|'scroll', + button?: 'left'|'middle'|'right'|'x1'|'x2', + x?: number, y?: number, dx?: number, dy?: number, + scrollX?: number, scrollY?: number, + modifiers?: { shift?: boolean, ctrl?: boolean, alt?: boolean, meta?: boolean } + }): void` +- Emits events: `{ button, phase: 'down'|'up', x, y, mods, timestamp }` + +These APIs are implemented natively per OS but identically shaped in Node. + +--- + +## Active‑Window‑Aware Filtering (tap/untap strategy) + +We minimize overhead and avoid “spying” on clicks in non‑target apps by enabling the intercepting hook only when necessary: + +- Maintain a precomputed index of triggers per app/window pattern (compiled regex), plus any `when: 'always'` triggers. +- Track foreground window changes and pointer screen transitions; when the active app/window does not match any mouse triggers, disable the intercepting hook (or switch to a listen‑only tap where available). When a match appears, enable the intercepting hook. +- On platforms where toggling hooks is cheap, fully stop/start; otherwise `enable/disable` the same handle. + +Platform specifics: +- macOS: subscribe to `NSWorkspaceDidActivateApplicationNotification` to detect app changes; `CGEventTapEnable(tap, true|false)` to toggle; use `kCGEventTapOptionListenOnly` when you want metrics without the ability to swallow. On match, keep the tap enabled in intercept mode; otherwise disable or switch to listen‑only. +- Windows: use `SetWinEventHook(EVENT_SYSTEM_FOREGROUND, ...)` to detect focus changes; toggle `WH_MOUSE_LL` hook accordingly. +- X11: watch `_NET_ACTIVE_WINDOW` via `XSelectInput` and PropertyNotify; toggle XI2 hook. +- Wayland: no reliable foreground app events; default to disabled hooks (mouse triggers unsupported) or to a per‑DE integration if available. + +Race considerations: +- For RMB interception you must already be in intercept mode before the actual `RightDown` is dispatched by the OS. Therefore we only disable interception in apps without matching triggers; in apps with matches, interception remains enabled and the event is swallowed conditionally (constant‑time checks). +- Region conditions: decision is per‑event (we read pointer position from the event); interception remains enabled in candidate apps. + +Behavior summary: +- Not a candidate app/window → hook disabled (zero overhead). +- Candidate app/window → hook enabled, events checked against triggers; if not matched, immediately pass through; if matched, swallow and open menu. + +--- + +## LLM‑Driven Context‑Sensitive Menus (window snapshot → pie) + +We can dynamically propose a menu when a trigger fires by analyzing the active window snapshot or accessibility tree. This augments (not replaces) authored menus. + +High‑level pipeline: +1) Capture: use `aquery.screen.captureWindow(activeWindowId)` (prefer GPU/zero‑copy). Optionally include `aquery.a11y.query('window > *')` summaries. +2) Prompt: send snapshot (and AX summary) to `aquery.llm.describe/plan` with an instruction to emit a Kando `menus.json` fragment limited to 8–12 directions, names/icons, and safe actions only. +3) Validate: parse with Kando Zod schemas; reject if invalid or if contains disallowed actions. +4) Render: open the generated pie (ephemeral) or merge into a temporary overlay group; show a small “AI” badge and a “pin/save” affordance. +5) Learn/cache: key by app/window signature (bundle id + canonicalized title + UI hash). Cache top suggestions; allow feedback (👍/👎) and corrections; respect per‑app opt‑in. + +Output schema (LLM target): +```json +{ + "version": "1", + "menus": [ + { + "name": "AI Context", + "centered": false, + "anchored": false, + "root": { + "type": "submenu", + "name": "Context", + "icon": "lightbulb", + "iconTheme": "material-symbols-rounded", + "children": [ + { "type": "hotkey", "name": "Copy", "icon": "content_copy", "iconTheme": "material-symbols-rounded", "data": { "hotkey": "Command+C" } }, + { "type": "hotkey", "name": "Paste", "icon": "content_paste", "iconTheme": "material-symbols-rounded", "data": { "hotkey": "Command+V" } } + ] + } + } + ] +} +``` + +Safety & UX guardrails: +- Privacy: default to on‑device VLM; if cloud is used, require explicit per‑app consent and allow redaction regions. +- Safety: only emit Kando‑supported safe actions (`hotkey`, `command`, `uri`, etc.); require confirmation for destructive actions. +- Determinism: clamp to ≤12 slices; prefer well‑known icons; avoid ambiguous labels; show confidence tooltips. +- Latency: if the LLM response exceeds a threshold, show the default authored menu first and add AI suggestions as a sibling pie when ready. + +Refresh policy: +- Recompute when window identity or major layout hash changes. +- Cache per app/title signature; decay over time; respect user feedback. + +Integration points: +- Triggers: `MouseTrigger`/`GamepadTrigger` can select an AI pie variant when `conditions` match and AI is enabled for the app. +- Editor: provide a “Generate with AI” button that seeds a baseline menu the user can edit and save. + +--- + +## Editor UI Changes + +- Keyboard: keep `ShortcutPicker` (existing) +- Mouse: add `MouseTriggerPicker` + - Record button captures `mousedown` inside the dialog (button + current modifiers) + - Options: When (`matching-conditions|always`), Passthrough (`on-cancel|never|always`) +- Gamepad: add `GamepadTriggerPicker` + - Record listens via Gamepad API; captures button index; optional tilt threshold slider; stick selector + +All pickers edit a `triggers[]` list on the menu. + +--- + +## Gesture Pipeline Integration + +The menu opens in the same state machine as keyboard triggers; thereafter pointer/gamepad input is handled by the existing InputMethods. + +- PointerInput: unchanged for motion/jitter/marking/turbo/hover. Only the open event origin differs. +- GestureDetector: remains authoritative for corner/pause detection and fixed‑stroke; nothing changes here. +- GamepadInput: continues to publish an `InputState` with `distance/angle` based on stick tilt; optional “select on button up” adds a small arm/disarm flag in the input method. + +Fast gesture modes (e.g., `fixedStrokeLength`) continue to apply; if configured, a gamepad tilt beyond threshold may immediately select upon button release if distance crosses the fixed stroke. + +--- + +## Svelte Variant (browser) + +Svelte apps can mirror the same TypeScript model without native code: +- Mouse triggers: use `pointerdown`/`contextmenu` on a global overlay to detect RMB/MMB; browsers allow canceling the default context menu +- Gamepad triggers: use the browser Gamepad API (as in Kando renderer) for both opening and browsing + +The Svelte implementation should parse the same `triggers` array and apply identical selection/gesture logic, differing only in the capture layer. + +--- + +## Telemetry, Testing, and Migration + +- Logging: emit concise lines when a trigger matches or is ignored (kind, button/index, app/window, chosen menu) +- Unit tests: Zod schema for `triggers`; condition matcher remains as is +- Manual tests: per‑app RMB, double‑RMB passthrough, gamepad open/tilt/select, fixed‑stroke interactions +- Migration: if `triggers` missing, build a single `keyboard` trigger from `shortcut`/`shortcutID` at load time; editor writes the new schema going forward + +--- + +## Rationale Summary + +- Distinct trigger kinds keep platform realities clear (keyboard via Electron/portals; mouse via native hooks; gamepad via web API) while sharing the same conditions and open/gesture pipeline. +- Double‑RMB passthrough preserves native app context menus without spending a slice. +- The unified `triggers[]` is backward‑compatible and future‑proof (room for touch/pen or OS‑level gestures later). + + diff --git a/kando-svelte/notes/dom-css-compatibility.md b/kando-svelte/notes/dom-css-compatibility.md new file mode 100644 index 000000000..efbbffc67 --- /dev/null +++ b/kando-svelte/notes/dom-css-compatibility.md @@ -0,0 +1,665 @@ +# Exhaustive DOM/CSS compatibility report (Svelte vs Kando) + +This document captures the exact DOM/CSS contract Kando uses and the differences observed in kando‑svelte, together with concrete fixes. The goal is 100% compatibility: same DOM structure, same CSS variables, same classes, and the same behavior across themes. + +## 1) High‑level structure + +- Kando + - Single nested tree. One `div.menu-node.level-0` (the parent/center) contains all descendants. + - The selected child that becomes the new center is still a descendant of the parent and is positioned via a relative translate inside the parent node. +- Current Svelte + - Two parallel trees rendered as separate siblings: one `.pie-level` for the parent preview and another `.pie-level` for the active tip level. + - Both centers are absolutely translated to the same screen center, instead of nesting the active center under the parent. +- Fix + - Render a single `PieMenu` tree. If you keep a parent preview, its active child MUST be an immediate child of the parent `menu-node` with a relative `translate(...)` equal to child distance in the selected direction. Avoid a second top‑level `.pie-level` for the active level. + +## 2) Center positioning + +- Kando + - Parent center: `transform: translate(, )` on `.menu-node.level-0.parent`. + - Active submenu center: `transform: translate(dx, dy)` on `.menu-node.level-1.active`, where `dx/dy = max(--child-distance, 10px * var(--sibling-count)) * (--dir-x/--dir-y)`. Absolute center = parent absolute + relative. +- Current Svelte + - Both parent and tip `.menu-node.level-0` receive the same absolute translate. + - The active center is not a relative child of the parent; child placement and connectors misalign and “slide”. +- Fix + - Keep the parent `.menu-node.level-0` translated to absolute center. Insert the active child `.menu-node.level-1.active` as its descendant with relative translate only. Do not re‑translate a second root. + +## 3) DOM nesting and levels + +- Kando: `.menu-node.level-0.parent` contains `.menu-node.level-1.*` children; each level‑1 submenu contains `.menu-node.level-2.*` grandchildren. +- Current Svelte: the top‑level “tip” `.menu-node.level-0.active` contains `.level-1.*`, while a separate `.level-0.parent` exists alongside it. +- Fix: ensure the “active” level (and its grandchildren) are descendants of the parent node, not siblings in a different top‑level container. + +## 4) Parent/child bars, gaps and “nubs” + +- Kando: parent’s `.connector` is drawn inside `.menu-node.level-0.parent` with a width equal to the active child distance and rotated to connect to that child. The active child sits at the connector end. +- Current Svelte: parent and active levels are disconnected; connectors are computed relative to duplicate centers; bars and nubs don’t align. +- Fix: compute the parent connector width/rotation inside the parent node (only) and position the active child as a descendant of the parent. Stop drawing connectors in a duplicate “tip” tree. + +## 5) Relative transforms and CSS contract + +- Kando: children/grandchildren transform via CSS only using `--dir-x/--dir-y` and theme distances: + - Child: `translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), calc(... * var(--dir-y)))`. + - Grandchild: `translate(var(--grandchild-distance) * var(--dir-x), var(--grandchild-distance) * var(--dir-y))`. + - JS sets inline transforms only for dragged/clicked items. +- Current Svelte: correct vars exist but the wrong ancestor breaks relative positioning. +- Fix: keep children/grandchildren in the correct parent; do NOT inline transforms on children (except drag/click); let theme CSS position them. + +## 6) Active submenu center transform + +- Kando: `.menu-node.level-1.type-submenu.active` has a relative `translate(dx, dy)` under the parent (e.g., `translate(≈0px, -150px)` for a top child). +- Current Svelte: no `.level-1.active` relative translate under the parent; a second `.level-0.active` is used instead. +- Fix: produce `.menu-node.level-1.active` under the parent with relative translate per theme CSS rules. + +## 7) `--parent-angle` propagation for grandchildren + +- Kando: grandchildren `.level-2` nodes carry `--parent-angle: deg` to orient wedges/gaps. +- Current Svelte: sets `--parent-angle`, but grandchildren live under a separate root so visuals appear in the wrong place. +- Fix: ensure grandchildren are descendants of the selected child (itself a descendant of the parent) so `--parent-angle` works as intended. + +## 8) Selection wedges and separators + +- Kando: global singletons sized to the viewport; separators: `translate(centerX, centerY) rotate(angle-90deg)`; wedges read `--center-x/--center-y`. +- Current Svelte: components added and translated correctly but must be driven by the same center as the single nested tree. +- Fix: maintain one instance of each overlay, recomputed when chain/hover changes. + +## 9) Center text + +- Kando: `center-text` is absolutely positioned with `translate(centerX, centerY)` and uses deferred measurement to vertically center text in the circle; cached for performance. +- Current Svelte: placeholder; placed within the active node without iterative layout. +- Fix: replicate `center-text.ts` (deferred measurement + caching) and absolutely translate to the active center. + +## 10) Icon font tag + +- Kando: `glyph` and `` in `.icon-container`. +- Current Svelte: updated to `` (good). Keep consistent. + +## 11) Directional classes + +- Kando: `.left/.right/.top/.bottom` based on `--dir-x/--dir-y` thresholds. +- Fix: keep generating these; correctness depends on proper nesting. + +## 12) Connector rotation accumulation + +- Kando: avoids 360° flips by tracking `lastConnectorAngle` and using closest‑equivalent rotation. +- Current Svelte: connector angle recomputed directly; can flip. +- Fix: store/accumulate connector rotation per item using closest‑equivalent logic. + +## 13) Duplicate centers and the “sliding” bug + +- Root cause: a second absolute root is rendered for the active level; the UI slides instead of nesting. +- Fix: use one root; move it once when needed; nest the active center under the parent. + +--- + +## Concrete implementation changes (Svelte) + +### `PieTree.svelte` +- Render a single nested tree (remove separate tip tree). +- On selection, compute the new root center per Kando’s `selectItem()`; then nest the active child relative to the parent. +- Keep a single global wedges/separators overlay driven by the same center. + +### `PieMenu.svelte` +- Stop creating a second `.level-0.active` root. +- Render `.level-1.active` as a child of the parent node; children/grandchildren position via CSS vars only. +- Set `--parent-angle` for grandchildren; compute parent connector width/rotation in parent; use closest‑equivalent rotation. + +### `PieItem.svelte` +- Maintain `` icon tags; set CSS vars and directional classes; avoid inline transforms (except drag/click). + +### `SelectionWedges.svelte` / `WedgeSeparators.svelte` +- Singletons; `translate(center.x, center.y) rotate(angle-90deg)` for separators; wedges read `--center-x/--center-y` and hovered wedge angles. + +### `CenterText.svelte` +- Implement iterative layout and caching; translate to active center; mirror Kando behavior. + +--- + +## Key acceptance checks + +- One `.menu-node.level-0.parent` at absolute center containing the entire tree. +- After selecting a submenu, the active `.menu-node.level-1.active` is a descendant with correct relative translate. +- Parent connector terminates exactly at the active child; bars/nubs align; wedges/separators rotate correctly. +- Direction classes, CSS vars, and name/icon layers behave identically across Kando’s themes. + +--- + +## Kando Goal HTML and CSS outerHTML (Apps submenu selected) + +```html +
+ + +
E-Mail
+
+``` + +--- + +## Kando DOM/CSS and Protocol Contracts (Authoritative) + +This section defines the target contract the Svelte implementation MUST match exactly. + +### A. Layering and Structure +- One overlay root per popup with two logical layers: + - Parent layer: `.menu-node.level-0.parent` positioned at absolute center using `transform: translate(, )`. + - Active layer: `.menu-node.level-0.active` positioned at the same absolute center. Its `.level-1` children are rendered here. +- Grandchild previews ("nubs") are rendered in the parent layer under each `.level-1` child that has children, as `.menu-node.level-2.grandchild` elements. They are not separate menus. +- No duplicate absolute roots other than the two centers above. + +### B. Class names and dataset attributes +- Every node is `.menu-node level- type- [parent|active|child|grandchild] [left|right|top|bottom]`. +- Required data attributes for tooling and themes: + - `data-name`, `data-type`, `data-path`, `data-level`. +- Directional classes derive from `--dir-x/--dir-y` thresholds exactly like Kando. + +### C. Inline styles and CSS custom properties +- Inline style variables are authoritative inputs for theme CSS: + - `--dir-x`, `--dir-y`: unitless direction cosines for each item. + - `--angle`: item’s absolute direction in degrees. + - `--sibling-count`: number of siblings at that level. + - `--child-distance`: pixel radius for level‑1 children; set on the center node. + - `--parent-angle`: on grandchildren, equal to the angle of their parent (the selected child) relative to its parent center. + - Optional: `--angle-diff` for themes that need delta from pointer. +- Center nodes apply absolute `transform: translate(centerX, centerY)` only. Children and grandchildren must not receive inline `transform` from JS (except transient drag/press effects). Their placement is driven by CSS using the variables above. + +### D. Child placement (CSS-driven, no JS transforms) +- Level‑1 children translation relative to center: + - `translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-y)))`. +- Level‑2 grandchildren translation relative to their child: + - `translate(calc(var(--grandchild-distance) * var(--dir-x)), calc(var(--grandchild-distance) * var(--dir-y)))`. +- Themes define `--grandchild-distance`; core code only sets `--parent-angle` for wedge alignment. + +### E. Connectors and gaps +- The parent layer draws a single `.connector` inside `.menu-node.level-0.parent`: + - Width equals distance from parent center to the selected child’s center. + - Rotation equals `angle(parent→child) - 90deg`, accumulated with closest‑equivalent logic to avoid flips. +- The active layer draws its own `.connector` sized to `--child-distance` and rotated to the hovered child; it may be zero width. +- Gaps and “nubs” are entirely CSS-driven via `--parent-angle` on descendants. + +### F. Layers content and ordering +- Theme layers follow Kando’s `MenuThemeDescription.layers` contract. Typical: + - `.icon-layer[data-content="icon"]` (required for icon glyphs) + - `.label-layer[data-content="name"]` (optional; theme decides visibility and style) +- Icons render inside `.icon-container` as: + - `{glyph}` or `` + - SVG `` allowed for packs; must live inside `.icon-container`. +- A visually hidden `` mirrors the center label for screen readers. + +### G. Center text +- A `.center-text` element is absolutely positioned at the center using `transform: translate(-50%, -50%)` within the center node’s local space. +- Text content is the hovered child’s name on the active layer and the parent item’s name on the parent layer. +- Measurement is deferred and cached to ensure proper max‑width and vertical centering without layout thrash. + +### H. Selection wedges and separators (overlays) +- Singletons: + - Separators: `translate(centerX, centerY) rotate(angle - 90deg)` for each separator angle. + - Wedges: read `--center-x`, `--center-y`, `--start-angle`, `--end-angle` from the active selection; use viewport‑sized geometry. +- These overlays are recomputed when pointer, hover, or chain change. + +### I. Input protocol (mouse/keyboard/gamepad) +- Hovering and selection state machine: `idle` → `pressed-static` → `pressed-dragging` → `hovering` → selection. +- Mouse binding parity: left click selects; right click cancels or selects parent (config); X1 back pops chain. +- Escape cancels; Backspace/Delete pops one level. +- No global keyboard capture outside the overlay; focus remains scoped. + +### J. Lifecycle and creation +- Only two centers exist concurrently: parent and active. Deeper submenus are not instantiated; their “nubs” are rendered by the parent level as `.level-2.grandchild` nodes. +- Entering a submenu updates the chain; the parent remains at the same absolute translate; the active center is rendered in its own layer and children are positioned by CSS. + +### K. Accessibility +- Use `role="application"` on the overlay root and `role="menuitem"` per node. +- The live region `` announces hovered item names. +- Directional classes and labels must not interfere with screen readers; icons are `aria-hidden`. + +These constraints form the binding contract for 100% theme compatibility with Kando. Any deviation (extra wrappers, missing variables, inline transforms on children, incorrect layering) will cause visual or behavioral drift across themes and must be avoided. + +--- + +## Kando Menu Themes: DOM/CSS Protocol (Authoritative) + +This section describes the theme engine contract the implementation must honor exactly so all existing Kando themes work without modification. + +### 1. Theme package layout +- A theme directory contains at minimum: `theme.json5`, `theme.css`, `preview.jpg` (plus optional `REUSE.toml`). +- Assets (fonts, images, SVG) live alongside or in subfolders; CSS references them via relative URLs. + +### 2. `theme.json5` metadata keys +- `name`, `author`, `license`, `themeVersion`, `engineVersion` (compatibility gate) +- `maxMenuRadius` (px): soft constraint used by Kando when nudging menus from screen edges. +- `centerTextWrapWidth` (px): maximum width for center text measurement and wrapping. +- `drawChildrenBelow` (boolean): whether children render “below” the center (affects stacking hints only; actual transforms remain CSS-driven). +- `drawCenterText` (boolean): whether a `.center-text` element should be shown. +- `drawSelectionWedges` (boolean): whether selection wedges are drawn. +- `drawWedgeSeparators` (boolean): whether separator lines are drawn between wedges. +- `colors: { : }`: theme‑configurable color variables injected into CSS (e.g., `--background-color`, `--text-color`). +- `layers: Array<{ class: string; content: 'none'|'icon'|'name' }>`: ordered top‑to‑bottom layer descriptors, created for each `.menu-node`. + +### 3. Layer DOM contract +- For each `.menu-node`, create one div for each `layers[]` item with: + - `class` attribute set to the layer’s class (e.g., `icon-layer`, `label-layer`). + - `data-content` attribute equal to `content`. +- `content` behavior: + - `none`: empty layer (used for backgrounds, outlines, effects via CSS). + - `icon`: contains `
`. + - Inside the container: + - Material Symbols: `glyph` + - Simple Icons: `` + - Optional SVG: `{item.name}` + - `name`: the layer’s text content is the item name. +- Order matters. First `layers[]` entry is rendered on top; last entry is at the bottom (same as Kando). + +### 4. Node classes and attributes +- Each item is rendered as `.menu-node` with classes: + - `level-`: depth (root is 0, children 1, grandchildren 2, …) + - `type-`: Kando item type (submenu, command, hotkey, uri, macro, …) + - State classes: `active`, `parent`, `child`, `grandchild`, `hovered`, `clicked`, `dragged` + - Direction classes: `left`, `right`, `top`, `bottom` per direction thresholds +- Required data attributes: + - `data-name`, `data-type`, `data-path`, `data-level` + +### 5. Inline CSS variables (authoritative inputs to theme CSS) +Set on `.menu-node` as inline styles: +- `--dir-x`, `--dir-y`: unitless direction cosines of the item +- `--angle`: absolute direction in degrees +- `--sibling-count`: number of siblings at that level +- `--child-distance`: pixel radius for level‑1 children; set on center node (active or parent as applicable) +- `--parent-angle`: on grandchildren: the angle of their parent (child) relative to its parent center +- `--angle-diff` (optional): difference to pointer for hover/zoom effects + +Center node special variables for layers (set as inline or style props on layers): +- `--pointer-angle`: current pointer angle (deg) around center +- `--hover-angle`: angle of hovered child (deg) +- `--hovered-child-angle`: same as `--hover-angle` when a child is hovered and the parent is not hovered + +### 6. Transform rules (CSS-driven) +- Center nodes (both parent and active) use inline `transform: translate(centerX, centerY)` to move to absolute screen center. +- Children (level‑1) placement (no inline JS transforms): + - `translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-y)))` +- Grandchildren (level‑2) placement relative to their level‑1 parent: + - `translate(calc(var(--grandchild-distance) * var(--dir-x)), calc(var(--grandchild-distance) * var(--dir-y)))` +- Themes define `--grandchild-distance`, `--center-size`, `--child-size`, `--grandchild-size`, `--connector-width`, and transitions. + +### 7. Layering and visibility +- Two centers exist concurrently: + - `.menu-node.level-0.parent` (parent layer): absolute translate; draws connector to selected child; may draw grandchild “nubs”. Does not render `.level-1` children. + - `.menu-node.level-0.active` (active layer): absolute translate; renders `.level-1` children; may draw its own zero/short connector. +- Grandchild “nubs” appear under the appropriate parent layer children as `.menu-node.level-2.grandchild` elements. +- A visually hidden `` mirrors the current center text. + +### 8. Connectors (bars) +- Parent layer `.connector`: + - Width = distance from parent center to selected child center + - Rotation = `angle(parent→child) - 90deg`, using closest‑equivalent accumulation to avoid flips +- Active layer `.connector`: + - Width = `--child-distance` or theme‑specific; rotation to hovered child + +### 9. Center text +- `.center-text` is absolutely positioned at the center using `transform: translate(-50%, -50%)` inside the node’s local space. +- Parent layer shows parent item name; active layer shows hovered child (or active item) name. +- Measurement is deferred and cached; width constrained to `centerTextWrapWidth`. + +### 10. Overlays +- Separators: global element with `translate(centerX, centerY) rotate(angle - 90deg)` per separator angle. +- Selection wedges: global element reading `--center-x`, `--center-y`, `--start-angle`, `--end-angle`. + +### 11. Input and state mapping +- States map to classes exactly: `hovered`, `clicked`, `dragged`; chain push/pop toggles `parent`/`active`. +- Mouse buttons: left select, right cancel/back per config, X1 back; keyboard Esc cancel; Backspace/Delete back. + +### 12. Theme CSS responsibilities +- Use the variables above to position nodes; never rely on extra wrappers or inline child transforms. +- Style layers via the layer classes and `data-content` attributes. +- Provide transitions (`--menu-transition`, `--opacity-transition`) and sizing variables. + +Adhering to these rules guarantees full compatibility across bundled and community themes under `menu-themes/` (e.g., `default`, `clean-circle`, `neon-lights`, `rainbow-labels`, `nord`, etc.). Any deviation (extra wrappers, altered class names, missing variables, JS‑driven transforms for children) will break visual parity and must be avoided. + +--- + +## Example Themes + +Here’s a quick tour of the bundled themes under `static/kando/menu-themes/`. Each one showcases a facet of the Kando theme engine and is a great starting point for your own designs. + +### Default +- Layers: `[ icon-layer(icon) ]` +- Options: `drawChildrenBelow: true`, `drawSelectionWedges: true`, `centerTextWrapWidth: 95` +- Colors: background/text/border/hover plus wedge colors +- Vibe: A clean baseline ring with separators and subtle wedge highlights. Perfect reference for minimal, readable setups. + +### Clean Circle +- Layers: `[ arrow-layer(none), icon-layer(icon) ]` +- Options: `drawChildrenBelow: true`, `centerTextWrapWidth: 95` +- Colors: action/submenu icon colors, text/background +- Vibe: Sleek circles and a directional arrow layer. Great example of mixing a foreground icon with a slim guidance layer. + +### Rainbow Labels +- Layers: `[ icon-layer(icon), label-layer(name) ]` +- Options: `drawChildrenBelow: false`, `drawCenterText: false`, `centerTextWrapWidth: 90` +- Colors: icon and label background +- Vibe: Joyful labels around the ring. Shows how name layers can take center stage without a center text. + +### Neon Lights +- Layers: `[ icon-layer(icon), ring-fast-layer(none), ring-slow-layer(none), arrow-layer(none) ]` +- Options: `drawChildrenBelow: true`, `drawSelectionWedges: true`, `drawWedgeSeparators: true`, `centerTextWrapWidth: 95` +- Colors: glow/connector/separator/wedge tints +- Vibe: Pulsing neon energy with animated rings. A great stress test for multi-layer glow and motion aesthetics. + +### neon-lights-color +- Layers: `[ inner-glow-ring(none), outer-glow-ring(none), icon-layer(icon), icon-glow-layer(icon), ring-fast-layer(none), ring-slow-layer(none), arrow-layer(none) ]` +- Options: `drawChildrenBelow: true`, generous `centerTextWrapWidth` +- Colors: comprehensive icon/text/ring/connector/interaction/background palette +- Vibe: A richly parameterized spin on Neon—colorful, configurable, and designed to glow beautifully. + +### Navigation +- Layers: `[ icon-layer(icon) ]` +- Options: `drawChildrenBelow: true`, `centerTextWrapWidth: 140`, `maxMenuRadius: 180` +- Colors: text/icon/ring/sector/parent-indicator/background +- Vibe: Minimalist navigation ring with gentle ripples and natural sway. Crisp, modern, and focused. + +### Minecraft +- Layers: `[ icon-layer(icon) ]` +- Options: `drawChildrenBelow: true`, `centerTextWrapWidth: 120` +- Colors: text +- Vibe: A simple, playful template—ideal to study the essentials without extra layers. + +### KnightForge +- Layers: `[ label-layer(name) ]` +- Options: `drawChildrenBelow: true`, `drawSelectionWedges: true`, `centerTextWrapWidth: 120` +- Colors: text and wedge colors +- Vibe: High-contrast labels and wedge highlights—great for classic, text-forward designs. + +### Hexperiment +- Layers: `[ glow-layer(none), icon-layer(icon) ]` +- Options: `drawChildrenBelow: false`, `centerTextWrapWidth: 90` +- Colors: dark background, bright text, pink glow/hover +- Vibe: Futuristic hex glow. Shows how a single glow layer can transform the entire look. + +### Bent Photon Modified +- Layers: `[ label-layer(name), icon-layer(icon) ]` +- Options: `drawChildrenBelow: true` +- Colors: rich OKLCH palette for nuanced lights and canvas/dots +- Vibe: Elegant mixed label+icon theme with carefully tuned color science. + +### Nord +- Layers: `[ icon-layer(icon), label-layer(name) ]` +- Options: `drawChildrenBelow: false` +- Colors: Nord-inspired cool palette: accents, connectors, borders, labels, text +- Vibe: Calm, legible nordic vibes with tasteful labels—professional and friendly. + +### Nether Labels +- Layers: `[ icon-layer(icon), label-layer(name) ]` +- Options: `drawChildrenBelow: false` +- Colors: deep purples/blacks and matching accents, shrinked-outline, submenu hover +- Vibe: Moody and magical—great example of label-forward styling with bold accents. + +Tips +- Start from Default or Minecraft to learn the variables and transforms. +- Add a `label-layer` when you want text-heavy menus; adjust `centerTextWrapWidth` accordingly. +- Use multiple `none` layers to build glows/rings without changing the icon layer. +- Keep transforms in CSS, feed variables from the DOM; it keeps themes portable and fast. diff --git a/kando-svelte/notes/don-notes.txt b/kando-svelte/notes/don-notes.txt new file mode 100644 index 000000000..6ff7b59f2 --- /dev/null +++ b/kando-svelte/notes/don-notes.txt @@ -0,0 +1,160 @@ +Trying with latest versions of: + +node: 20.10.0 +npm: 10.2.3 + +nvm install 20.10.0 + +Kando for Snap AR Spectacles / Lens Studio! +https://ar.snap.com/lens-studio + +submenus with links back to the parent menu +kando/src/renderer/math/math.ts + +/** + * This method receives an array of objects, each representing an item in a menu level. + * For each item it computes an angle defining the direction in which the item should be + * rendered. The angles are returned in an array (of the same length as the input array). + * If an item in the input array already has an 'angle' property, this is considered a + * fixed angle and all others are distributed more ore less evenly around. This method + * also reserves the required angular space for the back navigation link to the parent + * item (if given). Angles in items are always in degrees, 0° is on the top, 90° on the + * right, 180° on the bottom and so on. Fixed input angles must be monotonically + * increasing. If this is not the case, the smaller angle is ignored. + * + * @param items The Items for which the angles should be computed. They may already have + * an angle property. If so, this is considered a fixed angle. + * @param parentAngle The angle of the parent item. If given, there will be some reserved + * space. + * @returns An array of angles in degrees. + */ + +How about the back links only occupy the "inner radius" of the pie +menu, so you can still put a normal item in the same direction as +"back", but you just have to move out further to select it, because +the "back" button has a max radius (i.e. inside the menu label radius). + +Or the back link could be the outer radius segment instead of the +inner radius segment. + +This could be a general purpose way of "stacking" multipl pie menu +items in the same direction, each occupying a radius interval arc. And +outer arcs can double and further increase the number of directional +items. + +Think of them like segments of orbitals for electrons. + +Slices / Petals? Multiple petals per slice. + +ChatGPT suggests: + +For naming the radial segments in a pie menu, especially when +considering stacking multiple items in the same direction with +different radii, you could use terms like "Orbital Layers" or "Radial +Tiers." Each tier or layer can represent a different level of menu +items, with the innermost being the back links or primary options, and +the outer layers for additional or secondary options. The outermost +layer that extends to the edge of the screen could be called the +"Peripheral Tier" or "Boundary Layer." These terms can effectively +convey the idea of multiple, concentric segments within a single +directional slice of the pie menu. + +Each slice has a tierSlices property, an array of integers, defining +the number of directional slices each layer has. The top level pie +menu typically has a subtend of 360 degrees (although it could be cut +in half or quarter at the screen edge or corner). The first layer is +usually 1 (if there is only one item), 2, 4, 8, or even 12. + +tierSlices = [8, 1, 2, 4, 8] + +That is an 8 slice pie menu that can hold 8 * (1 + 2 + 4 + 8) = 120 +items. + +The narrowest subtend outer layers extend to the screen edge, making +them easier to select than if their outer radius was limited. + +The layout algorithm could normalize the area by adjusting the inner +and outer radius of each layer, so all but the big outer items had the +same area. + +---- + +pie menu layout and editor + +the layout algorithms should be in the editor, and you should be able +to plug new ones in, and select different layout and tracking and +rendering policies per pie, slice, and item. You can select a high +level style in the editor, and it can apply it recursively to the +whole menu tree, stashing default values in the objects. Then you can +edit each pie, slice and item to override and customize those +parameters. Each policy might have its own parameters, but there is a +set of standard shared parameters too. + +Layout, tracking, and redering policies can declare their parameters +in a way that the editor can automatically generate editing dialogs +and direct manipulation interfaces for them. + +Use pie menus to edit pie menus! + +the MenuNode Path is like /1/2/3, which is a numeric path through the user interface. + +It might also be nice to support paths with identifiers instead of (or addition to) +paths with numbers, since the user could change the numbers by editing the menus. + +So each node would have its own id, which did not have to be globally +unique, just local to the menu. (what happens when items have the same +id? barf? stack them together? give them each a unique index?) + +You might also want to arrange items in their own semantic hierarchy, +and give items an id like blender.file.open, to reflect the command +structure instead of the user interface structure. Those names would +be unique by definition. + +When you have a menu bound to a keystroke you want to use for other purposes, +double pressing the keystroke should send that keystroke through to the +current focus. Like telnet! + +https://wonderingminstrels.blogspot.com/2004/01/telnet-song-guy-l-steele-jr.html + +Implement sharing mouse and keyboard across different devices like synergy. +Pop up menu of all screens (thumbnames of screens), drag mouse to position and screen to "warp" there! + +Logitech haptic feedback plugin api, and ring menu, easy switch to devices. +https://www.youtube.com/watch?v=id_7aFaMYlE + +https://logitech.github.io/actions-sdk-docs/ + +The new "Redirect" menu item type does not support gesturing into and back from the indirect menu. +The menu rendering code renders the entire menu tree, but not including recursing down redirect items. +Theoretically a redirect menu could refer to itself (i.e. to enter a series of digits) but eagerly rendering recursive redirected menus would explode. +But if the menu rendering code was lazy it could support deep and recursive submens (arbtirary graphs). +Lazy submenu dom generation. +Items with dynamically generated submenus (need to generate them to know how many there are to draw the numbs of the item before it's selected). + +Why not represent the parent links as actual items? Right now they are handled specially. + +Absolutely placed items (not associated with slices, but just defined by their area). +Put a small parent link in the menu center (but not under the absolute center, maybe offset in the direction of the parent but sitting on the edge of the center circle) instead of consuming a whole slice for the parent link. + +Absolute label target area based items would be higher priority than slices and could be placed anywhere, inside or outside the inactive enver, or at screen corners like the settings button. +Generalize the idea of the settings button so any menu can have screen corner buttons. +Corner targets are justifiable since screen corners are easily jump-to-able (see Tog's writings about that). +Even quarter sub pie menus on the corners. + +How can we support the idea of "slices" that contain "items", including empty slices with no items, and slices with multiple items (pull-out). + +Support fixing the number slices in a menu before putting any items in. +The parent link will completely consume the slice that's closes to the angle back to the parent, but you could even "pull out" to grand*x parents). +Support submenus with a "how many degrees the submenu should encompass" parameter like 120deg or so (or alternatively "how many degrees the parent item should encompass"), like the way logitech actions circle submenus look. + +That could also support half and quarter pies for screen edges and corners. + +Here's a way to implement "slices" in kando without changing its model, simply extending it with a new kind of item, like "list item". The list can contain zero or more other items. + +So the editor can encourage 8 item menus by creating a new menu with 8 empty list items, and then the user can drag other items into those. + +Will have to be integrated into the tracking code, for "pull out" item selection (and linear slider functionality with one or zero items). + +Direct manipulation pie menu editing. +As with HyperLook, the editor is an optional plug-in that you can enable by toggling "edit mode", so you don't have to include it in runtime read-only app. It intercepts the normal mouse tracking and lets you drag and drop items and edit every aspect of the pie menus, drawing an overlay of "gadgets" for adjusting parameters, inserting and deleting, resizing, popping up editing menus, etc. It could also enable editing theme parameters like color, layout, measurements, fonts, icons, etc. + diff --git a/kando-svelte/notes/inventory.md b/kando-svelte/notes/inventory.md new file mode 100644 index 000000000..dcb19572e --- /dev/null +++ b/kando-svelte/notes/inventory.md @@ -0,0 +1,304 @@ +## Kando repository inventory and deep dive (pie menu + macOS) + +This document is a guided tour of the Kando codebase, with a strong focus on the JavaScript/HTML/CSS pie menu engine, its APIs, extension points, and theming; an enumeration of platform drivers; and an in-depth look at the macOS backend and native bridge. Use it as a map for navigating and extending the project. + +### Top-level layout (what lives where) + +- **src/**: Application code. + - **main/**: Electron host (main) process + - Creates windows, binds shortcuts, chooses and drives the platform backend, houses item actions, exposes IPC to renderers. + - Key files: + - `index.ts`: process bootstrap, CLI/deeplink parsing, backend selection, settings/theme directories, i18n init, app lifecycle. + - `app.ts` (exported as `KandoApp`): application core; owns `MenuWindow`, `SettingsWindow`, tray UI, settings and menu settings objects, update checker, and IPC wiring to both renderers. + - `menu-window.ts`: full-screen transparent window hosting the pie menu renderer; moves/zooms window, passes `ShowMenuOptions` to the renderer, manages inhibit/restore of shortcuts, macOS/Linux/Windows visibility/focus quirks. + - `settings-window.ts`: window for the React settings UI; flavor-dependent transparency (Acrylic on Windows, vibrancy on macOS). + - `backends/`: platform drivers (see “Platform backends” below). + - `item-actions/`: executable behaviors for menu items (`command`, `file`, `hotkey`, `macro`, `text`, `uri`, `redirect`, `settings`), registered via `ItemActionRegistry`. + - `settings/`: general + menu settings shape and persistence. + - `utils/`: notifications, shell helpers, version checking. + - `example-menus/`: OS-specific starter menus in JSON. + - **menu-renderer/**: Pie menu UI (DOM + CSS + behaviors) running in a sandboxed/isolated renderer process. + - Entry `index.ts` wires theme, sound, icon registry, settings button, and menu event handlers. + - `menu.ts`: the core pie menu state machine and renderer (selection chain, wedges, connectors, input integration, geometry). + - `menu-theme.ts`: theming engine for layered DOM + CSS custom properties. + - `sound-theme.ts`: sound theming via Howler. + - `rendered-menu-item.ts`: runtime item properties appended to `MenuItem` (angles, wedges, DOM nodes, etc.). + - `input-methods/`: `pointer-input` (mouse/touch with gestures; Marking/Turbo/Hover), `gamepad-input` (+ polling helper `gamepad`), plus base `input-method` types. + - `selection-wedges.ts` and `wedge-separators.ts`: optional background visuals driven by theme flags. + - `menu-window-api.ts`: renderer-side IPC surface (paired with `menu-window.ts`). + - `index.html` + `index.scss`: host page and default renderer styles (also pulls font icon CSS). + - **settings-renderer/**: React settings app (component library, dialogs, preview, pickers, state via Zustand, preload + IPC API). Useful when editing themes/menus and testing backends, but not required to understand menu runtime. + - **common/**: Shared types and utilities. + - `index.ts`: Type exports and core shared types: `Vec2`, `MenuThemeDescription`, `SoundThemeDescription`, `ShowMenuOptions`, `SoundType`, `BackendInfo`, `VersionInfo`, `WMInfo`, `SystemInfo`, `AppDescription`, etc. + - `math/`: geometry and wedge algorithms (angles, wedges, clamping to monitor, etc.). + - `icon-themes/`: icon theme registry implementations: system icons, file-based themes, Material Symbols, Simple Icons (colored + monochrome), emoji, fallback compositing, etc. + - `item-types/`: item type registry and per-type metadata (distinct from item actions in `main`). + - `settings-schemata/`: versioned settings schemas. + +- **assets/**: Built-in content packaged with the app. + - `menu-themes/`: first-class menu themes (each dir: `theme.json5` + `theme.css` + assets). + - `icon-themes/`, `sound-themes/`, `images/`, `installer/`, `videos/`, `icons/`. + - Tray icons: includes platform-specific variants; macOS uses `trayTemplate.png`. + +- **locales/**: i18n strings for all supported languages (loaded by i18next in main; both renderers query via IPC). + +- **build/**, **out/**: build artifacts (CMake for native add-ons; packaged apps). + +- **Configuration**: `package.json` (scripts, versions), `webpack.*.ts` (bundling and asset relocation), `forge.config.ts` (makers: dmg/rpm/deb/zip/squirrel), `eslint.config.mjs`, `tsconfig.json`. + +- **test/**: mocha-based tests. + +## Pie menu engine: concepts, APIs, extensibility + +### Data model and event flow + +- **Menu items**: Tree structured `MenuItem` objects (via `common`). Items can be executable (leaf) or submenu (non-leaf). + - Runtime rendering uses `RenderedMenuItem` (menu-renderer) which augments items with: + - `path` (like `/0/2/1`), computed by `Menu.setupPaths()` on show. + - Angular `wedge` and optional `parentWedge` (start/end angles in degrees), computed by `Menu.setupAngles()` using `common/math`. + - DOM associations: `nodeDiv`, optional `connectorDiv`, and last-known angle caches for smooth CSS transitions. + +- **Renderer lifecycle** (`menu-renderer/index.ts`): + 1. Requests theme, color overrides, sound theme, and general settings via `window.commonAPI` (preload IPC surface). + 2. Instantiates `MenuTheme`, `SoundTheme`, `SettingsButton`, then `Menu` with these dependencies and settings. + 3. Hooks IPC subscriptions: + - Show/hide menu (`menuAPI.onShowMenu`, `onHideMenu`). + - Theme reload (`onReloadMenuTheme`) and sound reload (`onReloadSoundTheme`). + - Dark mode updates via `commonAPI.darkModeChanged`. + 4. Forwards menu events back to main: selection, cancel, hover/unhover, pointer warp request. + 5. Updates settings-driven behaviors live (e.g., sound volume, turbo/marking/hover modes, thresholds, fade durations, etc.). + +- **Host lifecycle** (`main/index.ts` → `KandoApp`): + - Parses CLI and deep links (e.g., `kando://menu?name=`), enforces single instance, sets protocol handler. + - Chooses backend (see “Backends”), loads settings, optionally disables hardware acceleration, ensures theme directories exist. + - Initializes i18n and the chosen backend, then constructs `KandoApp` and windows. + - Binds/unbinds shortcuts per menus, wires tray menu for quick access, applies update checks. + - When a shortcut/deeplink arrives: gathers `WMInfo` from backend, calculates `ShowMenuOptions` and sends it plus menu root over IPC to the renderer via `MenuWindow.showMenu()`. + +### Menu class and rendering loop (`menu-renderer/menu.ts`) + +- **Selection chain**: The central model is a stack from root to current item (`selectionChain`). The last element is the active center. Parents and children are styled and transformed relative to that center. + +- **Show/hide**: + - `show(root, showMenuOptions)`: clears DOM, applies input modes from settings (Marking/Turbo/Hover), computes paths and angles, constructs DOM tree, selects initial item (usually the root), optionally warps pointer to center, fades in. + - `hide()`: toggles `.hidden`, schedules DOM clear after fade-out. + - `cancel()`: emits `cancel` and plays close sound. + +- **Angles and wedges** (via `common/math`): + - `computeItemAngles(items, parentAngle?)`: distributes child directions; respects any given fixed angles and reserves parent gap when present. Fixed angle rules: monotonic increase; 0° at top; 90° right. + - `computeItemWedges(angles, parentAngle?)`: converts center angles to [start,end] wedge arcs around each child; scales wedges (default 50%) and optionally yields a `parentWedge`. + - `clampToMonitor(center, maxRadius, windowSize)`: clamps submenu positions to current monitor to keep the full structure visible (root moves; can trigger pointer warp on non-anchored with warp enabled). + +- **DOM, transforms, CSS**: + - Each item is a `.menu-node` containing one `connector` div and a per-theme set of layered divs (see theming). + - Items get classes at runtime: `level-{n}`, `type-{item.type}`, plus direction hints `left|right|top|bottom` to assist CSS. + - State classes: `active`, `parent`, `child`, `grandchild`, `hovered`, `clicked`, `dragged`. CSS selectors (see `index.scss`) use these to display connectors and other effects. + - Transform loop updates: + - For center: compute pointer/hover angles and call `theme.setCenterProperties()` to push CSS custom properties. + - For children: call `theme.setChildProperties()` and let CSS drive transform/scale; inline transforms used only for dragged or clicked items. + - Connectors: runtime width/rotation from child position or angle; stores accumulated rotations to avoid 360° jumps. + +- **Inputs and gestures** (`input-methods/`): + - `PointerInput`: + - Modes: Marking (mouse drag), Turbo (keyboard modifier drag), Hover (no click if `hoverModeNeedsConfirmation` is false). + - Thresholds: `dragThreshold`, `gestureMinStrokeLength`, `gestureMinStrokeAngle`, `gestureJitterThreshold`, `gesturePauseTimeout`, `fixedStrokeLength`, `centerDeadZone` (all driven from General Settings). + - Gesture-based submenu selection via `GestureDetector`: detects corners (sharp turns) or pauses; also supports distance-based instant selections with `fixedStrokeLength`. + - Right click closes (or goes to parent per setting), aux back button selects parent. + - `GamepadInput`: + - Polls the web Gamepad API (normalized wrapper), maps stick tilt to `InputState`, button indices for close/back configurable. + - Selections are anchored at the initial center position; distance scaled by `parentDistance`. + +### Theming and skinnability + +- **MenuThemeDescription JSON (per theme)** (`common/index.ts`): + - `id`, `directory` (auto-filled), `name`, `author`, `themeVersion`, `engineVersion`, `license`. + - Layout flags: `maxMenuRadius`, `centerTextWrapWidth`, `drawChildrenBelow`, `drawCenterText`, `drawSelectionWedges`, `drawWedgeSeparators`. + - `colors`: name→CSS color map; becomes CSS custom properties `--` (user overrideable per theme, with dark-mode variant if configured). + - `layers`: array of `{ class, content }` drawn back-to-front per item; `content` is `none | name | icon`. + +- **How themes render** (`menu-renderer/menu-theme.ts`): + - Registers CSS custom properties once globally: + - `--angle-diff` per child (absolute angular distance from pointer) → drive zoom/scale rings. + - For center layers: `--pointer-angle`, `--hover-angle`, `--hovered-child-angle` (degrees), set each frame; values eased by `getClosestEquivalentAngle` to avoid discontinuities. + - `loadDescription(desc)`: injects `` and registers/updates color properties as CSS custom properties. + - `createItem(item)`: builds `.menu-node`, sets `data-name`, appends each layer div with `class` and optional icon/name. + - `setChildProperties(item, pointerAngle)`: updates `--angle-diff`. + - `setCenterProperties(item, pointerAngle, hoverAngle, parentHovered)`: updates angle properties on each center layer. + +- **Optional global visuals** (`drawSelectionWedges`, `drawWedgeSeparators`): + - `SelectionWedges`: full-viewport container whose CSS can use conic gradients; exposes `--center-x`, `--center-y`, `--start-angle`, `--end-angle` when hovered. + - `WedgeSeparators`: injects absolute `div.separator` lines rotated to angles; theme defines style (width, colors, blend modes). + +- **Icons** (`common/icon-themes` + `IconThemeRegistry`): + - System icon theme comes from the backend (`common.get-system-icons()` → name → CSS image source, often base64 data URL), merged with user file/icon themes and built-ins (Material Symbols, Simple Icons, Emoji, fallback composition). + - Items declare `iconTheme` + `icon` and `IconThemeRegistry.createIcon()` returns the element to append to layer divs. + +- **Sounds** (`SoundThemeDescription` + `sound-theme.ts`): + - `SoundType` enum covers open/close/select/hover variants (submenu/parent/leaf). Theme maps types to files and optional `volume`, `minPitch`, `maxPitch`. + - Renderer uses Howler to play by building a `file://` URL and randomizing pitch; central volume from settings. + +- **Theme development workflow**: + - Place a theme under `/menu-themes//theme.json5` and `theme.css`, or use built-in `assets/menu-themes/`. + - Switch theme in settings; modify color overrides (persisted per theme and per dark/light if enabled). + - Hot reload themes via CLI `--reload-menu-theme` or UI button (invokes IPC to renderer to reload without restart). + +### IPC surface and security + +- **Context isolation and sandboxing** are enabled for renderers. Preloads expose whitelisted APIs only. + - `common/common-window-api.ts`: shared IPC for both renderers (log, general/menu settings get/set + change streams, locales, theme descriptions/colors, isDarkMode, system icons, createItemForDroppedFile, devtools). + - `menu-renderer/menu-window-api.ts`: menu-specific events (show/hide menu, selection/hover events, pointer warping, show settings, reload callbacks). + - `main/menu-window.ts` listens to these channels and bridges to `KandoApp` and backends. + +## Platform backends (enumeration and contract) + +### Backend contract (`main/backends/backend.ts`) + +- Responsibilities: + - Expose `BackendInfo` (name, `menuWindowType`, `supportsShortcuts`, `shouldUseTransparentSettingsWindow`, optional hints for OS-level shortcut setup). + - `init()` / `deinit()` lifecycle for native hooks. + - Global shortcuts: `bindShortcuts(shortcuts)`, `inhibitShortcuts(shortcuts)`, `inhibitAllShortcuts()`, and emit `'shortcutPressed'` events. Base class default uses Electron `globalShortcut` but backends can override for OS integration/limitations. + - Pointer: `movePointer(dx, dy)` relative movement in screen coordinates. + - Key simulation: `simulateKeys(keySequence)` for macros/hotkeys. + - Window manager info: `getWMInfo()` → `{ appName, windowName, pointerX, pointerY, workArea }`. + - Installed apps: `getInstalledApps()` array for settings UI (id, name, command, icon, iconTheme). + - System icons: `getSystemIcons()` map and `systemIconsChanged()` (hint for regenerating icon theme in renderer). + - Drag-and-drop helper: `createItemForDroppedFile(name, path)` (override for platform semantics; defaults to `file` item with MIME-derived icon hint). + +### Available backends (overview) + +- **macOS** (`main/backends/macos/...`): see next section for a deep dive. + +- **Windows** (`main/backends/windows/...`): Win32-native add-on; binds global shortcuts; simulates keys; moves pointer; lists apps and system icons. Window chrome configured to get reliable always-on-top overlay behavior (uses `type: 'toolbar'` for menu window in many cases, adjusted per-platform in `BackendInfo`). Includes native C++ (.cpp) with `stb_image_write` for icon handling. + +- **Linux** (`main/backends/linux/...`): multiple flavors by desktop/sessions: + - X11 generic (`x11/`), KDE X11 (`kde/x11/`), Cinnamon X11; use Xlib/XTest or native add-ons for input and WM info. + - Wayland flavors (GNOME, KDE/Plasma, wlroots compositors, Niri); rely on portals or compositor-specific protocols (`wlr-layer-shell`, `wlr-virtual-pointer`, `virtual-keyboard`, `xdg-shell`), with a native shim where necessary. + - Portals helpers (`portals/desktop-portal.ts`, `global-shortcuts.ts`, `remote-desktop.ts`). + +## macOS backend and native bridge (deep dive) + +### High-level architecture + +- **Backend class**: `MacosBackend` extends `Backend`. + - `getBackendInfo()` returns `{ name: 'macOS', menuWindowType: 'normal', supportsShortcuts: true, shouldUseTransparentSettingsWindow: true }`. + - `init()` hides the app’s Dock icon (`app.dock.hide()`), enumerates installed apps+native icons, and fills `installedApps` and `systemIcons` for the renderer icon theme. + - `getWMInfo()` returns active app/window (via native bridge) and pointer position (Electron `screen.getCursorScreenPoint()`); computes `workArea` for the display nearest to pointer. + - `getInstalledApps()` returns cached list; `getSystemIcons()` returns cached base64 data URLs keyed by app name; `systemIconsChanged()` returns false (no dynamic changes). + - `createItemForDroppedFile(name, path)` special-cases: + - Executables (`isexe`) → `command` item with quoted absolute path. + - App bundles (by matching `CFBundleExecutable` id against installed list) → `command` item with `open -a ""` and system icon. + - Falls back to default `file` item otherwise. + - Pointer and keys: + - `movePointer(dx, dy)` delegates to native; errors logged if bridge fails. + - `simulateKeys(keys)` maps DOM key names to Apple key codes using `common/key-codes` mapping (`mapKeys(keys, 'macos')`) and calls native per stroke, honoring per-keystroke delays. + +### Native add-on (Node-API, Objective‑C++) + +- **Module binding** (`main/backends/macos/native/index.ts`): requires `build/Release/NativeMacOS.node` and defines TypeScript interface `Native` exposing: + - `movePointer(dx: number, dy: number)` + - `simulateKey(keycode: number, down: boolean)` + - `getActiveWindow(): { app: string; name: string }` + - `listInstalledApplications(): Array<{ name: string; id: string; base64Icon: string }>` + +- **Build** (`CMakeLists.txt`): + - `enable_language(OBJCXX)`; builds `NativeMacOS` shared library with `.mm` sources and Node-API glue (`CMAKE_JS_*`), produces `.node` binary with no prefix/suffix tweaks. + +- **Implementation** (`Native.mm`): + - `movePointer(dx, dy)`: reads current pointer with Core Graphics and `CGWarpMouseCursorPosition` to warp cursor by delta. Used by renderer to gently nudge pointer when clamped near edges (see `menu.ts` + `menu-window.ts` scaling rules). + - `simulateKey(keycode, down)`: ensures Accessibility permission via `CGRequestPostEventAccess()`; updates internal left/right modifier masks for Command/Shift/Control/Option; posts a keyboard event with combined modifier flags. + - `getActiveWindow()`: uses AppKit to get `frontmostApplication`. Chooses ID via `bundleIdentifier` (preferred) or `localizedName`; scans on‑screen windows via `CGWindowListCopyWindowInfo` to find first window with same PID; returns title if available. If window title missing, returns a sentinel “Missing Screen Recording Permissions” and prints a console hint. This reflects macOS 10.15+ Screen Recording privacy requirement for enumerating window names. + - `listInstalledApplications()`: enumerates `/Applications`, `/System/Applications`, `~/Applications`, and `~/Library/Applications` for `.app` bundles; reads `CFBundleName` and `CFBundleExecutable`; renders the Finder icon at 64×64 to a PNG and returns base64 data URL. The backend caches these for the system icon theme and the App picker in settings. + +### macOS-specific windowing behaviors in main process + +- **Menu window** (`main/menu-window.ts`): + - `show()`: on macOS, toggles visibility on all workspaces briefly (`setVisibleOnAllWorkspaces(true)` → delay → `false`) to ensure the window is on the current desktop (#461 fix); always-on-top `screen-saver` level. + - `hideWindow()`: on macOS, calls `super.hide()` and then `app.hide()` to properly return focus to the previous app, except when settings are visible. + - **Pointer movement scaling**: Only non-macOS platforms scale deltas by monitor DPI; on macOS the native bridge is “pixel accurate”, so scaling is left at 1. The renderer deltas are additionally scaled by `webContents.getZoomFactor()` before sending to backend. + +- **App policy & tray** (`main/app.ts` constructor of `KandoApp`): + - On macOS, sets activation policy to `accessory` so the app doesn’t appear in Dock or Cmd‑Tab. + - Tray icon uses a template PNG for automatic tinting. Context menu includes menus, settings, inhibit/uninhibit shortcuts, and Quit. + +- **Settings window** (`main/settings-window.ts`): + - Transparent flavors use macOS vibrancy `menu` (Electron `vibrancy`), with hidden titlebar overlay. + +### macOS permissions and UX implications + +- **Accessibility**: required for simulated key events (macros/hotkeys). If unavailable, native throws and the backend reports errors. +- **Screen Recording**: needed to retrieve window titles in `getActiveWindow()`; without it, Kando will still work but UI condition matching by `windowName` is impaired and a hint is logged. + +## How it all fits together (runtime flow) + +- A menu is shown when: + - The user presses a globally bound shortcut; or + - Kando is launched with `--menu ` or via `kando://menu?name=` deep link; or + - Tray context menu selection. + +- Host-side (`KandoApp.showMenu`): + - Ensures `MenuWindow` is created and loaded; asks backend for `WMInfo` and `systemIconsChanged`. + - Uses `chooseMenu(request, info)` to pick the best-matching menu by trigger and optional conditions (`appName`, `windowName`, `screenArea` regex/substring checks). Supports cycling behavior when the same shortcut is pressed repeatedly. + - Adjusts window bounds to the monitor `workArea`, shows the window, and computes `ShowMenuOptions` (mouse position relative to window, scaled by zoom; centered/anchored/hover mode flags; system icon theme change hint). + - Sends root `MenuItem` and `ShowMenuOptions` to renderer. + +- Renderer-side (`menu-renderer/index.ts` + `menu.ts`): + - Creates themed DOM and inputs, positions root and children, and begins selection loop. + - Emits `select`/`cancel` which `MenuWindow` converts to actions via `ItemActionRegistry`. + - Hides with fade; host delays executing “delayedExecution” actions until fade-out completes so keystrokes go to the target app, not Kando’s window. + +## Extending Kando: key seams + +- **Add a new menu theme**: + - Create `/menu-themes//theme.json5` + `theme.css` (or add to `assets/menu-themes` to ship with app). + - Define `layers` and `colors`. Use CSS custom properties set by the engine: `--angle-diff` (children), `--pointer-angle`/`--hover-angle`/`--hovered-child-angle` (center layers), and item-level properties like `--dir-x`, `--dir-y`, `--angle`, `--parent-angle`, `--sibling-count`. + - Turn on `drawSelectionWedges` / `drawWedgeSeparators` for global background effects. + +- **Add/modify an item type**: + - Define metadata in `common/item-types` (icon defaults, validation, etc.). + - Implement behavior in `main/item-actions` and register in `ItemActionRegistry`. + - Add a settings editor in `settings-renderer/components/menu-properties/item-configs` if user-editable. + +- **Add a platform capability on macOS**: + - Extend native add-on (`Native.hpp`/`.mm`) with a new method; export via `index.ts` and call from `MacosBackend`. + - Watch for additional entitlements/permissions (e.g., accessibility, screen recording) and error surfaces. + +- **Support a new backend**: + - Create a `Backend` subclass with the required methods; consider session/DE detection in `backends/index.ts`; add native shim if needed. + +## Settings overview (selected fields that affect the menu) + +- Visuals & timing: `fadeInDuration`, `fadeOutDuration`, `zoomFactor`, `enableDarkModeForMenuThemes`. +- Input behavior: `warpMouse`, `enableMarkingMode`, `enableTurboMode`, `hoverModeNeedsConfirmation`, `dragThreshold`, gesture thresholds, `centerDeadZone`, `keepInputFocus`. +- Selection policies: `sameShortcutBehavior` (`nothing|close|cycle-from-first|cycle-from-recent`), `anchored`, `centered`, `hoverMode` (per menu). +- Sounds: `soundTheme`, `soundVolume`. +- Backend-specific UI hints: `trayIconFlavor`, `settingsWindowFlavor` (auto→system default per backend preference), `enableVersionCheck`. + +## Build and run + +- Toolchain: Node 20.x, Electron 38, TypeScript 5.9, Webpack forge plugin, cmake-js for native modules. +- Scripts (see `package.json`): + - `postinstall`: `cmake-js compile` (builds native add-ons across platforms). + - `start`: dev with electron-forge (webpack dev server for renderers; webSecurity disabled in dev only). + - `make`: package with forge makers (DMG/RPM/DEB/Squirrel/ZIP as configured). + - `test`: mocha specs. + - `i18n`: extract strings. + +## Quick reference: important types + +- `ShowMenuOptions`: `{ mousePosition, windowSize, zoomFactor, centeredMode, anchoredMode, hoverMode, systemIconsChanged }`. +- `MenuThemeDescription`: identity metadata, layout flags, `colors`, `layers`. +- `SoundThemeDescription`: identity metadata + `sounds: Record`. +- `WMInfo`: `{ windowName, appName, pointerX, pointerY, workArea }`. +- `BackendInfo`: `{ name, menuWindowType, supportsShortcuts, shouldUseTransparentSettingsWindow, shortcutHint? }`. + +## Troubleshooting: macOS + +- No window titles in conditions: grant Screen Recording permission to Kando (System Settings → Privacy & Security → Screen Recording). +- Macros/hotkeys not working: grant Accessibility permission (Privacy & Security → Accessibility). +- Menu not appearing on current desktop: the app sets `setVisibleOnAllWorkspaces(true) → false` on show as a workaround; verify Mission Control/Stage Manager settings. + +--- + +If you’re extending the pie menu visuals, start with a copy of a built-in theme under `assets/menu-themes/default` and experiment with `--angle-diff`, conic gradients for `SelectionWedges`, and layer-specific transitions keyed off `--pointer-angle`/`--hover-angle`. For platform features on macOS, add minimally-scoped native methods and surface them through the `MacosBackend` while preserving renderer sandboxing and IPC boundaries. + + diff --git a/kando-svelte/notes/jquery-pie-menus.md b/kando-svelte/notes/jquery-pie-menus.md new file mode 100644 index 000000000..051fde930 --- /dev/null +++ b/kando-svelte/notes/jquery-pie-menus.md @@ -0,0 +1,329 @@ +## jQuery Pie Menus — Clean Reference (from the MediaWiki page) + +This document is a structured, markdown version of the historical jQuery Pie Menus write‑up. It captures the model, API, configuration formats, callbacks, and selection rules of that system. It intentionally differs from Kando, but we document it precisely so we can learn from its design (what to adopt, adapt, or avoid) and mine its ideas for Svelte/Kando integration. + +Key links (source/history): +- Project: `https://github.com/SimHacker/jquery-pie` +- Clone: `git clone --recursive https://github.com/SimHacker/jquery-pie.git` + +Notes: +- The original system de‑emphasizes “menus,” calling them “pies,” to encourage a gestural, direct‑manipulation framing (graph/map vs strict tree). +- The model introduces an explicit “slice” layer between pies and items to stabilize layout while freely adding/removing items. + +--- + +## Model + +### Target +- A DOM element enhanced by the jQuery pie component. +- Holds a set of named pies, each defined by a DOM node or by JavaScript data. +- Clicking the target activates a pie: either a default or one chosen dynamically by a handler. +- Multiple pies per target support context sensitivity and navigation (submenus and sibling interlinks). + +### Pie +- A pie contains an ordered list of slices; each slice has a direction. +- Pies have a background and an overlay that may contain arbitrary HTML. +- Explicit slices provide a stable directional scaffold; items then populate slices without perturbing directions. +- Enforces good practice (e.g., 8 or 12 slices, even counts) without dummy items. +- Allows per‑slice layout and interaction policies (mix/match across a pie). + +### Slice +- Slice is defined by a unique direction; slices need not be evenly spaced. +- Slices have background and overlay HTML layers. +- Selection rule: compute the dot product between cursor direction and slice direction; the closest wins. +- Edges are implicit mid‑angles between adjacent directions (no explicit subtend needed): every possible direction maps to exactly one slice; no gaps/overlaps. +- Cursor distance is available as a continuous parameter or for discrete item selection. +- Slices contain ordered items and support per‑slice layout/selection policies. +- Slices can emulate classic controls: + - Linear slider (“slideout”) + - Pull‑down (“pullout”) + - Drop‑down (“dropout”) + +### Item +- Items live inside slices; item position/selection is driven by the slice’s policy. +- Items provide an optional label plus background and overlay HTML. + +Item layout/selection policies (examples): +- Equidistant: place compact icons at even distances along the slice vector; select nearest center. +- Justified: arrange text labels so they touch but don’t overlap; select by containment (or nearest if none contain the cursor). +- Pull‑out: show only the currently selected item centered at a fixed distance; switch by cursor distance. + +--- + +## Defining and Editing + +Multiple authoring channels are supported: + +### API +- Programmatic definition in JavaScript (client/server). +- Supports embedded “onshow” handlers that build/modify pies/slices/items dynamically. +- Utility helpers and templates envisioned for common styles. + +### JSON +- Pure data definitions suitable for static authoring or dynamic server‑side generation. +- Functions are not JSON, but you may reference handler names which resolve to functions. + +### HTML DOM +- Pies, slices, items defined by nested DOM; backgrounds/overlays can include arbitrary HTML/CSS/JS. +- Good for designers comfortable in HTML. + +### Tools +- UI builders: lists, property sheets, or direct manipulation (drag/drop) editors. +- Goal: empower both designers and end users (task‑specific pies). + +--- + +## Notification and Tracking + +Integration requires events and feedback during live tracking for previews, documentation, and emphasis of relevant choices. Three ways to attach handlers: + +### HTML Attributes +Inline event attributes (traditional): + +```html +
+``` + +### jQuery Handlers +Programmatic binding: + +```js +$('#target').on('pieitemselect', function (e, pie, slice, item) { + // ... +}); +``` + +### JavaScript Data +Handlers embedded directly in pie definitions (as functions) or referenced by name: + +```js +const pieDefinition = { + slices: [ /* ... */ ], + onpieitemselect: function (event, pie, slice, item) { + // ... + } +}; +``` + +Event bubbling/capturing rules let you attach specific handlers at the item level or generalized handlers at the slice/pie/target levels. + +--- + +## Customization + +### CSS Classes and Styles +- Assign static or dynamic classes/styles to pies, slices, and items—during tracking as well. + +### HTML Content +- Backgrounds and overlays may contain arbitrary HTML/CSS/JS: SVG, Canvas, WebGL, video, CSS 3D, filters, etc. + +### Dynamic Feedback +- Rich, real‑time preview via callbacks and layered presentation. + +### Rich Application Integration +- The host app may provide in‑world previews responding live to tracking. +- Pie selection is purely directional; once learned, users can “mouse ahead,” keeping attention on objects while menus provide peripheral/contextual feedback. + +--- + +## Documentation (Behavioral Spec) + +### Creating a Target + +```js +const gTarget = $('#target').pie({ + // options +}); +``` + +### Options Dictionary +- Configures the target and defines pies. +- Contains optional event handler functions. +- Pies/slices/items may inherit properties from options via delegation and events bubble up to the target. + +Two main forms of pie definitions: dictionaries or DOM elements. Several ways to reference them (see below). + +--- + +## DOM Pie Definitions + +- Pies can be declared as DOM elements and referred to by jQuery selectors, provided via `options.defaultPie`, returned by `options.findPie`, or listed in `options.pies`. +- DOM attributes encode properties: `data--=""` (supports inheritance). +- Inline event attributes `on="..."` are used (since events do not dispatch directly to definition nodes). + +--- + +## Pie/Slice/Item Dictionaries + +### Pie Dictionary +- Keys configure appearance/behavior. +- Usually contains `slices: []`. +- May define `onshowpie` to create/modify slices/items before show. +- A pie may have zero slices for continuous angle/distance tracking use‑cases. + +### Slice Dictionary +- Keys configure appearance/behavior. +- Usually contains `items: []`. +- May define `onshowslice` to create/modify items before show. +- A slice may have zero items (continuous distance parameter). + +### Item Dictionary +- Keys configure appearance/behavior. +- Usually contains `label` (optional if using icons/HTML instead). +- May define `onshowitem` for dynamic label/content. + +--- + +## Deciding Which Pie to Pop Up (Resolution Algorithm) + +1) `options.defaultPie` + - When the user clicks the target, `findDefaultPie(event)` reads `options.defaultPie`. + - It may be: a pie dictionary, a `pieRef` string, or `null` (no pie). + +2) `pieRef` string + - A reference to a pie definition. Possible forms: + - Key into `options.pies` + - jQuery selector string + - Any identifier interpretable by `options.findPie` + +3) `options.findPie(event, pieRef)` (optional) + - First stage resolver; enables context sensitivity (location, state machine for submenus/sibmenus). + - Return `null` → no pie; a dictionary → use it; a string → further resolve; absent → skip. + +4) `options.pies` + - If still a string, look it up in `options.pies`. + - Value may be a dictionary or a jQuery selector string pointing to a single DOM element. + +5) jQuery selector to DOM + - If still unresolved, treat as a jQuery selector; call `makePieFromDOM(selector)`. + - On success, cache the resulting dictionary in `options.pies` and use it; else no pie. + +--- + +## Callbacks and Events (exhaustive per source) + +Three notification mechanisms are supported simultaneously (and events bubble from Item → Slice → Pie → Target): +- DOM attributes: `on="..."` evaluated with `this = widget` and local bindings `{ event, pie, slice, item }` when available +- jQuery events: `$(el).on('', (event, targetWidget, pie, slice, item) => { ... })` +- Dictionary handlers: `on(event, pie, slice, item)` functions placed on item/slice/pie/options dictionaries + +Where an event fires first (leaf) and bubbles upward is indicated below. For jQuery handlers, the extra arg order is always `(targetWidget, pie, slice, item)`. + +### Pie‑level lifecycle and input +- `pieshow` – leaf: Pie → Target + - DOM/jQuery element: `pie.$pie` + - Args: `(event, pie)` (plus `slice=null,item=null` in generic plumbing) + - Dictionary handler keys: `onpieshow` +- `piestart` / `piestop` – Pie shown/hidden during a tracking session + - Leaf: Pie → Target; Args: `(event, pie)` + - Keys: `onpiestart`, `onpiestop` +- `piepin` / `pieunpin` – pin/unpin transitions (click‑up to stick; click‑down to unstick) + - Leaf: Pie → Target; Args: `(event, pie)` + - Keys: `onpiepin`, `onpieunpin` +- `piecancel` – cancel (e.g., pinned and user clicks without selecting) + - Leaf: Pie → Target; Args: `(event, pie)` + - Key: `onpiecancel` +- `pieupdate` – per‑motion update (after slice/item updates) + - Leaf: Pie → Target; Args: `(event, pie)` + - Key: `onpieupdate` +- `pieselect` – a selection occurred in the current pie (may be with/without item) + - Leaf: Pie → Target; Args: `(event, pie)` + - Key: `onpieselect` +- Low‑level input passthrough for diagnostics or tooling: + - `piedown` / `piemove` / `pieup` – Leaf: Pie → Target; Args: `(event, pie)` + +### Slice‑level lifecycle and tracking +- `piesliceshow` – before slice is shown (within `pieshow`) + - Leaf: Slice → Pie → Target; Args: `(event, pie, slice)` + - Key: `onpiesliceshow` +- `pieslicestart` / `pieslicestop` – enter/leave slice (null slice marks center dead‑zone) + - Leaf: Slice → Pie → Target; Args: `(event, pie, slice)` + - Keys: `onpieslicestart`, `onpieslicestop` +- `piesliceupdate` – per‑motion while in current slice + - Leaf: Slice → Pie → Target; Args: `(event, pie, slice)` + - Key: `onpiesliceupdate` +- `piesliceselect` – slice commit (also raised when an item within slice is selected) + - Leaf: Slice → Pie → Target; Args: `(event, pie, slice)` + - Key: `onpiesliceselect` + +### Item‑level lifecycle and tracking +- `pieitemshow` – before item is shown (within `piesliceshow`) + - Leaf: Item → Slice → Pie → Target; Args: `(event, pie, slice, item)` + - Key: `onpieitemshow` +- `pieitemstart` / `pieitemstop` – enter/leave item + - Leaf: Item → Slice → Pie → Target; Args: `(event, pie, slice, item)` + - Keys: `onpieitemstart`, `onpieitemstop` +- `pieitemupdate` – per‑motion while over item + - Leaf: Item → Slice → Pie → Target; Args: `(event, pie, slice, item)` + - Key: `onpieitemupdate` +- `pieitemselect` – item commit + - Leaf: Item → Slice → Pie → Target; Args: `(event, pie, slice, item)` + - Key: `onpieitemselect` +- `pietimer` – periodic timer tick during tracking (event is `null` by design) + - Leaf: Item → Slice → Pie → Target; Args: `(null, pie, slice, item)` + - Key: `onpietimer` + +### Handler signatures recap +- DOM attribute: `on="..."` evaluated with `this === target widget`; locals: `event`, `pie`, `slice`, `item` +- jQuery: `$(el).on('', (event, targetWidget, pie, slice, item) => { ... })` +- Dictionary: `dict.on = function(event, pie, slice, item) { ... }` + +### Selection, pinning, and navigation nuances +- Dead‑zone: `inactiveDistance` px gate; inside it, no slice selected. +- Select item under cursor: `selectItemUnderCursor` (pie/slice/item level) uses `elementFromPoint` to promote direct‑hit items regardless of slice. +- Slice item tracking: `sliceItemTracking` policies include `'closestItem'` (distance‑based) and `'target'` (defer to app logic; no auto item). +- Pinning: first click pins (`piepin`); next click either cancels (`piecancel`) or selects; sticky and draggy pin behaviors via `stickyPin`/`draggyPin`/`dragThreshold`. +- Submenus: set `nextPie` on an item; after `pieitemselect`, the widget resolves `nextPie` (string ref or DOM selector) and continues tracking with the next pie already pinned. + +--- + +## Porting Notes (to Svelte/Kando) + +- Keep the explicit slice layer to stabilize directions while editing. +- Support multiple item policies per slice (equidistant/justified/pull‑out) via themeable CSS variables and per‑slice props. +- Provide a rich callback/event surface equivalent to the jQuery version and connect it to Svelte runes/snippets for dynamic content, previews, and instrumentation. +- Align selection math (angle → slice by nearest direction; edges as mid‑angles) to avoid gaps/overlaps and to guarantee a unique match. +- Encourage app‑level integration for real‑time in‑world previews during tracking. + + +--- + +## Options, Attributes, and CSS (from source) + +### Core options (selected) +- `pies`: dictionary of pie definitions; values can be dictionaries or jQuery selector strings to DOM pies +- `defaultPie`: dictionary or pieRef string; used by `findDefaultPie(event)` +- `findPie(event, pieRef)`: optional resolver; may return dictionary or new `pieRef` +- `root`: element/selector for overlay root (defaults to `document.body`) +- `triggerEvents`, `triggerSelector`, `triggerData`: configure activation binding (default `'mousedown.pieTrigger'` on target) +- Notifier switches: `notifyDOM` (default true), `notifyjQuery` (true), `notifyDictionaries` (true) +- Timer: `timer` (bool), `timerDelay` (ms) + +### Per‑pie/slice/item parameters (examples) +- Direction scaffold and placement: + - `initialSliceDirection` (default `'North'`), `clockwise` (bool), `turn` (deg step or auto), `pieSliced` (0..1 proportion of circle) +- Selection & layout: + - `sliceItemLayout`: `'spacedDistance' | 'minDistance' | 'nonOverlapping' | 'layered'` (prototype) + - `sliceItemTracking`: `'closestItem' | 'target'` + - `selectItemUnderCursor`: bool + - Distance/spacing: `inactiveDistance`, `itemDistanceMin`, `itemDistanceSpacing`, `itemGap`, `itemShear`, `itemOffsetX`, `itemOffsetY` + - Rotation: `rotateItems` (bool), `itemRotation` (deg) +- Navigation: `nextPie` (string ref) on slice/item + +All parameters may be specified at item, slice, or pie level; lower levels override higher ones. Defaults can be injected via `pieDefaults`, `sliceDefaults`, `itemDefaults`. + +### DOM data‑attributes +Attributes are read from DOM declarations for pies/slices/items and coerced by type. Prefixes: +- Pie: `data-pie-`; keys include those in `pieAttributes` (string/number/boolean/eval) +- Slice: `data-pieslice-`; keys include `sliceItemLayout`, `sliceItemTracking`, `sliceDirection`, etc. +- Item: `data-pieitem-`; keys include per‑item overrides and `nextPie` + +### CSS class map (used by the widget) +``` +Pie, PieBackground, PieTitle, PieOverlay, +PieSlices, PieSlice, PieSliceHighlight, PieSliceBackground, PieSliceOverlay, PieSliceItems, +PieItem, PieItemHighlight, PieItemLink, PieItemBackground, PieItemLabel, PieItemOverlay, +PieCaptureOverlay +``` + + diff --git a/kando-svelte/notes/kando-svelte.md b/kando-svelte/notes/kando-svelte.md new file mode 100644 index 000000000..e99173384 --- /dev/null +++ b/kando-svelte/notes/kando-svelte.md @@ -0,0 +1,944 @@ +## Svelte 5 pie menu implementation aligned with Kando (data, theme, and code compatible) + +Goal: Build a Svelte 5/SvelteKit implementation of Kando’s pie menu that reuses (and where practical, copies verbatim) Kando’s code, APIs, schemas, algorithms, and contracts, while preserving attribution and licenses. Keep naming and structure aligned so both projects stay in sync; use Svelte 5 only as the packaging/rendering layer. No editor in Svelte initially—design menus/themes in Kando and import them into Svelte apps (e.g., Micropolis) seamlessly. Eventually add a SvelteKit editor. + +### High-level requirements + +- Load and render Kando menu JSONs and Kando menu themes (theme.json5 + theme.css) as-is. +- Reproduce Kando’s geometry and interaction (angles, wedges, connectors, selection chain, hover/drag/click semantics) so the same data yields the same UX. +- Keep engine concerns (geometry, input, state) separate from skinning (theme layers + CSS custom properties) and from app wiring (SvelteKit-specific loading and routes). +- Svelte 5 features: use runes ($state/$derived/$effect) for local state/derivations, pass snippets for theme layer templating, and provide small, composable components. + +--- + +### Reuse policy (import-first) and header-note requirement + +- Primary rule: Prefer direct imports of Kando source whenever technically possible (renderer-safe TS, math, schemas, theme helpers). Keep names and signatures identical. +- If direct import is not possible, first consider a minimal upstream-friendly refactor in Kando (layering/abstraction, DOM-free helper extraction, EventEmitter → tiny emitter interface, parameterizing environment). +- Only re-implement when import/refactor is impractical for now. In that case: + - Add a short header comment at the top of the file titled “Reuse Note” that explains: + 1) Which Kando file(s) this mirrors (paths/commit if relevant) + 2) Why it cannot be imported as-is today + 3) Suggested refactor to make it importable later (scope and feasibility) + 4) Any deliberate deviations from Kando behavior + - Keep type/function names and behavior 1:1 where feasible to simplify diffing and future convergence. +- Track identified refactor opportunities in this doc (Code re‑use inventory) so we can upstream PRs. + +Header comment template to use in re-implemented files: + +``` +/* Reuse Note + - Mirrors: + - Blocker to import-as-is: + - Proposed upstream refactor: + - Behavior notes: +*/ +``` + +## Compatibility surface (what must match Kando) + +### Menu JSON shape (reader contract) + +Kando example (`src/main/example-menus/*.json`) shows top-level fields and item tree. Svelte must accept: + +- Top-level menu object properties (as produced by Kando editor): + - `shortcut`: string (unused by Svelte at runtime, but preserved) + - `shortcutID`: string (unused in Svelte) + - `centered`: boolean + - `anchored?`: boolean + - `hoverMode?`: boolean + - `conditions?`: { appName?, windowName?, screenArea? } (ignored unless consumer app wants ambient conditions) + - `root`: `MenuItem` + +- `MenuItem` (union by `type`): + - Common: + - `type`: 'submenu' | 'command' | 'file' | 'hotkey' | 'macro' | 'text' | 'uri' | 'redirect' | 'settings' + - `name`: string + - `icon?`: string + - `iconTheme?`: string (e.g., 'material-symbols-rounded', 'simple-icons', 'system', etc.) + - `angle?`: number (fixed angle in degrees; obey Kando’s fixed angles policy) + - `children?`: `MenuItem[]` (for 'submenu') + - Type-specific `data` payloads (pass-through): + - 'command': `{ command: string }` + - 'file': `{ path: string }` + - 'hotkey': `{ hotkey: string; delayed?: boolean }` + - 'macro': `{ keys: KeySequence }` + - 'text': `{ text: string }` + - 'uri': `{ uri: string }` + - 'redirect': `{ path: string }` + - 'settings': `{ dialog?: string }` + +Svelte renderer should treat items as opaque actions and emit `select(path, item)`; host app decides execution. + +### Theme JSON5 + CSS shape (reader contract) + +Theme JSON5 fields (see `assets/menu-themes/*/theme.json5`): + +- Identity: `name`, `author`, `license`, `themeVersion`, `engineVersion` (Svelte should accept engineVersion >= 1) +- Layout flags: + - `maxMenuRadius`: pixels used to clamp away from edges + - `centerTextWrapWidth`: px width for text wrap in center + - `drawChildrenBelow`: boolean (z-order) + - `drawCenterText`: boolean + - `drawSelectionWedges`: boolean + - `drawWedgeSeparators`: boolean +- `colors`: Record (becomes CSS custom properties `--`) +- `layers`: array of `{ class: string; content: 'none' | 'name' | 'icon' }` + +Theme CSS assumptions: + +- CSS operates on DOM structure created by the engine: + - Container `div.menu-node` per item, plus a `.connector` under nodes with children. + - Theme engine creates one div per `layer.class` inside each `.menu-node` and may inject an `.icon-container` or name text depending on `content`. +- Engine sets CSS custom props every frame: + - For children: `--angle-diff` + - For center layers: `--pointer-angle`, `--hover-angle`, `--hovered-child-angle` +- Engine sets static per-item CSS variables on `.menu-node` elements: + - `--dir-x`, `--dir-y`, `--angle`, `--parent-angle?`, `--sibling-count` + +Svelte must adhere to the same DOM/CSS contract to make Kando themes work without changes. + +### Differences from Kando (scope, necessary deviations, and TODOs) + +The prime directive is DOM/CSS compatibility. Where differences exist, they are either required by Svelte/web constraints or temporary stopgaps with explicit TODOs. + +- Input and interaction + - Same abstractions: selection chain, hover hit‑testing via wedges, center dead zone, RMB cancel/back, X1 back, angle/distance tracking. + - Svelte implements a small input state machine in `PieTree` (idle → pressed‑static → pressed‑dragging → hovering) and integrates Kando’s `GestureDetector`. + - Keyboard/gamepad: planned parity. Keyboard will be scoped to the overlay; gamepad maps to relative vectors and back/close buttons. + - TODOs: + - Marking/Turbo parity events and thresholds end‑to‑end + - Gamepad integration using Kando’s gamepad input with a browser emitter shim + - Full keyboard navigation per A11y plan (roving tabindex or aria‑activedescendant) + +- Rendering and DOM creation + - Svelte components create the same DOM tree: `.menu-node` container per item with a `.connector` child for items with children; theme layer divs exactly as in theme description. + - JS sets only the root/menu center translate and connector width/rotation; children use CSS `transform` via `--dir-x/--dir-y` and theme distances (same as Kando). + - Angle/vars contract is identical: `--dir-x`, `--dir-y`, `--angle`, `--parent-angle?`, `--sibling-count`; center layers receive `--pointer-angle`, `--hover-angle`, `--hovered-child-angle`. + - Icon font tags use `` with Kando class names (e.g., `material-symbols-rounded`, `si si-`). + - Labels: rendering is controlled by `labelsEnabled` (defaults false) to avoid first‑popup text flash; honoring theme layers otherwise. + - TODOs: + - Implement global `SelectionWedges` and `WedgeSeparators` helpers (CSS var‑driven) to match Kando visuals + - Finalize `CenterText` to match Kando’s iterative layout (deferred measurement); current version is a minimal placeholder + +- Environment (browser vs Electron) + - Kando runs in Electron with privileged APIs; Svelte runs in a normal browser. + - Theme CSS is injected via `` (no `window.commonAPI`); colors applied via CSS variables. + - Icon themes: provide a browser resolver for Material Symbols and Simple Icons; system/file icon themes require host adapters. + - Sounds: use Howler in the browser with a thin wrapper mirroring Kando’s `SoundTheme` API. + - TODOs: + - Web `IconThemeRegistry` wrapper with parity API; host hooks for system/file packs + - `SoundTheme` wrapper exposing `loadDescription`, `setVolume`, `playSound` + +- Build/runtime differences + - Use TypeScript NodeNext, explicit `.ts` extensions for direct source imports; Vite aliases map `@kando/*` to monorepo sources. + - Guard DOM APIs (`CSS.registerProperty`, `document.head`, Howler) in browser‑only lifecycles. + - Provide a small EventEmitter shim if needed for gesture/gamepad imports. + - TODOs: + - Validate SSR safety across all components + - Document/emplace an emitter shim when importing Kando input modules directly + +- Accessibility and keyboard + - Keep roles/ARIA on container and items; screen‑reader announcements via a visually hidden ``. + - Keyboard scope: focused overlay only; Escape cancels, Backspace/Delete selects parent; arrow/Enter navigation planned. + - TODOs: + - Implement full roving‑tabindex or aria‑activedescendant model; announce changes tersely + +These differences should converge toward zero over time as we upstream small refactors and extend the Svelte adapter. + +### Icons and icon themes + +- Kando uses an `IconThemeRegistry` to materialize icons from different sources: + - Material Symbols font (rounded), Simple Icons font, Emoji, system icon data URLs, file icon themes. +- In Svelte, provide an injectable icon resolver that mirrors Kando semantics and DOM: + - 'material-symbols-rounded': render `glyph`; include package/font CSS. + - 'simple-icons'/'simple-icons-colored': render `` or SVG fallback. + - 'file-icon-theme': map to URL space; allow mounting a theme directory. + - 'system': host app provides name→data URL mapping (optional). + +--- + +## Geometry, interaction, and rendering rules (compat) + +Import Kando math as‑is (1:1 behavior) from `@kando/common/math`: + +- Angles and wedges: + - Compute child angles with `computeItemAngles(children, parentAngle?)`, honoring fixed angles (monotonic increasing, clamp to [0, 360) for first, remove duplicates and overflow beyond first+360). + - Compute wedges with `computeItemWedges(angles, parentAngle?)` and scale wedges inward by 50% toward center (see Kando’s `scaleWedge`). Optionally produce `parentWedge`. + - Angle conventions: 0° = top, 90° = right, 180° = bottom; `getAngle(vec)` uses atan2(y, x) transformed accordingly. + +- Selection chain: + - Maintain a stack `[root, ..., current]`. Selecting a parent pops; selecting a child pushes. + - Center position is root position plus relative offsets of chain items; children positions are derived from angle and current distance. + - Clamp center for submenus to keep `maxMenuRadius` fully visible; optionally emit “move-pointer” if you implement pointer warp (desktop). + +- Input methods (initial scope): + - Pointer/touch: hover detection based on current angle vs wedge arcs, `centerDeadZone` for parent hover logic, clicked and dragged states. + - Gestures (optional in v1): marking/turbo modes; reproduce Kando’s `GestureDetector` thresholds and behaviors (min stroke length, jitter, pause, fixedStrokeLength). + - Keyboard (optional): numeric/alpha selections; Backspace selects parent; Escape cancels. + - Gamepad (later): normalized axes and button mapping; keep semantics if needed. + +- DOM + classes: + - Use exactly the class names Kando expects: `.menu-node`, `.connector`, and state classes (`active`, `parent`, `child`, `grandchild`, `hovered`, `clicked`, `dragged`, `level-N`, `type-`). + - For direction-based styling, set `.left/.right/.top/.bottom` heuristic classes as Kando does. + +--- + +## Svelte 5 architecture + +### Packages and layers + +- `@kando-svelte/core` (Svelte library folder inside your app): + - Pure TS math (angles, wedges, clamp) + - Types (mirroring Kando’s `common/index.ts` subset) + - Renderer store/state (selection chain, input state) + - Icon resolver interface + default resolvers +- `@kando-svelte/svelte-components`: + - `PieMenu.svelte`: orchestrates rendering; accepts `root`, `theme`, `options` and emits `select`, `cancel`, `hover`, `unhover`, `movePointer?` events + - `PieItem.svelte`: renders a single `.menu-node` (recursive) + - `SelectionWedges.svelte` and `WedgeSeparators.svelte` + - `CenterText.svelte` (optional) + - Theme layer rendering helpers/snippets +- `@kando-svelte/sveltekit`: + - SvelteKit loaders/adapters for reading theme.json5 and injecting theme.css links + - Vite plugin config for fonts/icons (Material Symbols, Simple Icons) + +### Types and schemata (imported, single source of truth) + +- From `src/common/settings-schemata/` (zod): + - `general-settings-v1.ts`: `GENERAL_SETTINGS_SCHEMA_V1`, type `GeneralSettingsV1` + - `menu-settings-v1.ts`: `MENU_SETTINGS_SCHEMA_V1`, unions for `MenuItemV1`, plus `MenuV1`, `MenuSettingsV1`, and collection schemas + +- From `src/common/index.ts` (renderer-safe types): + - `Vec2`, `ShowMenuOptions`, `MenuThemeDescription`, `SoundType`, `SoundThemeDescription`, `KeyStroke`, `KeySequence` + +- How we use them + - Validation: parse `config.json` and `menus.json` with `GENERAL_SETTINGS_SCHEMA_V1` and `MENU_SETTINGS_SCHEMA_V1`. + - Types: consume `GeneralSettingsV1`, `MenuSettingsV1`, `MenuV1`, `MenuItemV1` exported by those modules (no local copies). + - Extensions: add optional Svelte-only configuration under a namespaced `svelte` object in app code; keep zod schemas unchanged to preserve 1:1 compatibility. + - Imports during dev via aliases: `@kando/schemata/general-settings-v1`, `@kando/schemata/menu-settings-v1`, and `@kando/common`. + +### State and reactivity with Svelte 5 runes + +- `$state` to hold local mutable state: + - `selectionChain`, `hoveredItem`, `clickedItem`, `draggedItem` + - `latestInput` (angle, distance, absolute/relative position, button state) + - `rootPosition`, `showMenuOptions`, `hideTimeout?` +- `$derived` for computed geometry: + - `currentCenterPosition`, `childDirections`, `clampedCenter`, `separatorAngles` + - CSS-friendly properties per item (dir vectors, angle diff) +- `$effect` to: + - Inject and update theme link element and color CSS props when theme changes + - Register/unregister event listeners (pointer/move/up, keydown/up) + +### Component design + +- `PieMenu.svelte` + - Props: `{ root: MenuItem; theme: MenuThemeDescription; options?: Partial; iconResolver?: IconResolver }` + - Emits: `select(path: string, item: MenuItem)`, `cancel()`, `hover(path)`, `unhover(path)` + - Responsibilities: + - Initialize theme (inject theme.css via ``; set CSS registerProperty if available) + - Build `RenderedMenuItem` tree: assign paths `/`, `/0`, …; compute angles & wedges recursively + - Manage selection chain and transforms; set CSS variables and classes on nodes + - Mount optional `SelectionWedges` and `WedgeSeparators` if theme flags are true + - Handle input modes (hover/marking/turbo as configured); expose hooks to plug gesture detector when needed + +- `PieItem.svelte` + - Renders one `.menu-node` with per-theme layers + - Sets static CSS variables (`--dir-x`, `--dir-y`, `--angle`, `--sibling-count`, `--parent-angle?`) + - Updates layer angle props on center (`--pointer-angle`, `--hover-angle`, `--hovered-child-angle`) + - Emits hover/click/drag events up to `PieMenu` + +- `SelectionWedges.svelte` + - Full-screen container setting `--center-x/--center-y`, `--start-angle/--end-angle` on hover + +- `WedgeSeparators.svelte` + - Renders absolute `div.separator` lines rotated to `--angle`, positioned from the center + +### Theme compatibility layer + +At mount or theme change: + +1. Inject `` (or serve via SvelteKit static assets). Remove previous link if present. +2. Register CSS properties if supported: + - `--angle-diff` as `` + - `--pointer-angle`, `--hover-angle`, `--hovered-child-angle` as `` +3. Apply `theme.colors` to `document.documentElement.style.setProperty('--'+name, color)`; on color change, update directly. +4. Build per-item layers according to `theme.layers`: + - If `content === 'icon'`, create an `.icon-container` and append icon element from the icon resolver. + - If `content === 'name'`, set innerText to item name. + +### SvelteKit integration + +- Theme and menu loading strategies: + - Static bundle: copy Kando-exported themes under `static/menu-themes//` and import theme JSON5 via a small loader that resolves `directory` and `id` based on file path. + - User-provided themes at runtime: expose an origin or file-system adapter; compute `file://` or `base`-relative URLs to CSS. +- Fonts and icon CSS: include Material Symbols and Simple Icons CSS in `app.html` or via layout to ensure theme CSS selectors resolve. +- SSR beware: DOM APIs (registerProperty, document.head) only in browser; gate via `onMount` or `$effect` with `browser` guard. + +--- + +## Settings compatibility (Kando → Svelte) + +Adopt Kando’s schemas for plug‑and‑play, but only enforce the subset that affects web rendering/input; keep the rest for round‑trip with Kando’s editor. + +- Must support (renderer behavior) + - Per menu (MENU_SCHEMA_V1): `root`, `centered`, `anchored`, `hoverMode`, `tags`, `conditions` (optional to use), `shortcut`, `shortcutID` (store, usually ignore in web). + - Per item (MENU_ITEM_SCHEMA_V1): `type`, `name`, `icon`, `iconTheme`, `children`, `angle`, `data` (opaque to renderer; host app executes). + - General (GENERAL_SETTINGS_SCHEMA_V1): + - Visuals/timing: `zoomFactor`, `fadeInDuration`, `fadeOutDuration`. + - Input/interaction: `centerDeadZone`, `minParentDistance`, `dragThreshold`, `enableMarkingMode`, `enableTurboMode`, `hoverModeNeedsConfirmation`, `gestureMinStrokeLength`, `gestureMinStrokeAngle`, `gestureJitterThreshold`, `gesturePauseTimeout`, `fixedStrokeLength`, `rmbSelectsParent`, `enableGamepad`, `gamepadBackButton`, `gamepadCloseButton`, `sameShortcutBehavior`. + - Theming/sounds: `menuTheme`, `darkMenuTheme`, `enableDarkModeForMenuThemes`, `menuThemeColors`, `darkMenuThemeColors`, `soundTheme` (optional in web), `soundVolume`. + +- Optional (web nice‑to‑have) + - Sound themes with Howler honoring `soundTheme` and `soundVolume`. + - Dark‑mode switch honoring `enableDarkModeForMenuThemes` + system theme. + +- Store but ignore in Svelte (Electron/OS chrome) + - `locale`, `showIntroductionDialog`, `settingsWindowColorScheme`, `settingsWindowFlavor`, `trayIconFlavor`, `hardwareAcceleration`, `lazyInitialization`, `hideSettingsButton`, `settingsButtonPosition`, `keepInputFocus`, `warpMouse`, `enableVersionCheck`, `useDefaultOsShowSettingsHotkey`. + - `conditions` may be evaluated only if the host app provides a mapping (e.g., Micropolis in‑game context) – otherwise ignore in the browser. + +- Svelte/web extensions + - Namespaced under `svelte`, e.g.: + - `svelte: { actionDispatcher?: (item) => void; mountSelector?: string; pointerLock?: boolean }` + - Keep extensions additive; never mutate Kando’s fields. + +- Loader responsibilities + - Accept Kando settings and menus unmodified. + - Pick a menu by name/tag; optionally match `conditions` via host‑provided predicates. + - Resolve theme JSON5 → `{ id, directory }` and inject theme CSS; apply `menuThemeColors` with dark‑mode variant when enabled. + +- Decision + - Import entire Kando schemas; use the renderer/input/theming subset; ignore OS/Electron chrome; add namespaced Svelte extensions. This keeps you interoperable with Kando’s editor and future schema updates. + +--- + +## Code re‑use inventory (what to import vs rewrite) + +Maximize reuse by importing Kando source where it’s renderer/platform‑agnostic; rewrite only DOM/Svelte bits. + +- Directly reusable (portable TS) + - `src/common/math/index.ts`: vectors, angles, wedges, clamping. + - `src/common/index.ts` (renderer‑safe types): `Vec2`, `ShowMenuOptions`, `MenuThemeDescription`, `SoundType`, `SoundThemeDescription`, `KeyStroke/KeySequence`. + - `src/common/settings-schemata/*.ts`: zod schemas for menus and general settings. + - `src/menu-renderer/input-methods/input-method.ts`: base input contracts (callbacks only). + - `src/menu-renderer/input-methods/gesture-detector.ts`: geometry + timers; replace Node `EventEmitter` with a tiny dispatcher. + - `src/menu-renderer/input-methods/gamepad.ts`: Web Gamepad API; same emitter note. + - `src/menu-renderer/sound-theme.ts`: Howler wrapper (guard in client‑only lifecycle). + +- Reuse with small shims + - `src/menu-renderer/menu-theme.ts`: + - Keep: CSS property registration, `loadDescription`, `setColors`, child/center angle property setters. + - Adapt: layer DOM creation (`createItem`) to Svelte components/refs; set CSS vars via bindings, not `querySelector`. + - `src/menu-renderer/selection-wedges.ts`, `wedge-separators.ts`: re‑express as Svelte components emitting the same DOM/CSS variables. + +- Rewrite in Svelte + - `src/menu-renderer/menu.ts`: imperative DOM, transforms, classes, connectors. Rebuild as component state + derived props: + - Keep algorithms (selection chain, connectors smoothing) but compute in `$derived` and bind to style/class. + - Icon registry (`src/common/icon-themes/*`): implement a web resolver (Material Symbols, Simple Icons, user URLs/data URIs). System/file themes via backends are not available in browser. + - IPC (`menu-window-api.ts`, `common-window-api.ts`): replace with Svelte events/props. + +- Not applicable (skip) + - `src/main/**` (Electron host, backends, actions, tray, notifications, settings window). + +- Portability highlights + - Gesture recognizer: portable with an emitter shim; thresholds map 1:1 from settings. + - Layout algorithms: fully portable (in `common/math`). + - Connectors: reuse `getClosestEquivalentAngle` and accumulate last angles; bind width/rotation via style. + - Theme engine: reuse angle/child property logic and color application; wire to Svelte refs. + - Sounds: Howler works in browser/Electron; init only on client. + +- Optional refactor upstream (improves reuse) + - Extract a `kando-core` workspace package exporting: `common/math`, renderer‑safe `common` types, zod schemas, theme helpers (angle smoothing). + - Split `menu-theme.ts` into DOM‑free helpers and DOM‑bound layer builders. + - Replace `EventEmitter` in renderer utilities with a minimal event interface usable in browsers. +--- + +## Input model (pointer first) + +Define an `InputState` compatible with Kando: + +```ts +export enum ButtonState { Released, Clicked, Dragged } +export type InputState = { + button: ButtonState; + absolutePosition: Vec2; + relativePosition: Vec2; + distance: number; + angle: number; +}; +``` + +Pointer/touch handlers should: + +- Maintain `clickPosition`, `keydownPosition` (for turbo/hover modes later) and `centerPosition` (current submenu center) to compute `relativePosition` and `angle`. +- Switch to `Dragged` when `dragThreshold` exceeded (Marking mode), or when modifiers pressed (Turbo) beyond threshold. +- Emit selection on pointer up (Click) or when gesture detector fires (Marking/Turbo), with `SelectionType` hint: ActiveItem/SubmenuOnly/Parent. + +Gesture detector (optional v1) mirrors Kando’s: min stroke length, jitter threshold, pause timeout, and distance-based selection via `fixedStrokeLength`. + +--- + +## Callbacks and host API (PieTree) + +`` uses function callbacks instead of DOM events. All callbacks receive a single discriminated-union context as defined below: + +- `onOpenCtx(ctx)` +- `onCloseCtx(ctx)` +- `onCancelCtx(ctx)` +- `onHoverCtx(ctx)` +- `onPathChangeCtx(ctx)` +- `onMarkCtx(ctx)` +- `onSelectCtx(ctx)` + +Props/control (subset): `root`, `center`, `radiusPx`, `settings`, `layers`, `centerTextWrapWidth`, `drawChildrenBelow`, `labelsEnabled`, `startPressed`, `initialPointer`, `initialTarget`, `resolveTarget`. + +Notes: +- The library closes automatically after leaf selection and after cancel; the app never calls cancel to hide the popup. +- `labelsEnabled` defaults to false to avoid first‑popup text flash; when true, name layers render. + +--- + +## Example usage (SvelteKit) + +```svelte + + + +{#if menu && theme} + +{/if} +``` + +Theme loader should parse JSON5, set `id` from parent dir, and `directory` to that dir path for resolving `theme.css` as `file:////theme.css` or app-relative URL. + +--- + +## Micropolis integration notes + +- Micropolis can author menus with Kando’s editor and export: + - Menus: place JSON under `static/menus/` or fetch from a CMS + - Themes: place under `static/menu-themes//` with unmodified `theme.json5` + `theme.css` +- Use Micropolis-specific action dispatchers for 'command'/'uri'/'hotkey' etc., or translate to game engine events. +- Consider turning on `centered` and `anchored` for console/controller UX. + +--- + +## Edge cases and risks + +- Fixed angles must be monotonically increasing after normalization; if not, ignore later duplicates—match Kando’s `fixFixedAngles`. +- Theme engineVersion mismatch: warn and attempt best-effort (engine v1 expected). +- SSR pitfalls: only inject theme CSS in browser; guard API usage. +- Icons: Material/Simple Icons versions and CSS class names must match the theme’s expectations; include appropriate CSS. +- System icons: not available in the browser; provide stub icon theme or map names to app-provided assets. + +--- + +## Roadmap (post-v1) + +- Full gesture support (Marking/Turbo) with configurable thresholds +- Gamepad input with stick hover and button mapping +- Sound themes (Howler) with `SoundThemeDescription` +- Theme editor preview inside Svelte (read-only) +- Menu editor (longer-term; keep Kando as the primary authoring tool for now) + +--- + +## PieTree callbacks and context (Svelte) + +This section defines the Svelte-friendly, function-based callback API for interactive pie menus. It complements Kando’s behavior while avoiding DOM event complexity. + +### Lifecycle and roles + +- PieTree: one interactive popup instance representing a menu tree. Created on demand; closes itself after select or cancel. +- Multiple previews: use a non-interactive preview component (future) for theme/menu browsers and editors; keep PieTree for tracking. + +### Transport: plain function callbacks + +- No DOM CustomEvents; the host app passes functions as props to `PieTree`. +- Every callback receives a single, strongly typed context object describing pointer, pie, menu, target, and (if applicable) item. + +### Context types (discriminated union) + +```ts +type Mods = { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }; + +type Pointer = { + clientX: number; clientY: number; + dx: number; dy: number; + distance: number; angle: number; + button: 0|1|2|3|4; + mods: Mods; + // Input origin and keyboard details (when opened via keyboard) + source: 'mouse' | 'touch' | 'keyboard' | 'gamepad'; + key?: string; // e.g., 'K', 'Enter', 'ArrowRight' + code?: string; // e.g., 'KeyK', 'Enter', 'ArrowRight' + repeat?: boolean; // repeated keydown + location?: number;// KeyboardEvent.location (0: standard, 1: left, 2: right, 3: numpad) +}; + +type PieCtx = { + center: { x: number; y: number }; + radius: number; + chain: number[]; // current selection chain + hoverIndex: number; // -1 center, -2 parent, >=0 child index +}; + +type MenuCtx = { + item: MenuItem; // current (owning) menu item + indexPath: number[]; // path to current menu +}; + +type ItemCtx = { + item: MenuItem; index: number; path: string; + name?: string; data?: unknown; id?: string; +}; + +type BaseCtx = { + kind: + | 'open' | 'close' | 'cancel' + | 'hover' | 'path-change' + | 'mark-start' | 'mark-update' | 'mark-select' + | 'turbo-start' | 'turbo-end' + | 'select'; + time: number; + pointer: Pointer; + pie: PieCtx; + menu: MenuCtx; + target: unknown; // current context target + targetRoot?: unknown; // initial target + // Optional advanced: + targetStack?: unknown[]; +}; + +type HoverCtx = BaseCtx & { kind: 'hover'; item?: ItemCtx }; +type PathCtx = BaseCtx & { kind: 'path-change'; op: 'push'|'pop'; item?: ItemCtx }; +type MarkCtx = BaseCtx & { kind: 'mark-start'|'mark-update'|'mark-select' }; +type TurboCtx = BaseCtx & { kind: 'turbo-start'|'turbo-end' }; +type SelectCtx = BaseCtx & { kind: 'select'; item: ItemCtx }; +type OpenClose = BaseCtx & { kind: 'open'|'close'|'cancel' }; +``` + +### Callback contracts + +- onOpen(ctx: OpenClose) + - Fired when PieTree becomes interactive. +- onClose(ctx: OpenClose) + - Fired after select/cancel; the popup is already hidden. +- onCancel(ctx: OpenClose) + - RMB cancel (when not selecting parent), back/escape, or app-requested cancel. +- onHover(ctx: HoverCtx) + - Fired when hovered target changes (child/back/center), not on every move. +- onPathChange(ctx: PathCtx) + - Fired after selection chain changes: + - op='push' when entering a submenu (selected child had children) + - op='pop' when going back (parent) + - Use for submenu “select/deselect” side-effects (e.g., mutate target). +- onMark(ctx: MarkCtx) + - mark-start: entering drag (or turbo drag) + - mark-update: throttled gesture progress + - mark-select: corner/pause caused submenu selection (GestureDetector) +- onTurbo(ctx: TurboCtx) + - Modifier-based drag toggles +- onSelect(ctx: SelectCtx) + - Leaf selected. Library will close immediately after returning from this handler. + +### Target inheritance/override + +- PieTree accepts `initialTarget?: unknown` and optional `resolveTarget?: (current: unknown, item: MenuItem) => unknown`. +- Maintains a `targetStack` aligned to `pie.chain`: + - push on submenu entry: resolver can return a new target or keep existing + - pop on back: restore parent target +- `ctx.target` reflects the active target for every callback. + +### Triggers and opening + +- Browser/Svelte: host app detects triggers (e.g., RMB) and mounts one PieTree at the pointer with `{ startPressed, initialPointer }`. +- Native/Electron (later): native layer opens the overlay; the same props drive PieTree. + +### Design rationale + +- Single, typed context object keeps the API stable and expressive. +- No DOM events/bubbling: simpler, testable, and app-controlled. +- Symbolic actions in `item.data` drive application behavior; no functions in JSON. + +--- + +## Keyboard, focus, and accessibility (plan) + +Goal: first‑class keyboard navigation and screen‑reader support without global key capture. Leverage ARIA and focus semantics so assistive tech can follow state changes. + +### Scope and event model + +- Scope keyboard to the PieTree overlay (focused container). Do not attach global handlers, except letting Escape bubble if desired. +- Keep all other input on the focused menu container or items (no window‑level listeners). Pointer/gamepad continue to work in parallel. + +### Roles and structure + +- Container (interactive): + - role="menu" (context menu), aria‑label + - Focus trap on open; restore previous focus on close + - Focus model (choose one): + - Roving tabindex: tabindex=0 on the active item; −1 on others; container handles keys + - Or aria‑activedescendant: focus stays on container; set aria‑activedescendant to active item id +- Items: + - role="menuitem" (or menuitemcheckbox/menuitemradio) + - Submenu parents: aria‑haspopup="menu"; aria‑expanded=true when open + - aria‑disabled for unavailable entries; ensure readable names + +### Key bindings (overlay focused) + +- ArrowLeft/ArrowRight: move active item clockwise/counterclockwise +- ArrowUp/ArrowDown: optional vertical variants +- Enter/Space: select active item +- ArrowRight/Enter on submenu parent: open submenu (push chain) +- ArrowLeft/Backspace/Delete: go to parent (pop chain) +- Escape: close entire tree immediately +- Tab: either preventDefault (arrow‑driven) or confine to container + +### Submenus and chain updates + +- Push: set aria‑expanded on parent; choose initial active in new level; optionally announce +- Pop: clear aria‑expanded on previous parent; recompute active in restored level + +### Screen readers + +- Stable readable names; reflect current item via roving tabindex or aria‑activedescendant +- Keep announcements terse; avoid frequent updates on pointer hover + +### Implementation notes + +- PieTree: focus container on open; trap focus; restore on close; maintain activeIndex per level; harmonize pointer and keyboard +- PieItem: stable ids if activedescendant used; roles and aria attributes for submenu parents +- Pointer + keyboard: pointer hover sets activeIndex; keys move it; selection logic identical + +### Why not global capture + +- Global handlers bypass AT and can collide with app shortcuts. Scoped handlers on a focused container yield predictable behavior and better a11y. + + +## License and provenance + +This project intentionally reuses—and where practical, copies—Kando’s source code, APIs, schemas, algorithms, and contracts to maximize compatibility and ease of synchronization. Preserve original copyright headers and SPDX identifiers, retain license notices, and attribute the Kando project and its author. Kando is licensed under MIT; theme assets and fonts carry their own licenses (e.g., CC0-1.0 for the default theme, Material Symbols, Simple Icons). Ensure all copied files keep their original licenses and attributions. + +--- +### Addendum: direct import strategy (preferred) + +Where possible, import Kando source directly instead of reimplementing: + +- Import as‑is + - `@kando/common/math/*` → `../src/common/math/*` + - `@kando/common/*` (renderer‑safe types only) → `../src/common/*` + - `@kando/schemata/*` → `../src/common/settings-schemata/*` + - `@kando/gesture` → `../src/menu-renderer/input-methods/gesture-detector` + - `@kando/gamepad` → `../src/menu-renderer/input-methods/gamepad` + - `@kando/sound-theme` → `../src/menu-renderer/sound-theme` + +- Minimal shims + - `events` (Node EventEmitter) used by gesture/gamepad: alias to a tiny emitter or bundle a small emitter polyfill so imports work unchanged. + - `menu-theme.ts`: import and use `loadDescription`, `setColors`, `setChildProperties`, `setCenterProperties`; avoid `createItem` (Svelte builds layers in markup). + +- Path aliases (dev in monorepo) + - kando-svelte/tsconfig.json: + ```json + { + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@kando/common/*": ["../src/common/*"], + "@kando/schemata/*": ["../src/common/settings-schemata/*"], + "@kando/gesture": ["../src/menu-renderer/input-methods/gesture-detector.ts"], + "@kando/gamepad": ["../src/menu-renderer/input-methods/gamepad.ts"], + "@kando/sound-theme": ["../src/menu-renderer/sound-theme.ts"] + } + } + } + ``` + - kando-svelte/vite.config.ts: + ```ts + import { defineConfig } from 'vite'; + import { fileURLToPath } from 'node:url'; + + const r = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + + export default defineConfig({ + resolve: { + alias: { + '@kando/common': r('../src/common'), + '@kando/schemata': r('../src/common/settings-schemata'), + '@kando/gesture': r('../src/menu-renderer/input-methods/gesture-detector.ts'), + '@kando/gamepad': r('../src/menu-renderer/input-methods/gamepad.ts'), + '@kando/sound-theme': r('../src/menu-renderer/sound-theme.ts'), + // optional: alias 'events' to a tiny emitter polyfill if needed + } + } + }); + ``` + +- Publishing strategy + - For npm publishing, either extract a shared `kando-core` workspace package that re‑exports these files and depend on it, or bundle the imported sources into the kando‑svelte build so consumers don’t need the monorepo. + +- Math: import directly (preferred) + - Use `import * as math from '@kando/common/math';` rather than reimplementing. The earlier “Porting Kando math” sketch is only a fallback if decoupling is required. +--- +## Implementation TODOs (tracker) + +Current status: Engine scaffolding is in progress; other items pending. + +- [ ] Create engine scaffolding: state store, interfaces, event contracts +- [ ] Implement wedge geometry using Kando math (angles, rings, gaps) +- [ ] Add pointer input: track origin, angle, radius; compute hovered wedge +- [ ] Render root ring with hover highlight and center zone +- [ ] Dispatch select/cancel events on release; keyboard escape +- [ ] Implement selection chain and child rings navigation +- [ ] Map theme layers/colors to CSS variables; inject theme.css +- [ ] Hook sounds (Howler) for open/hover/select/cancel +- [ ] Wire demo to show interactive menu, theme switcher, logs + +These align 1:1 with the library/demo plan above and should remain in sync with commit messages. + +--- + +## Code reuse summary and shims (concise) + +Import as-is (no changes required) +- `src/common/math/index.ts`: `computeItemAngles`, `computeItemWedges`, `normalizeConsequtiveAngles`, `scaleWedge`, `isAngleBetween`, `getClosestEquivalentAngle`, `getAngle`, `getDistance`, clamps, etc. +- `src/common/index.ts`: `Vec2`, `ShowMenuOptions`, `MenuThemeDescription`, `SoundType`, `SoundThemeDescription`, `KeyStroke`, `KeySequence`. +- `src/common/settings-schemata/general-settings-v1.ts` and `menu-settings-v1.ts`: `GENERAL_SETTINGS_SCHEMA_V1`, `MENU_SETTINGS_SCHEMA_V1` and inferred types `GeneralSettingsV1`, `MenuSettingsV1`, `MenuV1`, `MenuItemV1` (used for validation and types; no local copies). + +Import with tiny shim (browser-friendly) +- `src/menu-renderer/input-methods/gesture-detector.ts` (`GestureDetector`) and `gamepad.ts` (`Gamepad`) depend on Node `events`. + - Shim: provide an `events` alias to a minimal emitter (or `eventemitter3`) so imports resolve unchanged. + - Justification: keeps the original classes intact and importable; avoids touching upstream code; bundler-only change. + - Where used: `PointerInput` and `GamepadInput` directly import these; Svelte code wires DOM events to `PointerInput` and subscribes to `GestureDetector`/`Gamepad` events. + +Adapt/wrap in Svelte (DOM-bound in Kando) +- `src/menu-renderer/menu.ts` (`Menu`): selection chain, DOM building, and event wiring are tightly coupled. We mirror behavior in `PieMenu.svelte` + child components, preserving semantics (push/pop chain, `selectItem` logic, hover/click/drag states). +- `src/menu-renderer/rendered-menu-item.ts` (`RenderedMenuItem`): contains DOM fields (`nodeDiv`, `connectorDiv`). We use a DOM-free state type in Svelte and bind styles/classes via props. +- `src/menu-renderer/menu-theme.ts`: keep semantics of `loadDescription`, `setColors`, `setChildProperties`, `setCenterProperties` but perform them through Svelte refs and our `theme-loader` instead of imperative DOM creation. +- `src/menu-renderer/selection-wedges.ts`, `wedge-separators.ts`: implemented as `SelectionWedges.svelte` and `WedgeSeparators.svelte`. +- `src/common/icon-themes/*`, `icon-theme-registry.ts`: relies on `window.commonAPI` and system/file icon packs. We expose a web `IconResolver` that supports Material Symbols and Simple Icons; host apps can add resolvers. + +Environment-specific adjustments +- `src/menu-renderer/sound-theme.ts` (`SoundTheme`): original builds `file://` URLs and logs via `window.commonAPI`. In the browser we construct app-relative URLs and guard Howler usage to client-only lifecycle. +- CSS property registration: we keep `CSS.registerProperty` where available and fall back to `document.documentElement.style.setProperty`. + +Why this approach +- Preserves Kando as single source of truth for algorithms and schemas. +- Minimizes drift by importing core logic and only re-expressing DOM and reactivity in Svelte 5. +- Shims are bundler-level and upstream-friendly (no invasive edits to Kando source). + +--- +## Configuration parity with Kando (config.json, menus.json) + +Kando stores settings in two JSON files: `config.json` (general) and `menus.json` (menus/collections). The Svelte integration should accept the same shapes and semantics, with web‑appropriate loading and validation. + +- Where to load from + - Web/SvelteKit: serve these files from `static/` (public) or fetch from your own backend. Keep the exact file names so exports from Kando can be dropped in. + - Electron (optional): you can also point at the OS config directory, but for Micropolis/SvelteKit, prefer app‑local assets. + +- Hot reload and validation + - Use the existing zod schemas from Kando (`GENERAL_SETTINGS_SCHEMA_V1`, `MENU_SETTINGS_SCHEMA_V1`) to validate on load. + - On save/HMR, re‑validate; if invalid, log and ignore the change (don’t update state), mirroring Kando’s behavior. + - Optional: show a non‑blocking banner if the last change failed validation. + +- General settings (config.json) + - Import and honor all renderer/input/theming fields (see Settings compatibility above). Defaults match Kando’s docs (e.g., `zoomFactor: 1`, `centerDeadZone: 50`, `dragThreshold: 15`, fade timings, gesture thresholds, etc.). + - Ignored in web (but preserved): Electron window chrome settings, tray icon flavor, OS hotkey handling. + - Dark mode: if `enableDarkModeForMenuThemes` is true, switch theme and color overrides when `window.matchMedia('(prefers-color-scheme: dark)')` changes. + +- Menus (menus.json) + - Accept the full structure: `{ version, menus: Menu[], collections: MenuCollection[] }`. + - Per‑menu properties: `root`, `shortcut`, `shortcutID`, `centered`, `anchored`, `hoverMode`, `conditions`, `tags`. + - `shortcut/shortcutID`: store for round‑trip, usually ignore in the web demo. + - `conditions`: allow app‑provided predicates (Micropolis can map in‑game state to “appName/windowName/screenArea” analogs). Otherwise ignore. + - Collections: accept and surface as tags/filters in the demo UI if desired. + +- Menu items (types and data) + - Supported types map as follows in Svelte: + - `submenu`: structure only. + - `uri`: open via `window.open` (respect `rel=noopener` and user gesture policies). + - `text`: copy/paste or in‑app paste actions (browser clipboard API). + - `redirect`: navigate to a different menu path. + - `command`/`file`/`hotkey`/`macro`/`settings`: require host integration (Electron/Kando backends) or Micropolis‑specific adapters. In the Svelte demo, stub with no‑ops or console messages; document how Micropolis implements these. + - `angle` field follows Kando’s monotonic fixed‑angle rules; reuse `fixFixedAngles` and `computeItemAngles`. + - `delayed` semantics (execute after fade‑out) remain; in the browser demo, simulate by deferring handlers until the close animation ends. + +--- +## Themes, sounds, and icons (concrete handling) + +- Menu themes + - Directory layout: `/{ theme.json[5], theme.css, preview.jpg? }`. + - Load `theme.json` (JSON/JSON5), set `id` from folder name and `directory` to parent path; inject `theme.css` via ``. + - Color overrides: apply `menuThemeColors[theme.id]` from `config.json` as CSS custom properties; support dark‑mode variant when enabled. + - Theme selection UI in demo: list subfolders in `static/menu-themes/` (or from an index file) and allow switching at runtime. + +- Sound themes (Howler) + - Directory layout: `/theme.json[5] + audio files`. + - Use Howler to play `SoundType` → file mappings; support per‑sound `volume`, `minPitch`, `maxPitch`, and global `soundVolume`. + - Safari/iOS unlock: call a muted play() on first user gesture to resume the audio context. + - Optional preloading: warm up key sounds on app load. + +- Icon themes + - Built‑ins: Material Symbols Rounded, Simple Icons (plain/colored), Emoji, Base64/URL. Load needed CSS/fonts in `app.html`. + - System/file icon themes require platform backends; in web demo, provide a “user theme” bucket (URLs or project assets). Keep the same `iconTheme` names for drop‑in compatibility. + - Adaptive colors: use `currentColor` in SVGs to let theme CSS recolor icons, as per Kando docs. + +--- +## Practical defaults for the demo + +- Place Kando exports in: + - `static/menus/` (menus.json or multiple named menus) + - `static/menu-themes//` (theme.json5 + theme.css) + - `static/sound-themes//` (theme.json5 + wav/ogg) +- Demo page shows: + - Active theme selector (reads folders and switches ``) + - Active menu selector (reads menus list) + - Sound theme toggle + volume slider + - Live JSON validator result (valid/invalid) +- Execution adapters: + - Implement `uri`, `text`, `redirect` in browser. + - Expose an interface for Micropolis to provide adapters for `command`, `file`, `hotkey`, `macro`, `settings`. + +--- +## Recommendations extracted from Kando docs (TL;DR) + +- Keep zod validation in the load path and block state updates on invalid JSON. +- Mirror defaults exactly; users expect the same feel (dead zone 50px, thresholds, fades). +- Treat dark‑mode as a theme switch, not a stylesheet filter; support separate color overrides. +- Preload sounds and use small pitch randomization for UI polish (as in Kando). +- Prefer SVG icons with `currentColor` for theme‑driven recoloring; fall back to PNG for external packs. +- For conditions and OS integrations, make them host‑provided so the same menus can run in Micropolis or Electron without Svelte changes. +--- + +## Implementation plan: kando-svelte library and static kando-svelte-demo + +1) Monorepo and workspaces +- Root package.json: add workspaces for "kando-svelte" and "kando-svelte-demo". +- Use npm workspaces; keep library and demo independent (lib has no adapter, demo uses adapter-auto). + +2) kando-svelte (library) +- Goal: publishable Svelte lib exposing PieMenu and helpers, reusing Kando code directly. +- Imports (direct): add TS/Vite aliases to reference Kando’s sources: + - @kando/common/* -> ../src/common/* (types + math) + - @kando/schemata/* -> ../src/common/settings-schemata/* (zod) + - @kando/gesture -> ../src/menu-renderer/input-methods/gesture-detector.ts + - @kando/gamepad -> ../src/menu-renderer/input-methods/gamepad.ts + - @kando/sound-theme -> ../src/menu-renderer/sound-theme.ts +- Minimal emitter shim: if needed, alias Node EventEmitter to a tiny emitter so gesture/gamepad can import unchanged. +- Components: + - src/lib/PieMenu.svelte: orchestrates theme inject, selection chain, input, events; emits select/cancel/hover. + - src/lib/PieItem.svelte: renders a single node; binds CSS vars (--dir-x/--dir-y/--angle/--sibling-count/--parent-angle). + - src/lib/SelectionWedges.svelte & src/lib/WedgeSeparators.svelte: global visuals driven by CSS vars. +- Theme engine: + - Reuse menu-theme.ts functions: loadDescription, setColors, setChildProperties, setCenterProperties; set vars via bind:this refs. + - Inject theme.css and apply color overrides. +- Math & geometry: + - Use @kando/common/math directly: computeItemAngles, computeItemWedges, clampToMonitor, etc. +- Sounds: + - Wrap Howler usage from @kando/sound-theme; guard in onMount. +- Types & settings: + - Export renderer-safe types mirroring Kando (Vec2, MenuItem, MenuThemeDescription, ShowMenuOptions). + - Accept settings subset via props; provide defaults matching Kando. +- Packaging: + - Configure svelte-package output (dist), proper exports map; exclude dev-only aliases from published build or bundle imported TS. + +3) kando-svelte demo (inside the library project) +- Demo lives under `kando-svelte/src/routes` and `kando-svelte/static`. +- Static snapshot layout under `kando-svelte/static/kando/`: + - config.json (Kando general settings) + - menus.json (Kando menus/collections) + - menu-themes//{ theme.json5, theme.css, preview.jpg? } + - sound-themes//{ theme.json5, *.wav/ogg } +- Loaders/utilities: + - Fetch and validate config.json / menus.json using zod schemas (@kando/schemata/*). + - Discover themes (list subfolders) and inject selected theme.css; apply color overrides + dark-mode variants. + - Load sound theme (Howler) and set master volume. +- Demo UI: + - Theme selector (dropdown), sound theme + volume, menu selector (from menus.json), validity indicator. + - Render +- Execution adapters: + - Implement browser-safe handlers for uri/text/redirect. + - Surface optional hooks for Micropolis to provide command/file/hotkey/macro/settings behaviors. +- SPA behavior: + - SSR guards for DOM/Howler; prerender true is fine if data is static. + +4) Direct-import configuration +- kando-svelte/tsconfig.json: add paths for @kando/common, @kando/schemata, @kando/gesture, @kando/gamepad, @kando/sound-theme. +- kando-svelte/vite.config.ts: add matching resolve.alias; optionally alias 'events' to a tiny emitter. +- For publishing: either bundle imported Kando sources into the lib build or extract a shared workspace package (kando-core) exporting common/math/schemata. + +5) Selection & input details (parity) +- Build selection chain; compute child angles/wedges; clamp center; accumulate last connector angles (closest-equivalent) to avoid 360° flips. +- Pointer/touch: hover hit-testing vs wedges; Dragged threshold; Hover/Marking/Turbo modes and gesture detector thresholds. +- Keyboard shortcuts (optional): numeric/alpha to select children; Backspace selects parent; Escape cancels. +- Gamepad: stick → relative position; back/close buttons; center anchored placement. + +6) Validation & hot reload in demo +- On each snapshot file change (dev), refetch and validate; if valid, update state; if invalid, show error and keep prior state. +- Include a compact error viewer to mirror Kando’s console output. + +7) Testing +- Reuse Kando’s math tests (test/math.spec.ts) to verify ported/aliased math; adapt harness to Vitest in the demo if desired. +- Add a few component tests (rendering classes/vars) later. + +8) Build & run +- Lib: npm run -w kando-svelte package (svelte-package) +- Demo: npm run -w kando-svelte-demo dev -- --open +- Workspaces: root npm install to link workspace deps; demo depends on "kando-svelte": "workspace:*". + +9) Future steps +- Icon resolver for user-provided icon packs; optional system icon bridge in Electron. +- Theme editor preview (read-only) and ultimately a Svelte menu editor. +- Micropolis adapter package implementing command/file/hotkey/macro. +--- + +## Terminology and callback model + +The model is: + +- Components: `PieTree`, `PieMenu`, `PieItem`. +- Callbacks: plain function props on `PieTree` only. No DOM `CustomEvent`s, no component instances passed out. +- Context: a single "kitchen-sink" discriminated union object with shared fields and a `kind` discriminator. + +Shared context shapes used across all callbacks: + +```startLine:endLine:kando-svelte/src/lib/PieTree.svelte +// PointerCtx, PieCtx, MenuCtx, ItemCtx, BaseCtx (see source for full types) +``` + +Concrete callback variants (subset): `open | close | cancel | hover | path-change | mark-start | mark-update | mark-select | turbo-start | turbo-end | select`. + +Example signatures implemented in `PieTree.svelte`: + +```startLine:endLine:kando-svelte/src/lib/PieTree.svelte +export let onOpenCtx: ((ctx: OpenCloseCtx) => void) | null = null; +export let onCloseCtx: ((ctx: OpenCloseCtx) => void) | null = null; +export let onCancelCtx: ((ctx: OpenCloseCtx) => void) | null = null; +export let onHoverCtx: ((ctx: HoverCtx) => void) | null = null; +export let onPathChangeCtx: ((ctx: PathCtx) => void) | null = null; +export let onMarkCtx: ((ctx: MarkCtx) => void) | null = null; +export let onSelectCtx: ((ctx: SelectCtx) => void) | null = null; +``` + +Notes: +- We never pass Svelte components or DOM nodes to clients; `ItemCtx/MenuCtx/PieCtx` expose the necessary state in a type-safe way. +- Keyboard/gamepad integration extends `PointerCtx` with keys/locations while keeping the same callback surface. + diff --git a/kando-svelte/notes/kando-web-component.md b/kando-svelte/notes/kando-web-component.md new file mode 100644 index 000000000..37f19e4f2 --- /dev/null +++ b/kando-svelte/notes/kando-web-component.md @@ -0,0 +1,164 @@ +## Web Components with Svelte — Critiques, Support, Caveats, and Direction + +### Summary of Rich Harris’s criticisms (condensed) +- Progressive enhancement: Web Components (CEs) don’t SSR and don’t render without JS, so they can’t progressively enhance document UX like plain HTML or framework‑SSR can. Using CEs for navigation or non‑interactive chrome is a bad fit. +- CSS in JS/string styles: Shadow DOM typically implies injecting styles via ` diff --git a/kando-svelte/src/lib/components/KandoWrapper.svelte b/kando-svelte/src/lib/components/KandoWrapper.svelte new file mode 100644 index 000000000..34e7c5cf6 --- /dev/null +++ b/kando-svelte/src/lib/components/KandoWrapper.svelte @@ -0,0 +1,742 @@ + + +
+ + + diff --git a/kando-svelte/src/lib/components/MenuOutline.svelte b/kando-svelte/src/lib/components/MenuOutline.svelte new file mode 100644 index 000000000..b221c0d86 --- /dev/null +++ b/kando-svelte/src/lib/components/MenuOutline.svelte @@ -0,0 +1,28 @@ + + +
  • + + + {item.name} ({item.type}{item.angle != null ? ` @ ${item.angle}°` : ''} + {item.icon ? `, icon: ${item.icon}` : ''} + {item.iconTheme ? `, theme: ${item.iconTheme}` : ''}) + + + {#if item.children?.length} + +
      + {#each item.children as c} + + {/each} +
    + + {/if} + +
  • diff --git a/kando-svelte/src/lib/components/PieItem.svelte b/kando-svelte/src/lib/components/PieItem.svelte new file mode 100644 index 000000000..ed3651e2d --- /dev/null +++ b/kando-svelte/src/lib/components/PieItem.svelte @@ -0,0 +1,158 @@ + + +
    + + +
    + + + {#if below} + + {@render below({ index: belowIndex ?? 0 })} + + {/if} + + {#if layers && layers.length} + + {#each [...layers].reverse() as layer} + +
    + + {#if layer.content === 'name'} + + {item.name} + + {:else if layer.content === 'icon'} + + + + {/if} + +
    + + {/each} + + {:else} + + + + + + {/if} + + {#if content} + + {@render content({})} + + {/if} + +
    + + diff --git a/kando-svelte/src/lib/components/PieMenu.svelte b/kando-svelte/src/lib/components/PieMenu.svelte new file mode 100644 index 000000000..beee98084 --- /dev/null +++ b/kando-svelte/src/lib/components/PieMenu.svelte @@ -0,0 +1,448 @@ + + +{#snippet RenderGrandchildren({ index }: { index: number })} + + {#if (item?.children?.[index] as any)?.children?.length} + + {#if centerStateClasses === 'parent'} + + + + + {:else} + + {#each grandAnglesPreviewByChild[index] as gAng, j} + + + + {/each} + + {/if} + + {/if} + +{/snippet} + +{#snippet RenderChildren()} + + {#each item?.children ?? [] as c, i} + + {#if !(centerStateClasses === 'parent' && i === hoveredIndex)} + + + + {/if} + + {/each} + +{/snippet} + +{#snippet RenderActiveCenter()} + + {#if hoveredIndex >= 0 && (item?.children?.[hoveredIndex] as any)?.children?.length} + + + + {/if} + +{/snippet} + +{#snippet RenderActiveGrandchildren({ index }: { index: number })} + + {#each (item as any).children[index].children ?? [] as gc, j} + + + + {/each} + +{/snippet} + +{#snippet RenderParentBelow()} + + {@render RenderChildren()} + {@render RenderActiveCenter()} + +{/snippet} + +{#snippet CenterContent()} + + {#if renderChildren && !drawChildrenBelow} + {@render RenderChildren()} + {/if} + + {#if centerStateClasses === 'parent' && !drawChildrenBelow} + {@render RenderActiveCenter()} + {/if} + + {#if drawCenterText && centerStateClasses === 'active'} + + + + {/if} + + + {centerLabel} + + +{/snippet} + +{#snippet ParentNubsContent()}{/snippet} + +{#if centerReady} + +
    + + {#if drawWedgeSeparators} + + {/if} + + = 0 ? childAngles[hoveredIndex] : (parentAngle != null ? (parentAngle + 180) % 360 : null)} + parentHovered={hoverIndex === -2} + transformStyle={`translate(${center.x}px, ${center.y}px)`} + childDistancePx={radiusPx} + dataPath={'/'} + dataLevel={0} + layers={(layers as any) ?? [{ class: 'icon-layer' }]} + connectorStyle={connectorStyle} + below={drawChildrenBelow && renderChildren ? (centerStateClasses === 'parent' ? RenderParentBelow : RenderChildren) : null} + content={CenterContent} + /> + + {#if drawSelectionWedges && hoveredIndex >= 0} + + {/if} + +
    + +{:else} + + + +{/if} + + diff --git a/kando-svelte/src/lib/components/PieMenuDemo.svelte b/kando-svelte/src/lib/components/PieMenuDemo.svelte new file mode 100644 index 000000000..fe6b48ed4 --- /dev/null +++ b/kando-svelte/src/lib/components/PieMenuDemo.svelte @@ -0,0 +1,488 @@ + + + +
    +

    KandoWrapper (native Kando renderer)

    +
    + {#if themeEffective} + dispatch('select', { path, item: root })} + onHover={(path) => dispatch('hover', { path })} + onUnhover={(path) => dispatch('unhover', { path })} + onCancel={() => dispatch('cancel')} + /> + {:else} +
    Load a theme to run the native Kando renderer.
    + {/if} +
    +
    + + + + diff --git a/kando-svelte/src/lib/components/PieTree.svelte b/kando-svelte/src/lib/components/PieTree.svelte new file mode 100644 index 000000000..2a352426d --- /dev/null +++ b/kando-svelte/src/lib/components/PieTree.svelte @@ -0,0 +1,684 @@ + + +
    { e.stopPropagation(); onPointerDown(e); }} oncontextmenu={(e)=>{ e.preventDefault(); }} role="application" + style={`--fade-in-duration:${settings?.fadeInDuration ?? 150}ms; --fade-out-duration:${settings?.fadeOutDuration ?? 200}ms;`}> + + {#key chain.join(',')} + + + + + {/key} + +
    + + + + diff --git a/kando-svelte/src/lib/components/SelectionWedges.svelte b/kando-svelte/src/lib/components/SelectionWedges.svelte new file mode 100644 index 000000000..1d0b6e51c --- /dev/null +++ b/kando-svelte/src/lib/components/SelectionWedges.svelte @@ -0,0 +1,13 @@ + + +
    + + diff --git a/kando-svelte/src/lib/components/WedgeSeparators.svelte b/kando-svelte/src/lib/components/WedgeSeparators.svelte new file mode 100644 index 000000000..9282b1ad0 --- /dev/null +++ b/kando-svelte/src/lib/components/WedgeSeparators.svelte @@ -0,0 +1,22 @@ + + +
    + + {#each angles as ang} + +
    + + {/each} + +
    + + diff --git a/kando-svelte/src/lib/context.ts b/kando-svelte/src/lib/context.ts new file mode 100644 index 000000000..d2a4767b6 --- /dev/null +++ b/kando-svelte/src/lib/context.ts @@ -0,0 +1,31 @@ +import type { Vec2 } from './types.js'; +import type { MenuItem } from './types.js'; + +export type IndexPath = number[]; + +export type PointerInfo = { dx: number; dy: number; angle: number; distance: number } | null; + +export type PieTreeContext = { + getChain(): IndexPath; + getCenter(): Vec2; + getRadius(): number; + getPointer(): PointerInfo; + hoverIndex(): number; + select(index: number): void; + back(): void; + resolve(path: IndexPath): MenuItem | null; +}; + +export const PIE_TREE_CTX: unique symbol = Symbol('pie-tree'); + +export type PieMenuContext = { + item: MenuItem; + indexPath: IndexPath; + parentItem: MenuItem | null; +}; + +export const PIE_MENU_CTX: unique symbol = Symbol('pie-menu'); + +// Optional: internal registry mapping item path -> PieItem component instance +export type ItemRegistry = Map; +export const ITEM_REGISTRY_CTX: unique symbol = Symbol('item-registry'); diff --git a/kando-svelte/src/lib/gesture-detector.ts b/kando-svelte/src/lib/gesture-detector.ts new file mode 100644 index 000000000..96863c253 --- /dev/null +++ b/kando-svelte/src/lib/gesture-detector.ts @@ -0,0 +1,2 @@ +export { GestureDetector } from '@kando/gesture'; + diff --git a/kando-svelte/src/lib/index.ts b/kando-svelte/src/lib/index.ts new file mode 100644 index 000000000..7cb692b60 --- /dev/null +++ b/kando-svelte/src/lib/index.ts @@ -0,0 +1,15 @@ +export { default as PieMenuDemo } from './components/PieMenuDemo.svelte'; +export { default as PieMenu } from './components/PieMenu.svelte'; +export { default as PieTree } from './components/PieTree.svelte'; +export { default as CenterText } from './components/CenterText.svelte'; +export { default as KandoWrapper } from './components/KandoWrapper.svelte'; +// Internal debug components not exported +export * from './types.js'; +export * from './theme-loader.js'; +export * from './kando-web.js'; + +export const Vendor = { + defaultThemeCss: new URL('./vendor/default-theme.css', import.meta.url).toString(), + defaultThemeJson: new URL('./vendor/default-theme.json5', import.meta.url).toString(), + noneSoundThemeJson: new URL('./vendor/sounds/none/theme.json', import.meta.url).toString() +}; diff --git a/kando-svelte/src/lib/kando-web.ts b/kando-svelte/src/lib/kando-web.ts new file mode 100644 index 000000000..e776dbf7c --- /dev/null +++ b/kando-svelte/src/lib/kando-web.ts @@ -0,0 +1,33 @@ +import type { MenuThemeDescription } from '@kando/common'; + +/** + * Inject a link element for a Kando theme.css using a web-safe URL base. + * Expects description.directory to be an http(s) or relative path base. + */ +export function injectThemeCssLink(description: MenuThemeDescription) { + const id = 'kando-menu-theme'; + const old = document.getElementById(id); + if (old) old.remove(); + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.id = id; + link.href = `${description.directory}/${description.id}/theme.css`; + document.head.appendChild(link); +} + +/** + * Build default ShowMenuOptions fields for web usage. + */ +export function defaultMenuOptions() { + return { + zoomFactor: 1, + centeredMode: false, + anchoredMode: false, + hoverMode: false, + systemIconsChanged: false + } as const; +} + + diff --git a/kando-svelte/src/lib/theme-loader.ts b/kando-svelte/src/lib/theme-loader.ts new file mode 100644 index 000000000..b4f95a53c --- /dev/null +++ b/kando-svelte/src/lib/theme-loader.ts @@ -0,0 +1,47 @@ +import JSON5 from 'json5'; +import type { MenuThemeDescription } from './types.js'; + +export async function fetchThemeJson(themeDirUrl: string, themeId: string): Promise { + const base = themeDirUrl.replace(/\/$/, ''); + const url = `${base}/${themeId}/theme.json5`; + return fetchThemeJsonFromUrl(url, { id: themeId, directory: base }); +} + +export async function fetchThemeJsonFromUrl(url: string, overrides?: Partial): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load theme: ${url}`); + const text = await res.text(); + const parsed = JSON5.parse(text); + return { + ...parsed, + ...overrides, + } as MenuThemeDescription; +} + +export function injectThemeCss(theme: MenuThemeDescription): HTMLLinkElement { + const href = `${theme.directory.replace(/\/$/, '')}/${theme.id}/theme.css`; + return injectThemeCssHref(href); +} + +export function injectThemeCssHref(href: string): HTMLLinkElement { + // Remove any previously injected theme link (single active theme) + const old = document.getElementById('kando-theme-current'); + if (old) old.remove(); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.id = 'kando-theme-current'; + document.head.appendChild(link); + return link; +} + +export function unloadThemeCss() { + const old = document.getElementById('kando-theme-current'); + if (old) old.remove(); +} + +export function applyThemeColors(colors: Record) { + Object.entries(colors).forEach(([name, value]) => { + document.documentElement.style.setProperty(`--${name}`, value); + }); +} diff --git a/kando-svelte/src/lib/types.ts b/kando-svelte/src/lib/types.ts new file mode 100644 index 000000000..7c0fc5232 --- /dev/null +++ b/kando-svelte/src/lib/types.ts @@ -0,0 +1,14 @@ +export type { Vec2, ShowMenuOptions, MenuThemeDescription } from '@kando/common'; +export type { MenuItemV1 as MenuItem, MenuV1, MenuCollectionV1 } from '@kando/common'; + +// Svelte-side trigger types (browser polyfill variant) +export type MouseButton = 'left' | 'middle' | 'right' | 'x1' | 'x2'; +export type ModifierKey = 'ctrl' | 'alt' | 'shift' | 'meta'; + +export type MouseTrigger = { + kind: 'mouse'; + button: MouseButton; + mods?: ModifierKey[]; +}; + +export type Trigger = MouseTrigger; // Extend with gamepad/keyboard variants as needed diff --git a/kando-svelte/src/lib/validation.ts b/kando-svelte/src/lib/validation.ts new file mode 100644 index 000000000..bcc965c19 --- /dev/null +++ b/kando-svelte/src/lib/validation.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { GENERAL_SETTINGS_SCHEMA_V1 } from '@kando/schemata/general-settings-v1'; +import { MENU_SETTINGS_SCHEMA_V1 } from '@kando/schemata/menu-settings-v1'; +import type { GeneralSettingsV1 } from '@kando/schemata/general-settings-v1'; +import type { MenuSettingsV1 } from '@kando/schemata/menu-settings-v1'; + +export { GENERAL_SETTINGS_SCHEMA_V1, MENU_SETTINGS_SCHEMA_V1 }; + +export type ValidationResult = { ok: true; data: T } | { ok: false; errors: z.ZodIssue[] }; + +export function parseConfig(input: unknown): ValidationResult { + const res = GENERAL_SETTINGS_SCHEMA_V1.safeParse(input); + return res.success ? { ok: true, data: res.data } : { ok: false, errors: res.error.issues }; +} + +export function parseMenus(input: unknown): ValidationResult { + const res = MENU_SETTINGS_SCHEMA_V1.safeParse(input); + return res.success ? { ok: true, data: res.data } : { ok: false, errors: res.error.issues }; +} diff --git a/kando-svelte/src/lib/vendor/default-theme.css b/kando-svelte/src/lib/vendor/default-theme.css new file mode 100644 index 000000000..83ca25b43 --- /dev/null +++ b/kando-svelte/src/lib/vendor/default-theme.css @@ -0,0 +1,196 @@ +/*//////////////////////////////////////////////////////////////////////////////////////*/ +/* _ _ ____ _ _ ___ ____ */ +/* |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform */ +/* | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando */ +/* */ +/*//////////////////////////////////////////////////////////////////////////////////////*/ + +/* SPDX-FileCopyrightText: Simon Schneegans */ +/* SPDX-License-Identifier: CC0-1.0 */ + +.menu-node { + --child-distance: 100px; + --grandchild-distance: 25px; + + --center-size: 100px; + --child-size: 50px; + --grandchild-size: 15px; + --connector-width: 10px; + + --menu-transition: all 250ms cubic-bezier(0.775, 1.325, 0.535, 1); + --opacity-transition: opacity 250ms ease; + + transition: var(--menu-transition); + + /* Positioning ---------------------------------------------------------------------- */ + + /* Child items are positioned around the active node. */ + &.child { + transform: translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), + calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-y))); + } + + /* Grandchild items are positioned around the child items. */ + &.grandchild { + transform: translate(calc(var(--grandchild-distance) * var(--dir-x)), + calc(var(--grandchild-distance) * var(--dir-y))); + } + + /* If there is a hovered child node, we scale all children up a bit to create a cool + zoom effect. The hovered child itself is scaled up even more. */ + &.active:has(.hovered)>.child { + transform: scale(calc(1.15 - pow(var(--angle-diff) / 180, 0.25) * 0.15)) translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), + calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-y))); + + &.hovered { + transform: scale(1.15) translate(calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-x)), + calc(max(var(--child-distance), 10px * var(--sibling-count)) * var(--dir-y))); + } + } + + + /* Theme Layers --------------------------------------------------------------------- */ + + /* This theme comes with only one layer. This contains the icon of the menu item. */ + + /* We hide all icons by default. They will be shown further down in this file for the + center item and the child items. */ + .icon-container { + opacity: 0; + color: var(--text-color); + transition: var(--opacity-transition); + margin: 5%; + width: 90% !important; + height: 90% !important; + border-radius: 50%; + overflow: hidden; + } + + /* All menu items have a border and are circles in this theme. */ + .icon-layer { + position: absolute; + border-radius: 50%; + border: 1px solid var(--border-color); + transition: var(--menu-transition); + } + + /* The active menu item is the center of the menu. */ + &.active>.icon-layer { + top: calc(-1 * var(--center-size) / 2); + left: calc(-1 * var(--center-size) / 2); + width: var(--center-size); + height: var(--center-size); + background-color: var(--background-color); + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); + } + + /* If the center item has a hovered child node, we scale it up and hide its icon. */ + &.active:has(>.hovered)>.icon-layer { + transform: scale(1.1); + + &>.icon-container { + opacity: 0; + } + } + + /* If the center node is hovered, we want to highlight it. */ + &.active.hovered>.icon-layer { + background-color: var(--hover-color); + } + + /* If the parent or a child node is clicked, we scale it down to normal size. */ + &.parent.hovered.clicked>.icon-layer, + &.child.hovered.clicked>.icon-layer { + transform: scale(0.95); + } + + /* If the center node is clicked, we scale it down a bit. */ + &.active.hovered.clicked>.icon-layer { + transform: scale(0.95); + } + + /* Show the icons of the center, parent and child items. */ + &.parent>.icon-layer>.icon-container, + &.child>.icon-layer>.icon-container, + &.active>.icon-layer>.icon-container { + opacity: 1; + } + + /* Child items are displayed around the active node. The parent node of the active + node is displayed in a similar style. */ + &.parent>.icon-layer, + &.child>.icon-layer { + top: calc(-1 * var(--child-size) / 2); + left: calc(-1 * var(--child-size) / 2); + width: var(--child-size); + height: var(--child-size); + background-color: var(--background-color); + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); + + } + + /* Hovered child or parent items are highlighted. */ + &.parent.hovered>.icon-layer, + &.child.hovered>.icon-layer { + background-color: var(--hover-color); + } + + /* Grandchild items are very small and drawn below the child items. */ + &.grandchild>.icon-layer { + top: calc(-1 * var(--grandchild-size) / 2); + left: calc(-1 * var(--grandchild-size) / 2); + width: var(--grandchild-size); + height: var(--grandchild-size); + background-color: var(--border-color); + } + + /* We disable any transition for dragged items. */ + &.dragged { + transition: none; + } + + /* Connectors ----------------------------------------------------------------------- */ + + .connector { + transition: var(--menu-transition); + height: var(--connector-width); + background-color: var(--border-color); + top: calc(-1 * var(--connector-width) / 2); + } + + &:has(.dragged)>.connector { + transition: none; + } + + &.hovered>.connector { + background-color: color-mix(in srgb, var(--hover-color) 50%, var(--border-color)); + } + + &.active>.connector { + background-color: var(--hover-color); + } +} + +/* Center Text ------------------------------------------------------------------------ */ + +.center-text { + color: var(--text-color); + transition: var(--opacity-transition); + font-size: 16px; + line-height: 22px; +} + +/* Selection Wedges ------------------------------------------------------------------- */ + +.selection-wedges { + mask: radial-gradient(circle at var(--center-x) var(--center-y), black 100px, transparent 50%); + + &.hovered { + --width: calc(var(--end-angle) - var(--start-angle)); + background: conic-gradient(from calc(var(--start-angle)) at var(--center-x) var(--center-y), + var(--wedge-highlight-color) var(--width), + var(--wedge-color) var(--width)); + } + + background: var(--wedge-color); +} \ No newline at end of file diff --git a/kando-svelte/src/lib/vendor/default-theme.json5 b/kando-svelte/src/lib/vendor/default-theme.json5 new file mode 100644 index 000000000..66a41a681 --- /dev/null +++ b/kando-svelte/src/lib/vendor/default-theme.json5 @@ -0,0 +1,47 @@ +////////////////////////////////////////////////////////////////////////////////////////// +// _ _ ____ _ _ ___ ____ // +// |_/ |__| |\ | | \ | | This file belongs to Kando, the cross-platform // +// | \_ | | | \| |__/ |__| pie menu. Read more on github.com/kando-menu/kando // +// // +////////////////////////////////////////////////////////////////////////////////////////// + +// SPDX-FileCopyrightText: Simon Schneegans +// SPDX-License-Identifier: CC0-1.0 + +{ + name: 'Default', + author: 'Simon Schneegans', + license: 'CC0-1.0', + themeVersion: '1.0', + + // This theme was created for Kando's theme engine version 1. Kando will use this to + // check if the theme is compatible with the current version of Kando. + engineVersion: 1, + + // When a menu is opened too close to a screen's edge, it is moved away from the edge + // by this amount of pixels. + maxMenuRadius: 160, + + // The width at which the center text is wrapped. + centerTextWrapWidth: 95, + + // This theme draws child items below their parent items. + drawChildrenBelow: true, + + // Draw the thin lines between adjacent menu items. + drawSelectionWedges: true, + + // These colors can be configured by the user and are vailable in the CSS file as CSS + // variables. The default values are used if the user does not provide any values. + colors: { + 'background-color': 'rgb(255, 255, 255)', + 'text-color': 'rgb(60, 60, 60)', + 'border-color': 'rgb(109, 109, 109)', + 'hover-color': 'rgb(255, 200, 200)', + 'wedge-highlight-color': 'rgba(0, 0, 0, 0.1)', + 'wedge-color': 'rgba(0, 0, 0, 0.2)', + }, + + // This theme is very simple and only uses one layer for the menu items. + layers: [{ class: 'icon-layer', content: 'icon' }], +} diff --git a/kando-svelte/src/lib/vendor/sounds/none/theme.json b/kando-svelte/src/lib/vendor/sounds/none/theme.json new file mode 100644 index 000000000..b0d228abb --- /dev/null +++ b/kando-svelte/src/lib/vendor/sounds/none/theme.json @@ -0,0 +1,10 @@ +{ + "id": "none", + "name": "None", + "directory": "internal", + "engineVersion": 1, + "themeVersion": "1.0.0", + "author": "Kando", + "license": "CC0-1.0", + "sounds": {} +} diff --git a/kando-svelte/src/routes/+layout.svelte b/kando-svelte/src/routes/+layout.svelte new file mode 100644 index 000000000..dc1e1cbbc --- /dev/null +++ b/kando-svelte/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + + + +{@render children?.()} diff --git a/kando-svelte/src/routes/+page.svelte b/kando-svelte/src/routes/+page.svelte new file mode 100644 index 000000000..5dd81face --- /dev/null +++ b/kando-svelte/src/routes/+page.svelte @@ -0,0 +1,427 @@ + + +{#if error} +

    {error}

    +{/if} + +{#if menuSettings} +
    + + + +
    +{/if} + +{#if firstRoot} + +

    Svelte KandoWrapper Pie Menu Demo

    + {#if theme} + + {:else} +
    Load a theme to run the native Kando renderer.
    + {/if} + +{/if} + +{#if theme && firstRoot} + +

    Callback Log

    + +
    +{#each logLines as line}
    +{line}
    +{/each}
    +
    + +{/if} + +{#if menuSettings} + +

    Menus (version {menuSettings.version})

    + +
      + + {#each menuSettings.menus as m: MenuV1, i} +
    • + + {m.root?.name ?? `Menu ${i+1}`} + +
        + + {#if m.shortcut}
      • shortcut: {m.shortcut}
      • {/if} + {#if m.shortcutID}
      • shortcutID: {m.shortcutID}
      • {/if} +
      • centered: {String(m.centered ?? false)}
      • +
      • anchored: {String(m.anchored ?? false)}
      • +
      • hoverMode: {String(m.hoverMode ?? false)}
      • + {#if m.tags?.length} +
      • tags: {m.tags.join(', ')}
      • + {/if} + +
      • outline: + + {#if m.root} +
          + +
        + {:else} + no root + {/if} + +
      • + +
      + +
    • + + {/each} + +
    + +{/if} + +{#if config} + +

    Config

    + +
    {JSON.stringify(config, null, 2)}
    + +{/if} + +{#if theme} + +

    Theme

    + +
      +
    • id: {theme.id}
    • +
    • name: {theme.name}
    • +
    • engineVersion: {theme.engineVersion}
    • +
    • layers: {theme.layers.length}
    • +
    + + {#if mathPreview.angles} + +

    Math quick test

    + +

    First menu child angles: {mathPreview.angles?.map((a) => a.toFixed(1)).join(', ')}

    + +

    Wedges: {mathPreview.wedges?.map((w) => `${w.start.toFixed(1)}–${w.end.toFixed(1)}`).join(' | ')}

    + + {/if} + +{/if} diff --git a/kando-svelte/src/routes/api/themes/+server.ts b/kando-svelte/src/routes/api/themes/+server.ts new file mode 100644 index 000000000..d75466d50 --- /dev/null +++ b/kando-svelte/src/routes/api/themes/+server.ts @@ -0,0 +1,102 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { readdir, readFile, stat } from 'fs/promises'; +import path from 'path'; +import JSON5 from 'json5'; + +async function listMenuThemes(): Promise> { + const base = path.resolve('static/kando/menu-themes'); + let entries: any[] = []; + try { entries = await readdir(base, { withFileTypes: true }); } catch {} + const themes: Array<{ id: string; name?: string }> = []; + for (const ent of entries) { + if (!(ent.isDirectory() || ent.isSymbolicLink())) continue; + const id = ent.name; + const file = path.join(base, id, 'theme.json5'); + try { + const st = await stat(file); if (!st.isFile()) continue; + const txt = await readFile(file, 'utf8'); + const parsed: any = JSON5.parse(txt); + themes.push({ id, name: parsed?.name }); + } catch {} + } + // inject vendor default at head + try { + const vendorPath = path.resolve('src/lib/vendor/default-theme.json5'); + const txt = await readFile(vendorPath, 'utf8'); + const parsed: any = JSON5.parse(txt); + if (!themes.find((t) => t.id === 'default')) { + themes.unshift({ id: 'default', name: parsed?.name ?? 'Default' }); + } + } catch { + if (!themes.find((t) => t.id === 'default')) themes.unshift({ id: 'default', name: 'Default' }); + } + return themes; +} + +async function listSoundThemes(): Promise> { + const base = path.resolve('static/kando/sound-themes'); + let entries: any[] = []; + try { entries = await readdir(base, { withFileTypes: true }); } catch {} + const sounds: Array<{ id: string; name?: string }> = []; + for (const ent of entries) { + if (!(ent.isDirectory() || ent.isSymbolicLink())) continue; + const id = ent.name; + const fileJson5 = path.join(base, id, 'theme.json5'); + const fileJson = path.join(base, id, 'theme.json'); + let name: string | undefined; + try { const txt = await readFile(fileJson5, 'utf8'); const parsed: any = JSON5.parse(txt); name = parsed?.name; } + catch { + try { const txt = await readFile(fileJson, 'utf8'); const parsed: any = JSON.parse(txt); name = parsed?.name; } catch {} + } + sounds.push({ id, name }); + } + // vendor none + try { + const txt = await readFile(path.resolve('src/lib/vendor/sounds/none/theme.json'), 'utf8'); + const parsed: any = JSON.parse(txt); + sounds.unshift({ id: 'none', name: parsed?.name ?? 'None' }); + } catch { + sounds.unshift({ id: 'none', name: 'None' }); + } + return sounds; +} + +async function listIconThemes(): Promise> { + const icons: Array<{ id: string; name?: string }> = [] as any; + // Known built-ins + icons.push({ id: 'material-symbols-rounded', name: 'Material Symbols Rounded' }); + icons.push({ id: 'simple-icons', name: 'Simple Icons' }); + // Discover local static icon sets (e.g., kando) + const base = path.resolve('static/kando/icon-themes'); + let entries: any[] = []; + try { entries = await readdir(base, { withFileTypes: true }); } catch {} + for (const ent of entries) { + if (!(ent.isDirectory() || ent.isSymbolicLink())) continue; + const id = ent.name; + if (!icons.find((t) => t.id === id)) icons.push({ id, name: id }); + } + return icons; +} + +export const GET: RequestHandler = async () => { + try { + console.log('[api/themes] GET start'); + const [menu, sound, icon] = await Promise.all([ + listMenuThemes(), + listSoundThemes(), + listIconThemes(), + ]); + console.log('[api/themes] resolved', { menu: menu.length, sound: sound.length, icon: icon.length }); + return new Response(JSON.stringify({ themes: { menu, sound, icon } }), { + headers: { 'content-type': 'application/json' }, + }); + } catch (e) { + console.error('[api/themes] error', e); + return new Response(JSON.stringify({ themes: { menu: [], sound: [], icon: [] }, error: (e as Error).message }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + } +}; + + diff --git a/kando-svelte/src/routes/~material-symbols/[...path]/+server.ts b/kando-svelte/src/routes/~material-symbols/[...path]/+server.ts new file mode 100644 index 000000000..0459230b5 --- /dev/null +++ b/kando-svelte/src/routes/~material-symbols/[...path]/+server.ts @@ -0,0 +1,50 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +// Try local node_modules first, then the monorepo parent (kando root) +const bases = [ + path.resolve(process.cwd(), 'node_modules', 'material-symbols'), + path.resolve(process.cwd(), '..', 'node_modules', 'material-symbols') +].filter((p) => existsSync(p)); + +function contentType(p: string): string { + if (p.endsWith('.woff2')) return 'font/woff2'; + if (p.endsWith('.woff')) return 'font/woff'; + if (p.endsWith('.ttf')) return 'font/ttf'; + if (p.endsWith('.css')) return 'text/css; charset=utf-8'; + if (p.endsWith('.svg')) return 'image/svg+xml'; + return 'application/octet-stream'; +} + +export const GET: RequestHandler = async ({ params }) => { + try { + const reqPath = params.path ?? ''; + // Prevent path traversal + const safePath = path.normalize(reqPath).replace(/^\/+/, ''); + let abs: string | null = null; + for (const b of bases) { + const candidate = path.join(b, safePath); + if (candidate.startsWith(b) && existsSync(candidate)) { + abs = candidate; break; + } + } + if (!abs) { + return new Response('Forbidden', { status: 403 }); + } + const data = await readFile(abs); + // Convert Buffer to Uint8Array for Web Response type compatibility + const body = new Uint8Array(data); + return new Response(body, { + headers: { + 'content-type': contentType(abs), + 'cache-control': 'public, max-age=31536000, immutable' + } + }); + } catch (e) { + return new Response('Not found', { status: 404 }); + } +}; + + diff --git a/kando-svelte/src/routes/~simple-icons/[...path]/+server.ts b/kando-svelte/src/routes/~simple-icons/[...path]/+server.ts new file mode 100644 index 000000000..5c0f5d7d3 --- /dev/null +++ b/kando-svelte/src/routes/~simple-icons/[...path]/+server.ts @@ -0,0 +1,38 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import { readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const bases = [ + path.resolve(process.cwd(), 'node_modules', 'simple-icons-font', 'font'), + path.resolve(process.cwd(), '..', 'node_modules', 'simple-icons-font', 'font') +].filter((p) => existsSync(p)); + +function contentType(p: string): string { + if (p.endsWith('.woff2')) return 'font/woff2'; + if (p.endsWith('.woff')) return 'font/woff'; + if (p.endsWith('.ttf')) return 'font/ttf'; + if (p.endsWith('.otf')) return 'font/otf'; + if (p.endsWith('.css')) return 'text/css; charset=utf-8'; + return 'application/octet-stream'; +} + +export const GET: RequestHandler = async ({ params }) => { + try { + const reqPath = params.path ?? ''; + const safePath = path.normalize(reqPath).replace(/^\/+/, ''); + let abs: string | null = null; + for (const b of bases) { + const candidate = path.join(b, safePath); + if (candidate.startsWith(b) && existsSync(candidate)) { abs = candidate; break; } + } + if (!abs) return new Response('Forbidden', { status: 403 }); + const data = await readFile(abs); + const body = new Uint8Array(data); + return new Response(body, { headers: { 'content-type': contentType(abs), 'cache-control': 'public, max-age=31536000, immutable' } }); + } catch (e) { + return new Response('Not found', { status: 404 }); + } +}; + + diff --git a/kando-svelte/static/favicon.svg b/kando-svelte/static/favicon.svg new file mode 100644 index 000000000..cc5dc66a3 --- /dev/null +++ b/kando-svelte/static/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/kando-svelte/static/kando/config.json b/kando-svelte/static/kando/config.json new file mode 100644 index 000000000..d85e6029b --- /dev/null +++ b/kando-svelte/static/kando/config.json @@ -0,0 +1,36 @@ +{ + "locale": "auto", + "menuTheme": "default", + "darkMenuTheme": "default", + "menuThemeColors": {}, + "darkMenuThemeColors": {}, + "enableDarkModeForMenuThemes": false, + "soundTheme": "none", + "soundVolume": 0.5, + "sidebarVisible": true, + "ignoreWriteProtectedConfigFiles": false, + "trayIconFlavor": "color", + "enableVersionCheck": true, + "zoomFactor": 1, + "menuOptions": { + "centerDeadZone": 50, + "minParentDistance": 150, + "dragThreshold": 15, + "fadeInDuration": 150, + "fadeOutDuration": 200, + "enableMarkingMode": true, + "enableTurboMode": true, + "gestureMinStrokeLength": 150, + "gestureMinStrokeAngle": 20, + "gestureJitterThreshold": 10, + "gesturePauseTimeout": 100, + "fixedStrokeLength": 0, + "rmbSelectsParent": false, + "gamepadBackButton": 1, + "gamepadCloseButton": 2 + }, + "editorOptions": { + "showSidebarButtonVisible": true, + "showEditorButtonVisible": true + } +} diff --git a/kando-svelte/static/kando/icon-themes b/kando-svelte/static/kando/icon-themes new file mode 120000 index 000000000..79e2cf64a --- /dev/null +++ b/kando-svelte/static/kando/icon-themes @@ -0,0 +1 @@ +../../../assets/icon-themes \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/clean-circle b/kando-svelte/static/kando/menu-themes/clean-circle new file mode 120000 index 000000000..06ad50718 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/clean-circle @@ -0,0 +1 @@ +../../../../assets/menu-themes/clean-circle \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/default b/kando-svelte/static/kando/menu-themes/default new file mode 120000 index 000000000..74699cfc8 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/default @@ -0,0 +1 @@ +../../../../assets/menu-themes/default \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/evntech-vache b/kando-svelte/static/kando/menu-themes/evntech-vache new file mode 120000 index 000000000..c8fd89830 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/evntech-vache @@ -0,0 +1 @@ +../../../../../menu-themes/themes/evntech-vache \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/hexperiment b/kando-svelte/static/kando/menu-themes/hexperiment new file mode 120000 index 000000000..3b8725b87 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/hexperiment @@ -0,0 +1 @@ +../../../../../menu-themes/themes/hexperiment \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/knight-forge b/kando-svelte/static/kando/menu-themes/knight-forge new file mode 120000 index 000000000..73afc67de --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/knight-forge @@ -0,0 +1 @@ +../../../../../menu-themes/themes/knight-forge \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/minecraft b/kando-svelte/static/kando/menu-themes/minecraft new file mode 120000 index 000000000..266cd8515 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/minecraft @@ -0,0 +1 @@ +../../../../../menu-themes/themes/minecraft \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/modified-bent-photon b/kando-svelte/static/kando/menu-themes/modified-bent-photon new file mode 120000 index 000000000..d7fa19fce --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/modified-bent-photon @@ -0,0 +1 @@ +../../../../../menu-themes/themes/modified-bent-photon \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/navigation b/kando-svelte/static/kando/menu-themes/navigation new file mode 120000 index 000000000..ef2077ad4 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/navigation @@ -0,0 +1 @@ +../../../../../menu-themes/themes/navigation \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/neo-ring b/kando-svelte/static/kando/menu-themes/neo-ring new file mode 120000 index 000000000..5b53a092c --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/neo-ring @@ -0,0 +1 @@ +../../../../../menu-themes/themes/neo-ring \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/neon-lights b/kando-svelte/static/kando/menu-themes/neon-lights new file mode 120000 index 000000000..5ae7eba06 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/neon-lights @@ -0,0 +1 @@ +../../../../assets/menu-themes/neon-lights \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/neon-lights-color b/kando-svelte/static/kando/menu-themes/neon-lights-color new file mode 120000 index 000000000..3203f30e8 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/neon-lights-color @@ -0,0 +1 @@ +../../../../../menu-themes/themes/neon-lights-color \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/nether-labels b/kando-svelte/static/kando/menu-themes/nether-labels new file mode 120000 index 000000000..6fa0036c0 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/nether-labels @@ -0,0 +1 @@ +../../../../../menu-themes/themes/nether-labels \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/nord b/kando-svelte/static/kando/menu-themes/nord new file mode 120000 index 000000000..094d05452 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/nord @@ -0,0 +1 @@ +../../../../../menu-themes/themes/nord \ No newline at end of file diff --git a/kando-svelte/static/kando/menu-themes/rainbow-labels b/kando-svelte/static/kando/menu-themes/rainbow-labels new file mode 120000 index 000000000..1836af8b8 --- /dev/null +++ b/kando-svelte/static/kando/menu-themes/rainbow-labels @@ -0,0 +1 @@ +../../../../assets/menu-themes/rainbow-labels \ No newline at end of file diff --git a/kando-svelte/static/kando/menus.json b/kando-svelte/static/kando/menus.json new file mode 100644 index 000000000..78cb73747 --- /dev/null +++ b/kando-svelte/static/kando/menus.json @@ -0,0 +1,1221 @@ +{ + "version": "2.1.0", + "menus": [ + { + "root": { + "type": "submenu", + "name": "Example Menu", + "icon": "award_star", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "submenu", + "name": "Apps", + "icon": "apps", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "redirect", + "data": { + "menu": "" + }, + "name": "Redirect", + "icon": "redirect-item.svg", + "iconTheme": "kando" + }, + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "command", + "data": { + "command": "open -a Safari" + }, + "name": "Safari", + "icon": "safari", + "iconTheme": "simple-icons" + }, + { + "type": "command", + "data": { + "command": "open -a Mail" + }, + "name": "E-Mail", + "icon": "mail", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open -a Music" + }, + "name": "Music", + "icon": "itunes", + "iconTheme": "simple-icons" + }, + { + "type": "command", + "data": { + "command": "open -a Finder" + }, + "name": "Finder", + "icon": "folder_shared", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open -a Terminal" + }, + "name": "Terminal", + "icon": "terminal", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "redirect", + "data": { + "menu": "Compass" + }, + "name": "Compass", + "icon": "menu", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 124 using control down'" + }, + "name": "Next Workspace", + "icon": "arrow_forward", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "submenu", + "name": "Clipboard", + "icon": "assignment", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyV", + "delayed": true + }, + "name": "Paste", + "icon": "content_paste_go", + "iconTheme": "material-symbols-rounded", + "angle": 90 + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyC", + "delayed": true + }, + "name": "Copy", + "icon": "content_copy", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyX", + "delayed": true + }, + "name": "Cut", + "icon": "cut", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "submenu", + "name": "Audio", + "icon": "play_circle", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to next track'" + }, + "name": "Next Track", + "icon": "skip_next", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to playpause'" + }, + "name": "Play / Pause", + "icon": "play_pause", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Music\" to previous track'" + }, + "name": "Previous Track", + "icon": "skip_previous", + "iconTheme": "material-symbols-rounded" + } + ] + }, + { + "type": "submenu", + "name": "Windows", + "icon": "select_window", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 126 using control down'" + }, + "name": "Mission Control", + "icon": "select_window", + "iconTheme": "material-symbols-rounded", + "angle": 0 + }, + { + "type": "hotkey", + "data": { + "hotkey": "ControlLeft+AltLeft+ArrowRight", + "delayed": true + }, + "name": "Tile Right", + "icon": "text_select_jump_to_end", + "iconTheme": "material-symbols-rounded", + "angle": 90 + }, + { + "type": "hotkey", + "data": { + "hotkey": "MetaLeft+KeyW", + "delayed": true + }, + "name": "Close Window", + "icon": "cancel_presentation", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "hotkey", + "data": { + "hotkey": "ControlLeft+AltLeft+ArrowLeft", + "delayed": true + }, + "name": "Tile Left", + "icon": "text_select_jump_to_beginning", + "iconTheme": "material-symbols-rounded", + "angle": 270 + } + ] + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"System Events\" to key code 123 using control down'" + }, + "name": "Previous Workspace", + "icon": "arrow_back", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "submenu", + "name": "Bookmarks", + "icon": "folder_special", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to downloads folder as text)'" + }, + "name": "Downloads", + "icon": "download", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to movies folder as text)'" + }, + "name": "Videos", + "icon": "video_camera_front", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to pictures folder as text)'" + }, + "name": "Pictures", + "icon": "imagesmode", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to documents folder as text)'" + }, + "name": "Docuexample-menu.bookmarks.documentsments", + "icon": "text_ad", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to desktop folder as text)'" + }, + "name": "Desktop", + "icon": "desktop_windows", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "open $HOME" + }, + "name": "Home", + "icon": "home", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "data": { + "command": "osascript -e 'tell application \"Finder\" to open (path to music folder as text)'" + }, + "name": "Music", + "icon": "music_note", + "iconTheme": "material-symbols-rounded" + } + ] + } + ] + }, + "shortcut": "Control+Alt+Space", + "shortcutID": "example-menu", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Deep", + "icon": "apps", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [] + } + ] + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [ + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + }, + { + "type": "submenu", + "data": {}, + "name": "Submenu", + "icon": "submenu-item.svg", + "iconTheme": "kando", + "children": [] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + { + "type": "text", + "data": { + "text": "" + }, + "name": "Paste Text", + "icon": "text-item.svg", + "iconTheme": "kando" + } + ] + }, + "shortcut": "Control+Space", + "shortcutID": "", + "centered": false, + "anchored": false, + "hoverMode": false, + "conditions": { + "appName": "Cursor" + }, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Holding Menu", + "icon": "award_star", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "submenu", + "name": "Web Links", + "icon": "public", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "uri", + "data": { + "uri": "https://www.google.com" + }, + "name": "Google", + "icon": "google", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://github.com/kando-menu/kando" + }, + "name": "Kando on GitHub", + "icon": "github", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://ko-fi.com/schneegans" + }, + "name": "Kando on Ko-fi", + "icon": "kofi", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://www.youtube.com/@simonschneegans" + }, + "name": "Kando on YouTube", + "icon": "youtube", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://discord.gg/hZwbVSDkhy" + }, + "name": "Kando on Discord", + "icon": "discord", + "iconTheme": "simple-icons" + } + ] + }, + { + "type": "submenu", + "name": "Web Links", + "icon": "public", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "uri", + "data": { + "uri": "https://www.google.com" + }, + "name": "Google", + "icon": "google", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://github.com/kando-menu/kando" + }, + "name": "Kando on GitHub", + "icon": "github", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://ko-fi.com/schneegans" + }, + "name": "Kando on Ko-fi", + "icon": "kofi", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://www.youtube.com/@simonschneegans" + }, + "name": "Kando on YouTube", + "icon": "youtube", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://discord.gg/hZwbVSDkhy" + }, + "name": "Kando on Discord", + "icon": "discord", + "iconTheme": "simple-icons" + } + ] + }, + { + "type": "submenu", + "name": "Web Links", + "icon": "public", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "uri", + "data": { + "uri": "https://www.google.com" + }, + "name": "Google", + "icon": "google", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://github.com/kando-menu/kando" + }, + "name": "Kando on GitHub", + "icon": "github", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://ko-fi.com/schneegans" + }, + "name": "Kando on Ko-fi", + "icon": "kofi", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://www.youtube.com/@simonschneegans" + }, + "name": "Kando on YouTube", + "icon": "youtube", + "iconTheme": "simple-icons" + }, + { + "type": "uri", + "data": { + "uri": "https://discord.gg/hZwbVSDkhy" + }, + "name": "Kando on Discord", + "icon": "discord", + "iconTheme": "simple-icons" + } + ] + } + ] + }, + "shortcut": "", + "shortcutID": "example-menu", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Compass", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "North", + "icon": "north", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "North-East", + "icon": "north_east", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "East", + "icon": "east", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "South-East", + "icon": "south_east", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "South", + "icon": "south", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "South-West", + "icon": "south_west", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "West", + "icon": "west", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "North-West", + "icon": "north_west", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "Control+`", + "shortcutID": "compass", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Boolean", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "True", + "icon": "north", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "False", + "icon": "south", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "boolean", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Quad", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "0", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "1", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "2", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "3", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "quad", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Octal", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "0", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "1", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "2", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "3", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "4", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "5", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "6", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "7", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "octal", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Digital", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "0", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "1", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "2", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "3", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "4", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "5", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "6", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "7", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "8", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "9", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "digital", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Clock", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "12", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "1", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "2", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "3", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "4", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "5", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "6", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "7", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "8", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "9", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "10", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "11", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "clock", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + }, + { + "root": { + "type": "submenu", + "name": "Hex", + "icon": "menu", + "iconTheme": "material-symbols-rounded", + "children": [ + { + "type": "command", + "name": "0", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "1", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "2", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "3", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "4", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "5", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "6", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "7", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "8", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "9", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "10", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "11", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "12", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "13", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "14", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + }, + { + "type": "command", + "name": "15", + "icon": "radio_button_unchecked", + "iconTheme": "material-symbols-rounded" + } + ] + }, + "shortcut": "", + "shortcutID": "hex", + "centered": false, + "anchored": false, + "hoverMode": false, + "tags": [] + } + ], + "collections": [ + { + "name": "Favorites", + "icon": "favorite", + "iconTheme": "material-symbols-rounded", + "tags": [ + "favs" + ] + } + ] +} diff --git a/kando-svelte/static/kando/sound-themes/EVNTech-Vache b/kando-svelte/static/kando/sound-themes/EVNTech-Vache new file mode 120000 index 000000000..b981c91a9 --- /dev/null +++ b/kando-svelte/static/kando/sound-themes/EVNTech-Vache @@ -0,0 +1 @@ +../../../../../sound-themes/themes/EVNTech-Vache \ No newline at end of file diff --git a/kando-svelte/static/kando/sound-themes/Synthetic sound b/kando-svelte/static/kando/sound-themes/Synthetic sound new file mode 120000 index 000000000..ad0a60fa7 --- /dev/null +++ b/kando-svelte/static/kando/sound-themes/Synthetic sound @@ -0,0 +1 @@ +../../../../../sound-themes/themes/Synthetic sound \ No newline at end of file diff --git a/kando-svelte/static/kando/sound-themes/example b/kando-svelte/static/kando/sound-themes/example new file mode 120000 index 000000000..eb017dd4a --- /dev/null +++ b/kando-svelte/static/kando/sound-themes/example @@ -0,0 +1 @@ +../../../../../sound-themes/themes/example \ No newline at end of file diff --git a/kando-svelte/static/kando/sound-themes/simple-clicks b/kando-svelte/static/kando/sound-themes/simple-clicks new file mode 120000 index 000000000..d2996be59 --- /dev/null +++ b/kando-svelte/static/kando/sound-themes/simple-clicks @@ -0,0 +1 @@ +../../../../assets/sound-themes/simple-clicks \ No newline at end of file diff --git a/kando-svelte/svelte.config.js b/kando-svelte/svelte.config.js new file mode 100644 index 000000000..ffdb8728d --- /dev/null +++ b/kando-svelte/svelte.config.js @@ -0,0 +1,29 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + alias: { + '@kando/common': '../src/common', + '@kando/schemata': '../src/common/settings-schemata', + '@kando/menu': '../src/menu-renderer/menu.ts', + '@kando/menu-theme': '../src/menu-renderer/menu-theme.ts', + '@kando/gesture': '../src/menu-renderer/input-methods/gesture-detector.ts', + '@kando/gamepad': '../src/menu-renderer/input-methods/gamepad.ts', + '@kando/sound-theme': '../src/menu-renderer/sound-theme.ts', + '@kando/base-css': '../src/menu-renderer/index.scss', + '$lib': './src/lib' + } + } +}; + +export default config; diff --git a/kando-svelte/tsconfig.json b/kando-svelte/tsconfig.json new file mode 100644 index 000000000..3e62c3445 --- /dev/null +++ b/kando-svelte/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/kando-svelte/vite.config.ts b/kando-svelte/vite.config.ts new file mode 100644 index 000000000..acf6f9e2c --- /dev/null +++ b/kando-svelte/vite.config.ts @@ -0,0 +1,13 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + // Use SvelteKit kit.alias from svelte.config.js; avoid duplicating aliases here + server: { + fs: { + // Allow serving parent repo paths (../node_modules) for dev-only font assets + allow: ['..'] + } + } +}); diff --git a/package-lock.json b/package-lock.json index 07a907668..e15be622e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@electron-forge/maker-dmg": "^7.9.0", "@electron-forge/maker-rpm": "^7.9.0", "@electron-forge/maker-squirrel": "^7.9.0", - "@electron-forge/maker-zip": "^7.9.0", "@electron-forge/plugin-webpack": "^7.9.0", "@eslint/compat": "^1.4.0", "@eslint/js": "^9.37.0", @@ -93,7 +92,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.45.0", "typescript-plugin-css-modules": "^5.2.0", - "zod": "^4.1.11", + "zod": "^3.25.76", "zundo": "^2.1.0", "zustand": "^5.0.8" } @@ -352,23 +351,6 @@ "electron-winstaller": "^5.3.0" } }, - "node_modules/@electron-forge/maker-zip": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.9.0.tgz", - "integrity": "sha512-UGeziReiz8yuDTjliOjvbdyulIHpKAWkDeW3kOcMTUmRcCgrCkBNr+Pp6ih8Q3aBhG+CCd4++oe2rDnnuVvxFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron-forge/maker-base": "7.9.0", - "@electron-forge/shared-types": "7.9.0", - "cross-zip": "^4.0.0", - "fs-extra": "^10.0.0", - "got": "^11.8.5" - }, - "engines": { - "node": ">= 16.4.0" - } - }, "node_modules/@electron-forge/plugin-base": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.9.0.tgz", @@ -5497,30 +5479,6 @@ "node": ">= 8" } }, - "node_modules/cross-zip": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cross-zip/-/cross-zip-4.0.1.tgz", - "integrity": "sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=12.10" - } - }, "node_modules/css-loader": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", @@ -18805,9 +18763,9 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index e0653ce67..417e831c4 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@electron-forge/maker-dmg": "^7.9.0", "@electron-forge/maker-rpm": "^7.9.0", "@electron-forge/maker-squirrel": "^7.9.0", - "@electron-forge/maker-zip": "^7.9.0", "@electron-forge/plugin-webpack": "^7.9.0", "@eslint/compat": "^1.4.0", "@eslint/js": "^9.37.0", @@ -107,7 +106,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.45.0", "typescript-plugin-css-modules": "^5.2.0", - "zod": "^4.1.11", + "zod": "^3.25.76", "zundo": "^2.1.0", "zustand": "^5.0.8" } diff --git a/src/common/index.d.ts b/src/common/index.d.ts new file mode 100644 index 000000000..60baa4525 --- /dev/null +++ b/src/common/index.d.ts @@ -0,0 +1,331 @@ +export * from './settings-schemata/menu-settings-v1'; +export * from './settings-schemata'; +/** This type is used to pass command line arguments to the app. */ +export type CommandlineOptions = { + readonly menu?: string; + readonly settings?: boolean; + readonly reloadMenuTheme?: boolean; + readonly reloadSoundTheme?: boolean; +}; +/** + * A simple 2D vector. + * + * You can find some vector math in the `src/renderer/math` directory. + */ +export type Vec2 = { + x: number; + y: number; +}; +/** This type describes some information about the currently used backend. */ +export type BackendInfo = { + /** + * The name of the backend. This is shown in the user interface so that users can see + * which backend is currently active. + */ + readonly name: string; + /** + * Each backend should return a suitable window type here. The window type determines + * how Kando's menu window is drawn. The most suitable type is dependent on the + * operating system and the window manager. For example, on GNOME, the window type + * "dock" seems to work best, on KDE "toolbar" provides a better experience. On Windows, + * "toolbar" is the only type that works. + * https://www.electronjs.org/docs/latest/api/browser-window#new-browserwindowoptions + * + * @returns The window type to use for the pie menu window. + */ + readonly menuWindowType: string; + /** + * There are some backends which do not support custom shortcuts. In this case, the user + * will not be able to change the shortcuts in the settings. Instead, the user will set + * a shortcut ID and then assign a shortcut in the operating system. + */ + readonly supportsShortcuts: boolean; + /** + * This hint is shown in the settings next to the shortcut-id input field if + * supportsShortcuts is false. It should very briefly explain how to change the + * shortcuts in the operating system. If supportsShortcuts is true, this is not + * required. + */ + readonly shortcutHint?: string; + /** This determines whether the settings window should use transparency per default. */ + readonly shouldUseTransparentSettingsWindow: boolean; +}; +/** This type describes some information about the current version of Kando. */ +export type VersionInfo = { + readonly kandoVersion: string; + readonly electronVersion: string; + readonly chromeVersion: string; + readonly nodeVersion: string; +}; +/** + * This type is used to transfer information required from the window manager when opening + * the pie menu. It contains the name of the currently focused app / window, the current + * pointer position, and the screen area where a maximized window can be placed. That is + * the screen resolution minus the taskbar and other panels. + */ +export type WMInfo = { + readonly windowName: string; + readonly appName: string; + readonly pointerX: number; + readonly pointerY: number; + readonly workArea: Electron.Rectangle; +}; +/** + * This type is used to transfer information about the system to the renderer process. It + * will determine the visibility of some UI elements and the availability of some + * features. + */ +export type SystemInfo = { + /** Whether the system supports launching isolated processes. */ + readonly supportsIsolatedProcesses: boolean; +}; +/** This type describes a icon theme consisting of a collection of icon files. */ +export type FileIconThemeDescription = { + /** + * The ID of the theme. This is used to identify the theme in the settings file. It is + * also the directory name of the icon theme. + */ + readonly name: string; + /** + * The absolute path to the directory where the theme is stored, including the name as + * the last part of the path. + */ + readonly directory: string; + /** + * A list of all available icons in this theme. These are the filenames of the icons + * relative to the theme directory. In case of nested directories, the filenames can + * actually be paths. + */ + readonly icons: string[]; +}; +/** + * This type is used to pass information about all available icon themes to the renderer + * process. + */ +export type IconThemesInfo = { + /** The absolute path to the directory where the user may store custom icon themes. */ + readonly userIconDirectory: string; + /** All available file icon themes. */ + readonly fileIconThemes: FileIconThemeDescription[]; +}; +/** + * This type is used to describe an element of a key sequence. It contains the DOM name of + * the key, a boolean indicating whether the key is pressed or released and a delay in + * milliseconds. + */ +export type KeyStroke = { + name: string; + down: boolean; + delay: number; +}; +/** + * This type is used to describe a sequence of key strokes. It is used to simulate + * keyboard shortcuts. + */ +export type KeySequence = Array; +/** + * There are different reasons why a menu should be shown. This type is used to describe + * the request to show a menu. A menu can be shown because a shortcut was pressed (in this + * case `trigger` will be the shortcut or the shortcut ID) or because a menu was requested + * by name. + */ +export type ShowMenuRequest = { + readonly trigger: string; + readonly name: string; +}; +/** + * This type is used to describe the additional information that is passed to the Menu's + * `show()` method from the main to the renderer process. + */ +export type ShowMenuOptions = { + /** + * The position of the mouse cursor when the menu was opened. Relative to the top left + * corner of the window. + */ + readonly mousePosition: Vec2; + /** + * The size of the window. Usually, this is the same as window.innerWidth and + * window.innerHeight. However, when the window was just resized, this can be different. + * Therefore, we need to pass it from the main process. + */ + readonly windowSize: Vec2; + /** + * The scale factor of the menu. This is required to compute the correct position of the + * menu. + */ + readonly zoomFactor: number; + /** + * If this is set, the menu will be opened in the screen's center. Else it will be + * opened at the mouse pointer. + */ + readonly centeredMode: boolean; + /** + * If this is set, the menu will be "anchored". This means that any submenus will be + * opened at the same position as the parent menu. + */ + readonly anchoredMode: boolean; + /** + * If this is set, the menu will be in "hover mode". This means that the menu items can + * be selected by only hovering over them. + */ + readonly hoverMode: boolean; + /** + * If this is set, the system-icon theme has changed since the last time the menu was + * opened. This is used to determine if the menu needs to be reloaded. + */ + readonly systemIconsChanged: boolean; +}; +/** + * The description of a menu theme. These are the properties which can be defined in the + * JSON file of a menu theme. + */ +export type MenuThemeDescription = { + /** + * The ID of the theme. This is used to identify the theme in the settings file. It is + * also the directory name of the theme and is set by Kando when loading the theme.json + * file. So the path to the theme.json file is this.directory/this.id/theme.json. + */ + id: string; + /** + * The absolute path to the directory where the theme is stored. This is set by Kando + * when loading the theme.json file. + */ + directory: string; + /** A human readable name of the theme. */ + readonly name: string; + /** The author of the theme. */ + readonly author: string; + /** The version of the theme. Should be a semantic version string like "1.0.0". */ + readonly themeVersion: string; + /** The version of the Kando theme engine this theme is compatible with. */ + readonly engineVersion: number; + /** The license of the theme. For instance "CC-BY-4.0". */ + readonly license: string; + /** + * The maximum radius in pixels of a menu when using this theme. This is used to move + * the menu away from the screen edges when it's opened too close to them. Default is + * 150px. + */ + readonly maxMenuRadius: number; + /** The width of the text wrap in the center of the menu in pixels. Default is 90px. */ + readonly centerTextWrapWidth: number; + /** + * If this is true, children of a menu item will be drawn below the parent. Otherwise + * they will be drawn above. Default is true. + */ + readonly drawChildrenBelow: boolean; + /** + * If this is set to true, the center text of the menu will be drawn. This is the text + * that is displayed in the center of the menu when it is opened. Default is true. + */ + readonly drawCenterText: boolean; + /** + * If this is set to true, a full-screen div will be drawn below the menu with the CSS + * class "selection-wedges". If any menu item is hovered, it will also receive the class + * "hovered" and the "--start-angle" and "--end-angle" CSS properties will indicate + * where the selected child is. Default is false. + */ + readonly drawSelectionWedges: boolean; + /** + * If this is set to true, a full-screen div will be drawn below the menu with the CSS + * class "wedge-separators". It will contain a div for each separator line between + * adjacent wedges. They will have the "separator" class. Default is false. + */ + readonly drawWedgeSeparators: boolean; + /** + * These colors will be available as var(--name) in the CSS file and can be adjusted by + * the user in the settings. The map assigns a default CSS color to each name. + */ + readonly colors: Record; + /** + * The layers which are drawn on top of each other for each menu item. Each layer will + * be a html div element with a class defined in the theme file. Also, each layer can + * have a `content` property which can be used to make the layer contain the item's icon + * or name. + */ + readonly layers: { + readonly class: string; + readonly content: 'none' | 'name' | 'icon'; + }[]; +}; +/** + * Sound themes can define different sounds for different actions. This enum is used to + * identify the different sounds. + */ +export declare enum SoundType { + eOpenMenu = "openMenu", + eCloseMenu = "closeMenu", + eSelectItem = "selectItem", + eSelectSubmenu = "selectSubmenu", + eSelectParent = "selectParent", + eHoverItem = "hoverItem", + eHoverSubmenu = "hoverSubmenu", + eHoverParent = "hoverParent" +} +/** + * This type is used to describe a sound effect. It contains the path to the sound file + * and some optional properties like the volume and pitch shift. + */ +export type SoundEffect = { + /** The path to the sound file. */ + readonly file: string; + /** The volume of the sound. */ + readonly volume?: number; + /** The maximum pitch shift. */ + readonly maxPitch?: number; + /** The minimum pitch shift. */ + readonly minPitch?: number; +}; +/** + * This type is used to describe a sound theme. It contains the properties which can be + * defined in the JSON file of a sound theme. All paths are relative to the theme + * directory. + */ +export type SoundThemeDescription = { + /** + * The ID of the theme. This is used to identify the theme in the settings file. It is + * also the directory name of the theme and is set by Kando when loading the theme.json + * file. So the path to the theme.json file is this.directory/this.id/theme.json. + */ + id: string; + /** + * The absolute path to the directory where the theme is stored. This is set by Kando + * when loading the theme.json file. + */ + directory: string; + /** A human readable name of the theme. */ + readonly name: string; + /** The author of the theme. */ + readonly author: string; + /** The version of the theme. Should be a semantic version string like "1.0.0". */ + readonly themeVersion: string; + /** The version of the Kando sound theme engine this theme is compatible with. */ + readonly engineVersion: number; + /** The license of the theme. For instance "CC-BY-4.0". */ + readonly license: string; + /** + * All available sound effects. If a given sound is not defined here, no sound will be + * played for the corresponding action. + */ + readonly sounds: Record; +}; +/** + * This type is used to describe an installed application. When the settings window is + * opened, it will query the host process for a list of all installed applications. + */ +export type AppDescription = { + /** + * Some unique identifier for the application. What that is depends on the backend. + * Could be for instance the UWP app ID. If the backend is not able to provide a unique + * ID, it may fall back to using the application command. + */ + readonly id: string; + /** The name of the application. */ + readonly name: string; + /** The command to launch the application. */ + readonly command: string; + /** The icon used for the application. */ + readonly icon: string; + /** The icon theme used for the above icon. */ + readonly iconTheme: string; +}; diff --git a/src/common/math/index.d.ts b/src/common/math/index.d.ts new file mode 100644 index 000000000..106a58174 --- /dev/null +++ b/src/common/math/index.d.ts @@ -0,0 +1,152 @@ +import { Vec2 } from '../../common'; +/** This method returns the the given value clamped to the given range. */ +export declare function clamp(value: number, min: number, max: number): number; +/** This method converts radians to degrees. */ +export declare function toDegrees(radians: number): number; +/** This method converts degrees to radians. */ +export declare function toRadians(degrees: number): number; +/** This method returns the length of the given vector. */ +export declare function getLength(vec: Vec2): number; +/** This method normalizes the given vector. */ +export declare function normalize(vec: Vec2): Vec2; +/** This method returns the distance between the two given vectors. */ +export declare function getDistance(vec1: Vec2, vec2: Vec2): number; +/** This adds two Vec2 together. */ +export declare function add(vec1: Vec2, vec2: Vec2): Vec2; +/** This subtracts vec2 from vec1. */ +export declare function subtract(vec1: Vec2, vec2: Vec2): Vec2; +/** This multiplies a vector with a scalar. */ +export declare function multiply(vec: Vec2, scalar: number): Vec2; +/** + * This returns the angular difference between the two given angles using the shortest + * path. The result will always be between 0° and 180°. + */ +export declare function getAngularDifference(angle1: number, angle2: number): number; +/** + * This method returns the angle which equivalent to the given angle (modulo 360) and + * closest to the given angle. The result can be negative. + */ +export declare function getClosestEquivalentAngle(angle: number, to: number): number; +/** + * Returns the largest angle which is equivalent to the given angle (modulo 360) but + * smaller or equal to the given reference angle. + */ +export declare function getEquivalentAngleSmallerThan(angle: number, than: number): number; +/** + * Returns the smallest angle which is equivalent to the given angle (modulo 360) but + * larger or equal to the given reference angle. + */ +export declare function getEquivalentAngleLargerThan(angle: number, than: number): number; +/** + * This method returns true if the given angle is between the given start and end angles. + * The angles are in degrees and start should be smaller than end. The method also works + * if the angle and the start and end angles are negative or larger than 360°. + * + * @param angle The angle to check. + * @param start The start angle. + * @param end The end angle. + */ +export declare function isAngleBetween(angle: number, start: number, end: number): boolean; +/** + * This method ensures that the given angles have increasing values. To ensure this, they + * will be increased or decreased by 360° if necessary. The center angle will be between + * 0° and 360°. The start angle may be negative and the end angle may be larger than 360°. + * But their mutual difference will be less than 360°. + * + * @param start The first angle. + * @param center The second angle. + * @param end The third angle. + * @returns An array of three angles. + */ +export declare function normalizeConsequtiveAngles(start: number, center: number, end: number): number[]; +/** + * This method returns the angle of the given vector in degrees. 0° is on the top, 90° is + * on the right, 180° is on the bottom and 270° is on the right. The vector does not need + * to be normalized. + */ +export declare function getAngle(vec: Vec2): number; +/** + * This method returns the direction vector for the given angle and length. 0° is on the + * top, 90° is on the right, 180° is on the bottom and 270° is on the right. + */ +export declare function getDirection(angle: number, length: number): Vec2; +/** + * Each menu item can have (but not must must have) a fixed angle. If it does not have a + * fixed angle, it will be automatically computed at run time using the computeItemAngles + * method below. + * + * However, there are some rules for fixed angles in menus. Among sibling items, the angle + * properties must be monotonically increasing, i.e. the first given angle must be smaller + * than the second, which must be smaller than the third, and so on. The first given angle + * must be greater or equal to 0° and all other angles must be smaller than the first + * given angle plus 360°. + * + * This method ensures that these conditions are met. It will increase or decrease all + * angles by multiples of 360° to ensure that the first angle is between 0° and 360° and + * all other angles are monotonically increasing. If there are two items with the same + * angle, the angle of the second one will be removed. Also, any angle which is larger + * than the first angle plus 360° will be removed. + * + * @param items An array of items representing the menu items. Each item can have an + * `angle` property which is a number representing the angle in degrees. The array will + * be modified in-place. + */ +export declare function fixFixedAngles(items: { + angle?: number; +}[]): void; +/** + * This method receives an array of objects, each representing an item in a menu level. + * For each item it computes an angle defining the direction in which the item should be + * rendered. The angles are returned in an array of the same length as the input array. If + * an item in the input array already has an 'angle' property, this is considered a fixed + * angle and all others are distributed more ore less evenly around. This method also + * reserves the required angular space for the back navigation link to the parent item (if + * given). Angles in items are always in degrees, 0° is on the top, 90° on the right, 180° + * on the bottom and so on. Fixed input angles must be monotonically increasing. If this + * is not the case, the smaller angle is ignored. + * + * @param items The Items for which the angles should be computed. They may already have + * an angle property. If so, this is considered a fixed angle. + * @param parentAngle The angle of the parent item. If given, there will be some reserved + * space. + * @returns An array of angles in degrees. + */ +export declare function computeItemAngles(items: { + angle?: number; +}[], parentAngle?: number): number[]; +/** + * Computes the start and end angles of the wedges for the given items. The parent angle + * is optional. If it is given, there will be a gap towards the parent item. + * + * @param itemAngles A list of angles for each item. The angles are in degrees and between + * 0° and 360°. + * @param parentAngle The angle of the parent item. If given, there will be a gap towards + * the parent item. This should be in degrees and between 0° and 360°. + * @returns A list of start and end angles for each item. Each item in the list + * corresponds to the item at the same index in the `itemAngles` list. The start angle + * will always be smaller than the end angle. Consequently, the start angle can be + * negative and the end angle can be larger than 360°. If a parent angle was given, + * there will be an additional `parentWedge` property in the returned object which + * contains the start and end angles of the parent wedge. + */ +export declare function computeItemWedges(itemAngles: number[], parentAngle?: number): { + itemWedges: { + start: number; + end: number; + }[]; + parentWedge?: { + start: number; + end: number; + }; +}; +/** + * Given the center coordinates and maximum radius of a menu, this method returns a new + * position which ensures that the menu and all of its children and grandchildren are + * inside the current monitor's bounds. + * + * @param position The center position of the menu. + * @param radius The maximum radius of the menu. + * @param monitorSize The size of the monitor. + * @returns The clamped position. + */ +export declare function clampToMonitor(position: Vec2, radius: number, monitorSize: Vec2): Vec2; diff --git a/src/common/settings-schemata/general-settings-v1.d.ts b/src/common/settings-schemata/general-settings-v1.d.ts new file mode 100644 index 000000000..999b07f11 --- /dev/null +++ b/src/common/settings-schemata/general-settings-v1.d.ts @@ -0,0 +1,228 @@ +import * as z from 'zod'; +/** + * Starting with Kando 2.1.0, we use zod to define the schema of the general settings. + * This allows us to better validate the settings file. + */ +export declare const GENERAL_SETTINGS_SCHEMA_V1: z.ZodObject<{ + /** + * The last version of Kando. This is used to determine whether the settings file needs + * to be backed up and potentially migrated to a newer version. + */ + version: z.ZodDefault; + /** + * The locale to use. If set to 'auto', the system's locale will be used. If the locale + * is not available, english will be used. + */ + locale: z.ZodDefault; + /** If true, the introduction dialog will be shown when the settings window is opened. */ + showIntroductionDialog: z.ZodDefault; + /** The name of the theme to use for the menu. */ + menuTheme: z.ZodDefault; + /** The name of the theme which should be used for the dark mode. */ + darkMenuTheme: z.ZodDefault; + /** + * The accent color overrides to use for menu themes. The outer key is the theme's ID, + * the inner key is the color's name. The final value is the CSS color. + */ + menuThemeColors: z.ZodDefault>>; + /** + * The accent color overrides to use for the dark mode. The outer key is the theme's ID, + * the inner key is the color's name. The final value is the CSS color. + */ + darkMenuThemeColors: z.ZodDefault>>; + /** + * If enabled, the dark menu theme and dark color variants will be used if the system is + * in dark mode. + */ + enableDarkModeForMenuThemes: z.ZodDefault; + /** The name of the current sound theme. */ + soundTheme: z.ZodDefault; + /** The overall volume of the sound effects. */ + soundVolume: z.ZodDefault; + /** Set this to false to disable the check for new versions. */ + enableVersionCheck: z.ZodDefault; + /** Whether to silently handle read-only config files. */ + ignoreWriteProtectedConfigFiles: z.ZodDefault; + /** The color scheme of the settings window. */ + settingsWindowColorScheme: z.ZodDefault>; + /** + * If set to a transparent style, the settings window will attempt to use some sort of + * transparency. What that means exactly depends on the OS. + */ + settingsWindowFlavor: z.ZodDefault>; + /** The tray icon flavor. */ + trayIconFlavor: z.ZodDefault>; + /** Enable GPU acceleration. */ + hardwareAcceleration: z.ZodDefault; + /** Whether to initialize the menu window when it is opened for the first time. */ + lazyInitialization: z.ZodDefault; + /** A scale factor for the menu. */ + zoomFactor: z.ZodDefault; + /** If true, the settings button will be hidden if not hovered. */ + hideSettingsButton: z.ZodDefault; + /** The position of the settings button. */ + settingsButtonPosition: z.ZodDefault>; + /** Clicking inside this radius will select the parent element. */ + centerDeadZone: z.ZodDefault; + /** + * The distance in pixels at which the parent menu item is placed if a submenu is + * selected close to the parent. + */ + minParentDistance: z.ZodDefault; + /** + * This is the threshold in pixels which is used to differentiate between a click and a + * drag. If the mouse is moved more than this threshold before the mouse button is + * released, an item is dragged. + */ + dragThreshold: z.ZodDefault; + /** The time in milliseconds it takes to fade in the menu. */ + fadeInDuration: z.ZodDefault; + /** The time in milliseconds it takes to fade out the menu. */ + fadeOutDuration: z.ZodDefault; + /** + * If enabled, the menu will not take the input focus when opened. This will disable + * turbo mode. + */ + keepInputFocus: z.ZodDefault; + /** If enabled, items can be selected by dragging the mouse over them. */ + enableMarkingMode: z.ZodDefault; + /** + * If enabled, items can be selected by hovering over them while holding down a keyboard + * key. + */ + enableTurboMode: z.ZodDefault; + /** If true, the mouse pointer will be warped to the center of the menu when necessary. */ + warpMouse: z.ZodDefault; + /** If enabled, menus using the hover mode require a final click for selecting items. */ + hoverModeNeedsConfirmation: z.ZodDefault; + /** Shorter gestures will not lead to selections. */ + gestureMinStrokeLength: z.ZodDefault; + /** Smaller turns will not lead to selections. */ + gestureMinStrokeAngle: z.ZodDefault; + /** Smaller movements will not be considered. */ + gestureJitterThreshold: z.ZodDefault; + /** + * If the pointer is stationary for this many milliseconds, the current item will be + * selected. + */ + gesturePauseTimeout: z.ZodDefault; + /** + * If set to a value greater than 0, items will be instantly selected if the mouse + * travelled more than centerDeadZone + fixedStrokeLength pixels in marking or turbo + * mode. Any other gesture detection based on angles or motion speed will be disabled in + * this case. + */ + fixedStrokeLength: z.ZodDefault; + /** + * If enabled, the parent of a selected item will be selected on a right mouse button + * click. Else the menu will be closed directly. + */ + rmbSelectsParent: z.ZodDefault; + /** + * If disabled, gamepad input will be ignored. This can be useful if the gamepad is not + * connected or if the user prefers to use the mouse. + */ + enableGamepad: z.ZodDefault; + /** + * This button will select the parent item when using a gamepad. Set to -1 to disable. + * See https://w3c.github.io/gamepad/#remapping for the mapping of numbers to buttons. + */ + gamepadBackButton: z.ZodDefault; + /** + * This button will close the menu when using a gamepad. Set to -1 to disable. See + * https://w3c.github.io/gamepad/#remapping for the mapping of numbers to buttons. + */ + gamepadCloseButton: z.ZodDefault; + /** Determines the behavior of pressing the trigger shortcut once the menu is open. */ + sameShortcutBehavior: z.ZodDefault>; + /** + * If enabled, pressing 'cmd + ,' on macOS or 'ctrl + ,' on Linux or Windows will open + * the settings window. If disabled, the default hotkey will be ignored. + */ + useDefaultOsShowSettingsHotkey: z.ZodDefault; +}, "strip", z.ZodTypeAny, { + version: string; + locale: string; + showIntroductionDialog: boolean; + menuTheme: string; + darkMenuTheme: string; + menuThemeColors: Record>; + darkMenuThemeColors: Record>; + enableDarkModeForMenuThemes: boolean; + soundTheme: string; + soundVolume: number; + enableVersionCheck: boolean; + ignoreWriteProtectedConfigFiles: boolean; + settingsWindowColorScheme: "light" | "dark" | "system"; + settingsWindowFlavor: "auto" | "sakura-light" | "sakura-dark" | "sakura-system" | "transparent-light" | "transparent-dark" | "transparent-system"; + trayIconFlavor: "none" | "light" | "dark" | "color" | "black" | "white"; + hardwareAcceleration: boolean; + lazyInitialization: boolean; + zoomFactor: number; + hideSettingsButton: boolean; + settingsButtonPosition: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + centerDeadZone: number; + minParentDistance: number; + dragThreshold: number; + fadeInDuration: number; + fadeOutDuration: number; + keepInputFocus: boolean; + enableMarkingMode: boolean; + enableTurboMode: boolean; + warpMouse: boolean; + hoverModeNeedsConfirmation: boolean; + gestureMinStrokeLength: number; + gestureMinStrokeAngle: number; + gestureJitterThreshold: number; + gesturePauseTimeout: number; + fixedStrokeLength: number; + rmbSelectsParent: boolean; + enableGamepad: boolean; + gamepadBackButton: number; + gamepadCloseButton: number; + sameShortcutBehavior: "cycle-from-first" | "cycle-from-recent" | "close" | "nothing"; + useDefaultOsShowSettingsHotkey: boolean; +}, { + version?: string | undefined; + locale?: string | undefined; + showIntroductionDialog?: boolean | undefined; + menuTheme?: string | undefined; + darkMenuTheme?: string | undefined; + menuThemeColors?: Record> | undefined; + darkMenuThemeColors?: Record> | undefined; + enableDarkModeForMenuThemes?: boolean | undefined; + soundTheme?: string | undefined; + soundVolume?: number | undefined; + enableVersionCheck?: boolean | undefined; + ignoreWriteProtectedConfigFiles?: boolean | undefined; + settingsWindowColorScheme?: "light" | "dark" | "system" | undefined; + settingsWindowFlavor?: "auto" | "sakura-light" | "sakura-dark" | "sakura-system" | "transparent-light" | "transparent-dark" | "transparent-system" | undefined; + trayIconFlavor?: "none" | "light" | "dark" | "color" | "black" | "white" | undefined; + hardwareAcceleration?: boolean | undefined; + lazyInitialization?: boolean | undefined; + zoomFactor?: number | undefined; + hideSettingsButton?: boolean | undefined; + settingsButtonPosition?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | undefined; + centerDeadZone?: number | undefined; + minParentDistance?: number | undefined; + dragThreshold?: number | undefined; + fadeInDuration?: number | undefined; + fadeOutDuration?: number | undefined; + keepInputFocus?: boolean | undefined; + enableMarkingMode?: boolean | undefined; + enableTurboMode?: boolean | undefined; + warpMouse?: boolean | undefined; + hoverModeNeedsConfirmation?: boolean | undefined; + gestureMinStrokeLength?: number | undefined; + gestureMinStrokeAngle?: number | undefined; + gestureJitterThreshold?: number | undefined; + gesturePauseTimeout?: number | undefined; + fixedStrokeLength?: number | undefined; + rmbSelectsParent?: boolean | undefined; + enableGamepad?: boolean | undefined; + gamepadBackButton?: number | undefined; + gamepadCloseButton?: number | undefined; + sameShortcutBehavior?: "cycle-from-first" | "cycle-from-recent" | "close" | "nothing" | undefined; + useDefaultOsShowSettingsHotkey?: boolean | undefined; +}>; +export type GeneralSettingsV1 = z.infer; diff --git a/src/common/settings-schemata/index.d.ts b/src/common/settings-schemata/index.d.ts new file mode 100644 index 000000000..3a4bdaac9 --- /dev/null +++ b/src/common/settings-schemata/index.d.ts @@ -0,0 +1,4 @@ +export type { GeneralSettingsV1 as GeneralSettings } from './general-settings-v1'; +export { GENERAL_SETTINGS_SCHEMA_V1 as GENERAL_SETTINGS_SCHEMA } from './general-settings-v1'; +export type { MenuConditionsV1 as MenuConditions, MenuItemV1 as MenuItem, MenuV1 as Menu, MenuCollectionV1 as MenuCollection, MenuSettingsV1 as MenuSettings, } from './menu-settings-v1'; +export { MENU_SETTINGS_SCHEMA_V1 as MENU_SETTINGS_SCHEMA } from './menu-settings-v1'; diff --git a/src/common/settings-schemata/menu-settings-v1.d.ts b/src/common/settings-schemata/menu-settings-v1.d.ts new file mode 100644 index 000000000..de9f28f30 --- /dev/null +++ b/src/common/settings-schemata/menu-settings-v1.d.ts @@ -0,0 +1,406 @@ +import * as z from 'zod'; +/** + * This type is used to describe the conditions under which a menu should be shown. When a + * menu shall be shown, the conditions of all menus are checked. The menu with the most + * conditions that are met is selected. + */ +export declare const MENU_CONDITIONS_SCHEMA_V1: z.ZodObject<{ + /** Regex to match for a window name */ + windowName: z.ZodOptional>; + /** Regex to match for an application name. */ + appName: z.ZodOptional>; + /** + * Cursor position to match. In pixels relative to the top-left corner of the primary + * display. + */ + screenArea: z.ZodOptional>; + xMax: z.ZodOptional>; + yMin: z.ZodOptional>; + yMax: z.ZodOptional>; + }, "strip", z.ZodTypeAny, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }>>>; +}, "strip", z.ZodTypeAny, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; +}, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; +}>; +/** The menu consists of a tree of menu items. */ +export declare const MENU_ITEM_SCHEMA_V1: any; +/** + * This type describes a menu. It contains the root item of the menu, the shortcut to open + * the menu and a flag indicating whether the menu should be opened in the center of the + * screen or at the mouse pointer. + * + * This type is used to describe one of the configured menus in the settings file. + */ +export declare const MENU_SCHEMA_V1: z.ZodObject<{ + /** The root item of the menu. */ + root: any; + /** + * The shortcut to open the menu. Something like 'Control+Space'. + * + * @todo: Add description of the format of the shortcut string. + */ + shortcut: z.ZodDefault; + /** + * Some backends do not support direct binding of shortcuts. In this case, the user will + * not be able to change the shortcut in the settings. Instead, the user provides an ID + * for the shortcut and can then assign a key binding in the operating system. + */ + shortcutID: z.ZodDefault; + /** + * If true, the menu will open in the screen's center. Else it will open at the mouse + * pointer. + */ + centered: z.ZodDefault; + /** + * If true, the menu will be "anchored". This means that any submenus will be opened at + * the same position as the parent menu. + */ + anchored: z.ZodDefault; + /** + * If true, the menu will be in "hover mode". This means that the menu items can be + * selected by only hovering over them. + */ + hoverMode: z.ZodDefault; + /** + * Conditions are matched before showing a menu. The one that has more conditions and + * met them all is selected. + */ + conditions: z.ZodOptional>; + /** Regex to match for an application name. */ + appName: z.ZodOptional>; + /** + * Cursor position to match. In pixels relative to the top-left corner of the primary + * display. + */ + screenArea: z.ZodOptional>; + xMax: z.ZodOptional>; + yMin: z.ZodOptional>; + yMax: z.ZodOptional>; + }, "strip", z.ZodTypeAny, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }>>>; + }, "strip", z.ZodTypeAny, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + }, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + }>>>; + /** Tags can be used to group and filter menus. */ + tags: z.ZodDefault>; +}, "strip", z.ZodTypeAny, { + shortcut: string; + shortcutID: string; + centered: boolean; + anchored: boolean; + hoverMode: boolean; + tags: string[]; + root?: any; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; +}, { + root?: any; + shortcut?: string | undefined; + shortcutID?: string | undefined; + centered?: boolean | undefined; + anchored?: boolean | undefined; + hoverMode?: boolean | undefined; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; + tags?: string[] | undefined; +}>; +/** The user can create menu collections to group menus according to their tags. */ +export declare const MENU_COLLECTION_SCHEMA_V1: z.ZodObject<{ + /** The name of the collection. */ + name: z.ZodString; + /** The icon of the collection. */ + icon: z.ZodString; + /** The theme from which the above icon should be used. */ + iconTheme: z.ZodString; + /** The tags which should be included in this collection. */ + tags: z.ZodDefault>; +}, "strip", z.ZodTypeAny, { + name: string; + icon: string; + iconTheme: string; + tags: string[]; +}, { + name: string; + icon: string; + iconTheme: string; + tags?: string[] | undefined; +}>; +/** + * This type describes the content of the settings file. It contains the configured menus + * as well as the templates. + */ +export declare const MENU_SETTINGS_SCHEMA_V1: z.ZodObject<{ + /** + * The last version of Kando. This is used to determine whether the settings file needs + * to be backed up and potentially migrated to a newer version. + */ + version: z.ZodDefault; + /** The currently configured menus. */ + menus: z.ZodDefault; + /** + * Some backends do not support direct binding of shortcuts. In this case, the user will + * not be able to change the shortcut in the settings. Instead, the user provides an ID + * for the shortcut and can then assign a key binding in the operating system. + */ + shortcutID: z.ZodDefault; + /** + * If true, the menu will open in the screen's center. Else it will open at the mouse + * pointer. + */ + centered: z.ZodDefault; + /** + * If true, the menu will be "anchored". This means that any submenus will be opened at + * the same position as the parent menu. + */ + anchored: z.ZodDefault; + /** + * If true, the menu will be in "hover mode". This means that the menu items can be + * selected by only hovering over them. + */ + hoverMode: z.ZodDefault; + /** + * Conditions are matched before showing a menu. The one that has more conditions and + * met them all is selected. + */ + conditions: z.ZodOptional>; + /** Regex to match for an application name. */ + appName: z.ZodOptional>; + /** + * Cursor position to match. In pixels relative to the top-left corner of the primary + * display. + */ + screenArea: z.ZodOptional>; + xMax: z.ZodOptional>; + yMin: z.ZodOptional>; + yMax: z.ZodOptional>; + }, "strip", z.ZodTypeAny, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }, { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + }>>>; + }, "strip", z.ZodTypeAny, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + }, { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + }>>>; + /** Tags can be used to group and filter menus. */ + tags: z.ZodDefault>; + }, "strip", z.ZodTypeAny, { + shortcut: string; + shortcutID: string; + centered: boolean; + anchored: boolean; + hoverMode: boolean; + tags: string[]; + root?: any; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; + }, { + root?: any; + shortcut?: string | undefined; + shortcutID?: string | undefined; + centered?: boolean | undefined; + anchored?: boolean | undefined; + hoverMode?: boolean | undefined; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; + tags?: string[] | undefined; + }>, "many">>; + /** The currently configured menu collections. */ + collections: z.ZodDefault>; + }, "strip", z.ZodTypeAny, { + name: string; + icon: string; + iconTheme: string; + tags: string[]; + }, { + name: string; + icon: string; + iconTheme: string; + tags?: string[] | undefined; + }>, "many">>; +}, "strip", z.ZodTypeAny, { + version: string; + menus: { + shortcut: string; + shortcutID: string; + centered: boolean; + anchored: boolean; + hoverMode: boolean; + tags: string[]; + root?: any; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; + }[]; + collections: { + name: string; + icon: string; + iconTheme: string; + tags: string[]; + }[]; +}, { + version?: string | undefined; + menus?: { + root?: any; + shortcut?: string | undefined; + shortcutID?: string | undefined; + centered?: boolean | undefined; + anchored?: boolean | undefined; + hoverMode?: boolean | undefined; + conditions?: { + windowName?: string | null | undefined; + appName?: string | null | undefined; + screenArea?: { + xMin?: number | null | undefined; + xMax?: number | null | undefined; + yMin?: number | null | undefined; + yMax?: number | null | undefined; + } | null | undefined; + } | null | undefined; + tags?: string[] | undefined; + }[] | undefined; + collections?: { + name: string; + icon: string; + iconTheme: string; + tags?: string[] | undefined; + }[] | undefined; +}>; +export type MenuConditionsV1 = z.infer; +export type MenuItemV1 = z.infer; +export type MenuV1 = z.infer; +export type MenuCollectionV1 = z.infer; +export type MenuSettingsV1 = z.infer; diff --git a/src/common/settings-schemata/menu-settings-v1.ts b/src/common/settings-schemata/menu-settings-v1.ts index 7107f49c2..aa2392ca3 100644 --- a/src/common/settings-schemata/menu-settings-v1.ts +++ b/src/common/settings-schemata/menu-settings-v1.ts @@ -98,6 +98,13 @@ export const MENU_SCHEMA_V1 = z.object({ */ shortcutID: z.string().default(''), + /** + * Mouse button bindings to open this menu. Strings like 'right', 'middle', 'left', + * 'x1', 'x2', optionally with modifiers: 'ctrl+right', 'alt+right', 'shift+right', + * 'meta+right'. + */ + mouseBindings: z.array(z.string()).default([]), + /** * If true, the menu will open in the screen's center. Else it will open at the mouse * pointer. diff --git a/src/main/app.ts b/src/main/app.ts index 849d67e73..d35f3b1f7 100644 --- a/src/main/app.ts +++ b/src/main/app.ts @@ -143,6 +143,12 @@ export class KandoApp { this.updateChecker.checkForUpdates(); }); + // Mouse bindings are layered and independent of keyboard shortcuts. + this.backend.on('mouseBinding', (binding: string) => { + this.showMenu({ trigger: binding }); + this.updateChecker.checkForUpdates(); + }); + // We ensure that there is always a menu available. If the user deletes all menus, // we create a new example menu when Kando is started the next time. if (this.menuSettings.get('menus').length === 0) { @@ -866,6 +872,18 @@ export class KandoApp { }); } + // Additionally, pass mouseBindings to the backend so macOS can bind mouse triggers. + try { + const bindings = this.menuSettings + .get('menus') + .flatMap((m) => (m as any).mouseBindings || []) + .filter((s: string) => s && s.length > 0); + await this.backend.bindMouseTriggers(bindings); + } catch (error) { + // Non-fatal; other backends may not implement this. + console.warn('bindMouseTriggers failed:', error); + } + this.bindingShortcuts = false; } diff --git a/src/main/backends/backend.ts b/src/main/backends/backend.ts index c587bcc76..b1a6134f4 100644 --- a/src/main/backends/backend.ts +++ b/src/main/backends/backend.ts @@ -166,6 +166,27 @@ export abstract class Backend extends EventEmitter { */ public abstract simulateKeys(keys: KeySequence): Promise; + /** + * Optional: Bind mouse-button based triggers (backend-specific). Default is no-op. + * Backends that support mouse buttons can override this to start/stop hooks. + * + * @param ids An array of trigger IDs (e.g., 'mouse:right', 'ctrl+mouse:right'). + */ + public async bindMouseTriggers(ids: string[]): Promise { + // Default: do nothing. + } + + /** + * Derived backends should call this when a configured mouse binding is activated. + * This is intentionally separate from keyboard shortcuts to allow non-menu uses later. + * + * @param binding Normalized id like 'right' or 'ctrl+right'. + * @param details Optional extra info (position, modifiers, timestamp). + */ + protected onMouseBinding(binding: string, details?: unknown): void { + this.emit('mouseBinding', binding, details); + } + /** * This binds the given shortcuts globally. What the shortcut strings look like depends * on the backend: diff --git a/src/main/backends/macos/backend.ts b/src/main/backends/macos/backend.ts index f58de3b88..fbd20cdd4 100644 --- a/src/main/backends/macos/backend.ts +++ b/src/main/backends/macos/backend.ts @@ -31,6 +31,10 @@ export class MacosBackend extends Backend { */ private systemIcons: Map = new Map(); + /** Currently bound mouse trigger IDs like 'mouse:right' or 'ctrl+mouse:right'. */ + private mouseTriggerIds: Set = new Set(); + private mouseHookActive = false; + /** * On macOS, the window type is set to 'panel'. This makes sure that the window is * always on top of other windows and that it is shown on all workspaces. @@ -185,6 +189,47 @@ export class MacosBackend extends Backend { } } + /** Bind macOS mouse triggers based on configured mouseBindings (e.g., 'right', 'ctrl+right'). */ + public override async bindMouseTriggers(ids: string[]): Promise { + const desired = new Set(ids.filter((id) => typeof id === 'string')); + + // If no mouse triggers remain, stop hook if running. + if (desired.size === 0) { + this.mouseTriggerIds.clear(); + if (this.mouseHookActive) { + try { native.stopMouseHook?.(); } catch {} + this.mouseHookActive = false; + } + return; + } + + this.mouseTriggerIds = desired; + + if (!this.mouseHookActive) { + try { + native.startMouseHook?.((evt: { type: 'down'|'up'; button: 'left'|'middle'|'right'|'x1'|'x2'; x: number; y: number; mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean } }) => { + if (evt.type !== 'down') return; // open on down + // Build normalized id: 'ctrl+alt+right' (mods in fixed order) + const parts: string[] = []; + if (evt.mods.ctrl) parts.push('ctrl'); + if (evt.mods.alt) parts.push('alt'); + if (evt.mods.shift) parts.push('shift'); + if (evt.mods.meta) parts.push('meta'); + const base = evt.button as string; + parts.push(base); + const id = parts.join('+'); + if (this.mouseTriggerIds.has(id) || this.mouseTriggerIds.has(base)) { + // Emit mouse binding as its own event; app decides what to do (e.g., show menu). + this.onMouseBinding(id, { x: evt.x, y: evt.y, mods: evt.mods, base }); + } + }); + this.mouseHookActive = true; + } catch (e) { + console.error('Failed to start macOS mouse hook:', e); + } + } + } + /** * If one of the given keys in the sequence is not known, an exception will be thrown. * diff --git a/src/main/backends/macos/native/Native.hpp b/src/main/backends/macos/native/Native.hpp index cb7d95574..c9ea81409 100644 --- a/src/main/backends/macos/native/Native.hpp +++ b/src/main/backends/macos/native/Native.hpp @@ -64,10 +64,19 @@ class Native : public Napi::Addon { */ Napi::Value listInstalledApplications(const Napi::CallbackInfo& info); + /** Start/stop a global mouse hook and forward events to JS callback. */ + void startMouseHook(const Napi::CallbackInfo& info); + void stopMouseHook(const Napi::CallbackInfo& info); + // We have to keep track of the current modifier mask to be able to simulate key // presses. uint32_t mLeftModifierMask = 0; uint32_t mRightModifierMask = 0; + + // Mouse hook state + Napi::ThreadSafeFunction mMouseTSFN; + CFMachPortRef mEventTap = nullptr; + CFRunLoopSourceRef mRunLoopSource = nullptr; }; #endif // NATIVE_HPP \ No newline at end of file diff --git a/src/main/backends/macos/native/Native.mm b/src/main/backends/macos/native/Native.mm index 335a542f5..d67b22ec8 100644 --- a/src/main/backends/macos/native/Native.mm +++ b/src/main/backends/macos/native/Native.mm @@ -87,12 +87,28 @@ InstanceMethod("simulateKey", &Native::simulateKey), InstanceMethod("getActiveWindow", &Native::getActiveWindow), InstanceMethod("listInstalledApplications", &Native::listInstalledApplications), + InstanceMethod("startMouseHook", &Native::startMouseHook), + InstanceMethod("stopMouseHook", &Native::stopMouseHook), }); } ////////////////////////////////////////////////////////////////////////////////////////// Native::~Native() { + // Ensure hook is stopped + if (mRunLoopSource) { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), mRunLoopSource, kCFRunLoopCommonModes); + CFRelease(mRunLoopSource); + mRunLoopSource = nullptr; + } + if (mEventTap) { + CFMachPortInvalidate(mEventTap); + CFRelease(mEventTap); + mEventTap = nullptr; + } + if (mMouseTSFN) { + mMouseTSFN.Release(); + } } ////////////////////////////////////////////////////////////////////////////////////////// @@ -238,6 +254,120 @@ ////////////////////////////////////////////////////////////////////////////////////////// +static CGEventRef MouseTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) { + Native* self = (Native*)refcon; + if (!self) return event; + if (type != kCGEventLeftMouseDown && type != kCGEventLeftMouseUp && + type != kCGEventRightMouseDown && type != kCGEventRightMouseUp && + type != kCGEventOtherMouseDown && type != kCGEventOtherMouseUp) { + return event; + } + + CGPoint pos = CGEventGetLocation(event); + // Modifiers + CGEventFlags flags = CGEventGetFlags(event); + bool ctrl = (flags & kCGEventFlagMaskControl) != 0; + bool alt = (flags & kCGEventFlagMaskAlternate) != 0; + bool shift= (flags & kCGEventFlagMaskShift) != 0; + bool meta = (flags & kCGEventFlagMaskCommand) != 0; + + // Button mapping + int64_t number = CGEventGetIntegerValueField(event, kCGMouseEventButtonNumber); + const char* button = "left"; + if (type == kCGEventRightMouseDown || type == kCGEventRightMouseUp) button = "right"; + else if (type == kCGEventOtherMouseDown || type == kCGEventOtherMouseUp) { + if (number == 1) button = "middle"; + else if (number == 3) button = "x1"; + else if (number == 4) button = "x2"; + else button = "middle"; + } else if (type == kCGEventLeftMouseDown || type == kCGEventLeftMouseUp) { + button = "left"; + } + + const char* phase = (type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown || type == kCGEventOtherMouseDown) ? "down" : "up"; + + if (self->mMouseTSFN) { + // Copy values to heap for TSFN + auto x = pos.x; auto y = pos.y; + std::string btn(button); + std::string ph(phase); + self->mMouseTSFN.BlockingCall([x, y, btn, ph, ctrl, alt, shift, meta](Napi::Env env, Napi::Function jsCallback) { + Napi::Object mods = Napi::Object::New(env); + mods.Set("ctrl", Napi::Boolean::New(env, ctrl)); + mods.Set("alt", Napi::Boolean::New(env, alt)); + mods.Set("shift", Napi::Boolean::New(env, shift)); + mods.Set("meta", Napi::Boolean::New(env, meta)); + Napi::Object evt = Napi::Object::New(env); + evt.Set("type", Napi::String::New(env, ph)); + evt.Set("button", Napi::String::New(env, btn)); + evt.Set("x", Napi::Number::New(env, x)); + evt.Set("y", Napi::Number::New(env, y)); + evt.Set("mods", mods); + jsCallback.Call({ evt }); + }); + } + + return event; // do not swallow +} + +void Native::startMouseHook(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + if (info.Length() < 1 || !info[0].IsFunction()) { + Napi::TypeError::New(env, "Callback function expected").ThrowAsJavaScriptException(); + return; + } + + if (mMouseTSFN) { + // Already started; replace callback + mMouseTSFN.Release(); + } + mMouseTSFN = Napi::ThreadSafeFunction::New(env, info[0].As(), "MouseHook", 0, 1); + + if (mEventTap) return; // already active + + if (!CGRequestPostEventAccess()) { + Napi::Error::New(env, "Please give accessibility permissions to Kando!").ThrowAsJavaScriptException(); + return; + } + + mEventTap = CGEventTapCreate(kCGHIDEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionListenOnly, + CGEventMaskBit(kCGEventLeftMouseDown) | + CGEventMaskBit(kCGEventLeftMouseUp) | + CGEventMaskBit(kCGEventRightMouseDown) | + CGEventMaskBit(kCGEventRightMouseUp) | + CGEventMaskBit(kCGEventOtherMouseDown) | + CGEventMaskBit(kCGEventOtherMouseUp), + MouseTapCallback, + this); + if (!mEventTap) { + Napi::Error::New(env, "Failed to create event tap").ThrowAsJavaScriptException(); + return; + } + mRunLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, mEventTap, 0); + CFRunLoopAddSource(CFRunLoopGetCurrent(), mRunLoopSource, kCFRunLoopCommonModes); + CGEventTapEnable(mEventTap, true); +} + +void Native::stopMouseHook(const Napi::CallbackInfo& info) { + if (mRunLoopSource) { + CFRunLoopRemoveSource(CFRunLoopGetCurrent(), mRunLoopSource, kCFRunLoopCommonModes); + CFRelease(mRunLoopSource); + mRunLoopSource = nullptr; + } + if (mEventTap) { + CFMachPortInvalidate(mEventTap); + CFRelease(mEventTap); + mEventTap = nullptr; + } + if (mMouseTSFN) { + mMouseTSFN.Release(); + } +} + +////////////////////////////////////////////////////////////////////////////////////////// + Napi::Value Native::listInstalledApplications(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::Array result = Napi::Array::New(env); diff --git a/src/main/backends/macos/native/index.ts b/src/main/backends/macos/native/index.ts index 58b7e7b69..4a9bb8fb5 100644 --- a/src/main/backends/macos/native/index.ts +++ b/src/main/backends/macos/native/index.ts @@ -43,6 +43,12 @@ export type Native = { id: string; base64Icon: string; }[]; + + /** Start a global mouse hook; callback receives down/up, button, coords, and modifiers. */ + startMouseHook?(cb: (evt: { type: 'down'|'up'; button: 'left'|'middle'|'right'|'x1'|'x2'; x: number; y: number; mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean } }) => void): void; + + /** Stop the global mouse hook, if running. */ + stopMouseHook?(): void; }; const native: Native = require('./../../../../../build/Release/NativeMacOS.node'); diff --git a/src/main/menu-window.ts b/src/main/menu-window.ts index 55a38c5c7..d9904f9ed 100644 --- a/src/main/menu-window.ts +++ b/src/main/menu-window.ts @@ -412,14 +412,24 @@ export class MenuWindow extends BrowserWindow { // Store scores for all menus which match the trigger. const scores: number[] = []; - const useID = !this.kando.getBackend().getBackendInfo().supportsShortcuts; - menus.forEach((menu, index) => { scores[index] = 0; - // If the trigger matches, we set the score to 1. Else we skip this menu. - const trigger = useID ? menu.shortcutID : menu.shortcut; - if (request.trigger === trigger) { + // If the trigger matches any configured binding (keyboard shortcut, shortcutID, or mouseBindings) + // we set the score to 1. Else skip. For mouse bindings, we also allow a base-only + // fallback (e.g., 'right' matching an incoming 'ctrl+right'). + const triggers: string[] = []; + if (menu.shortcut) triggers.push(menu.shortcut); + if (menu.shortcutID) triggers.push(menu.shortcutID); + if ((menu as any).mouseBindings?.length) triggers.push(...(menu as any).mouseBindings); + let matches = triggers.includes(request.trigger); + if (!matches && request.trigger?.includes('+')) { + const base = request.trigger.split('+').pop(); + if (base) { + matches = triggers.includes(base); + } + } + if (matches) { scores[index] += 1; } else { return; diff --git a/src/menu-renderer/center-text.ts b/src/menu-renderer/center-text.ts index 322f35563..5c3473fb1 100644 --- a/src/menu-renderer/center-text.ts +++ b/src/menu-renderer/center-text.ts @@ -65,6 +65,7 @@ export class CenterText { /** Removes the current text from the container. */ public hide() { + try { console.log('[Kando:CenterText.hide]'); } catch {} this.div?.remove(); } @@ -78,6 +79,7 @@ export class CenterText { * current position of the pie menu on the screen. */ public async show(text: string, position: Vec2) { + try { console.log('[Kando:CenterText.show] start', { text, position, diameter: this.diameter }); } catch {} const currentCallCount = ++this.callCount; // If the text is already cached, we can use it directly. @@ -87,6 +89,7 @@ export class CenterText { this.div.style.transform = `translate(${position.x}px, ${position.y}px)`; this.div.style.visibility = 'initial'; this.container.appendChild(this.div); + try { console.log('[Kando:CenterText.show] use cache'); } catch {} return; } @@ -189,6 +192,7 @@ export class CenterText { this.div = stagingDiv; this.div.style.transform = `translate(${position.x}px, ${position.y}px)`; this.div.style.visibility = 'initial'; + try { console.log('[Kando:CenterText.show] applied', { fontSize, textHeight }); } catch {} } else { stagingDiv.remove(); } diff --git a/src/menu-renderer/input-methods/gesture-detector.d.ts b/src/menu-renderer/input-methods/gesture-detector.d.ts new file mode 100644 index 000000000..4e767b8ea --- /dev/null +++ b/src/menu-renderer/input-methods/gesture-detector.d.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'events'; +import { Vec2 } from '../../common'; +/** + * This class detects gestures. It is used to detect marking mode selections in the menu. + * It is fed with motion events and emits a selection event if either the mouse pointer + * was stationary for some time or if the mouse pointer made a sharp turn. + * + * @fires selection - This event is emitted when a selection is detected. The event data + * contains the coordinates of the location where the selection event occurred. + */ +export declare class GestureDetector extends EventEmitter { + /** + * This will be initialized with the coordinates of the first motion event after the + * last reset() call. + */ + private strokeStart; + /** This will be updated with each motion event. */ + private strokeEnd; + /** + * This timer is used to detect pause-events where the pointer was stationary for some + * time. These events also lead to selections. + */ + private timeout; + /** Shorter gestures will not lead to selections. */ + minStrokeLength: number; + /** Smaller turns will not lead to selections. */ + minStrokeAngle: number; + /** Smaller movements will not be considered. */ + jitterThreshold: number; + /** + * If the pointer is stationary for this many milliseconds, the current item will be + * selected. + */ + pauseTimeout: number; + /** + * This is used if fixedStrokeLength is greater than zero to allow for distance-based + * selections. + */ + centerDeadZone: number; + /** + * If set to a value greater than 0, items will be instantly selected if the mouse + * travelled more than centerDeadZone + fixedStrokeLength pixels in marking or turbo + * mode. Any other gesture detection based on angles or motion speed will be disabled in + * this case. + */ + fixedStrokeLength: number; + /** + * This method detects the gestures. It should be called if the mouse pointer was moved + * while the left mouse button is held down. Consider the diagram below: + * + * M + * . + * . + * S -------------------- E + * + * The mouse button was pressed at S (strokeStart) and the moved to E (strokeEnd). When + * the next motion event comes in (M), we compare the directions of S->E with E->M. If + * they differ significantly, this is considered a corner. There are some minimum + * lengths for both vectors - if they are not long enough, nothing is done. If E->M is + * long enough, but there is no corner, E is set to M and we wait for the next motion + * event. + * + * @param event + */ + onMotionEvent(coords: Vec2): void; + /** + * This method resets the gesture detection. For instance, it should be called if the + * left mouse button is released. + * + * @param lastCorner - If the gesture may continue, this parameter can be used to + * provide the last corner of the gesture, e.g. the start of the next stroke. + */ + reset(lastCorner?: Vec2): void; +} diff --git a/src/menu-renderer/input-methods/gesture-detector.ts b/src/menu-renderer/input-methods/gesture-detector.ts index eb99a0376..265bfef25 100644 --- a/src/menu-renderer/input-methods/gesture-detector.ts +++ b/src/menu-renderer/input-methods/gesture-detector.ts @@ -85,6 +85,7 @@ export class GestureDetector extends EventEmitter { * @param event */ public onMotionEvent(coords: Vec2): void { + try { console.log('[Kando:Gesture.onMotion]', coords); } catch {} if (this.strokeStart === null) { // It's the first event of this gesture, so we store the current mouse position as // start and end. There is nothing more to be done. @@ -108,6 +109,7 @@ export class GestureDetector extends EventEmitter { y: this.strokeStart.y + (strokeDir.y / strokeLength) * minStrokeLength, }; + try { console.log('[Kando:Gesture.fixed-length-select]', { idealCoords, minStrokeLength }); } catch {} this.reset(idealCoords); this.emit('selection', idealCoords); } @@ -138,6 +140,7 @@ export class GestureDetector extends EventEmitter { // Emit the selection events if it exceeds the configured threshold. We pass // the coordinates of E for the selection event. if ((angle * 180) / Math.PI > this.minStrokeAngle) { + try { console.log('[Kando:Gesture.corner-select]', { angleDeg: (angle * 180) / Math.PI, strokeEnd: this.strokeEnd }); } catch {} this.reset(this.strokeEnd); this.emit('selection', this.strokeEnd); return; @@ -153,6 +156,7 @@ export class GestureDetector extends EventEmitter { // also lead to selections. if (this.timeout === null) { this.timeout = setTimeout(() => { + try { console.log('[Kando:Gesture.pause-select]', coords); } catch {} this.reset(coords); this.emit('selection', coords); }, this.pauseTimeout); @@ -173,6 +177,7 @@ export class GestureDetector extends EventEmitter { * provide the last corner of the gesture, e.g. the start of the next stroke. */ public reset(lastCorner: Vec2 = null): void { + try { console.log('[Kando:Gesture.reset]', { lastCorner }); } catch {} if (this.timeout !== null) { clearTimeout(this.timeout); this.timeout = null; diff --git a/src/menu-renderer/input-methods/pointer-input.ts b/src/menu-renderer/input-methods/pointer-input.ts index 63fea0259..24c2294c2 100644 --- a/src/menu-renderer/input-methods/pointer-input.ts +++ b/src/menu-renderer/input-methods/pointer-input.ts @@ -110,6 +110,7 @@ export class PointerInput extends InputMethod { /** @inheritdoc */ public setCurrentCenter(center: Vec2, radius: number) { + try { console.log('[Kando:Pointer.setCurrentCenter]', { center, radius }); } catch {} this.update(center, center, this.buttonState); this.gestureDetector.reset(); this.gestureDetector.onMotionEvent(center); @@ -125,6 +126,7 @@ export class PointerInput extends InputMethod { * think it's not an issue. */ public ignoreNextMotionEvents() { + try { console.log('[Kando:Pointer.ignoreNextMotionEvents] set=2'); } catch {} this.ignoreMotionEvents = 2; } @@ -133,6 +135,7 @@ export class PointerInput extends InputMethod { * useful if the menu is not opened under the mouse pointer. */ public deferTurboMode() { + try { console.log('[Kando:Pointer.deferTurboMode]'); } catch {} this.deferredTurboMode = true; } @@ -142,6 +145,7 @@ export class PointerInput extends InputMethod { * @param event The mouse or touch event. */ public onMotionEvent(event: MouseEvent | TouchEvent) { + try { console.log('[Kando:Pointer.motion]', event instanceof MouseEvent ? { x: event.clientX, y: event.clientY, buttons: (event as MouseEvent).buttons } : { x: event.touches?.[0]?.clientX, y: event.touches?.[0]?.clientY }); } catch {} event.preventDefault(); event.stopPropagation(); @@ -210,6 +214,7 @@ export class PointerInput extends InputMethod { * @param event The mouse or touch event. */ public onPointerDownEvent(event: MouseEvent | TouchEvent) { + try { console.log('[Kando:Pointer.down]', event instanceof MouseEvent ? { x: event.clientX, y: event.clientY, button: (event as MouseEvent).button } : { x: event.touches?.[0]?.clientX, y: event.touches?.[0]?.clientY }); } catch {} event.preventDefault(); event.stopPropagation(); @@ -249,17 +254,20 @@ export class PointerInput extends InputMethod { * @param event The mouse or touch event. */ public onPointerUpEvent(event: MouseEvent | TouchEvent) { + try { console.log('[Kando:Pointer.up]', event instanceof MouseEvent ? { x: event.clientX, y: event.clientY, button: (event as MouseEvent).button } : { x: event.changedTouches?.[0]?.clientX, y: event.changedTouches?.[0]?.clientY }); } catch {} event.preventDefault(); event.stopPropagation(); this.gestureDetector.reset(); const clickSelection = this.buttonState === ButtonState.eClicked; + try { console.log('[Kando:Pointer.up:selection-check]', { clickSelection, buttonState: this.buttonState }); } catch {} // Do not trigger marking-mode selections on the center item. const markingModeSelection = this.buttonState === ButtonState.eDragged && math.getDistance(this.pointerPosition, this.centerPosition) > this.centerRadius; + try { console.log('[Kando:Pointer.up:marking-check]', { markingModeSelection, centerRadius: this.centerRadius }); } catch {} if (clickSelection || markingModeSelection) { this.selectCallback(this.pointerPosition, SelectionType.eActiveItem); } diff --git a/src/menu-renderer/menu.ts b/src/menu-renderer/menu.ts index 0dbcef34a..62c50454c 100644 --- a/src/menu-renderer/menu.ts +++ b/src/menu-renderer/menu.ts @@ -152,6 +152,15 @@ export class Menu extends EventEmitter { this.clear(); this.showMenuOptions = showMenuOptions; + try { + console.log('[Kando:Menu.show] opts', { + windowSize: this.showMenuOptions?.windowSize, + mousePosition: this.showMenuOptions?.mousePosition, + centeredMode: this.showMenuOptions?.centeredMode, + anchoredMode: this.showMenuOptions?.anchoredMode, + hoverMode: this.showMenuOptions?.hoverMode + }); + } catch {} // If the pointer is not warped to the center of the menu, we should not enter // turbo-mode right away. @@ -184,7 +193,9 @@ export class Menu extends EventEmitter { this.setupPaths(this.root); this.setupAngles(this.root); this.createNodeTree(this.root, this.container); - this.selectItem(this.root, this.getInitialMenuPosition()); + const initial = this.getInitialMenuPosition(); + try { console.log('[Kando:Menu.show] initialCenter', initial); } catch {} + this.selectItem(this.root, initial); // If required, move the pointer to the center of the menu. if (this.settings.warpMouse && showMenuOptions.centeredMode) { @@ -279,6 +290,7 @@ export class Menu extends EventEmitter { * called if nothing is selected but the menu should be closed. */ public cancel() { + try { console.log('[Kando:Menu.cancel]'); } catch {} if (!this.hideTimeout) { this.soundTheme.playSound(SoundType.eCloseMenu); this.emit('cancel'); @@ -306,6 +318,7 @@ export class Menu extends EventEmitter { }; const onSelection = (coords: Vec2, type: SelectionType) => { + try { console.log('[Kando:Menu.onSelection]', { coords, type }); } catch {} // Ignore all input if the menu is in the process of hiding. if (this.container.classList.contains('hidden')) { return; diff --git a/src/settings-renderer/components/menu-properties/Properties.tsx b/src/settings-renderer/components/menu-properties/Properties.tsx index 89efccfc6..69e768bd2 100644 --- a/src/settings-renderer/components/menu-properties/Properties.tsx +++ b/src/settings-renderer/components/menu-properties/Properties.tsx @@ -44,10 +44,12 @@ export default function Properties() { const editMenu = useMenuSettings((state) => state.editMenu); const editMenuItem = useMenuSettings((state) => state.editMenuItem); const [menuTags, setMenuTags] = React.useState([]); + const [mouseBindings, setMouseBindings] = React.useState([]); // Update the tag editor whenever the selected menu changes. React.useEffect(() => { setMenuTags(menus[selectedMenu]?.tags || []); + setMouseBindings((menus[selectedMenu] as any)?.mouseBindings || []); }, [selectedMenu, menus]); if (selectedMenu === -1 || selectedMenu >= menus.length) { @@ -141,6 +143,25 @@ export default function Properties() { // Show the hotkey selector for the root menu. isRoot ? getShortcutPicker() : null } + { + // Mouse bindings input (simple strings like 'right', 'ctrl+right'). + isRoot ? ( + { + editMenu(selectedMenu, (menu) => { + (menu as any).mouseBindings = newBindings; + return menu; + }); + setMouseBindings(newBindings as string[]); + }} + /> + ) : null + } { // If the selected item is the root of the menu, we show the tag editor. isRoot ? (