feat(webui): add keybindings editor UI in config page
- commit
- 7e934a87ae383f6eb00dc78cc42b305af7de5cc0
- author
- bnema · · 2026-01-11 14:21 UTC
- committer
- brice · · 2026-01-24 06:22 UTC
- parent
- 5d469a0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +517 -0
M webui/src/pages/ConfigPage.svelte +6 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | import { ModeWatcher } from "mode-watcher"; | |
| 4 | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | |
| 5 | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | |
| 6 | + | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |
| 6 | 7 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |
| 7 | 8 | import { Button } from "$lib/components/ui/button"; | |
| 8 | 9 | import { ColorPicker } from "$lib/components/ui/color-picker"; | |
| @@ -298,6 +299,7 @@ | |||
| 298 | 299 | <Tabs.List class="bg-muted/60"> | |
| 299 | 300 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | |
| 300 | 301 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | |
| 302 | + | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |
| 301 | 303 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | |
| 302 | 304 | </Tabs.List> | |
| 303 | 305 | </div> | |
| @@ -424,6 +426,10 @@ | |||
| 424 | 426 | </Card.Root> | |
| 425 | 427 | </Tabs.Content> | |
| 426 | 428 | ||
| 429 | + | <Tabs.Content value="keybindings"> | |
| 430 | + | <KeybindingsTab /> | |
| 431 | + | </Tabs.Content> | |
| 432 | + | ||
| 427 | 433 | <Tabs.Content value="performance"> | |
| 428 | 434 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 429 | 435 | <Card.Header> | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | import { ModeWatcher } from "mode-watcher"; | 3 | import { ModeWatcher } from "mode-watcher"; | ||
| 4 | import ConfigShell from "./config/ConfigShell.svelte"; | 4 | import ConfigShell from "./config/ConfigShell.svelte"; | ||
| 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | 5 | import ShortcutsTable from "./config/ShortcutsTable.svelte"; | ||
| 6 | + | import KeybindingsTab from "./config/KeybindingsTab.svelte"; | |||
| 6 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | 7 | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | ||
| 7 | import { Button } from "$lib/components/ui/button"; | 8 | import { Button } from "$lib/components/ui/button"; | ||
| 8 | import { ColorPicker } from "$lib/components/ui/color-picker"; | 9 | import { ColorPicker } from "$lib/components/ui/color-picker"; | ||
| @@ -298,6 +299,7 @@ | |||||
| 298 | <Tabs.List class="bg-muted/60"> | 299 | <Tabs.List class="bg-muted/60"> | ||
| 299 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | 300 | <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger> | ||
| 300 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | 301 | <Tabs.Trigger value="search">Search</Tabs.Trigger> | ||
| 302 | + | <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger> | |||
| 301 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | 303 | <Tabs.Trigger value="performance">Performance</Tabs.Trigger> | ||
| 302 | </Tabs.List> | 304 | </Tabs.List> | ||
| 303 | </div> | 305 | </div> | ||
| @@ -424,6 +426,10 @@ | |||||
| 424 | </Card.Root> | 426 | </Card.Root> | ||
| 425 | </Tabs.Content> | 427 | </Tabs.Content> | ||
| 426 | 428 | ||||
| 429 | + | <Tabs.Content value="keybindings"> | |||
| 430 | + | <KeybindingsTab /> | |||
| 431 | + | </Tabs.Content> | |||
| 432 | + | ||||
| 427 | <Tabs.Content value="performance"> | 433 | <Tabs.Content value="performance"> | ||
| 428 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | 434 | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | ||
| 429 | <Card.Header> | 435 | <Card.Header> | ||
A webui/src/pages/config/KeyCaptureModal.svelte +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from "svelte"; | |
| 3 | + | import { Button } from "$lib/components/ui/button"; | |
| 4 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |
| 5 | + | import { Keyboard, X, Plus } from "@lucide/svelte"; | |
| 6 | + | ||
| 7 | + | type KeybindingEntry = { | |
| 8 | + | action: string; | |
| 9 | + | description: string; | |
| 10 | + | keys: string[]; | |
| 11 | + | default_keys: string[]; | |
| 12 | + | is_custom: boolean; | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | interface Props { | |
| 16 | + | binding: KeybindingEntry; | |
| 17 | + | onSave: (keys: string[]) => void; | |
| 18 | + | onCancel: () => void; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | let { binding, onSave, onCancel }: Props = $props(); | |
| 22 | + | ||
| 23 | + | // Use $derived for initial keys to maintain reactivity if binding changes | |
| 24 | + | let newKeys = $state<string[]>([]); | |
| 25 | + | let isCapturing = $state(false); | |
| 26 | + | let capturedKey = $state<string | null>(null); | |
| 27 | + | let initialized = $state(false); | |
| 28 | + | ||
| 29 | + | // Initialize keys from binding on mount | |
| 30 | + | $effect(() => { | |
| 31 | + | if (!initialized) { | |
| 32 | + | newKeys = [...binding.keys]; | |
| 33 | + | initialized = true; | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | function handleKeyDown(e: KeyboardEvent) { | |
| 38 | + | if (!isCapturing) return; | |
| 39 | + | e.preventDefault(); | |
| 40 | + | e.stopPropagation(); | |
| 41 | + | ||
| 42 | + | const parts: string[] = []; | |
| 43 | + | if (e.ctrlKey) parts.push("ctrl"); | |
| 44 | + | if (e.altKey) parts.push("alt"); | |
| 45 | + | if (e.shiftKey) parts.push("shift"); | |
| 46 | + | ||
| 47 | + | let key = e.key.toLowerCase(); | |
| 48 | + | switch (key) { | |
| 49 | + | case "control": | |
| 50 | + | case "alt": | |
| 51 | + | case "shift": | |
| 52 | + | case "meta": | |
| 53 | + | return; // Modifier only, wait for actual key | |
| 54 | + | case "arrowleft": | |
| 55 | + | key = "arrowleft"; | |
| 56 | + | break; | |
| 57 | + | case "arrowright": | |
| 58 | + | key = "arrowright"; | |
| 59 | + | break; | |
| 60 | + | case "arrowup": | |
| 61 | + | key = "arrowup"; | |
| 62 | + | break; | |
| 63 | + | case "arrowdown": | |
| 64 | + | key = "arrowdown"; | |
| 65 | + | break; | |
| 66 | + | case "escape": | |
| 67 | + | isCapturing = false; | |
| 68 | + | capturedKey = null; | |
| 69 | + | return; | |
| 70 | + | case " ": | |
| 71 | + | key = "space"; | |
| 72 | + | break; | |
| 73 | + | case "enter": | |
| 74 | + | key = "enter"; | |
| 75 | + | break; | |
| 76 | + | case "tab": | |
| 77 | + | key = "tab"; | |
| 78 | + | break; | |
| 79 | + | case "backspace": | |
| 80 | + | key = "backspace"; | |
| 81 | + | break; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | parts.push(key); | |
| 85 | + | capturedKey = parts.join("+"); | |
| 86 | + | isCapturing = false; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | function addCapturedKey() { | |
| 90 | + | if (capturedKey && !newKeys.includes(capturedKey)) { | |
| 91 | + | newKeys = [...newKeys, capturedKey]; | |
| 92 | + | } | |
| 93 | + | capturedKey = null; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function removeKey(index: number) { | |
| 97 | + | newKeys = newKeys.filter((_, i) => i !== index); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | function formatKey(key: string): string { | |
| 101 | + | return key | |
| 102 | + | .replace(/arrowleft/gi, "Left") | |
| 103 | + | .replace(/arrowright/gi, "Right") | |
| 104 | + | .replace(/arrowup/gi, "Up") | |
| 105 | + | .replace(/arrowdown/gi, "Down") | |
| 106 | + | .replace(/\+/g, " + ") | |
| 107 | + | .replace(/ctrl/gi, "Ctrl") | |
| 108 | + | .replace(/alt/gi, "Alt") | |
| 109 | + | .replace(/shift/gi, "Shift"); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | onMount(() => { | |
| 113 | + | const handler = (e: KeyboardEvent) => handleKeyDown(e); | |
| 114 | + | window.addEventListener("keydown", handler, true); | |
| 115 | + | return () => window.removeEventListener("keydown", handler, true); | |
| 116 | + | }); | |
| 117 | + | </script> | |
| 118 | + | ||
| 119 | + | <AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}> | |
| 120 | + | <AlertDialog.Content class="max-w-md"> | |
| 121 | + | <AlertDialog.Header> | |
| 122 | + | <AlertDialog.Title>Edit Keybinding</AlertDialog.Title> | |
| 123 | + | <AlertDialog.Description> | |
| 124 | + | Configure keybindings for <strong>{binding.action}</strong> | |
| 125 | + | </AlertDialog.Description> | |
| 126 | + | </AlertDialog.Header> | |
| 127 | + | ||
| 128 | + | <div class="space-y-4 py-4"> | |
| 129 | + | <!-- Current bindings --> | |
| 130 | + | <div class="space-y-2"> | |
| 131 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 132 | + | Current Bindings | |
| 133 | + | </div> | |
| 134 | + | <div class="flex flex-wrap gap-2"> | |
| 135 | + | {#each newKeys as key, i} | |
| 136 | + | <div class="flex items-center gap-1 rounded bg-muted px-2 py-1"> | |
| 137 | + | <kbd class="text-xs font-mono">{formatKey(key)}</kbd> | |
| 138 | + | <button | |
| 139 | + | type="button" | |
| 140 | + | onclick={() => removeKey(i)} | |
| 141 | + | class="text-muted-foreground hover:text-foreground" | |
| 142 | + | > | |
| 143 | + | <X class="size-3" /> | |
| 144 | + | </button> | |
| 145 | + | </div> | |
| 146 | + | {/each} | |
| 147 | + | {#if newKeys.length === 0} | |
| 148 | + | <span class="text-sm text-muted-foreground">(no bindings)</span> | |
| 149 | + | {/if} | |
| 150 | + | </div> | |
| 151 | + | </div> | |
| 152 | + | ||
| 153 | + | <!-- Capture area --> | |
| 154 | + | <div class="space-y-2"> | |
| 155 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 156 | + | Add New Binding | |
| 157 | + | </div> | |
| 158 | + | <div class="flex gap-2"> | |
| 159 | + | <button | |
| 160 | + | type="button" | |
| 161 | + | onclick={() => { isCapturing = true; capturedKey = null; }} | |
| 162 | + | class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}" | |
| 163 | + | > | |
| 164 | + | <Keyboard class="size-5" /> | |
| 165 | + | {#if isCapturing} | |
| 166 | + | Press a key combination... | |
| 167 | + | {:else if capturedKey} | |
| 168 | + | <kbd class="font-mono">{formatKey(capturedKey)}</kbd> | |
| 169 | + | {:else} | |
| 170 | + | Click to capture key | |
| 171 | + | {/if} | |
| 172 | + | </button> | |
| 173 | + | {#if capturedKey} | |
| 174 | + | <Button variant="outline" onclick={addCapturedKey}> | |
| 175 | + | <Plus class="mr-1 size-4" /> | |
| 176 | + | Add | |
| 177 | + | </Button> | |
| 178 | + | {/if} | |
| 179 | + | </div> | |
| 180 | + | </div> | |
| 181 | + | ||
| 182 | + | <!-- Default hint --> | |
| 183 | + | {#if binding.default_keys.length > 0} | |
| 184 | + | <div class="text-xs text-muted-foreground"> | |
| 185 | + | Default: {binding.default_keys.map(formatKey).join(", ")} | |
| 186 | + | </div> | |
| 187 | + | {/if} | |
| 188 | + | </div> | |
| 189 | + | ||
| 190 | + | <AlertDialog.Footer> | |
| 191 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 192 | + | <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action> | |
| 193 | + | </AlertDialog.Footer> | |
| 194 | + | </AlertDialog.Content> | |
| 195 | + | </AlertDialog.Root> | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from "svelte"; | |||
| 3 | + | import { Button } from "$lib/components/ui/button"; | |||
| 4 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |||
| 5 | + | import { Keyboard, X, Plus } from "@lucide/svelte"; | |||
| 6 | + | ||||
| 7 | + | type KeybindingEntry = { | |||
| 8 | + | action: string; | |||
| 9 | + | description: string; | |||
| 10 | + | keys: string[]; | |||
| 11 | + | default_keys: string[]; | |||
| 12 | + | is_custom: boolean; | |||
| 13 | + | }; | |||
| 14 | + | ||||
| 15 | + | interface Props { | |||
| 16 | + | binding: KeybindingEntry; | |||
| 17 | + | onSave: (keys: string[]) => void; | |||
| 18 | + | onCancel: () => void; | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | let { binding, onSave, onCancel }: Props = $props(); | |||
| 22 | + | ||||
| 23 | + | // Use $derived for initial keys to maintain reactivity if binding changes | |||
| 24 | + | let newKeys = $state<string[]>([]); | |||
| 25 | + | let isCapturing = $state(false); | |||
| 26 | + | let capturedKey = $state<string | null>(null); | |||
| 27 | + | let initialized = $state(false); | |||
| 28 | + | ||||
| 29 | + | // Initialize keys from binding on mount | |||
| 30 | + | $effect(() => { | |||
| 31 | + | if (!initialized) { | |||
| 32 | + | newKeys = [...binding.keys]; | |||
| 33 | + | initialized = true; | |||
| 34 | + | } | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | function handleKeyDown(e: KeyboardEvent) { | |||
| 38 | + | if (!isCapturing) return; | |||
| 39 | + | e.preventDefault(); | |||
| 40 | + | e.stopPropagation(); | |||
| 41 | + | ||||
| 42 | + | const parts: string[] = []; | |||
| 43 | + | if (e.ctrlKey) parts.push("ctrl"); | |||
| 44 | + | if (e.altKey) parts.push("alt"); | |||
| 45 | + | if (e.shiftKey) parts.push("shift"); | |||
| 46 | + | ||||
| 47 | + | let key = e.key.toLowerCase(); | |||
| 48 | + | switch (key) { | |||
| 49 | + | case "control": | |||
| 50 | + | case "alt": | |||
| 51 | + | case "shift": | |||
| 52 | + | case "meta": | |||
| 53 | + | return; // Modifier only, wait for actual key | |||
| 54 | + | case "arrowleft": | |||
| 55 | + | key = "arrowleft"; | |||
| 56 | + | break; | |||
| 57 | + | case "arrowright": | |||
| 58 | + | key = "arrowright"; | |||
| 59 | + | break; | |||
| 60 | + | case "arrowup": | |||
| 61 | + | key = "arrowup"; | |||
| 62 | + | break; | |||
| 63 | + | case "arrowdown": | |||
| 64 | + | key = "arrowdown"; | |||
| 65 | + | break; | |||
| 66 | + | case "escape": | |||
| 67 | + | isCapturing = false; | |||
| 68 | + | capturedKey = null; | |||
| 69 | + | return; | |||
| 70 | + | case " ": | |||
| 71 | + | key = "space"; | |||
| 72 | + | break; | |||
| 73 | + | case "enter": | |||
| 74 | + | key = "enter"; | |||
| 75 | + | break; | |||
| 76 | + | case "tab": | |||
| 77 | + | key = "tab"; | |||
| 78 | + | break; | |||
| 79 | + | case "backspace": | |||
| 80 | + | key = "backspace"; | |||
| 81 | + | break; | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | parts.push(key); | |||
| 85 | + | capturedKey = parts.join("+"); | |||
| 86 | + | isCapturing = false; | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | function addCapturedKey() { | |||
| 90 | + | if (capturedKey && !newKeys.includes(capturedKey)) { | |||
| 91 | + | newKeys = [...newKeys, capturedKey]; | |||
| 92 | + | } | |||
| 93 | + | capturedKey = null; | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | function removeKey(index: number) { | |||
| 97 | + | newKeys = newKeys.filter((_, i) => i !== index); | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | function formatKey(key: string): string { | |||
| 101 | + | return key | |||
| 102 | + | .replace(/arrowleft/gi, "Left") | |||
| 103 | + | .replace(/arrowright/gi, "Right") | |||
| 104 | + | .replace(/arrowup/gi, "Up") | |||
| 105 | + | .replace(/arrowdown/gi, "Down") | |||
| 106 | + | .replace(/\+/g, " + ") | |||
| 107 | + | .replace(/ctrl/gi, "Ctrl") | |||
| 108 | + | .replace(/alt/gi, "Alt") | |||
| 109 | + | .replace(/shift/gi, "Shift"); | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | onMount(() => { | |||
| 113 | + | const handler = (e: KeyboardEvent) => handleKeyDown(e); | |||
| 114 | + | window.addEventListener("keydown", handler, true); | |||
| 115 | + | return () => window.removeEventListener("keydown", handler, true); | |||
| 116 | + | }); | |||
| 117 | + | </script> | |||
| 118 | + | ||||
| 119 | + | <AlertDialog.Root open={true} onOpenChange={(open) => !open && onCancel()}> | |||
| 120 | + | <AlertDialog.Content class="max-w-md"> | |||
| 121 | + | <AlertDialog.Header> | |||
| 122 | + | <AlertDialog.Title>Edit Keybinding</AlertDialog.Title> | |||
| 123 | + | <AlertDialog.Description> | |||
| 124 | + | Configure keybindings for <strong>{binding.action}</strong> | |||
| 125 | + | </AlertDialog.Description> | |||
| 126 | + | </AlertDialog.Header> | |||
| 127 | + | ||||
| 128 | + | <div class="space-y-4 py-4"> | |||
| 129 | + | <!-- Current bindings --> | |||
| 130 | + | <div class="space-y-2"> | |||
| 131 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 132 | + | Current Bindings | |||
| 133 | + | </div> | |||
| 134 | + | <div class="flex flex-wrap gap-2"> | |||
| 135 | + | {#each newKeys as key, i} | |||
| 136 | + | <div class="flex items-center gap-1 rounded bg-muted px-2 py-1"> | |||
| 137 | + | <kbd class="text-xs font-mono">{formatKey(key)}</kbd> | |||
| 138 | + | <button | |||
| 139 | + | type="button" | |||
| 140 | + | onclick={() => removeKey(i)} | |||
| 141 | + | class="text-muted-foreground hover:text-foreground" | |||
| 142 | + | > | |||
| 143 | + | <X class="size-3" /> | |||
| 144 | + | </button> | |||
| 145 | + | </div> | |||
| 146 | + | {/each} | |||
| 147 | + | {#if newKeys.length === 0} | |||
| 148 | + | <span class="text-sm text-muted-foreground">(no bindings)</span> | |||
| 149 | + | {/if} | |||
| 150 | + | </div> | |||
| 151 | + | </div> | |||
| 152 | + | ||||
| 153 | + | <!-- Capture area --> | |||
| 154 | + | <div class="space-y-2"> | |||
| 155 | + | <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 156 | + | Add New Binding | |||
| 157 | + | </div> | |||
| 158 | + | <div class="flex gap-2"> | |||
| 159 | + | <button | |||
| 160 | + | type="button" | |||
| 161 | + | onclick={() => { isCapturing = true; capturedKey = null; }} | |||
| 162 | + | class="flex flex-1 items-center justify-center gap-2 rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground transition hover:border-foreground hover:text-foreground {isCapturing ? 'border-primary bg-primary/5 text-primary' : ''}" | |||
| 163 | + | > | |||
| 164 | + | <Keyboard class="size-5" /> | |||
| 165 | + | {#if isCapturing} | |||
| 166 | + | Press a key combination... | |||
| 167 | + | {:else if capturedKey} | |||
| 168 | + | <kbd class="font-mono">{formatKey(capturedKey)}</kbd> | |||
| 169 | + | {:else} | |||
| 170 | + | Click to capture key | |||
| 171 | + | {/if} | |||
| 172 | + | </button> | |||
| 173 | + | {#if capturedKey} | |||
| 174 | + | <Button variant="outline" onclick={addCapturedKey}> | |||
| 175 | + | <Plus class="mr-1 size-4" /> | |||
| 176 | + | Add | |||
| 177 | + | </Button> | |||
| 178 | + | {/if} | |||
| 179 | + | </div> | |||
| 180 | + | </div> | |||
| 181 | + | ||||
| 182 | + | <!-- Default hint --> | |||
| 183 | + | {#if binding.default_keys.length > 0} | |||
| 184 | + | <div class="text-xs text-muted-foreground"> | |||
| 185 | + | Default: {binding.default_keys.map(formatKey).join(", ")} | |||
| 186 | + | </div> | |||
| 187 | + | {/if} | |||
| 188 | + | </div> | |||
| 189 | + | ||||
| 190 | + | <AlertDialog.Footer> | |||
| 191 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |||
| 192 | + | <AlertDialog.Action onclick={() => onSave(newKeys)}>Save</AlertDialog.Action> | |||
| 193 | + | </AlertDialog.Footer> | |||
| 194 | + | </AlertDialog.Content> | |||
| 195 | + | </AlertDialog.Root> | |||
A webui/src/pages/config/KeybindingGroup.svelte +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { Button } from "$lib/components/ui/button"; | |
| 3 | + | import * as Table from "$lib/components/ui/table"; | |
| 4 | + | import { RotateCcw, Edit3 } from "@lucide/svelte"; | |
| 5 | + | ||
| 6 | + | type KeybindingEntry = { | |
| 7 | + | action: string; | |
| 8 | + | description: string; | |
| 9 | + | keys: string[]; | |
| 10 | + | default_keys: string[]; | |
| 11 | + | is_custom: boolean; | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | type KeybindingGroupData = { | |
| 15 | + | mode: string; | |
| 16 | + | display_name: string; | |
| 17 | + | bindings: KeybindingEntry[]; | |
| 18 | + | activation?: string; | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | interface Props { | |
| 22 | + | group: KeybindingGroupData; | |
| 23 | + | onEdit: (action: string, entry: KeybindingEntry) => void; | |
| 24 | + | onReset: (action: string) => void; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | let { group, onEdit, onReset }: Props = $props(); | |
| 28 | + | ||
| 29 | + | function formatKey(key: string): string { | |
| 30 | + | return key | |
| 31 | + | .replace(/arrowleft/gi, "Left") | |
| 32 | + | .replace(/arrowright/gi, "Right") | |
| 33 | + | .replace(/arrowup/gi, "Up") | |
| 34 | + | .replace(/arrowdown/gi, "Down") | |
| 35 | + | .replace(/\+/g, " + ") | |
| 36 | + | .replace(/ctrl/gi, "Ctrl") | |
| 37 | + | .replace(/alt/gi, "Alt") | |
| 38 | + | .replace(/shift/gi, "Shift"); | |
| 39 | + | } | |
| 40 | + | </script> | |
| 41 | + | ||
| 42 | + | <div class="space-y-3"> | |
| 43 | + | <div class="flex items-center gap-2"> | |
| 44 | + | <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |
| 45 | + | {group.display_name} | |
| 46 | + | </h3> | |
| 47 | + | {#if group.activation} | |
| 48 | + | <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground"> | |
| 49 | + | {formatKey(group.activation)} | |
| 50 | + | </span> | |
| 51 | + | {/if} | |
| 52 | + | </div> | |
| 53 | + | ||
| 54 | + | <Table.Root class="rounded-md border border-border"> | |
| 55 | + | <Table.Header> | |
| 56 | + | <Table.Row> | |
| 57 | + | <Table.Head class="w-[180px]">Action</Table.Head> | |
| 58 | + | <Table.Head>Description</Table.Head> | |
| 59 | + | <Table.Head class="w-[200px]">Keybinding</Table.Head> | |
| 60 | + | <Table.Head class="w-[100px] text-right">Actions</Table.Head> | |
| 61 | + | </Table.Row> | |
| 62 | + | </Table.Header> | |
| 63 | + | <Table.Body> | |
| 64 | + | {#each group.bindings as binding (binding.action)} | |
| 65 | + | <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}> | |
| 66 | + | <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell> | |
| 67 | + | <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell> | |
| 68 | + | <Table.Cell> | |
| 69 | + | <div class="flex flex-wrap gap-1"> | |
| 70 | + | {#each binding.keys as key} | |
| 71 | + | <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"> | |
| 72 | + | {formatKey(key)} | |
| 73 | + | </kbd> | |
| 74 | + | {/each} | |
| 75 | + | {#if binding.keys.length === 0} | |
| 76 | + | <span class="text-xs text-muted-foreground">(unbound)</span> | |
| 77 | + | {/if} | |
| 78 | + | </div> | |
| 79 | + | </Table.Cell> | |
| 80 | + | <Table.Cell class="text-right"> | |
| 81 | + | <div class="flex justify-end gap-1"> | |
| 82 | + | <Button | |
| 83 | + | variant="ghost" | |
| 84 | + | size="icon" | |
| 85 | + | onclick={() => onEdit(binding.action, binding)} | |
| 86 | + | aria-label="Edit binding" | |
| 87 | + | > | |
| 88 | + | <Edit3 class="size-4" /> | |
| 89 | + | </Button> | |
| 90 | + | {#if binding.is_custom} | |
| 91 | + | <Button | |
| 92 | + | variant="ghost" | |
| 93 | + | size="icon" | |
| 94 | + | onclick={() => onReset(binding.action)} | |
| 95 | + | aria-label="Reset to default" | |
| 96 | + | > | |
| 97 | + | <RotateCcw class="size-4" /> | |
| 98 | + | </Button> | |
| 99 | + | {/if} | |
| 100 | + | </div> | |
| 101 | + | </Table.Cell> | |
| 102 | + | </Table.Row> | |
| 103 | + | {/each} | |
| 104 | + | </Table.Body> | |
| 105 | + | </Table.Root> | |
| 106 | + | </div> | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { Button } from "$lib/components/ui/button"; | |||
| 3 | + | import * as Table from "$lib/components/ui/table"; | |||
| 4 | + | import { RotateCcw, Edit3 } from "@lucide/svelte"; | |||
| 5 | + | ||||
| 6 | + | type KeybindingEntry = { | |||
| 7 | + | action: string; | |||
| 8 | + | description: string; | |||
| 9 | + | keys: string[]; | |||
| 10 | + | default_keys: string[]; | |||
| 11 | + | is_custom: boolean; | |||
| 12 | + | }; | |||
| 13 | + | ||||
| 14 | + | type KeybindingGroupData = { | |||
| 15 | + | mode: string; | |||
| 16 | + | display_name: string; | |||
| 17 | + | bindings: KeybindingEntry[]; | |||
| 18 | + | activation?: string; | |||
| 19 | + | }; | |||
| 20 | + | ||||
| 21 | + | interface Props { | |||
| 22 | + | group: KeybindingGroupData; | |||
| 23 | + | onEdit: (action: string, entry: KeybindingEntry) => void; | |||
| 24 | + | onReset: (action: string) => void; | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | let { group, onEdit, onReset }: Props = $props(); | |||
| 28 | + | ||||
| 29 | + | function formatKey(key: string): string { | |||
| 30 | + | return key | |||
| 31 | + | .replace(/arrowleft/gi, "Left") | |||
| 32 | + | .replace(/arrowright/gi, "Right") | |||
| 33 | + | .replace(/arrowup/gi, "Up") | |||
| 34 | + | .replace(/arrowdown/gi, "Down") | |||
| 35 | + | .replace(/\+/g, " + ") | |||
| 36 | + | .replace(/ctrl/gi, "Ctrl") | |||
| 37 | + | .replace(/alt/gi, "Alt") | |||
| 38 | + | .replace(/shift/gi, "Shift"); | |||
| 39 | + | } | |||
| 40 | + | </script> | |||
| 41 | + | ||||
| 42 | + | <div class="space-y-3"> | |||
| 43 | + | <div class="flex items-center gap-2"> | |||
| 44 | + | <h3 class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground"> | |||
| 45 | + | {group.display_name} | |||
| 46 | + | </h3> | |||
| 47 | + | {#if group.activation} | |||
| 48 | + | <span class="rounded-md bg-muted px-2 py-0.5 text-xs font-mono text-muted-foreground"> | |||
| 49 | + | {formatKey(group.activation)} | |||
| 50 | + | </span> | |||
| 51 | + | {/if} | |||
| 52 | + | </div> | |||
| 53 | + | ||||
| 54 | + | <Table.Root class="rounded-md border border-border"> | |||
| 55 | + | <Table.Header> | |||
| 56 | + | <Table.Row> | |||
| 57 | + | <Table.Head class="w-[180px]">Action</Table.Head> | |||
| 58 | + | <Table.Head>Description</Table.Head> | |||
| 59 | + | <Table.Head class="w-[200px]">Keybinding</Table.Head> | |||
| 60 | + | <Table.Head class="w-[100px] text-right">Actions</Table.Head> | |||
| 61 | + | </Table.Row> | |||
| 62 | + | </Table.Header> | |||
| 63 | + | <Table.Body> | |||
| 64 | + | {#each group.bindings as binding (binding.action)} | |||
| 65 | + | <Table.Row class={binding.is_custom ? "bg-accent/5" : ""}> | |||
| 66 | + | <Table.Cell class="font-mono text-sm">{binding.action}</Table.Cell> | |||
| 67 | + | <Table.Cell class="text-muted-foreground">{binding.description}</Table.Cell> | |||
| 68 | + | <Table.Cell> | |||
| 69 | + | <div class="flex flex-wrap gap-1"> | |||
| 70 | + | {#each binding.keys as key} | |||
| 71 | + | <kbd class="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"> | |||
| 72 | + | {formatKey(key)} | |||
| 73 | + | </kbd> | |||
| 74 | + | {/each} | |||
| 75 | + | {#if binding.keys.length === 0} | |||
| 76 | + | <span class="text-xs text-muted-foreground">(unbound)</span> | |||
| 77 | + | {/if} | |||
| 78 | + | </div> | |||
| 79 | + | </Table.Cell> | |||
| 80 | + | <Table.Cell class="text-right"> | |||
| 81 | + | <div class="flex justify-end gap-1"> | |||
| 82 | + | <Button | |||
| 83 | + | variant="ghost" | |||
| 84 | + | size="icon" | |||
| 85 | + | onclick={() => onEdit(binding.action, binding)} | |||
| 86 | + | aria-label="Edit binding" | |||
| 87 | + | > | |||
| 88 | + | <Edit3 class="size-4" /> | |||
| 89 | + | </Button> | |||
| 90 | + | {#if binding.is_custom} | |||
| 91 | + | <Button | |||
| 92 | + | variant="ghost" | |||
| 93 | + | size="icon" | |||
| 94 | + | onclick={() => onReset(binding.action)} | |||
| 95 | + | aria-label="Reset to default" | |||
| 96 | + | > | |||
| 97 | + | <RotateCcw class="size-4" /> | |||
| 98 | + | </Button> | |||
| 99 | + | {/if} | |||
| 100 | + | </div> | |||
| 101 | + | </Table.Cell> | |||
| 102 | + | </Table.Row> | |||
| 103 | + | {/each} | |||
| 104 | + | </Table.Body> | |||
| 105 | + | </Table.Root> | |||
| 106 | + | </div> | |||
A webui/src/pages/config/KeybindingsTab.svelte +210 -0
| @@ -0,0 +1,210 @@ | |||
| 1 | + | <script lang="ts"> | |
| 2 | + | import { onMount } from "svelte"; | |
| 3 | + | import { Button } from "$lib/components/ui/button"; | |
| 4 | + | import { Spinner } from "$lib/components/ui/spinner"; | |
| 5 | + | import * as Card from "$lib/components/ui/card"; | |
| 6 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |
| 7 | + | import { RefreshCw, RotateCcw, AlertTriangle } from "@lucide/svelte"; | |
| 8 | + | import KeybindingGroup from "./KeybindingGroup.svelte"; | |
| 9 | + | import KeyCaptureModal from "./KeyCaptureModal.svelte"; | |
| 10 | + | ||
| 11 | + | type KeybindingEntry = { | |
| 12 | + | action: string; | |
| 13 | + | description: string; | |
| 14 | + | keys: string[]; | |
| 15 | + | default_keys: string[]; | |
| 16 | + | is_custom: boolean; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | type KeybindingGroupData = { | |
| 20 | + | mode: string; | |
| 21 | + | display_name: string; | |
| 22 | + | bindings: KeybindingEntry[]; | |
| 23 | + | activation?: string; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | type KeybindingsConfig = { | |
| 27 | + | groups: KeybindingGroupData[]; | |
| 28 | + | }; | |
| 29 | + | ||
| 30 | + | let loading = $state(true); | |
| 31 | + | let error = $state<string | null>(null); | |
| 32 | + | let keybindings = $state<KeybindingsConfig | null>(null); | |
| 33 | + | let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null); | |
| 34 | + | let resetAllDialogOpen = $state(false); | |
| 35 | + | ||
| 36 | + | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |
| 37 | + | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |
| 38 | + | return bridge && typeof bridge.postMessage === "function" ? bridge : null; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function getWebViewId(): number { | |
| 42 | + | return (window as any).__dumber_webview_id || 0; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | async function loadKeybindings() { | |
| 46 | + | loading = true; | |
| 47 | + | error = null; | |
| 48 | + | ||
| 49 | + | const bridge = getWebKitBridge(); | |
| 50 | + | if (!bridge) { | |
| 51 | + | error = "WebKit bridge not available"; | |
| 52 | + | loading = false; | |
| 53 | + | return; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => { | |
| 57 | + | keybindings = data; | |
| 58 | + | loading = false; | |
| 59 | + | }; | |
| 60 | + | (window as any).__dumber_keybindings_error = (msg: string) => { | |
| 61 | + | error = typeof msg === "string" ? msg : "Failed to load keybindings"; | |
| 62 | + | loading = false; | |
| 63 | + | }; | |
| 64 | + | ||
| 65 | + | bridge.postMessage({ | |
| 66 | + | type: "get_keybindings", | |
| 67 | + | webviewId: getWebViewId(), | |
| 68 | + | payload: {}, | |
| 69 | + | }); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | function startEdit(mode: string, action: string, entry: KeybindingEntry) { | |
| 73 | + | editingBinding = { mode, action, entry }; | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | async function saveBinding(keys: string[]) { | |
| 77 | + | if (!editingBinding) return; | |
| 78 | + | ||
| 79 | + | const bridge = getWebKitBridge(); | |
| 80 | + | if (!bridge) return; | |
| 81 | + | ||
| 82 | + | (window as any).__dumber_keybinding_set = () => { | |
| 83 | + | loadKeybindings(); | |
| 84 | + | editingBinding = null; | |
| 85 | + | }; | |
| 86 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |
| 87 | + | error = typeof msg === "string" ? msg : "Failed to save keybinding"; | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | bridge.postMessage({ | |
| 91 | + | type: "set_keybinding", | |
| 92 | + | webviewId: getWebViewId(), | |
| 93 | + | payload: { | |
| 94 | + | mode: editingBinding.mode, | |
| 95 | + | action: editingBinding.action, | |
| 96 | + | keys, | |
| 97 | + | }, | |
| 98 | + | }); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | async function resetBinding(mode: string, action: string) { | |
| 102 | + | const bridge = getWebKitBridge(); | |
| 103 | + | if (!bridge) return; | |
| 104 | + | ||
| 105 | + | (window as any).__dumber_keybinding_reset = () => { | |
| 106 | + | loadKeybindings(); | |
| 107 | + | }; | |
| 108 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |
| 109 | + | error = typeof msg === "string" ? msg : "Failed to reset keybinding"; | |
| 110 | + | }; | |
| 111 | + | ||
| 112 | + | bridge.postMessage({ | |
| 113 | + | type: "reset_keybinding", | |
| 114 | + | webviewId: getWebViewId(), | |
| 115 | + | payload: { mode, action }, | |
| 116 | + | }); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | async function resetAllBindings() { | |
| 120 | + | const bridge = getWebKitBridge(); | |
| 121 | + | if (!bridge) return; | |
| 122 | + | ||
| 123 | + | (window as any).__dumber_keybindings_reset_all = () => { | |
| 124 | + | loadKeybindings(); | |
| 125 | + | resetAllDialogOpen = false; | |
| 126 | + | }; | |
| 127 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |
| 128 | + | error = typeof msg === "string" ? msg : "Failed to reset keybindings"; | |
| 129 | + | }; | |
| 130 | + | ||
| 131 | + | bridge.postMessage({ | |
| 132 | + | type: "reset_all_keybindings", | |
| 133 | + | webviewId: getWebViewId(), | |
| 134 | + | payload: {}, | |
| 135 | + | }); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | onMount(() => { | |
| 139 | + | loadKeybindings(); | |
| 140 | + | }); | |
| 141 | + | </script> | |
| 142 | + | ||
| 143 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |
| 144 | + | <Card.Header> | |
| 145 | + | <div class="flex items-center justify-between"> | |
| 146 | + | <div> | |
| 147 | + | <Card.Title>Keybindings</Card.Title> | |
| 148 | + | <Card.Description> | |
| 149 | + | Customize keyboard shortcuts for all modes. | |
| 150 | + | </Card.Description> | |
| 151 | + | </div> | |
| 152 | + | <div class="flex gap-2"> | |
| 153 | + | <Button variant="outline" size="sm" onclick={loadKeybindings}> | |
| 154 | + | <RefreshCw class="mr-1 size-4" /> | |
| 155 | + | Refresh | |
| 156 | + | </Button> | |
| 157 | + | <AlertDialog.Root bind:open={resetAllDialogOpen}> | |
| 158 | + | <AlertDialog.Trigger> | |
| 159 | + | {#snippet child({ props })} | |
| 160 | + | <Button variant="outline" size="sm" {...props}> | |
| 161 | + | <RotateCcw class="mr-1 size-4" /> | |
| 162 | + | Reset All | |
| 163 | + | </Button> | |
| 164 | + | {/snippet} | |
| 165 | + | </AlertDialog.Trigger> | |
| 166 | + | <AlertDialog.Content> | |
| 167 | + | <AlertDialog.Header> | |
| 168 | + | <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title> | |
| 169 | + | <AlertDialog.Description> | |
| 170 | + | This will restore all keybindings to their default values. This action cannot be undone. | |
| 171 | + | </AlertDialog.Description> | |
| 172 | + | </AlertDialog.Header> | |
| 173 | + | <AlertDialog.Footer> | |
| 174 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |
| 175 | + | <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action> | |
| 176 | + | </AlertDialog.Footer> | |
| 177 | + | </AlertDialog.Content> | |
| 178 | + | </AlertDialog.Root> | |
| 179 | + | </div> | |
| 180 | + | </div> | |
| 181 | + | </Card.Header> | |
| 182 | + | <Card.Content class="space-y-6"> | |
| 183 | + | {#if loading} | |
| 184 | + | <div class="flex items-center justify-center py-8"> | |
| 185 | + | <Spinner class="size-6" /> | |
| 186 | + | </div> | |
| 187 | + | {:else if error} | |
| 188 | + | <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |
| 189 | + | <AlertTriangle class="size-4" /> | |
| 190 | + | {error} | |
| 191 | + | </div> | |
| 192 | + | {:else if keybindings} | |
| 193 | + | {#each keybindings.groups as group (group.mode)} | |
| 194 | + | <KeybindingGroup | |
| 195 | + | {group} | |
| 196 | + | onEdit={(action, entry) => startEdit(group.mode, action, entry)} | |
| 197 | + | onReset={(action) => resetBinding(group.mode, action)} | |
| 198 | + | /> | |
| 199 | + | {/each} | |
| 200 | + | {/if} | |
| 201 | + | </Card.Content> | |
| 202 | + | </Card.Root> | |
| 203 | + | ||
| 204 | + | {#if editingBinding} | |
| 205 | + | <KeyCaptureModal | |
| 206 | + | binding={editingBinding.entry} | |
| 207 | + | onSave={saveBinding} | |
| 208 | + | onCancel={() => (editingBinding = null)} | |
| 209 | + | /> | |
| 210 | + | {/if} | |
| @@ -0,0 +1,210 @@ | |||||
| 1 | + | <script lang="ts"> | |||
| 2 | + | import { onMount } from "svelte"; | |||
| 3 | + | import { Button } from "$lib/components/ui/button"; | |||
| 4 | + | import { Spinner } from "$lib/components/ui/spinner"; | |||
| 5 | + | import * as Card from "$lib/components/ui/card"; | |||
| 6 | + | import * as AlertDialog from "$lib/components/ui/alert-dialog"; | |||
| 7 | + | import { RefreshCw, RotateCcw, AlertTriangle } from "@lucide/svelte"; | |||
| 8 | + | import KeybindingGroup from "./KeybindingGroup.svelte"; | |||
| 9 | + | import KeyCaptureModal from "./KeyCaptureModal.svelte"; | |||
| 10 | + | ||||
| 11 | + | type KeybindingEntry = { | |||
| 12 | + | action: string; | |||
| 13 | + | description: string; | |||
| 14 | + | keys: string[]; | |||
| 15 | + | default_keys: string[]; | |||
| 16 | + | is_custom: boolean; | |||
| 17 | + | }; | |||
| 18 | + | ||||
| 19 | + | type KeybindingGroupData = { | |||
| 20 | + | mode: string; | |||
| 21 | + | display_name: string; | |||
| 22 | + | bindings: KeybindingEntry[]; | |||
| 23 | + | activation?: string; | |||
| 24 | + | }; | |||
| 25 | + | ||||
| 26 | + | type KeybindingsConfig = { | |||
| 27 | + | groups: KeybindingGroupData[]; | |||
| 28 | + | }; | |||
| 29 | + | ||||
| 30 | + | let loading = $state(true); | |||
| 31 | + | let error = $state<string | null>(null); | |||
| 32 | + | let keybindings = $state<KeybindingsConfig | null>(null); | |||
| 33 | + | let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null); | |||
| 34 | + | let resetAllDialogOpen = $state(false); | |||
| 35 | + | ||||
| 36 | + | function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null { | |||
| 37 | + | const bridge = (window as any).webkit?.messageHandlers?.dumber; | |||
| 38 | + | return bridge && typeof bridge.postMessage === "function" ? bridge : null; | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | function getWebViewId(): number { | |||
| 42 | + | return (window as any).__dumber_webview_id || 0; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | async function loadKeybindings() { | |||
| 46 | + | loading = true; | |||
| 47 | + | error = null; | |||
| 48 | + | ||||
| 49 | + | const bridge = getWebKitBridge(); | |||
| 50 | + | if (!bridge) { | |||
| 51 | + | error = "WebKit bridge not available"; | |||
| 52 | + | loading = false; | |||
| 53 | + | return; | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | (window as any).__dumber_keybindings_loaded = (data: KeybindingsConfig) => { | |||
| 57 | + | keybindings = data; | |||
| 58 | + | loading = false; | |||
| 59 | + | }; | |||
| 60 | + | (window as any).__dumber_keybindings_error = (msg: string) => { | |||
| 61 | + | error = typeof msg === "string" ? msg : "Failed to load keybindings"; | |||
| 62 | + | loading = false; | |||
| 63 | + | }; | |||
| 64 | + | ||||
| 65 | + | bridge.postMessage({ | |||
| 66 | + | type: "get_keybindings", | |||
| 67 | + | webviewId: getWebViewId(), | |||
| 68 | + | payload: {}, | |||
| 69 | + | }); | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | function startEdit(mode: string, action: string, entry: KeybindingEntry) { | |||
| 73 | + | editingBinding = { mode, action, entry }; | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | async function saveBinding(keys: string[]) { | |||
| 77 | + | if (!editingBinding) return; | |||
| 78 | + | ||||
| 79 | + | const bridge = getWebKitBridge(); | |||
| 80 | + | if (!bridge) return; | |||
| 81 | + | ||||
| 82 | + | (window as any).__dumber_keybinding_set = () => { | |||
| 83 | + | loadKeybindings(); | |||
| 84 | + | editingBinding = null; | |||
| 85 | + | }; | |||
| 86 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |||
| 87 | + | error = typeof msg === "string" ? msg : "Failed to save keybinding"; | |||
| 88 | + | }; | |||
| 89 | + | ||||
| 90 | + | bridge.postMessage({ | |||
| 91 | + | type: "set_keybinding", | |||
| 92 | + | webviewId: getWebViewId(), | |||
| 93 | + | payload: { | |||
| 94 | + | mode: editingBinding.mode, | |||
| 95 | + | action: editingBinding.action, | |||
| 96 | + | keys, | |||
| 97 | + | }, | |||
| 98 | + | }); | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | async function resetBinding(mode: string, action: string) { | |||
| 102 | + | const bridge = getWebKitBridge(); | |||
| 103 | + | if (!bridge) return; | |||
| 104 | + | ||||
| 105 | + | (window as any).__dumber_keybinding_reset = () => { | |||
| 106 | + | loadKeybindings(); | |||
| 107 | + | }; | |||
| 108 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |||
| 109 | + | error = typeof msg === "string" ? msg : "Failed to reset keybinding"; | |||
| 110 | + | }; | |||
| 111 | + | ||||
| 112 | + | bridge.postMessage({ | |||
| 113 | + | type: "reset_keybinding", | |||
| 114 | + | webviewId: getWebViewId(), | |||
| 115 | + | payload: { mode, action }, | |||
| 116 | + | }); | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | async function resetAllBindings() { | |||
| 120 | + | const bridge = getWebKitBridge(); | |||
| 121 | + | if (!bridge) return; | |||
| 122 | + | ||||
| 123 | + | (window as any).__dumber_keybindings_reset_all = () => { | |||
| 124 | + | loadKeybindings(); | |||
| 125 | + | resetAllDialogOpen = false; | |||
| 126 | + | }; | |||
| 127 | + | (window as any).__dumber_keybinding_error = (msg: string) => { | |||
| 128 | + | error = typeof msg === "string" ? msg : "Failed to reset keybindings"; | |||
| 129 | + | }; | |||
| 130 | + | ||||
| 131 | + | bridge.postMessage({ | |||
| 132 | + | type: "reset_all_keybindings", | |||
| 133 | + | webviewId: getWebViewId(), | |||
| 134 | + | payload: {}, | |||
| 135 | + | }); | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | onMount(() => { | |||
| 139 | + | loadKeybindings(); | |||
| 140 | + | }); | |||
| 141 | + | </script> | |||
| 142 | + | ||||
| 143 | + | <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none"> | |||
| 144 | + | <Card.Header> | |||
| 145 | + | <div class="flex items-center justify-between"> | |||
| 146 | + | <div> | |||
| 147 | + | <Card.Title>Keybindings</Card.Title> | |||
| 148 | + | <Card.Description> | |||
| 149 | + | Customize keyboard shortcuts for all modes. | |||
| 150 | + | </Card.Description> | |||
| 151 | + | </div> | |||
| 152 | + | <div class="flex gap-2"> | |||
| 153 | + | <Button variant="outline" size="sm" onclick={loadKeybindings}> | |||
| 154 | + | <RefreshCw class="mr-1 size-4" /> | |||
| 155 | + | Refresh | |||
| 156 | + | </Button> | |||
| 157 | + | <AlertDialog.Root bind:open={resetAllDialogOpen}> | |||
| 158 | + | <AlertDialog.Trigger> | |||
| 159 | + | {#snippet child({ props })} | |||
| 160 | + | <Button variant="outline" size="sm" {...props}> | |||
| 161 | + | <RotateCcw class="mr-1 size-4" /> | |||
| 162 | + | Reset All | |||
| 163 | + | </Button> | |||
| 164 | + | {/snippet} | |||
| 165 | + | </AlertDialog.Trigger> | |||
| 166 | + | <AlertDialog.Content> | |||
| 167 | + | <AlertDialog.Header> | |||
| 168 | + | <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title> | |||
| 169 | + | <AlertDialog.Description> | |||
| 170 | + | This will restore all keybindings to their default values. This action cannot be undone. | |||
| 171 | + | </AlertDialog.Description> | |||
| 172 | + | </AlertDialog.Header> | |||
| 173 | + | <AlertDialog.Footer> | |||
| 174 | + | <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> | |||
| 175 | + | <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action> | |||
| 176 | + | </AlertDialog.Footer> | |||
| 177 | + | </AlertDialog.Content> | |||
| 178 | + | </AlertDialog.Root> | |||
| 179 | + | </div> | |||
| 180 | + | </div> | |||
| 181 | + | </Card.Header> | |||
| 182 | + | <Card.Content class="space-y-6"> | |||
| 183 | + | {#if loading} | |||
| 184 | + | <div class="flex items-center justify-center py-8"> | |||
| 185 | + | <Spinner class="size-6" /> | |||
| 186 | + | </div> | |||
| 187 | + | {:else if error} | |||
| 188 | + | <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"> | |||
| 189 | + | <AlertTriangle class="size-4" /> | |||
| 190 | + | {error} | |||
| 191 | + | </div> | |||
| 192 | + | {:else if keybindings} | |||
| 193 | + | {#each keybindings.groups as group (group.mode)} | |||
| 194 | + | <KeybindingGroup | |||
| 195 | + | {group} | |||
| 196 | + | onEdit={(action, entry) => startEdit(group.mode, action, entry)} | |||
| 197 | + | onReset={(action) => resetBinding(group.mode, action)} | |||
| 198 | + | /> | |||
| 199 | + | {/each} | |||
| 200 | + | {/if} | |||
| 201 | + | </Card.Content> | |||
| 202 | + | </Card.Root> | |||
| 203 | + | ||||
| 204 | + | {#if editingBinding} | |||
| 205 | + | <KeyCaptureModal | |||
| 206 | + | binding={editingBinding.entry} | |||
| 207 | + | onSave={saveBinding} | |||
| 208 | + | onCancel={() => (editingBinding = null)} | |||
| 209 | + | /> | |||
| 210 | + | {/if} | |||