@preset.nz/app-kit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 preset.nz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # app-kit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The behaviour of a desktop app, shared across the preset.nz family: one command table feeding the native menu and the toolbar, Undo and Redo that say what they undo, Cmd+Z that never fights a text field, and a small selection store. [`ux-kit`](https://github.com/preset-nz/ux-kit) holds the look; app-kit holds how the app acts.
4
+
5
+ An app declares its commands in Rust and plugs in its history. app-kit builds the menu bar, keeps Undo and Redo titled and gated, and hands the same table to the webview, so a label or an accelerator is written once.
6
+
7
+ Two packages, one repo, one version:
8
+
9
+ | Path | Package | What it is |
10
+ |---|---|---|
11
+ | `src/` | `preset-app-kit` (crate) | Command table, menu builder, `History` trait, Tauri commands |
12
+ | `src-ts/` | `@preset.nz/app-kit` (npm) | `useCommands`, toolbar bridge, history hook, text-field undo, selection store |
13
+
14
+ Pre-1.0: a minor version can break things, a patch never does. Crate and npm versions move together, and the TypeScript types mirror the serde shape by hand.
15
+
16
+ The toolbar bridge renders ux-kit's `ToolbarItems`, so ux-kit is an optional peer; `@preset.nz/app-kit/core` needs none of it. ux-kit never depends on app-kit. Neither depends on rhizome: each app adapts its own history to the trait.
17
+
18
+ ## Rust
19
+
20
+ ```rust
21
+ use preset_app_kit::{AppKit, Command, History, MenuName};
22
+
23
+ impl History for Doc { /* undo_label, redo_label, undo_labels, redo_labels, undo, redo */ }
24
+
25
+ tauri::Builder::default()
26
+ .manage(Doc::new())
27
+ .setup(|app| {
28
+ AppKit::<tauri::Wry>::new("My App")
29
+ .settings() // opt in: Settings… with Cmd+,
30
+ .commands([
31
+ Command::toggle("panel.left", "Show Outline")
32
+ .accelerator("CmdOrCtrl+Alt+S")
33
+ .toolbar_label("Outline")
34
+ .menu(MenuName::View)
35
+ .section(0),
36
+ Command::item("doc.reset", "Reset").accelerator("CmdOrCtrl+Alt+R").disabled(),
37
+ ])
38
+ .on_command(|app, id| match id { "app.settings" => { /* open it */ true } _ => false })
39
+ .install::<Doc>(app.handle())?; // Doc is the managed History
40
+ Ok(())
41
+ })
42
+ .invoke_handler(tauri::generate_handler![
43
+ preset_app_kit::app_kit_commands,
44
+ preset_app_kit::app_kit_menu_state,
45
+ preset_app_kit::app_kit_history,
46
+ preset_app_kit::app_kit_undo,
47
+ preset_app_kit::app_kit_redo,
48
+ preset_app_kit::app_kit_text_menu,
49
+ ])
50
+ ```
51
+
52
+ - **`Command`**: id, menu label, optional toolbar label, Tauri accelerator, `MenuName` (App, File, Edit, View, Window, Help), `section` (items in a section sit together), item or toggle. Undo and Redo are built in; Settings is opt-in.
53
+ - **File menu**: New, Open…, Open Recent ▸, Close, Save, Save As… and Revert to Saved… are built in, run against the app's `Document` behind the close guard. Revert is enabled only for an unsaved document with a file, and asks first, because reloading starts an empty history. Open Recent keeps ten documents, newest first, in `recent-documents.json` in the app's data directory; a file that has gone says so and leaves the list. `note_recent(app, path)` adds a document opened some other way (from the Finder, or by restore), and `recent_documents(app)[0]` is the last document. `.documents_folder(|app| …)` names where the Open and Save panels start for an Untitled document (`~/preset-nz/<App>/…`); the app supplies the lookup, so app-kit doesn't depend on app-folders.
54
+ - **Relaunch**: `.plugin(preset_app_kit::window_state())` on the builder brings each window back at its size and position (`tauri-plugin-window-state`, not visibility). `on_run_event` reopens the last document at launch, the first entry of Open Recent, and opens a document the Finder hands over (double-click, `open some.x`, the Dock), which wins over the restored one. A last document that has gone, or won't load, becomes a note, not a dialog: register `app_kit_document_note` and show what `useDocumentNotes` hands you. For an app with documents, app-kit owns Finder opens, so don't add a second handler.
55
+ - **Menu config**: `.menu_config(include_str!("../menu.toml"))` switches the built-in items on and off: `[file] new, open, open_recent, close, save, save_as, revert` and `[app] settings`. A missing key takes the default (on); an unknown key is an error, so add a test that calls `MenuConfig::parse` on your file. Labels, accelerators and positions stay the standard's. Design: guidance `projects/app-kit/design/menu-config.md`.
56
+ - **Apps without documents**: `install_history::<H: History>` instead of `install::<H: Document>`, for a library such as Strata. The config must turn New, Open, Open Recent, Save, Save As and Revert off; with Close off too, Window gains Close Window on Cmd+W. No close guard, no window-title handling, and it doesn't register `tauri-plugin-dialog`, so the app keeps its own.
57
+ - **`History`**: `undo_label`, `redo_label`, `undo_labels` (oldest first), `redo_labels` (next first), `undo`, `redo`. Implement it on the type you manage as Tauri state. The reads may lock, so call `refresh_history(app)` after a change and never while holding that lock.
58
+ - **`edit_titles`**: the pure rule behind Edit > Undo: "Undo Set Opacity" when there is a step, plain and disabled when there is none, plain and enabled while a text field has focus.
59
+ - **Events**: `command` (id, to the main window), `text-undo` (to the focused window), `app-kit://history` (a `HistoryState`), `app-kit://document` (a `DocumentState`), `app-kit://note` (a string).
60
+ - Commands are plain Tauri commands, not a plugin, so no capability entries are needed.
61
+
62
+ ## TypeScript
63
+
64
+ ```tsx
65
+ import { CommandToolbar, useCommands, nativeContextMenu } from "@preset.nz/app-kit"
66
+
67
+ const { commands, run, shortcut } = useCommands(bindings) // bindings: Record<id, { icon, run, enabled, pressed, ... }>
68
+ <CommandToolbar commands={commands} layout={{ leading: ["panel.left"], groups: [["edit.undo", "edit.redo"]] }} run={run} />
69
+ ```
70
+
71
+ - **`useCommands(bindings)`**: loads the table from Rust, binds `run`, icon and gating by id, follows the history, routes text-field undo and keeps the menu's enabled and checked state in step. `edit.undo` and `edit.redo` need only an icon. `bindCommands`, `useCommandTable`, `useMenuSync`, `toItems` are the parts.
72
+ - **`CommandToolbar`**: ux-kit's `ToolbarItems` fed from the commands.
73
+ - **`@preset.nz/app-kit/core`**: everything except `CommandToolbar` and `toItems`, with no ux-kit import, for apps that use only the menu half. ux-kit is an optional peer.
74
+ - **`useDocument`, `useDocumentNotes(onNote)`**: the live `DocumentState`, and notes about the document (a last document that wouldn't reopen), each delivered once. Show a note as a snackbar.
75
+ - **`useHistory`, `undo`, `redo`**: the live `HistoryState`, and the same undo the menu takes. The Inspector's History list reads `undoLabels` and `redoLabels`.
76
+ - **`useTextUndo`, `useTextFocus`, `isTextField`, `blurField`**: Cmd+Z undoes typing while a text field has focus, the document otherwise.
77
+ - **`createSelection<T>(none)`**: single-select store with `get`, `use`, `select`, `clear`. **`createMultiSelection<T>(key)`**: the opt-in multi-select. **`createStore`, `useStore`**: the small store under both. Wrap them in your own narrow setters; there is no `setSelection`, and selection is not an undo step.
78
+ - **`nativeContextMenu()`**: no WebKit context menu. Right-click in a text field pops up a short native menu (Undo, Redo, Cut, Copy, Paste, Select All; needs `app_kit_text_menu` registered); elsewhere nothing, and app-drawn menus still open.
79
+
80
+ The table arrives from Rust, so a plain browser tab (`just dev-web` in the playground) has no commands and an empty toolbar.
81
+
82
+ ## Consuming it
83
+
84
+ ```toml
85
+ # src-tauri/Cargo.toml
86
+ preset-app-kit = "0.1"
87
+ ```
88
+
89
+ ```sh
90
+ pnpm add @preset.nz/app-kit
91
+ ```
92
+
93
+ `@preset.nz/app-kit/core` is everything except the toolbar, for an app that doesn't use ux-kit; ux-kit is an optional peer.
94
+
95
+ In Vite, add `@preset.nz/app-kit` (with `react`, `react-dom`, `@tauri-apps/api`, `@preset.nz/ux-kit`) to `resolve.dedupe`; a second copy of React fails only in the window. app-kit ships no Tailwind classes, so it needs no `@source` line.
96
+
97
+ ## Development
98
+
99
+ `just check` runs `cargo fmt --check`, clippy, `cargo test`, `tsc` and eslint. MIT.
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@preset.nz/app-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "The behaviour of a preset.nz desktop app: command table, menu sync, Undo and Redo titles, text-field undo routing and a selection store, over the preset-app-kit Rust crate.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/preset-nz/app-kit.git"
9
+ },
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "exports": {
13
+ ".": "./src-ts/index.ts",
14
+ "./core": "./src-ts/core.ts"
15
+ },
16
+ "files": [
17
+ "src-ts"
18
+ ],
19
+ "peerDependencies": {
20
+ "@preset.nz/ux-kit": "^0.1.0",
21
+ "@tauri-apps/api": "^2",
22
+ "react": "^19"
23
+ },
24
+ "devDependencies": {
25
+ "@eslint/js": "^9.39.4",
26
+ "@preset.nz/ux-kit": "^0.1.0",
27
+ "@tauri-apps/api": "^2.11.1",
28
+ "@types/react": "^19.2.17",
29
+ "@types/react-dom": "^19.2.3",
30
+ "eslint": "^9.39.4",
31
+ "eslint-plugin-react-hooks": "^7.1.1",
32
+ "eslint-plugin-react-refresh": "^0.5.3",
33
+ "globals": "^16.5.0",
34
+ "react": "^19.2.7",
35
+ "react-dom": "^19.2.7",
36
+ "typescript": "~5.9.3",
37
+ "typescript-eslint": "^8.61.1"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@preset.nz/ux-kit": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
6
47
  }
