1<!--
2 Omnibox Component
3
4 Main container for omnibox/find functionality using Svelte 5 runes
5-->
6<script lang="ts">
7 import { onMount, onDestroy } from 'svelte';
8 import OmniboxInput from './OmniboxInput.svelte';
9 import OmniboxSuggestions from './OmniboxSuggestions.svelte';
10 import OmniboxFind from './OmniboxFind.svelte';
11 import { omniboxStore } from './stores.svelte.ts';
12 import { omniboxBridge } from './messaging';
13
14 // Reactive state from store
15 let visible = $derived(omniboxStore.visible);
16 let mode = $derived(omniboxStore.mode);
17 let faded = $derived(omniboxStore.faded);
18
19 // Component refs
20 let boxElement: HTMLDivElement;
21 let blurLayerElement: HTMLDivElement;
22 let inputElement = $state<HTMLInputElement>();
23
24 // Responsive styling state
25 let responsiveStyles = $state({
26 width: 'min(90vw, 720px)',
27 padding: '8px 12px',
28 fontSize: '16px',
29 inputPadding: '10px 12px'
30 });
31
32 // Track theme mode derived from injected color-scheme module
33 let isDarkMode = $state(false);
34 let themeObserver: MutationObserver | null = null;
35
36 function syncTheme() {
37 if (typeof document === 'undefined') return;
38
39 const globalWindow = typeof window === 'undefined' ? undefined : window;
40
41 if (document.documentElement.classList.contains('dark')) {
42 isDarkMode = true;
43 return;
44 }
45
46 if (globalWindow?.matchMedia) {
47 try {
48 const prefersDark = globalWindow.matchMedia('(prefers-color-scheme: dark)').matches;
49 if (typeof prefersDark === 'boolean') {
50 isDarkMode = prefersDark;
51 return;
52 }
53 } catch {
54 // matchMedia might be unavailable in some environments
55 }
56 }
57
58 // Fallback to injected GTK preference flag if available
59 isDarkMode = Boolean((globalWindow as any)?.__dumber_gtk_prefers_dark);
60 }
61
62 function observeThemeChanges() {
63 if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
64 return;
65 }
66
67 if (themeObserver) {
68 themeObserver.disconnect();
69 }
70
71 themeObserver = new MutationObserver(syncTheme);
72 themeObserver.observe(document.documentElement, {
73 attributes: true,
74 attributeFilter: ['class']
75 });
76 }
77
78 // Handle click outside to close
79 function handleOverlayClick(event: MouseEvent) {
80 const target = event.target as HTMLElement;
81 if (target && boxElement && !boxElement.contains(target)) {
82 event.preventDefault();
83 event.stopPropagation();
84 omniboxStore.close();
85 }
86 }
87
88 // Handle box click to prevent propagation
89 function handleBoxClick(event: MouseEvent) {
90 event.stopPropagation();
91 }
92
93 // Handle mouse enter to focus input and clear fade
94 function handleMouseEnter() {
95 if (visible && inputElement) {
96 try {
97 inputElement.focus({ preventScroll: true });
98 } catch {
99 inputElement.focus();
100 }
101 omniboxStore.setFaded(false);
102 }
103 }
104
105 // Update responsive styles based on viewport width (handles zoom levels)
106 function updateResponsiveStyles() {
107 const vw = window.innerWidth;
108 const isSmall = vw < 640;
109 const isLarge = vw > 1920;
110
111 responsiveStyles = {
112 width: isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)',
113 padding: isSmall ? '6px 8px' : '8px 12px',
114 fontSize: isSmall ? '14px' : isLarge ? '17px' : '16px',
115 inputPadding: isSmall ? '8px 10px' : '10px 12px'
116 };
117 }
118
119 // Focus input when component becomes visible and manage page event blocking
120 $effect(() => {
121 if (visible && inputElement) {
122 inputElement.focus();
123 // Enable keyboard event blocking in main world when omnibox opens
124 enablePageEventBlocking();
125 } else if (!visible) {
126 // Disable keyboard event blocking when omnibox closes
127 disablePageEventBlocking();
128 }
129 });
130
131 // Functions to control main-world event blocking
132 function enablePageEventBlocking() {
133 try {
134 // Send message to Go backend to enable keyboard blocking
135 if (window.webkit?.messageHandlers?.dumber) {
136 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
137 type: 'keyboard_blocking',
138 action: 'enable'
139 }));
140 }
141 } catch (error) {
142 console.warn('[omnibox] Failed to enable page event blocking:', error);
143 }
144 }
145
146 function disablePageEventBlocking() {
147 try {
148 // Send message to Go backend to disable keyboard blocking
149 if (window.webkit?.messageHandlers?.dumber) {
150 window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({
151 type: 'keyboard_blocking',
152 action: 'disable'
153 }));
154 }
155 } catch (error) {
156 console.warn('[omnibox] Failed to disable page event blocking:', error);
157 }
158 }
159
160
161 // Apply faded styling effect (matching original JS implementation)
162 // NOTE: to avoid text becoming blurry while animating, we animate the
163 // blur via an absolutely-positioned layer's opacity instead of
164 // animating `backdrop-filter` on the container. This keeps the text
165 // on a separate compositing layer and prevents browser antialiasing
166 // artifacts during transitions.
167 $effect(() => {
168 if (!boxElement || !inputElement || !blurLayerElement) return;
169
170 // Ensure subtle transitions are present on the container so
171 // background-color (and other cheap properties) animate smoothly.
172 try {
173 if (!boxElement.style.transition) {
174 boxElement.style.transition = 'background-color 120ms ease, border-color 120ms ease';
175 }
176 } catch (e) {
177 // defensive: ignore if setting styles fails for some reason
178 }
179
180 const surfaceMix = isDarkMode ? '55%' : '88%';
181 const blurMix = isDarkMode ? '22%' : '12%';
182
183 const applyFade = faded && mode === 'find';
184
185 // GTK4-style: Container is slightly raised (use surface)
186 const containerBackground = applyFade
187 ? `color-mix(in srgb, var(--dynamic-surface) ${surfaceMix}, transparent)`
188 : 'var(--dynamic-surface)';
189
190 // GTK4-style: Input is recessed (darker background)
191 const inputBackground = applyFade
192 ? isDarkMode
193 ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 85%, black 15%) ${surfaceMix}, transparent)`
194 : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-bg) 95%, black 5%) ${surfaceMix}, transparent)`
195 : isDarkMode
196 ? 'color-mix(in srgb, var(--dynamic-bg) 85%, black 15%)'
197 : 'color-mix(in srgb, var(--dynamic-bg) 95%, black 5%)';
198
199 // GTK4-style: Use palette border directly, slightly darker for input
200 const containerBorderColor = applyFade
201 ? `color-mix(in srgb, var(--dynamic-border) ${surfaceMix}, transparent)`
202 : 'var(--dynamic-border)';
203
204 const inputBorderColor = applyFade
205 ? isDarkMode
206 ? `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 80%, black 20%) ${surfaceMix}, transparent)`
207 : `color-mix(in srgb, color-mix(in srgb, var(--dynamic-border) 85%, black 15%) ${surfaceMix}, transparent)`
208 : isDarkMode
209 ? 'color-mix(in srgb, var(--dynamic-border) 80%, black 20%)'
210 : 'color-mix(in srgb, var(--dynamic-border) 85%, black 15%)';
211
212 const blurTint = `color-mix(in srgb, var(--dynamic-bg) ${blurMix}, transparent)`;
213
214 boxElement.style.background = containerBackground;
215 boxElement.style.color = 'var(--dynamic-text)';
216 boxElement.style.setProperty('--dumber-border-color', containerBorderColor);
217 // Force the omnibox to use our neutral surfaces so it stays in sync with the shell palette
218 boxElement.style.setProperty('--dumber-omnibox-surface', containerBackground);
219
220 inputElement.style.background = inputBackground;
221 inputElement.style.color = 'var(--dynamic-text)';
222 inputElement.style.setProperty('--dumber-input-bg', inputBackground);
223 inputElement.style.setProperty('--dumber-input-border-base', inputBorderColor);
224 inputElement.style.setProperty('--dumber-placeholder-color', 'var(--dynamic-muted)');
225
226 blurLayerElement.style.setProperty('--dumber-blur-color', blurTint);
227 blurLayerElement.style.background = 'var(--dumber-blur-color)';
228
229 let accentColor = 'var(--dynamic-accent)';
230 try {
231 const computedAccent = getComputedStyle(boxElement).getPropertyValue('--dynamic-accent');
232 if (computedAccent && computedAccent.trim()) {
233 accentColor = computedAccent.trim();
234 }
235 } catch {
236 // If computed styles fail, keep fallback accent color
237 }
238 inputElement.style.setProperty('--dumber-input-accent', accentColor);
239 boxElement.style.setProperty('--dumber-input-accent', 'var(--dynamic-muted)');
240
241 const isFocused = document.activeElement === inputElement;
242
243 if (!isFocused) {
244 inputElement.style.setProperty('--dumber-input-border-color', 'var(--dynamic-border)');
245 }
246
247 if (applyFade) {
248 blurLayerElement.style.opacity = '1';
249 } else {
250 blurLayerElement.style.opacity = '0';
251 }
252 });
253
254 // Global click handler to close omnibox when clicking anywhere outside
255 function handleGlobalClick(event: MouseEvent) {
256 if (!visible) return;
257
258 const target = event.target as HTMLElement;
259 const omniboxContainer = document.getElementById('dumber-omnibox-root');
260
261 // Close if clicking outside the omnibox container
262 if (omniboxContainer && !omniboxContainer.contains(target)) {
263 omniboxStore.close();
264 }
265 }
266
267
268
269 onMount(async () => {
270 syncTheme();
271 observeThemeChanges();
272
273 console.log('🔧 Omnibox component mounted');
274
275 // Initialize responsive styles
276 updateResponsiveStyles();
277
278 // Add resize listener for responsive updates (handles zoom changes)
279 window.addEventListener('resize', updateResponsiveStyles);
280
281 // Add global click listener
282 document.addEventListener('click', handleGlobalClick, true);
283
284 // Load search shortcuts and favorites on mount
285 try {
286 await Promise.all([
287 omniboxBridge.fetchSearchShortcuts(),
288 omniboxBridge.fetchFavorites()
289 ]);
290 console.log('✅ Search shortcuts and favorites loaded on mount');
291 } catch (error) {
292 console.warn('Failed to load initial data:', error);
293 }
294
295 try {
296 // Set up global API for Go bridge
297 window.__dumber_omnibox = {
298 setSuggestions: (suggestions) => {
299 try {
300 console.log('📋 setSuggestions called with:', suggestions?.length || 0, 'items');
301 omniboxBridge.setSuggestions(suggestions);
302 } catch (error) {
303 console.error('Error in setSuggestions:', error);
304 }
305 },
306 toggle: () => {
307 try {
308 console.log('🔄 Omnibox toggle called, current visible:', omniboxStore.visible);
309 omniboxStore.toggle();
310 console.log('🔄 After toggle, visible:', omniboxStore.visible);
311 } catch (error) {
312 console.error('Error in toggle:', error);
313 }
314 },
315 open: (mode, query) => {
316 try {
317 console.log('🚪 Omnibox open called with mode:', mode, 'query:', query);
318 omniboxStore.open(mode as any, query);
319 console.log('🚪 After open, visible:', omniboxStore.visible, 'mode:', omniboxStore.mode);
320 } catch (error) {
321 console.error('Error in open:', error);
322 }
323 },
324 close: () => {
325 try {
326 console.log('🚪 Omnibox close called');
327 omniboxStore.close();
328 } catch (error) {
329 console.error('Error in close:', error);
330 }
331 },
332 findQuery: (query) => {
333 try {
334 console.log('🔍 Find query called with:', query);
335 if (omniboxStore.mode !== 'find') {
336 omniboxStore.setMode('find');
337 }
338 if (!omniboxStore.visible) {
339 omniboxStore.setVisible(true);
340 }
341 omniboxStore.setInputValue(query || '');
342 // Find will be triggered by input value change
343 } catch (error) {
344 console.error('Error in findQuery:', error);
345 }
346 },
347 setActive: (active) => {
348 try {
349 console.log('🎯 Omnibox setActive called with:', active);
350 // For now, this is a no-op but maintains API compatibility
351 // In the future, this could control focus management or visibility
352 } catch (error) {
353 console.error('Error in setActive:', error);
354 }
355 }
356 };
357
358 console.log('✅ Omnibox API exposed:', Object.keys(window.__dumber_omnibox));
359
360 // Check for pending suggestions that arrived before API was ready
361 if ((window as any).__dumber_omnibox_pending_suggestions) {
362 console.log('🔄 [OMNIBOX] Processing pending suggestions');
363 const pending = (window as any).__dumber_omnibox_pending_suggestions;
364 omniboxBridge.setSuggestions(pending);
365 delete (window as any).__dumber_omnibox_pending_suggestions;
366 }
367
368 // Auto-open omnibox on about:blank pages
369 if (window.location.href === 'about:blank') {
370 console.log('🚀 Auto-opening omnibox for about:blank');
371 omniboxStore.open('omnibox');
372 }
373 } catch (error) {
374 console.error('❌ Failed to set up omnibox global API:', error);
375 }
376 });
377
378 onDestroy(() => {
379 // Cleanup resize listener
380 window.removeEventListener('resize', updateResponsiveStyles);
381
382 // Cleanup global click listener
383 document.removeEventListener('click', handleGlobalClick, true);
384
385 if (themeObserver) {
386 themeObserver.disconnect();
387 themeObserver = null;
388 }
389
390 // Reset omnibox state completely
391 omniboxStore.reset();
392
393 // Cleanup global API
394 if (window.__dumber_omnibox) {
395 delete window.__dumber_omnibox;
396 }
397 });
398</script>
399
400<!-- Overlay container -->
401<div
402 class="fixed inset-0 z-[2147483647]"
403 style="display: {visible ? 'block' : 'none'};
404 position: fixed !important;
405 top: 0 !important;
406 left: 0 !important;
407 right: 0 !important;
408 bottom: 0 !important;
409 z-index: 2147483647 !important;
410 pointer-events: none !important;
411 margin: 0 !important;
412 padding: 0 !important;
413 isolation: isolate;"
414 onmousedown={handleOverlayClick}
415 role="presentation"
416>
417 <!-- Main omnibox container -->
418 <div
419 bind:this={boxElement}
420 class="dumber-omnibox-container omnibox-base terminal-omnibox"
421 style="position: relative !important;
422 left: 50% !important;
423 transform: translateX(-50%) !important;
424 margin-top: 20vh !important;
425 margin-left: 0 !important;
426 margin-right: 0 !important;
427 width: {responsiveStyles.width};
428 padding: {responsiveStyles.padding};
429 font-family: 'Fira Sans', system-ui, -apple-system, 'Segoe UI', 'Ubuntu', 'Cantarell', sans-serif;
430 pointer-events: auto !important;
431 box-sizing: border-box !important;
432 background: var(--dumber-omnibox-surface, var(--dynamic-surface));
433 color: var(--dynamic-text);
434 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
435 border-radius: 3px;
436 box-shadow:
437 0 4px 12px rgba(0, 0, 0, 0.2),
438 0 2px 6px rgba(0, 0, 0, 0.15),
439 0 1px 3px rgba(0, 0, 0, 0.1),
440 inset 0 1px 0 rgba(255, 255, 255, 0.04);
441 --dumber-border-color: var(--dynamic-border);"
442 onmousedown={handleBoxClick}
443 onmouseenter={handleMouseEnter}
444 role="dialog"
445 tabindex="-1"
446 aria-modal="true"
447 aria-label={mode === 'find' ? 'Find in page' : 'Omnibox search'}
448 >
449 <!-- Blur layer: absolute full-cover element providing backdrop blur.
450 We animate its opacity rather than the container's backdrop-filter
451 to avoid causing text to blur during transitions. -->
452 <div
453 bind:this={blurLayerElement}
454 class="dumber-omnibox-blur-layer"
455 aria-hidden="true"
456 style="position: absolute; inset: 0; pointer-events: none; border-radius: 0; opacity: 0;"
457 ></div>
458 <div class="dumber-omnibox-content">
459 <!-- Input component -->
460 <OmniboxInput bind:inputElement {responsiveStyles} />
461
462 <!-- Content based on mode -->
463 {#if mode === 'omnibox'}
464 <OmniboxSuggestions />
465 {:else if mode === 'find'}
466 <OmniboxFind />
467 {/if}
468 </div>
469 </div>
470</div>
471
472<style>
473 .dumber-omnibox-container {
474 background: var(--dumber-omnibox-surface, var(--dynamic-surface));
475 color: var(--dynamic-text);
476 border: 1px solid var(--dumber-border-color, var(--dynamic-border));
477 transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
478 border-radius: 3px;
479 box-shadow:
480 0 4px 12px rgba(0, 0, 0, 0.2),
481 0 2px 6px rgba(0, 0, 0, 0.15),
482 0 1px 3px rgba(0, 0, 0, 0.1),
483 inset 0 1px 0 rgba(255, 255, 255, 0.04);
484 }
485
486 .dumber-omnibox-container:focus-within {
487 border-color: var(--dynamic-accent);
488 box-shadow:
489 0 6px 16px rgba(0, 0, 0, 0.24),
490 0 3px 8px rgba(0, 0, 0, 0.18),
491 0 2px 4px rgba(0, 0, 0, 0.12),
492 inset 0 1px 0 rgba(255, 255, 255, 0.06),
493 0 0 0 1px var(--dynamic-accent);
494 }
495
496 :global(.dumber-omnibox-container input) {
497 background: var(--dumber-input-bg, var(--dynamic-bg));
498 color: var(--dynamic-text);
499 border: 1px solid var(--dumber-input-border-color, var(--dynamic-border));
500 border-radius: 2px;
501 box-shadow:
502 inset 0 1px 2px rgba(0, 0, 0, 0.15),
503 inset 0 0 0 1px rgba(0, 0, 0, 0.03);
504 transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
505 font-family:
506 "Fira Sans",
507 system-ui,
508 -apple-system,
509 "Segoe UI",
510 "Ubuntu",
511 "Cantarell",
512 sans-serif;
513 font-size: inherit;
514 letter-spacing: normal;
515 text-transform: none;
516 }
517
518 :global(.dumber-omnibox-container input::placeholder) {
519 color: var(--dynamic-muted);
520 letter-spacing: normal;
521 text-transform: none;
522 }
523
524 /* Blur layer styles: use `backdrop-filter` but animate opacity only. */
525 /* GTK4-style: Minimal blur for faded state */
526 .dumber-omnibox-blur-layer {
527 backdrop-filter: blur(0.5px) saturate(102%);
528 -webkit-backdrop-filter: blur(0.5px) saturate(102%);
529 background: var(--dumber-blur-color, color-mix(in srgb, var(--dynamic-bg) 8%, transparent));
530 transition: opacity 140ms ease;
531 will-change: opacity;
532 z-index: 0;
533 mix-blend-mode: normal;
534 border-radius: 0;
535 }
536
537 /* Ensure the actual content (input, suggestions) sits above the blur layer */
538 .dumber-omnibox-content {
539 position: relative;
540 z-index: 1;
541 /* promote to its own layer to avoid being rasterized with the blur */
542 transform: translateZ(0);
543 backface-visibility: hidden;
544 }
545 /* Additional custom styles if needed */
546</style>