1<script lang="ts">
2 import { onMount, untrack } 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 import { formatKey } from "$lib/utils/keys";
7
8 type KeybindingEntry = {
9 action: string;
10 description: string;
11 keys: string[];
12 default_keys: string[];
13 is_custom: boolean;
14 };
15
16 interface Props {
17 binding: KeybindingEntry;
18 onSave: (keys: string[]) => void;
19 onCancel: () => void;
20 }
21
22 let { binding, onSave, onCancel }: Props = $props();
23
24 // Initialize with a plain copy of the binding keys using $state.snapshot()
25 // to strip Svelte 5 Proxy wrappers. untrack() ensures we only read the initial value.
26 let newKeys = $state<string[]>(
27 untrack(() => $state.snapshot(binding.keys) as string[])
28 );
29 let isCapturing = $state(false);
30 let capturedKey = $state<string | null>(null);
31 let isSaving = $state(false);
32
33 // Symbol keys that are already shifted (pressing shift produces these characters)
34 // Don't add 'shift' modifier for these since they're self-describing
35 const SHIFTED_SYMBOLS = new Set([
36 "{", "}", "(", ")", "<", ">", "!", "@", "#", "$", "%", "^", "&", "*",
37 "+", "_", ":", '"', "?", "|", "~",
38 ]);
39
40 function handleKeyDown(e: KeyboardEvent) {
41 if (!isCapturing) return;
42 e.preventDefault();
43 e.stopPropagation();
44
45 const parts: string[] = [];
46 if (e.ctrlKey) parts.push("ctrl");
47 if (e.altKey) parts.push("alt");
48
49 // Ignore modifier-only presses
50 if (["control", "alt", "shift", "meta"].includes(e.key.toLowerCase())) {
51 return;
52 }
53
54 // Escape closes the modal
55 if (e.key === "Escape") {
56 onCancel();
57 return;
58 }
59
60 // Normalize key name
61 const key = e.key === " " ? "space" : e.key.toLowerCase();
62
63 // Only add shift modifier for letter keys and non-symbol keys
64 // Symbol keys like { } + etc. are already the shifted result
65 if (e.shiftKey && !SHIFTED_SYMBOLS.has(e.key)) {
66 parts.push("shift");
67 }
68
69 parts.push(key);
70 capturedKey = parts.join("+");
71 isCapturing = false;
72 }
73
74 function addCapturedKey() {
75 if (capturedKey && !newKeys.includes(capturedKey)) {
76 newKeys = [...newKeys, capturedKey];
77 }
78 capturedKey = null;
79 }
80
81 function removeKey(index: number) {
82 newKeys = newKeys.filter((_, i) => i !== index);
83 }
84
85 function handleSave() {
86 // Use $state.snapshot() to get plain array without Svelte 5 Proxy wrappers
87 const keysToSave = $state.snapshot(newKeys) as string[];
88 // Auto-add any pending captured key before saving
89 if (capturedKey && !keysToSave.includes(capturedKey)) {
90 keysToSave.push(capturedKey);
91 }
92 isSaving = true;
93 try {
94 onSave(keysToSave);
95 } finally {
96 // Reset isSaving so modal remains dismissible if save fails
97 isSaving = false;
98 }
99 }
100
101 onMount(() => {
102 const handler = (e: KeyboardEvent) => handleKeyDown(e);
103 window.addEventListener("keydown", handler, true);
104 return () => window.removeEventListener("keydown", handler, true);
105 });
106</script>
107
108<AlertDialog.Root open={true} onOpenChange={(open) => !open && !isSaving && onCancel()}>
109 <AlertDialog.Content class="max-w-md">
110 <AlertDialog.Header>
111 <AlertDialog.Title>Edit Keybinding</AlertDialog.Title>
112 <AlertDialog.Description>
113 Configure keybindings for <strong>{binding.action}</strong>
114 </AlertDialog.Description>
115 </AlertDialog.Header>
116
117 <div class="space-y-4 py-4">
118 <!-- Current bindings -->
119 <div class="space-y-2">
120 <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
121 Current Bindings
122 </div>
123 <div class="flex flex-wrap gap-2">
124 {#each newKeys as key, i (key)}
125 <div class="flex items-center gap-1 rounded bg-muted px-2 py-1">
126 <kbd class="text-xs font-mono">{formatKey(key)}</kbd>
127 <button
128 type="button"
129 onclick={() => removeKey(i)}
130 class="text-muted-foreground hover:text-foreground"
131 >
132 <X class="size-3" />
133 </button>
134 </div>
135 {/each}
136 {#if newKeys.length === 0}
137 <span class="text-sm text-muted-foreground">(no bindings)</span>
138 {/if}
139 </div>
140 </div>
141
142 <!-- Capture area -->
143 <div class="space-y-2">
144 <div class="text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
145 Add New Binding
146 </div>
147 <div class="flex gap-2">
148 <button
149 type="button"
150 onclick={() => { isCapturing = true; capturedKey = null; }}
151 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' : ''}"
152 >
153 <Keyboard class="size-5" />
154 {#if isCapturing}
155 Press a key combination...
156 {:else if capturedKey}
157 <kbd class="font-mono">{formatKey(capturedKey)}</kbd>
158 {:else}
159 Click to capture key
160 {/if}
161 </button>
162 {#if capturedKey}
163 <Button variant="outline" onclick={addCapturedKey}>
164 <Plus class="mr-1 size-4" />
165 Add
166 </Button>
167 {/if}
168 </div>
169 </div>
170
171 <!-- Default hint -->
172 {#if binding.default_keys.length > 0}
173 <div class="text-xs text-muted-foreground">
174 Default: {binding.default_keys.map(formatKey).join(", ")}
175 </div>
176 {/if}
177 </div>
178
179 <AlertDialog.Footer>
180 <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
181 <AlertDialog.Action onclick={handleSave}>Save</AlertDialog.Action>
182 </AlertDialog.Footer>
183 </AlertDialog.Content>
184</AlertDialog.Root>