detached ee4a1fb

feat(omnibox): add standalone layer-shell launcher

Launch the omnibox as a standalone GTK layer-shell surface and keep its sizing, transparency, and theme behavior consistent. Route in-app URLs back through dumber, open external schemes with the desktop handler, and cover the new runtime paths with regression tests.
commit
ee4a1fbb50b16addecaf4da57a256c1356165a6f
author
brice · · 2026-04-06 18:12 UTC
parent
408294c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
31 files · +1881 -153
M cmd/dumber/main.go +114 -4
@@ -51,20 +51,52 @@ var initialURL string
5151 // restoreSessionID holds the session ID to restore on startup.
5252 var restoreSessionID string
5353
54+type launchMode string
55+
56+const (
57+ launchModeCLI launchMode = "cli"
58+ launchModeBrowse launchMode = "browse"
59+ launchModeStandaloneOmnibox launchMode = "omnibox"
60+)
61+
62+func launchModeFromArgs(args []string) (launchMode, string) {
63+ if len(args) > 1 {
64+ switch args[1] {
65+ case "browse":
66+ if len(args) > 2 {
67+ return launchModeBrowse, args[2]
68+ }
69+ return launchModeBrowse, ""
70+ case "omnibox":
71+ if len(args) > 2 {
72+ return launchModeCLI, ""
73+ }
74+ return launchModeStandaloneOmnibox, ""
75+ }
76+ }
77+
78+ return launchModeCLI, ""
79+}
80+
5481 func main() {
5582 enableCrashForensics()
5683
84+ mode, browseURL := launchModeFromArgs(os.Args)
85+
5786 // Run GUI mode for browse command
58- if len(os.Args) > 1 && os.Args[1] == "browse" {
59- if len(os.Args) > 2 {
60- initialURL = os.Args[2]
61- }
87+ if mode == launchModeBrowse {
88+ initialURL = browseURL
6289 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
6390 os.Args = os.Args[:1]
6491 os.Exit(runGUI())
6592 return
6693 }
6794
95+ if mode == launchModeStandaloneOmnibox {
96+ os.Exit(runStandaloneOmnibox())
97+ return
98+ }
99+
68100 // Pass build info to CLI
69101 cmd.SetBuildInfo(build.Info{
70102 Version: version,
@@ -140,6 +172,83 @@ func runGUI() int {
140172 return app.Run(ctx, os.Args)
141173 }
142174
175+func runStandaloneOmnibox() int {
176+ maybeReexecStandaloneOmniboxWithLayerShell()
177+
178+ bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
179+ runtime.LockOSThread()
180+ component.SetSkeletonVersion(version)
181+
182+ cfg := initConfig()
183+ ctx := initStartupContextWithTrace(cfg)
184+
185+ initResult, err := runParallelInitPhase(ctx, cfg)
186+ if err != nil {
187+ return 1
188+ }
189+
190+ engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
191+ if err != nil {
192+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
193+ return 1
194+ }
195+ if dbCleanup != nil {
196+ defer dbCleanup()
197+ }
198+
199+ useCases := createUseCases(repos, cfg)
200+ uiDeps := buildUIDependencies(
201+ ctx,
202+ cfg,
203+ initResult.ThemeManager,
204+ initResult.ColorResolver,
205+ initResult.AdwaitaDetector,
206+ engine,
207+ repos,
208+ useCases,
209+ nil,
210+ "",
211+ nil,
212+ )
213+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
214+
215+ return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
216+}
217+
218+func maybeReexecStandaloneOmniboxWithLayerShell() {
219+ env := bootstrap.CurrentEnvMap()
220+ if !bootstrap.ShouldPreloadLayerShell(env) {
221+ return
222+ }
223+
224+ libraryPath := bootstrap.LayerShellLibraryPath()
225+ if libraryPath == "" {
226+ return
227+ }
228+
229+ env = bootstrap.LayerShellPreloadEnv(env, libraryPath)
230+ envSlice := make([]string, 0, len(env))
231+ for key, value := range env {
232+ envSlice = append(envSlice, key+"="+value)
233+ }
234+
235+ execPath, err := resolveCurrentExecutable(os.Executable)
236+ if err != nil {
237+ fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
238+ return
239+ }
240+
241+ // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env.
242+ if err := syscall.Exec(execPath, os.Args, envSlice); err != nil {
243+ fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
244+ return
245+ }
246+}
247+
248+func resolveCurrentExecutable(executable func() (string, error)) (string, error) {
249+ return executable()
250+}
251+
143252 func initStartupContextWithTrace(cfg *config.Config) context.Context {
144253 logging.InitStartupTrace(cfg.Logging.Level)
145254 logging.Trace().Mark("config_loaded")
@@ -581,6 +690,7 @@ func buildUIDependencies(
581690 return config.GetManager().Watch()
582691 },
583692 LaunchExternalURL: desktop.LaunchExternalURL,
693+ LaunchBrowserURL: desktop.LaunchBrowserURL,
584694 ConfigMigrator: config.NewMigrator(),
585695 }
586696
@@ -51,20 +51,52 @@ var initialURL string
51 // restoreSessionID holds the session ID to restore on startup.51 // restoreSessionID holds the session ID to restore on startup.
52 var restoreSessionID string52 var restoreSessionID string
53 53
54+type launchMode string
55+
56+const (
57+ launchModeCLI launchMode = "cli"
58+ launchModeBrowse launchMode = "browse"
59+ launchModeStandaloneOmnibox launchMode = "omnibox"
60+)
61+
62+func launchModeFromArgs(args []string) (launchMode, string) {
63+ if len(args) > 1 {
64+ switch args[1] {
65+ case "browse":
66+ if len(args) > 2 {
67+ return launchModeBrowse, args[2]
68+ }
69+ return launchModeBrowse, ""
70+ case "omnibox":
71+ if len(args) > 2 {
72+ return launchModeCLI, ""
73+ }
74+ return launchModeStandaloneOmnibox, ""
75+ }
76+ }
77+
78+ return launchModeCLI, ""
79+}
80+
54 func main() {81 func main() {
55 enableCrashForensics()82 enableCrashForensics()
56 83
84+ mode, browseURL := launchModeFromArgs(os.Args)
85+
57 // Run GUI mode for browse command86 // Run GUI mode for browse command
58- if len(os.Args) > 1 && os.Args[1] == "browse" {87+ if mode == launchModeBrowse {
59- if len(os.Args) > 2 {88+ initialURL = browseURL
60- initialURL = os.Args[2]
61- }
62 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")89 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
63 os.Args = os.Args[:1]90 os.Args = os.Args[:1]
64 os.Exit(runGUI())91 os.Exit(runGUI())
65 return92 return
66 }93 }
67 94
95+ if mode == launchModeStandaloneOmnibox {
96+ os.Exit(runStandaloneOmnibox())
97+ return
98+ }
99+
68 // Pass build info to CLI100 // Pass build info to CLI
69 cmd.SetBuildInfo(build.Info{101 cmd.SetBuildInfo(build.Info{
70 Version: version,102 Version: version,
@@ -140,6 +172,83 @@ func runGUI() int {
140 return app.Run(ctx, os.Args)172 return app.Run(ctx, os.Args)
141 }173 }
142 174
175+func runStandaloneOmnibox() int {
176+ maybeReexecStandaloneOmniboxWithLayerShell()
177+
178+ bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
179+ runtime.LockOSThread()
180+ component.SetSkeletonVersion(version)
181+
182+ cfg := initConfig()
183+ ctx := initStartupContextWithTrace(cfg)
184+
185+ initResult, err := runParallelInitPhase(ctx, cfg)
186+ if err != nil {
187+ return 1
188+ }
189+
190+ engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
191+ if err != nil {
192+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
193+ return 1
194+ }
195+ if dbCleanup != nil {
196+ defer dbCleanup()
197+ }
198+
199+ useCases := createUseCases(repos, cfg)
200+ uiDeps := buildUIDependencies(
201+ ctx,
202+ cfg,
203+ initResult.ThemeManager,
204+ initResult.ColorResolver,
205+ initResult.AdwaitaDetector,
206+ engine,
207+ repos,
208+ useCases,
209+ nil,
210+ "",
211+ nil,
212+ )
213+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
214+
215+ return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
216+}
217+
218+func maybeReexecStandaloneOmniboxWithLayerShell() {
219+ env := bootstrap.CurrentEnvMap()
220+ if !bootstrap.ShouldPreloadLayerShell(env) {
221+ return
222+ }
223+
224+ libraryPath := bootstrap.LayerShellLibraryPath()
225+ if libraryPath == "" {
226+ return
227+ }
228+
229+ env = bootstrap.LayerShellPreloadEnv(env, libraryPath)
230+ envSlice := make([]string, 0, len(env))
231+ for key, value := range env {
232+ envSlice = append(envSlice, key+"="+value)
233+ }
234+
235+ execPath, err := resolveCurrentExecutable(os.Executable)
236+ if err != nil {
237+ fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
238+ return
239+ }
240+
241+ // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env.
242+ if err := syscall.Exec(execPath, os.Args, envSlice); err != nil {
243+ fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
244+ return
245+ }
246+}
247+
248+func resolveCurrentExecutable(executable func() (string, error)) (string, error) {
249+ return executable()
250+}
251+
143 func initStartupContextWithTrace(cfg *config.Config) context.Context {252 func initStartupContextWithTrace(cfg *config.Config) context.Context {
144 logging.InitStartupTrace(cfg.Logging.Level)253 logging.InitStartupTrace(cfg.Logging.Level)
145 logging.Trace().Mark("config_loaded")254 logging.Trace().Mark("config_loaded")
@@ -581,6 +690,7 @@ func buildUIDependencies(
581 return config.GetManager().Watch()690 return config.GetManager().Watch()
582 },691 },
583 LaunchExternalURL: desktop.LaunchExternalURL,692 LaunchExternalURL: desktop.LaunchExternalURL,
693+ LaunchBrowserURL: desktop.LaunchBrowserURL,
584 ConfigMigrator: config.NewMigrator(),694 ConfigMigrator: config.NewMigrator(),
585 }695 }
586 696
A cmd/dumber/main_test.go +69 -0
@@ -0,0 +1,69 @@
1+package main
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
9+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"})
10+ if mode != launchModeStandaloneOmnibox {
11+ t.Fatalf("expected standalone omnibox mode, got %q", mode)
12+ }
13+}
14+
15+func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) {
16+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"})
17+ if mode != launchModeBrowse {
18+ t.Fatalf("expected browse mode, got %q", mode)
19+ }
20+ if browseURL != "https://example.com" {
21+ t.Fatalf("expected browse url to be preserved, got %q", browseURL)
22+ }
23+}
24+
25+func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) {
26+ mode, browseURL := launchModeFromArgs([]string{"dumber"})
27+ if mode != launchModeCLI {
28+ t.Fatalf("expected cli mode, got %q", mode)
29+ }
30+ if browseURL != "" {
31+ t.Fatalf("expected empty browse url, got %q", browseURL)
32+ }
33+}
34+
35+func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) {
36+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"})
37+ if mode != launchModeCLI {
38+ t.Fatalf("expected cli mode for omnibox help, got %q", mode)
39+ }
40+}
41+
42+func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
43+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"})
44+ if mode != launchModeCLI {
45+ t.Fatalf("expected cli mode for omnibox flags, got %q", mode)
46+ }
47+}
48+
49+func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
50+ got, err := resolveCurrentExecutable(func() (string, error) {
51+ return "/usr/bin/dumber", nil
52+ })
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+ if got != "/usr/bin/dumber" {
57+ t.Fatalf("expected executable path to be preserved, got %q", got)
58+ }
59+}
60+
61+func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
62+ wantErr := errors.New("not found")
63+ _, err := resolveCurrentExecutable(func() (string, error) {
64+ return "", wantErr
65+ })
66+ if !errors.Is(err, wantErr) {
67+ t.Fatalf("expected error %v, got %v", wantErr, err)
68+ }
69+}
@@ -0,0 +1,69 @@
1+package main
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
9+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"})
10+ if mode != launchModeStandaloneOmnibox {
11+ t.Fatalf("expected standalone omnibox mode, got %q", mode)
12+ }
13+}
14+
15+func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) {
16+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"})
17+ if mode != launchModeBrowse {
18+ t.Fatalf("expected browse mode, got %q", mode)
19+ }
20+ if browseURL != "https://example.com" {
21+ t.Fatalf("expected browse url to be preserved, got %q", browseURL)
22+ }
23+}
24+
25+func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) {
26+ mode, browseURL := launchModeFromArgs([]string{"dumber"})
27+ if mode != launchModeCLI {
28+ t.Fatalf("expected cli mode, got %q", mode)
29+ }
30+ if browseURL != "" {
31+ t.Fatalf("expected empty browse url, got %q", browseURL)
32+ }
33+}
34+
35+func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) {
36+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"})
37+ if mode != launchModeCLI {
38+ t.Fatalf("expected cli mode for omnibox help, got %q", mode)
39+ }
40+}
41+
42+func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
43+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"})
44+ if mode != launchModeCLI {
45+ t.Fatalf("expected cli mode for omnibox flags, got %q", mode)
46+ }
47+}
48+
49+func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
50+ got, err := resolveCurrentExecutable(func() (string, error) {
51+ return "/usr/bin/dumber", nil
52+ })
53+ if err != nil {
54+ t.Fatalf("expected no error, got %v", err)
55+ }
56+ if got != "/usr/bin/dumber" {
57+ t.Fatalf("expected executable path to be preserved, got %q", got)
58+ }
59+}
60+
61+func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
62+ wantErr := errors.New("not found")
63+ _, err := resolveCurrentExecutable(func() (string, error) {
64+ return "", wantErr
65+ })
66+ if !errors.Is(err, wantErr) {
67+ t.Fatalf("expected error %v, got %v", wantErr, err)
68+ }
69+}
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.26.1
44
55 require (
66 github.com/bnema/purego-sqlite v0.1.1
7- github.com/bnema/puregotk v0.2.2
7+ github.com/bnema/puregotk v0.3.0
88 github.com/bnema/puregotk-webkit v0.0.16
99 github.com/charmbracelet/bubbles v1.0.0
1010 github.com/charmbracelet/bubbletea v1.3.10
@@ -4,7 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego-sqlite v0.1.16 github.com/bnema/purego-sqlite v0.1.1
7- github.com/bnema/puregotk v0.2.27+ github.com/bnema/puregotk v0.3.0
8 github.com/bnema/puregotk-webkit v0.0.168 github.com/bnema/puregotk-webkit v0.0.16
9 github.com/charmbracelet/bubbles v1.0.09 github.com/charmbracelet/bubbles v1.0.0
10 github.com/charmbracelet/bubbletea v1.3.1010 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +2 -0 generated file: collapsed
A internal/bootstrap/layershell_preload.go +101 -0
@@ -0,0 +1,101 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+const layerShellLibrarySubstring = "libgtk4-layer-shell"
10+
11+var layerShellLibraryCandidates = []string{
12+ "/usr/lib/libgtk4-layer-shell.so.0",
13+ "/usr/lib64/libgtk4-layer-shell.so.0",
14+ "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0",
15+ "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0",
16+ "/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0",
17+}
18+
19+func ShouldPreloadLayerShell(env map[string]string) bool {
20+ if env["WAYLAND_DISPLAY"] == "" {
21+ return false
22+ }
23+
24+ return !LayerShellPreloadPresent(env)
25+}
26+
27+func LayerShellPreloadPresent(env map[string]string) bool {
28+ return strings.Contains(env["LD_PRELOAD"], layerShellLibrarySubstring)
29+}
30+
31+func CurrentEnvMap() map[string]string {
32+ env := make(map[string]string)
33+ for _, entry := range os.Environ() {
34+ key, value, ok := strings.Cut(entry, "=")
35+ if !ok {
36+ continue
37+ }
38+ env[key] = value
39+ }
40+ return env
41+}
42+
43+func FindLayerShellLibrary(exists func(path string) bool) string {
44+ for _, path := range layerShellLibrarySearchPaths(CurrentEnvMap()) {
45+ if exists(path) {
46+ return path
47+ }
48+ }
49+
50+ return ""
51+}
52+
53+func LayerShellLibraryPath() string {
54+ return FindLayerShellLibrary(func(path string) bool {
55+ info, err := os.Stat(filepath.Clean(path))
56+ return err == nil && !info.IsDir()
57+ })
58+}
59+
60+func layerShellLibrarySearchPaths(env map[string]string) []string {
61+ paths := make([]string, 0, len(layerShellLibraryCandidates)+4)
62+ paths = append(paths, layerShellLibraryCandidates...)
63+
64+ seen := make(map[string]struct{}, len(paths))
65+ for _, path := range paths {
66+ seen[path] = struct{}{}
67+ }
68+
69+ for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) {
70+ if dir == "" {
71+ continue
72+ }
73+ candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0")
74+ if _, ok := seen[candidate]; ok {
75+ continue
76+ }
77+ seen[candidate] = struct{}{}
78+ paths = append(paths, candidate)
79+ }
80+
81+ return paths
82+}
83+
84+func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string {
85+ cloned := make(map[string]string, len(env)+1)
86+ for key, value := range env {
87+ cloned[key] = value
88+ }
89+
90+ if libraryPath == "" {
91+ return cloned
92+ }
93+
94+ if current := cloned["LD_PRELOAD"]; current != "" {
95+ cloned["LD_PRELOAD"] = libraryPath + " " + current
96+ return cloned
97+ }
98+
99+ cloned["LD_PRELOAD"] = libraryPath
100+ return cloned
101+}
@@ -0,0 +1,101 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+const layerShellLibrarySubstring = "libgtk4-layer-shell"
10+
11+var layerShellLibraryCandidates = []string{
12+ "/usr/lib/libgtk4-layer-shell.so.0",
13+ "/usr/lib64/libgtk4-layer-shell.so.0",
14+ "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0",
15+ "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0",
16+ "/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0",
17+}
18+
19+func ShouldPreloadLayerShell(env map[string]string) bool {
20+ if env["WAYLAND_DISPLAY"] == "" {
21+ return false
22+ }
23+
24+ return !LayerShellPreloadPresent(env)
25+}
26+
27+func LayerShellPreloadPresent(env map[string]string) bool {
28+ return strings.Contains(env["LD_PRELOAD"], layerShellLibrarySubstring)
29+}
30+
31+func CurrentEnvMap() map[string]string {
32+ env := make(map[string]string)
33+ for _, entry := range os.Environ() {
34+ key, value, ok := strings.Cut(entry, "=")
35+ if !ok {
36+ continue
37+ }
38+ env[key] = value
39+ }
40+ return env
41+}
42+
43+func FindLayerShellLibrary(exists func(path string) bool) string {
44+ for _, path := range layerShellLibrarySearchPaths(CurrentEnvMap()) {
45+ if exists(path) {
46+ return path
47+ }
48+ }
49+
50+ return ""
51+}
52+
53+func LayerShellLibraryPath() string {
54+ return FindLayerShellLibrary(func(path string) bool {
55+ info, err := os.Stat(filepath.Clean(path))
56+ return err == nil && !info.IsDir()
57+ })
58+}
59+
60+func layerShellLibrarySearchPaths(env map[string]string) []string {
61+ paths := make([]string, 0, len(layerShellLibraryCandidates)+4)
62+ paths = append(paths, layerShellLibraryCandidates...)
63+
64+ seen := make(map[string]struct{}, len(paths))
65+ for _, path := range paths {
66+ seen[path] = struct{}{}
67+ }
68+
69+ for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) {
70+ if dir == "" {
71+ continue
72+ }
73+ candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0")
74+ if _, ok := seen[candidate]; ok {
75+ continue
76+ }
77+ seen[candidate] = struct{}{}
78+ paths = append(paths, candidate)
79+ }
80+
81+ return paths
82+}
83+
84+func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string {
85+ cloned := make(map[string]string, len(env)+1)
86+ for key, value := range env {
87+ cloned[key] = value
88+ }
89+
90+ if libraryPath == "" {
91+ return cloned
92+ }
93+
94+ if current := cloned["LD_PRELOAD"]; current != "" {
95+ cloned["LD_PRELOAD"] = libraryPath + " " + current
96+ return cloned
97+ }
98+
99+ cloned["LD_PRELOAD"] = libraryPath
100+ return cloned
101+}
A internal/bootstrap/layershell_preload_test.go +123 -0
@@ -0,0 +1,123 @@
1+package bootstrap
2+
3+import "testing"
4+
5+func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) {
6+ env := map[string]string{
7+ "WAYLAND_DISPLAY": "wayland-1",
8+ "LD_PRELOAD": "",
9+ }
10+
11+ if !ShouldPreloadLayerShell(env) {
12+ t.Fatalf("expected preload to be required")
13+ }
14+}
15+
16+func TestShouldPreloadLayerShell_SkipsWithoutLibraryPath(t *testing.T) {
17+ env := map[string]string{
18+ "WAYLAND_DISPLAY": "wayland-1",
19+ "LD_PRELOAD": "",
20+ }
21+
22+ if !ShouldPreloadLayerShell(env) {
23+ t.Fatalf("expected preload to be required before library probing")
24+ }
25+}
26+
27+func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) {
28+ env := map[string]string{
29+ "WAYLAND_DISPLAY": "wayland-1",
30+ "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0",
31+ }
32+
33+ if ShouldPreloadLayerShell(env) {
34+ t.Fatalf("expected preload to be skipped")
35+ }
36+}
37+
38+func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) {
39+ if ShouldPreloadLayerShell(map[string]string{}) {
40+ t.Fatalf("expected preload to be skipped outside Wayland")
41+ }
42+}
43+
44+func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) {
45+ env := map[string]string{
46+ "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so",
47+ }
48+
49+ if !LayerShellPreloadPresent(env) {
50+ t.Fatalf("expected preload presence to be detected")
51+ }
52+}
53+
54+func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) {
55+ got := FindLayerShellLibrary(func(path string) bool {
56+ return path == layerShellLibraryCandidates[1]
57+ })
58+
59+ if got != layerShellLibraryCandidates[1] {
60+ t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got)
61+ }
62+}
63+
64+func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) {
65+ arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0"
66+ got := FindLayerShellLibrary(func(path string) bool {
67+ return path == arm64Candidate
68+ })
69+
70+ if got != arm64Candidate {
71+ t.Fatalf("expected %q, got %q", arm64Candidate, got)
72+ }
73+}
74+
75+func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) {
76+ if got := FindLayerShellLibrary(func(string) bool { return false }); got != "" {
77+ t.Fatalf("expected empty result, got %q", got)
78+ }
79+}
80+
81+func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) {
82+ paths := layerShellLibrarySearchPaths(map[string]string{
83+ "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib",
84+ })
85+
86+ wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0"
87+ wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0"
88+ if !containsString(paths, wantCustom) {
89+ t.Fatalf("expected custom search path %q in %#v", wantCustom, paths)
90+ }
91+ if !containsString(paths, wantNix) {
92+ t.Fatalf("expected nix search path %q in %#v", wantNix, paths)
93+ }
94+}
95+
96+func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) {
97+ env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0")
98+
99+ if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" {
100+ t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"])
101+ }
102+ if env["PATH"] != "/usr/bin" {
103+ t.Fatalf("expected unrelated env to be preserved")
104+ }
105+}
106+
107+func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) {
108+ env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0")
109+
110+ want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so"
111+ if env["LD_PRELOAD"] != want {
112+ t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"])
113+ }
114+}
115+
116+func containsString(items []string, want string) bool {
117+ for _, item := range items {
118+ if item == want {
119+ return true
120+ }
121+ }
122+ return false
123+}
@@ -0,0 +1,123 @@
1+package bootstrap
2+
3+import "testing"
4+
5+func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) {
6+ env := map[string]string{
7+ "WAYLAND_DISPLAY": "wayland-1",
8+ "LD_PRELOAD": "",
9+ }
10+
11+ if !ShouldPreloadLayerShell(env) {
12+ t.Fatalf("expected preload to be required")
13+ }
14+}
15+
16+func TestShouldPreloadLayerShell_SkipsWithoutLibraryPath(t *testing.T) {
17+ env := map[string]string{
18+ "WAYLAND_DISPLAY": "wayland-1",
19+ "LD_PRELOAD": "",
20+ }
21+
22+ if !ShouldPreloadLayerShell(env) {
23+ t.Fatalf("expected preload to be required before library probing")
24+ }
25+}
26+
27+func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) {
28+ env := map[string]string{
29+ "WAYLAND_DISPLAY": "wayland-1",
30+ "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0",
31+ }
32+
33+ if ShouldPreloadLayerShell(env) {
34+ t.Fatalf("expected preload to be skipped")
35+ }
36+}
37+
38+func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) {
39+ if ShouldPreloadLayerShell(map[string]string{}) {
40+ t.Fatalf("expected preload to be skipped outside Wayland")
41+ }
42+}
43+
44+func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) {
45+ env := map[string]string{
46+ "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so",
47+ }
48+
49+ if !LayerShellPreloadPresent(env) {
50+ t.Fatalf("expected preload presence to be detected")
51+ }
52+}
53+
54+func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) {
55+ got := FindLayerShellLibrary(func(path string) bool {
56+ return path == layerShellLibraryCandidates[1]
57+ })
58+
59+ if got != layerShellLibraryCandidates[1] {
60+ t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got)
61+ }
62+}
63+
64+func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) {
65+ arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0"
66+ got := FindLayerShellLibrary(func(path string) bool {
67+ return path == arm64Candidate
68+ })
69+
70+ if got != arm64Candidate {
71+ t.Fatalf("expected %q, got %q", arm64Candidate, got)
72+ }
73+}
74+
75+func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) {
76+ if got := FindLayerShellLibrary(func(string) bool { return false }); got != "" {
77+ t.Fatalf("expected empty result, got %q", got)
78+ }
79+}
80+
81+func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) {
82+ paths := layerShellLibrarySearchPaths(map[string]string{
83+ "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib",
84+ })
85+
86+ wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0"
87+ wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0"
88+ if !containsString(paths, wantCustom) {
89+ t.Fatalf("expected custom search path %q in %#v", wantCustom, paths)
90+ }
91+ if !containsString(paths, wantNix) {
92+ t.Fatalf("expected nix search path %q in %#v", wantNix, paths)
93+ }
94+}
95+
96+func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) {
97+ env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0")
98+
99+ if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" {
100+ t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"])
101+ }
102+ if env["PATH"] != "/usr/bin" {
103+ t.Fatalf("expected unrelated env to be preserved")
104+ }
105+}
106+
107+func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) {
108+ env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0")
109+
110+ want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so"
111+ if env["LD_PRELOAD"] != want {
112+ t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"])
113+ }
114+}
115+
116+func containsString(items []string, want string) bool {
117+ for _, item := range items {
118+ if item == want {
119+ return true
120+ }
121+ }
122+ return false
123+}
A internal/cli/cmd/omnibox.go +22 -0
@@ -0,0 +1,22 @@
1+package cmd
2+
3+import (
4+ "github.com/spf13/cobra"
5+)
6+
7+var omniboxCmd = &cobra.Command{
8+ Use: "omnibox",
9+ Short: "Launch the standalone omnibox overlay",
10+ Args: cobra.NoArgs,
11+ Long: `Launch the GTK omnibox as a standalone surface.
12+
13+On Wayland, dumber will enable layer-shell when available so the omnibox can
14+appear as its own overlay instead of inside the main browser window.`,
15+ Run: func(_ *cobra.Command, _ []string) {
16+ // Handled by cmd/dumber/main.go before Cobra executes.
17+ },
18+}
19+
20+func init() {
21+ rootCmd.AddCommand(omniboxCmd)
22+}
@@ -0,0 +1,22 @@
1+package cmd
2+
3+import (
4+ "github.com/spf13/cobra"
5+)
6+
7+var omniboxCmd = &cobra.Command{
8+ Use: "omnibox",
9+ Short: "Launch the standalone omnibox overlay",
10+ Args: cobra.NoArgs,
11+ Long: `Launch the GTK omnibox as a standalone surface.
12+
13+On Wayland, dumber will enable layer-shell when available so the omnibox can
14+appear as its own overlay instead of inside the main browser window.`,
15+ Run: func(_ *cobra.Command, _ []string) {
16+ // Handled by cmd/dumber/main.go before Cobra executes.
17+ },
18+}
19+
20+func init() {
21+ rootCmd.AddCommand(omniboxCmd)
22+}
A internal/cli/cmd/omnibox_test.go +13 -0
@@ -0,0 +1,13 @@
1+package cmd
2+
3+import "testing"
4+
5+func TestRootCommand_RegistersOmniboxCommand(t *testing.T) {
6+ cmd, _, err := rootCmd.Find([]string{"omnibox"})
7+ if err != nil {
8+ t.Fatalf("expected omnibox command to be registered: %v", err)
9+ }
10+ if cmd == nil || cmd.Name() != "omnibox" {
11+ t.Fatalf("expected omnibox command, got %#v", cmd)
12+ }
13+}
@@ -0,0 +1,13 @@
1+package cmd
2+
3+import "testing"
4+
5+func TestRootCommand_RegistersOmniboxCommand(t *testing.T) {
6+ cmd, _, err := rootCmd.Find([]string{"omnibox"})
7+ if err != nil {
8+ t.Fatalf("expected omnibox command to be registered: %v", err)
9+ }
10+ if cmd == nil || cmd.Name() != "omnibox" {
11+ t.Fatalf("expected omnibox command, got %#v", cmd)
12+ }
13+}
M internal/cli/cmd/root.go +3 -3
@@ -34,9 +34,9 @@ Features:
3434 - Search shortcuts via bangs (!g, !gi, etc.)
3535 - Session management with auto-save and restore
3636
37-Use 'dumber browse' to launch the graphical browser, or explore the
38-subcommands for CLI-based operations like history search and session
39-management.`,
37+Use 'dumber browse' to launch the graphical browser, 'dumber omnibox' to open
38+the standalone launcher overlay, or explore the subcommands for CLI-based
39+operations like history search and session management.`,
4040 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
4141 // Skip initialization for commands that don't need app context
4242 switch cmd.Name() {
@@ -34,9 +34,9 @@ Features:
34 - Search shortcuts via bangs (!g, !gi, etc.)34 - Search shortcuts via bangs (!g, !gi, etc.)
35 - Session management with auto-save and restore35 - Session management with auto-save and restore
36 36
37-Use 'dumber browse' to launch the graphical browser, or explore the37+Use 'dumber browse' to launch the graphical browser, 'dumber omnibox' to open
38-subcommands for CLI-based operations like history search and session38+the standalone launcher overlay, or explore the subcommands for CLI-based
39-management.`,39+operations like history search and session management.`,
40 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {40 PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
41 // Skip initialization for commands that don't need app context41 // Skip initialization for commands that don't need app context
42 switch cmd.Name() {42 switch cmd.Name() {
M internal/domain/url/normalize.go +39 -0
@@ -57,6 +57,10 @@ func Normalize(input string) string {
5757 return input
5858 case strings.HasPrefix(input, "about:"):
5959 return input
60+ case IsExternalScheme(input):
61+ return input
62+ case hasExplicitSchemeURL(input):
63+ return input
6064 }
6165
6266 // Check if input looks like a file path
@@ -134,6 +138,10 @@ func LooksLikeURL(input string) bool {
134138 return true
135139 case strings.HasPrefix(input, "about:"):
136140 return true
141+ case IsExternalScheme(input):
142+ return true
143+ case hasExplicitSchemeURL(input):
144+ return true
137145 }
138146
139147 // Contains a dot and no spaces = likely a URL
@@ -146,6 +154,19 @@ func LooksLikeURL(input string) bool {
146154 return strings.Contains(input, ".") && !strings.Contains(input, " ")
147155 }
148156
157+func hasExplicitSchemeURL(input string) bool {
158+ if !strings.Contains(input, "://") {
159+ return false
160+ }
161+
162+ parsed, err := url.Parse(input)
163+ if err != nil {
164+ return false
165+ }
166+
167+ return parsed.Scheme != ""
168+}
169+
149170 // ExtractDomain extracts the normalized domain (host) from a URL string.
150171 // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
151172 // resolve to the same value.
@@ -220,6 +241,12 @@ func IsExternalScheme(uri string) bool {
220241 if err != nil || parsed.Scheme == "" {
221242 return false
222243 }
244+ if !strings.Contains(uri, "://") {
245+ rest := strings.TrimPrefix(uri, parsed.Scheme+":")
246+ if strings.EqualFold(parsed.Scheme, "localhost") || strings.Contains(parsed.Scheme, ".") || isDecimalPort(rest) {
247+ return false
248+ }
249+ }
223250
224251 switch strings.ToLower(parsed.Scheme) {
225252 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
@@ -229,6 +256,18 @@ func IsExternalScheme(uri string) bool {
229256 }
230257 }
231258
259+func isDecimalPort(value string) bool {
260+ if value == "" {
261+ return false
262+ }
263+ for _, r := range value {
264+ if r < '0' || r > '9' {
265+ return false
266+ }
267+ }
268+ return true
269+}
270+
232271 // ExtractOrigin extracts the origin (scheme://host) from a URI.
233272 // This normalizes URIs to origins for permission storage and comparison.
234273 // Canonicalizes by lowercasing scheme and host, and omitting default ports.
@@ -57,6 +57,10 @@ func Normalize(input string) string {
57 return input57 return input
58 case strings.HasPrefix(input, "about:"):58 case strings.HasPrefix(input, "about:"):
59 return input59 return input
60+ case IsExternalScheme(input):
61+ return input
62+ case hasExplicitSchemeURL(input):
63+ return input
60 }64 }
61 65
62 // Check if input looks like a file path66 // Check if input looks like a file path
@@ -134,6 +138,10 @@ func LooksLikeURL(input string) bool {
134 return true138 return true
135 case strings.HasPrefix(input, "about:"):139 case strings.HasPrefix(input, "about:"):
136 return true140 return true
141+ case IsExternalScheme(input):
142+ return true
143+ case hasExplicitSchemeURL(input):
144+ return true
137 }145 }
138 146
139 // Contains a dot and no spaces = likely a URL147 // Contains a dot and no spaces = likely a URL
@@ -146,6 +154,19 @@ func LooksLikeURL(input string) bool {
146 return strings.Contains(input, ".") && !strings.Contains(input, " ")154 return strings.Contains(input, ".") && !strings.Contains(input, " ")
147 }155 }
148 156
157+func hasExplicitSchemeURL(input string) bool {
158+ if !strings.Contains(input, "://") {
159+ return false
160+ }
161+
162+ parsed, err := url.Parse(input)
163+ if err != nil {
164+ return false
165+ }
166+
167+ return parsed.Scheme != ""
168+}
169+
149 // ExtractDomain extracts the normalized domain (host) from a URL string.170 // ExtractDomain extracts the normalized domain (host) from a URL string.
150 // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com171 // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com
151 // resolve to the same value.172 // resolve to the same value.
@@ -220,6 +241,12 @@ func IsExternalScheme(uri string) bool {
220 if err != nil || parsed.Scheme == "" {241 if err != nil || parsed.Scheme == "" {
221 return false242 return false
222 }243 }
244+ if !strings.Contains(uri, "://") {
245+ rest := strings.TrimPrefix(uri, parsed.Scheme+":")
246+ if strings.EqualFold(parsed.Scheme, "localhost") || strings.Contains(parsed.Scheme, ".") || isDecimalPort(rest) {
247+ return false
248+ }
249+ }
223 250
224 switch strings.ToLower(parsed.Scheme) {251 switch strings.ToLower(parsed.Scheme) {
225 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":252 case "http", "https", "file", "dumb", "about", "data", "blob", "javascript":
@@ -229,6 +256,18 @@ func IsExternalScheme(uri string) bool {
229 }256 }
230 }257 }
231 258
259+func isDecimalPort(value string) bool {
260+ if value == "" {
261+ return false
262+ }
263+ for _, r := range value {
264+ if r < '0' || r > '9' {
265+ return false
266+ }
267+ }
268+ return true
269+}
270+
232 // ExtractOrigin extracts the origin (scheme://host) from a URI.271 // ExtractOrigin extracts the origin (scheme://host) from a URI.
233 // This normalizes URIs to origins for permission storage and comparison.272 // This normalizes URIs to origins for permission storage and comparison.
234 // Canonicalizes by lowercasing scheme and host, and omitting default ports.273 // Canonicalizes by lowercasing scheme and host, and omitting default ports.
M internal/domain/url/normalize_test.go +55 -0
@@ -42,6 +42,21 @@ func TestNormalize(t *testing.T) {
4242 input: "about:blank",
4343 want: "about:blank",
4444 },
45+ {
46+ name: "mailto scheme unchanged",
47+ input: "mailto:foo@example.com",
48+ want: "mailto:foo@example.com",
49+ },
50+ {
51+ name: "magnet scheme unchanged",
52+ input: "magnet:?xt=urn:btih:123",
53+ want: "magnet:?xt=urn:btih:123",
54+ },
55+ {
56+ name: "obsidian scheme unchanged",
57+ input: "obsidian://open?vault=foo.bar",
58+ want: "obsidian://open?vault=foo.bar",
59+ },
4560 {
4661 name: "domain gets https",
4762 input: "example.com",
@@ -335,11 +350,36 @@ func TestLooksLikeURL(t *testing.T) {
335350 input: "dumb://home",
336351 want: true,
337352 },
353+ {
354+ name: "obsidian URL",
355+ input: "obsidian://open?vault=foo.bar",
356+ want: true,
357+ },
338358 {
339359 name: "about URL",
340360 input: "about:blank",
341361 want: true,
342362 },
363+ {
364+ name: "spotify URL",
365+ input: "spotify://track/123",
366+ want: true,
367+ },
368+ {
369+ name: "mailto URL",
370+ input: "mailto:foo@example.com",
371+ want: true,
372+ },
373+ {
374+ name: "magnet URL",
375+ input: "magnet:?xt=urn:btih:123",
376+ want: true,
377+ },
378+ {
379+ name: "vscode URL",
380+ input: "vscode://file/tmp/demo",
381+ want: true,
382+ },
343383 {
344384 name: "domain",
345385 input: "example.com",
@@ -519,6 +559,16 @@ func TestIsExternalScheme(t *testing.T) {
519559 input: "VSCODE://open",
520560 want: true,
521561 },
562+ {
563+ name: "geo scheme",
564+ input: "geo:37.786971,-122.399677",
565+ want: true,
566+ },
567+ {
568+ name: "xmpp scheme",
569+ input: "xmpp:user@example.com",
570+ want: true,
571+ },
522572 {
523573 name: "malformed URL",
524574 input: "://invalid",
@@ -529,6 +579,11 @@ func TestIsExternalScheme(t *testing.T) {
529579 input: "example.com",
530580 want: false,
531581 },
582+ {
583+ name: "localhost port is not external scheme",
584+ input: "localhost:5173",
585+ want: false,
586+ },
532587 {
533588 name: "scheme only",
534589 input: "custom://",
@@ -42,6 +42,21 @@ func TestNormalize(t *testing.T) {
42 input: "about:blank",42 input: "about:blank",
43 want: "about:blank",43 want: "about:blank",
44 },44 },
45+ {
46+ name: "mailto scheme unchanged",
47+ input: "mailto:foo@example.com",
48+ want: "mailto:foo@example.com",
49+ },
50+ {
51+ name: "magnet scheme unchanged",
52+ input: "magnet:?xt=urn:btih:123",
53+ want: "magnet:?xt=urn:btih:123",
54+ },
55+ {
56+ name: "obsidian scheme unchanged",
57+ input: "obsidian://open?vault=foo.bar",
58+ want: "obsidian://open?vault=foo.bar",
59+ },
45 {60 {
46 name: "domain gets https",61 name: "domain gets https",
47 input: "example.com",62 input: "example.com",
@@ -335,11 +350,36 @@ func TestLooksLikeURL(t *testing.T) {
335 input: "dumb://home",350 input: "dumb://home",
336 want: true,351 want: true,
337 },352 },
353+ {
354+ name: "obsidian URL",
355+ input: "obsidian://open?vault=foo.bar",
356+ want: true,
357+ },
338 {358 {
339 name: "about URL",359 name: "about URL",
340 input: "about:blank",360 input: "about:blank",
341 want: true,361 want: true,
342 },362 },
363+ {
364+ name: "spotify URL",
365+ input: "spotify://track/123",
366+ want: true,
367+ },
368+ {
369+ name: "mailto URL",
370+ input: "mailto:foo@example.com",
371+ want: true,
372+ },
373+ {
374+ name: "magnet URL",
375+ input: "magnet:?xt=urn:btih:123",
376+ want: true,
377+ },
378+ {
379+ name: "vscode URL",
380+ input: "vscode://file/tmp/demo",
381+ want: true,
382+ },
343 {383 {
344 name: "domain",384 name: "domain",
345 input: "example.com",385 input: "example.com",
@@ -519,6 +559,16 @@ func TestIsExternalScheme(t *testing.T) {
519 input: "VSCODE://open",559 input: "VSCODE://open",
520 want: true,560 want: true,
521 },561 },
562+ {
563+ name: "geo scheme",
564+ input: "geo:37.786971,-122.399677",
565+ want: true,
566+ },
567+ {
568+ name: "xmpp scheme",
569+ input: "xmpp:user@example.com",
570+ want: true,
571+ },
522 {572 {
523 name: "malformed URL",573 name: "malformed URL",
524 input: "://invalid",574 input: "://invalid",
@@ -529,6 +579,11 @@ func TestIsExternalScheme(t *testing.T) {
529 input: "example.com",579 input: "example.com",
530 want: false,580 want: false,
531 },581 },
582+ {
583+ name: "localhost port is not external scheme",
584+ input: "localhost:5173",
585+ want: false,
586+ },
532 {587 {
533 name: "scheme only",588 name: "scheme only",
534 input: "custom://",589 input: "custom://",
M internal/domain/url/search_test.go +14 -0
@@ -76,6 +76,20 @@ func TestBuildSearchURL(t *testing.T) {
7676 defaultSearch: testDefaultSearch,
7777 want: "dumb://settings",
7878 },
79+ {
80+ name: "custom scheme spotify unchanged",
81+ input: "spotify://track/123",
82+ shortcuts: testShortcuts,
83+ defaultSearch: testDefaultSearch,
84+ want: "spotify://track/123",
85+ },
86+ {
87+ name: "mailto scheme unchanged",
88+ input: "mailto:foo@example.com",
89+ shortcuts: testShortcuts,
90+ defaultSearch: testDefaultSearch,
91+ want: "mailto:foo@example.com",
92+ },
7993 {
8094 name: "plain search query uses default",
8195 input: "how to parse json",
@@ -76,6 +76,20 @@ func TestBuildSearchURL(t *testing.T) {
76 defaultSearch: testDefaultSearch,76 defaultSearch: testDefaultSearch,
77 want: "dumb://settings",77 want: "dumb://settings",
78 },78 },
79+ {
80+ name: "custom scheme spotify unchanged",
81+ input: "spotify://track/123",
82+ shortcuts: testShortcuts,
83+ defaultSearch: testDefaultSearch,
84+ want: "spotify://track/123",
85+ },
86+ {
87+ name: "mailto scheme unchanged",
88+ input: "mailto:foo@example.com",
89+ shortcuts: testShortcuts,
90+ defaultSearch: testDefaultSearch,
91+ want: "mailto:foo@example.com",
92+ },
79 {93 {
80 name: "plain search query uses default",94 name: "plain search query uses default",
81 input: "how to parse json",95 input: "how to parse json",
M internal/infrastructure/desktop/adapter.go +59 -4
@@ -392,23 +392,78 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394394
395+const layerShellPreloadToken = "libgtk4" + "-layer-shell"
396+
395397 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
396398 // Used for external URL schemes like vscode://, spotify://, steam://, etc.
397399 func LaunchExternalURL(uri string) {
398400 cmd := exec.Command("xdg-open", uri)
399- // Detach from current process - we don't care about the result
401+ cmd.Env = sanitizedChildEnv(os.Environ())
402+ if err := startDetachedProcess(cmd); err != nil {
403+ fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
404+ }
405+}
406+
407+// LaunchBrowserURL opens a URL in a new dumber browser window.
408+func LaunchBrowserURL(uri string) {
409+ execPath, err := getExecutablePath()
410+ if err != nil {
411+ fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err)
412+ return
413+ }
414+
415+ cmd := exec.Command(execPath, "browse", uri)
416+ cmd.Env = sanitizedChildEnv(os.Environ())
417+ if err := startDetachedProcess(cmd); err != nil {
418+ fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err)
419+ }
420+}
421+
422+func startDetachedProcess(cmd *exec.Cmd) error {
423+ if cmd == nil {
424+ return nil
425+ }
426+
400427 cmd.Stdin = nil
401428 cmd.Stdout = nil
402429 cmd.Stderr = nil
403430 if err := cmd.Start(); err != nil {
404- fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
405- return
431+ return err
406432 }
407433 if cmd.Process != nil {
408434 if err := cmd.Process.Release(); err != nil {
409- fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err)
435+ return err
436+ }
437+ }
438+
439+ return nil
440+}
441+
442+func sanitizedChildEnv(env []string) []string {
443+ cleaned := make([]string, 0, len(env))
444+ for _, entry := range env {
445+ if !strings.HasPrefix(entry, "LD_PRELOAD=") {
446+ cleaned = append(cleaned, entry)
447+ continue
448+ }
449+
450+ value := strings.TrimPrefix(entry, "LD_PRELOAD=")
451+ parts := strings.FieldsFunc(value, func(r rune) bool {
452+ return r == ' ' || r == ':'
453+ })
454+ kept := make([]string, 0, len(parts))
455+ for _, part := range parts {
456+ if strings.Contains(part, layerShellPreloadToken) {
457+ continue
458+ }
459+ kept = append(kept, part)
460+ }
461+ if len(kept) > 0 {
462+ cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
410463 }
411464 }
465+
466+ return cleaned
412467 }
413468
414469 // SpawnWithSession starts a new dumber instance to restore a session.
@@ -392,23 +392,78 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394 394
395+const layerShellPreloadToken = "libgtk4" + "-layer-shell"
396+
395 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).397 // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
396 // Used for external URL schemes like vscode://, spotify://, steam://, etc.398 // Used for external URL schemes like vscode://, spotify://, steam://, etc.
397 func LaunchExternalURL(uri string) {399 func LaunchExternalURL(uri string) {
398 cmd := exec.Command("xdg-open", uri)400 cmd := exec.Command("xdg-open", uri)
399- // Detach from current process - we don't care about the result401+ cmd.Env = sanitizedChildEnv(os.Environ())
402+ if err := startDetachedProcess(cmd); err != nil {
403+ fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
404+ }
405+}
406+
407+// LaunchBrowserURL opens a URL in a new dumber browser window.
408+func LaunchBrowserURL(uri string) {
409+ execPath, err := getExecutablePath()
410+ if err != nil {
411+ fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err)
412+ return
413+ }
414+
415+ cmd := exec.Command(execPath, "browse", uri)
416+ cmd.Env = sanitizedChildEnv(os.Environ())
417+ if err := startDetachedProcess(cmd); err != nil {
418+ fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err)
419+ }
420+}
421+
422+func startDetachedProcess(cmd *exec.Cmd) error {
423+ if cmd == nil {
424+ return nil
425+ }
426+
400 cmd.Stdin = nil427 cmd.Stdin = nil
401 cmd.Stdout = nil428 cmd.Stdout = nil
402 cmd.Stderr = nil429 cmd.Stderr = nil
403 if err := cmd.Start(); err != nil {430 if err := cmd.Start(); err != nil {
404- fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)431+ return err
405- return
406 }432 }
407 if cmd.Process != nil {433 if cmd.Process != nil {
408 if err := cmd.Process.Release(); err != nil {434 if err := cmd.Process.Release(); err != nil {
409- fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err)435+ return err
436+ }
437+ }
438+
439+ return nil
440+}
441+
442+func sanitizedChildEnv(env []string) []string {
443+ cleaned := make([]string, 0, len(env))
444+ for _, entry := range env {
445+ if !strings.HasPrefix(entry, "LD_PRELOAD=") {
446+ cleaned = append(cleaned, entry)
447+ continue
448+ }
449+
450+ value := strings.TrimPrefix(entry, "LD_PRELOAD=")
451+ parts := strings.FieldsFunc(value, func(r rune) bool {
452+ return r == ' ' || r == ':'
453+ })
454+ kept := make([]string, 0, len(parts))
455+ for _, part := range parts {
456+ if strings.Contains(part, layerShellPreloadToken) {
457+ continue
458+ }
459+ kept = append(kept, part)
460+ }
461+ if len(kept) > 0 {
462+ cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
410 }463 }
411 }464 }
465+
466+ return cleaned
412 }467 }
413 468
414 // SpawnWithSession starts a new dumber instance to restore a session.469 // SpawnWithSession starts a new dumber instance to restore a session.
A internal/infrastructure/desktop/adapter_test.go +34 -0
@@ -0,0 +1,34 @@
1+package desktop
2+
3+import "testing"
4+
5+func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
6+ env := sanitizedChildEnv([]string{
7+ "PATH=/usr/bin",
8+ "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so",
9+ })
10+
11+ if len(env) != 2 {
12+ t.Fatalf("expected two env entries, got %#v", env)
13+ }
14+ if env[0] != "PATH=/usr/bin" {
15+ t.Fatalf("expected PATH to be preserved, got %#v", env)
16+ }
17+ if env[1] != "LD_PRELOAD=/tmp/keep.so" {
18+ t.Fatalf("expected non-layer-shell preload to remain, got %#v", env)
19+ }
20+}
21+
22+func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) {
23+ env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"})
24+ if len(env) != 0 {
25+ t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env)
26+ }
27+}
28+
29+func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
30+ env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"})
31+ if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" {
32+ t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
33+ }
34+}
@@ -0,0 +1,34 @@
1+package desktop
2+
3+import "testing"
4+
5+func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
6+ env := sanitizedChildEnv([]string{
7+ "PATH=/usr/bin",
8+ "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so",
9+ })
10+
11+ if len(env) != 2 {
12+ t.Fatalf("expected two env entries, got %#v", env)
13+ }
14+ if env[0] != "PATH=/usr/bin" {
15+ t.Fatalf("expected PATH to be preserved, got %#v", env)
16+ }
17+ if env[1] != "LD_PRELOAD=/tmp/keep.so" {
18+ t.Fatalf("expected non-layer-shell preload to remain, got %#v", env)
19+ }
20+}
21+
22+func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) {
23+ env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"})
24+ if len(env) != 0 {
25+ t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env)
26+ }
27+}
28+
29+func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
30+ env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"})
31+ if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" {
32+ t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
33+ }
34+}
M internal/ui/app.go +95 -23
@@ -751,33 +751,101 @@ func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
751751 }
752752 }
753753
754-func (a *App) initOmniboxConfig(ctx context.Context) {
755- if a.deps == nil || a.deps.Config == nil {
756- return
757- }
758-
759- log := logging.FromContext(ctx)
760-
761- // Convert config shortcuts to use case type
762- shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))
763- for key, shortcut := range a.deps.Config.SearchShortcuts {
754+type omniboxCallbacks struct {
755+ OnNavigate func(url string)
756+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
757+ OnFocusIn func(entry *gtk.SearchEntry)
758+ OnFocusOut func()
759+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
760+ OnAccentKeyRelease func(keyval uint)
761+}
762+
763+func buildOmniboxConfig(
764+ deps *Dependencies,
765+ faviconAdapter *adapter.FaviconAdapter,
766+ callbacks omniboxCallbacks,
767+) component.OmniboxConfig {
768+ if deps == nil || deps.Config == nil {
769+ return component.OmniboxConfig{}
770+ }
771+
772+ shortcuts := make(map[string]usecase.SearchShortcut, len(deps.Config.SearchShortcuts))
773+ for key, shortcut := range deps.Config.SearchShortcuts {
764774 shortcuts[key] = usecase.SearchShortcut{
765775 URL: shortcut.URL,
766776 Description: shortcut.Description,
767777 }
768778 }
769- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
770779
771- // Store omnibox config (omnibox is created per-pane via WorkspaceView).
772- a.omniboxCfg = component.OmniboxConfig{
773- HistoryUC: a.deps.HistoryUC,
774- FavoritesUC: a.deps.FavoritesUC,
775- FaviconAdapter: a.faviconAdapter,
776- CopyURLUC: a.deps.CopyURLUC,
777- ShortcutsUC: shortcutsUC,
778- DefaultSearch: a.deps.Config.DefaultSearchEngine,
779- InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
780- UIScale: a.deps.Config.DefaultUIScale,
780+ return component.OmniboxConfig{
781+ HistoryUC: deps.HistoryUC,
782+ FavoritesUC: deps.FavoritesUC,
783+ FaviconAdapter: faviconAdapter,
784+ CopyURLUC: deps.CopyURLUC,
785+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
786+ DefaultSearch: deps.Config.DefaultSearchEngine,
787+ InitialBehavior: deps.Config.Omnibox.InitialBehavior,
788+ UIScale: deps.Config.DefaultUIScale,
789+ OnNavigate: callbacks.OnNavigate,
790+ OnToast: callbacks.OnToast,
791+ OnFocusIn: callbacks.OnFocusIn,
792+ OnFocusOut: callbacks.OnFocusOut,
793+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
794+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
795+ }
796+}
797+
798+func NewStandaloneOmniboxRuntime(
799+ ctx context.Context,
800+ deps *Dependencies,
801+ faviconDB port.FaviconDatabase,
802+) *StandaloneOmniboxRuntime {
803+ var faviconAdapter *adapter.FaviconAdapter
804+ if deps != nil && deps.FaviconService != nil {
805+ faviconAdapter = adapter.NewFaviconAdapter(deps.FaviconService, faviconDB, deps.FaviconAdapterConfig)
806+ }
807+
808+ omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{
809+ OnNavigate: func(url string) {
810+ handleStandaloneOmniboxNavigation(deps, ctx, url)
811+ },
812+ OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
813+ logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
814+ },
815+ OnFocusIn: func(*gtk.SearchEntry) {},
816+ OnFocusOut: func() {},
817+ OnAccentKeyPress: func(uint, gdk.ModifierType) bool { return false },
818+ OnAccentKeyRelease: func(uint) {},
819+ })
820+
821+ return &StandaloneOmniboxRuntime{OmniboxCfg: omniboxCfg, ApplyTheme: func(display *gdk.Display) {
822+ if deps != nil && deps.Theme != nil && display != nil {
823+ deps.Theme.ApplyToDisplay(ctx, display)
824+ }
825+ }}
826+}
827+
828+func handleStandaloneOmniboxNavigation(deps *Dependencies, ctx context.Context, rawURL string) {
829+ if urlutil.IsExternalScheme(rawURL) {
830+ if deps != nil && deps.LaunchExternalURL != nil {
831+ deps.LaunchExternalURL(rawURL)
832+ return
833+ }
834+ } else if deps != nil && deps.LaunchBrowserURL != nil {
835+ deps.LaunchBrowserURL(rawURL)
836+ return
837+ }
838+
839+ logging.FromContext(ctx).Info().Str("url", rawURL).Msg("standalone omnibox navigate submitted")
840+}
841+
842+func (a *App) initOmniboxConfig(ctx context.Context) {
843+ if a.deps == nil || a.deps.Config == nil {
844+ return
845+ }
846+
847+ log := logging.FromContext(ctx)
848+ a.omniboxCfg = buildOmniboxConfig(a.deps, a.faviconAdapter, omniboxCallbacks{
781849 OnNavigate: func(url string) {
782850 if err := a.navigateFromOmnibox(ctx, url); err != nil {
783851 log.Error().Err(err).Str("url", url).Msg("navigation failed")
@@ -803,7 +871,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
803871 OnAccentKeyRelease: func(keyval uint) {
804872 a.handleAccentKeyRelease(ctx, keyval)
805873 },
806- }
874+ })
807875 a.navCoord.SetOmniboxProvider(a)
808876 log.Debug().Msg("omnibox config stored, provider set")
809877 }
@@ -2410,7 +2478,11 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve
24102478 return
24112479 }
24122480
2413- cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2481+ cb := func(_ gtk.Overlay, widget *gtk.Widget, allocationPtr *uintptr) bool {
2482+ var widgetPtr uintptr
2483+ if widget != nil {
2484+ widgetPtr = widget.GoPointer()
2485+ }
24142486 overlayWidth := workspaceOverlay.GetAllocatedWidth()
24152487 overlayHeight := workspaceOverlay.GetAllocatedHeight()
24162488 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
@@ -751,33 +751,101 @@ func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
751 }751 }
752 }752 }
753 753
754-func (a *App) initOmniboxConfig(ctx context.Context) {754+type omniboxCallbacks struct {
755- if a.deps == nil || a.deps.Config == nil {755+ OnNavigate func(url string)
756- return756+ OnToast func(ctx context.Context, message string, level component.ToastLevel)
757- }757+ OnFocusIn func(entry *gtk.SearchEntry)
758-758+ OnFocusOut func()
759- log := logging.FromContext(ctx)759+ OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool
760-760+ OnAccentKeyRelease func(keyval uint)
761- // Convert config shortcuts to use case type761+}
762- shortcuts := make(map[string]usecase.SearchShortcut, len(a.deps.Config.SearchShortcuts))762+
763- for key, shortcut := range a.deps.Config.SearchShortcuts {763+func buildOmniboxConfig(
764+ deps *Dependencies,
765+ faviconAdapter *adapter.FaviconAdapter,
766+ callbacks omniboxCallbacks,
767+) component.OmniboxConfig {
768+ if deps == nil || deps.Config == nil {
769+ return component.OmniboxConfig{}
770+ }
771+
772+ shortcuts := make(map[string]usecase.SearchShortcut, len(deps.Config.SearchShortcuts))
773+ for key, shortcut := range deps.Config.SearchShortcuts {
764 shortcuts[key] = usecase.SearchShortcut{774 shortcuts[key] = usecase.SearchShortcut{
765 URL: shortcut.URL,775 URL: shortcut.URL,
766 Description: shortcut.Description,776 Description: shortcut.Description,
767 }777 }
768 }778 }
769- shortcutsUC := usecase.NewSearchShortcutsUseCase(shortcuts)
770 779
771- // Store omnibox config (omnibox is created per-pane via WorkspaceView).780+ return component.OmniboxConfig{
772- a.omniboxCfg = component.OmniboxConfig{781+ HistoryUC: deps.HistoryUC,
773- HistoryUC: a.deps.HistoryUC,782+ FavoritesUC: deps.FavoritesUC,
774- FavoritesUC: a.deps.FavoritesUC,783+ FaviconAdapter: faviconAdapter,
775- FaviconAdapter: a.faviconAdapter,784+ CopyURLUC: deps.CopyURLUC,
776- CopyURLUC: a.deps.CopyURLUC,785+ ShortcutsUC: usecase.NewSearchShortcutsUseCase(shortcuts),
777- ShortcutsUC: shortcutsUC,786+ DefaultSearch: deps.Config.DefaultSearchEngine,
778- DefaultSearch: a.deps.Config.DefaultSearchEngine,787+ InitialBehavior: deps.Config.Omnibox.InitialBehavior,
779- InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,788+ UIScale: deps.Config.DefaultUIScale,
780- UIScale: a.deps.Config.DefaultUIScale,789+ OnNavigate: callbacks.OnNavigate,
790+ OnToast: callbacks.OnToast,
791+ OnFocusIn: callbacks.OnFocusIn,
792+ OnFocusOut: callbacks.OnFocusOut,
793+ OnAccentKeyPress: callbacks.OnAccentKeyPress,
794+ OnAccentKeyRelease: callbacks.OnAccentKeyRelease,
795+ }
796+}
797+
798+func NewStandaloneOmniboxRuntime(
799+ ctx context.Context,
800+ deps *Dependencies,
801+ faviconDB port.FaviconDatabase,
802+) *StandaloneOmniboxRuntime {
803+ var faviconAdapter *adapter.FaviconAdapter
804+ if deps != nil && deps.FaviconService != nil {
805+ faviconAdapter = adapter.NewFaviconAdapter(deps.FaviconService, faviconDB, deps.FaviconAdapterConfig)
806+ }
807+
808+ omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{
809+ OnNavigate: func(url string) {
810+ handleStandaloneOmniboxNavigation(deps, ctx, url)
811+ },
812+ OnToast: func(toastCtx context.Context, message string, level component.ToastLevel) {
813+ logging.FromContext(toastCtx).Debug().Str("message", message).Int("level", int(level)).Msg("standalone omnibox toast")
814+ },
815+ OnFocusIn: func(*gtk.SearchEntry) {},
816+ OnFocusOut: func() {},
817+ OnAccentKeyPress: func(uint, gdk.ModifierType) bool { return false },
818+ OnAccentKeyRelease: func(uint) {},
819+ })
820+
821+ return &StandaloneOmniboxRuntime{OmniboxCfg: omniboxCfg, ApplyTheme: func(display *gdk.Display) {
822+ if deps != nil && deps.Theme != nil && display != nil {
823+ deps.Theme.ApplyToDisplay(ctx, display)
824+ }
825+ }}
826+}
827+
828+func handleStandaloneOmniboxNavigation(deps *Dependencies, ctx context.Context, rawURL string) {
829+ if urlutil.IsExternalScheme(rawURL) {
830+ if deps != nil && deps.LaunchExternalURL != nil {
831+ deps.LaunchExternalURL(rawURL)
832+ return
833+ }
834+ } else if deps != nil && deps.LaunchBrowserURL != nil {
835+ deps.LaunchBrowserURL(rawURL)
836+ return
837+ }
838+
839+ logging.FromContext(ctx).Info().Str("url", rawURL).Msg("standalone omnibox navigate submitted")
840+}
841+
842+func (a *App) initOmniboxConfig(ctx context.Context) {
843+ if a.deps == nil || a.deps.Config == nil {
844+ return
845+ }
846+
847+ log := logging.FromContext(ctx)
848+ a.omniboxCfg = buildOmniboxConfig(a.deps, a.faviconAdapter, omniboxCallbacks{
781 OnNavigate: func(url string) {849 OnNavigate: func(url string) {
782 if err := a.navigateFromOmnibox(ctx, url); err != nil {850 if err := a.navigateFromOmnibox(ctx, url); err != nil {
783 log.Error().Err(err).Str("url", url).Msg("navigation failed")851 log.Error().Err(err).Str("url", url).Msg("navigation failed")
@@ -803,7 +871,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
803 OnAccentKeyRelease: func(keyval uint) {871 OnAccentKeyRelease: func(keyval uint) {
804 a.handleAccentKeyRelease(ctx, keyval)872 a.handleAccentKeyRelease(ctx, keyval)
805 },873 },
806- }874+ })
807 a.navCoord.SetOmniboxProvider(a)875 a.navCoord.SetOmniboxProvider(a)
808 log.Debug().Msg("omnibox config stored, provider set")876 log.Debug().Msg("omnibox config stored, provider set")
809 }877 }
@@ -2410,7 +2478,11 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve
2410 return2478 return
2411 }2479 }
2412 2480
2413- cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {2481+ cb := func(_ gtk.Overlay, widget *gtk.Widget, allocationPtr *uintptr) bool {
2482+ var widgetPtr uintptr
2483+ if widget != nil {
2484+ widgetPtr = widget.GoPointer()
2485+ }
2414 overlayWidth := workspaceOverlay.GetAllocatedWidth()2486 overlayWidth := workspaceOverlay.GetAllocatedWidth()
2415 overlayHeight := workspaceOverlay.GetAllocatedHeight()2487 overlayHeight := workspaceOverlay.GetAllocatedHeight()
2416 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)2488 x, y, width, height, ok := a.floatingAllocationForWidget(tabID, widgetPtr, overlayWidth, overlayHeight)
M internal/ui/component/modal_sizing.go +50 -9
@@ -7,11 +7,14 @@ import (
77
88 // ModalSizeConfig holds configuration for modal sizing calculations.
99 type ModalSizeConfig struct {
10- WidthPct float64 // Percentage of parent width (e.g., 0.6)
11- MaxWidth int // Maximum width in pixels
12- TopMarginPct float64 // Top margin as percentage of parent height
13- FallbackWidth int // Fallback when parent not allocated
14- FallbackHeight int // Fallback height
10+ WidthPct float64 // Percentage of parent width (e.g., 0.6)
11+ MaxWidth int // Maximum width in pixels
12+ TopMarginPct float64 // Top margin as percentage of parent height
13+ FallbackWidth int // Fallback when parent not allocated
14+ FallbackHeight int // Fallback height
15+ FixedWidth int // Optional fixed width in pixels (overrides WidthPct/MaxWidth)
16+ FixedTopMargin int // Optional fixed top margin in pixels
17+ UseFixedTopMargin bool // When true, FixedTopMargin overrides TopMarginPct
1518 }
1619
1720 // RowHeightDefaults holds default (unscaled) row heights for list-based modals.
@@ -110,6 +113,36 @@ func EffectiveMaxRows(parentHeight, rowHeight int, sizeCfg ModalSizeConfig, defa
110113 return defaults.MaxVisibleRows
111114 }
112115
116+// ResolveModalSizeConfig merges a partial override with defaults.
117+func ResolveModalSizeConfig(cfg, defaults ModalSizeConfig) ModalSizeConfig {
118+ resolved := defaults
119+
120+ if cfg.WidthPct > 0 {
121+ resolved.WidthPct = cfg.WidthPct
122+ }
123+ if cfg.MaxWidth > 0 {
124+ resolved.MaxWidth = cfg.MaxWidth
125+ }
126+ if cfg.TopMarginPct > 0 {
127+ resolved.TopMarginPct = cfg.TopMarginPct
128+ }
129+ if cfg.FallbackWidth > 0 {
130+ resolved.FallbackWidth = cfg.FallbackWidth
131+ }
132+ if cfg.FallbackHeight > 0 {
133+ resolved.FallbackHeight = cfg.FallbackHeight
134+ }
135+ if cfg.FixedWidth > 0 {
136+ resolved.FixedWidth = cfg.FixedWidth
137+ }
138+ if cfg.UseFixedTopMargin {
139+ resolved.FixedTopMargin = cfg.FixedTopMargin
140+ resolved.UseFixedTopMargin = true
141+ }
142+
143+ return resolved
144+}
145+
113146 // CalculateModalDimensions computes width and top margin based on parent overlay.
114147 // Returns calculated width and top margin in pixels.
115148 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
@@ -129,12 +162,20 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
129162 parentHeight = cfg.FallbackHeight
130163 }
131164
132- width = int(float64(parentWidth) * cfg.WidthPct)
133- if width > cfg.MaxWidth {
134- width = cfg.MaxWidth
165+ if cfg.FixedWidth > 0 {
166+ width = cfg.FixedWidth
167+ } else {
168+ width = int(float64(parentWidth) * cfg.WidthPct)
169+ if width > cfg.MaxWidth {
170+ width = cfg.MaxWidth
171+ }
135172 }
136173
137- marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
174+ if cfg.UseFixedTopMargin {
175+ marginTop = cfg.FixedTopMargin
176+ } else {
177+ marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
178+ }
138179 return width, marginTop
139180 }
140181
@@ -7,11 +7,14 @@ import (
7 7
8 // ModalSizeConfig holds configuration for modal sizing calculations.8 // ModalSizeConfig holds configuration for modal sizing calculations.
9 type ModalSizeConfig struct {9 type ModalSizeConfig struct {
10- WidthPct float64 // Percentage of parent width (e.g., 0.6)10+ WidthPct float64 // Percentage of parent width (e.g., 0.6)
11- MaxWidth int // Maximum width in pixels11+ MaxWidth int // Maximum width in pixels
12- TopMarginPct float64 // Top margin as percentage of parent height12+ TopMarginPct float64 // Top margin as percentage of parent height
13- FallbackWidth int // Fallback when parent not allocated13+ FallbackWidth int // Fallback when parent not allocated
14- FallbackHeight int // Fallback height14+ FallbackHeight int // Fallback height
15+ FixedWidth int // Optional fixed width in pixels (overrides WidthPct/MaxWidth)
16+ FixedTopMargin int // Optional fixed top margin in pixels
17+ UseFixedTopMargin bool // When true, FixedTopMargin overrides TopMarginPct
15 }18 }
16 19
17 // RowHeightDefaults holds default (unscaled) row heights for list-based modals.20 // RowHeightDefaults holds default (unscaled) row heights for list-based modals.
@@ -110,6 +113,36 @@ func EffectiveMaxRows(parentHeight, rowHeight int, sizeCfg ModalSizeConfig, defa
110 return defaults.MaxVisibleRows113 return defaults.MaxVisibleRows
111 }114 }
112 115
116+// ResolveModalSizeConfig merges a partial override with defaults.
117+func ResolveModalSizeConfig(cfg, defaults ModalSizeConfig) ModalSizeConfig {
118+ resolved := defaults
119+
120+ if cfg.WidthPct > 0 {
121+ resolved.WidthPct = cfg.WidthPct
122+ }
123+ if cfg.MaxWidth > 0 {
124+ resolved.MaxWidth = cfg.MaxWidth
125+ }
126+ if cfg.TopMarginPct > 0 {
127+ resolved.TopMarginPct = cfg.TopMarginPct
128+ }
129+ if cfg.FallbackWidth > 0 {
130+ resolved.FallbackWidth = cfg.FallbackWidth
131+ }
132+ if cfg.FallbackHeight > 0 {
133+ resolved.FallbackHeight = cfg.FallbackHeight
134+ }
135+ if cfg.FixedWidth > 0 {
136+ resolved.FixedWidth = cfg.FixedWidth
137+ }
138+ if cfg.UseFixedTopMargin {
139+ resolved.FixedTopMargin = cfg.FixedTopMargin
140+ resolved.UseFixedTopMargin = true
141+ }
142+
143+ return resolved
144+}
145+
113 // CalculateModalDimensions computes width and top margin based on parent overlay.146 // CalculateModalDimensions computes width and top margin based on parent overlay.
114 // Returns calculated width and top margin in pixels.147 // Returns calculated width and top margin in pixels.
115 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {148 func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) {
@@ -129,12 +162,20 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig)
129 parentHeight = cfg.FallbackHeight162 parentHeight = cfg.FallbackHeight
130 }163 }
131 164
132- width = int(float64(parentWidth) * cfg.WidthPct)165+ if cfg.FixedWidth > 0 {
133- if width > cfg.MaxWidth {166+ width = cfg.FixedWidth
134- width = cfg.MaxWidth167+ } else {
168+ width = int(float64(parentWidth) * cfg.WidthPct)
169+ if width > cfg.MaxWidth {
170+ width = cfg.MaxWidth
171+ }
135 }172 }
136 173
137- marginTop = int(float64(parentHeight) * cfg.TopMarginPct)174+ if cfg.UseFixedTopMargin {
175+ marginTop = cfg.FixedTopMargin
176+ } else {
177+ marginTop = int(float64(parentHeight) * cfg.TopMarginPct)
178+ }
138 return width, marginTop179 return width, marginTop
139 }180 }
140 181
M internal/ui/component/modal_sizing_test.go +45 -5
@@ -88,6 +88,47 @@ func TestCalculateModalDimensions_WidthPercentage(t *testing.T) {
8888 assert.Equal(t, 90, marginTop)
8989 }
9090
91+func TestCalculateModalDimensions_FixedWidthAndTopMargin(t *testing.T) {
92+ cfg := ModalSizeConfig{
93+ WidthPct: 0.8,
94+ MaxWidth: 600,
95+ TopMarginPct: 0.2,
96+ FallbackWidth: 1000,
97+ FallbackHeight: 800,
98+ FixedWidth: 800,
99+ FixedTopMargin: 0,
100+ UseFixedTopMargin: true,
101+ }
102+
103+ parent := layoutmocks.NewMockOverlayWidget(t)
104+ parent.EXPECT().GetAllocatedWidth().Return(1920)
105+ parent.EXPECT().GetAllocatedHeight().Return(1080)
106+
107+ width, marginTop := CalculateModalDimensions(parent, cfg)
108+
109+ assert.Equal(t, 800, width)
110+ assert.Equal(t, 0, marginTop)
111+}
112+
113+func TestResolveModalSizeConfig_MergesDefaultsAndOverrides(t *testing.T) {
114+ defaults := OmniboxSizeDefaults
115+ override := ModalSizeConfig{
116+ FixedWidth: 800,
117+ FixedTopMargin: 0,
118+ UseFixedTopMargin: true,
119+ }
120+
121+ resolved := ResolveModalSizeConfig(override, defaults)
122+
123+ assert.Equal(t, defaults.WidthPct, resolved.WidthPct)
124+ assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth)
125+ assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth)
126+ assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight)
127+ assert.Equal(t, 800, resolved.FixedWidth)
128+ assert.True(t, resolved.UseFixedTopMargin)
129+ assert.Equal(t, 0, resolved.FixedTopMargin)
130+}
131+
91132 func TestScaleValue_DefaultScale(t *testing.T) {
92133 // When scale is 0 or negative, should default to 1.0
93134 assert.Equal(t, 50, ScaleValue(50, 0))
@@ -122,11 +163,10 @@ func TestDefaultRowHeights_Values(t *testing.T) {
122163 }
123164
124165 func TestOmniboxSizeDefaults_Values(t *testing.T) {
125- assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0)
126- assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0)
127- assert.Positive(t, OmniboxSizeDefaults.MaxWidth)
128- assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0)
129- assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0)
166+ assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001)
167+ assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth)
168+ assert.InDelta(t, 0.2, OmniboxSizeDefaults.TopMarginPct, 0.0001)
169+ assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight)
130170 }
131171
132172 func TestSessionManagerSizeDefaults_Values(t *testing.T) {
@@ -88,6 +88,47 @@ func TestCalculateModalDimensions_WidthPercentage(t *testing.T) {
88 assert.Equal(t, 90, marginTop)88 assert.Equal(t, 90, marginTop)
89 }89 }
90 90
91+func TestCalculateModalDimensions_FixedWidthAndTopMargin(t *testing.T) {
92+ cfg := ModalSizeConfig{
93+ WidthPct: 0.8,
94+ MaxWidth: 600,
95+ TopMarginPct: 0.2,
96+ FallbackWidth: 1000,
97+ FallbackHeight: 800,
98+ FixedWidth: 800,
99+ FixedTopMargin: 0,
100+ UseFixedTopMargin: true,
101+ }
102+
103+ parent := layoutmocks.NewMockOverlayWidget(t)
104+ parent.EXPECT().GetAllocatedWidth().Return(1920)
105+ parent.EXPECT().GetAllocatedHeight().Return(1080)
106+
107+ width, marginTop := CalculateModalDimensions(parent, cfg)
108+
109+ assert.Equal(t, 800, width)
110+ assert.Equal(t, 0, marginTop)
111+}
112+
113+func TestResolveModalSizeConfig_MergesDefaultsAndOverrides(t *testing.T) {
114+ defaults := OmniboxSizeDefaults
115+ override := ModalSizeConfig{
116+ FixedWidth: 800,
117+ FixedTopMargin: 0,
118+ UseFixedTopMargin: true,
119+ }
120+
121+ resolved := ResolveModalSizeConfig(override, defaults)
122+
123+ assert.Equal(t, defaults.WidthPct, resolved.WidthPct)
124+ assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth)
125+ assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth)
126+ assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight)
127+ assert.Equal(t, 800, resolved.FixedWidth)
128+ assert.True(t, resolved.UseFixedTopMargin)
129+ assert.Equal(t, 0, resolved.FixedTopMargin)
130+}
131+
91 func TestScaleValue_DefaultScale(t *testing.T) {132 func TestScaleValue_DefaultScale(t *testing.T) {
92 // When scale is 0 or negative, should default to 1.0133 // When scale is 0 or negative, should default to 1.0
93 assert.Equal(t, 50, ScaleValue(50, 0))134 assert.Equal(t, 50, ScaleValue(50, 0))
@@ -122,11 +163,10 @@ func TestDefaultRowHeights_Values(t *testing.T) {
122 }163 }
123 164
124 func TestOmniboxSizeDefaults_Values(t *testing.T) {165 func TestOmniboxSizeDefaults_Values(t *testing.T) {
125- assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0)166+ assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001)
126- assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0)167+ assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth)
127- assert.Positive(t, OmniboxSizeDefaults.MaxWidth)168+ assert.InDelta(t, 0.2, OmniboxSizeDefaults.TopMarginPct, 0.0001)
128- assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0)169+ assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight)
129- assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0)
130 }170 }
131 171
132 func TestSessionManagerSizeDefaults_Values(t *testing.T) {172 func TestSessionManagerSizeDefaults_Values(t *testing.T) {
M internal/ui/component/omnibox.go +220 -78
@@ -124,6 +124,7 @@ type Omnibox struct {
124124
125125 // Scaling
126126 uiScale float64
127+ sizeCfg ModalSizeConfig
127128
128129 // Cached measurements (populated after first layout)
129130 measuredHeights struct {
@@ -158,6 +159,7 @@ type OmniboxConfig struct {
158159 OnFocusOut func() // Callback when entry loses focus
159160 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
160161 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel
162+ SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing
161163 }
162164
163165 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -170,6 +172,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
170172 uiScale = 1.0
171173 }
172174
175+ sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
176+
173177 o := &Omnibox{
174178 viewMode: ViewModeHistory,
175179 selectedIndex: -1,
@@ -185,6 +189,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
185189 onAccentKeyRelease: cfg.OnAccentKeyRelease,
186190 ctx: ctx,
187191 uiScale: uiScale,
192+ sizeCfg: sizeCfg,
188193 }
189194 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
190195 var cb glib.SourceFunc = func(uintptr) bool {
@@ -317,13 +322,92 @@ func (o *Omnibox) estimateRowHeight() int {
317322 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)
318323 }
319324
325+func resultsContainerState(rowCount int) (visible bool, expand bool, listVisible bool, emptyBackdrop bool) {
326+ return rowCount > 0, rowCount > 0, rowCount > 0, false
327+}
328+
329+func (o *Omnibox) setResultsContainerState(rowCount int) {
330+ if o.scrolledWin == nil {
331+ return
332+ }
333+ visible, expand, listVisible, emptyBackdrop := resultsContainerState(rowCount)
334+ o.scrolledWin.SetVisible(visible)
335+ o.scrolledWin.SetVexpand(expand)
336+ if o.listBox != nil {
337+ o.listBox.SetVisible(listVisible)
338+ }
339+ if o.mainBox != nil && !emptyBackdrop {
340+ o.mainBox.RemoveCssClass("omnibox-empty")
341+ }
342+}
343+
344+func logOmniboxWidgetState(ctx context.Context, overlay layout.OverlayWidget, outerBox, mainBox *gtk.Box, scrolledWin *gtk.ScrolledWindow, listBox *gtk.ListBox) {
345+ log := logging.FromContext(ctx)
346+ var overlayWidth, overlayHeight int
347+ if overlay != nil {
348+ overlayWidth = overlay.GetAllocatedWidth()
349+ overlayHeight = overlay.GetAllocatedHeight()
350+ }
351+
352+ var outerVisible bool
353+ var outerWidth, outerHeight int
354+ if outerBox != nil {
355+ outerVisible = outerBox.GetVisible()
356+ outerWidth = outerBox.GetAllocatedWidth()
357+ outerHeight = outerBox.GetAllocatedHeight()
358+ }
359+
360+ var mainVisible bool
361+ var mainWidth, mainHeight int
362+ if mainBox != nil {
363+ mainVisible = mainBox.GetVisible()
364+ mainWidth = mainBox.GetAllocatedWidth()
365+ mainHeight = mainBox.GetAllocatedHeight()
366+ }
367+
368+ var scrolledVisible, scrolledExpand bool
369+ var scrolledWidth, scrolledHeight int
370+ if scrolledWin != nil {
371+ scrolledVisible = scrolledWin.GetVisible()
372+ scrolledExpand = scrolledWin.GetVexpand()
373+ scrolledWidth = scrolledWin.GetAllocatedWidth()
374+ scrolledHeight = scrolledWin.GetAllocatedHeight()
375+ }
376+
377+ var listVisible bool
378+ var listWidth, listHeight int
379+ if listBox != nil {
380+ listVisible = listBox.GetVisible()
381+ listWidth = listBox.GetAllocatedWidth()
382+ listHeight = listBox.GetAllocatedHeight()
383+ }
384+
385+ log.Debug().
386+ Int("overlayWidth", overlayWidth).
387+ Int("overlayHeight", overlayHeight).
388+ Bool("outerVisible", outerVisible).
389+ Int("outerWidth", outerWidth).
390+ Int("outerHeight", outerHeight).
391+ Bool("mainVisible", mainVisible).
392+ Int("mainWidth", mainWidth).
393+ Int("mainHeight", mainHeight).
394+ Bool("scrolledVisible", scrolledVisible).
395+ Bool("scrolledExpand", scrolledExpand).
396+ Int("scrolledWidth", scrolledWidth).
397+ Int("scrolledHeight", scrolledHeight).
398+ Bool("listVisible", listVisible).
399+ Int("listWidth", listWidth).
400+ Int("listHeight", listHeight).
401+ Msg("omnibox widget state")
402+}
403+
320404 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.
321405 // Must be called on the GTK main thread.
322406 func (o *Omnibox) effectiveMaxRows() int {
323407 if o.parentOverlay == nil {
324408 return OmniboxListDefaults.MaxVisibleRows
325409 }
326- return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults)
410+ return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
327411 }
328412
329413 // resizeAndCenter adjusts the omnibox size based on content and centers it.
@@ -340,7 +424,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
340424
341425 // Schedule measurement after GTK has laid out widgets
342426 var cb glib.SourceFunc = func(uintptr) bool {
343- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
427+ width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
344428 o.measureAndResize(width, rowCount)
345429 return false
346430 }
@@ -387,6 +471,21 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
387471
388472 // Force layout recalculation
389473 o.outerBox.QueueResize()
474+ if parent := o.parentOverlay; parent != nil {
475+ if widget := parent.GtkWidget(); widget != nil {
476+ widget.QueueResize()
477+ if parentWidget := widget.GetParent(); parentWidget != nil {
478+ parentWidget.QueueResize()
479+ }
480+ }
481+ }
482+ if o.listBox != nil {
483+ o.listBox.QueueResize()
484+ }
485+ if o.scrolledWin != nil {
486+ o.scrolledWin.QueueResize()
487+ }
488+ o.mainBox.QueueResize()
390489
391490 log.Debug().
392491 Int("width", width).
@@ -395,6 +494,12 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
395494 Int("rows", rowCount).
396495 Bool("measured", o.measuredHeights.valid).
397496 Msg("omnibox resized")
497+
498+ var cb glib.SourceFunc = func(uintptr) bool {
499+ logOmniboxWidgetState(o.ctx, o.parentOverlay, o.outerBox, o.mainBox, o.scrolledWin, o.listBox)
500+ return false
501+ }
502+ glib.IdleAdd(&cb, 0)
398503 }
399504
400505 // createWidgets builds the GTK widget hierarchy.
@@ -544,7 +649,7 @@ func (o *Omnibox) initList() error {
544649 return errNilWidget("scrolledWin")
545650 }
546651 o.scrolledWin.AddCssClass("omnibox-scrolled")
547- o.scrolledWin.SetVexpand(true)
652+ o.scrolledWin.SetVexpand(false)
548653 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
549654 o.scrolledWin.SetPropagateNaturalHeight(true)
550655
@@ -555,19 +660,16 @@ func (o *Omnibox) initList() error {
555660 o.listBox.AddCssClass("omnibox-listbox")
556661 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
557662
558- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
663+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
559664 o.restoreEntryToRealInput()
560- if rowPtr == 0 {
665+ if row == nil {
561666 o.mu.Lock()
562667 o.selectedIndex = -1
563668 o.mu.Unlock()
564669 } else {
565- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
566- if row != nil {
567- o.mu.Lock()
568- o.selectedIndex = row.GetIndex()
569- o.mu.Unlock()
570- }
670+ o.mu.Lock()
671+ o.selectedIndex = row.GetIndex()
672+ o.mu.Unlock()
571673 }
572674 o.updateGhostFromSelection()
573675 }
@@ -575,11 +677,7 @@ func (o *Omnibox) initList() error {
575677 o.listBox.ConnectRowSelected(&rowSelectedCb)
576678
577679 // Handle row activation (click or Enter) - navigate directly to the URL
578- rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
579- if rowPtr == 0 {
580- return
581- }
582- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
680+ rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
583681 if row == nil {
584682 return
585683 }
@@ -1598,9 +1696,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
15981696 o.rebuildList()
15991697
16001698 rowCount := len(suggestions)
1601- if o.scrolledWin != nil {
1602- o.scrolledWin.SetVisible(rowCount > 0)
1603- }
1699+ o.setResultsContainerState(rowCount)
16041700 o.resizeAndCenter(rowCount)
16051701 }
16061702
@@ -1618,9 +1714,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
16181714
16191715 // Hide scrolled window when there are no suggestions
16201716 rowCount := len(suggestions)
1621- if o.scrolledWin != nil {
1622- o.scrolledWin.SetVisible(rowCount > 0)
1623- }
1717+ o.setResultsContainerState(rowCount)
16241718 o.resizeAndCenter(rowCount)
16251719
16261720 if rowCount > 0 {
@@ -1644,9 +1738,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
16441738
16451739 // Hide scrolled window when there are no favorites
16461740 rowCount := len(favorites)
1647- if o.scrolledWin != nil {
1648- o.scrolledWin.SetVisible(rowCount > 0)
1649- }
1741+ o.setResultsContainerState(rowCount)
16501742 o.resizeAndCenter(rowCount)
16511743
16521744 if rowCount > 0 {
@@ -1702,7 +1794,7 @@ func (o *Omnibox) rebuildList() {
17021794 if width <= 0 {
17031795 return false // Overlay not allocated yet, skip
17041796 }
1705- forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
1797+ forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
17061798 if o.measureComponentHeights(forWidth) {
17071799 // Re-trigger resize with accurate measurements
17081800 o.mu.RLock()
@@ -1749,15 +1841,91 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
17491841 return favicon
17501842 }
17511843
1844+const favoriteStarBaseSize = 18
1845+
1846+func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
1847+
1848+func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
1849+
1850+func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
1851+ star := gtk.NewImage()
1852+ if star == nil {
1853+ return nil
1854+ }
1855+ iconName := "starred-symbolic"
1856+ star.SetFromIconName(&iconName)
1857+ star.SetPixelSize(favoriteStarSize(o.uiScale))
1858+ star.AddCssClass("omnibox-favorite-star")
1859+ star.SetValign(gtk.AlignCenterValue)
1860+ return star
1861+}
1862+
1863+func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
1864+ displayTitle := title
1865+ if displayTitle == "" {
1866+ displayTitle = displayURL
1867+ }
1868+
1869+ titleLabel := gtk.NewLabel(nil)
1870+ if titleLabel != nil {
1871+ titleLabel.SetText(displayTitle)
1872+ titleLabel.AddCssClass("omnibox-suggestion-title")
1873+ titleLabel.SetHalign(gtk.AlignStartValue)
1874+ titleLabel.SetEllipsize(2)
1875+ textBox.Append(&titleLabel.Widget)
1876+ }
1877+
1878+ if title != "" && title != displayURL {
1879+ urlLabel := gtk.NewLabel(nil)
1880+ if urlLabel != nil {
1881+ urlLabel.SetText(displayURL)
1882+ urlLabel.AddCssClass("omnibox-suggestion-url")
1883+ urlLabel.SetHalign(gtk.AlignStartValue)
1884+ urlLabel.SetEllipsize(2)
1885+ textBox.Append(&urlLabel.Widget)
1886+ }
1887+ }
1888+}
1889+
1890+func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
1891+ if showFavoriteStar {
1892+ if star := o.createFavoriteStarIcon(); star != nil {
1893+ hbox.Append(&star.Widget)
1894+ }
1895+ }
1896+
1897+ const maxShortcutIndex = 9
1898+ if index > maxShortcutIndex {
1899+ return
1900+ }
1901+
1902+ shortcutLabel := gtk.NewLabel(nil)
1903+ if shortcutLabel == nil {
1904+ return
1905+ }
1906+ if index < maxShortcutIndex {
1907+ shortcutLabel.SetText(formatShortcut(index + 1))
1908+ } else {
1909+ shortcutLabel.SetText("Ctrl+0")
1910+ }
1911+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1912+ shortcutLabel.SetValign(gtk.AlignCenterValue)
1913+ hbox.Append(&shortcutLabel.Widget)
1914+}
1915+
17521916 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
17531917 // Uses rawURL for both favicon fetching and display.
17541918 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1755- return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1919+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
17561920 }
17571921
17581922 // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
17591923 // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1760-func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
1924+func (o *Omnibox) createRowWithFaviconURL(
1925+ displayURL, title, faviconURL, fallbackIcon string,
1926+ showFavoriteStar bool,
1927+ index int,
1928+) *gtk.ListBoxRow {
17611929 row := gtk.NewListBoxRow()
17621930 if row == nil {
17631931 return nil
@@ -1786,49 +1954,10 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac
17861954 textBox.SetHexpand(true)
17871955 textBox.SetValign(gtk.AlignCenterValue)
17881956
1789- // Title label (or URL if no title)
1790- displayTitle := title
1791- if displayTitle == "" {
1792- displayTitle = displayURL
1793- }
1794- titleLabel := gtk.NewLabel(nil)
1795- if titleLabel != nil {
1796- titleLabel.SetText(displayTitle)
1797- titleLabel.AddCssClass("omnibox-suggestion-title")
1798- titleLabel.SetHalign(gtk.AlignStartValue)
1799- titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1800- textBox.Append(&titleLabel.Widget)
1801- }
1802-
1803- // URL label (only if title exists and differs from URL)
1804- if title != "" && title != displayURL {
1805- urlLabel := gtk.NewLabel(nil)
1806- if urlLabel != nil {
1807- urlLabel.SetText(displayURL)
1808- urlLabel.AddCssClass("omnibox-suggestion-url")
1809- urlLabel.SetHalign(gtk.AlignStartValue)
1810- urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1811- textBox.Append(&urlLabel.Widget)
1812- }
1813- }
1957+ o.appendSuggestionTitleAndURL(textBox, title, displayURL)
18141958
18151959 hbox.Append(&textBox.Widget)
1816-
1817- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1818- const maxShortcutIndex = 9
1819- if index <= maxShortcutIndex {
1820- shortcutLabel := gtk.NewLabel(nil)
1821- if shortcutLabel != nil {
1822- if index < maxShortcutIndex {
1823- shortcutLabel.SetText(formatShortcut(index + 1))
1824- } else {
1825- shortcutLabel.SetText("Ctrl+0")
1826- }
1827- shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1828- shortcutLabel.SetValign(gtk.AlignCenterValue)
1829- hbox.Append(&shortcutLabel.Widget)
1830- }
1831- }
1960+ o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
18321961
18331962 row.SetChild(&hbox.Widget)
18341963 return row
@@ -1863,7 +1992,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo
18631992
18641993 // createSuggestionRow creates a ListBoxRow for a suggestion.
18651994 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1866- row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
1995+ row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
18671996 if row != nil && s.IsFavorite {
18681997 row.AddCssClass("omnibox-row-favorite")
18691998 }
@@ -1878,7 +2007,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
18782007 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
18792008 // Pass description as URL param (displayed as secondary label) and empty
18802009 // faviconURL to skip async favicon fetching - bang rows use static icon only
1881- row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
2010+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
18822011 if row != nil {
18832012 row.AddCssClass("omnibox-row-bang")
18842013 }
@@ -2316,20 +2445,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
23162445 o.entry.SetText(query)
23172446 o.entry.SelectRegion(-1, -1)
23182447
2319- // Determine if we expect content initially
2320- // No content expected if: no query AND initialBehavior is "none"
2321- expectContent := query != "" || o.initialBehavior != "none"
2322-
2323- // Hide scrolled window if no content expected
2448+ // Keep the results container hidden until real rows arrive.
2449+ // Pre-showing an empty, expanding scroller renders a dead area in standalone mode.
23242450 if o.scrolledWin != nil {
2325- o.scrolledWin.SetVisible(expectContent)
2451+ o.setResultsContainerState(0)
23262452 // Reset content height constraints - will be updated when results arrive
23272453 o.scrolledWin.SetMinContentHeight(-1)
23282454 o.scrolledWin.SetMaxContentHeight(0)
23292455 }
23302456
23312457 // Calculate dimensions using shared helper
2332- width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
2458+ parentWidth, parentHeight := 0, 0
2459+ if o.parentOverlay != nil {
2460+ parentWidth = o.parentOverlay.GetAllocatedWidth()
2461+ parentHeight = o.parentOverlay.GetAllocatedHeight()
2462+ }
2463+ width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2464+ log.Debug().
2465+ Int("parentWidth", parentWidth).
2466+ Int("parentHeight", parentHeight).
2467+ Int("width", width).
2468+ Int("marginTop", marginTop).
2469+ Int("fixedWidth", o.sizeCfg.FixedWidth).
2470+ Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2471+ Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2472+ Float64("widthPct", o.sizeCfg.WidthPct).
2473+ Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2474+ Msg("omnibox geometry decision")
23332475
23342476 o.mainBox.SetSizeRequest(width, -1)
23352477 o.outerBox.SetMarginTop(marginTop)
@@ -124,6 +124,7 @@ type Omnibox struct {
124 124
125 // Scaling125 // Scaling
126 uiScale float64126 uiScale float64
127+ sizeCfg ModalSizeConfig
127 128
128 // Cached measurements (populated after first layout)129 // Cached measurements (populated after first layout)
129 measuredHeights struct {130 measuredHeights struct {
@@ -158,6 +159,7 @@ type OmniboxConfig struct {
158 OnFocusOut func() // Callback when entry loses focus159 OnFocusOut func() // Callback when entry loses focus
159 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection160 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
160 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel161 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel
162+ SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing
161 }163 }
162 164
163 // NewOmnibox creates a new native GTK4 omnibox widget.165 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -170,6 +172,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
170 uiScale = 1.0172 uiScale = 1.0
171 }173 }
172 174
175+ sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
176+
173 o := &Omnibox{177 o := &Omnibox{
174 viewMode: ViewModeHistory,178 viewMode: ViewModeHistory,
175 selectedIndex: -1,179 selectedIndex: -1,
@@ -185,6 +189,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
185 onAccentKeyRelease: cfg.OnAccentKeyRelease,189 onAccentKeyRelease: cfg.OnAccentKeyRelease,
186 ctx: ctx,190 ctx: ctx,
187 uiScale: uiScale,191 uiScale: uiScale,
192+ sizeCfg: sizeCfg,
188 }193 }
189 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {194 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
190 var cb glib.SourceFunc = func(uintptr) bool {195 var cb glib.SourceFunc = func(uintptr) bool {
@@ -317,13 +322,92 @@ func (o *Omnibox) estimateRowHeight() int {
317 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)322 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)
318 }323 }
319 324
325+func resultsContainerState(rowCount int) (visible bool, expand bool, listVisible bool, emptyBackdrop bool) {
326+ return rowCount > 0, rowCount > 0, rowCount > 0, false
327+}
328+
329+func (o *Omnibox) setResultsContainerState(rowCount int) {
330+ if o.scrolledWin == nil {
331+ return
332+ }
333+ visible, expand, listVisible, emptyBackdrop := resultsContainerState(rowCount)
334+ o.scrolledWin.SetVisible(visible)
335+ o.scrolledWin.SetVexpand(expand)
336+ if o.listBox != nil {
337+ o.listBox.SetVisible(listVisible)
338+ }
339+ if o.mainBox != nil && !emptyBackdrop {
340+ o.mainBox.RemoveCssClass("omnibox-empty")
341+ }
342+}
343+
344+func logOmniboxWidgetState(ctx context.Context, overlay layout.OverlayWidget, outerBox, mainBox *gtk.Box, scrolledWin *gtk.ScrolledWindow, listBox *gtk.ListBox) {
345+ log := logging.FromContext(ctx)
346+ var overlayWidth, overlayHeight int
347+ if overlay != nil {
348+ overlayWidth = overlay.GetAllocatedWidth()
349+ overlayHeight = overlay.GetAllocatedHeight()
350+ }
351+
352+ var outerVisible bool
353+ var outerWidth, outerHeight int
354+ if outerBox != nil {
355+ outerVisible = outerBox.GetVisible()
356+ outerWidth = outerBox.GetAllocatedWidth()
357+ outerHeight = outerBox.GetAllocatedHeight()
358+ }
359+
360+ var mainVisible bool
361+ var mainWidth, mainHeight int
362+ if mainBox != nil {
363+ mainVisible = mainBox.GetVisible()
364+ mainWidth = mainBox.GetAllocatedWidth()
365+ mainHeight = mainBox.GetAllocatedHeight()
366+ }
367+
368+ var scrolledVisible, scrolledExpand bool
369+ var scrolledWidth, scrolledHeight int
370+ if scrolledWin != nil {
371+ scrolledVisible = scrolledWin.GetVisible()
372+ scrolledExpand = scrolledWin.GetVexpand()
373+ scrolledWidth = scrolledWin.GetAllocatedWidth()
374+ scrolledHeight = scrolledWin.GetAllocatedHeight()
375+ }
376+
377+ var listVisible bool
378+ var listWidth, listHeight int
379+ if listBox != nil {
380+ listVisible = listBox.GetVisible()
381+ listWidth = listBox.GetAllocatedWidth()
382+ listHeight = listBox.GetAllocatedHeight()
383+ }
384+
385+ log.Debug().
386+ Int("overlayWidth", overlayWidth).
387+ Int("overlayHeight", overlayHeight).
388+ Bool("outerVisible", outerVisible).
389+ Int("outerWidth", outerWidth).
390+ Int("outerHeight", outerHeight).
391+ Bool("mainVisible", mainVisible).
392+ Int("mainWidth", mainWidth).
393+ Int("mainHeight", mainHeight).
394+ Bool("scrolledVisible", scrolledVisible).
395+ Bool("scrolledExpand", scrolledExpand).
396+ Int("scrolledWidth", scrolledWidth).
397+ Int("scrolledHeight", scrolledHeight).
398+ Bool("listVisible", listVisible).
399+ Int("listWidth", listWidth).
400+ Int("listHeight", listHeight).
401+ Msg("omnibox widget state")
402+}
403+
320 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.404 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.
321 // Must be called on the GTK main thread.405 // Must be called on the GTK main thread.
322 func (o *Omnibox) effectiveMaxRows() int {406 func (o *Omnibox) effectiveMaxRows() int {
323 if o.parentOverlay == nil {407 if o.parentOverlay == nil {
324 return OmniboxListDefaults.MaxVisibleRows408 return OmniboxListDefaults.MaxVisibleRows
325 }409 }
326- return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults)410+ return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
327 }411 }
328 412
329 // resizeAndCenter adjusts the omnibox size based on content and centers it.413 // resizeAndCenter adjusts the omnibox size based on content and centers it.
@@ -340,7 +424,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
340 424
341 // Schedule measurement after GTK has laid out widgets425 // Schedule measurement after GTK has laid out widgets
342 var cb glib.SourceFunc = func(uintptr) bool {426 var cb glib.SourceFunc = func(uintptr) bool {
343- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)427+ width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
344 o.measureAndResize(width, rowCount)428 o.measureAndResize(width, rowCount)
345 return false429 return false
346 }430 }
@@ -387,6 +471,21 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
387 471
388 // Force layout recalculation472 // Force layout recalculation
389 o.outerBox.QueueResize()473 o.outerBox.QueueResize()
474+ if parent := o.parentOverlay; parent != nil {
475+ if widget := parent.GtkWidget(); widget != nil {
476+ widget.QueueResize()
477+ if parentWidget := widget.GetParent(); parentWidget != nil {
478+ parentWidget.QueueResize()
479+ }
480+ }
481+ }
482+ if o.listBox != nil {
483+ o.listBox.QueueResize()
484+ }
485+ if o.scrolledWin != nil {
486+ o.scrolledWin.QueueResize()
487+ }
488+ o.mainBox.QueueResize()
390 489
391 log.Debug().490 log.Debug().
392 Int("width", width).491 Int("width", width).
@@ -395,6 +494,12 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
395 Int("rows", rowCount).494 Int("rows", rowCount).
396 Bool("measured", o.measuredHeights.valid).495 Bool("measured", o.measuredHeights.valid).
397 Msg("omnibox resized")496 Msg("omnibox resized")
497+
498+ var cb glib.SourceFunc = func(uintptr) bool {
499+ logOmniboxWidgetState(o.ctx, o.parentOverlay, o.outerBox, o.mainBox, o.scrolledWin, o.listBox)
500+ return false
501+ }
502+ glib.IdleAdd(&cb, 0)
398 }503 }
399 504
400 // createWidgets builds the GTK widget hierarchy.505 // createWidgets builds the GTK widget hierarchy.
@@ -544,7 +649,7 @@ func (o *Omnibox) initList() error {
544 return errNilWidget("scrolledWin")649 return errNilWidget("scrolledWin")
545 }650 }
546 o.scrolledWin.AddCssClass("omnibox-scrolled")651 o.scrolledWin.AddCssClass("omnibox-scrolled")
547- o.scrolledWin.SetVexpand(true)652+ o.scrolledWin.SetVexpand(false)
548 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)653 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
549 o.scrolledWin.SetPropagateNaturalHeight(true)654 o.scrolledWin.SetPropagateNaturalHeight(true)
550 655
@@ -555,19 +660,16 @@ func (o *Omnibox) initList() error {
555 o.listBox.AddCssClass("omnibox-listbox")660 o.listBox.AddCssClass("omnibox-listbox")
556 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)661 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
557 662
558- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {663+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
559 o.restoreEntryToRealInput()664 o.restoreEntryToRealInput()
560- if rowPtr == 0 {665+ if row == nil {
561 o.mu.Lock()666 o.mu.Lock()
562 o.selectedIndex = -1667 o.selectedIndex = -1
563 o.mu.Unlock()668 o.mu.Unlock()
564 } else {669 } else {
565- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)670+ o.mu.Lock()
566- if row != nil {671+ o.selectedIndex = row.GetIndex()
567- o.mu.Lock()672+ o.mu.Unlock()
568- o.selectedIndex = row.GetIndex()
569- o.mu.Unlock()
570- }
571 }673 }
572 o.updateGhostFromSelection()674 o.updateGhostFromSelection()
573 }675 }
@@ -575,11 +677,7 @@ func (o *Omnibox) initList() error {
575 o.listBox.ConnectRowSelected(&rowSelectedCb)677 o.listBox.ConnectRowSelected(&rowSelectedCb)
576 678
577 // Handle row activation (click or Enter) - navigate directly to the URL679 // Handle row activation (click or Enter) - navigate directly to the URL
578- rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {680+ rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
579- if rowPtr == 0 {
580- return
581- }
582- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
583 if row == nil {681 if row == nil {
584 return682 return
585 }683 }
@@ -1598,9 +1696,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1598 o.rebuildList()1696 o.rebuildList()
1599 1697
1600 rowCount := len(suggestions)1698 rowCount := len(suggestions)
1601- if o.scrolledWin != nil {1699+ o.setResultsContainerState(rowCount)
1602- o.scrolledWin.SetVisible(rowCount > 0)
1603- }
1604 o.resizeAndCenter(rowCount)1700 o.resizeAndCenter(rowCount)
1605 }1701 }
1606 1702
@@ -1618,9 +1714,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1618 1714
1619 // Hide scrolled window when there are no suggestions1715 // Hide scrolled window when there are no suggestions
1620 rowCount := len(suggestions)1716 rowCount := len(suggestions)
1621- if o.scrolledWin != nil {1717+ o.setResultsContainerState(rowCount)
1622- o.scrolledWin.SetVisible(rowCount > 0)
1623- }
1624 o.resizeAndCenter(rowCount)1718 o.resizeAndCenter(rowCount)
1625 1719
1626 if rowCount > 0 {1720 if rowCount > 0 {
@@ -1644,9 +1738,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1644 1738
1645 // Hide scrolled window when there are no favorites1739 // Hide scrolled window when there are no favorites
1646 rowCount := len(favorites)1740 rowCount := len(favorites)
1647- if o.scrolledWin != nil {1741+ o.setResultsContainerState(rowCount)
1648- o.scrolledWin.SetVisible(rowCount > 0)
1649- }
1650 o.resizeAndCenter(rowCount)1742 o.resizeAndCenter(rowCount)
1651 1743
1652 if rowCount > 0 {1744 if rowCount > 0 {
@@ -1702,7 +1794,7 @@ func (o *Omnibox) rebuildList() {
1702 if width <= 0 {1794 if width <= 0 {
1703 return false // Overlay not allocated yet, skip1795 return false // Overlay not allocated yet, skip
1704 }1796 }
1705- forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)1797+ forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
1706 if o.measureComponentHeights(forWidth) {1798 if o.measureComponentHeights(forWidth) {
1707 // Re-trigger resize with accurate measurements1799 // Re-trigger resize with accurate measurements
1708 o.mu.RLock()1800 o.mu.RLock()
@@ -1749,15 +1841,91 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1749 return favicon1841 return favicon
1750 }1842 }
1751 1843
1844+const favoriteStarBaseSize = 18
1845+
1846+func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
1847+
1848+func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
1849+
1850+func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
1851+ star := gtk.NewImage()
1852+ if star == nil {
1853+ return nil
1854+ }
1855+ iconName := "starred-symbolic"
1856+ star.SetFromIconName(&iconName)
1857+ star.SetPixelSize(favoriteStarSize(o.uiScale))
1858+ star.AddCssClass("omnibox-favorite-star")
1859+ star.SetValign(gtk.AlignCenterValue)
1860+ return star
1861+}
1862+
1863+func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
1864+ displayTitle := title
1865+ if displayTitle == "" {
1866+ displayTitle = displayURL
1867+ }
1868+
1869+ titleLabel := gtk.NewLabel(nil)
1870+ if titleLabel != nil {
1871+ titleLabel.SetText(displayTitle)
1872+ titleLabel.AddCssClass("omnibox-suggestion-title")
1873+ titleLabel.SetHalign(gtk.AlignStartValue)
1874+ titleLabel.SetEllipsize(2)
1875+ textBox.Append(&titleLabel.Widget)
1876+ }
1877+
1878+ if title != "" && title != displayURL {
1879+ urlLabel := gtk.NewLabel(nil)
1880+ if urlLabel != nil {
1881+ urlLabel.SetText(displayURL)
1882+ urlLabel.AddCssClass("omnibox-suggestion-url")
1883+ urlLabel.SetHalign(gtk.AlignStartValue)
1884+ urlLabel.SetEllipsize(2)
1885+ textBox.Append(&urlLabel.Widget)
1886+ }
1887+ }
1888+}
1889+
1890+func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
1891+ if showFavoriteStar {
1892+ if star := o.createFavoriteStarIcon(); star != nil {
1893+ hbox.Append(&star.Widget)
1894+ }
1895+ }
1896+
1897+ const maxShortcutIndex = 9
1898+ if index > maxShortcutIndex {
1899+ return
1900+ }
1901+
1902+ shortcutLabel := gtk.NewLabel(nil)
1903+ if shortcutLabel == nil {
1904+ return
1905+ }
1906+ if index < maxShortcutIndex {
1907+ shortcutLabel.SetText(formatShortcut(index + 1))
1908+ } else {
1909+ shortcutLabel.SetText("Ctrl+0")
1910+ }
1911+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1912+ shortcutLabel.SetValign(gtk.AlignCenterValue)
1913+ hbox.Append(&shortcutLabel.Widget)
1914+}
1915+
1752 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.1916 // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1753 // Uses rawURL for both favicon fetching and display.1917 // Uses rawURL for both favicon fetching and display.
1754 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {1918 func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1755- return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)1919+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
1756 }1920 }
1757 1921
1758 // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.1922 // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1759 // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.1923 // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1760-func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {1924+func (o *Omnibox) createRowWithFaviconURL(
1925+ displayURL, title, faviconURL, fallbackIcon string,
1926+ showFavoriteStar bool,
1927+ index int,
1928+) *gtk.ListBoxRow {
1761 row := gtk.NewListBoxRow()1929 row := gtk.NewListBoxRow()
1762 if row == nil {1930 if row == nil {
1763 return nil1931 return nil
@@ -1786,49 +1954,10 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac
1786 textBox.SetHexpand(true)1954 textBox.SetHexpand(true)
1787 textBox.SetValign(gtk.AlignCenterValue)1955 textBox.SetValign(gtk.AlignCenterValue)
1788 1956
1789- // Title label (or URL if no title)1957+ o.appendSuggestionTitleAndURL(textBox, title, displayURL)
1790- displayTitle := title
1791- if displayTitle == "" {
1792- displayTitle = displayURL
1793- }
1794- titleLabel := gtk.NewLabel(nil)
1795- if titleLabel != nil {
1796- titleLabel.SetText(displayTitle)
1797- titleLabel.AddCssClass("omnibox-suggestion-title")
1798- titleLabel.SetHalign(gtk.AlignStartValue)
1799- titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1800- textBox.Append(&titleLabel.Widget)
1801- }
1802-
1803- // URL label (only if title exists and differs from URL)
1804- if title != "" && title != displayURL {
1805- urlLabel := gtk.NewLabel(nil)
1806- if urlLabel != nil {
1807- urlLabel.SetText(displayURL)
1808- urlLabel.AddCssClass("omnibox-suggestion-url")
1809- urlLabel.SetHalign(gtk.AlignStartValue)
1810- urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1811- textBox.Append(&urlLabel.Widget)
1812- }
1813- }
1814 1958
1815 hbox.Append(&textBox.Widget)1959 hbox.Append(&textBox.Widget)
1816-1960+ o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
1817- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1818- const maxShortcutIndex = 9
1819- if index <= maxShortcutIndex {
1820- shortcutLabel := gtk.NewLabel(nil)
1821- if shortcutLabel != nil {
1822- if index < maxShortcutIndex {
1823- shortcutLabel.SetText(formatShortcut(index + 1))
1824- } else {
1825- shortcutLabel.SetText("Ctrl+0")
1826- }
1827- shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1828- shortcutLabel.SetValign(gtk.AlignCenterValue)
1829- hbox.Append(&shortcutLabel.Widget)
1830- }
1831- }
1832 1961
1833 row.SetChild(&hbox.Widget)1962 row.SetChild(&hbox.Widget)
1834 return row1963 return row
@@ -1863,7 +1992,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo
1863 1992
1864 // createSuggestionRow creates a ListBoxRow for a suggestion.1993 // createSuggestionRow creates a ListBoxRow for a suggestion.
1865 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {1994 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1866- row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)1995+ row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
1867 if row != nil && s.IsFavorite {1996 if row != nil && s.IsFavorite {
1868 row.AddCssClass("omnibox-row-favorite")1997 row.AddCssClass("omnibox-row-favorite")
1869 }1998 }
@@ -1878,7 +2007,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
1878 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {2007 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1879 // Pass description as URL param (displayed as secondary label) and empty2008 // Pass description as URL param (displayed as secondary label) and empty
1880 // faviconURL to skip async favicon fetching - bang rows use static icon only2009 // faviconURL to skip async favicon fetching - bang rows use static icon only
1881- row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)2010+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
1882 if row != nil {2011 if row != nil {
1883 row.AddCssClass("omnibox-row-bang")2012 row.AddCssClass("omnibox-row-bang")
1884 }2013 }
@@ -2316,20 +2445,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2316 o.entry.SetText(query)2445 o.entry.SetText(query)
2317 o.entry.SelectRegion(-1, -1)2446 o.entry.SelectRegion(-1, -1)
2318 2447
2319- // Determine if we expect content initially2448+ // Keep the results container hidden until real rows arrive.
2320- // No content expected if: no query AND initialBehavior is "none"2449+ // Pre-showing an empty, expanding scroller renders a dead area in standalone mode.
2321- expectContent := query != "" || o.initialBehavior != "none"
2322-
2323- // Hide scrolled window if no content expected
2324 if o.scrolledWin != nil {2450 if o.scrolledWin != nil {
2325- o.scrolledWin.SetVisible(expectContent)2451+ o.setResultsContainerState(0)
2326 // Reset content height constraints - will be updated when results arrive2452 // Reset content height constraints - will be updated when results arrive
2327 o.scrolledWin.SetMinContentHeight(-1)2453 o.scrolledWin.SetMinContentHeight(-1)
2328 o.scrolledWin.SetMaxContentHeight(0)2454 o.scrolledWin.SetMaxContentHeight(0)
2329 }2455 }
2330 2456
2331 // Calculate dimensions using shared helper2457 // Calculate dimensions using shared helper
2332- width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)2458+ parentWidth, parentHeight := 0, 0
2459+ if o.parentOverlay != nil {
2460+ parentWidth = o.parentOverlay.GetAllocatedWidth()
2461+ parentHeight = o.parentOverlay.GetAllocatedHeight()
2462+ }
2463+ width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2464+ log.Debug().
2465+ Int("parentWidth", parentWidth).
2466+ Int("parentHeight", parentHeight).
2467+ Int("width", width).
2468+ Int("marginTop", marginTop).
2469+ Int("fixedWidth", o.sizeCfg.FixedWidth).
2470+ Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2471+ Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2472+ Float64("widthPct", o.sizeCfg.WidthPct).
2473+ Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2474+ Msg("omnibox geometry decision")
2333 2475
2334 o.mainBox.SetSizeRequest(width, -1)2476 o.mainBox.SetSizeRequest(width, -1)
2335 o.outerBox.SetMarginTop(marginTop)2477 o.outerBox.SetMarginTop(marginTop)
A internal/ui/component/omnibox_favorite_icon_test.go +17 -0
@@ -0,0 +1,17 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestOmniboxFavoriteStarSize(t *testing.T) {
10+ assert.Equal(t, 18, favoriteStarSize(1.0))
11+ assert.Equal(t, 27, favoriteStarSize(1.5))
12+}
13+
14+func TestOmniboxFavoriteStarVisibility(t *testing.T) {
15+ assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true}))
16+ assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false}))
17+}
@@ -0,0 +1,17 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestOmniboxFavoriteStarSize(t *testing.T) {
10+ assert.Equal(t, 18, favoriteStarSize(1.0))
11+ assert.Equal(t, 27, favoriteStarSize(1.5))
12+}
13+
14+func TestOmniboxFavoriteStarVisibility(t *testing.T) {
15+ assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true}))
16+ assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false}))
17+}
M internal/ui/component/omnibox_navigation_state_test.go +34 -0
@@ -87,6 +87,40 @@ func TestEffectiveSearchQuery(t *testing.T) {
8787 }
8888 }
8989
90+func TestResultsContainerState(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ rowCount int
94+ wantVisible bool
95+ wantExpand bool
96+ wantListVisible bool
97+ wantEmptyBackdrop bool
98+ }{
99+ {name: "no rows stays hidden without empty backdrop", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false, wantEmptyBackdrop: false},
100+ {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true, wantEmptyBackdrop: false},
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ gotVisible, gotExpand, gotListVisible, gotEmptyBackdrop := resultsContainerState(tt.rowCount)
106+ if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible || gotEmptyBackdrop != tt.wantEmptyBackdrop {
107+ t.Fatalf(
108+ "resultsContainerState(%d) = (%v, %v, %v, %v), want (%v, %v, %v, %v)",
109+ tt.rowCount,
110+ gotVisible,
111+ gotExpand,
112+ gotListVisible,
113+ gotEmptyBackdrop,
114+ tt.wantVisible,
115+ tt.wantExpand,
116+ tt.wantListVisible,
117+ tt.wantEmptyBackdrop,
118+ )
119+ }
120+ })
121+ }
122+}
123+
90124 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {
91125 entryText := "github.com/bnema/dumber"
92126 realInput := "upl"
@@ -87,6 +87,40 @@ func TestEffectiveSearchQuery(t *testing.T) {
87 }87 }
88 }88 }
89 89
90+func TestResultsContainerState(t *testing.T) {
91+ tests := []struct {
92+ name string
93+ rowCount int
94+ wantVisible bool
95+ wantExpand bool
96+ wantListVisible bool
97+ wantEmptyBackdrop bool
98+ }{
99+ {name: "no rows stays hidden without empty backdrop", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false, wantEmptyBackdrop: false},
100+ {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true, wantEmptyBackdrop: false},
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ gotVisible, gotExpand, gotListVisible, gotEmptyBackdrop := resultsContainerState(tt.rowCount)
106+ if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible || gotEmptyBackdrop != tt.wantEmptyBackdrop {
107+ t.Fatalf(
108+ "resultsContainerState(%d) = (%v, %v, %v, %v), want (%v, %v, %v, %v)",
109+ tt.rowCount,
110+ gotVisible,
111+ gotExpand,
112+ gotListVisible,
113+ gotEmptyBackdrop,
114+ tt.wantVisible,
115+ tt.wantExpand,
116+ tt.wantListVisible,
117+ tt.wantEmptyBackdrop,
118+ )
119+ }
120+ })
121+ }
122+}
123+
90 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {124 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {
91 entryText := "github.com/bnema/dumber"125 entryText := "github.com/bnema/dumber"
92 realInput := "upl"126 realInput := "upl"
M internal/ui/component/session_manager.go +1 -5
@@ -314,11 +314,7 @@ func (sm *SessionManager) initList() error {
314314 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
315315
316316 // Connect row selection - map list row index to session index
317- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
318- if rowPtr == 0 {
319- return
320- }
321- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
317+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
322318 if row == nil {
323319 return
324320 }
@@ -314,11 +314,7 @@ func (sm *SessionManager) initList() error {
314 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)314 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
315 315
316 // Connect row selection - map list row index to session index316 // Connect row selection - map list row index to session index
317- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {317+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
318- if rowPtr == 0 {
319- return
320- }
321- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
322 if row == nil {318 if row == nil {
323 return319 return
324 }320 }
M internal/ui/component/tab_picker.go +1 -5
@@ -232,11 +232,7 @@ func (tp *TabPicker) createList() error {
232232 tp.listBox.AddCssClass("tab-picker-list")
233233 tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234234
235- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236- if rowPtr == 0 {
237- return
238- }
239- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
235+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
240236 if row == nil {
241237 return
242238 }
@@ -232,11 +232,7 @@ func (tp *TabPicker) createList() error {
232 tp.listBox.AddCssClass("tab-picker-list")232 tp.listBox.AddCssClass("tab-picker-list")
233 tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)233 tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234 234
235- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {235+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
236- if rowPtr == 0 {
237- return
238- }
239- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
240 if row == nil {236 if row == nil {
241 return237 return
242 }238 }
M internal/ui/coordinator/navigation_test.go +11 -0
@@ -39,3 +39,14 @@ func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *tes
3939 provider.onNavigate("https://example.com")
4040 })
4141 }
42+
43+func TestNavigationCoordinator_SetOmniboxProviderInstallsNavigateCallback(t *testing.T) {
44+ c := &NavigationCoordinator{}
45+ provider := &fakeOmniboxProvider{}
46+
47+ c.SetOmniboxProvider(provider)
48+
49+ if provider.onNavigate == nil {
50+ t.Fatalf("expected omnibox navigate callback to be installed")
51+ }
52+}
@@ -39,3 +39,14 @@ func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *tes
39 provider.onNavigate("https://example.com")39 provider.onNavigate("https://example.com")
40 })40 })
41 }41 }
42+
43+func TestNavigationCoordinator_SetOmniboxProviderInstallsNavigateCallback(t *testing.T) {
44+ c := &NavigationCoordinator{}
45+ provider := &fakeOmniboxProvider{}
46+
47+ c.SetOmniboxProvider(provider)
48+
49+ if provider.onNavigate == nil {
50+ t.Fatalf("expected omnibox navigate callback to be installed")
51+ }
52+}
M internal/ui/deps.go +3 -0
@@ -98,6 +98,9 @@ type Dependencies struct {
9898 // Used for external URL schemes (vscode://, spotify://, etc.).
9999 // Optional: if nil, external URLs are silently dropped.
100100 LaunchExternalURL func(uri string)
101+ // LaunchBrowserURL opens a URL in a new dumber browser window.
102+ // Optional: if nil, standalone omnibox web navigations are logged only.
103+ LaunchBrowserURL func(uri string)
101104 }
102105
103106 // Validate checks that all required dependencies are set.
@@ -98,6 +98,9 @@ type Dependencies struct {
98 // Used for external URL schemes (vscode://, spotify://, etc.).98 // Used for external URL schemes (vscode://, spotify://, etc.).
99 // Optional: if nil, external URLs are silently dropped.99 // Optional: if nil, external URLs are silently dropped.
100 LaunchExternalURL func(uri string)100 LaunchExternalURL func(uri string)
101+ // LaunchBrowserURL opens a URL in a new dumber browser window.
102+ // Optional: if nil, standalone omnibox web navigations are logged only.
103+ LaunchBrowserURL func(uri string)
101 }104 }
102 105
103 // Validate checks that all required dependencies are set.106 // Validate checks that all required dependencies are set.
M internal/ui/layout/gtk_widgets.go +1 -1
@@ -169,7 +169,7 @@ func (p *gtkPaned) ConnectMap(callback func()) uint {
169169 }
170170
171171 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
172- cb := func(_ gobject.Object, _ uintptr) {
172+ cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
173173 callback()
174174 }
175175 return p.inner.ConnectNotifyWithDetail("position", &cb)
@@ -169,7 +169,7 @@ func (p *gtkPaned) ConnectMap(callback func()) uint {
169 }169 }
170 170
171 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {171 func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint {
172- cb := func(_ gobject.Object, _ uintptr) {172+ cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
173 callback()173 callback()
174 }174 }
175 return p.inner.ConnectNotifyWithDetail("position", &cb)175 return p.inner.ConnectNotifyWithDetail("position", &cb)
A internal/ui/standalone_omnibox.go +313 -0
@@ -0,0 +1,313 @@
1+package ui
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/bootstrap"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/puregotk/v4/adw"
13+ "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/gio"
15+ "github.com/bnema/puregotk/v4/glib"
16+ "github.com/bnema/puregotk/v4/gtk"
17+ "github.com/bnema/puregotk/v4/layershell"
18+)
19+
20+var layerShellLibraryAvailable = func() bool {
21+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) {
22+ return true
23+ }
24+
25+ return bootstrap.LayerShellLibraryPath() != ""
26+}
27+
28+const (
29+ standaloneOmniboxNamespace = AppID + ".omnibox"
30+ standaloneOmniboxTitle = "Dumber Omnibox"
31+ standaloneOmniboxWidth = 1200
32+ standaloneOmniboxHeight = 200
33+ standaloneOmniboxWindowCSS = "standalone-omnibox-window"
34+)
35+
36+type StandaloneOmniboxWindowConfig struct {
37+ Namespace string
38+ Title string
39+ Width int
40+ Height int
41+}
42+
43+type StandaloneOmniboxRuntime struct {
44+ OmniboxCfg component.OmniboxConfig
45+ ApplyTheme func(display *gdk.Display)
46+}
47+
48+type standaloneOmniboxHostLayoutConfig struct {
49+ UseDefaultSize bool
50+ OmniboxSize component.ModalSizeConfig
51+}
52+
53+var (
54+ layerShellIsSupported = layershell.IsSupported
55+ layerShellInitForWindow = func(window *gtk.ApplicationWindow) {
56+ layershell.InitForWindow(&window.Window)
57+ }
58+ layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) {
59+ layershell.SetLayer(&window.Window, layer)
60+ }
61+ layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) {
62+ layershell.SetExclusiveZone(&window.Window, zone)
63+ }
64+ layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) {
65+ layershell.SetNamespace(&window.Window, namespace)
66+ }
67+ layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
68+ layershell.SetKeyboardMode(&window.Window, mode)
69+ }
70+)
71+
72+func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig {
73+ return StandaloneOmniboxWindowConfig{
74+ Namespace: standaloneOmniboxNamespace,
75+ Title: standaloneOmniboxTitle,
76+ Width: standaloneOmniboxWidth,
77+ Height: standaloneOmniboxHeight,
78+ }
79+}
80+
81+func standaloneOmniboxArgv(args []string) []string {
82+ if len(args) == 0 {
83+ return []string{"dumber-omnibox"}
84+ }
85+
86+ filtered := []string{args[0]}
87+ for i := 1; i < len(args); i++ {
88+ arg := args[i]
89+ if arg == "omnibox" {
90+ continue
91+ }
92+ if strings.HasPrefix(arg, "-test.") {
93+ if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) {
94+ i++
95+ }
96+ continue
97+ }
98+ filtered = append(filtered, arg)
99+ }
100+
101+ return filtered
102+}
103+
104+func goTestFlagConsumesNextArg(arg string) bool {
105+ switch arg {
106+ case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile",
107+ "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir",
108+ "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile",
109+ "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction",
110+ "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle",
111+ "-test.skip", "-test.timeout", "-test.trace":
112+ return true
113+ default:
114+ return false
115+ }
116+}
117+
118+func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) {
119+ return func(url string) {
120+ if original != nil {
121+ original(url)
122+ }
123+ if quit != nil {
124+ quit()
125+ }
126+ }
127+}
128+
129+func standaloneOmniboxCloseHandler(quit func()) func() {
130+ return func() {
131+ if quit != nil {
132+ quit()
133+ }
134+ }
135+}
136+
137+func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool {
138+ return !layerShellApplied
139+}
140+
141+func standaloneOmniboxWindowResizable(layerShellApplied bool) bool {
142+ return !layerShellApplied
143+}
144+
145+func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig {
146+ return standaloneOmniboxHostLayoutConfig{
147+ UseDefaultSize: !layerShellApplied,
148+ OmniboxSize: standaloneOmniboxSizeConfig(),
149+ }
150+}
151+
152+func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) {
153+ if host == nil {
154+ return
155+ }
156+
157+ host.SetChild(omniboxWidget)
158+}
159+
160+func standaloneOmniboxSizeConfig() component.ModalSizeConfig {
161+ return component.ResolveModalSizeConfig(component.ModalSizeConfig{
162+ FixedWidth: component.OmniboxSizeDefaults.MaxWidth,
163+ FixedTopMargin: 0,
164+ UseFixedTopMargin: true,
165+ }, component.OmniboxSizeDefaults)
166+}
167+
168+func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool {
169+ if window == nil {
170+ return false
171+ }
172+ if !safeLayerShellSupported() {
173+ return false
174+ }
175+
176+ layerShellInitForWindow(window)
177+ layerShellSetLayer(window, layershell.LayerOverlayValue)
178+ layerShellSetExclusiveZone(window, 0)
179+ layerShellSetNamespace(window, &cfg.Namespace)
180+ layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue)
181+ return true
182+}
183+
184+func safeLayerShellSupported() (supported bool) {
185+ defer func() {
186+ if recover() != nil {
187+ supported = false
188+ }
189+ }()
190+
191+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() {
192+ return layerShellIsSupported()
193+ }
194+
195+ return false
196+}
197+
198+func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
199+ if window == nil {
200+ return
201+ }
202+
203+ title := windowCfg.Title
204+ window.SetTitle(&title)
205+ layerShellApplied := tryInitLayerShell(window, windowCfg)
206+ hostLayout := standaloneOmniboxHostLayout(layerShellApplied)
207+ if hostLayout.UseDefaultSize {
208+ window.SetDefaultSize(windowCfg.Width, windowCfg.Height)
209+ }
210+ if layerShellApplied {
211+ window.AddCssClass(standaloneOmniboxWindowCSS)
212+ }
213+ window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied))
214+ window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied))
215+ window.SetChild(child)
216+}
217+
218+func buildStandaloneOmniboxHost(
219+ ctx context.Context,
220+ runtimeCfg *StandaloneOmniboxRuntime,
221+ gtkApp *gtk.Application,
222+) (*gtk.Widget, *component.Omnibox) {
223+ factory := layout.NewGtkWidgetFactory()
224+ overlay := factory.NewOverlay()
225+ hostLayout := standaloneOmniboxHostLayout(false)
226+ if overlay == nil {
227+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets")
228+ return nil, nil
229+ }
230+
231+ cfg := runtimeCfg.OmniboxCfg
232+ cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit)
233+ cfg.SizeConfig = hostLayout.OmniboxSize
234+
235+ omnibox := component.NewOmnibox(ctx, cfg)
236+ if omnibox == nil {
237+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox")
238+ return nil, nil
239+ }
240+
241+ overlay.SetVisible(true)
242+ omnibox.SetParentOverlay(overlay)
243+ omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit))
244+
245+ omniboxWidget := omnibox.WidgetAsLayout(factory)
246+ if omniboxWidget == nil {
247+ logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget")
248+ return nil, nil
249+ }
250+
251+ attachStandaloneOmniboxHostWidget(overlay, omniboxWidget)
252+ logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child")
253+ return overlay.GtkWidget(), omnibox
254+}
255+
256+func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) {
257+ if host == nil {
258+ return
259+ }
260+
261+ var cb glib.SourceFunc = func(uintptr) bool {
262+ logging.FromContext(ctx).Debug().
263+ Int("width", host.GetAllocatedWidth()).
264+ Int("height", host.GetAllocatedHeight()).
265+ Msg("standalone omnibox host allocation")
266+ return false
267+ }
268+ glib.IdleAdd(&cb, 0)
269+}
270+
271+func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int {
272+ if runtimeCfg == nil {
273+ logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured")
274+ return 1
275+ }
276+
277+ adw.Init()
278+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
279+ appID := windowCfg.Namespace
280+ gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
281+ if gtkApp == nil {
282+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application")
283+ return 1
284+ }
285+ defer gtkApp.Unref()
286+
287+ activateCb := func(_ gio.Application) {
288+ child, omnibox := buildStandaloneOmniboxHost(ctx, runtimeCfg, gtkApp)
289+ if child == nil || omnibox == nil {
290+ gtkApp.Quit()
291+ return
292+ }
293+
294+ window := gtk.NewApplicationWindow(gtkApp)
295+ if window == nil {
296+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window")
297+ gtkApp.Quit()
298+ return
299+ }
300+
301+ configureStandaloneOmniboxWindow(window, windowCfg, child)
302+ if display := window.GetDisplay(); display != nil && runtimeCfg.ApplyTheme != nil {
303+ runtimeCfg.ApplyTheme(display)
304+ }
305+ window.Present()
306+ logStandaloneOmniboxHostAllocation(ctx, child)
307+ omnibox.Show(ctx, "")
308+ }
309+ gtkApp.ConnectActivate(&activateCb)
310+
311+ argv := standaloneOmniboxArgv(os.Args)
312+ return gtkApp.Run(len(argv), argv)
313+}
@@ -0,0 +1,313 @@
1+package ui
2+
3+import (
4+ "context"
5+ "os"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/bootstrap"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/bnema/puregotk/v4/adw"
13+ "github.com/bnema/puregotk/v4/gdk"
14+ "github.com/bnema/puregotk/v4/gio"
15+ "github.com/bnema/puregotk/v4/glib"
16+ "github.com/bnema/puregotk/v4/gtk"
17+ "github.com/bnema/puregotk/v4/layershell"
18+)
19+
20+var layerShellLibraryAvailable = func() bool {
21+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) {
22+ return true
23+ }
24+
25+ return bootstrap.LayerShellLibraryPath() != ""
26+}
27+
28+const (
29+ standaloneOmniboxNamespace = AppID + ".omnibox"
30+ standaloneOmniboxTitle = "Dumber Omnibox"
31+ standaloneOmniboxWidth = 1200
32+ standaloneOmniboxHeight = 200
33+ standaloneOmniboxWindowCSS = "standalone-omnibox-window"
34+)
35+
36+type StandaloneOmniboxWindowConfig struct {
37+ Namespace string
38+ Title string
39+ Width int
40+ Height int
41+}
42+
43+type StandaloneOmniboxRuntime struct {
44+ OmniboxCfg component.OmniboxConfig
45+ ApplyTheme func(display *gdk.Display)
46+}
47+
48+type standaloneOmniboxHostLayoutConfig struct {
49+ UseDefaultSize bool
50+ OmniboxSize component.ModalSizeConfig
51+}
52+
53+var (
54+ layerShellIsSupported = layershell.IsSupported
55+ layerShellInitForWindow = func(window *gtk.ApplicationWindow) {
56+ layershell.InitForWindow(&window.Window)
57+ }
58+ layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) {
59+ layershell.SetLayer(&window.Window, layer)
60+ }
61+ layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) {
62+ layershell.SetExclusiveZone(&window.Window, zone)
63+ }
64+ layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) {
65+ layershell.SetNamespace(&window.Window, namespace)
66+ }
67+ layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
68+ layershell.SetKeyboardMode(&window.Window, mode)
69+ }
70+)
71+
72+func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig {
73+ return StandaloneOmniboxWindowConfig{
74+ Namespace: standaloneOmniboxNamespace,
75+ Title: standaloneOmniboxTitle,
76+ Width: standaloneOmniboxWidth,
77+ Height: standaloneOmniboxHeight,
78+ }
79+}
80+
81+func standaloneOmniboxArgv(args []string) []string {
82+ if len(args) == 0 {
83+ return []string{"dumber-omnibox"}
84+ }
85+
86+ filtered := []string{args[0]}
87+ for i := 1; i < len(args); i++ {
88+ arg := args[i]
89+ if arg == "omnibox" {
90+ continue
91+ }
92+ if strings.HasPrefix(arg, "-test.") {
93+ if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) {
94+ i++
95+ }
96+ continue
97+ }
98+ filtered = append(filtered, arg)
99+ }
100+
101+ return filtered
102+}
103+
104+func goTestFlagConsumesNextArg(arg string) bool {
105+ switch arg {
106+ case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile",
107+ "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir",
108+ "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile",
109+ "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction",
110+ "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle",
111+ "-test.skip", "-test.timeout", "-test.trace":
112+ return true
113+ default:
114+ return false
115+ }
116+}
117+
118+func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) {
119+ return func(url string) {
120+ if original != nil {
121+ original(url)
122+ }
123+ if quit != nil {
124+ quit()
125+ }
126+ }
127+}
128+
129+func standaloneOmniboxCloseHandler(quit func()) func() {
130+ return func() {
131+ if quit != nil {
132+ quit()
133+ }
134+ }
135+}
136+
137+func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool {
138+ return !layerShellApplied
139+}
140+
141+func standaloneOmniboxWindowResizable(layerShellApplied bool) bool {
142+ return !layerShellApplied
143+}
144+
145+func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig {
146+ return standaloneOmniboxHostLayoutConfig{
147+ UseDefaultSize: !layerShellApplied,
148+ OmniboxSize: standaloneOmniboxSizeConfig(),
149+ }
150+}
151+
152+func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) {
153+ if host == nil {
154+ return
155+ }
156+
157+ host.SetChild(omniboxWidget)
158+}
159+
160+func standaloneOmniboxSizeConfig() component.ModalSizeConfig {
161+ return component.ResolveModalSizeConfig(component.ModalSizeConfig{
162+ FixedWidth: component.OmniboxSizeDefaults.MaxWidth,
163+ FixedTopMargin: 0,
164+ UseFixedTopMargin: true,
165+ }, component.OmniboxSizeDefaults)
166+}
167+
168+func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool {
169+ if window == nil {
170+ return false
171+ }
172+ if !safeLayerShellSupported() {
173+ return false
174+ }
175+
176+ layerShellInitForWindow(window)
177+ layerShellSetLayer(window, layershell.LayerOverlayValue)
178+ layerShellSetExclusiveZone(window, 0)
179+ layerShellSetNamespace(window, &cfg.Namespace)
180+ layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue)
181+ return true
182+}
183+
184+func safeLayerShellSupported() (supported bool) {
185+ defer func() {
186+ if recover() != nil {
187+ supported = false
188+ }
189+ }()
190+
191+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() {
192+ return layerShellIsSupported()
193+ }
194+
195+ return false
196+}
197+
198+func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
199+ if window == nil {
200+ return
201+ }
202+
203+ title := windowCfg.Title
204+ window.SetTitle(&title)
205+ layerShellApplied := tryInitLayerShell(window, windowCfg)
206+ hostLayout := standaloneOmniboxHostLayout(layerShellApplied)
207+ if hostLayout.UseDefaultSize {
208+ window.SetDefaultSize(windowCfg.Width, windowCfg.Height)
209+ }
210+ if layerShellApplied {
211+ window.AddCssClass(standaloneOmniboxWindowCSS)
212+ }
213+ window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied))
214+ window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied))
215+ window.SetChild(child)
216+}
217+
218+func buildStandaloneOmniboxHost(
219+ ctx context.Context,
220+ runtimeCfg *StandaloneOmniboxRuntime,
221+ gtkApp *gtk.Application,
222+) (*gtk.Widget, *component.Omnibox) {
223+ factory := layout.NewGtkWidgetFactory()
224+ overlay := factory.NewOverlay()
225+ hostLayout := standaloneOmniboxHostLayout(false)
226+ if overlay == nil {
227+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets")
228+ return nil, nil
229+ }
230+
231+ cfg := runtimeCfg.OmniboxCfg
232+ cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit)
233+ cfg.SizeConfig = hostLayout.OmniboxSize
234+
235+ omnibox := component.NewOmnibox(ctx, cfg)
236+ if omnibox == nil {
237+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox")
238+ return nil, nil
239+ }
240+
241+ overlay.SetVisible(true)
242+ omnibox.SetParentOverlay(overlay)
243+ omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit))
244+
245+ omniboxWidget := omnibox.WidgetAsLayout(factory)
246+ if omniboxWidget == nil {
247+ logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget")
248+ return nil, nil
249+ }
250+
251+ attachStandaloneOmniboxHostWidget(overlay, omniboxWidget)
252+ logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child")
253+ return overlay.GtkWidget(), omnibox
254+}
255+
256+func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) {
257+ if host == nil {
258+ return
259+ }
260+
261+ var cb glib.SourceFunc = func(uintptr) bool {
262+ logging.FromContext(ctx).Debug().
263+ Int("width", host.GetAllocatedWidth()).
264+ Int("height", host.GetAllocatedHeight()).
265+ Msg("standalone omnibox host allocation")
266+ return false
267+ }
268+ glib.IdleAdd(&cb, 0)
269+}
270+
271+func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int {
272+ if runtimeCfg == nil {
273+ logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured")
274+ return 1
275+ }
276+
277+ adw.Init()
278+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
279+ appID := windowCfg.Namespace
280+ gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
281+ if gtkApp == nil {
282+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application")
283+ return 1
284+ }
285+ defer gtkApp.Unref()
286+
287+ activateCb := func(_ gio.Application) {
288+ child, omnibox := buildStandaloneOmniboxHost(ctx, runtimeCfg, gtkApp)
289+ if child == nil || omnibox == nil {
290+ gtkApp.Quit()
291+ return
292+ }
293+
294+ window := gtk.NewApplicationWindow(gtkApp)
295+ if window == nil {
296+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window")
297+ gtkApp.Quit()
298+ return
299+ }
300+
301+ configureStandaloneOmniboxWindow(window, windowCfg, child)
302+ if display := window.GetDisplay(); display != nil && runtimeCfg.ApplyTheme != nil {
303+ runtimeCfg.ApplyTheme(display)
304+ }
305+ window.Present()
306+ logStandaloneOmniboxHostAllocation(ctx, child)
307+ omnibox.Show(ctx, "")
308+ }
309+ gtkApp.ConnectActivate(&activateCb)
310+
311+ argv := standaloneOmniboxArgv(os.Args)
312+ return gtkApp.Run(len(argv), argv)
313+}
A internal/ui/standalone_omnibox_test.go +305 -0
@@ -0,0 +1,305 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/ui/component"
7+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
8+ "github.com/bnema/puregotk/v4/gtk"
9+ "github.com/bnema/puregotk/v4/layershell"
10+)
11+
12+func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) {
13+ var calls []string
14+ wrapped := wrapStandaloneOmniboxNavigate(func(url string) {
15+ calls = append(calls, "navigate:"+url)
16+ }, func() {
17+ calls = append(calls, "quit")
18+ })
19+
20+ wrapped("https://example.com")
21+
22+ if len(calls) != 2 {
23+ t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls)
24+ }
25+ if calls[0] != "navigate:https://example.com" {
26+ t.Fatalf("expected navigate call first, got %#v", calls)
27+ }
28+ if calls[1] != "quit" {
29+ t.Fatalf("expected quit call second, got %#v", calls)
30+ }
31+}
32+
33+func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) {
34+ called := false
35+ wrapped := wrapStandaloneOmniboxNavigate(nil, func() {
36+ called = true
37+ })
38+
39+ wrapped("https://example.com")
40+
41+ if !called {
42+ t.Fatalf("expected quit to be called")
43+ }
44+}
45+
46+func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) {
47+ called := false
48+ onClose := standaloneOmniboxCloseHandler(func() {
49+ called = true
50+ })
51+
52+ onClose()
53+
54+ if !called {
55+ t.Fatalf("expected close handler to quit app")
56+ }
57+}
58+
59+func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) {
60+ if standaloneOmniboxWindowDecorated(true) {
61+ t.Fatalf("expected layer-shell window to be undecorated")
62+ }
63+}
64+
65+func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) {
66+ if !standaloneOmniboxWindowDecorated(false) {
67+ t.Fatalf("expected fallback window to remain decorated")
68+ }
69+}
70+
71+func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) {
72+ if standaloneOmniboxWindowResizable(true) {
73+ t.Fatalf("expected layer-shell window to stop keeping stale height allocations")
74+ }
75+}
76+
77+func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) {
78+ if !standaloneOmniboxWindowResizable(false) {
79+ t.Fatalf("expected fallback window to remain resizable")
80+ }
81+}
82+
83+func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) {
84+ layoutCfg := standaloneOmniboxHostLayout(true)
85+
86+ if layoutCfg.UseDefaultSize {
87+ t.Fatalf("expected layer-shell host to avoid fixed default size")
88+ }
89+ if layoutCfg.OmniboxSize.FixedWidth != 800 {
90+ t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth)
91+ }
92+ if !layoutCfg.OmniboxSize.UseFixedTopMargin {
93+ t.Fatalf("expected standalone omnibox to use a fixed top margin")
94+ }
95+ if layoutCfg.OmniboxSize.FixedTopMargin != 0 {
96+ t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin)
97+ }
98+}
99+
100+func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) {
101+ layoutCfg := standaloneOmniboxHostLayout(false)
102+
103+ if !layoutCfg.UseDefaultSize {
104+ t.Fatalf("expected non-layer-shell host to keep fixed fallback size")
105+ }
106+ if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() {
107+ t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize)
108+ }
109+}
110+
111+func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) {
112+ host := layoutmocks.NewMockOverlayWidget(t)
113+ child := layoutmocks.NewMockWidget(t)
114+
115+ host.EXPECT().SetChild(child).Once()
116+
117+ attachStandaloneOmniboxHostWidget(host, child)
118+}
119+
120+func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) {
121+ cfg := standaloneOmniboxSizeConfig()
122+
123+ if cfg != (component.ModalSizeConfig{
124+ WidthPct: component.OmniboxSizeDefaults.WidthPct,
125+ MaxWidth: component.OmniboxSizeDefaults.MaxWidth,
126+ TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct,
127+ FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth,
128+ FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight,
129+ FixedWidth: 800,
130+ FixedTopMargin: 0,
131+ UseFixedTopMargin: true,
132+ }) {
133+ t.Fatalf("unexpected standalone omnibox size config: %#v", cfg)
134+ }
135+}
136+
137+func TestStandaloneWindowConfig_Defaults(t *testing.T) {
138+ cfg := DefaultStandaloneOmniboxWindowConfig()
139+ if cfg.Namespace == "" {
140+ t.Fatalf("expected namespace")
141+ }
142+ if cfg.Title == "" {
143+ t.Fatalf("expected title")
144+ }
145+}
146+
147+func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) {
148+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"})
149+
150+ if len(argv) != 2 {
151+ t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv)
152+ }
153+ if argv[0] != "/tmp/testbin" {
154+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
155+ }
156+ if argv[1] != "--gtk-debug" {
157+ t.Fatalf("expected non-test arg to be preserved, got %#v", argv)
158+ }
159+}
160+
161+func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) {
162+ if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) {
163+ t.Fatalf("expected nil window to skip layer-shell")
164+ }
165+}
166+
167+func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) {
168+ originalLibraryAvailable := layerShellLibraryAvailable
169+ original := layerShellIsSupported
170+ layerShellLibraryAvailable = func() bool { return true }
171+ layerShellIsSupported = func() bool { return false }
172+ t.Cleanup(func() {
173+ layerShellLibraryAvailable = originalLibraryAvailable
174+ layerShellIsSupported = original
175+ })
176+
177+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
178+ t.Fatalf("expected unsupported layer-shell to skip initialization")
179+ }
180+}
181+
182+func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) {
183+ originalLibraryAvailable := layerShellLibraryAvailable
184+ originalIsSupported := layerShellIsSupported
185+ layerShellLibraryAvailable = func() bool { return false }
186+ layerShellIsSupported = func() bool {
187+ t.Fatalf("expected layer-shell support check to be skipped when library is missing")
188+ return true
189+ }
190+ t.Cleanup(func() {
191+ layerShellLibraryAvailable = originalLibraryAvailable
192+ layerShellIsSupported = originalIsSupported
193+ })
194+
195+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
196+ t.Fatalf("expected missing library to skip layer-shell initialization")
197+ }
198+}
199+
200+func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) {
201+ originalLibraryAvailable := layerShellLibraryAvailable
202+ originalIsSupported := layerShellIsSupported
203+ t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0")
204+ layerShellLibraryAvailable = func() bool {
205+ t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded")
206+ return false
207+ }
208+ layerShellIsSupported = func() bool { return true }
209+ t.Cleanup(func() {
210+ layerShellLibraryAvailable = originalLibraryAvailable
211+ layerShellIsSupported = originalIsSupported
212+ })
213+
214+ if !safeLayerShellSupported() {
215+ t.Fatalf("expected preloaded layer-shell library to be treated as supported")
216+ }
217+}
218+
219+func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) {
220+ originalLibraryAvailable := layerShellLibraryAvailable
221+ originalIsSupported := layerShellIsSupported
222+ originalInitForWindow := layerShellInitForWindow
223+ originalSetLayer := layerShellSetLayer
224+ originalSetExclusiveZone := layerShellSetExclusiveZone
225+ originalSetNamespace := layerShellSetNamespace
226+ originalSetKeyboardMode := layerShellSetKeyboardMode
227+ t.Cleanup(func() {
228+ layerShellLibraryAvailable = originalLibraryAvailable
229+ layerShellIsSupported = originalIsSupported
230+ layerShellInitForWindow = originalInitForWindow
231+ layerShellSetLayer = originalSetLayer
232+ layerShellSetExclusiveZone = originalSetExclusiveZone
233+ layerShellSetNamespace = originalSetNamespace
234+ layerShellSetKeyboardMode = originalSetKeyboardMode
235+ })
236+
237+ window := &gtk.ApplicationWindow{}
238+ cfg := DefaultStandaloneOmniboxWindowConfig()
239+ var callOrder []string
240+
241+ layerShellLibraryAvailable = func() bool {
242+ callOrder = append(callOrder, "library")
243+ return true
244+ }
245+ layerShellIsSupported = func() bool {
246+ callOrder = append(callOrder, "supported")
247+ return true
248+ }
249+ layerShellInitForWindow = func(got *gtk.ApplicationWindow) {
250+ if got != window {
251+ t.Fatalf("expected init window %p, got %p", window, got)
252+ }
253+ callOrder = append(callOrder, "init")
254+ }
255+ layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) {
256+ if got != window {
257+ t.Fatalf("expected layer window %p, got %p", window, got)
258+ }
259+ if layer != layershell.LayerOverlayValue {
260+ t.Fatalf("expected overlay layer, got %v", layer)
261+ }
262+ callOrder = append(callOrder, "layer")
263+ }
264+ layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) {
265+ if got != window {
266+ t.Fatalf("expected exclusive zone window %p, got %p", window, got)
267+ }
268+ if zone != 0 {
269+ t.Fatalf("expected exclusive zone 0, got %d", zone)
270+ }
271+ callOrder = append(callOrder, "zone")
272+ }
273+ layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) {
274+ if got != window {
275+ t.Fatalf("expected namespace window %p, got %p", window, got)
276+ }
277+ if namespace == nil || *namespace != cfg.Namespace {
278+ t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace)
279+ }
280+ callOrder = append(callOrder, "namespace")
281+ }
282+ layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
283+ if got != window {
284+ t.Fatalf("expected keyboard mode window %p, got %p", window, got)
285+ }
286+ if mode != layershell.KeyboardModeExclusiveValue {
287+ t.Fatalf("expected exclusive keyboard mode, got %v", mode)
288+ }
289+ callOrder = append(callOrder, "keyboard")
290+ }
291+
292+ if !tryInitLayerShell(window, cfg) {
293+ t.Fatalf("expected supported layer-shell to initialize")
294+ }
295+
296+ wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"}
297+ if len(callOrder) != len(wantOrder) {
298+ t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder)
299+ }
300+ for i := range wantOrder {
301+ if callOrder[i] != wantOrder[i] {
302+ t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i])
303+ }
304+ }
305+}
@@ -0,0 +1,305 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/ui/component"
7+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
8+ "github.com/bnema/puregotk/v4/gtk"
9+ "github.com/bnema/puregotk/v4/layershell"
10+)
11+
12+func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) {
13+ var calls []string
14+ wrapped := wrapStandaloneOmniboxNavigate(func(url string) {
15+ calls = append(calls, "navigate:"+url)
16+ }, func() {
17+ calls = append(calls, "quit")
18+ })
19+
20+ wrapped("https://example.com")
21+
22+ if len(calls) != 2 {
23+ t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls)
24+ }
25+ if calls[0] != "navigate:https://example.com" {
26+ t.Fatalf("expected navigate call first, got %#v", calls)
27+ }
28+ if calls[1] != "quit" {
29+ t.Fatalf("expected quit call second, got %#v", calls)
30+ }
31+}
32+
33+func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) {
34+ called := false
35+ wrapped := wrapStandaloneOmniboxNavigate(nil, func() {
36+ called = true
37+ })
38+
39+ wrapped("https://example.com")
40+
41+ if !called {
42+ t.Fatalf("expected quit to be called")
43+ }
44+}
45+
46+func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) {
47+ called := false
48+ onClose := standaloneOmniboxCloseHandler(func() {
49+ called = true
50+ })
51+
52+ onClose()
53+
54+ if !called {
55+ t.Fatalf("expected close handler to quit app")
56+ }
57+}
58+
59+func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) {
60+ if standaloneOmniboxWindowDecorated(true) {
61+ t.Fatalf("expected layer-shell window to be undecorated")
62+ }
63+}
64+
65+func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) {
66+ if !standaloneOmniboxWindowDecorated(false) {
67+ t.Fatalf("expected fallback window to remain decorated")
68+ }
69+}
70+
71+func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) {
72+ if standaloneOmniboxWindowResizable(true) {
73+ t.Fatalf("expected layer-shell window to stop keeping stale height allocations")
74+ }
75+}
76+
77+func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) {
78+ if !standaloneOmniboxWindowResizable(false) {
79+ t.Fatalf("expected fallback window to remain resizable")
80+ }
81+}
82+
83+func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) {
84+ layoutCfg := standaloneOmniboxHostLayout(true)
85+
86+ if layoutCfg.UseDefaultSize {
87+ t.Fatalf("expected layer-shell host to avoid fixed default size")
88+ }
89+ if layoutCfg.OmniboxSize.FixedWidth != 800 {
90+ t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth)
91+ }
92+ if !layoutCfg.OmniboxSize.UseFixedTopMargin {
93+ t.Fatalf("expected standalone omnibox to use a fixed top margin")
94+ }
95+ if layoutCfg.OmniboxSize.FixedTopMargin != 0 {
96+ t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin)
97+ }
98+}
99+
100+func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) {
101+ layoutCfg := standaloneOmniboxHostLayout(false)
102+
103+ if !layoutCfg.UseDefaultSize {
104+ t.Fatalf("expected non-layer-shell host to keep fixed fallback size")
105+ }
106+ if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() {
107+ t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize)
108+ }
109+}
110+
111+func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) {
112+ host := layoutmocks.NewMockOverlayWidget(t)
113+ child := layoutmocks.NewMockWidget(t)
114+
115+ host.EXPECT().SetChild(child).Once()
116+
117+ attachStandaloneOmniboxHostWidget(host, child)
118+}
119+
120+func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) {
121+ cfg := standaloneOmniboxSizeConfig()
122+
123+ if cfg != (component.ModalSizeConfig{
124+ WidthPct: component.OmniboxSizeDefaults.WidthPct,
125+ MaxWidth: component.OmniboxSizeDefaults.MaxWidth,
126+ TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct,
127+ FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth,
128+ FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight,
129+ FixedWidth: 800,
130+ FixedTopMargin: 0,
131+ UseFixedTopMargin: true,
132+ }) {
133+ t.Fatalf("unexpected standalone omnibox size config: %#v", cfg)
134+ }
135+}
136+
137+func TestStandaloneWindowConfig_Defaults(t *testing.T) {
138+ cfg := DefaultStandaloneOmniboxWindowConfig()
139+ if cfg.Namespace == "" {
140+ t.Fatalf("expected namespace")
141+ }
142+ if cfg.Title == "" {
143+ t.Fatalf("expected title")
144+ }
145+}
146+
147+func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) {
148+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"})
149+
150+ if len(argv) != 2 {
151+ t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv)
152+ }
153+ if argv[0] != "/tmp/testbin" {
154+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
155+ }
156+ if argv[1] != "--gtk-debug" {
157+ t.Fatalf("expected non-test arg to be preserved, got %#v", argv)
158+ }
159+}
160+
161+func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) {
162+ if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) {
163+ t.Fatalf("expected nil window to skip layer-shell")
164+ }
165+}
166+
167+func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) {
168+ originalLibraryAvailable := layerShellLibraryAvailable
169+ original := layerShellIsSupported
170+ layerShellLibraryAvailable = func() bool { return true }
171+ layerShellIsSupported = func() bool { return false }
172+ t.Cleanup(func() {
173+ layerShellLibraryAvailable = originalLibraryAvailable
174+ layerShellIsSupported = original
175+ })
176+
177+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
178+ t.Fatalf("expected unsupported layer-shell to skip initialization")
179+ }
180+}
181+
182+func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) {
183+ originalLibraryAvailable := layerShellLibraryAvailable
184+ originalIsSupported := layerShellIsSupported
185+ layerShellLibraryAvailable = func() bool { return false }
186+ layerShellIsSupported = func() bool {
187+ t.Fatalf("expected layer-shell support check to be skipped when library is missing")
188+ return true
189+ }
190+ t.Cleanup(func() {
191+ layerShellLibraryAvailable = originalLibraryAvailable
192+ layerShellIsSupported = originalIsSupported
193+ })
194+
195+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
196+ t.Fatalf("expected missing library to skip layer-shell initialization")
197+ }
198+}
199+
200+func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) {
201+ originalLibraryAvailable := layerShellLibraryAvailable
202+ originalIsSupported := layerShellIsSupported
203+ t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0")
204+ layerShellLibraryAvailable = func() bool {
205+ t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded")
206+ return false
207+ }
208+ layerShellIsSupported = func() bool { return true }
209+ t.Cleanup(func() {
210+ layerShellLibraryAvailable = originalLibraryAvailable
211+ layerShellIsSupported = originalIsSupported
212+ })
213+
214+ if !safeLayerShellSupported() {
215+ t.Fatalf("expected preloaded layer-shell library to be treated as supported")
216+ }
217+}
218+
219+func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) {
220+ originalLibraryAvailable := layerShellLibraryAvailable
221+ originalIsSupported := layerShellIsSupported
222+ originalInitForWindow := layerShellInitForWindow
223+ originalSetLayer := layerShellSetLayer
224+ originalSetExclusiveZone := layerShellSetExclusiveZone
225+ originalSetNamespace := layerShellSetNamespace
226+ originalSetKeyboardMode := layerShellSetKeyboardMode
227+ t.Cleanup(func() {
228+ layerShellLibraryAvailable = originalLibraryAvailable
229+ layerShellIsSupported = originalIsSupported
230+ layerShellInitForWindow = originalInitForWindow
231+ layerShellSetLayer = originalSetLayer
232+ layerShellSetExclusiveZone = originalSetExclusiveZone
233+ layerShellSetNamespace = originalSetNamespace
234+ layerShellSetKeyboardMode = originalSetKeyboardMode
235+ })
236+
237+ window := &gtk.ApplicationWindow{}
238+ cfg := DefaultStandaloneOmniboxWindowConfig()
239+ var callOrder []string
240+
241+ layerShellLibraryAvailable = func() bool {
242+ callOrder = append(callOrder, "library")
243+ return true
244+ }
245+ layerShellIsSupported = func() bool {
246+ callOrder = append(callOrder, "supported")
247+ return true
248+ }
249+ layerShellInitForWindow = func(got *gtk.ApplicationWindow) {
250+ if got != window {
251+ t.Fatalf("expected init window %p, got %p", window, got)
252+ }
253+ callOrder = append(callOrder, "init")
254+ }
255+ layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) {
256+ if got != window {
257+ t.Fatalf("expected layer window %p, got %p", window, got)
258+ }
259+ if layer != layershell.LayerOverlayValue {
260+ t.Fatalf("expected overlay layer, got %v", layer)
261+ }
262+ callOrder = append(callOrder, "layer")
263+ }
264+ layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) {
265+ if got != window {
266+ t.Fatalf("expected exclusive zone window %p, got %p", window, got)
267+ }
268+ if zone != 0 {
269+ t.Fatalf("expected exclusive zone 0, got %d", zone)
270+ }
271+ callOrder = append(callOrder, "zone")
272+ }
273+ layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) {
274+ if got != window {
275+ t.Fatalf("expected namespace window %p, got %p", window, got)
276+ }
277+ if namespace == nil || *namespace != cfg.Namespace {
278+ t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace)
279+ }
280+ callOrder = append(callOrder, "namespace")
281+ }
282+ layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
283+ if got != window {
284+ t.Fatalf("expected keyboard mode window %p, got %p", window, got)
285+ }
286+ if mode != layershell.KeyboardModeExclusiveValue {
287+ t.Fatalf("expected exclusive keyboard mode, got %v", mode)
288+ }
289+ callOrder = append(callOrder, "keyboard")
290+ }
291+
292+ if !tryInitLayerShell(window, cfg) {
293+ t.Fatalf("expected supported layer-shell to initialize")
294+ }
295+
296+ wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"}
297+ if len(callOrder) != len(wantOrder) {
298+ t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder)
299+ }
300+ for i := range wantOrder {
301+ if callOrder[i] != wantOrder[i] {
302+ t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i])
303+ }
304+ }
305+}
A internal/ui/standalone_runtime_test.go +82 -0
@@ -0,0 +1,82 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ urlutil "github.com/bnema/dumber/internal/domain/url"
8+)
9+
10+func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) {
11+ browserURL := ""
12+ externalURL := ""
13+ handleStandaloneOmniboxNavigation(&Dependencies{
14+ LaunchBrowserURL: func(uri string) { browserURL = uri },
15+ LaunchExternalURL: func(uri string) {
16+ externalURL = uri
17+ },
18+ }, context.Background(), "https://example.com")
19+
20+ if browserURL != "https://example.com" {
21+ t.Fatalf("expected browser launcher to receive URL, got %q", browserURL)
22+ }
23+ if externalURL != "" {
24+ t.Fatalf("expected external launcher to be unused, got %q", externalURL)
25+ }
26+}
27+
28+func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) {
29+ browserURL := ""
30+ externalURL := ""
31+ handleStandaloneOmniboxNavigation(&Dependencies{
32+ LaunchBrowserURL: func(uri string) { browserURL = uri },
33+ LaunchExternalURL: func(uri string) {
34+ externalURL = uri
35+ },
36+ }, context.Background(), "vscode://file/tmp/demo")
37+
38+ if externalURL != "vscode://file/tmp/demo" {
39+ t.Fatalf("expected external launcher to receive URL, got %q", externalURL)
40+ }
41+ if browserURL != "" {
42+ t.Fatalf("expected browser launcher to be unused, got %q", browserURL)
43+ }
44+}
45+
46+func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) {
47+ browserURL := ""
48+ externalURL := ""
49+ handleStandaloneOmniboxNavigation(&Dependencies{
50+ LaunchBrowserURL: func(uri string) { browserURL = uri },
51+ LaunchExternalURL: func(uri string) {
52+ externalURL = uri
53+ },
54+ }, context.Background(), "mailto:foo@example.com")
55+
56+ if externalURL != "mailto:foo@example.com" {
57+ t.Fatalf("expected external launcher to receive URL, got %q", externalURL)
58+ }
59+ if browserURL != "" {
60+ t.Fatalf("expected browser launcher to be unused, got %q", browserURL)
61+ }
62+}
63+
64+func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) {
65+ if urlutil.IsExternalScheme("https://example.com") {
66+ t.Fatalf("expected http url to stay in dumber")
67+ }
68+}
69+
70+func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) {
71+ if !urlutil.IsExternalScheme("spotify://track/123") {
72+ t.Fatalf("expected custom scheme to be external")
73+ }
74+}
75+
76+func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {
77+ for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
78+ if urlutil.IsExternalScheme(rawURL) {
79+ t.Fatalf("expected %q to stay internal", rawURL)
80+ }
81+ }
82+}
@@ -0,0 +1,82 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ urlutil "github.com/bnema/dumber/internal/domain/url"
8+)
9+
10+func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) {
11+ browserURL := ""
12+ externalURL := ""
13+ handleStandaloneOmniboxNavigation(&Dependencies{
14+ LaunchBrowserURL: func(uri string) { browserURL = uri },
15+ LaunchExternalURL: func(uri string) {
16+ externalURL = uri
17+ },
18+ }, context.Background(), "https://example.com")
19+
20+ if browserURL != "https://example.com" {
21+ t.Fatalf("expected browser launcher to receive URL, got %q", browserURL)
22+ }
23+ if externalURL != "" {
24+ t.Fatalf("expected external launcher to be unused, got %q", externalURL)
25+ }
26+}
27+
28+func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) {
29+ browserURL := ""
30+ externalURL := ""
31+ handleStandaloneOmniboxNavigation(&Dependencies{
32+ LaunchBrowserURL: func(uri string) { browserURL = uri },
33+ LaunchExternalURL: func(uri string) {
34+ externalURL = uri
35+ },
36+ }, context.Background(), "vscode://file/tmp/demo")
37+
38+ if externalURL != "vscode://file/tmp/demo" {
39+ t.Fatalf("expected external launcher to receive URL, got %q", externalURL)
40+ }
41+ if browserURL != "" {
42+ t.Fatalf("expected browser launcher to be unused, got %q", browserURL)
43+ }
44+}
45+
46+func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) {
47+ browserURL := ""
48+ externalURL := ""
49+ handleStandaloneOmniboxNavigation(&Dependencies{
50+ LaunchBrowserURL: func(uri string) { browserURL = uri },
51+ LaunchExternalURL: func(uri string) {
52+ externalURL = uri
53+ },
54+ }, context.Background(), "mailto:foo@example.com")
55+
56+ if externalURL != "mailto:foo@example.com" {
57+ t.Fatalf("expected external launcher to receive URL, got %q", externalURL)
58+ }
59+ if browserURL != "" {
60+ t.Fatalf("expected browser launcher to be unused, got %q", browserURL)
61+ }
62+}
63+
64+func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) {
65+ if urlutil.IsExternalScheme("https://example.com") {
66+ t.Fatalf("expected http url to stay in dumber")
67+ }
68+}
69+
70+func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) {
71+ if !urlutil.IsExternalScheme("spotify://track/123") {
72+ t.Fatalf("expected custom scheme to be external")
73+ }
74+}
75+
76+func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {
77+ for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
78+ if urlutil.IsExternalScheme(rawURL) {
79+ t.Fatalf("expected %q to stay internal", rawURL)
80+ }
81+ }
82+}
M internal/ui/theme/css.go +37 -14
@@ -206,6 +206,10 @@ button.tab-button.tab-button-active {
206206 func generateOmniboxCSS(p Palette) string {
207207 return `/* ===== Omnibox Styling ===== */
208208
209+window.standalone-omnibox-window {
210+ background-color: transparent;
211+}
212+
209213 /* Omnibox outer container - for positioning in overlay */
210214 .omnibox-outer {
211215 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
@@ -213,23 +217,26 @@ func generateOmniboxCSS(p Palette) string {
213217
214218 /* Omnibox main container - the visible popup */
215219 .omnibox-container {
216- background-color: var(--surface-variant);
217- border: 0.0625em solid var(--border);
220+ background-color: alpha(var(--surface-variant), 0.94);
221+ background-image: linear-gradient(to bottom, alpha(var(--surface), 0.22), alpha(var(--surface-variant), 0.08));
222+ border: 0.0625em solid alpha(var(--border), 0.85);
218223 border-radius: 0.1875em;
224+ box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset;
219225 padding: 0;
220226 }
221227
222228 /* Header with History/Favorites toggle */
223229 .omnibox-header {
224- background-color: shade(var(--surface-variant), 1.1);
225- border-bottom: 0.0625em solid var(--border);
230+ background-color: alpha(var(--surface), 0.34);
231+ background-image: linear-gradient(to bottom, alpha(white, 0.03), alpha(black, 0.04));
232+ border-bottom: 0.0625em solid alpha(var(--border), 0.75);
226233 padding: 0.375em 0.75em;
227234 }
228235
229236 .omnibox-header-btn {
230- background-color: transparent;
237+ background-color: alpha(var(--surface), 0.12);
231238 background-image: none;
232- border: none;
239+ border: 0.0625em solid transparent;
233240 border-radius: 0.125em;
234241 padding: 0.25em 0.75em;
235242 margin-right: 0.5em;
@@ -240,26 +247,29 @@ func generateOmniboxCSS(p Palette) string {
240247 }
241248
242249 .omnibox-header-btn:hover {
243- background-color: alpha(var(--accent), 0.15);
250+ background-color: alpha(var(--accent), 0.14);
251+ border-color: alpha(var(--accent), 0.18);
244252 color: var(--text);
245253 }
246254
247255 .omnibox-header-btn.omnibox-header-active {
248- background-color: alpha(var(--accent), 0.2);
256+ background-color: alpha(var(--accent), 0.18);
257+ border-color: alpha(var(--accent), 0.28);
249258 color: var(--accent);
250259 font-weight: 600;
251260 }
252261
253262 /* Search entry field */
254263 .omnibox-entry {
255- background-color: var(--bg);
264+ background-color: alpha(var(--bg), 0.88);
256265 color: var(--text);
257- border: 0.0625em solid var(--border);
266+ border: 0.0625em solid alpha(var(--border), 0.82);
258267 border-radius: 0.125em;
259268 padding: 0.625em 0.75em;
260269 margin: 0.5em 0.75em;
261270 font-size: 1em;
262271 caret-color: var(--accent);
272+ box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset;
263273 }
264274
265275 entry.omnibox-entry:focus,
@@ -281,8 +291,8 @@ entry.omnibox-entry > *:focus-visible {
281291
282292 /* Scrolled window for suggestions */
283293 .omnibox-scrolled {
284- background-color: shade(var(--surface-variant), 0.95);
285- border-top: 0.0625em solid var(--border);
294+ background-color: alpha(var(--surface-variant), 0.72);
295+ border-top: 0.0625em solid alpha(var(--border), 0.65);
286296 }
287297
288298 /* List box */
@@ -296,7 +306,8 @@ entry.omnibox-entry > *:focus-visible {
296306 margin: 0;
297307 border-radius: 0;
298308 border-left: 0.1875em solid transparent;
299- border-bottom: 0.0625em solid alpha(var(--border), 0.5);
309+ border-bottom: 0.0625em solid alpha(var(--border), 0.35);
310+ background-color: transparent;
300311 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
301312 min-height: 2.75em;
302313 }
@@ -358,10 +369,22 @@ entry.omnibox-entry > *:focus-visible {
358369 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
359370 .omnibox-row .omnibox-suggestion-url {
360371 font-size: 0.75em;
361- color: alpha(var(--muted), 0.65);
372+ color: alpha(var(--muted), 0.72);
362373 font-weight: 400;
363374 }
364375
376+.omnibox-favorite-star {
377+ color: var(--warning);
378+ opacity: 0.9;
379+ margin-left: 0.5em;
380+ margin-right: 0.125em;
381+}
382+
383+.omnibox-row:selected .omnibox-favorite-star {
384+ color: var(--warning);
385+ opacity: 1;
386+}
387+
365388 .omnibox-row:selected .omnibox-suggestion-url {
366389 color: alpha(var(--muted), 0.75);
367390 }
@@ -206,6 +206,10 @@ button.tab-button.tab-button-active {
206 func generateOmniboxCSS(p Palette) string {206 func generateOmniboxCSS(p Palette) string {
207 return `/* ===== Omnibox Styling ===== */207 return `/* ===== Omnibox Styling ===== */
208 208
209+window.standalone-omnibox-window {
210+ background-color: transparent;
211+}
212+
209 /* Omnibox outer container - for positioning in overlay */213 /* Omnibox outer container - for positioning in overlay */
210 .omnibox-outer {214 .omnibox-outer {
211 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */215 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
@@ -213,23 +217,26 @@ func generateOmniboxCSS(p Palette) string {
213 217
214 /* Omnibox main container - the visible popup */218 /* Omnibox main container - the visible popup */
215 .omnibox-container {219 .omnibox-container {
216- background-color: var(--surface-variant);220+ background-color: alpha(var(--surface-variant), 0.94);
217- border: 0.0625em solid var(--border);221+ background-image: linear-gradient(to bottom, alpha(var(--surface), 0.22), alpha(var(--surface-variant), 0.08));
222+ border: 0.0625em solid alpha(var(--border), 0.85);
218 border-radius: 0.1875em;223 border-radius: 0.1875em;
224+ box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset;
219 padding: 0;225 padding: 0;
220 }226 }
221 227
222 /* Header with History/Favorites toggle */228 /* Header with History/Favorites toggle */
223 .omnibox-header {229 .omnibox-header {
224- background-color: shade(var(--surface-variant), 1.1);230+ background-color: alpha(var(--surface), 0.34);
225- border-bottom: 0.0625em solid var(--border);231+ background-image: linear-gradient(to bottom, alpha(white, 0.03), alpha(black, 0.04));
232+ border-bottom: 0.0625em solid alpha(var(--border), 0.75);
226 padding: 0.375em 0.75em;233 padding: 0.375em 0.75em;
227 }234 }
228 235
229 .omnibox-header-btn {236 .omnibox-header-btn {
230- background-color: transparent;237+ background-color: alpha(var(--surface), 0.12);
231 background-image: none;238 background-image: none;
232- border: none;239+ border: 0.0625em solid transparent;
233 border-radius: 0.125em;240 border-radius: 0.125em;
234 padding: 0.25em 0.75em;241 padding: 0.25em 0.75em;
235 margin-right: 0.5em;242 margin-right: 0.5em;
@@ -240,26 +247,29 @@ func generateOmniboxCSS(p Palette) string {
240 }247 }
241 248
242 .omnibox-header-btn:hover {249 .omnibox-header-btn:hover {
243- background-color: alpha(var(--accent), 0.15);250+ background-color: alpha(var(--accent), 0.14);
251+ border-color: alpha(var(--accent), 0.18);
244 color: var(--text);252 color: var(--text);
245 }253 }
246 254
247 .omnibox-header-btn.omnibox-header-active {255 .omnibox-header-btn.omnibox-header-active {
248- background-color: alpha(var(--accent), 0.2);256+ background-color: alpha(var(--accent), 0.18);
257+ border-color: alpha(var(--accent), 0.28);
249 color: var(--accent);258 color: var(--accent);
250 font-weight: 600;259 font-weight: 600;
251 }260 }
252 261
253 /* Search entry field */262 /* Search entry field */
254 .omnibox-entry {263 .omnibox-entry {
255- background-color: var(--bg);264+ background-color: alpha(var(--bg), 0.88);
256 color: var(--text);265 color: var(--text);
257- border: 0.0625em solid var(--border);266+ border: 0.0625em solid alpha(var(--border), 0.82);
258 border-radius: 0.125em;267 border-radius: 0.125em;
259 padding: 0.625em 0.75em;268 padding: 0.625em 0.75em;
260 margin: 0.5em 0.75em;269 margin: 0.5em 0.75em;
261 font-size: 1em;270 font-size: 1em;
262 caret-color: var(--accent);271 caret-color: var(--accent);
272+ box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset;
263 }273 }
264 274
265 entry.omnibox-entry:focus,275 entry.omnibox-entry:focus,
@@ -281,8 +291,8 @@ entry.omnibox-entry > *:focus-visible {
281 291
282 /* Scrolled window for suggestions */292 /* Scrolled window for suggestions */
283 .omnibox-scrolled {293 .omnibox-scrolled {
284- background-color: shade(var(--surface-variant), 0.95);294+ background-color: alpha(var(--surface-variant), 0.72);
285- border-top: 0.0625em solid var(--border);295+ border-top: 0.0625em solid alpha(var(--border), 0.65);
286 }296 }
287 297
288 /* List box */298 /* List box */
@@ -296,7 +306,8 @@ entry.omnibox-entry > *:focus-visible {
296 margin: 0;306 margin: 0;
297 border-radius: 0;307 border-radius: 0;
298 border-left: 0.1875em solid transparent;308 border-left: 0.1875em solid transparent;
299- border-bottom: 0.0625em solid alpha(var(--border), 0.5);309+ border-bottom: 0.0625em solid alpha(var(--border), 0.35);
310+ background-color: transparent;
300 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;311 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
301 min-height: 2.75em;312 min-height: 2.75em;
302 }313 }
@@ -358,10 +369,22 @@ entry.omnibox-entry > *:focus-visible {
358 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */369 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
359 .omnibox-row .omnibox-suggestion-url {370 .omnibox-row .omnibox-suggestion-url {
360 font-size: 0.75em;371 font-size: 0.75em;
361- color: alpha(var(--muted), 0.65);372+ color: alpha(var(--muted), 0.72);
362 font-weight: 400;373 font-weight: 400;
363 }374 }
364 375
376+.omnibox-favorite-star {
377+ color: var(--warning);
378+ opacity: 0.9;
379+ margin-left: 0.5em;
380+ margin-right: 0.125em;
381+}
382+
383+.omnibox-row:selected .omnibox-favorite-star {
384+ color: var(--warning);
385+ opacity: 1;
386+}
387+
365 .omnibox-row:selected .omnibox-suggestion-url {388 .omnibox-row:selected .omnibox-suggestion-url {
366 color: alpha(var(--muted), 0.75);389 color: alpha(var(--muted), 0.75);
367 }390 }
A internal/ui/theme/css_test.go +17 -0
@@ -0,0 +1,17 @@
1+package theme
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
9+ css := GenerateCSS(DefaultDarkPalette())
10+
11+ if !strings.Contains(css, "window.standalone-omnibox-window") {
12+ t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css)
13+ }
14+ if !strings.Contains(css, "window.standalone-omnibox-window {\n\tbackground-color: transparent;") {
15+ t.Fatalf("expected standalone omnibox window background to be transparent")
16+ }
17+}
@@ -0,0 +1,17 @@
1+package theme
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
9+ css := GenerateCSS(DefaultDarkPalette())
10+
11+ if !strings.Contains(css, "window.standalone-omnibox-window") {
12+ t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css)
13+ }
14+ if !strings.Contains(css, "window.standalone-omnibox-window {\n\tbackground-color: transparent;") {
15+ t.Fatalf("expected standalone omnibox window background to be transparent")
16+ }
17+}
M webui/package-lock.json +0 -1 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close