detached ee036f3

Merge pull request #211 from bnema/feature/omnibox-layer-shell

feat(omnibox): add standalone layer-shell launcher
commit
ee036f3eac3ef02b3075e44ef6bfcf00a19bf77b
author
bnema · · 2026-04-08 17:39 UTC
committer
GitHub · · 2026-04-08 17:39 UTC
merge of
408294c + 1630cf5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
32 files · +2425 -228
M cmd/dumber/main.go +121 -4
@@ -7,6 +7,7 @@ import (
77 "os"
88 "os/signal"
99 "runtime"
10+ "strings"
1011 "syscall"
1112
1213 "github.com/bnema/dumber/internal/application/port"
@@ -51,20 +52,58 @@ var initialURL string
5152 // restoreSessionID holds the session ID to restore on startup.
5253 var restoreSessionID string
5354
55+type launchMode string
56+
57+const (
58+ launchModeCLI launchMode = "cli"
59+ launchModeBrowse launchMode = "browse"
60+ launchModeStandaloneOmnibox launchMode = "omnibox"
61+)
62+
63+func launchModeFromArgs(args []string) (launchMode, string) {
64+ if len(args) > 1 {
65+ switch args[1] {
66+ case "browse":
67+ if len(args) > 3 {
68+ return launchModeCLI, ""
69+ }
70+ if len(args) > 2 {
71+ if strings.HasPrefix(args[2], "-") {
72+ return launchModeCLI, ""
73+ }
74+ return launchModeBrowse, args[2]
75+ }
76+ return launchModeBrowse, ""
77+ case "omnibox":
78+ if len(args) > 2 {
79+ return launchModeCLI, ""
80+ }
81+ return launchModeStandaloneOmnibox, ""
82+ }
83+ }
84+
85+ return launchModeCLI, ""
86+}
87+
5488 func main() {
5589 enableCrashForensics()
5690
91+ mode, browseURL := launchModeFromArgs(os.Args)
92+
5793 // 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- }
94+ if mode == launchModeBrowse {
95+ initialURL = browseURL
6296 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
6397 os.Args = os.Args[:1]
6498 os.Exit(runGUI())
6599 return
66100 }
67101
102+ if mode == launchModeStandaloneOmnibox {
103+ os.Exit(runStandaloneOmnibox())
104+ return
105+ }
106+
68107 // Pass build info to CLI
69108 cmd.SetBuildInfo(build.Info{
70109 Version: version,
@@ -140,6 +179,83 @@ func runGUI() int {
140179 return app.Run(ctx, os.Args)
141180 }
142181
182+func runStandaloneOmnibox() int {
183+ maybeReexecStandaloneOmniboxWithLayerShell()
184+
185+ bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
186+ runtime.LockOSThread()
187+ component.SetSkeletonVersion(version)
188+
189+ cfg := initConfig()
190+ ctx := initStartupContextWithTrace(cfg)
191+
192+ initResult, err := runParallelInitPhase(ctx, cfg)
193+ if err != nil {
194+ return 1
195+ }
196+
197+ engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
198+ if err != nil {
199+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
200+ return 1
201+ }
202+ if dbCleanup != nil {
203+ defer dbCleanup()
204+ }
205+
206+ useCases := createUseCases(repos, cfg)
207+ uiDeps := buildUIDependencies(
208+ ctx,
209+ cfg,
210+ initResult.ThemeManager,
211+ initResult.ColorResolver,
212+ initResult.AdwaitaDetector,
213+ engine,
214+ repos,
215+ useCases,
216+ nil,
217+ "",
218+ nil,
219+ )
220+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
221+
222+ return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
223+}
224+
225+func maybeReexecStandaloneOmniboxWithLayerShell() {
226+ env := bootstrap.CurrentEnvMap()
227+ if !bootstrap.ShouldPreloadLayerShell(env) {
228+ return
229+ }
230+
231+ libraryPath := bootstrap.LayerShellLibraryPath()
232+ if libraryPath == "" {
233+ return
234+ }
235+
236+ env = bootstrap.LayerShellPreloadEnv(env, libraryPath)
237+ envSlice := make([]string, 0, len(env))
238+ for key, value := range env {
239+ envSlice = append(envSlice, key+"="+value)
240+ }
241+
242+ execPath, err := resolveCurrentExecutable(os.Executable)
243+ if err != nil {
244+ fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
245+ return
246+ }
247+
248+ // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env.
249+ if err := syscall.Exec(execPath, os.Args, envSlice); err != nil {
250+ fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
251+ return
252+ }
253+}
254+
255+func resolveCurrentExecutable(executable func() (string, error)) (string, error) {
256+ return executable()
257+}
258+
143259 func initStartupContextWithTrace(cfg *config.Config) context.Context {
144260 logging.InitStartupTrace(cfg.Logging.Level)
145261 logging.Trace().Mark("config_loaded")
@@ -581,6 +697,7 @@ func buildUIDependencies(
581697 return config.GetManager().Watch()
582698 },
583699 LaunchExternalURL: desktop.LaunchExternalURL,
700+ LaunchBrowserURL: desktop.LaunchBrowserURL,
584701 ConfigMigrator: config.NewMigrator(),
585702 }
586703
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "os/signal"8 "os/signal"
9 "runtime"9 "runtime"
10+ "strings"
10 "syscall"11 "syscall"
11 12
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
@@ -51,20 +52,58 @@ var initialURL string
51 // restoreSessionID holds the session ID to restore on startup.52 // restoreSessionID holds the session ID to restore on startup.
52 var restoreSessionID string53 var restoreSessionID string
53 54
55+type launchMode string
56+
57+const (
58+ launchModeCLI launchMode = "cli"
59+ launchModeBrowse launchMode = "browse"
60+ launchModeStandaloneOmnibox launchMode = "omnibox"
61+)
62+
63+func launchModeFromArgs(args []string) (launchMode, string) {
64+ if len(args) > 1 {
65+ switch args[1] {
66+ case "browse":
67+ if len(args) > 3 {
68+ return launchModeCLI, ""
69+ }
70+ if len(args) > 2 {
71+ if strings.HasPrefix(args[2], "-") {
72+ return launchModeCLI, ""
73+ }
74+ return launchModeBrowse, args[2]
75+ }
76+ return launchModeBrowse, ""
77+ case "omnibox":
78+ if len(args) > 2 {
79+ return launchModeCLI, ""
80+ }
81+ return launchModeStandaloneOmnibox, ""
82+ }
83+ }
84+
85+ return launchModeCLI, ""
86+}
87+
54 func main() {88 func main() {
55 enableCrashForensics()89 enableCrashForensics()
56 90
91+ mode, browseURL := launchModeFromArgs(os.Args)
92+
57 // Run GUI mode for browse command93 // Run GUI mode for browse command
58- if len(os.Args) > 1 && os.Args[1] == "browse" {94+ if mode == launchModeBrowse {
59- if len(os.Args) > 2 {95+ initialURL = browseURL
60- initialURL = os.Args[2]
61- }
62 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")96 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
63 os.Args = os.Args[:1]97 os.Args = os.Args[:1]
64 os.Exit(runGUI())98 os.Exit(runGUI())
65 return99 return
66 }100 }
67 101
102+ if mode == launchModeStandaloneOmnibox {
103+ os.Exit(runStandaloneOmnibox())
104+ return
105+ }
106+
68 // Pass build info to CLI107 // Pass build info to CLI
69 cmd.SetBuildInfo(build.Info{108 cmd.SetBuildInfo(build.Info{
70 Version: version,109 Version: version,
@@ -140,6 +179,83 @@ func runGUI() int {
140 return app.Run(ctx, os.Args)179 return app.Run(ctx, os.Args)
141 }180 }
142 181
182+func runStandaloneOmnibox() int {
183+ maybeReexecStandaloneOmniboxWithLayerShell()
184+
185+ bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
186+ runtime.LockOSThread()
187+ component.SetSkeletonVersion(version)
188+
189+ cfg := initConfig()
190+ ctx := initStartupContextWithTrace(cfg)
191+
192+ initResult, err := runParallelInitPhase(ctx, cfg)
193+ if err != nil {
194+ return 1
195+ }
196+
197+ engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
198+ if err != nil {
199+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
200+ return 1
201+ }
202+ if dbCleanup != nil {
203+ defer dbCleanup()
204+ }
205+
206+ useCases := createUseCases(repos, cfg)
207+ uiDeps := buildUIDependencies(
208+ ctx,
209+ cfg,
210+ initResult.ThemeManager,
211+ initResult.ColorResolver,
212+ initResult.AdwaitaDetector,
213+ engine,
214+ repos,
215+ useCases,
216+ nil,
217+ "",
218+ nil,
219+ )
220+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
221+
222+ return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
223+}
224+
225+func maybeReexecStandaloneOmniboxWithLayerShell() {
226+ env := bootstrap.CurrentEnvMap()
227+ if !bootstrap.ShouldPreloadLayerShell(env) {
228+ return
229+ }
230+
231+ libraryPath := bootstrap.LayerShellLibraryPath()
232+ if libraryPath == "" {
233+ return
234+ }
235+
236+ env = bootstrap.LayerShellPreloadEnv(env, libraryPath)
237+ envSlice := make([]string, 0, len(env))
238+ for key, value := range env {
239+ envSlice = append(envSlice, key+"="+value)
240+ }
241+
242+ execPath, err := resolveCurrentExecutable(os.Executable)
243+ if err != nil {
244+ fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err)
245+ return
246+ }
247+
248+ // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env.
249+ if err := syscall.Exec(execPath, os.Args, envSlice); err != nil {
250+ fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err)
251+ return
252+ }
253+}
254+
255+func resolveCurrentExecutable(executable func() (string, error)) (string, error) {
256+ return executable()
257+}
258+
143 func initStartupContextWithTrace(cfg *config.Config) context.Context {259 func initStartupContextWithTrace(cfg *config.Config) context.Context {
144 logging.InitStartupTrace(cfg.Logging.Level)260 logging.InitStartupTrace(cfg.Logging.Level)
145 logging.Trace().Mark("config_loaded")261 logging.Trace().Mark("config_loaded")
@@ -581,6 +697,7 @@ func buildUIDependencies(
581 return config.GetManager().Watch()697 return config.GetManager().Watch()
582 },698 },
583 LaunchExternalURL: desktop.LaunchExternalURL,699 LaunchExternalURL: desktop.LaunchExternalURL,
700+ LaunchBrowserURL: desktop.LaunchBrowserURL,
584 ConfigMigrator: config.NewMigrator(),701 ConfigMigrator: config.NewMigrator(),
585 }702 }
586 703
A cmd/dumber/main_test.go +89 -0
@@ -0,0 +1,89 @@
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_BrowseHelpFallsBackToCLI(t *testing.T) {
26+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "--help"})
27+ if mode != launchModeCLI {
28+ t.Fatalf("expected cli mode for browse help, got %q", mode)
29+ }
30+ if browseURL != "" {
31+ t.Fatalf("expected empty browse url for browse help, got %q", browseURL)
32+ }
33+}
34+
35+func TestLaunchModeFromArgs_BrowseExtraPositionalFallsBackToCLI(t *testing.T) {
36+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com", "extra"})
37+ if mode != launchModeCLI {
38+ t.Fatalf("expected cli mode for browse extra args, got %q", mode)
39+ }
40+ if browseURL != "" {
41+ t.Fatalf("expected empty browse url for browse extra args, got %q", browseURL)
42+ }
43+}
44+
45+func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) {
46+ mode, browseURL := launchModeFromArgs([]string{"dumber"})
47+ if mode != launchModeCLI {
48+ t.Fatalf("expected cli mode, got %q", mode)
49+ }
50+ if browseURL != "" {
51+ t.Fatalf("expected empty browse url, got %q", browseURL)
52+ }
53+}
54+
55+func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) {
56+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"})
57+ if mode != launchModeCLI {
58+ t.Fatalf("expected cli mode for omnibox help, got %q", mode)
59+ }
60+}
61+
62+func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
63+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"})
64+ if mode != launchModeCLI {
65+ t.Fatalf("expected cli mode for omnibox flags, got %q", mode)
66+ }
67+}
68+
69+func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
70+ got, err := resolveCurrentExecutable(func() (string, error) {
71+ return "/usr/bin/dumber", nil
72+ })
73+ if err != nil {
74+ t.Fatalf("expected no error, got %v", err)
75+ }
76+ if got != "/usr/bin/dumber" {
77+ t.Fatalf("expected executable path to be preserved, got %q", got)
78+ }
79+}
80+
81+func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
82+ wantErr := errors.New("not found")
83+ _, err := resolveCurrentExecutable(func() (string, error) {
84+ return "", wantErr
85+ })
86+ if !errors.Is(err, wantErr) {
87+ t.Fatalf("expected error %v, got %v", wantErr, err)
88+ }
89+}
@@ -0,0 +1,89 @@
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_BrowseHelpFallsBackToCLI(t *testing.T) {
26+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "--help"})
27+ if mode != launchModeCLI {
28+ t.Fatalf("expected cli mode for browse help, got %q", mode)
29+ }
30+ if browseURL != "" {
31+ t.Fatalf("expected empty browse url for browse help, got %q", browseURL)
32+ }
33+}
34+
35+func TestLaunchModeFromArgs_BrowseExtraPositionalFallsBackToCLI(t *testing.T) {
36+ mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com", "extra"})
37+ if mode != launchModeCLI {
38+ t.Fatalf("expected cli mode for browse extra args, got %q", mode)
39+ }
40+ if browseURL != "" {
41+ t.Fatalf("expected empty browse url for browse extra args, got %q", browseURL)
42+ }
43+}
44+
45+func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) {
46+ mode, browseURL := launchModeFromArgs([]string{"dumber"})
47+ if mode != launchModeCLI {
48+ t.Fatalf("expected cli mode, got %q", mode)
49+ }
50+ if browseURL != "" {
51+ t.Fatalf("expected empty browse url, got %q", browseURL)
52+ }
53+}
54+
55+func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) {
56+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"})
57+ if mode != launchModeCLI {
58+ t.Fatalf("expected cli mode for omnibox help, got %q", mode)
59+ }
60+}
61+
62+func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
63+ mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"})
64+ if mode != launchModeCLI {
65+ t.Fatalf("expected cli mode for omnibox flags, got %q", mode)
66+ }
67+}
68+
69+func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) {
70+ got, err := resolveCurrentExecutable(func() (string, error) {
71+ return "/usr/bin/dumber", nil
72+ })
73+ if err != nil {
74+ t.Fatalf("expected no error, got %v", err)
75+ }
76+ if got != "/usr/bin/dumber" {
77+ t.Fatalf("expected executable path to be preserved, got %q", got)
78+ }
79+}
80+
81+func TestResolveCurrentExecutable_PropagatesError(t *testing.T) {
82+ wantErr := errors.New("not found")
83+ _, err := resolveCurrentExecutable(func() (string, error) {
84+ return "", wantErr
85+ })
86+ if !errors.Is(err, wantErr) {
87+ t.Fatalf("expected error %v, got %v", wantErr, err)
88+ }
89+}
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(env map[string]string, exists func(path string) bool) string {
44+ for _, path := range layerShellLibrarySearchPaths(env) {
45+ if exists(path) {
46+ return path
47+ }
48+ }
49+
50+ return ""
51+}
52+
53+func LayerShellLibraryPath() string {
54+ return FindLayerShellLibrary(CurrentEnvMap(), 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(env map[string]string, exists func(path string) bool) string {
44+ for _, path := range layerShellLibrarySearchPaths(env) {
45+ if exists(path) {
46+ return path
47+ }
48+ }
49+
50+ return ""
51+}
52+
53+func LayerShellLibraryPath() string {
54+ return FindLayerShellLibrary(CurrentEnvMap(), 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 +125 -0
@@ -0,0 +1,125 @@
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 TestFindLayerShellLibrary_UsesLDLibraryPathCandidates(t *testing.T) {
17+ customCandidate := "/opt/custom/lib/libgtk4-layer-shell.so.0"
18+ got := FindLayerShellLibrary(map[string]string{
19+ "LD_LIBRARY_PATH": "/opt/custom/lib",
20+ }, func(path string) bool {
21+ return path == customCandidate
22+ })
23+
24+ if got != customCandidate {
25+ t.Fatalf("expected %q, got %q", customCandidate, got)
26+ }
27+}
28+
29+func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) {
30+ env := map[string]string{
31+ "WAYLAND_DISPLAY": "wayland-1",
32+ "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0",
33+ }
34+
35+ if ShouldPreloadLayerShell(env) {
36+ t.Fatalf("expected preload to be skipped")
37+ }
38+}
39+
40+func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) {
41+ if ShouldPreloadLayerShell(map[string]string{}) {
42+ t.Fatalf("expected preload to be skipped outside Wayland")
43+ }
44+}
45+
46+func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) {
47+ env := map[string]string{
48+ "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so",
49+ }
50+
51+ if !LayerShellPreloadPresent(env) {
52+ t.Fatalf("expected preload presence to be detected")
53+ }
54+}
55+
56+func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) {
57+ got := FindLayerShellLibrary(map[string]string{}, func(path string) bool {
58+ return path == layerShellLibraryCandidates[1]
59+ })
60+
61+ if got != layerShellLibraryCandidates[1] {
62+ t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got)
63+ }
64+}
65+
66+func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) {
67+ arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0"
68+ got := FindLayerShellLibrary(map[string]string{}, func(path string) bool {
69+ return path == arm64Candidate
70+ })
71+
72+ if got != arm64Candidate {
73+ t.Fatalf("expected %q, got %q", arm64Candidate, got)
74+ }
75+}
76+
77+func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) {
78+ if got := FindLayerShellLibrary(map[string]string{}, func(string) bool { return false }); got != "" {
79+ t.Fatalf("expected empty result, got %q", got)
80+ }
81+}
82+
83+func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) {
84+ paths := layerShellLibrarySearchPaths(map[string]string{
85+ "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib",
86+ })
87+
88+ wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0"
89+ wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0"
90+ if !containsString(paths, wantCustom) {
91+ t.Fatalf("expected custom search path %q in %#v", wantCustom, paths)
92+ }
93+ if !containsString(paths, wantNix) {
94+ t.Fatalf("expected nix search path %q in %#v", wantNix, paths)
95+ }
96+}
97+
98+func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) {
99+ env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0")
100+
101+ if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" {
102+ t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"])
103+ }
104+ if env["PATH"] != "/usr/bin" {
105+ t.Fatalf("expected unrelated env to be preserved")
106+ }
107+}
108+
109+func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) {
110+ env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0")
111+
112+ want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so"
113+ if env["LD_PRELOAD"] != want {
114+ t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"])
115+ }
116+}
117+
118+func containsString(items []string, want string) bool {
119+ for _, item := range items {
120+ if item == want {
121+ return true
122+ }
123+ }
124+ return false
125+}
@@ -0,0 +1,125 @@
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 TestFindLayerShellLibrary_UsesLDLibraryPathCandidates(t *testing.T) {
17+ customCandidate := "/opt/custom/lib/libgtk4-layer-shell.so.0"
18+ got := FindLayerShellLibrary(map[string]string{
19+ "LD_LIBRARY_PATH": "/opt/custom/lib",
20+ }, func(path string) bool {
21+ return path == customCandidate
22+ })
23+
24+ if got != customCandidate {
25+ t.Fatalf("expected %q, got %q", customCandidate, got)
26+ }
27+}
28+
29+func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) {
30+ env := map[string]string{
31+ "WAYLAND_DISPLAY": "wayland-1",
32+ "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0",
33+ }
34+
35+ if ShouldPreloadLayerShell(env) {
36+ t.Fatalf("expected preload to be skipped")
37+ }
38+}
39+
40+func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) {
41+ if ShouldPreloadLayerShell(map[string]string{}) {
42+ t.Fatalf("expected preload to be skipped outside Wayland")
43+ }
44+}
45+
46+func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) {
47+ env := map[string]string{
48+ "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so",
49+ }
50+
51+ if !LayerShellPreloadPresent(env) {
52+ t.Fatalf("expected preload presence to be detected")
53+ }
54+}
55+
56+func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) {
57+ got := FindLayerShellLibrary(map[string]string{}, func(path string) bool {
58+ return path == layerShellLibraryCandidates[1]
59+ })
60+
61+ if got != layerShellLibraryCandidates[1] {
62+ t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got)
63+ }
64+}
65+
66+func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) {
67+ arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0"
68+ got := FindLayerShellLibrary(map[string]string{}, func(path string) bool {
69+ return path == arm64Candidate
70+ })
71+
72+ if got != arm64Candidate {
73+ t.Fatalf("expected %q, got %q", arm64Candidate, got)
74+ }
75+}
76+
77+func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) {
78+ if got := FindLayerShellLibrary(map[string]string{}, func(string) bool { return false }); got != "" {
79+ t.Fatalf("expected empty result, got %q", got)
80+ }
81+}
82+
83+func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) {
84+ paths := layerShellLibrarySearchPaths(map[string]string{
85+ "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib",
86+ })
87+
88+ wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0"
89+ wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0"
90+ if !containsString(paths, wantCustom) {
91+ t.Fatalf("expected custom search path %q in %#v", wantCustom, paths)
92+ }
93+ if !containsString(paths, wantNix) {
94+ t.Fatalf("expected nix search path %q in %#v", wantNix, paths)
95+ }
96+}
97+
98+func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) {
99+ env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0")
100+
101+ if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" {
102+ t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"])
103+ }
104+ if env["PATH"] != "/usr/bin" {
105+ t.Fatalf("expected unrelated env to be preserved")
106+ }
107+}
108+
109+func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) {
110+ env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0")
111+
112+ want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so"
113+ if env["LD_PRELOAD"] != want {
114+ t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"])
115+ }
116+}
117+
118+func containsString(items []string, want string) bool {
119+ for _, item := range items {
120+ if item == want {
121+ return true
122+ }
123+ }
124+ return false
125+}
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 +29 -0
@@ -0,0 +1,29 @@
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+}
14+
15+func TestRootCommand_BrowseCommandRejectsExtraArgs(t *testing.T) {
16+ cmd, _, err := rootCmd.Find([]string{"browse"})
17+ if err != nil {
18+ t.Fatalf("expected browse command to be registered: %v", err)
19+ }
20+ if cmd == nil || cmd.Name() != "browse" {
21+ t.Fatalf("expected browse command, got %#v", cmd)
22+ }
23+ if cmd.Args == nil {
24+ t.Fatal("expected browse command to define argument validation")
25+ }
26+ if err := cmd.Args(cmd, []string{"https://example.com", "extra"}); err == nil {
27+ t.Fatal("expected browse command to reject extra args")
28+ }
29+}
@@ -0,0 +1,29 @@
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+}
14+
15+func TestRootCommand_BrowseCommandRejectsExtraArgs(t *testing.T) {
16+ cmd, _, err := rootCmd.Find([]string{"browse"})
17+ if err != nil {
18+ t.Fatalf("expected browse command to be registered: %v", err)
19+ }
20+ if cmd == nil || cmd.Name() != "browse" {
21+ t.Fatalf("expected browse command, got %#v", cmd)
22+ }
23+ if cmd.Args == nil {
24+ t.Fatal("expected browse command to define argument validation")
25+ }
26+ if err := cmd.Args(cmd, []string{"https://example.com", "extra"}); err == nil {
27+ t.Fatal("expected browse command to reject extra args")
28+ }
29+}
M internal/cli/cmd/root.go +4 -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() {
@@ -107,6 +107,7 @@ func GetApp() *cli.App {
107107 var browseCmd = &cobra.Command{
108108 Use: "browse [url]",
109109 Short: "Launch the graphical browser",
110+ Args: cobra.MaximumNArgs(1),
110111 Long: `Launch the GTK4 graphical browser.
111112
112113 If a URL is provided, navigate to it. Otherwise, open the homepage.
@@ -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() {
@@ -107,6 +107,7 @@ func GetApp() *cli.App {
107 var browseCmd = &cobra.Command{107 var browseCmd = &cobra.Command{
108 Use: "browse [url]",108 Use: "browse [url]",
109 Short: "Launch the graphical browser",109 Short: "Launch the graphical browser",
110+ Args: cobra.MaximumNArgs(1),
110 Long: `Launch the GTK4 graphical browser.111 Long: `Launch the GTK4 graphical browser.
111 112
112 If a URL is provided, navigate to it. Otherwise, open the homepage.113 If a URL is provided, navigate to it. Otherwise, open the homepage.
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 +66 -8
@@ -392,23 +392,81 @@ 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+ logging.FromContext(context.Background()).Warn().
436+ Err(err).
437+ Str("path", cmd.Path).
438+ Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)")
439+ }
440+ }
441+
442+ return nil
443+}
444+
445+func sanitizedChildEnv(env []string) []string {
446+ cleaned := make([]string, 0, len(env))
447+ for _, entry := range env {
448+ if !strings.HasPrefix(entry, "LD_PRELOAD=") {
449+ cleaned = append(cleaned, entry)
450+ continue
451+ }
452+
453+ value := strings.TrimPrefix(entry, "LD_PRELOAD=")
454+ parts := strings.FieldsFunc(value, func(r rune) bool {
455+ return r == ' ' || r == ':'
456+ })
457+ kept := make([]string, 0, len(parts))
458+ for _, part := range parts {
459+ if strings.Contains(part, layerShellPreloadToken) {
460+ continue
461+ }
462+ kept = append(kept, part)
463+ }
464+ if len(kept) > 0 {
465+ cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
410466 }
411467 }
468+
469+ return cleaned
412470 }
413471
414472 // SpawnWithSession starts a new dumber instance to restore a session.
@@ -424,9 +482,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
424482 cmd := exec.Command(execPath, "browse")
425483
426484 // Inherit environment and add restore session ID
427- cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
485+ cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID))
428486
429- // Detach from current process group so the new process survives
487+ // Detach from current process group so the new process survives.
430488 cmd.Stdin = nil
431489 cmd.Stdout = nil
432490 cmd.Stderr = nil
@@ -435,10 +493,10 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
435493 return fmt.Errorf("spawn session: %w", err)
436494 }
437495
438- // Capture PID before releasing
496+ // Capture PID before releasing.
439497 pid := cmd.Process.Pid
440498
441- // Release the process so it continues running after we close
499+ // Release the process so it continues running after we close.
442500 if err := cmd.Process.Release(); err != nil {
443501 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
444502 }
@@ -392,23 +392,81 @@ 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+ logging.FromContext(context.Background()).Warn().
436+ Err(err).
437+ Str("path", cmd.Path).
438+ Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)")
439+ }
440+ }
441+
442+ return nil
443+}
444+
445+func sanitizedChildEnv(env []string) []string {
446+ cleaned := make([]string, 0, len(env))
447+ for _, entry := range env {
448+ if !strings.HasPrefix(entry, "LD_PRELOAD=") {
449+ cleaned = append(cleaned, entry)
450+ continue
451+ }
452+
453+ value := strings.TrimPrefix(entry, "LD_PRELOAD=")
454+ parts := strings.FieldsFunc(value, func(r rune) bool {
455+ return r == ' ' || r == ':'
456+ })
457+ kept := make([]string, 0, len(parts))
458+ for _, part := range parts {
459+ if strings.Contains(part, layerShellPreloadToken) {
460+ continue
461+ }
462+ kept = append(kept, part)
463+ }
464+ if len(kept) > 0 {
465+ cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
410 }466 }
411 }467 }
468+
469+ return cleaned
412 }470 }
413 471
414 // SpawnWithSession starts a new dumber instance to restore a session.472 // SpawnWithSession starts a new dumber instance to restore a session.
@@ -424,9 +482,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
424 cmd := exec.Command(execPath, "browse")482 cmd := exec.Command(execPath, "browse")
425 483
426 // Inherit environment and add restore session ID484 // Inherit environment and add restore session ID
427- cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))485+ cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID))
428 486
429- // Detach from current process group so the new process survives487+ // Detach from current process group so the new process survives.
430 cmd.Stdin = nil488 cmd.Stdin = nil
431 cmd.Stdout = nil489 cmd.Stdout = nil
432 cmd.Stderr = nil490 cmd.Stderr = nil
@@ -435,10 +493,10 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
435 return fmt.Errorf("spawn session: %w", err)493 return fmt.Errorf("spawn session: %w", err)
436 }494 }
437 495
438- // Capture PID before releasing496+ // Capture PID before releasing.
439 pid := cmd.Process.Pid497 pid := cmd.Process.Pid
440 498
441- // Release the process so it continues running after we close499+ // Release the process so it continues running after we close.
442 if err := cmd.Process.Release(); err != nil {500 if err := cmd.Process.Release(); err != nil {
443 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")501 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
444 }502 }
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.InDelta(t, defaults.WidthPct, resolved.WidthPct, 0)
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.InDelta(t, defaults.WidthPct, resolved.WidthPct, 0)
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 +300 -102
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/puregotk/v4/glib"
1414 "github.com/bnema/puregotk/v4/graphene"
1515 "github.com/bnema/puregotk/v4/gtk"
16+ "github.com/bnema/puregotk/v4/pango"
1617
1718 "github.com/bnema/dumber/internal/application/usecase"
1819 "github.com/bnema/dumber/internal/domain/autocomplete"
@@ -32,6 +33,11 @@ const (
3233 minGhostInputLength = 1
3334 )
3435
36+type favoriteRowIndicatorUpdate struct {
37+ Apply bool
38+ ShowStarSlot bool
39+}
40+
3541 // ViewMode distinguishes history search from favorites display.
3642 type ViewMode string
3743
@@ -124,6 +130,7 @@ type Omnibox struct {
124130
125131 // Scaling
126132 uiScale float64
133+ sizeCfg ModalSizeConfig
127134
128135 // Cached measurements (populated after first layout)
129136 measuredHeights struct {
@@ -158,6 +165,7 @@ type OmniboxConfig struct {
158165 OnFocusOut func() // Callback when entry loses focus
159166 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
160167 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel
168+ SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing
161169 }
162170
163171 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -170,6 +178,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
170178 uiScale = 1.0
171179 }
172180
181+ sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
182+
173183 o := &Omnibox{
174184 viewMode: ViewModeHistory,
175185 selectedIndex: -1,
@@ -185,6 +195,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
185195 onAccentKeyRelease: cfg.OnAccentKeyRelease,
186196 ctx: ctx,
187197 uiScale: uiScale,
198+ sizeCfg: sizeCfg,
188199 }
189200 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
190201 var cb glib.SourceFunc = func(uintptr) bool {
@@ -317,13 +328,40 @@ func (o *Omnibox) estimateRowHeight() int {
317328 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)
318329 }
319330
331+func resultsContainerState(rowCount int) (visible, expand, listVisible bool) {
332+ return rowCount > 0, rowCount > 0, rowCount > 0
333+}
334+
335+func shouldApplyEmptyResultsState(rowCount int, visible bool) bool {
336+ return !visible || rowCount <= 0
337+}
338+
339+func (o *Omnibox) setResultsContainerState(rowCount int) {
340+ if o.scrolledWin == nil {
341+ return
342+ }
343+ visible, expand, listVisible := resultsContainerState(rowCount)
344+ o.scrolledWin.SetVisible(visible)
345+ o.scrolledWin.SetVexpand(expand)
346+ if o.listBox != nil {
347+ o.listBox.SetVisible(listVisible)
348+ }
349+ if o.mainBox != nil {
350+ if shouldApplyEmptyResultsState(rowCount, visible) {
351+ o.mainBox.AddCssClass("omnibox-empty")
352+ } else {
353+ o.mainBox.RemoveCssClass("omnibox-empty")
354+ }
355+ }
356+}
357+
320358 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.
321359 // Must be called on the GTK main thread.
322360 func (o *Omnibox) effectiveMaxRows() int {
323361 if o.parentOverlay == nil {
324362 return OmniboxListDefaults.MaxVisibleRows
325363 }
326- return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults)
364+ return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
327365 }
328366
329367 // resizeAndCenter adjusts the omnibox size based on content and centers it.
@@ -340,7 +378,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
340378
341379 // Schedule measurement after GTK has laid out widgets
342380 var cb glib.SourceFunc = func(uintptr) bool {
343- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
381+ width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
344382 o.measureAndResize(width, rowCount)
345383 return false
346384 }
@@ -387,6 +425,15 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
387425
388426 // Force layout recalculation
389427 o.outerBox.QueueResize()
428+ if parent := o.parentOverlay; parent != nil {
429+ if widget := parent.GtkWidget(); widget != nil {
430+ widget.QueueResize()
431+ }
432+ }
433+ if o.scrolledWin != nil {
434+ o.scrolledWin.QueueResize()
435+ }
436+ o.mainBox.QueueResize()
390437
391438 log.Debug().
392439 Int("width", width).
@@ -544,7 +591,7 @@ func (o *Omnibox) initList() error {
544591 return errNilWidget("scrolledWin")
545592 }
546593 o.scrolledWin.AddCssClass("omnibox-scrolled")
547- o.scrolledWin.SetVexpand(true)
594+ o.scrolledWin.SetVexpand(false)
548595 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
549596 o.scrolledWin.SetPropagateNaturalHeight(true)
550597
@@ -555,19 +602,16 @@ func (o *Omnibox) initList() error {
555602 o.listBox.AddCssClass("omnibox-listbox")
556603 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
557604
558- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
605+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
559606 o.restoreEntryToRealInput()
560- if rowPtr == 0 {
607+ if row == nil {
561608 o.mu.Lock()
562609 o.selectedIndex = -1
563610 o.mu.Unlock()
564611 } else {
565- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
566- if row != nil {
567- o.mu.Lock()
568- o.selectedIndex = row.GetIndex()
569- o.mu.Unlock()
570- }
612+ o.mu.Lock()
613+ o.selectedIndex = row.GetIndex()
614+ o.mu.Unlock()
571615 }
572616 o.updateGhostFromSelection()
573617 }
@@ -575,11 +619,7 @@ func (o *Omnibox) initList() error {
575619 o.listBox.ConnectRowSelected(&rowSelectedCb)
576620
577621 // 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)
622+ rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
583623 if row == nil {
584624 return
585625 }
@@ -596,7 +636,7 @@ func (o *Omnibox) initList() error {
596636 return
597637 }
598638
599- targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites)
639+ targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
600640
601641 if targetURL != "" && o.onNavigate != nil {
602642 o.Hide(o.ctx)
@@ -1101,6 +1141,7 @@ func (o *Omnibox) restoreEntryToRealInput() {
11011141 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
11021142 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
11031143 log := logging.FromContext(o.ctx)
1144+ maxVisible := o.effectiveMaxRows()
11041145 o.mu.RLock()
11051146 idx := o.selectedIndex
11061147 mode := o.viewMode
@@ -1128,10 +1169,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
11281169 return
11291170 }
11301171
1131- targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites)
1172+ targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)
11321173
11331174 if entryText != "" {
1134- o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites)
1175+ o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)
11351176 return
11361177 }
11371178
@@ -1153,6 +1194,7 @@ func (o *Omnibox) resolveGhostCompletion(
11531194 entryText, selectedURL string,
11541195 hasExplicitSelection bool,
11551196 mode ViewMode,
1197+ maxVisible int,
11561198 suggestions []Suggestion,
11571199 favorites []Favorite,
11581200 ) {
@@ -1163,7 +1205,15 @@ func (o *Omnibox) resolveGhostCompletion(
11631205 return
11641206 }
11651207
1166- fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)
1208+ fullText, suffix, found := visibleGhostSuggestion(
1209+ completionInput,
1210+ selectedURL,
1211+ hasExplicitSelection,
1212+ mode,
1213+ maxVisible,
1214+ suggestions,
1215+ favorites,
1216+ )
11671217 if !found {
11681218 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
11691219 return
@@ -1235,6 +1285,7 @@ func visibleGhostSuggestion(
12351285 query, selectedURL string,
12361286 hasExplicitSelection bool,
12371287 mode ViewMode,
1288+ maxVisible int,
12381289 suggestions []Suggestion,
12391290 favorites []Favorite,
12401291 ) (fullText, suffix string, ok bool) {
@@ -1243,15 +1294,16 @@ func visibleGhostSuggestion(
12431294 return fullText, suffix, ok
12441295 }
12451296
1246- visibleURLs := visibleURLsForMode(mode, suggestions, favorites)
1297+ visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
12471298 suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
12481299 return fullText, suffix, ok
12491300 }
12501301
1251-func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string {
1302+func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
12521303 if mode == ViewModeHistory {
1253- urls := make([]string, 0, len(suggestions))
1254- for _, s := range suggestions {
1304+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
1305+ urls := make([]string, 0, visibleCount)
1306+ for _, s := range suggestions[:visibleCount] {
12551307 if s.URL != "" {
12561308 urls = append(urls, s.URL)
12571309 }
@@ -1259,8 +1311,9 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav
12591311 return urls
12601312 }
12611313
1262- urls := make([]string, 0, len(favorites))
1263- for _, f := range favorites {
1314+ visibleCount := visibleResultCount(len(favorites), maxVisible)
1315+ urls := make([]string, 0, visibleCount)
1316+ for _, f := range favorites[:visibleCount] {
12641317 if f.URL != "" {
12651318 urls = append(urls, f.URL)
12661319 }
@@ -1268,11 +1321,21 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav
12681321 return urls
12691322 }
12701323
1271-func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
1324+func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
12721325 if idx < 0 {
12731326 return "", false
12741327 }
1275- return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true
1328+ return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
1329+}
1330+
1331+func visibleResultCount(total, maxVisible int) int {
1332+ if total <= 0 {
1333+ return 0
1334+ }
1335+ if maxVisible <= 0 || total < maxVisible {
1336+ return total
1337+ }
1338+ return maxVisible
12761339 }
12771340
12781341 // performSearch executes the search based on current view mode and query.
@@ -1598,9 +1661,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
15981661 o.rebuildList()
15991662
16001663 rowCount := len(suggestions)
1601- if o.scrolledWin != nil {
1602- o.scrolledWin.SetVisible(rowCount > 0)
1603- }
1664+ o.setResultsContainerState(rowCount)
16041665 o.resizeAndCenter(rowCount)
16051666 }
16061667
@@ -1618,9 +1679,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
16181679
16191680 // Hide scrolled window when there are no suggestions
16201681 rowCount := len(suggestions)
1621- if o.scrolledWin != nil {
1622- o.scrolledWin.SetVisible(rowCount > 0)
1623- }
1682+ o.setResultsContainerState(rowCount)
16241683 o.resizeAndCenter(rowCount)
16251684
16261685 if rowCount > 0 {
@@ -1644,9 +1703,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
16441703
16451704 // Hide scrolled window when there are no favorites
16461705 rowCount := len(favorites)
1647- if o.scrolledWin != nil {
1648- o.scrolledWin.SetVisible(rowCount > 0)
1649- }
1706+ o.setResultsContainerState(rowCount)
16501707 o.resizeAndCenter(rowCount)
16511708
16521709 if rowCount > 0 {
@@ -1702,7 +1759,7 @@ func (o *Omnibox) rebuildList() {
17021759 if width <= 0 {
17031760 return false // Overlay not allocated yet, skip
17041761 }
1705- forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)
1762+ forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
17061763 if o.measureComponentHeights(forWidth) {
17071764 // Re-trigger resize with accurate measurements
17081765 o.mu.RLock()
@@ -1749,86 +1806,166 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
17491806 return favicon
17501807 }
17511808
1752-// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1753-// Uses rawURL for both favicon fetching and display.
1754-func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1755- return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)
1809+const favoriteStarBaseSize = 18
1810+const favoriteStarSlotClass = "omnibox-favorite-star-slot"
1811+
1812+func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
1813+
1814+func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
1815+
1816+func clearBoxChildren(box *gtk.Box) {
1817+ if box == nil {
1818+ return
1819+ }
1820+ for child := box.GetFirstChild(); child != nil; {
1821+ next := child.GetNextSibling()
1822+ box.Remove(child)
1823+ child = next
1824+ }
17561825 }
17571826
1758-// 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.
1760-func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {
1761- row := gtk.NewListBoxRow()
1827+func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) {
1828+ if slot == nil {
1829+ return
1830+ }
1831+ clearBoxChildren(slot)
1832+ if showFavoriteStar {
1833+ if star := o.createFavoriteStarIcon(); star != nil {
1834+ slot.Append(&star.Widget)
1835+ }
1836+ }
1837+ slot.SetVisible(showFavoriteStar)
1838+}
1839+
1840+func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box {
17621841 if row == nil {
17631842 return nil
17641843 }
1765- row.AddCssClass("omnibox-row")
1766- o.attachRowHoverSelection(row, index)
1767-
1768- const rowSpacing = 8
1769- hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1844+ hbox := row.GetChild()
17701845 if hbox == nil {
17711846 return nil
17721847 }
1773- hbox.SetHexpand(true)
1774-
1775- // Favicon image (vertically centered)
1776- if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1777- favicon.SetValign(gtk.AlignCenterValue)
1778- hbox.Append(&favicon.Widget)
1848+ for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() {
1849+ if child.HasCssClass(favoriteStarSlotClass) {
1850+ return gtk.BoxNewFromInternalPtr(child.GoPointer())
1851+ }
17791852 }
1853+ return nil
1854+}
17801855
1781- // Vertical box for title + URL
1782- textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1783- if textBox == nil {
1856+func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
1857+ star := gtk.NewImage()
1858+ if star == nil {
17841859 return nil
17851860 }
1786- textBox.SetHexpand(true)
1787- textBox.SetValign(gtk.AlignCenterValue)
1861+ iconName := "starred-symbolic"
1862+ star.SetFromIconName(&iconName)
1863+ star.SetPixelSize(favoriteStarSize(o.uiScale))
1864+ star.AddCssClass("omnibox-favorite-star")
1865+ star.SetValign(gtk.AlignCenterValue)
1866+ return star
1867+}
17881868
1789- // Title label (or URL if no title)
1869+func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
17901870 displayTitle := title
17911871 if displayTitle == "" {
17921872 displayTitle = displayURL
17931873 }
1874+
17941875 titleLabel := gtk.NewLabel(nil)
17951876 if titleLabel != nil {
17961877 titleLabel.SetText(displayTitle)
17971878 titleLabel.AddCssClass("omnibox-suggestion-title")
17981879 titleLabel.SetHalign(gtk.AlignStartValue)
1799- titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1880+ titleLabel.SetEllipsize(pango.EllipsizeEndValue)
18001881 textBox.Append(&titleLabel.Widget)
18011882 }
18021883
1803- // URL label (only if title exists and differs from URL)
18041884 if title != "" && title != displayURL {
18051885 urlLabel := gtk.NewLabel(nil)
18061886 if urlLabel != nil {
18071887 urlLabel.SetText(displayURL)
18081888 urlLabel.AddCssClass("omnibox-suggestion-url")
18091889 urlLabel.SetHalign(gtk.AlignStartValue)
1810- urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END
1890+ urlLabel.SetEllipsize(pango.EllipsizeEndValue)
18111891 textBox.Append(&urlLabel.Widget)
18121892 }
18131893 }
1894+}
18141895
1815- hbox.Append(&textBox.Widget)
1896+func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
1897+ starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
1898+ if starSlot != nil {
1899+ starSlot.AddCssClass(favoriteStarSlotClass)
1900+ starSlot.SetValign(gtk.AlignCenterValue)
1901+ o.syncFavoriteStarSlot(starSlot, showFavoriteStar)
1902+ hbox.Append(&starSlot.Widget)
1903+ }
18161904
1817- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
18181905 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- }
1906+ if index > maxShortcutIndex {
1907+ return
1908+ }
1909+
1910+ shortcutLabel := gtk.NewLabel(nil)
1911+ if shortcutLabel == nil {
1912+ return
1913+ }
1914+ if index < maxShortcutIndex {
1915+ shortcutLabel.SetText(formatShortcut(index + 1))
1916+ } else {
1917+ shortcutLabel.SetText("Ctrl+0")
1918+ }
1919+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1920+ shortcutLabel.SetValign(gtk.AlignCenterValue)
1921+ hbox.Append(&shortcutLabel.Widget)
1922+}
1923+
1924+// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1925+// Uses rawURL for both favicon fetching and display.
1926+func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1927+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
1928+}
1929+
1930+// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1931+// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1932+func (o *Omnibox) createRowWithFaviconURL(
1933+ displayURL, title, faviconURL, fallbackIcon string,
1934+ showFavoriteStar bool,
1935+ index int,
1936+) *gtk.ListBoxRow {
1937+ row := gtk.NewListBoxRow()
1938+ if row == nil {
1939+ return nil
18311940 }
1941+ row.AddCssClass("omnibox-row")
1942+ o.attachRowHoverSelection(row, index)
1943+
1944+ const rowSpacing = 8
1945+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1946+ if hbox == nil {
1947+ return nil
1948+ }
1949+ hbox.SetHexpand(true)
1950+
1951+ // Favicon image (vertically centered)
1952+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1953+ favicon.SetValign(gtk.AlignCenterValue)
1954+ hbox.Append(&favicon.Widget)
1955+ }
1956+
1957+ // Vertical box for title + URL
1958+ textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1959+ if textBox == nil {
1960+ return nil
1961+ }
1962+ textBox.SetHexpand(true)
1963+ textBox.SetValign(gtk.AlignCenterValue)
1964+
1965+ o.appendSuggestionTitleAndURL(textBox, title, displayURL)
1966+
1967+ hbox.Append(&textBox.Widget)
1968+ o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
18321969
18331970 row.SetChild(&hbox.Widget)
18341971 return row
@@ -1863,7 +2000,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo
18632000
18642001 // createSuggestionRow creates a ListBoxRow for a suggestion.
18652002 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1866- row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)
2003+ row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
18672004 if row != nil && s.IsFavorite {
18682005 row.AddCssClass("omnibox-row-favorite")
18692006 }
@@ -1878,7 +2015,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
18782015 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
18792016 // Pass description as URL param (displayed as secondary label) and empty
18802017 // faviconURL to skip async favicon fetching - bang rows use static icon only
1881- row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)
2018+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
18822019 if row != nil {
18832020 row.AddCssClass("omnibox-row-bang")
18842021 }
@@ -1908,13 +2045,14 @@ func (o *Omnibox) selectNext() {
19082045 current := o.selectedIndex
19092046 mode := o.viewMode
19102047 bangMode := o.bangMode
2048+ maxVisible := o.effectiveMaxRows()
19112049 var maxIndex int
19122050 if bangMode {
1913- maxIndex = len(o.bangSuggestions) - 1
2051+ maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
19142052 } else if mode == ViewModeHistory {
1915- maxIndex = len(o.suggestions) - 1
2053+ maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
19162054 } else {
1917- maxIndex = len(o.favorites) - 1
2055+ maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
19182056 }
19192057 o.hasNavigated = true // User is navigating with arrow keys
19202058 o.mu.Unlock()
@@ -1936,13 +2074,14 @@ func (o *Omnibox) selectPrevious() {
19362074 current := o.selectedIndex
19372075 mode := o.viewMode
19382076 bangMode := o.bangMode
2077+ maxVisible := o.effectiveMaxRows()
19392078 var maxIndex int
19402079 if bangMode {
1941- maxIndex = len(o.bangSuggestions) - 1
2080+ maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
19422081 } else if mode == ViewModeHistory {
1943- maxIndex = len(o.suggestions) - 1
2082+ maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
19442083 } else {
1945- maxIndex = len(o.favorites) - 1
2084+ maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
19462085 }
19472086 o.hasNavigated = true // User is navigating with arrow keys
19482087 o.mu.Unlock()
@@ -1968,13 +2107,14 @@ func (o *Omnibox) selectAndNavigate(index int) {
19682107 suggestions := o.suggestions
19692108 favorites := o.favorites
19702109 o.mu.RUnlock()
2110+ maxVisible := o.effectiveMaxRows()
19712111
19722112 if bangMode {
19732113 o.navigateToSelected()
19742114 return
19752115 }
19762116
1977- targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites)
2117+ targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)
19782118 if targetURL == "" {
19792119 return
19802120 }
@@ -2028,7 +2168,7 @@ func (o *Omnibox) navigateToSelected() {
20282168 var targetURL string
20292169 if idx >= 0 {
20302170 // If user has selected a result, navigate to that result.
2031- targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)
2171+ targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
20322172 } else {
20332173 targetURL = o.buildURL(entryText)
20342174 }
@@ -2044,14 +2184,16 @@ func (o *Omnibox) navigateToSelected() {
20442184 }
20452185 }
20462186
2047-func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string {
2187+func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
20482188 if mode == ViewModeHistory {
2049- if idx >= 0 && idx < len(suggestions) {
2189+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
2190+ if idx >= 0 && idx < visibleCount {
20502191 return suggestions[idx].URL
20512192 }
20522193 return ""
20532194 }
2054- if idx >= 0 && idx < len(favorites) {
2195+ visibleCount := visibleResultCount(len(favorites), maxVisible)
2196+ if idx >= 0 && idx < visibleCount {
20552197 return favorites[idx].URL
20562198 }
20572199 return ""
@@ -2126,7 +2268,7 @@ func (o *Omnibox) toggleFavorite() {
21262268
21272269 // Update row CSS and show toast on GTK main thread
21282270 cb := glib.SourceFunc(func(_ uintptr) bool {
2129- o.updateRowFavoriteIndicator(idx, result.Added)
2271+ o.updateRowFavoriteIndicator(idx, s.URL, result.Added)
21302272 if o.onToast != nil {
21312273 o.onToast(ctx, result.Message, ToastSuccess)
21322274 }
@@ -2188,16 +2330,59 @@ func (o *Omnibox) toggleFavorite() {
21882330
21892331 // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
21902332 // Must be called from GTK main thread (via glib.IdleAdd).
2191-func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {
2333+func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) {
21922334 row := o.listBox.GetRowAtIndex(index)
21932335 if row == nil {
21942336 return
21952337 }
2338+
2339+ o.mu.RLock()
2340+ mode := o.viewMode
2341+ suggestions := append([]Suggestion(nil), o.suggestions...)
2342+ o.mu.RUnlock()
2343+
2344+ update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite)
2345+ if !update.Apply {
2346+ return
2347+ }
2348+
21962349 if isFavorite {
21972350 row.AddCssClass("omnibox-row-favorite")
21982351 } else {
21992352 row.RemoveCssClass("omnibox-row-favorite")
22002353 }
2354+ if slot := favoriteStarSlotForRow(row); slot != nil {
2355+ o.syncFavoriteStarSlot(slot, isFavorite)
2356+ slot.QueueDraw()
2357+ if update.ShowStarSlot {
2358+ slot.Show()
2359+ }
2360+ }
2361+ if child := row.GetChild(); child != nil {
2362+ child.QueueDraw()
2363+ child.Show()
2364+ }
2365+ row.QueueDraw()
2366+ row.Show()
2367+}
2368+
2369+func resolveFavoriteRowIndicatorUpdate(
2370+ mode ViewMode,
2371+ index int,
2372+ expectedURL string,
2373+ suggestions []Suggestion,
2374+ isFavorite bool,
2375+) favoriteRowIndicatorUpdate {
2376+ if mode != ViewModeHistory {
2377+ return favoriteRowIndicatorUpdate{}
2378+ }
2379+ if index < 0 || index >= len(suggestions) {
2380+ return favoriteRowIndicatorUpdate{}
2381+ }
2382+ if suggestions[index].URL != expectedURL {
2383+ return favoriteRowIndicatorUpdate{}
2384+ }
2385+ return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite}
22012386 }
22022387
22032388 // yankSelectedURL copies the URL of the selected item to clipboard.
@@ -2316,20 +2501,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
23162501 o.entry.SetText(query)
23172502 o.entry.SelectRegion(-1, -1)
23182503
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
2504+ // Keep the results container hidden until real rows arrive.
2505+ // Pre-showing an empty, expanding scroller renders a dead area in standalone mode.
23242506 if o.scrolledWin != nil {
2325- o.scrolledWin.SetVisible(expectContent)
2507+ o.setResultsContainerState(0)
23262508 // Reset content height constraints - will be updated when results arrive
23272509 o.scrolledWin.SetMinContentHeight(-1)
23282510 o.scrolledWin.SetMaxContentHeight(0)
23292511 }
23302512
23312513 // Calculate dimensions using shared helper
2332- width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)
2514+ parentWidth, parentHeight := 0, 0
2515+ if o.parentOverlay != nil {
2516+ parentWidth = o.parentOverlay.GetAllocatedWidth()
2517+ parentHeight = o.parentOverlay.GetAllocatedHeight()
2518+ }
2519+ width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2520+ log.Debug().
2521+ Int("parentWidth", parentWidth).
2522+ Int("parentHeight", parentHeight).
2523+ Int("width", width).
2524+ Int("marginTop", marginTop).
2525+ Int("fixedWidth", o.sizeCfg.FixedWidth).
2526+ Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2527+ Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2528+ Float64("widthPct", o.sizeCfg.WidthPct).
2529+ Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2530+ Msg("omnibox geometry decision")
23332531
23342532 o.mainBox.SetSizeRequest(width, -1)
23352533 o.outerBox.SetMarginTop(marginTop)
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/puregotk/v4/glib"13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/graphene"14 "github.com/bnema/puregotk/v4/graphene"
15 "github.com/bnema/puregotk/v4/gtk"15 "github.com/bnema/puregotk/v4/gtk"
16+ "github.com/bnema/puregotk/v4/pango"
16 17
17 "github.com/bnema/dumber/internal/application/usecase"18 "github.com/bnema/dumber/internal/application/usecase"
18 "github.com/bnema/dumber/internal/domain/autocomplete"19 "github.com/bnema/dumber/internal/domain/autocomplete"
@@ -32,6 +33,11 @@ const (
32 minGhostInputLength = 133 minGhostInputLength = 1
33 )34 )
34 35
36+type favoriteRowIndicatorUpdate struct {
37+ Apply bool
38+ ShowStarSlot bool
39+}
40+
35 // ViewMode distinguishes history search from favorites display.41 // ViewMode distinguishes history search from favorites display.
36 type ViewMode string42 type ViewMode string
37 43
@@ -124,6 +130,7 @@ type Omnibox struct {
124 130
125 // Scaling131 // Scaling
126 uiScale float64132 uiScale float64
133+ sizeCfg ModalSizeConfig
127 134
128 // Cached measurements (populated after first layout)135 // Cached measurements (populated after first layout)
129 measuredHeights struct {136 measuredHeights struct {
@@ -158,6 +165,7 @@ type OmniboxConfig struct {
158 OnFocusOut func() // Callback when entry loses focus165 OnFocusOut func() // Callback when entry loses focus
159 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection166 OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection
160 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel167 OnAccentKeyRelease func(keyval uint) // Key release for accent cancel
168+ SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing
161 }169 }
162 170
163 // NewOmnibox creates a new native GTK4 omnibox widget.171 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -170,6 +178,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
170 uiScale = 1.0178 uiScale = 1.0
171 }179 }
172 180
181+ sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults)
182+
173 o := &Omnibox{183 o := &Omnibox{
174 viewMode: ViewModeHistory,184 viewMode: ViewModeHistory,
175 selectedIndex: -1,185 selectedIndex: -1,
@@ -185,6 +195,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
185 onAccentKeyRelease: cfg.OnAccentKeyRelease,195 onAccentKeyRelease: cfg.OnAccentKeyRelease,
186 ctx: ctx,196 ctx: ctx,
187 uiScale: uiScale,197 uiScale: uiScale,
198+ sizeCfg: sizeCfg,
188 }199 }
189 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {200 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
190 var cb glib.SourceFunc = func(uintptr) bool {201 var cb glib.SourceFunc = func(uintptr) bool {
@@ -317,13 +328,40 @@ func (o *Omnibox) estimateRowHeight() int {
317 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)328 return ScaleValue(DefaultRowHeights.Standard, o.uiScale)
318 }329 }
319 330
331+func resultsContainerState(rowCount int) (visible, expand, listVisible bool) {
332+ return rowCount > 0, rowCount > 0, rowCount > 0
333+}
334+
335+func shouldApplyEmptyResultsState(rowCount int, visible bool) bool {
336+ return !visible || rowCount <= 0
337+}
338+
339+func (o *Omnibox) setResultsContainerState(rowCount int) {
340+ if o.scrolledWin == nil {
341+ return
342+ }
343+ visible, expand, listVisible := resultsContainerState(rowCount)
344+ o.scrolledWin.SetVisible(visible)
345+ o.scrolledWin.SetVexpand(expand)
346+ if o.listBox != nil {
347+ o.listBox.SetVisible(listVisible)
348+ }
349+ if o.mainBox != nil {
350+ if shouldApplyEmptyResultsState(rowCount, visible) {
351+ o.mainBox.AddCssClass("omnibox-empty")
352+ } else {
353+ o.mainBox.RemoveCssClass("omnibox-empty")
354+ }
355+ }
356+}
357+
320 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.358 // effectiveMaxRows returns the max visible rows adapted to the current parent pane height.
321 // Must be called on the GTK main thread.359 // Must be called on the GTK main thread.
322 func (o *Omnibox) effectiveMaxRows() int {360 func (o *Omnibox) effectiveMaxRows() int {
323 if o.parentOverlay == nil {361 if o.parentOverlay == nil {
324 return OmniboxListDefaults.MaxVisibleRows362 return OmniboxListDefaults.MaxVisibleRows
325 }363 }
326- return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults)364+ return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults)
327 }365 }
328 366
329 // resizeAndCenter adjusts the omnibox size based on content and centers it.367 // resizeAndCenter adjusts the omnibox size based on content and centers it.
@@ -340,7 +378,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
340 378
341 // Schedule measurement after GTK has laid out widgets379 // Schedule measurement after GTK has laid out widgets
342 var cb glib.SourceFunc = func(uintptr) bool {380 var cb glib.SourceFunc = func(uintptr) bool {
343- width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)381+ width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
344 o.measureAndResize(width, rowCount)382 o.measureAndResize(width, rowCount)
345 return false383 return false
346 }384 }
@@ -387,6 +425,15 @@ func (o *Omnibox) measureAndResize(width, rowCount int) {
387 425
388 // Force layout recalculation426 // Force layout recalculation
389 o.outerBox.QueueResize()427 o.outerBox.QueueResize()
428+ if parent := o.parentOverlay; parent != nil {
429+ if widget := parent.GtkWidget(); widget != nil {
430+ widget.QueueResize()
431+ }
432+ }
433+ if o.scrolledWin != nil {
434+ o.scrolledWin.QueueResize()
435+ }
436+ o.mainBox.QueueResize()
390 437
391 log.Debug().438 log.Debug().
392 Int("width", width).439 Int("width", width).
@@ -544,7 +591,7 @@ func (o *Omnibox) initList() error {
544 return errNilWidget("scrolledWin")591 return errNilWidget("scrolledWin")
545 }592 }
546 o.scrolledWin.AddCssClass("omnibox-scrolled")593 o.scrolledWin.AddCssClass("omnibox-scrolled")
547- o.scrolledWin.SetVexpand(true)594+ o.scrolledWin.SetVexpand(false)
548 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)595 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
549 o.scrolledWin.SetPropagateNaturalHeight(true)596 o.scrolledWin.SetPropagateNaturalHeight(true)
550 597
@@ -555,19 +602,16 @@ func (o *Omnibox) initList() error {
555 o.listBox.AddCssClass("omnibox-listbox")602 o.listBox.AddCssClass("omnibox-listbox")
556 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)603 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
557 604
558- rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {605+ rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
559 o.restoreEntryToRealInput()606 o.restoreEntryToRealInput()
560- if rowPtr == 0 {607+ if row == nil {
561 o.mu.Lock()608 o.mu.Lock()
562 o.selectedIndex = -1609 o.selectedIndex = -1
563 o.mu.Unlock()610 o.mu.Unlock()
564 } else {611 } else {
565- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)612+ o.mu.Lock()
566- if row != nil {613+ o.selectedIndex = row.GetIndex()
567- o.mu.Lock()614+ o.mu.Unlock()
568- o.selectedIndex = row.GetIndex()
569- o.mu.Unlock()
570- }
571 }615 }
572 o.updateGhostFromSelection()616 o.updateGhostFromSelection()
573 }617 }
@@ -575,11 +619,7 @@ func (o *Omnibox) initList() error {
575 o.listBox.ConnectRowSelected(&rowSelectedCb)619 o.listBox.ConnectRowSelected(&rowSelectedCb)
576 620
577 // Handle row activation (click or Enter) - navigate directly to the URL621 // Handle row activation (click or Enter) - navigate directly to the URL
578- rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {622+ rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
579- if rowPtr == 0 {
580- return
581- }
582- row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
583 if row == nil {623 if row == nil {
584 return624 return
585 }625 }
@@ -596,7 +636,7 @@ func (o *Omnibox) initList() error {
596 return636 return
597 }637 }
598 638
599- targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites)639+ targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
600 640
601 if targetURL != "" && o.onNavigate != nil {641 if targetURL != "" && o.onNavigate != nil {
602 o.Hide(o.ctx)642 o.Hide(o.ctx)
@@ -1101,6 +1141,7 @@ func (o *Omnibox) restoreEntryToRealInput() {
1101 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.1141 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1102 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {1142 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1103 log := logging.FromContext(o.ctx)1143 log := logging.FromContext(o.ctx)
1144+ maxVisible := o.effectiveMaxRows()
1104 o.mu.RLock()1145 o.mu.RLock()
1105 idx := o.selectedIndex1146 idx := o.selectedIndex
1106 mode := o.viewMode1147 mode := o.viewMode
@@ -1128,10 +1169,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1128 return1169 return
1129 }1170 }
1130 1171
1131- targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites)1172+ targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)
1132 1173
1133 if entryText != "" {1174 if entryText != "" {
1134- o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites)1175+ o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)
1135 return1176 return
1136 }1177 }
1137 1178
@@ -1153,6 +1194,7 @@ func (o *Omnibox) resolveGhostCompletion(
1153 entryText, selectedURL string,1194 entryText, selectedURL string,
1154 hasExplicitSelection bool,1195 hasExplicitSelection bool,
1155 mode ViewMode,1196 mode ViewMode,
1197+ maxVisible int,
1156 suggestions []Suggestion,1198 suggestions []Suggestion,
1157 favorites []Favorite,1199 favorites []Favorite,
1158 ) {1200 ) {
@@ -1163,7 +1205,15 @@ func (o *Omnibox) resolveGhostCompletion(
1163 return1205 return
1164 }1206 }
1165 1207
1166- fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites)1208+ fullText, suffix, found := visibleGhostSuggestion(
1209+ completionInput,
1210+ selectedURL,
1211+ hasExplicitSelection,
1212+ mode,
1213+ maxVisible,
1214+ suggestions,
1215+ favorites,
1216+ )
1167 if !found {1217 if !found {
1168 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")1218 log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found")
1169 return1219 return
@@ -1235,6 +1285,7 @@ func visibleGhostSuggestion(
1235 query, selectedURL string,1285 query, selectedURL string,
1236 hasExplicitSelection bool,1286 hasExplicitSelection bool,
1237 mode ViewMode,1287 mode ViewMode,
1288+ maxVisible int,
1238 suggestions []Suggestion,1289 suggestions []Suggestion,
1239 favorites []Favorite,1290 favorites []Favorite,
1240 ) (fullText, suffix string, ok bool) {1291 ) (fullText, suffix string, ok bool) {
@@ -1243,15 +1294,16 @@ func visibleGhostSuggestion(
1243 return fullText, suffix, ok1294 return fullText, suffix, ok
1244 }1295 }
1245 1296
1246- visibleURLs := visibleURLsForMode(mode, suggestions, favorites)1297+ visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
1247 suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)1298 suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
1248 return fullText, suffix, ok1299 return fullText, suffix, ok
1249 }1300 }
1250 1301
1251-func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string {1302+func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
1252 if mode == ViewModeHistory {1303 if mode == ViewModeHistory {
1253- urls := make([]string, 0, len(suggestions))1304+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
1254- for _, s := range suggestions {1305+ urls := make([]string, 0, visibleCount)
1306+ for _, s := range suggestions[:visibleCount] {
1255 if s.URL != "" {1307 if s.URL != "" {
1256 urls = append(urls, s.URL)1308 urls = append(urls, s.URL)
1257 }1309 }
@@ -1259,8 +1311,9 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav
1259 return urls1311 return urls
1260 }1312 }
1261 1313
1262- urls := make([]string, 0, len(favorites))1314+ visibleCount := visibleResultCount(len(favorites), maxVisible)
1263- for _, f := range favorites {1315+ urls := make([]string, 0, visibleCount)
1316+ for _, f := range favorites[:visibleCount] {
1264 if f.URL != "" {1317 if f.URL != "" {
1265 urls = append(urls, f.URL)1318 urls = append(urls, f.URL)
1266 }1319 }
@@ -1268,11 +1321,21 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav
1268 return urls1321 return urls
1269 }1322 }
1270 1323
1271-func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) {1324+func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
1272 if idx < 0 {1325 if idx < 0 {
1273 return "", false1326 return "", false
1274 }1327 }
1275- return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true1328+ return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
1329+}
1330+
1331+func visibleResultCount(total, maxVisible int) int {
1332+ if total <= 0 {
1333+ return 0
1334+ }
1335+ if maxVisible <= 0 || total < maxVisible {
1336+ return total
1337+ }
1338+ return maxVisible
1276 }1339 }
1277 1340
1278 // performSearch executes the search based on current view mode and query.1341 // performSearch executes the search based on current view mode and query.
@@ -1598,9 +1661,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) {
1598 o.rebuildList()1661 o.rebuildList()
1599 1662
1600 rowCount := len(suggestions)1663 rowCount := len(suggestions)
1601- if o.scrolledWin != nil {1664+ o.setResultsContainerState(rowCount)
1602- o.scrolledWin.SetVisible(rowCount > 0)
1603- }
1604 o.resizeAndCenter(rowCount)1665 o.resizeAndCenter(rowCount)
1605 }1666 }
1606 1667
@@ -1618,9 +1679,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1618 1679
1619 // Hide scrolled window when there are no suggestions1680 // Hide scrolled window when there are no suggestions
1620 rowCount := len(suggestions)1681 rowCount := len(suggestions)
1621- if o.scrolledWin != nil {1682+ o.setResultsContainerState(rowCount)
1622- o.scrolledWin.SetVisible(rowCount > 0)
1623- }
1624 o.resizeAndCenter(rowCount)1683 o.resizeAndCenter(rowCount)
1625 1684
1626 if rowCount > 0 {1685 if rowCount > 0 {
@@ -1644,9 +1703,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1644 1703
1645 // Hide scrolled window when there are no favorites1704 // Hide scrolled window when there are no favorites
1646 rowCount := len(favorites)1705 rowCount := len(favorites)
1647- if o.scrolledWin != nil {1706+ o.setResultsContainerState(rowCount)
1648- o.scrolledWin.SetVisible(rowCount > 0)
1649- }
1650 o.resizeAndCenter(rowCount)1707 o.resizeAndCenter(rowCount)
1651 1708
1652 if rowCount > 0 {1709 if rowCount > 0 {
@@ -1702,7 +1759,7 @@ func (o *Omnibox) rebuildList() {
1702 if width <= 0 {1759 if width <= 0 {
1703 return false // Overlay not allocated yet, skip1760 return false // Overlay not allocated yet, skip
1704 }1761 }
1705- forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct)1762+ forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
1706 if o.measureComponentHeights(forWidth) {1763 if o.measureComponentHeights(forWidth) {
1707 // Re-trigger resize with accurate measurements1764 // Re-trigger resize with accurate measurements
1708 o.mu.RLock()1765 o.mu.RLock()
@@ -1749,86 +1806,166 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image {
1749 return favicon1806 return favicon
1750 }1807 }
1751 1808
1752-// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.1809+const favoriteStarBaseSize = 18
1753-// Uses rawURL for both favicon fetching and display.1810+const favoriteStarSlotClass = "omnibox-favorite-star-slot"
1754-func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {1811+
1755- return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index)1812+func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) }
1813+
1814+func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite }
1815+
1816+func clearBoxChildren(box *gtk.Box) {
1817+ if box == nil {
1818+ return
1819+ }
1820+ for child := box.GetFirstChild(); child != nil; {
1821+ next := child.GetNextSibling()
1822+ box.Remove(child)
1823+ child = next
1824+ }
1756 }1825 }
1757 1826
1758-// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.1827+func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) {
1759-// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.1828+ if slot == nil {
1760-func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow {1829+ return
1761- row := gtk.NewListBoxRow()1830+ }
1831+ clearBoxChildren(slot)
1832+ if showFavoriteStar {
1833+ if star := o.createFavoriteStarIcon(); star != nil {
1834+ slot.Append(&star.Widget)
1835+ }
1836+ }
1837+ slot.SetVisible(showFavoriteStar)
1838+}
1839+
1840+func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box {
1762 if row == nil {1841 if row == nil {
1763 return nil1842 return nil
1764 }1843 }
1765- row.AddCssClass("omnibox-row")1844+ hbox := row.GetChild()
1766- o.attachRowHoverSelection(row, index)
1767-
1768- const rowSpacing = 8
1769- hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1770 if hbox == nil {1845 if hbox == nil {
1771 return nil1846 return nil
1772 }1847 }
1773- hbox.SetHexpand(true)1848+ for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() {
1774-1849+ if child.HasCssClass(favoriteStarSlotClass) {
1775- // Favicon image (vertically centered)1850+ return gtk.BoxNewFromInternalPtr(child.GoPointer())
1776- if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {1851+ }
1777- favicon.SetValign(gtk.AlignCenterValue)
1778- hbox.Append(&favicon.Widget)
1779 }1852 }
1853+ return nil
1854+}
1780 1855
1781- // Vertical box for title + URL1856+func (o *Omnibox) createFavoriteStarIcon() *gtk.Image {
1782- textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)1857+ star := gtk.NewImage()
1783- if textBox == nil {1858+ if star == nil {
1784 return nil1859 return nil
1785 }1860 }
1786- textBox.SetHexpand(true)1861+ iconName := "starred-symbolic"
1787- textBox.SetValign(gtk.AlignCenterValue)1862+ star.SetFromIconName(&iconName)
1863+ star.SetPixelSize(favoriteStarSize(o.uiScale))
1864+ star.AddCssClass("omnibox-favorite-star")
1865+ star.SetValign(gtk.AlignCenterValue)
1866+ return star
1867+}
1788 1868
1789- // Title label (or URL if no title)1869+func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) {
1790 displayTitle := title1870 displayTitle := title
1791 if displayTitle == "" {1871 if displayTitle == "" {
1792 displayTitle = displayURL1872 displayTitle = displayURL
1793 }1873 }
1874+
1794 titleLabel := gtk.NewLabel(nil)1875 titleLabel := gtk.NewLabel(nil)
1795 if titleLabel != nil {1876 if titleLabel != nil {
1796 titleLabel.SetText(displayTitle)1877 titleLabel.SetText(displayTitle)
1797 titleLabel.AddCssClass("omnibox-suggestion-title")1878 titleLabel.AddCssClass("omnibox-suggestion-title")
1798 titleLabel.SetHalign(gtk.AlignStartValue)1879 titleLabel.SetHalign(gtk.AlignStartValue)
1799- titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END1880+ titleLabel.SetEllipsize(pango.EllipsizeEndValue)
1800 textBox.Append(&titleLabel.Widget)1881 textBox.Append(&titleLabel.Widget)
1801 }1882 }
1802 1883
1803- // URL label (only if title exists and differs from URL)
1804 if title != "" && title != displayURL {1884 if title != "" && title != displayURL {
1805 urlLabel := gtk.NewLabel(nil)1885 urlLabel := gtk.NewLabel(nil)
1806 if urlLabel != nil {1886 if urlLabel != nil {
1807 urlLabel.SetText(displayURL)1887 urlLabel.SetText(displayURL)
1808 urlLabel.AddCssClass("omnibox-suggestion-url")1888 urlLabel.AddCssClass("omnibox-suggestion-url")
1809 urlLabel.SetHalign(gtk.AlignStartValue)1889 urlLabel.SetHalign(gtk.AlignStartValue)
1810- urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END1890+ urlLabel.SetEllipsize(pango.EllipsizeEndValue)
1811 textBox.Append(&urlLabel.Widget)1891 textBox.Append(&urlLabel.Widget)
1812 }1892 }
1813 }1893 }
1894+}
1814 1895
1815- hbox.Append(&textBox.Widget)1896+func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) {
1897+ starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
1898+ if starSlot != nil {
1899+ starSlot.AddCssClass(favoriteStarSlotClass)
1900+ starSlot.SetValign(gtk.AlignCenterValue)
1901+ o.syncFavoriteStarSlot(starSlot, showFavoriteStar)
1902+ hbox.Append(&starSlot.Widget)
1903+ }
1816 1904
1817- // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th)
1818 const maxShortcutIndex = 91905 const maxShortcutIndex = 9
1819- if index <= maxShortcutIndex {1906+ if index > maxShortcutIndex {
1820- shortcutLabel := gtk.NewLabel(nil)1907+ return
1821- if shortcutLabel != nil {1908+ }
1822- if index < maxShortcutIndex {1909+
1823- shortcutLabel.SetText(formatShortcut(index + 1))1910+ shortcutLabel := gtk.NewLabel(nil)
1824- } else {1911+ if shortcutLabel == nil {
1825- shortcutLabel.SetText("Ctrl+0")1912+ return
1826- }1913+ }
1827- shortcutLabel.AddCssClass("omnibox-shortcut-badge")1914+ if index < maxShortcutIndex {
1828- shortcutLabel.SetValign(gtk.AlignCenterValue)1915+ shortcutLabel.SetText(formatShortcut(index + 1))
1829- hbox.Append(&shortcutLabel.Widget)1916+ } else {
1830- }1917+ shortcutLabel.SetText("Ctrl+0")
1918+ }
1919+ shortcutLabel.AddCssClass("omnibox-shortcut-badge")
1920+ shortcutLabel.SetValign(gtk.AlignCenterValue)
1921+ hbox.Append(&shortcutLabel.Widget)
1922+}
1923+
1924+// createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge.
1925+// Uses rawURL for both favicon fetching and display.
1926+func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow {
1927+ return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index)
1928+}
1929+
1930+// createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge.
1931+// faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label.
1932+func (o *Omnibox) createRowWithFaviconURL(
1933+ displayURL, title, faviconURL, fallbackIcon string,
1934+ showFavoriteStar bool,
1935+ index int,
1936+) *gtk.ListBoxRow {
1937+ row := gtk.NewListBoxRow()
1938+ if row == nil {
1939+ return nil
1831 }1940 }
1941+ row.AddCssClass("omnibox-row")
1942+ o.attachRowHoverSelection(row, index)
1943+
1944+ const rowSpacing = 8
1945+ hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing)
1946+ if hbox == nil {
1947+ return nil
1948+ }
1949+ hbox.SetHexpand(true)
1950+
1951+ // Favicon image (vertically centered)
1952+ if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil {
1953+ favicon.SetValign(gtk.AlignCenterValue)
1954+ hbox.Append(&favicon.Widget)
1955+ }
1956+
1957+ // Vertical box for title + URL
1958+ textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2)
1959+ if textBox == nil {
1960+ return nil
1961+ }
1962+ textBox.SetHexpand(true)
1963+ textBox.SetValign(gtk.AlignCenterValue)
1964+
1965+ o.appendSuggestionTitleAndURL(textBox, title, displayURL)
1966+
1967+ hbox.Append(&textBox.Widget)
1968+ o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index)
1832 1969
1833 row.SetChild(&hbox.Widget)1970 row.SetChild(&hbox.Widget)
1834 return row1971 return row
@@ -1863,7 +2000,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo
1863 2000
1864 // createSuggestionRow creates a ListBoxRow for a suggestion.2001 // createSuggestionRow creates a ListBoxRow for a suggestion.
1865 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {2002 func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow {
1866- row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index)2003+ row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index)
1867 if row != nil && s.IsFavorite {2004 if row != nil && s.IsFavorite {
1868 row.AddCssClass("omnibox-row-favorite")2005 row.AddCssClass("omnibox-row-favorite")
1869 }2006 }
@@ -1878,7 +2015,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow {
1878 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {2015 func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow {
1879 // Pass description as URL param (displayed as secondary label) and empty2016 // Pass description as URL param (displayed as secondary label) and empty
1880 // faviconURL to skip async favicon fetching - bang rows use static icon only2017 // faviconURL to skip async favicon fetching - bang rows use static icon only
1881- row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index)2018+ row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index)
1882 if row != nil {2019 if row != nil {
1883 row.AddCssClass("omnibox-row-bang")2020 row.AddCssClass("omnibox-row-bang")
1884 }2021 }
@@ -1908,13 +2045,14 @@ func (o *Omnibox) selectNext() {
1908 current := o.selectedIndex2045 current := o.selectedIndex
1909 mode := o.viewMode2046 mode := o.viewMode
1910 bangMode := o.bangMode2047 bangMode := o.bangMode
2048+ maxVisible := o.effectiveMaxRows()
1911 var maxIndex int2049 var maxIndex int
1912 if bangMode {2050 if bangMode {
1913- maxIndex = len(o.bangSuggestions) - 12051+ maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
1914 } else if mode == ViewModeHistory {2052 } else if mode == ViewModeHistory {
1915- maxIndex = len(o.suggestions) - 12053+ maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
1916 } else {2054 } else {
1917- maxIndex = len(o.favorites) - 12055+ maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
1918 }2056 }
1919 o.hasNavigated = true // User is navigating with arrow keys2057 o.hasNavigated = true // User is navigating with arrow keys
1920 o.mu.Unlock()2058 o.mu.Unlock()
@@ -1936,13 +2074,14 @@ func (o *Omnibox) selectPrevious() {
1936 current := o.selectedIndex2074 current := o.selectedIndex
1937 mode := o.viewMode2075 mode := o.viewMode
1938 bangMode := o.bangMode2076 bangMode := o.bangMode
2077+ maxVisible := o.effectiveMaxRows()
1939 var maxIndex int2078 var maxIndex int
1940 if bangMode {2079 if bangMode {
1941- maxIndex = len(o.bangSuggestions) - 12080+ maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
1942 } else if mode == ViewModeHistory {2081 } else if mode == ViewModeHistory {
1943- maxIndex = len(o.suggestions) - 12082+ maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
1944 } else {2083 } else {
1945- maxIndex = len(o.favorites) - 12084+ maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
1946 }2085 }
1947 o.hasNavigated = true // User is navigating with arrow keys2086 o.hasNavigated = true // User is navigating with arrow keys
1948 o.mu.Unlock()2087 o.mu.Unlock()
@@ -1968,13 +2107,14 @@ func (o *Omnibox) selectAndNavigate(index int) {
1968 suggestions := o.suggestions2107 suggestions := o.suggestions
1969 favorites := o.favorites2108 favorites := o.favorites
1970 o.mu.RUnlock()2109 o.mu.RUnlock()
2110+ maxVisible := o.effectiveMaxRows()
1971 2111
1972 if bangMode {2112 if bangMode {
1973 o.navigateToSelected()2113 o.navigateToSelected()
1974 return2114 return
1975 }2115 }
1976 2116
1977- targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites)2117+ targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)
1978 if targetURL == "" {2118 if targetURL == "" {
1979 return2119 return
1980 }2120 }
@@ -2028,7 +2168,7 @@ func (o *Omnibox) navigateToSelected() {
2028 var targetURL string2168 var targetURL string
2029 if idx >= 0 {2169 if idx >= 0 {
2030 // If user has selected a result, navigate to that result.2170 // If user has selected a result, navigate to that result.
2031- targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites)2171+ targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
2032 } else {2172 } else {
2033 targetURL = o.buildURL(entryText)2173 targetURL = o.buildURL(entryText)
2034 }2174 }
@@ -2044,14 +2184,16 @@ func (o *Omnibox) navigateToSelected() {
2044 }2184 }
2045 }2185 }
2046 2186
2047-func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string {2187+func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
2048 if mode == ViewModeHistory {2188 if mode == ViewModeHistory {
2049- if idx >= 0 && idx < len(suggestions) {2189+ visibleCount := visibleResultCount(len(suggestions), maxVisible)
2190+ if idx >= 0 && idx < visibleCount {
2050 return suggestions[idx].URL2191 return suggestions[idx].URL
2051 }2192 }
2052 return ""2193 return ""
2053 }2194 }
2054- if idx >= 0 && idx < len(favorites) {2195+ visibleCount := visibleResultCount(len(favorites), maxVisible)
2196+ if idx >= 0 && idx < visibleCount {
2055 return favorites[idx].URL2197 return favorites[idx].URL
2056 }2198 }
2057 return ""2199 return ""
@@ -2126,7 +2268,7 @@ func (o *Omnibox) toggleFavorite() {
2126 2268
2127 // Update row CSS and show toast on GTK main thread2269 // Update row CSS and show toast on GTK main thread
2128 cb := glib.SourceFunc(func(_ uintptr) bool {2270 cb := glib.SourceFunc(func(_ uintptr) bool {
2129- o.updateRowFavoriteIndicator(idx, result.Added)2271+ o.updateRowFavoriteIndicator(idx, s.URL, result.Added)
2130 if o.onToast != nil {2272 if o.onToast != nil {
2131 o.onToast(ctx, result.Message, ToastSuccess)2273 o.onToast(ctx, result.Message, ToastSuccess)
2132 }2274 }
@@ -2188,16 +2330,59 @@ func (o *Omnibox) toggleFavorite() {
2188 2330
2189 // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.2331 // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class.
2190 // Must be called from GTK main thread (via glib.IdleAdd).2332 // Must be called from GTK main thread (via glib.IdleAdd).
2191-func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) {2333+func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) {
2192 row := o.listBox.GetRowAtIndex(index)2334 row := o.listBox.GetRowAtIndex(index)
2193 if row == nil {2335 if row == nil {
2194 return2336 return
2195 }2337 }
2338+
2339+ o.mu.RLock()
2340+ mode := o.viewMode
2341+ suggestions := append([]Suggestion(nil), o.suggestions...)
2342+ o.mu.RUnlock()
2343+
2344+ update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite)
2345+ if !update.Apply {
2346+ return
2347+ }
2348+
2196 if isFavorite {2349 if isFavorite {
2197 row.AddCssClass("omnibox-row-favorite")2350 row.AddCssClass("omnibox-row-favorite")
2198 } else {2351 } else {
2199 row.RemoveCssClass("omnibox-row-favorite")2352 row.RemoveCssClass("omnibox-row-favorite")
2200 }2353 }
2354+ if slot := favoriteStarSlotForRow(row); slot != nil {
2355+ o.syncFavoriteStarSlot(slot, isFavorite)
2356+ slot.QueueDraw()
2357+ if update.ShowStarSlot {
2358+ slot.Show()
2359+ }
2360+ }
2361+ if child := row.GetChild(); child != nil {
2362+ child.QueueDraw()
2363+ child.Show()
2364+ }
2365+ row.QueueDraw()
2366+ row.Show()
2367+}
2368+
2369+func resolveFavoriteRowIndicatorUpdate(
2370+ mode ViewMode,
2371+ index int,
2372+ expectedURL string,
2373+ suggestions []Suggestion,
2374+ isFavorite bool,
2375+) favoriteRowIndicatorUpdate {
2376+ if mode != ViewModeHistory {
2377+ return favoriteRowIndicatorUpdate{}
2378+ }
2379+ if index < 0 || index >= len(suggestions) {
2380+ return favoriteRowIndicatorUpdate{}
2381+ }
2382+ if suggestions[index].URL != expectedURL {
2383+ return favoriteRowIndicatorUpdate{}
2384+ }
2385+ return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite}
2201 }2386 }
2202 2387
2203 // yankSelectedURL copies the URL of the selected item to clipboard.2388 // yankSelectedURL copies the URL of the selected item to clipboard.
@@ -2316,20 +2501,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
2316 o.entry.SetText(query)2501 o.entry.SetText(query)
2317 o.entry.SelectRegion(-1, -1)2502 o.entry.SelectRegion(-1, -1)
2318 2503
2319- // Determine if we expect content initially2504+ // Keep the results container hidden until real rows arrive.
2320- // No content expected if: no query AND initialBehavior is "none"2505+ // 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 {2506 if o.scrolledWin != nil {
2325- o.scrolledWin.SetVisible(expectContent)2507+ o.setResultsContainerState(0)
2326 // Reset content height constraints - will be updated when results arrive2508 // Reset content height constraints - will be updated when results arrive
2327 o.scrolledWin.SetMinContentHeight(-1)2509 o.scrolledWin.SetMinContentHeight(-1)
2328 o.scrolledWin.SetMaxContentHeight(0)2510 o.scrolledWin.SetMaxContentHeight(0)
2329 }2511 }
2330 2512
2331 // Calculate dimensions using shared helper2513 // Calculate dimensions using shared helper
2332- width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults)2514+ parentWidth, parentHeight := 0, 0
2515+ if o.parentOverlay != nil {
2516+ parentWidth = o.parentOverlay.GetAllocatedWidth()
2517+ parentHeight = o.parentOverlay.GetAllocatedHeight()
2518+ }
2519+ width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg)
2520+ log.Debug().
2521+ Int("parentWidth", parentWidth).
2522+ Int("parentHeight", parentHeight).
2523+ Int("width", width).
2524+ Int("marginTop", marginTop).
2525+ Int("fixedWidth", o.sizeCfg.FixedWidth).
2526+ Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin).
2527+ Int("fixedTopMargin", o.sizeCfg.FixedTopMargin).
2528+ Float64("widthPct", o.sizeCfg.WidthPct).
2529+ Float64("topMarginPct", o.sizeCfg.TopMarginPct).
2530+ Msg("omnibox geometry decision")
2333 2531
2334 o.mainBox.SetSizeRequest(width, -1)2532 o.mainBox.SetSizeRequest(width, -1)
2335 o.outerBox.SetMarginTop(marginTop)2533 o.outerBox.SetMarginTop(marginTop)
A internal/ui/component/omnibox_favorite_icon_test.go +101 -0
@@ -0,0 +1,101 @@
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+}
18+
19+func TestShouldApplyEmptyResultsState(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ rowCount int
23+ visible bool
24+ want bool
25+ }{
26+ {name: "negative rows uses empty state", rowCount: -1, visible: false, want: true},
27+ {name: "zero rows uses empty state", rowCount: 0, visible: false, want: true},
28+ {name: "positive rows clears empty state", rowCount: 2, visible: true, want: false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ assert.Equal(t, tt.want, shouldApplyEmptyResultsState(tt.rowCount, tt.visible))
34+ })
35+ }
36+}
37+
38+func TestResolveFavoriteRowIndicatorUpdate(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ mode ViewMode
42+ index int
43+ expectedURL string
44+ suggestions []Suggestion
45+ isFavorite bool
46+ want favoriteRowIndicatorUpdate
47+ }{
48+ {
49+ name: "matching history row keeps update and shows star",
50+ mode: ViewModeHistory,
51+ index: 0,
52+ expectedURL: "https://example.com",
53+ suggestions: []Suggestion{{URL: "https://example.com"}},
54+ isFavorite: true,
55+ want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: true},
56+ },
57+ {
58+ name: "matching history row keeps update and hides star when removed",
59+ mode: ViewModeHistory,
60+ index: 0,
61+ expectedURL: "https://example.com",
62+ suggestions: []Suggestion{{URL: "https://example.com"}},
63+ isFavorite: false,
64+ want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: false},
65+ },
66+ {
67+ name: "mismatched URL skips stale row update",
68+ mode: ViewModeHistory,
69+ index: 0,
70+ expectedURL: "https://expected.example.com",
71+ suggestions: []Suggestion{{URL: "https://current.example.com"}},
72+ isFavorite: true,
73+ want: favoriteRowIndicatorUpdate{},
74+ },
75+ {
76+ name: "favorites mode never applies history row update",
77+ mode: ViewModeFavorites,
78+ index: 0,
79+ expectedURL: "https://example.com",
80+ suggestions: []Suggestion{{URL: "https://example.com"}},
81+ isFavorite: true,
82+ want: favoriteRowIndicatorUpdate{},
83+ },
84+ {
85+ name: "out of range index skips update",
86+ mode: ViewModeHistory,
87+ index: 1,
88+ expectedURL: "https://example.com",
89+ suggestions: []Suggestion{{URL: "https://example.com"}},
90+ isFavorite: true,
91+ want: favoriteRowIndicatorUpdate{},
92+ },
93+ }
94+
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ got := resolveFavoriteRowIndicatorUpdate(tt.mode, tt.index, tt.expectedURL, tt.suggestions, tt.isFavorite)
98+ assert.Equal(t, tt.want, got)
99+ })
100+ }
101+}
@@ -0,0 +1,101 @@
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+}
18+
19+func TestShouldApplyEmptyResultsState(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ rowCount int
23+ visible bool
24+ want bool
25+ }{
26+ {name: "negative rows uses empty state", rowCount: -1, visible: false, want: true},
27+ {name: "zero rows uses empty state", rowCount: 0, visible: false, want: true},
28+ {name: "positive rows clears empty state", rowCount: 2, visible: true, want: false},
29+ }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ assert.Equal(t, tt.want, shouldApplyEmptyResultsState(tt.rowCount, tt.visible))
34+ })
35+ }
36+}
37+
38+func TestResolveFavoriteRowIndicatorUpdate(t *testing.T) {
39+ tests := []struct {
40+ name string
41+ mode ViewMode
42+ index int
43+ expectedURL string
44+ suggestions []Suggestion
45+ isFavorite bool
46+ want favoriteRowIndicatorUpdate
47+ }{
48+ {
49+ name: "matching history row keeps update and shows star",
50+ mode: ViewModeHistory,
51+ index: 0,
52+ expectedURL: "https://example.com",
53+ suggestions: []Suggestion{{URL: "https://example.com"}},
54+ isFavorite: true,
55+ want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: true},
56+ },
57+ {
58+ name: "matching history row keeps update and hides star when removed",
59+ mode: ViewModeHistory,
60+ index: 0,
61+ expectedURL: "https://example.com",
62+ suggestions: []Suggestion{{URL: "https://example.com"}},
63+ isFavorite: false,
64+ want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: false},
65+ },
66+ {
67+ name: "mismatched URL skips stale row update",
68+ mode: ViewModeHistory,
69+ index: 0,
70+ expectedURL: "https://expected.example.com",
71+ suggestions: []Suggestion{{URL: "https://current.example.com"}},
72+ isFavorite: true,
73+ want: favoriteRowIndicatorUpdate{},
74+ },
75+ {
76+ name: "favorites mode never applies history row update",
77+ mode: ViewModeFavorites,
78+ index: 0,
79+ expectedURL: "https://example.com",
80+ suggestions: []Suggestion{{URL: "https://example.com"}},
81+ isFavorite: true,
82+ want: favoriteRowIndicatorUpdate{},
83+ },
84+ {
85+ name: "out of range index skips update",
86+ mode: ViewModeHistory,
87+ index: 1,
88+ expectedURL: "https://example.com",
89+ suggestions: []Suggestion{{URL: "https://example.com"}},
90+ isFavorite: true,
91+ want: favoriteRowIndicatorUpdate{},
92+ },
93+ }
94+
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ got := resolveFavoriteRowIndicatorUpdate(tt.mode, tt.index, tt.expectedURL, tt.suggestions, tt.isFavorite)
98+ assert.Equal(t, tt.want, got)
99+ })
100+ }
101+}
M internal/ui/component/omnibox_navigation_state_test.go +63 -8
@@ -87,6 +87,38 @@ 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+ }{
98+ {name: "negative rows stays hidden", rowCount: -1, wantVisible: false, wantExpand: false, wantListVisible: false},
99+ {name: "no rows stays hidden", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false},
100+ {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true},
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ gotVisible, gotExpand, gotListVisible := resultsContainerState(tt.rowCount)
106+ if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible {
107+ t.Fatalf(
108+ "resultsContainerState(%d) = (%v, %v, %v), want (%v, %v, %v)",
109+ tt.rowCount,
110+ gotVisible,
111+ gotExpand,
112+ gotListVisible,
113+ tt.wantVisible,
114+ tt.wantExpand,
115+ tt.wantListVisible,
116+ )
117+ }
118+ })
119+ }
120+}
121+
90122 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {
91123 entryText := "github.com/bnema/dumber"
92124 realInput := "upl"
@@ -101,25 +133,30 @@ func TestResolveTargetURLForSelection(t *testing.T) {
101133 suggestions := []Suggestion{
102134 {URL: "https://github.com/bnema/dumber"},
103135 {URL: "https://github.com/bnema/dumber/pulls"},
136+ {URL: "https://github.com/bnema/dumber/issues"},
104137 }
105138 favorites := []Favorite{
106139 {URL: "https://dumber.bnema.dev"},
140+ {URL: "https://docs.dumber.bnema.dev"},
107141 }
108142
109143 tests := []struct {
110144 name string
111145 mode ViewMode
112146 index int
147+ limit int
113148 wantURL string
114149 }{
115- {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"},
116- {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"},
117- {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""},
150+ {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"},
151+ {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev"},
152+ {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""},
153+ {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""},
154+ {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""},
118155 }
119156
120157 for _, tt := range tests {
121158 t.Run(tt.name, func(t *testing.T) {
122- got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites)
159+ got := resolveTargetURLForSelection(tt.mode, tt.index, tt.limit, suggestions, favorites)
123160 if got != tt.wantURL {
124161 t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL)
125162 }
@@ -130,26 +167,31 @@ func TestResolveTargetURLForSelection(t *testing.T) {
130167 func TestSelectedTargetURL(t *testing.T) {
131168 suggestions := []Suggestion{
132169 {URL: "https://github.com/bnema/dumber"},
170+ {URL: "https://github.com/bnema/dumber/pulls"},
133171 }
134172 favorites := []Favorite{
135173 {URL: "https://dumber.bnema.dev"},
174+ {URL: "https://docs.dumber.bnema.dev"},
136175 }
137176
138177 tests := []struct {
139178 name string
140179 mode ViewMode
141180 index int
181+ limit int
142182 wantURL string
143183 wantBool bool
144184 }{
145- {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false},
146- {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true},
147- {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true},
185+ {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false},
186+ {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true},
187+ {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev", wantBool: true},
188+ {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true},
189+ {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true},
148190 }
149191
150192 for _, tt := range tests {
151193 t.Run(tt.name, func(t *testing.T) {
152- gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites)
194+ gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, tt.limit, suggestions, favorites)
153195 if gotURL != tt.wantURL || gotBool != tt.wantBool {
154196 t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool)
155197 }
@@ -204,6 +246,7 @@ func TestVisibleGhostSuggestion(t *testing.T) {
204246 tt.selectedURL,
205247 tt.hasExplicitSelection,
206248 ViewModeHistory,
249+ 10,
207250 suggestions,
208251 nil,
209252 )
@@ -223,6 +266,18 @@ func TestVisibleGhostSuggestion(t *testing.T) {
223266 }
224267 }
225268
269+func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) {
270+ suggestions := []Suggestion{
271+ {URL: "https://github.com/bnema/dumber"},
272+ {URL: "https://gitlab.com/team/project"},
273+ }
274+
275+ gotFull, gotSuffix, gotOK := visibleGhostSuggestion("gitl", "", false, ViewModeHistory, 1, suggestions, nil)
276+ if gotFull != "" || gotSuffix != "" || gotOK {
277+ t.Fatalf("visibleGhostSuggestion should ignore hidden suggestions, got (%q, %q, %v)", gotFull, gotSuffix, gotOK)
278+ }
279+}
280+
226281 func TestShouldPromoteHoverSelection(t *testing.T) {
227282 tests := []struct {
228283 name string
@@ -87,6 +87,38 @@ 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+ }{
98+ {name: "negative rows stays hidden", rowCount: -1, wantVisible: false, wantExpand: false, wantListVisible: false},
99+ {name: "no rows stays hidden", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false},
100+ {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true},
101+ }
102+
103+ for _, tt := range tests {
104+ t.Run(tt.name, func(t *testing.T) {
105+ gotVisible, gotExpand, gotListVisible := resultsContainerState(tt.rowCount)
106+ if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible {
107+ t.Fatalf(
108+ "resultsContainerState(%d) = (%v, %v, %v), want (%v, %v, %v)",
109+ tt.rowCount,
110+ gotVisible,
111+ gotExpand,
112+ gotListVisible,
113+ tt.wantVisible,
114+ tt.wantExpand,
115+ tt.wantListVisible,
116+ )
117+ }
118+ })
119+ }
120+}
121+
90 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {122 func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) {
91 entryText := "github.com/bnema/dumber"123 entryText := "github.com/bnema/dumber"
92 realInput := "upl"124 realInput := "upl"
@@ -101,25 +133,30 @@ func TestResolveTargetURLForSelection(t *testing.T) {
101 suggestions := []Suggestion{133 suggestions := []Suggestion{
102 {URL: "https://github.com/bnema/dumber"},134 {URL: "https://github.com/bnema/dumber"},
103 {URL: "https://github.com/bnema/dumber/pulls"},135 {URL: "https://github.com/bnema/dumber/pulls"},
136+ {URL: "https://github.com/bnema/dumber/issues"},
104 }137 }
105 favorites := []Favorite{138 favorites := []Favorite{
106 {URL: "https://dumber.bnema.dev"},139 {URL: "https://dumber.bnema.dev"},
140+ {URL: "https://docs.dumber.bnema.dev"},
107 }141 }
108 142
109 tests := []struct {143 tests := []struct {
110 name string144 name string
111 mode ViewMode145 mode ViewMode
112 index int146 index int
147+ limit int
113 wantURL string148 wantURL string
114 }{149 }{
115- {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"},150+ {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"},
116- {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"},151+ {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev"},
117- {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""},152+ {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""},
153+ {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""},
154+ {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""},
118 }155 }
119 156
120 for _, tt := range tests {157 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {158 t.Run(tt.name, func(t *testing.T) {
122- got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites)159+ got := resolveTargetURLForSelection(tt.mode, tt.index, tt.limit, suggestions, favorites)
123 if got != tt.wantURL {160 if got != tt.wantURL {
124 t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL)161 t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL)
125 }162 }
@@ -130,26 +167,31 @@ func TestResolveTargetURLForSelection(t *testing.T) {
130 func TestSelectedTargetURL(t *testing.T) {167 func TestSelectedTargetURL(t *testing.T) {
131 suggestions := []Suggestion{168 suggestions := []Suggestion{
132 {URL: "https://github.com/bnema/dumber"},169 {URL: "https://github.com/bnema/dumber"},
170+ {URL: "https://github.com/bnema/dumber/pulls"},
133 }171 }
134 favorites := []Favorite{172 favorites := []Favorite{
135 {URL: "https://dumber.bnema.dev"},173 {URL: "https://dumber.bnema.dev"},
174+ {URL: "https://docs.dumber.bnema.dev"},
136 }175 }
137 176
138 tests := []struct {177 tests := []struct {
139 name string178 name string
140 mode ViewMode179 mode ViewMode
141 index int180 index int
181+ limit int
142 wantURL string182 wantURL string
143 wantBool bool183 wantBool bool
144 }{184 }{
145- {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false},185+ {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false},
146- {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true},186+ {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true},
147- {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true},187+ {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev", wantBool: true},
188+ {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true},
189+ {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true},
148 }190 }
149 191
150 for _, tt := range tests {192 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {193 t.Run(tt.name, func(t *testing.T) {
152- gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites)194+ gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, tt.limit, suggestions, favorites)
153 if gotURL != tt.wantURL || gotBool != tt.wantBool {195 if gotURL != tt.wantURL || gotBool != tt.wantBool {
154 t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool)196 t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool)
155 }197 }
@@ -204,6 +246,7 @@ func TestVisibleGhostSuggestion(t *testing.T) {
204 tt.selectedURL,246 tt.selectedURL,
205 tt.hasExplicitSelection,247 tt.hasExplicitSelection,
206 ViewModeHistory,248 ViewModeHistory,
249+ 10,
207 suggestions,250 suggestions,
208 nil,251 nil,
209 )252 )
@@ -223,6 +266,18 @@ func TestVisibleGhostSuggestion(t *testing.T) {
223 }266 }
224 }267 }
225 268
269+func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) {
270+ suggestions := []Suggestion{
271+ {URL: "https://github.com/bnema/dumber"},
272+ {URL: "https://gitlab.com/team/project"},
273+ }
274+
275+ gotFull, gotSuffix, gotOK := visibleGhostSuggestion("gitl", "", false, ViewModeHistory, 1, suggestions, nil)
276+ if gotFull != "" || gotSuffix != "" || gotOK {
277+ t.Fatalf("visibleGhostSuggestion should ignore hidden suggestions, got (%q, %q, %v)", gotFull, gotSuffix, gotOK)
278+ }
279+}
280+
226 func TestShouldPromoteHoverSelection(t *testing.T) {281 func TestShouldPromoteHoverSelection(t *testing.T) {
227 tests := []struct {282 tests := []struct {
228 name string283 name string
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/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 +368 -0
@@ -0,0 +1,368 @@
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+ buildStandaloneOmniboxHostFn = buildStandaloneOmniboxHost
71+ newStandaloneOmniboxWindow = gtk.NewApplicationWindow
72+ configureStandaloneOmniboxWindowFn = configureStandaloneOmniboxWindow
73+ presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) {
74+ window.Present()
75+ }
76+ logStandaloneOmniboxHostAllocationFn = logStandaloneOmniboxHostAllocation
77+ showStandaloneOmniboxFn = func(ctx context.Context, omnibox *component.Omnibox) {
78+ omnibox.Show(ctx, "")
79+ }
80+)
81+
82+type standaloneOmniboxActivationRetention struct {
83+ window *gtk.ApplicationWindow
84+ host *gtk.Widget
85+ omnibox *component.Omnibox
86+}
87+
88+func (r *standaloneOmniboxActivationRetention) retain(
89+ window *gtk.ApplicationWindow,
90+ host *gtk.Widget,
91+ omnibox *component.Omnibox,
92+) {
93+ if r == nil {
94+ return
95+ }
96+ r.window = window
97+ r.host = host
98+ r.omnibox = omnibox
99+}
100+
101+func (r *standaloneOmniboxActivationRetention) release() {
102+ if r == nil {
103+ return
104+ }
105+ r.window = nil
106+ r.host = nil
107+ r.omnibox = nil
108+}
109+
110+func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig {
111+ return StandaloneOmniboxWindowConfig{
112+ Namespace: standaloneOmniboxNamespace,
113+ Title: standaloneOmniboxTitle,
114+ Width: standaloneOmniboxWidth,
115+ Height: standaloneOmniboxHeight,
116+ }
117+}
118+
119+func standaloneOmniboxArgv(args []string) []string {
120+ if len(args) == 0 {
121+ return []string{"dumber-omnibox"}
122+ }
123+
124+ filtered := []string{args[0]}
125+ subcommandSkipped := false
126+ for i := 1; i < len(args); i++ {
127+ arg := args[i]
128+ if arg == "omnibox" && !subcommandSkipped {
129+ subcommandSkipped = true
130+ continue
131+ }
132+ if strings.HasPrefix(arg, "-test.") {
133+ if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) {
134+ i++
135+ }
136+ continue
137+ }
138+ filtered = append(filtered, arg)
139+ }
140+
141+ return filtered
142+}
143+
144+func goTestFlagConsumesNextArg(arg string) bool {
145+ switch arg {
146+ case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile",
147+ "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir",
148+ "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile",
149+ "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction",
150+ "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle",
151+ "-test.skip", "-test.timeout", "-test.trace":
152+ return true
153+ default:
154+ return false
155+ }
156+}
157+
158+func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) {
159+ return func(url string) {
160+ if original != nil {
161+ original(url)
162+ }
163+ if quit != nil {
164+ quit()
165+ }
166+ }
167+}
168+
169+func standaloneOmniboxCloseHandler(quit func()) func() {
170+ return func() {
171+ if quit != nil {
172+ quit()
173+ }
174+ }
175+}
176+
177+func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool {
178+ return !layerShellApplied
179+}
180+
181+func standaloneOmniboxWindowResizable(layerShellApplied bool) bool {
182+ return !layerShellApplied
183+}
184+
185+func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig {
186+ return standaloneOmniboxHostLayoutConfig{
187+ UseDefaultSize: !layerShellApplied,
188+ OmniboxSize: standaloneOmniboxSizeConfig(),
189+ }
190+}
191+
192+func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) {
193+ if host == nil {
194+ return
195+ }
196+
197+ host.SetChild(omniboxWidget)
198+}
199+
200+func standaloneOmniboxSizeConfig() component.ModalSizeConfig {
201+ return component.ResolveModalSizeConfig(component.ModalSizeConfig{
202+ FixedWidth: component.OmniboxSizeDefaults.MaxWidth,
203+ FixedTopMargin: 0,
204+ UseFixedTopMargin: true,
205+ }, component.OmniboxSizeDefaults)
206+}
207+
208+func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool {
209+ if window == nil {
210+ return false
211+ }
212+ if !safeLayerShellSupported() {
213+ return false
214+ }
215+
216+ layerShellInitForWindow(window)
217+ layerShellSetLayer(window, layershell.LayerOverlayValue)
218+ layerShellSetExclusiveZone(window, 0)
219+ layerShellSetNamespace(window, &cfg.Namespace)
220+ layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue)
221+ return true
222+}
223+
224+func safeLayerShellSupported() (supported bool) {
225+ defer func() {
226+ if recover() != nil {
227+ supported = false
228+ }
229+ }()
230+
231+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() {
232+ return layerShellIsSupported()
233+ }
234+
235+ return false
236+}
237+
238+func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
239+ if window == nil {
240+ return
241+ }
242+
243+ title := windowCfg.Title
244+ window.SetTitle(&title)
245+ layerShellApplied := tryInitLayerShell(window, windowCfg)
246+ hostLayout := standaloneOmniboxHostLayout(layerShellApplied)
247+ if hostLayout.UseDefaultSize {
248+ window.SetDefaultSize(windowCfg.Width, windowCfg.Height)
249+ }
250+ if layerShellApplied {
251+ window.AddCssClass(standaloneOmniboxWindowCSS)
252+ }
253+ window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied))
254+ window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied))
255+ window.SetChild(child)
256+}
257+
258+func buildStandaloneOmniboxHost(
259+ ctx context.Context,
260+ runtimeCfg *StandaloneOmniboxRuntime,
261+ gtkApp *gtk.Application,
262+) (*gtk.Widget, *component.Omnibox) {
263+ factory := layout.NewGtkWidgetFactory()
264+ overlay := factory.NewOverlay()
265+ if overlay == nil {
266+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets")
267+ return nil, nil
268+ }
269+
270+ cfg := runtimeCfg.OmniboxCfg
271+ cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit)
272+ cfg.SizeConfig = standaloneOmniboxSizeConfig()
273+
274+ omnibox := component.NewOmnibox(ctx, cfg)
275+ if omnibox == nil {
276+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox")
277+ return nil, nil
278+ }
279+
280+ overlay.SetVisible(true)
281+ omnibox.SetParentOverlay(overlay)
282+ omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit))
283+
284+ omniboxWidget := omnibox.WidgetAsLayout(factory)
285+ if omniboxWidget == nil {
286+ logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget")
287+ return nil, nil
288+ }
289+
290+ attachStandaloneOmniboxHostWidget(overlay, omniboxWidget)
291+ logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child")
292+ return overlay.GtkWidget(), omnibox
293+}
294+
295+func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) {
296+ if host == nil {
297+ return
298+ }
299+
300+ var cb glib.SourceFunc = func(uintptr) bool {
301+ logging.FromContext(ctx).Debug().
302+ Int("width", host.GetAllocatedWidth()).
303+ Int("height", host.GetAllocatedHeight()).
304+ Msg("standalone omnibox host allocation")
305+ return false
306+ }
307+ glib.IdleAdd(&cb, 0)
308+}
309+
310+func activateStandaloneOmnibox(
311+ ctx context.Context,
312+ runtimeCfg *StandaloneOmniboxRuntime,
313+ gtkApp *gtk.Application,
314+ windowCfg StandaloneOmniboxWindowConfig,
315+ retention *standaloneOmniboxActivationRetention,
316+) {
317+ child, omnibox := buildStandaloneOmniboxHostFn(ctx, runtimeCfg, gtkApp)
318+ if child == nil || omnibox == nil {
319+ gtkApp.Quit()
320+ return
321+ }
322+
323+ window := newStandaloneOmniboxWindow(gtkApp)
324+ if window == nil {
325+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window")
326+ gtkApp.Quit()
327+ return
328+ }
329+
330+ retention.retain(window, child, omnibox)
331+ configureStandaloneOmniboxWindowFn(window, windowCfg, child)
332+ if runtimeCfg.ApplyTheme != nil {
333+ if display := window.GetDisplay(); display != nil {
334+ runtimeCfg.ApplyTheme(display)
335+ }
336+ }
337+ presentStandaloneOmniboxWindow(window)
338+ logStandaloneOmniboxHostAllocationFn(ctx, child)
339+ showStandaloneOmniboxFn(ctx, omnibox)
340+}
341+
342+func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int {
343+ if runtimeCfg == nil {
344+ logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured")
345+ return 1
346+ }
347+
348+ adw.Init()
349+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
350+ appID := windowCfg.Namespace
351+ gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
352+ if gtkApp == nil {
353+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application")
354+ return 1
355+ }
356+ defer gtkApp.Unref()
357+ retention := &standaloneOmniboxActivationRetention{}
358+
359+ activateCb := func(_ gio.Application) {
360+ activateStandaloneOmnibox(ctx, runtimeCfg, gtkApp, windowCfg, retention)
361+ }
362+ gtkApp.ConnectActivate(&activateCb)
363+
364+ argv := standaloneOmniboxArgv(os.Args)
365+ code := gtkApp.Run(len(argv), argv)
366+ retention.release()
367+ return code
368+}
@@ -0,0 +1,368 @@
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+ buildStandaloneOmniboxHostFn = buildStandaloneOmniboxHost
71+ newStandaloneOmniboxWindow = gtk.NewApplicationWindow
72+ configureStandaloneOmniboxWindowFn = configureStandaloneOmniboxWindow
73+ presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) {
74+ window.Present()
75+ }
76+ logStandaloneOmniboxHostAllocationFn = logStandaloneOmniboxHostAllocation
77+ showStandaloneOmniboxFn = func(ctx context.Context, omnibox *component.Omnibox) {
78+ omnibox.Show(ctx, "")
79+ }
80+)
81+
82+type standaloneOmniboxActivationRetention struct {
83+ window *gtk.ApplicationWindow
84+ host *gtk.Widget
85+ omnibox *component.Omnibox
86+}
87+
88+func (r *standaloneOmniboxActivationRetention) retain(
89+ window *gtk.ApplicationWindow,
90+ host *gtk.Widget,
91+ omnibox *component.Omnibox,
92+) {
93+ if r == nil {
94+ return
95+ }
96+ r.window = window
97+ r.host = host
98+ r.omnibox = omnibox
99+}
100+
101+func (r *standaloneOmniboxActivationRetention) release() {
102+ if r == nil {
103+ return
104+ }
105+ r.window = nil
106+ r.host = nil
107+ r.omnibox = nil
108+}
109+
110+func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig {
111+ return StandaloneOmniboxWindowConfig{
112+ Namespace: standaloneOmniboxNamespace,
113+ Title: standaloneOmniboxTitle,
114+ Width: standaloneOmniboxWidth,
115+ Height: standaloneOmniboxHeight,
116+ }
117+}
118+
119+func standaloneOmniboxArgv(args []string) []string {
120+ if len(args) == 0 {
121+ return []string{"dumber-omnibox"}
122+ }
123+
124+ filtered := []string{args[0]}
125+ subcommandSkipped := false
126+ for i := 1; i < len(args); i++ {
127+ arg := args[i]
128+ if arg == "omnibox" && !subcommandSkipped {
129+ subcommandSkipped = true
130+ continue
131+ }
132+ if strings.HasPrefix(arg, "-test.") {
133+ if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) {
134+ i++
135+ }
136+ continue
137+ }
138+ filtered = append(filtered, arg)
139+ }
140+
141+ return filtered
142+}
143+
144+func goTestFlagConsumesNextArg(arg string) bool {
145+ switch arg {
146+ case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile",
147+ "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir",
148+ "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile",
149+ "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction",
150+ "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle",
151+ "-test.skip", "-test.timeout", "-test.trace":
152+ return true
153+ default:
154+ return false
155+ }
156+}
157+
158+func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) {
159+ return func(url string) {
160+ if original != nil {
161+ original(url)
162+ }
163+ if quit != nil {
164+ quit()
165+ }
166+ }
167+}
168+
169+func standaloneOmniboxCloseHandler(quit func()) func() {
170+ return func() {
171+ if quit != nil {
172+ quit()
173+ }
174+ }
175+}
176+
177+func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool {
178+ return !layerShellApplied
179+}
180+
181+func standaloneOmniboxWindowResizable(layerShellApplied bool) bool {
182+ return !layerShellApplied
183+}
184+
185+func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig {
186+ return standaloneOmniboxHostLayoutConfig{
187+ UseDefaultSize: !layerShellApplied,
188+ OmniboxSize: standaloneOmniboxSizeConfig(),
189+ }
190+}
191+
192+func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) {
193+ if host == nil {
194+ return
195+ }
196+
197+ host.SetChild(omniboxWidget)
198+}
199+
200+func standaloneOmniboxSizeConfig() component.ModalSizeConfig {
201+ return component.ResolveModalSizeConfig(component.ModalSizeConfig{
202+ FixedWidth: component.OmniboxSizeDefaults.MaxWidth,
203+ FixedTopMargin: 0,
204+ UseFixedTopMargin: true,
205+ }, component.OmniboxSizeDefaults)
206+}
207+
208+func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool {
209+ if window == nil {
210+ return false
211+ }
212+ if !safeLayerShellSupported() {
213+ return false
214+ }
215+
216+ layerShellInitForWindow(window)
217+ layerShellSetLayer(window, layershell.LayerOverlayValue)
218+ layerShellSetExclusiveZone(window, 0)
219+ layerShellSetNamespace(window, &cfg.Namespace)
220+ layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue)
221+ return true
222+}
223+
224+func safeLayerShellSupported() (supported bool) {
225+ defer func() {
226+ if recover() != nil {
227+ supported = false
228+ }
229+ }()
230+
231+ if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() {
232+ return layerShellIsSupported()
233+ }
234+
235+ return false
236+}
237+
238+func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
239+ if window == nil {
240+ return
241+ }
242+
243+ title := windowCfg.Title
244+ window.SetTitle(&title)
245+ layerShellApplied := tryInitLayerShell(window, windowCfg)
246+ hostLayout := standaloneOmniboxHostLayout(layerShellApplied)
247+ if hostLayout.UseDefaultSize {
248+ window.SetDefaultSize(windowCfg.Width, windowCfg.Height)
249+ }
250+ if layerShellApplied {
251+ window.AddCssClass(standaloneOmniboxWindowCSS)
252+ }
253+ window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied))
254+ window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied))
255+ window.SetChild(child)
256+}
257+
258+func buildStandaloneOmniboxHost(
259+ ctx context.Context,
260+ runtimeCfg *StandaloneOmniboxRuntime,
261+ gtkApp *gtk.Application,
262+) (*gtk.Widget, *component.Omnibox) {
263+ factory := layout.NewGtkWidgetFactory()
264+ overlay := factory.NewOverlay()
265+ if overlay == nil {
266+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets")
267+ return nil, nil
268+ }
269+
270+ cfg := runtimeCfg.OmniboxCfg
271+ cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit)
272+ cfg.SizeConfig = standaloneOmniboxSizeConfig()
273+
274+ omnibox := component.NewOmnibox(ctx, cfg)
275+ if omnibox == nil {
276+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox")
277+ return nil, nil
278+ }
279+
280+ overlay.SetVisible(true)
281+ omnibox.SetParentOverlay(overlay)
282+ omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit))
283+
284+ omniboxWidget := omnibox.WidgetAsLayout(factory)
285+ if omniboxWidget == nil {
286+ logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget")
287+ return nil, nil
288+ }
289+
290+ attachStandaloneOmniboxHostWidget(overlay, omniboxWidget)
291+ logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child")
292+ return overlay.GtkWidget(), omnibox
293+}
294+
295+func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) {
296+ if host == nil {
297+ return
298+ }
299+
300+ var cb glib.SourceFunc = func(uintptr) bool {
301+ logging.FromContext(ctx).Debug().
302+ Int("width", host.GetAllocatedWidth()).
303+ Int("height", host.GetAllocatedHeight()).
304+ Msg("standalone omnibox host allocation")
305+ return false
306+ }
307+ glib.IdleAdd(&cb, 0)
308+}
309+
310+func activateStandaloneOmnibox(
311+ ctx context.Context,
312+ runtimeCfg *StandaloneOmniboxRuntime,
313+ gtkApp *gtk.Application,
314+ windowCfg StandaloneOmniboxWindowConfig,
315+ retention *standaloneOmniboxActivationRetention,
316+) {
317+ child, omnibox := buildStandaloneOmniboxHostFn(ctx, runtimeCfg, gtkApp)
318+ if child == nil || omnibox == nil {
319+ gtkApp.Quit()
320+ return
321+ }
322+
323+ window := newStandaloneOmniboxWindow(gtkApp)
324+ if window == nil {
325+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window")
326+ gtkApp.Quit()
327+ return
328+ }
329+
330+ retention.retain(window, child, omnibox)
331+ configureStandaloneOmniboxWindowFn(window, windowCfg, child)
332+ if runtimeCfg.ApplyTheme != nil {
333+ if display := window.GetDisplay(); display != nil {
334+ runtimeCfg.ApplyTheme(display)
335+ }
336+ }
337+ presentStandaloneOmniboxWindow(window)
338+ logStandaloneOmniboxHostAllocationFn(ctx, child)
339+ showStandaloneOmniboxFn(ctx, omnibox)
340+}
341+
342+func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int {
343+ if runtimeCfg == nil {
344+ logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured")
345+ return 1
346+ }
347+
348+ adw.Init()
349+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
350+ appID := windowCfg.Namespace
351+ gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
352+ if gtkApp == nil {
353+ logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application")
354+ return 1
355+ }
356+ defer gtkApp.Unref()
357+ retention := &standaloneOmniboxActivationRetention{}
358+
359+ activateCb := func(_ gio.Application) {
360+ activateStandaloneOmnibox(ctx, runtimeCfg, gtkApp, windowCfg, retention)
361+ }
362+ gtkApp.ConnectActivate(&activateCb)
363+
364+ argv := standaloneOmniboxArgv(os.Args)
365+ code := gtkApp.Run(len(argv), argv)
366+ retention.release()
367+ return code
368+}
A internal/ui/standalone_omnibox_test.go +389 -0
@@ -0,0 +1,389 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/ui/component"
8+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
9+ "github.com/bnema/puregotk/v4/gtk"
10+ "github.com/bnema/puregotk/v4/layershell"
11+)
12+
13+func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) {
14+ var calls []string
15+ wrapped := wrapStandaloneOmniboxNavigate(func(url string) {
16+ calls = append(calls, "navigate:"+url)
17+ }, func() {
18+ calls = append(calls, "quit")
19+ })
20+
21+ wrapped("https://example.com")
22+
23+ if len(calls) != 2 {
24+ t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls)
25+ }
26+ if calls[0] != "navigate:https://example.com" {
27+ t.Fatalf("expected navigate call first, got %#v", calls)
28+ }
29+ if calls[1] != "quit" {
30+ t.Fatalf("expected quit call second, got %#v", calls)
31+ }
32+}
33+
34+func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) {
35+ called := false
36+ wrapped := wrapStandaloneOmniboxNavigate(nil, func() {
37+ called = true
38+ })
39+
40+ wrapped("https://example.com")
41+
42+ if !called {
43+ t.Fatalf("expected quit to be called")
44+ }
45+}
46+
47+func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) {
48+ called := false
49+ onClose := standaloneOmniboxCloseHandler(func() {
50+ called = true
51+ })
52+
53+ onClose()
54+
55+ if !called {
56+ t.Fatalf("expected close handler to quit app")
57+ }
58+}
59+
60+func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) {
61+ if standaloneOmniboxWindowDecorated(true) {
62+ t.Fatalf("expected layer-shell window to be undecorated")
63+ }
64+}
65+
66+func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) {
67+ if !standaloneOmniboxWindowDecorated(false) {
68+ t.Fatalf("expected fallback window to remain decorated")
69+ }
70+}
71+
72+func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) {
73+ if standaloneOmniboxWindowResizable(true) {
74+ t.Fatalf("expected layer-shell window to stop keeping stale height allocations")
75+ }
76+}
77+
78+func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) {
79+ if !standaloneOmniboxWindowResizable(false) {
80+ t.Fatalf("expected fallback window to remain resizable")
81+ }
82+}
83+
84+func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) {
85+ layoutCfg := standaloneOmniboxHostLayout(true)
86+
87+ if layoutCfg.UseDefaultSize {
88+ t.Fatalf("expected layer-shell host to avoid fixed default size")
89+ }
90+ if layoutCfg.OmniboxSize.FixedWidth != 800 {
91+ t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth)
92+ }
93+ if !layoutCfg.OmniboxSize.UseFixedTopMargin {
94+ t.Fatalf("expected standalone omnibox to use a fixed top margin")
95+ }
96+ if layoutCfg.OmniboxSize.FixedTopMargin != 0 {
97+ t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin)
98+ }
99+}
100+
101+func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) {
102+ layoutCfg := standaloneOmniboxHostLayout(false)
103+
104+ if !layoutCfg.UseDefaultSize {
105+ t.Fatalf("expected non-layer-shell host to keep fixed fallback size")
106+ }
107+ if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() {
108+ t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize)
109+ }
110+}
111+
112+func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) {
113+ host := layoutmocks.NewMockOverlayWidget(t)
114+ child := layoutmocks.NewMockWidget(t)
115+
116+ host.EXPECT().SetChild(child).Once()
117+
118+ attachStandaloneOmniboxHostWidget(host, child)
119+}
120+
121+func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) {
122+ cfg := standaloneOmniboxSizeConfig()
123+
124+ if cfg != (component.ModalSizeConfig{
125+ WidthPct: component.OmniboxSizeDefaults.WidthPct,
126+ MaxWidth: component.OmniboxSizeDefaults.MaxWidth,
127+ TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct,
128+ FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth,
129+ FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight,
130+ FixedWidth: 800,
131+ FixedTopMargin: 0,
132+ UseFixedTopMargin: true,
133+ }) {
134+ t.Fatalf("unexpected standalone omnibox size config: %#v", cfg)
135+ }
136+}
137+
138+func TestStandaloneWindowConfig_Defaults(t *testing.T) {
139+ cfg := DefaultStandaloneOmniboxWindowConfig()
140+ if cfg.Namespace == "" {
141+ t.Fatalf("expected namespace")
142+ }
143+ if cfg.Title == "" {
144+ t.Fatalf("expected title")
145+ }
146+}
147+
148+func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) {
149+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"})
150+
151+ if len(argv) != 2 {
152+ t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv)
153+ }
154+ if argv[0] != "/tmp/testbin" {
155+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
156+ }
157+ if argv[1] != "--gtk-debug" {
158+ t.Fatalf("expected non-test arg to be preserved, got %#v", argv)
159+ }
160+}
161+
162+func TestStandaloneOmniboxArgv_RemovesOnlyLeadingOmniboxSubcommand(t *testing.T) {
163+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "omnibox", "https://example.com/omnibox", "omnibox"})
164+
165+ if len(argv) != 3 {
166+ t.Fatalf("expected 3 args, got %d: %#v", len(argv), argv)
167+ }
168+ if argv[0] != "/tmp/testbin" {
169+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
170+ }
171+ if argv[1] != "https://example.com/omnibox" {
172+ t.Fatalf("expected non-subcommand omnibox value to be preserved, got %#v", argv)
173+ }
174+ if argv[2] != "omnibox" {
175+ t.Fatalf("expected later omnibox arg to be preserved, got %#v", argv)
176+ }
177+}
178+
179+func TestActivateStandaloneOmnibox_RetainsObjectsUntilReleased(t *testing.T) {
180+ originalBuildHost := buildStandaloneOmniboxHostFn
181+ originalNewWindow := newStandaloneOmniboxWindow
182+ originalConfigureWindow := configureStandaloneOmniboxWindowFn
183+ originalPresentWindow := presentStandaloneOmniboxWindow
184+ originalLogHostAllocation := logStandaloneOmniboxHostAllocationFn
185+ originalShowOmnibox := showStandaloneOmniboxFn
186+ t.Cleanup(func() {
187+ buildStandaloneOmniboxHostFn = originalBuildHost
188+ newStandaloneOmniboxWindow = originalNewWindow
189+ configureStandaloneOmniboxWindowFn = originalConfigureWindow
190+ presentStandaloneOmniboxWindow = originalPresentWindow
191+ logStandaloneOmniboxHostAllocationFn = originalLogHostAllocation
192+ showStandaloneOmniboxFn = originalShowOmnibox
193+ })
194+
195+ expectedHost := &gtk.Widget{}
196+ expectedOmnibox := &component.Omnibox{}
197+ expectedWindow := &gtk.ApplicationWindow{}
198+ refs := &standaloneOmniboxActivationRetention{}
199+ runtimeCfg := &StandaloneOmniboxRuntime{}
200+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
201+
202+ buildStandaloneOmniboxHostFn = func(_ context.Context, _ *StandaloneOmniboxRuntime, _ *gtk.Application) (*gtk.Widget, *component.Omnibox) {
203+ return expectedHost, expectedOmnibox
204+ }
205+ newStandaloneOmniboxWindow = func(_ *gtk.Application) *gtk.ApplicationWindow {
206+ return expectedWindow
207+ }
208+ configureStandaloneOmniboxWindowFn = func(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
209+ if window != expectedWindow || child != expectedHost || cfg != windowCfg {
210+ t.Fatalf("unexpected configure arguments: window=%p child=%p cfg=%#v", window, child, cfg)
211+ }
212+ }
213+ presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) {
214+ if window != expectedWindow {
215+ t.Fatalf("unexpected window presented: %p", window)
216+ }
217+ }
218+ logStandaloneOmniboxHostAllocationFn = func(_ context.Context, host *gtk.Widget) {
219+ if host != expectedHost {
220+ t.Fatalf("unexpected host logged: %p", host)
221+ }
222+ }
223+ showStandaloneOmniboxFn = func(_ context.Context, omnibox *component.Omnibox) {
224+ if omnibox != expectedOmnibox {
225+ t.Fatalf("unexpected omnibox shown: %p", omnibox)
226+ }
227+ if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox {
228+ t.Fatalf("expected standalone activation refs to retain GTK objects during show, got %#v", refs)
229+ }
230+ }
231+
232+ activateStandaloneOmnibox(context.Background(), runtimeCfg, nil, windowCfg, refs)
233+
234+ if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox {
235+ t.Fatalf("expected standalone activation refs to retain GTK objects, got %#v", refs)
236+ }
237+
238+ refs.release()
239+
240+ if refs.window != nil || refs.host != nil || refs.omnibox != nil {
241+ t.Fatalf("expected standalone activation refs to release GTK objects, got %#v", refs)
242+ }
243+}
244+
245+func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) {
246+ if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) {
247+ t.Fatalf("expected nil window to skip layer-shell")
248+ }
249+}
250+
251+func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) {
252+ originalLibraryAvailable := layerShellLibraryAvailable
253+ original := layerShellIsSupported
254+ layerShellLibraryAvailable = func() bool { return true }
255+ layerShellIsSupported = func() bool { return false }
256+ t.Cleanup(func() {
257+ layerShellLibraryAvailable = originalLibraryAvailable
258+ layerShellIsSupported = original
259+ })
260+
261+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
262+ t.Fatalf("expected unsupported layer-shell to skip initialization")
263+ }
264+}
265+
266+func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) {
267+ originalLibraryAvailable := layerShellLibraryAvailable
268+ originalIsSupported := layerShellIsSupported
269+ layerShellLibraryAvailable = func() bool { return false }
270+ layerShellIsSupported = func() bool {
271+ t.Fatalf("expected layer-shell support check to be skipped when library is missing")
272+ return true
273+ }
274+ t.Cleanup(func() {
275+ layerShellLibraryAvailable = originalLibraryAvailable
276+ layerShellIsSupported = originalIsSupported
277+ })
278+
279+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
280+ t.Fatalf("expected missing library to skip layer-shell initialization")
281+ }
282+}
283+
284+func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) {
285+ originalLibraryAvailable := layerShellLibraryAvailable
286+ originalIsSupported := layerShellIsSupported
287+ t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0")
288+ layerShellLibraryAvailable = func() bool {
289+ t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded")
290+ return false
291+ }
292+ layerShellIsSupported = func() bool { return true }
293+ t.Cleanup(func() {
294+ layerShellLibraryAvailable = originalLibraryAvailable
295+ layerShellIsSupported = originalIsSupported
296+ })
297+
298+ if !safeLayerShellSupported() {
299+ t.Fatalf("expected preloaded layer-shell library to be treated as supported")
300+ }
301+}
302+
303+func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) {
304+ originalLibraryAvailable := layerShellLibraryAvailable
305+ originalIsSupported := layerShellIsSupported
306+ originalInitForWindow := layerShellInitForWindow
307+ originalSetLayer := layerShellSetLayer
308+ originalSetExclusiveZone := layerShellSetExclusiveZone
309+ originalSetNamespace := layerShellSetNamespace
310+ originalSetKeyboardMode := layerShellSetKeyboardMode
311+ t.Cleanup(func() {
312+ layerShellLibraryAvailable = originalLibraryAvailable
313+ layerShellIsSupported = originalIsSupported
314+ layerShellInitForWindow = originalInitForWindow
315+ layerShellSetLayer = originalSetLayer
316+ layerShellSetExclusiveZone = originalSetExclusiveZone
317+ layerShellSetNamespace = originalSetNamespace
318+ layerShellSetKeyboardMode = originalSetKeyboardMode
319+ })
320+
321+ window := &gtk.ApplicationWindow{}
322+ cfg := DefaultStandaloneOmniboxWindowConfig()
323+ var callOrder []string
324+
325+ layerShellLibraryAvailable = func() bool {
326+ callOrder = append(callOrder, "library")
327+ return true
328+ }
329+ layerShellIsSupported = func() bool {
330+ callOrder = append(callOrder, "supported")
331+ return true
332+ }
333+ layerShellInitForWindow = func(got *gtk.ApplicationWindow) {
334+ if got != window {
335+ t.Fatalf("expected init window %p, got %p", window, got)
336+ }
337+ callOrder = append(callOrder, "init")
338+ }
339+ layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) {
340+ if got != window {
341+ t.Fatalf("expected layer window %p, got %p", window, got)
342+ }
343+ if layer != layershell.LayerOverlayValue {
344+ t.Fatalf("expected overlay layer, got %v", layer)
345+ }
346+ callOrder = append(callOrder, "layer")
347+ }
348+ layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) {
349+ if got != window {
350+ t.Fatalf("expected exclusive zone window %p, got %p", window, got)
351+ }
352+ if zone != 0 {
353+ t.Fatalf("expected exclusive zone 0, got %d", zone)
354+ }
355+ callOrder = append(callOrder, "zone")
356+ }
357+ layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) {
358+ if got != window {
359+ t.Fatalf("expected namespace window %p, got %p", window, got)
360+ }
361+ if namespace == nil || *namespace != cfg.Namespace {
362+ t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace)
363+ }
364+ callOrder = append(callOrder, "namespace")
365+ }
366+ layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
367+ if got != window {
368+ t.Fatalf("expected keyboard mode window %p, got %p", window, got)
369+ }
370+ if mode != layershell.KeyboardModeExclusiveValue {
371+ t.Fatalf("expected exclusive keyboard mode, got %v", mode)
372+ }
373+ callOrder = append(callOrder, "keyboard")
374+ }
375+
376+ if !tryInitLayerShell(window, cfg) {
377+ t.Fatalf("expected supported layer-shell to initialize")
378+ }
379+
380+ wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"}
381+ if len(callOrder) != len(wantOrder) {
382+ t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder)
383+ }
384+ for i := range wantOrder {
385+ if callOrder[i] != wantOrder[i] {
386+ t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i])
387+ }
388+ }
389+}
@@ -0,0 +1,389 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/ui/component"
8+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
9+ "github.com/bnema/puregotk/v4/gtk"
10+ "github.com/bnema/puregotk/v4/layershell"
11+)
12+
13+func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) {
14+ var calls []string
15+ wrapped := wrapStandaloneOmniboxNavigate(func(url string) {
16+ calls = append(calls, "navigate:"+url)
17+ }, func() {
18+ calls = append(calls, "quit")
19+ })
20+
21+ wrapped("https://example.com")
22+
23+ if len(calls) != 2 {
24+ t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls)
25+ }
26+ if calls[0] != "navigate:https://example.com" {
27+ t.Fatalf("expected navigate call first, got %#v", calls)
28+ }
29+ if calls[1] != "quit" {
30+ t.Fatalf("expected quit call second, got %#v", calls)
31+ }
32+}
33+
34+func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) {
35+ called := false
36+ wrapped := wrapStandaloneOmniboxNavigate(nil, func() {
37+ called = true
38+ })
39+
40+ wrapped("https://example.com")
41+
42+ if !called {
43+ t.Fatalf("expected quit to be called")
44+ }
45+}
46+
47+func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) {
48+ called := false
49+ onClose := standaloneOmniboxCloseHandler(func() {
50+ called = true
51+ })
52+
53+ onClose()
54+
55+ if !called {
56+ t.Fatalf("expected close handler to quit app")
57+ }
58+}
59+
60+func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) {
61+ if standaloneOmniboxWindowDecorated(true) {
62+ t.Fatalf("expected layer-shell window to be undecorated")
63+ }
64+}
65+
66+func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) {
67+ if !standaloneOmniboxWindowDecorated(false) {
68+ t.Fatalf("expected fallback window to remain decorated")
69+ }
70+}
71+
72+func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) {
73+ if standaloneOmniboxWindowResizable(true) {
74+ t.Fatalf("expected layer-shell window to stop keeping stale height allocations")
75+ }
76+}
77+
78+func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) {
79+ if !standaloneOmniboxWindowResizable(false) {
80+ t.Fatalf("expected fallback window to remain resizable")
81+ }
82+}
83+
84+func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) {
85+ layoutCfg := standaloneOmniboxHostLayout(true)
86+
87+ if layoutCfg.UseDefaultSize {
88+ t.Fatalf("expected layer-shell host to avoid fixed default size")
89+ }
90+ if layoutCfg.OmniboxSize.FixedWidth != 800 {
91+ t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth)
92+ }
93+ if !layoutCfg.OmniboxSize.UseFixedTopMargin {
94+ t.Fatalf("expected standalone omnibox to use a fixed top margin")
95+ }
96+ if layoutCfg.OmniboxSize.FixedTopMargin != 0 {
97+ t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin)
98+ }
99+}
100+
101+func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) {
102+ layoutCfg := standaloneOmniboxHostLayout(false)
103+
104+ if !layoutCfg.UseDefaultSize {
105+ t.Fatalf("expected non-layer-shell host to keep fixed fallback size")
106+ }
107+ if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() {
108+ t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize)
109+ }
110+}
111+
112+func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) {
113+ host := layoutmocks.NewMockOverlayWidget(t)
114+ child := layoutmocks.NewMockWidget(t)
115+
116+ host.EXPECT().SetChild(child).Once()
117+
118+ attachStandaloneOmniboxHostWidget(host, child)
119+}
120+
121+func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) {
122+ cfg := standaloneOmniboxSizeConfig()
123+
124+ if cfg != (component.ModalSizeConfig{
125+ WidthPct: component.OmniboxSizeDefaults.WidthPct,
126+ MaxWidth: component.OmniboxSizeDefaults.MaxWidth,
127+ TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct,
128+ FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth,
129+ FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight,
130+ FixedWidth: 800,
131+ FixedTopMargin: 0,
132+ UseFixedTopMargin: true,
133+ }) {
134+ t.Fatalf("unexpected standalone omnibox size config: %#v", cfg)
135+ }
136+}
137+
138+func TestStandaloneWindowConfig_Defaults(t *testing.T) {
139+ cfg := DefaultStandaloneOmniboxWindowConfig()
140+ if cfg.Namespace == "" {
141+ t.Fatalf("expected namespace")
142+ }
143+ if cfg.Title == "" {
144+ t.Fatalf("expected title")
145+ }
146+}
147+
148+func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) {
149+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"})
150+
151+ if len(argv) != 2 {
152+ t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv)
153+ }
154+ if argv[0] != "/tmp/testbin" {
155+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
156+ }
157+ if argv[1] != "--gtk-debug" {
158+ t.Fatalf("expected non-test arg to be preserved, got %#v", argv)
159+ }
160+}
161+
162+func TestStandaloneOmniboxArgv_RemovesOnlyLeadingOmniboxSubcommand(t *testing.T) {
163+ argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "omnibox", "https://example.com/omnibox", "omnibox"})
164+
165+ if len(argv) != 3 {
166+ t.Fatalf("expected 3 args, got %d: %#v", len(argv), argv)
167+ }
168+ if argv[0] != "/tmp/testbin" {
169+ t.Fatalf("expected binary path to be preserved, got %q", argv[0])
170+ }
171+ if argv[1] != "https://example.com/omnibox" {
172+ t.Fatalf("expected non-subcommand omnibox value to be preserved, got %#v", argv)
173+ }
174+ if argv[2] != "omnibox" {
175+ t.Fatalf("expected later omnibox arg to be preserved, got %#v", argv)
176+ }
177+}
178+
179+func TestActivateStandaloneOmnibox_RetainsObjectsUntilReleased(t *testing.T) {
180+ originalBuildHost := buildStandaloneOmniboxHostFn
181+ originalNewWindow := newStandaloneOmniboxWindow
182+ originalConfigureWindow := configureStandaloneOmniboxWindowFn
183+ originalPresentWindow := presentStandaloneOmniboxWindow
184+ originalLogHostAllocation := logStandaloneOmniboxHostAllocationFn
185+ originalShowOmnibox := showStandaloneOmniboxFn
186+ t.Cleanup(func() {
187+ buildStandaloneOmniboxHostFn = originalBuildHost
188+ newStandaloneOmniboxWindow = originalNewWindow
189+ configureStandaloneOmniboxWindowFn = originalConfigureWindow
190+ presentStandaloneOmniboxWindow = originalPresentWindow
191+ logStandaloneOmniboxHostAllocationFn = originalLogHostAllocation
192+ showStandaloneOmniboxFn = originalShowOmnibox
193+ })
194+
195+ expectedHost := &gtk.Widget{}
196+ expectedOmnibox := &component.Omnibox{}
197+ expectedWindow := &gtk.ApplicationWindow{}
198+ refs := &standaloneOmniboxActivationRetention{}
199+ runtimeCfg := &StandaloneOmniboxRuntime{}
200+ windowCfg := DefaultStandaloneOmniboxWindowConfig()
201+
202+ buildStandaloneOmniboxHostFn = func(_ context.Context, _ *StandaloneOmniboxRuntime, _ *gtk.Application) (*gtk.Widget, *component.Omnibox) {
203+ return expectedHost, expectedOmnibox
204+ }
205+ newStandaloneOmniboxWindow = func(_ *gtk.Application) *gtk.ApplicationWindow {
206+ return expectedWindow
207+ }
208+ configureStandaloneOmniboxWindowFn = func(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
209+ if window != expectedWindow || child != expectedHost || cfg != windowCfg {
210+ t.Fatalf("unexpected configure arguments: window=%p child=%p cfg=%#v", window, child, cfg)
211+ }
212+ }
213+ presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) {
214+ if window != expectedWindow {
215+ t.Fatalf("unexpected window presented: %p", window)
216+ }
217+ }
218+ logStandaloneOmniboxHostAllocationFn = func(_ context.Context, host *gtk.Widget) {
219+ if host != expectedHost {
220+ t.Fatalf("unexpected host logged: %p", host)
221+ }
222+ }
223+ showStandaloneOmniboxFn = func(_ context.Context, omnibox *component.Omnibox) {
224+ if omnibox != expectedOmnibox {
225+ t.Fatalf("unexpected omnibox shown: %p", omnibox)
226+ }
227+ if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox {
228+ t.Fatalf("expected standalone activation refs to retain GTK objects during show, got %#v", refs)
229+ }
230+ }
231+
232+ activateStandaloneOmnibox(context.Background(), runtimeCfg, nil, windowCfg, refs)
233+
234+ if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox {
235+ t.Fatalf("expected standalone activation refs to retain GTK objects, got %#v", refs)
236+ }
237+
238+ refs.release()
239+
240+ if refs.window != nil || refs.host != nil || refs.omnibox != nil {
241+ t.Fatalf("expected standalone activation refs to release GTK objects, got %#v", refs)
242+ }
243+}
244+
245+func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) {
246+ if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) {
247+ t.Fatalf("expected nil window to skip layer-shell")
248+ }
249+}
250+
251+func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) {
252+ originalLibraryAvailable := layerShellLibraryAvailable
253+ original := layerShellIsSupported
254+ layerShellLibraryAvailable = func() bool { return true }
255+ layerShellIsSupported = func() bool { return false }
256+ t.Cleanup(func() {
257+ layerShellLibraryAvailable = originalLibraryAvailable
258+ layerShellIsSupported = original
259+ })
260+
261+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
262+ t.Fatalf("expected unsupported layer-shell to skip initialization")
263+ }
264+}
265+
266+func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) {
267+ originalLibraryAvailable := layerShellLibraryAvailable
268+ originalIsSupported := layerShellIsSupported
269+ layerShellLibraryAvailable = func() bool { return false }
270+ layerShellIsSupported = func() bool {
271+ t.Fatalf("expected layer-shell support check to be skipped when library is missing")
272+ return true
273+ }
274+ t.Cleanup(func() {
275+ layerShellLibraryAvailable = originalLibraryAvailable
276+ layerShellIsSupported = originalIsSupported
277+ })
278+
279+ if tryInitLayerShell(&gtk.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) {
280+ t.Fatalf("expected missing library to skip layer-shell initialization")
281+ }
282+}
283+
284+func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) {
285+ originalLibraryAvailable := layerShellLibraryAvailable
286+ originalIsSupported := layerShellIsSupported
287+ t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0")
288+ layerShellLibraryAvailable = func() bool {
289+ t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded")
290+ return false
291+ }
292+ layerShellIsSupported = func() bool { return true }
293+ t.Cleanup(func() {
294+ layerShellLibraryAvailable = originalLibraryAvailable
295+ layerShellIsSupported = originalIsSupported
296+ })
297+
298+ if !safeLayerShellSupported() {
299+ t.Fatalf("expected preloaded layer-shell library to be treated as supported")
300+ }
301+}
302+
303+func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) {
304+ originalLibraryAvailable := layerShellLibraryAvailable
305+ originalIsSupported := layerShellIsSupported
306+ originalInitForWindow := layerShellInitForWindow
307+ originalSetLayer := layerShellSetLayer
308+ originalSetExclusiveZone := layerShellSetExclusiveZone
309+ originalSetNamespace := layerShellSetNamespace
310+ originalSetKeyboardMode := layerShellSetKeyboardMode
311+ t.Cleanup(func() {
312+ layerShellLibraryAvailable = originalLibraryAvailable
313+ layerShellIsSupported = originalIsSupported
314+ layerShellInitForWindow = originalInitForWindow
315+ layerShellSetLayer = originalSetLayer
316+ layerShellSetExclusiveZone = originalSetExclusiveZone
317+ layerShellSetNamespace = originalSetNamespace
318+ layerShellSetKeyboardMode = originalSetKeyboardMode
319+ })
320+
321+ window := &gtk.ApplicationWindow{}
322+ cfg := DefaultStandaloneOmniboxWindowConfig()
323+ var callOrder []string
324+
325+ layerShellLibraryAvailable = func() bool {
326+ callOrder = append(callOrder, "library")
327+ return true
328+ }
329+ layerShellIsSupported = func() bool {
330+ callOrder = append(callOrder, "supported")
331+ return true
332+ }
333+ layerShellInitForWindow = func(got *gtk.ApplicationWindow) {
334+ if got != window {
335+ t.Fatalf("expected init window %p, got %p", window, got)
336+ }
337+ callOrder = append(callOrder, "init")
338+ }
339+ layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) {
340+ if got != window {
341+ t.Fatalf("expected layer window %p, got %p", window, got)
342+ }
343+ if layer != layershell.LayerOverlayValue {
344+ t.Fatalf("expected overlay layer, got %v", layer)
345+ }
346+ callOrder = append(callOrder, "layer")
347+ }
348+ layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) {
349+ if got != window {
350+ t.Fatalf("expected exclusive zone window %p, got %p", window, got)
351+ }
352+ if zone != 0 {
353+ t.Fatalf("expected exclusive zone 0, got %d", zone)
354+ }
355+ callOrder = append(callOrder, "zone")
356+ }
357+ layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) {
358+ if got != window {
359+ t.Fatalf("expected namespace window %p, got %p", window, got)
360+ }
361+ if namespace == nil || *namespace != cfg.Namespace {
362+ t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace)
363+ }
364+ callOrder = append(callOrder, "namespace")
365+ }
366+ layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
367+ if got != window {
368+ t.Fatalf("expected keyboard mode window %p, got %p", window, got)
369+ }
370+ if mode != layershell.KeyboardModeExclusiveValue {
371+ t.Fatalf("expected exclusive keyboard mode, got %v", mode)
372+ }
373+ callOrder = append(callOrder, "keyboard")
374+ }
375+
376+ if !tryInitLayerShell(window, cfg) {
377+ t.Fatalf("expected supported layer-shell to initialize")
378+ }
379+
380+ wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"}
381+ if len(callOrder) != len(wantOrder) {
382+ t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder)
383+ }
384+ for i := range wantOrder {
385+ if callOrder[i] != wantOrder[i] {
386+ t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i])
387+ }
388+ }
389+}
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 +67 -53
@@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string {
1212 }
1313
1414 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
15-// Scale affects font sizes and widget padding/margins proportionally.
15+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
1616 func GenerateCSSWithScale(p Palette, scale float64) string {
1717 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
1818 }
@@ -49,19 +49,15 @@ window, tooltip, popover {
4949 }
5050
5151 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52-// Scale affects font sizes and widget padding/margins proportionally.
52+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
5353 // Uses default mode colors.
5454 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
5555 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
5656 }
5757
5858 // GenerateCSSFull creates GTK4 CSS using all provided configuration.
59-// Scale affects font sizes and widget padding/margins proportionally.
60-func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {
61- if scale <= 0 {
62- scale = 1.0
63- }
64-
59+// Text scaling is handled via GtkSettings; CSS here stays scale-independent.
60+func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string {
6561 defaults := DefaultFontConfig()
6662 fonts = FontConfig{
6763 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
@@ -82,12 +78,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
8278 sb.WriteString(generateFontCSS())
8379 sb.WriteString("\n")
8480
85- // Global UI scaling via font-size on all widgets
86- if scale != 1.0 {
87- sb.WriteString(generateScalingCSS(scale))
88- sb.WriteString("\n")
89- }
90-
9181 // Tab bar styling
9282 sb.WriteString(generateTabBarCSS(p))
9383 sb.WriteString("\n")
@@ -146,20 +136,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
146136 return sb.String()
147137 }
148138
149-// generateScalingCSS creates CSS rules that scale the UI.
150-// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
151-// and use relative units (em) for padding/margins in other rules.
152-func generateScalingCSS(scale float64) string {
153- // Use 16px base so em conversions are correct:
154- // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
155- basePx := int(16 * scale)
156- return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
157-window {
158- font-size: %dpx;
159-}
160-`, scale*100, basePx)
161-}
162-
163139 // generateTabBarCSS creates tab bar styles.
164140 // Uses em units for scalable UI.
165141 func generateTabBarCSS(p Palette) string {
@@ -206,6 +182,10 @@ button.tab-button.tab-button-active {
206182 func generateOmniboxCSS(p Palette) string {
207183 return `/* ===== Omnibox Styling ===== */
208184
185+window.standalone-omnibox-window {
186+ background-color: transparent;
187+}
188+
209189 /* Omnibox outer container - for positioning in overlay */
210190 .omnibox-outer {
211191 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
@@ -213,23 +193,26 @@ func generateOmniboxCSS(p Palette) string {
213193
214194 /* Omnibox main container - the visible popup */
215195 .omnibox-container {
216- background-color: var(--surface-variant);
217- border: 0.0625em solid var(--border);
196+ background-color: var(--surface);
197+ background-image: none;
198+ border: 0.0625em solid alpha(var(--border), 0.85);
218199 border-radius: 0.1875em;
200+ box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset;
219201 padding: 0;
220202 }
221203
222204 /* Header with History/Favorites toggle */
223205 .omnibox-header {
224- background-color: shade(var(--surface-variant), 1.1);
225- border-bottom: 0.0625em solid var(--border);
206+ background-color: var(--surface);
207+ background-image: none;
208+ border-bottom: 0.0625em solid alpha(var(--border), 0.75);
226209 padding: 0.375em 0.75em;
227210 }
228211
229212 .omnibox-header-btn {
230- background-color: transparent;
213+ background-color: alpha(var(--surface), 0.12);
231214 background-image: none;
232- border: none;
215+ border: 0.0625em solid transparent;
233216 border-radius: 0.125em;
234217 padding: 0.25em 0.75em;
235218 margin-right: 0.5em;
@@ -240,49 +223,61 @@ func generateOmniboxCSS(p Palette) string {
240223 }
241224
242225 .omnibox-header-btn:hover {
243- background-color: alpha(var(--accent), 0.15);
226+ background-color: alpha(var(--accent), 0.14);
227+ border-color: alpha(var(--accent), 0.18);
244228 color: var(--text);
245229 }
246230
247231 .omnibox-header-btn.omnibox-header-active {
248- background-color: alpha(var(--accent), 0.2);
232+ background-color: alpha(var(--accent), 0.18);
233+ border-color: alpha(var(--accent), 0.28);
249234 color: var(--accent);
250235 font-weight: 600;
251236 }
252237
253238 /* Search entry field */
254-.omnibox-entry {
255- background-color: var(--bg);
239+entry.omnibox-entry,
240+entry.omnibox-entry > text {
241+ background-color: alpha(var(--bg), 0.88);
242+ background-image: none;
256243 color: var(--text);
257- border: 0.0625em solid var(--border);
244+ caret-color: var(--accent);
245+}
246+
247+entry.omnibox-entry {
248+ border: 0.0625em solid alpha(var(--border), 0.82);
258249 border-radius: 0.125em;
259250 padding: 0.625em 0.75em;
260251 margin: 0.5em 0.75em;
261252 font-size: 1em;
262- caret-color: var(--accent);
253+ box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset;
263254 }
264255
265256 entry.omnibox-entry:focus,
266257 entry.omnibox-entry:focus-within,
267-entry.omnibox-entry:focus-visible {
258+entry.omnibox-entry:focus-visible,
259+entry.omnibox-entry:focus > text,
260+entry.omnibox-entry:focus-within > text,
261+entry.omnibox-entry:focus-visible > text {
268262 border-color: var(--accent);
269263 background-color: shade(var(--bg), 1.05);
264+ background-image: none;
270265 outline-style: none;
271266 outline-width: 0px;
272267 outline-color: transparent;
273268 }
274269
275270 /* Override any internal focus styling */
276-entry.omnibox-entry > *:focus,
277-entry.omnibox-entry > *:focus-visible {
271+entry.omnibox-entry > text:focus,
272+entry.omnibox-entry > text:focus-visible {
278273 outline-style: none;
279274 outline-color: transparent;
280275 }
281276
282277 /* Scrolled window for suggestions */
283278 .omnibox-scrolled {
284- background-color: shade(var(--surface-variant), 0.95);
285- border-top: 0.0625em solid var(--border);
279+ background-color: var(--surface);
280+ border-top: 0.0625em solid alpha(var(--border), 0.65);
286281 }
287282
288283 /* List box */
@@ -296,7 +291,9 @@ entry.omnibox-entry > *:focus-visible {
296291 margin: 0;
297292 border-radius: 0;
298293 border-left: 0.1875em solid transparent;
299- border-bottom: 0.0625em solid alpha(var(--border), 0.5);
294+ border-bottom: 0.0625em solid alpha(var(--border), 0.35);
295+ background-color: var(--surface);
296+ background-image: none;
300297 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
301298 min-height: 2.75em;
302299 }
@@ -315,19 +312,19 @@ entry.omnibox-entry > *:focus-visible {
315312 border-left: 0.1875em solid var(--accent);
316313 }
317314
318-/* Favorite indicator in history mode - yellowish accent */
315+/* Favorite indicator in history mode - keep the row neutral and use the left selection stripe */
319316 .omnibox-row.omnibox-row-favorite {
320- background-color: alpha(var(--warning), 0.08);
317+ background-color: transparent;
321318 border-left: 0.1875em solid var(--warning);
322319 }
323320
324321 .omnibox-row.omnibox-row-favorite:hover {
325- background-color: alpha(var(--warning), 0.15);
322+ background-color: alpha(var(--accent), 0.12);
326323 border-left: 0.1875em solid var(--warning);
327324 }
328325
329326 .omnibox-row.omnibox-row-favorite:selected {
330- background-color: alpha(var(--warning), 0.2);
327+ background-color: alpha(var(--accent), 0.2);
331328 border-left: 0.1875em solid var(--warning);
332329 }
333330
@@ -342,7 +339,7 @@ entry.omnibox-entry > *:focus-visible {
342339 .omnibox-suggestion-title {
343340 font-size: 0.875em;
344341 color: var(--text);
345- font-weight: 400;
342+ font-weight: 500;
346343 }
347344
348345 /* Also style labels inside omnibox rows directly */
@@ -358,10 +355,22 @@ entry.omnibox-entry > *:focus-visible {
358355 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
359356 .omnibox-row .omnibox-suggestion-url {
360357 font-size: 0.75em;
361- color: alpha(var(--muted), 0.65);
358+ color: alpha(var(--muted), 0.72);
362359 font-weight: 400;
363360 }
364361
362+.omnibox-favorite-star {
363+ color: mix(var(--warning), var(--muted), 0.45);
364+ opacity: 0.75;
365+ margin-left: 0.625em;
366+ margin-right: 0.125em;
367+}
368+
369+.omnibox-row:selected .omnibox-favorite-star {
370+ color: mix(var(--warning), var(--text), 0.35);
371+ opacity: 0.88;
372+}
373+
365374 .omnibox-row:selected .omnibox-suggestion-url {
366375 color: alpha(var(--muted), 0.75);
367376 }
@@ -412,9 +421,14 @@ entry.omnibox-entry > *:focus-visible {
412421 entry.omnibox-entry.omnibox-entry-bang-active,
413422 entry.omnibox-entry.omnibox-entry-bang-active:focus,
414423 entry.omnibox-entry.omnibox-entry-bang-active:focus-within,
415-entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {
424+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible,
425+entry.omnibox-entry.omnibox-entry-bang-active > text,
426+entry.omnibox-entry.omnibox-entry-bang-active:focus > text,
427+entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text,
428+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
416429 border-color: var(--accent);
417430 background-color: shade(var(--bg), 1.05);
431+ background-image: none;
418432 }
419433
420434 .omnibox-row.omnibox-row-bang .omnibox-suggestion-title {
@@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string {
12 }12 }
13 13
14 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.14 // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor.
15-// Scale affects font sizes and widget padding/margins proportionally.15+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
16 func GenerateCSSWithScale(p Palette, scale float64) string {16 func GenerateCSSWithScale(p Palette, scale float64) string {
17 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())17 return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig())
18 }18 }
@@ -49,19 +49,15 @@ window, tooltip, popover {
49 }49 }
50 50
51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.51 // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts.
52-// Scale affects font sizes and widget padding/margins proportionally.52+// Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings.
53 // Uses default mode colors.53 // Uses default mode colors.
54 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {54 func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string {
55 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())55 return GenerateCSSFull(p, scale, fonts, DefaultModeColors())
56 }56 }
57 57
58 // GenerateCSSFull creates GTK4 CSS using all provided configuration.58 // GenerateCSSFull creates GTK4 CSS using all provided configuration.
59-// Scale affects font sizes and widget padding/margins proportionally.59+// Text scaling is handled via GtkSettings; CSS here stays scale-independent.
60-func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string {60+func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string {
61- if scale <= 0 {
62- scale = 1.0
63- }
64-
65 defaults := DefaultFontConfig()61 defaults := DefaultFontConfig()
66 fonts = FontConfig{62 fonts = FontConfig{
67 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),63 SansFont: Coalesce(fonts.SansFont, defaults.SansFont),
@@ -82,12 +78,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
82 sb.WriteString(generateFontCSS())78 sb.WriteString(generateFontCSS())
83 sb.WriteString("\n")79 sb.WriteString("\n")
84 80
85- // Global UI scaling via font-size on all widgets
86- if scale != 1.0 {
87- sb.WriteString(generateScalingCSS(scale))
88- sb.WriteString("\n")
89- }
90-
91 // Tab bar styling81 // Tab bar styling
92 sb.WriteString(generateTabBarCSS(p))82 sb.WriteString(generateTabBarCSS(p))
93 sb.WriteString("\n")83 sb.WriteString("\n")
@@ -146,20 +136,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode
146 return sb.String()136 return sb.String()
147 }137 }
148 138
149-// generateScalingCSS creates CSS rules that scale the UI.
150-// GTK4 CSS doesn't support transform:scale() well, so we scale font-size
151-// and use relative units (em) for padding/margins in other rules.
152-func generateScalingCSS(scale float64) string {
153- // Use 16px base so em conversions are correct:
154- // 1px = 0.0625em (1/16), 4px = 0.25em, etc.
155- basePx := int(16 * scale)
156- return fmt.Sprintf(`/* UI Scaling (%.0f%%) */
157-window {
158- font-size: %dpx;
159-}
160-`, scale*100, basePx)
161-}
162-
163 // generateTabBarCSS creates tab bar styles.139 // generateTabBarCSS creates tab bar styles.
164 // Uses em units for scalable UI.140 // Uses em units for scalable UI.
165 func generateTabBarCSS(p Palette) string {141 func generateTabBarCSS(p Palette) string {
@@ -206,6 +182,10 @@ button.tab-button.tab-button-active {
206 func generateOmniboxCSS(p Palette) string {182 func generateOmniboxCSS(p Palette) string {
207 return `/* ===== Omnibox Styling ===== */183 return `/* ===== Omnibox Styling ===== */
208 184
185+window.standalone-omnibox-window {
186+ background-color: transparent;
187+}
188+
209 /* Omnibox outer container - for positioning in overlay */189 /* Omnibox outer container - for positioning in overlay */
210 .omnibox-outer {190 .omnibox-outer {
211 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */191 /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */
@@ -213,23 +193,26 @@ func generateOmniboxCSS(p Palette) string {
213 193
214 /* Omnibox main container - the visible popup */194 /* Omnibox main container - the visible popup */
215 .omnibox-container {195 .omnibox-container {
216- background-color: var(--surface-variant);196+ background-color: var(--surface);
217- border: 0.0625em solid var(--border);197+ background-image: none;
198+ border: 0.0625em solid alpha(var(--border), 0.85);
218 border-radius: 0.1875em;199 border-radius: 0.1875em;
200+ box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset;
219 padding: 0;201 padding: 0;
220 }202 }
221 203
222 /* Header with History/Favorites toggle */204 /* Header with History/Favorites toggle */
223 .omnibox-header {205 .omnibox-header {
224- background-color: shade(var(--surface-variant), 1.1);206+ background-color: var(--surface);
225- border-bottom: 0.0625em solid var(--border);207+ background-image: none;
208+ border-bottom: 0.0625em solid alpha(var(--border), 0.75);
226 padding: 0.375em 0.75em;209 padding: 0.375em 0.75em;
227 }210 }
228 211
229 .omnibox-header-btn {212 .omnibox-header-btn {
230- background-color: transparent;213+ background-color: alpha(var(--surface), 0.12);
231 background-image: none;214 background-image: none;
232- border: none;215+ border: 0.0625em solid transparent;
233 border-radius: 0.125em;216 border-radius: 0.125em;
234 padding: 0.25em 0.75em;217 padding: 0.25em 0.75em;
235 margin-right: 0.5em;218 margin-right: 0.5em;
@@ -240,49 +223,61 @@ func generateOmniboxCSS(p Palette) string {
240 }223 }
241 224
242 .omnibox-header-btn:hover {225 .omnibox-header-btn:hover {
243- background-color: alpha(var(--accent), 0.15);226+ background-color: alpha(var(--accent), 0.14);
227+ border-color: alpha(var(--accent), 0.18);
244 color: var(--text);228 color: var(--text);
245 }229 }
246 230
247 .omnibox-header-btn.omnibox-header-active {231 .omnibox-header-btn.omnibox-header-active {
248- background-color: alpha(var(--accent), 0.2);232+ background-color: alpha(var(--accent), 0.18);
233+ border-color: alpha(var(--accent), 0.28);
249 color: var(--accent);234 color: var(--accent);
250 font-weight: 600;235 font-weight: 600;
251 }236 }
252 237
253 /* Search entry field */238 /* Search entry field */
254-.omnibox-entry {239+entry.omnibox-entry,
255- background-color: var(--bg);240+entry.omnibox-entry > text {
241+ background-color: alpha(var(--bg), 0.88);
242+ background-image: none;
256 color: var(--text);243 color: var(--text);
257- border: 0.0625em solid var(--border);244+ caret-color: var(--accent);
245+}
246+
247+entry.omnibox-entry {
248+ border: 0.0625em solid alpha(var(--border), 0.82);
258 border-radius: 0.125em;249 border-radius: 0.125em;
259 padding: 0.625em 0.75em;250 padding: 0.625em 0.75em;
260 margin: 0.5em 0.75em;251 margin: 0.5em 0.75em;
261 font-size: 1em;252 font-size: 1em;
262- caret-color: var(--accent);253+ box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset;
263 }254 }
264 255
265 entry.omnibox-entry:focus,256 entry.omnibox-entry:focus,
266 entry.omnibox-entry:focus-within,257 entry.omnibox-entry:focus-within,
267-entry.omnibox-entry:focus-visible {258+entry.omnibox-entry:focus-visible,
259+entry.omnibox-entry:focus > text,
260+entry.omnibox-entry:focus-within > text,
261+entry.omnibox-entry:focus-visible > text {
268 border-color: var(--accent);262 border-color: var(--accent);
269 background-color: shade(var(--bg), 1.05);263 background-color: shade(var(--bg), 1.05);
264+ background-image: none;
270 outline-style: none;265 outline-style: none;
271 outline-width: 0px;266 outline-width: 0px;
272 outline-color: transparent;267 outline-color: transparent;
273 }268 }
274 269
275 /* Override any internal focus styling */270 /* Override any internal focus styling */
276-entry.omnibox-entry > *:focus,271+entry.omnibox-entry > text:focus,
277-entry.omnibox-entry > *:focus-visible {272+entry.omnibox-entry > text:focus-visible {
278 outline-style: none;273 outline-style: none;
279 outline-color: transparent;274 outline-color: transparent;
280 }275 }
281 276
282 /* Scrolled window for suggestions */277 /* Scrolled window for suggestions */
283 .omnibox-scrolled {278 .omnibox-scrolled {
284- background-color: shade(var(--surface-variant), 0.95);279+ background-color: var(--surface);
285- border-top: 0.0625em solid var(--border);280+ border-top: 0.0625em solid alpha(var(--border), 0.65);
286 }281 }
287 282
288 /* List box */283 /* List box */
@@ -296,7 +291,9 @@ entry.omnibox-entry > *:focus-visible {
296 margin: 0;291 margin: 0;
297 border-radius: 0;292 border-radius: 0;
298 border-left: 0.1875em solid transparent;293 border-left: 0.1875em solid transparent;
299- border-bottom: 0.0625em solid alpha(var(--border), 0.5);294+ border-bottom: 0.0625em solid alpha(var(--border), 0.35);
295+ background-color: var(--surface);
296+ background-image: none;
300 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;297 transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out;
301 min-height: 2.75em;298 min-height: 2.75em;
302 }299 }
@@ -315,19 +312,19 @@ entry.omnibox-entry > *:focus-visible {
315 border-left: 0.1875em solid var(--accent);312 border-left: 0.1875em solid var(--accent);
316 }313 }
317 314
318-/* Favorite indicator in history mode - yellowish accent */315+/* Favorite indicator in history mode - keep the row neutral and use the left selection stripe */
319 .omnibox-row.omnibox-row-favorite {316 .omnibox-row.omnibox-row-favorite {
320- background-color: alpha(var(--warning), 0.08);317+ background-color: transparent;
321 border-left: 0.1875em solid var(--warning);318 border-left: 0.1875em solid var(--warning);
322 }319 }
323 320
324 .omnibox-row.omnibox-row-favorite:hover {321 .omnibox-row.omnibox-row-favorite:hover {
325- background-color: alpha(var(--warning), 0.15);322+ background-color: alpha(var(--accent), 0.12);
326 border-left: 0.1875em solid var(--warning);323 border-left: 0.1875em solid var(--warning);
327 }324 }
328 325
329 .omnibox-row.omnibox-row-favorite:selected {326 .omnibox-row.omnibox-row-favorite:selected {
330- background-color: alpha(var(--warning), 0.2);327+ background-color: alpha(var(--accent), 0.2);
331 border-left: 0.1875em solid var(--warning);328 border-left: 0.1875em solid var(--warning);
332 }329 }
333 330
@@ -342,7 +339,7 @@ entry.omnibox-entry > *:focus-visible {
342 .omnibox-suggestion-title {339 .omnibox-suggestion-title {
343 font-size: 0.875em;340 font-size: 0.875em;
344 color: var(--text);341 color: var(--text);
345- font-weight: 400;342+ font-weight: 500;
346 }343 }
347 344
348 /* Also style labels inside omnibox rows directly */345 /* Also style labels inside omnibox rows directly */
@@ -358,10 +355,22 @@ entry.omnibox-entry > *:focus-visible {
358 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */355 /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
359 .omnibox-row .omnibox-suggestion-url {356 .omnibox-row .omnibox-suggestion-url {
360 font-size: 0.75em;357 font-size: 0.75em;
361- color: alpha(var(--muted), 0.65);358+ color: alpha(var(--muted), 0.72);
362 font-weight: 400;359 font-weight: 400;
363 }360 }
364 361
362+.omnibox-favorite-star {
363+ color: mix(var(--warning), var(--muted), 0.45);
364+ opacity: 0.75;
365+ margin-left: 0.625em;
366+ margin-right: 0.125em;
367+}
368+
369+.omnibox-row:selected .omnibox-favorite-star {
370+ color: mix(var(--warning), var(--text), 0.35);
371+ opacity: 0.88;
372+}
373+
365 .omnibox-row:selected .omnibox-suggestion-url {374 .omnibox-row:selected .omnibox-suggestion-url {
366 color: alpha(var(--muted), 0.75);375 color: alpha(var(--muted), 0.75);
367 }376 }
@@ -412,9 +421,14 @@ entry.omnibox-entry > *:focus-visible {
412 entry.omnibox-entry.omnibox-entry-bang-active,421 entry.omnibox-entry.omnibox-entry-bang-active,
413 entry.omnibox-entry.omnibox-entry-bang-active:focus,422 entry.omnibox-entry.omnibox-entry-bang-active:focus,
414 entry.omnibox-entry.omnibox-entry-bang-active:focus-within,423 entry.omnibox-entry.omnibox-entry-bang-active:focus-within,
415-entry.omnibox-entry.omnibox-entry-bang-active:focus-visible {424+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible,
425+entry.omnibox-entry.omnibox-entry-bang-active > text,
426+entry.omnibox-entry.omnibox-entry-bang-active:focus > text,
427+entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text,
428+entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
416 border-color: var(--accent);429 border-color: var(--accent);
417 background-color: shade(var(--bg), 1.05);430 background-color: shade(var(--bg), 1.05);
431+ background-image: none;
418 }432 }
419 433
420 .omnibox-row.omnibox-row-bang .omnibox-suggestion-title {434 .omnibox-row.omnibox-row-bang .omnibox-suggestion-title {
A internal/ui/theme/css_test.go +106 -0
@@ -0,0 +1,106 @@
1+package theme
2+
3+import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
12+ css := GenerateCSS(DefaultDarkPalette())
13+
14+ if !strings.Contains(css, "window.standalone-omnibox-window") {
15+ t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css)
16+ }
17+ re := regexp.MustCompile(`window\.standalone-omnibox-window\s*\{\s*background-color:\s*transparent\s*;?\s*\}`)
18+ if !re.MatchString(css) {
19+ t.Fatalf("expected standalone omnibox window background to be transparent")
20+ }
21+}
22+
23+func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
24+ css := GenerateCSS(DefaultDarkPalette())
25+
26+ if !strings.Contains(css, ".omnibox-header {") {
27+ t.Fatalf("expected omnibox header selector in CSS")
28+ }
29+ if !strings.Contains(css, "background-color: var(--surface);") {
30+ t.Fatalf("expected omnibox header to use an opaque surface background")
31+ }
32+ if !strings.Contains(css, "background-image: none;") {
33+ t.Fatalf("expected omnibox header gradient to be disabled")
34+ }
35+}
36+
37+func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
38+ css := GenerateCSS(DefaultDarkPalette())
39+
40+ if !strings.Contains(css, ".omnibox-row.omnibox-row-favorite {") {
41+ t.Fatalf("expected favorite row selector in CSS")
42+ }
43+ if !strings.Contains(css, "border-left: 0.1875em solid var(--warning);") {
44+ t.Fatalf("expected favorite rows to mark the left edge with the warning color")
45+ }
46+ if strings.Contains(css, "border-right: 0.125em solid alpha(var(--warning), 0.55);") {
47+ t.Fatalf("expected favorite indicator to move off the right edge")
48+ }
49+ if !strings.Contains(css, "color: mix(var(--warning), var(--muted), 0.45);") {
50+ t.Fatalf("expected favorite star color to be softened")
51+ }
52+ if strings.Contains(css, ".omnibox-row.omnibox-row-favorite .omnibox-favorite-star-slot") {
53+ t.Fatalf("expected favorite highlight to move off the star slot")
54+ }
55+}
56+
57+func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
58+ css := GenerateCSS(DefaultDarkPalette())
59+
60+ containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`)
61+ if !containerRe.MatchString(css) {
62+ t.Fatalf("expected omnibox container to use the header surface color")
63+ }
64+ entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`)
65+ if !entryRe.MatchString(css) {
66+ t.Fatalf("expected omnibox entry and its text node to use the darker background fill")
67+ }
68+ focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`)
69+ if !focusedEntryRe.MatchString(css) {
70+ t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill")
71+ }
72+ scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`)
73+ if !scrolledRe.MatchString(css) {
74+ t.Fatalf("expected omnibox results area to use the header surface color")
75+ }
76+}
77+
78+func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
79+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
80+
81+ assert.NotContains(t, css, "/* UI Scaling (130%) */")
82+ assert.NotContains(t, css, "window {\n\tfont-size:")
83+}
84+
85+func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) {
86+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
87+
88+ assert.NotContains(t, css, "line-height:")
89+ assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:")
90+}
91+
92+func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) {
93+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
94+
95+ assert.Contains(t, css, ".omnibox-row {")
96+ assert.Contains(t, css, "background-color: var(--surface);")
97+ assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;")
98+}
99+
100+func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) {
101+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
102+
103+ assert.Contains(t, css, ".omnibox-suggestion-title {")
104+ assert.Contains(t, css, "font-weight: 500;")
105+ assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
106+}
@@ -0,0 +1,106 @@
1+package theme
2+
3+import (
4+ "regexp"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) {
12+ css := GenerateCSS(DefaultDarkPalette())
13+
14+ if !strings.Contains(css, "window.standalone-omnibox-window") {
15+ t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css)
16+ }
17+ re := regexp.MustCompile(`window\.standalone-omnibox-window\s*\{\s*background-color:\s*transparent\s*;?\s*\}`)
18+ if !re.MatchString(css) {
19+ t.Fatalf("expected standalone omnibox window background to be transparent")
20+ }
21+}
22+
23+func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) {
24+ css := GenerateCSS(DefaultDarkPalette())
25+
26+ if !strings.Contains(css, ".omnibox-header {") {
27+ t.Fatalf("expected omnibox header selector in CSS")
28+ }
29+ if !strings.Contains(css, "background-color: var(--surface);") {
30+ t.Fatalf("expected omnibox header to use an opaque surface background")
31+ }
32+ if !strings.Contains(css, "background-image: none;") {
33+ t.Fatalf("expected omnibox header gradient to be disabled")
34+ }
35+}
36+
37+func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
38+ css := GenerateCSS(DefaultDarkPalette())
39+
40+ if !strings.Contains(css, ".omnibox-row.omnibox-row-favorite {") {
41+ t.Fatalf("expected favorite row selector in CSS")
42+ }
43+ if !strings.Contains(css, "border-left: 0.1875em solid var(--warning);") {
44+ t.Fatalf("expected favorite rows to mark the left edge with the warning color")
45+ }
46+ if strings.Contains(css, "border-right: 0.125em solid alpha(var(--warning), 0.55);") {
47+ t.Fatalf("expected favorite indicator to move off the right edge")
48+ }
49+ if !strings.Contains(css, "color: mix(var(--warning), var(--muted), 0.45);") {
50+ t.Fatalf("expected favorite star color to be softened")
51+ }
52+ if strings.Contains(css, ".omnibox-row.omnibox-row-favorite .omnibox-favorite-star-slot") {
53+ t.Fatalf("expected favorite highlight to move off the star slot")
54+ }
55+}
56+
57+func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
58+ css := GenerateCSS(DefaultDarkPalette())
59+
60+ containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`)
61+ if !containerRe.MatchString(css) {
62+ t.Fatalf("expected omnibox container to use the header surface color")
63+ }
64+ entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`)
65+ if !entryRe.MatchString(css) {
66+ t.Fatalf("expected omnibox entry and its text node to use the darker background fill")
67+ }
68+ focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`)
69+ if !focusedEntryRe.MatchString(css) {
70+ t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill")
71+ }
72+ scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`)
73+ if !scrolledRe.MatchString(css) {
74+ t.Fatalf("expected omnibox results area to use the header surface color")
75+ }
76+}
77+
78+func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) {
79+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
80+
81+ assert.NotContains(t, css, "/* UI Scaling (130%) */")
82+ assert.NotContains(t, css, "window {\n\tfont-size:")
83+}
84+
85+func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) {
86+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
87+
88+ assert.NotContains(t, css, "line-height:")
89+ assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:")
90+}
91+
92+func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) {
93+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
94+
95+ assert.Contains(t, css, ".omnibox-row {")
96+ assert.Contains(t, css, "background-color: var(--surface);")
97+ assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;")
98+}
99+
100+func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) {
101+ css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3)
102+
103+ assert.Contains(t, css, ".omnibox-suggestion-title {")
104+ assert.Contains(t, css, "font-weight: 500;")
105+ assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
106+}
M internal/ui/theme/manager.go +36 -0
@@ -3,6 +3,8 @@ package theme
33 import (
44 "context"
55 "fmt"
6+ "math"
7+ "strings"
68
79 "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/domain/entity"
@@ -21,6 +23,7 @@ type Manager struct {
2123 fonts FontConfig
2224 modeColors ModeColors // Modal mode indicator colors
2325 cssProvider *gtk.CssProvider
26+ appliedFont string
2427 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
2528 }
2629
@@ -150,6 +153,27 @@ func (m *Manager) GetWebUIThemeCSS() string {
150153 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
151154 }
152155
156+const gtkDefaultFontPointSize = 11
157+
158+func formatGTKFontName(family string, uiScale float64) string {
159+ family = strings.TrimSpace(family)
160+ if family == "" {
161+ family = DefaultFontConfig().SansFont
162+ }
163+ if uiScale <= 0 {
164+ uiScale = 1.0
165+ }
166+ size := int(math.Round(gtkDefaultFontPointSize * uiScale))
167+ if size < 1 {
168+ size = gtkDefaultFontPointSize
169+ }
170+ return fmt.Sprintf("%s %d", family, size)
171+}
172+
173+func shouldApplyGTKFontName(current, next string) bool {
174+ return next != "" && next != current
175+}
176+
153177 // ApplyToDisplay loads the theme CSS into the display.
154178 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
155179 log := logging.FromContext(ctx)
@@ -162,6 +186,18 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
162186 // Generate CSS with current palette, UI scale, fonts, and mode colors
163187 palette := m.GetCurrentPalette()
164188 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
189+ fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale)
190+
191+ settings := gtk.SettingsGetForDisplay(display)
192+ if settings == nil {
193+ log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
194+ } else if shouldApplyGTKFontName(m.appliedFont, fontName) {
195+ settings.SetPropertyGtkFontName(fontName)
196+ m.appliedFont = fontName
197+ settings.Unref()
198+ } else {
199+ settings.Unref()
200+ }
165201
166202 // Create CSS provider if needed
167203 if m.cssProvider == nil {
@@ -3,6 +3,8 @@ package theme
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
7+ "strings"
6 8
7 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -21,6 +23,7 @@ type Manager struct {
21 fonts FontConfig23 fonts FontConfig
22 modeColors ModeColors // Modal mode indicator colors24 modeColors ModeColors // Modal mode indicator colors
23 cssProvider *gtk.CssProvider25 cssProvider *gtk.CssProvider
26+ appliedFont string
24 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection27 colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
25 }28 }
26 29
@@ -150,6 +153,27 @@ func (m *Manager) GetWebUIThemeCSS() string {
150 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)153 return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars)
151 }154 }
152 155
156+const gtkDefaultFontPointSize = 11
157+
158+func formatGTKFontName(family string, uiScale float64) string {
159+ family = strings.TrimSpace(family)
160+ if family == "" {
161+ family = DefaultFontConfig().SansFont
162+ }
163+ if uiScale <= 0 {
164+ uiScale = 1.0
165+ }
166+ size := int(math.Round(gtkDefaultFontPointSize * uiScale))
167+ if size < 1 {
168+ size = gtkDefaultFontPointSize
169+ }
170+ return fmt.Sprintf("%s %d", family, size)
171+}
172+
173+func shouldApplyGTKFontName(current, next string) bool {
174+ return next != "" && next != current
175+}
176+
153 // ApplyToDisplay loads the theme CSS into the display.177 // ApplyToDisplay loads the theme CSS into the display.
154 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {178 func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
155 log := logging.FromContext(ctx)179 log := logging.FromContext(ctx)
@@ -162,6 +186,18 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
162 // Generate CSS with current palette, UI scale, fonts, and mode colors186 // Generate CSS with current palette, UI scale, fonts, and mode colors
163 palette := m.GetCurrentPalette()187 palette := m.GetCurrentPalette()
164 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)188 css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors)
189+ fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale)
190+
191+ settings := gtk.SettingsGetForDisplay(display)
192+ if settings == nil {
193+ log.Warn().Msg("cannot apply theme font scaling: settings unavailable")
194+ } else if shouldApplyGTKFontName(m.appliedFont, fontName) {
195+ settings.SetPropertyGtkFontName(fontName)
196+ m.appliedFont = fontName
197+ settings.Unref()
198+ } else {
199+ settings.Unref()
200+ }
165 201
166 // Create CSS provider if needed202 // Create CSS provider if needed
167 if m.cssProvider == nil {203 if m.cssProvider == nil {
M internal/ui/theme/manager_test.go +15 -0
@@ -313,3 +313,18 @@ func TestManager_ModeColors(t *testing.T) {
313313 assert.Equal(t, "#0000ff", modeColors.SessionMode)
314314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)
315315 }
316+
317+func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) {
318+ assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3))
319+}
320+
321+func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
322+ assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
323+ assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
324+}
325+
326+func TestShouldApplyGTKFontName(t *testing.T) {
327+ assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14"))
328+ assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14"))
329+ assert.False(t, shouldApplyGTKFontName("Fira Sans 14", ""))
330+}
@@ -313,3 +313,18 @@ func TestManager_ModeColors(t *testing.T) {
313 assert.Equal(t, "#0000ff", modeColors.SessionMode)313 assert.Equal(t, "#0000ff", modeColors.SessionMode)
314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)314 assert.Equal(t, "#ffff00", modeColors.ResizeMode)
315 }315 }
316+
317+func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) {
318+ assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3))
319+}
320+
321+func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) {
322+ assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0))
323+ assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05))
324+}
325+
326+func TestShouldApplyGTKFontName(t *testing.T) {
327+ assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14"))
328+ assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14"))
329+ assert.False(t, shouldApplyGTKFontName("Fira Sans 14", ""))
330+}
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