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