@@ -0,0 +1,25 @@
1
+ import type { ComponentProps } from "react"
2
+ import { ToolbarItems } from "@preset.nz/ux-kit"
3
+
4
+ import type { Command } from "./commands"
5
+ import { toItems, type ToolbarLayout } from "./toolbar"
6
+
7
+ type Props = Omit<ComponentProps<typeof ToolbarItems>, "groups" | "leading" | "trailing" | "onCommand"> & {
8
+ commands: Command[]
9
+ layout: ToolbarLayout
10
+ /** From `useCommands`: runs the command the menu would. */
11
+ run: (id: string) => void
12
+ }
13
+
14
+ /** ux-kit's `ToolbarItems`, fed from the command table: same labels, shortcuts and gating as the menu. */
15
+ export function CommandToolbar({ commands, layout, run, ...props }: Props) {
16
+ return (
17
+ <ToolbarItems
18
+ {...props}
19
+ leading={toItems(commands, layout.leading)}
20
+ groups={layout.groups.map((ids) => toItems(commands, ids))}
21
+ trailing={toItems(commands, layout.trailing)}
22
+ onCommand={run}
23
+ />
24
+ )
25
+ }
@@ -0,0 +1,138 @@
1
+ import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
2
+ import { invoke } from "@tauri-apps/api/core"
3
+
4
+ import { redo, undo, useHistory, type HistoryState } from "./history"
5
+ import { useTextUndo } from "./text"
6
+ import { useMenuSync } from "./useMenuSync"
7
+
8
+ /** A command as Rust declares it (`CommandInfo`): the one place ids, labels and accelerators live. */
9
+ export interface CommandSpec {
10
+ id: string
11
+ /** Menu text. */
12
+ label: string
13
+ /** Toolbar text, when it differs from the menu's. */
14
+ toolbarLabel: string | null
15
+ /** Tauri accelerator string, `CmdOrCtrl+Alt+S`. */
16
+ accelerator: string | null
17
+ /** Display form for tooltips, `⌥⌘S`. */
18
+ shortcut: string | null
19
+ menu: "app" | "file" | "edit" | "view" | "domain" | "window" | "help"
20
+ /** The domain menu's title ("Effect"), for `menu: "domain"`. */
21
+ domain: string | null
22
+ /** The submenu of its section ("Add"), if any. */
23
+ submenu: string | null
24
+ /** The op's own category and tags, for the Add submenu, the library and the palette. */
25
+ category: string | null
26
+ tags: string[]
27
+ kind: "item" | "toggle"
28
+ }
29
+
30
+ /** What the app attaches to a command id: what it does, how it looks, when it is available. */
31
+ export interface Binding {
32
+ icon?: ReactNode
33
+ /** Only for text that depends on state ("Light mode" / "Dark mode"). Otherwise the table's label shows. */
34
+ label?: string
35
+ /** Default true. */
36
+ enabled?: boolean
37
+ disabledReason?: string
38
+ /** For toggles. */
39
+ pressed?: boolean
40
+ run?: () => void
41
+ }
42
+
43
+ /** A command bound on both sides: the table's text and shortcut, the app's `run`, icon and gating. */
44
+ export interface Command {
45
+ id: string
46
+ label: string
47
+ icon: ReactNode
48
+ shortcut?: string
49
+ enabled: boolean
50
+ disabledReason?: string
51
+ /** Present for toggles. */
52
+ pressed?: boolean
53
+ run: () => void
54
+ }
55
+
56
+ /** The command table from Rust. Null until it arrives (a few milliseconds; never in a plain browser tab). */
57
+ export function useCommandTable(): CommandSpec[] | null {
58
+ const [table, setTable] = useState<CommandSpec[] | null>(null)
59
+ useEffect(() => {
60
+ invoke<CommandSpec[]>("app_kit_commands").then(setTable, () => {})
61
+ }, [])
62
+ return table
63
+ }
64
+
65
+ /** Undo and redo are built in: their text, gating and action come from the history. */
66
+ function historyBindings(h: HistoryState | null): Record<string, Binding> {
67
+ return {
68
+ "edit.undo": {
69
+ label: h?.undoLabel ? `Undo ${h.undoLabel}` : "Undo",
70
+ enabled: h?.undoLabel != null,
71
+ disabledReason: "Nothing to undo",
72
+ run: () => void undo(),
73
+ },
74
+ "edit.redo": {
75
+ label: h?.redoLabel ? `Redo ${h.redoLabel}` : "Redo",
76
+ enabled: h?.redoLabel != null,
77
+ disabledReason: "Nothing to redo",
78
+ run: () => void redo(),
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Bind the table to the app's behaviour by id. Commands with no binding (or no `run`) stay
85
+ * menu-only. For `edit.undo` and `edit.redo` a binding supplies just the icon; the rest is the history's.
86
+ */
87
+ export function bindCommands(
88
+ table: CommandSpec[],
89
+ bindings: Record<string, Binding>,
90
+ history: HistoryState | null = null,
91
+ ): Command[] {
92
+ const builtin = historyBindings(history)
93
+ return table.flatMap((spec) => {
94
+ const b: Binding = { ...bindings[spec.id], ...builtin[spec.id] }
95
+ const bound = spec.id in bindings || spec.id in builtin
96
+ if (!bound || !b.run) return []
97
+ return [
98
+ {
99
+ id: spec.id,
100
+ label: b.label ?? spec.toolbarLabel ?? spec.label,
101
+ icon: b.icon ?? null,
102
+ shortcut: spec.shortcut ?? undefined,
103
+ enabled: b.enabled ?? true,
104
+ disabledReason: b.disabledReason,
105
+ pressed: spec.kind === "toggle" ? (b.pressed ?? false) : b.pressed,
106
+ run: b.run,
107
+ },
108
+ ]
109
+ })
110
+ }
111
+
112
+ /**
113
+ * The app's whole command wiring in one hook: loads the table, binds it, follows the history,
114
+ * routes text-field undo, and keeps the native menu's enabled and checked state in step.
115
+ * `bindings` should be memoised; the commands are rebuilt when it changes.
116
+ */
117
+ export function useCommands(bindings: Record<string, Binding>) {
118
+ const table = useCommandTable()
119
+ const history = useHistory()
120
+ const textFocus = useTextUndo()
121
+ const commands = useMemo(
122
+ () => (table ? bindCommands(table, bindings, history) : []),
123
+ [table, bindings, history],
124
+ )
125
+ useMenuSync(commands, textFocus)
126
+
127
+ /** Run a command by id, as the menu does: only when enabled. */
128
+ const run = useCallback(
129
+ (id: string) => {
130
+ const c = commands.find((x) => x.id === id)
131
+ if (c?.enabled) c.run()
132
+ },
133
+ [commands],
134
+ )
135
+ /** A command's display shortcut, for anything that shows one (a panel header's tooltip). */
136
+ const shortcut = useCallback((id: string) => table?.find((s) => s.id === id)?.shortcut ?? undefined, [table])
137
+ return { commands, run, shortcut, history }
138
+ }
package/src-ts/core.ts ADDED
@@ -0,0 +1,23 @@
1
+ // The menu half of app-kit: commands, history, text-field undo, selection and stores, with
2
+ // no dependency on ux-kit. `index.ts` adds the toolbar bridge on top.
3
+ export {
4
+ bindCommands,
5
+ useCommands,
6
+ useCommandTable,
7
+ type Binding,
8
+ type Command,
9
+ type CommandSpec,
10
+ } from "./commands"
11
+ export { useDocument, useDocumentNotes, type DocumentState } from "./document"
12
+ export { redo, undo, useHistory, type HistoryState } from "./history"
13
+ export { nativeContextMenu } from "./nativeContextMenu"
14
+ export {
15
+ createMultiSelection,
16
+ createSelection,
17
+ insertIndex,
18
+ type MultiSelection,
19
+ type Selection,
20
+ } from "./selection"
21
+ export { createStore, useStore, type Store } from "./store"
22
+ export { blurField, isTextField, useTextFocus, useTextUndo } from "./text"
23
+ export { useMenuSync } from "./useMenuSync"
@@ -0,0 +1,58 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { invoke } from "@tauri-apps/api/core"
3
+ import { listen } from "@tauri-apps/api/event"
4
+
5
+ /** The document as Rust reports it (`DocumentState`): from `app_kit_document`, pushed as `app-kit://document`. */
6
+ export interface DocumentState {
7
+ /** `Untitled-1` or the file's name. */
8
+ name: string
9
+ path: string | null
10
+ unsaved: boolean
11
+ /** The window title: the name, then ` *` while unsaved. Rust sets it; shown here for reference. */
12
+ title: string
13
+ }
14
+
15
+ /** The live document state, or null until the first read. The status bar reads `unsaved`. */
16
+ export function useDocument(): DocumentState | null {
17
+ const [state, setState] = useState<DocumentState | null>(null)
18
+ useEffect(() => {
19
+ let live = true
20
+ invoke<DocumentState>("app_kit_document").then(
21
+ (s) => live && setState((cur) => cur ?? s),
22
+ () => {},
23
+ )
24
+ const un = listen<DocumentState>("app-kit://document", (e) => setState(e.payload))
25
+ return () => {
26
+ live = false
27
+ un.then((f) => f())
28
+ }
29
+ }, [])
30
+ return state
31
+ }
32
+
33
+ /**
34
+ * Notes about the document for the app to show, such as a last document that wouldn't reopen
35
+ * at launch. Each arrives once: the ones held from before the webview listened, then any later
36
+ * `app-kit://note`. Show them as the app shows a passing message (a snackbar), never a dialog.
37
+ */
38
+ export function useDocumentNotes(onNote: (message: string) => void): void {
39
+ const handler = useRef(onNote)
40
+ useEffect(() => {
41
+ handler.current = onNote
42
+ }, [onNote])
43
+ useEffect(() => {
44
+ let live = true
45
+ const un = listen<string>("app-kit://note", (e) => handler.current(e.payload))
46
+ // Ask only once listening, so nothing falls between the held notes and the event. And only
47
+ // while still mounted: taking them is once-only, so an unmounted effect (StrictMode mounts
48
+ // twice in dev) must leave them for the next.
49
+ un.then(() => (live ? invoke<string[]>("app_kit_document_note") : [])).then(
50
+ (held) => live && held.forEach((m) => handler.current(m)),
51
+ () => {},
52
+ )
53
+ return () => {
54
+ live = false
55
+ un.then((f) => f())
56
+ }
57
+ }, [])
58
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useState } from "react"
2
+ import { invoke } from "@tauri-apps/api/core"
3
+ import { listen } from "@tauri-apps/api/event"
4
+
5
+ import { blurField } from "./text"
6
+
7
+ /** The history as Rust reports it (`HistoryState`): from the `app_kit_history` command, pushed as `app-kit://history`. */
8
+ export interface HistoryState {
9
+ undoLabel: string | null
10
+ redoLabel: string | null
11
+ /** Steps done. */
12
+ historyLen: number
13
+ /** Oldest first. */
14
+ undoLabels: string[]
15
+ /** Next first. */
16
+ redoLabels: string[]
17
+ }
18
+
19
+ /** The live history, or null until the first read. */
20
+ export function useHistory(): HistoryState | null {
21
+ const [state, setState] = useState<HistoryState | null>(null)
22
+ useEffect(() => {
23
+ let live = true
24
+ invoke<HistoryState>("app_kit_history").then(
25
+ (s) => live && setState((cur) => cur ?? s),
26
+ () => {},
27
+ )
28
+ const un = listen<HistoryState>("app-kit://history", (e) => setState(e.payload))
29
+ return () => {
30
+ live = false
31
+ un.then((f) => f())
32
+ }
33
+ }, [])
34
+ return state
35
+ }
36
+
37
+ /** Undo one step, the one the menu item takes. Blurs a focused field first so its text commits as an edit. */
38
+ export function undo(): Promise<void> {
39
+ blurField()
40
+ return invoke<void>("app_kit_undo")
41
+ }
42
+
43
+ export function redo(): Promise<void> {
44
+ blurField()
45
+ return invoke<void>("app_kit_redo")
46
+ }
@@ -0,0 +1,5 @@
1
+ // Everything: the core plus the ux-kit toolbar bridge. Apps without ux-kit import
2
+ // "@preset.nz/app-kit/core" instead.
3
+ export * from "./core"
4
+ export { CommandToolbar } from "./CommandToolbar"
5
+ export { toItems, type ToolbarLayout } from "./toolbar"
@@ -0,0 +1,22 @@
1
+ import { invoke } from "@tauri-apps/api/core"
2
+
3
+ import { isTextField } from "./text"
4
+
5
+ /**
6
+ * Native context menus only (native-apps.md): right-click opens an app-drawn menu, the short
7
+ * native text menu, or nothing, never WebKit's Look Up / Translate / Inspect Element.
8
+ * - In an editable text field: WebKit's menu is prevented and `app_kit_text_menu` pops up a
9
+ * native Undo / Redo / Cut / Copy / Paste / Select All at the pointer.
10
+ * - Anywhere else: nothing.
11
+ * - App-drawn menus (the kit's ContextMenu) call preventDefault themselves; this leaves them be.
12
+ * Returns the cleanup.
13
+ */
14
+ export function nativeContextMenu(): () => void {
15
+ const onContextMenu = (e: MouseEvent) => {
16
+ if (e.defaultPrevented) return
17
+ e.preventDefault()
18
+ if (isTextField(e.target)) void invoke("app_kit_text_menu")
19
+ }
20
+ document.addEventListener("contextmenu", onContextMenu)
21
+ return () => document.removeEventListener("contextmenu", onContextMenu)
22
+ }
@@ -0,0 +1,81 @@
1
+ // Interaction state, kept out of the document (guidance/design/interaction-state.md): small
2
+ // stores, a discriminated union, narrow setters, no `setSelection`. Selection is not an undo
3
+ // step: undo and redo leave it alone. A selection whose target is gone must read as none; the
4
+ // app knows what exists, so it resolves the item against its document before using it.
5
+ //
6
+ // An app wraps these in its own narrow setters, `selectLayer = (id) => layer.select({ kind: "layer", id })`,
7
+ // and exports those, never the store.
8
+
9
+ import { createStore, useStore } from "./store"
10
+
11
+ /** One selected item of type `T`, or `none`. `T` is usually a discriminated union with a `none` member. */
12
+ export interface Selection<T> {
13
+ get(): T
14
+ /** Read from a component. */
15
+ use(): T
16
+ select(item: T): void
17
+ /** Back to `none`. */
18
+ clear(): void
19
+ subscribe(listener: () => void): () => void
20
+ }
21
+
22
+ /** A single-select store. `none` is the value that means nothing is selected. */
23
+ export function createSelection<T>(none: T): Selection<T> {
24
+ const store = createStore<T>(none)
25
+ return {
26
+ get: store.get,
27
+ use: () => useStore(store),
28
+ select: (item) => store.set(item),
29
+ clear: () => store.set(none),
30
+ subscribe: store.subscribe,
31
+ }
32
+ }
33
+
34
+ /** Several selected items. Opt in where an app has multi-select; most do not. */
35
+ export interface MultiSelection<T> {
36
+ get(): readonly T[]
37
+ use(): readonly T[]
38
+ has(item: T): boolean
39
+ /** Replace the selection with one item. */
40
+ select(item: T): void
41
+ /** Replace the selection with these items. */
42
+ selectMany(items: readonly T[]): void
43
+ /** Add the item, or remove it when already selected. */
44
+ toggle(item: T): void
45
+ add(item: T): void
46
+ remove(item: T): void
47
+ clear(): void
48
+ subscribe(listener: () => void): () => void
49
+ }
50
+
51
+ /** A multi-select store. `key` says when two items are the same item. Order is selection order. */
52
+ export function createMultiSelection<T>(key: (item: T) => string): MultiSelection<T> {
53
+ const store = createStore<readonly T[]>([])
54
+ const has = (item: T) => store.get().some((x) => key(x) === key(item))
55
+ const without = (item: T) => store.get().filter((x) => key(x) !== key(item))
56
+ return {
57
+ get: store.get,
58
+ use: () => useStore(store),
59
+ has,
60
+ select: (item) => store.set([item]),
61
+ selectMany: (items) => store.set([...items]),
62
+ toggle: (item) => store.set(has(item) ? without(item) : [...store.get(), item]),
63
+ add: (item) => {
64
+ if (!has(item)) store.set([...store.get(), item])
65
+ },
66
+ remove: (item) => store.set(without(item)),
67
+ clear: () => store.set([]),
68
+ subscribe: store.subscribe,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Where a new item goes (`ux-patterns.md`, menu-standard decision 6): straight after the selected
74
+ * item, or at the end when nothing in `items` is selected. Returns the index to insert at.
75
+ */
76
+ export function insertIndex<T>(items: readonly T[], isSelected: (item: T) => boolean): number {
77
+ for (let i = items.length - 1; i >= 0; i--) {
78
+ if (isSelected(items[i])) return i + 1
79
+ }
80
+ return items.length
81
+ }
@@ -0,0 +1,29 @@
1
+ import { useSyncExternalStore } from "react"
2
+
3
+ /** A tiny external store: one value, subscribers, no setter wider than the owner chooses to export. */
4
+ export interface Store<T> {
5
+ get(): T
6
+ set(next: T): void
7
+ subscribe(listener: () => void): () => void
8
+ }
9
+
10
+ export function createStore<T>(initial: T): Store<T> {
11
+ let state = initial
12
+ const listeners = new Set<() => void>()
13
+ return {
14
+ get: () => state,
15
+ set(next) {
16
+ if (Object.is(next, state)) return
17
+ state = next
18
+ listeners.forEach((l) => l())
19
+ },
20
+ subscribe(l) {
21
+ listeners.add(l)
22
+ return () => void listeners.delete(l)
23
+ },
24
+ }
25
+ }
26
+
27
+ /** Read a store from a component; it re-renders when the value changes. */
28
+ export const useStore = <T>(store: Pick<Store<T>, "get" | "subscribe">): T =>
29
+ useSyncExternalStore(store.subscribe, store.get)
package/src-ts/text.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from "react"
2
+ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
3
+
4
+ const TEXT_INPUT_TYPES = new Set(["", "text", "search", "url", "email", "tel", "password", "number"])
5
+
6
+ /** True for elements that have their own typing undo: text inputs, textareas, contenteditable. */
7
+ export function isTextField(el: EventTarget | Element | null): boolean {
8
+ if (!(el instanceof HTMLElement)) return false
9
+ if (el instanceof HTMLTextAreaElement) return !el.readOnly && !el.disabled
10
+ if (el instanceof HTMLInputElement) return TEXT_INPUT_TYPES.has(el.type) && !el.readOnly && !el.disabled
11
+ return el.isContentEditable
12
+ }
13
+
14
+ /**
15
+ * Whether an editable text field has focus in this window. Checkbox, colour, range and other
16
+ * non-text inputs do not count.
17
+ */
18
+ export function useTextFocus(): boolean {
19
+ const [focused, setFocused] = useState(() => isTextField(document.activeElement))
20
+ useEffect(() => {
21
+ const onIn = (e: FocusEvent) => setFocused(isTextField(e.target))
22
+ const onOut = (e: FocusEvent) => setFocused(isTextField(e.relatedTarget))
23
+ // The window losing focus keeps document.activeElement; the menu must not treat it as typing.
24
+ const onWinBlur = () => setFocused(false)
25
+ const onWinFocus = () => setFocused(isTextField(document.activeElement))
26
+ document.addEventListener("focusin", onIn)
27
+ document.addEventListener("focusout", onOut)
28
+ window.addEventListener("blur", onWinBlur)
29
+ window.addEventListener("focus", onWinFocus)
30
+ return () => {
31
+ document.removeEventListener("focusin", onIn)
32
+ document.removeEventListener("focusout", onOut)
33
+ window.removeEventListener("blur", onWinBlur)
34
+ window.removeEventListener("focus", onWinFocus)
35
+ }
36
+ }, [])
37
+ // WebKit fires no focusout when the focused field is removed from the page (a name field
38
+ // that unmounts on Enter), which would leave Undo stuck on the field. While a field has
39
+ // focus, recheck whenever nodes go away.
40
+ useEffect(() => {
41
+ if (!focused) return
42
+ const observer = new MutationObserver(() => {
43
+ if (!isTextField(document.activeElement)) setFocused(false)
44
+ })
45
+ observer.observe(document.body, { childList: true, subtree: true })
46
+ return () => observer.disconnect()
47
+ }, [focused])
48
+ return focused
49
+ }
50
+
51
+ /**
52
+ * Text fields keep their native typing undo. Edit > Undo and Redo are custom menu items, so
53
+ * the menu would swallow Cmd+Z; instead, while a text field has focus:
54
+ * 1. this hook reports `textFocus` (`useCommands` passes it to `useMenuSync`, which sends it);
55
+ * 2. Rust shows plain "Undo" and "Redo" and, on select, emits `text-undo` ("undo" | "redo")
56
+ * to the focused window instead of touching the document;
57
+ * 3. this hook runs `document.execCommand` on the focused field.
58
+ * Fields commit on blur or Enter as one labelled edit, so the two undo scopes do not overlap.
59
+ * `execCommand` is deprecated but is the only way to drive WebKit's field undo stack.
60
+ */
61
+ export function useTextUndo(): boolean {
62
+ const textFocus = useTextFocus()
63
+ useEffect(() => {
64
+ const un = getCurrentWebviewWindow().listen<"undo" | "redo">("text-undo", (e) => {
65
+ document.execCommand(e.payload)
66
+ })
67
+ return () => {
68
+ un.then((f) => f())
69
+ }
70
+ }, [])
71
+ return textFocus
72
+ }
73
+
74
+ /** Commit a focused field (its onBlur) before a document-level undo or redo from a button. */
75
+ export function blurField() {
76
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
77
+ }
@@ -0,0 +1,31 @@
1
+ import type { ToolbarItemSpec } from "@preset.nz/ux-kit"
2
+
3
+ import type { Command } from "./commands"
4
+
5
+ // The ux-kit half of the command wiring. Kept apart from `commands.ts` so the `./core` entry
6
+ // never reaches ux-kit, for apps that only use the menu half (Strata, Fault).
7
+
8
+ /** The toolbar is a subset of the commands: these ids, in this order, in these groups. */
9
+ export interface ToolbarLayout {
10
+ leading?: string[]
11
+ groups: string[][]
12
+ trailing?: string[]
13
+ }
14
+
15
+ export function toItems(commands: Command[], ids: string[] = []): ToolbarItemSpec[] {
16
+ return ids.flatMap((id) => {
17
+ const c = commands.find((x) => x.id === id)
18
+ if (!c) return []
19
+ return [
20
+ {
21
+ id: c.id,
22
+ label: c.label,
23
+ icon: c.icon,
24
+ shortcut: c.shortcut,
25
+ enabled: c.enabled,
26
+ disabledReason: c.disabledReason,
27
+ pressed: c.pressed,
28
+ },
29
+ ]
30
+ })
31
+ }
@@ -0,0 +1,42 @@
1
+ import { useEffect, useRef } from "react"
2
+ import { invoke } from "@tauri-apps/api/core"
3
+ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"
4
+
5
+ import type { Command } from "./commands"
6
+
7
+ /** Rust owns these: Undo and Redo follow the history, Revert to Saved… the document. */
8
+ const RUST_GATED_IDS = new Set(["edit.undo", "edit.redo", "file.revert"])
9
+
10
+ /**
11
+ * Connects the commands to the native menu: menu items arrive as `command` events carrying
12
+ * the command id, and enabled and checked state goes back through `app_kit_menu_state`.
13
+ * Undo and redo are not sent; Rust sets their titles from the history, or from `textFocus`
14
+ * (see text.ts) while a text field has focus.
15
+ */
16
+ export function useMenuSync(commands: Command[], textFocus: boolean) {
17
+ const latest = useRef(commands)
18
+ // Declared before the effects below, so they see the commands of this render.
19
+ useEffect(() => {
20
+ latest.current = commands
21
+ })
22
+
23
+ useEffect(() => {
24
+ const un = getCurrentWebviewWindow().listen<string>("command", (e) => {
25
+ const c = latest.current.find((x) => x.id === e.payload)
26
+ if (c?.enabled) c.run()
27
+ })
28
+ return () => {
29
+ un.then((f) => f())
30
+ }
31
+ }, [])
32
+
33
+ const key = JSON.stringify(commands.map((c) => [c.id, c.enabled, c.pressed])) + textFocus
34
+ useEffect(() => {
35
+ const states = latest.current
36
+ .filter((c) => !RUST_GATED_IDS.has(c.id))
37
+ .map((c) => ({ id: c.id, enabled: c.enabled, checked: c.pressed }))
38
+ invoke("app_kit_menu_state", { states, textFocus }).catch(() => {})
39
+ // `key` stands for the commands' menu-relevant state; `latest` holds the commands.
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [key])
42
+ }