1/**
2 * Keyboard Service
3 *
4 * Centralized keyboard shortcut management for the Dumber Browser GUI.
5 * Bridges between native GTK keyboard events and Svelte component handlers.
6 */
7
8import type {
9 ShortcutConfig,
10 ComponentShortcuts,
11 KeyboardServiceConfig,
12 KeyboardEventDetail,
13 KeyboardEvent,
14 KeyboardEventType,
15} from "./types";
16// COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
17
18export class KeyboardService {
19 private components = new Map<string, ComponentShortcuts>();
20 private activeComponent: string | null = null;
21 private globalShortcuts: ShortcutConfig[] = [];
22 private eventListeners = new Map<
23 KeyboardEventType,
24 Set<(event: KeyboardEvent) => void>
25 >();
26 private debug: boolean = false;
27 // Mode-aware keymaps (e.g., 'vim', 'emacs') are deferred for now
28
29 constructor(config: KeyboardServiceConfig = {}) {
30 this.debug = config.debug ?? false;
31 this.globalShortcuts = config.globalShortcuts ?? [];
32
33 this.log("info", "KeyboardService initialized", { config });
34 }
35
36 /**
37 * Register shortcuts for a component
38 */
39 registerShortcuts(componentId: string, shortcuts: ShortcutConfig[]): void {
40 if (this.components.has(componentId)) {
41 this.log(
42 "warn",
43 `Component ${componentId} already registered, overwriting shortcuts`,
44 );
45 }
46
47 const componentShortcuts: ComponentShortcuts = {
48 componentId,
49 shortcuts: shortcuts.map((shortcut) => ({
50 ...shortcut,
51 preventDefault: shortcut.preventDefault ?? true,
52 stopPropagation: shortcut.stopPropagation ?? true,
53 whenFocused: shortcut.whenFocused ?? false,
54 })),
55 focused: false,
56 };
57
58 this.components.set(componentId, componentShortcuts);
59
60 this.log("info", "Shortcuts registered", {
61 componentId,
62 shortcuts: shortcuts.length,
63 });
64 this.emit("shortcut-registered", {
65 shortcut: componentId,
66 timestamp: Date.now(),
67 targetComponent: componentId,
68 });
69 }
70
71 /**
72 * Unregister shortcuts for a component
73 */
74 unregisterShortcuts(componentId: string): void {
75 const component = this.components.get(componentId);
76 if (!component) {
77 this.log("warn", `Component ${componentId} not found for unregistration`);
78 return;
79 }
80
81 // Call cleanup function if provided
82 if (component.cleanup) {
83 component.cleanup();
84 }
85
86 this.components.delete(componentId);
87
88 // Clear active component if it was this one
89 if (this.activeComponent === componentId) {
90 this.activeComponent = null;
91 }
92
93 this.log("info", "Shortcuts unregistered", { componentId });
94 this.emit("shortcut-unregistered", {
95 shortcut: componentId,
96 timestamp: Date.now(),
97 targetComponent: componentId,
98 });
99 }
100
101 /**
102 * Set which component is currently focused
103 */
104 setActiveComponent(componentId: string | null): void {
105 // Blur previous component
106 if (this.activeComponent && this.components.has(this.activeComponent)) {
107 const prevComponent = this.components.get(this.activeComponent)!;
108 prevComponent.focused = false;
109 this.emit("component-blurred", {
110 shortcut: this.activeComponent,
111 timestamp: Date.now(),
112 targetComponent: this.activeComponent,
113 });
114 }
115
116 this.activeComponent = componentId;
117
118 // Focus new component
119 if (componentId && this.components.has(componentId)) {
120 const newComponent = this.components.get(componentId)!;
121 newComponent.focused = true;
122 this.emit("component-focused", {
123 shortcut: componentId,
124 timestamp: Date.now(),
125 targetComponent: componentId,
126 });
127 }
128
129 this.log("info", "Active component changed", {
130 from: this.activeComponent,
131 to: componentId,
132 });
133 }
134
135 /**
136 * Handle a keyboard shortcut from native GTK layer
137 */
138 handleNativeShortcut(shortcut: string): boolean {
139 this.log("info", "Native shortcut received", { shortcut });
140
141 // Normalize shortcut string
142 const normalizedShortcut = this.normalizeShortcut(shortcut);
143
144 // Try to handle with focused component first
145 if (this.activeComponent) {
146 const handled = this.handleComponentShortcut(
147 this.activeComponent,
148 normalizedShortcut,
149 true,
150 );
151 if (handled) {
152 return true;
153 }
154 }
155
156 // Try global shortcuts
157 const globalHandled = this.handleGlobalShortcuts(normalizedShortcut);
158 if (globalHandled) {
159 return true;
160 }
161
162 // Try all components (for non-focused shortcuts)
163 for (const [componentId] of this.components) {
164 if (componentId === this.activeComponent) continue; // Already tried above
165
166 const handled = this.handleComponentShortcut(
167 componentId,
168 normalizedShortcut,
169 false,
170 );
171 if (handled) {
172 return true;
173 }
174 }
175
176 this.log("info", "Shortcut not handled", { shortcut: normalizedShortcut });
177 return false;
178 }
179
180 /**
181 * Handle JavaScript keyboard events (for web page events)
182 */
183 handleKeyboardEvent(event: globalThis.KeyboardEvent): boolean {
184 const shortcut = this.eventToShortcut(event);
185 if (!shortcut) return false;
186
187 this.log("info", "Keyboard event received", { shortcut });
188 return this.handleNativeShortcut(shortcut);
189 }
190
191 /**
192 * Handle JavaScript mouse events (for navigation buttons)
193 */
194 handleMouseEvent(event: globalThis.MouseEvent): boolean {
195 try {
196 // Mouse button 3 (back button)
197 if (event.button === 3) {
198 event.preventDefault();
199 event.stopPropagation();
200
201 if (window.history.length > 1) {
202 console.log("⬅️ Mouse back button pressed");
203 window.history.back();
204 }
205 return true;
206 }
207
208 // Mouse button 4 (forward button)
209 if (event.button === 4) {
210 event.preventDefault();
211 event.stopPropagation();
212
213 console.log("➡️ Mouse forward button pressed");
214 window.history.forward();
215 return true;
216 }
217 } catch (error) {
218 this.log("error", "Mouse handler error", { error });
219 }
220
221 return false;
222 }
223
224 /**
225 * Add event listener for keyboard service events
226 */
227 addEventListener(
228 type: KeyboardEventType,
229 listener: (event: KeyboardEvent) => void,
230 ): void {
231 if (!this.eventListeners.has(type)) {
232 this.eventListeners.set(type, new Set());
233 }
234 this.eventListeners.get(type)!.add(listener);
235 }
236
237 /**
238 * Remove event listener
239 */
240 removeEventListener(
241 type: KeyboardEventType,
242 listener: (event: KeyboardEvent) => void,
243 ): void {
244 const listeners = this.eventListeners.get(type);
245 if (listeners) {
246 listeners.delete(listener);
247 }
248 }
249
250 /**
251 * Get all registered shortcuts for debugging
252 */
253 getDebugInfo(): object {
254 return {
255 components: Array.from(this.components.entries()).map(([id, comp]) => ({
256 id,
257 shortcuts: comp.shortcuts.length,
258 focused: comp.focused,
259 })),
260 activeComponent: this.activeComponent,
261 globalShortcuts: this.globalShortcuts.length,
262 };
263 }
264
265 // Private methods
266
267 private handleComponentShortcut(
268 componentId: string,
269 shortcut: string,
270 respectFocus: boolean,
271 ): boolean {
272 const component = this.components.get(componentId);
273 if (!component) return false;
274
275 for (const shortcutConfig of component.shortcuts) {
276 if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
277 // Check focus requirement
278 if (respectFocus && shortcutConfig.whenFocused && !component.focused) {
279 continue;
280 }
281
282 try {
283 shortcutConfig.handler();
284 this.log("info", "Shortcut handled", {
285 shortcut,
286 componentId,
287 focused: component.focused,
288 description: shortcutConfig.description,
289 });
290
291 this.emit("shortcut-triggered", {
292 shortcut,
293 timestamp: Date.now(),
294 targetComponent: componentId,
295 });
296
297 return true;
298 } catch (error) {
299 this.log("error", "Shortcut handler error", {
300 shortcut,
301 componentId,
302 error,
303 });
304 }
305 }
306 }
307
308 return false;
309 }
310
311 private handleGlobalShortcuts(shortcut: string): boolean {
312 for (const shortcutConfig of this.globalShortcuts) {
313 if (this.normalizeShortcut(shortcutConfig.key) === shortcut) {
314 try {
315 shortcutConfig.handler();
316 this.log("info", "Global shortcut handled", {
317 shortcut,
318 description: shortcutConfig.description,
319 });
320
321 this.emit("shortcut-triggered", {
322 shortcut,
323 timestamp: Date.now(),
324 });
325
326 return true;
327 } catch (error) {
328 this.log("error", "Global shortcut handler error", {
329 shortcut,
330 error,
331 });
332 }
333 }
334 }
335 return false;
336 }
337
338 private normalizeShortcut(shortcut: string): string {
339 return shortcut.toLowerCase().replace(/\s+/g, "");
340 }
341
342 private eventToShortcut(event: globalThis.KeyboardEvent): string | null {
343 const parts: string[] = [];
344
345 // Add modifiers using MODIFIER_KEYS mapping
346 if (event.ctrlKey && event.metaKey) {
347 // Both ctrl and meta pressed - use cmdorctrl
348 parts.push("cmdorctrl");
349 } else if (event.ctrlKey) {
350 parts.push("ctrl");
351 } else if (event.metaKey) {
352 parts.push("cmd");
353 }
354
355 if (event.altKey) parts.push("alt");
356 if (event.shiftKey) parts.push("shift");
357
358 // Add key
359 if (event.key) {
360 parts.push(event.key.toLowerCase());
361 }
362
363 return parts.length > 0 ? parts.join("+") : null;
364 }
365
366 private emit(type: KeyboardEventType, detail: KeyboardEventDetail): void {
367 const listeners = this.eventListeners.get(type);
368 if (listeners) {
369 const keyboardEvent: KeyboardEvent = { type, detail };
370 listeners.forEach((listener) => {
371 try {
372 listener(keyboardEvent);
373 } catch (error) {
374 this.log("error", "Event listener error", { type, error });
375 }
376 });
377 }
378 }
379
380 private log(
381 level: "info" | "warn" | "error" | string,
382 message?: string,
383 data?: unknown,
384 ): void {
385 if (!this.debug && level !== "error") return;
386
387 const logMessage = message
388 ? `[KeyboardService] ${message}`
389 : `[KeyboardService] ${level}`;
390
391 switch (level) {
392 case "error":
393 console.error(logMessage, data);
394 break;
395 case "warn":
396 console.warn(logMessage, data);
397 break;
398 default:
399 console.log(logMessage, data);
400 }
401 }
402}
403
404// Create and export singleton instance with global shortcuts
405export const keyboardService = new KeyboardService({
406 debug: false, // Disable debug logging in production
407 globalShortcuts: [
408 // Navigation shortcuts (migrated from controls module)
409 {
410 key: "cmdorctrl+arrowleft",
411 handler: () => {
412 if (window.history.length > 1) {
413 window.history.back();
414 }
415 },
416 description: "Navigate back (Ctrl/Cmd+Left Arrow)",
417 },
418 {
419 key: "cmdorctrl+arrowright",
420 handler: () => {
421 window.history.forward();
422 },
423 description: "Navigate forward (Ctrl/Cmd+Right Arrow)",
424 },
425 // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
426 // directly by the native Go shortcut handlers to ensure proper active pane detection.
427 // This avoids conflicts between multiple webview instances.
428 ],
429});
430
431// Export types for use in components
432export type {
433 ShortcutConfig,
434 ComponentShortcuts,
435 KeyboardServiceConfig,
436} from "./types";
437// COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers