feat(omnibox): add standalone layer-shell launcher
Launch the omnibox as a standalone GTK layer-shell surface and keep its sizing, transparency, and theme behavior consistent. Route in-app URLs back through dumber, open external schemes with the desktop handler, and cover the new runtime paths with regression tests.
- commit
- ee4a1fbb50b16addecaf4da57a256c1356165a6f
- author
- brice · · 2026-04-06 18:12 UTC
- parent
- 408294c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
31 files · +1881 -153
M cmd/dumber/main.go +114 -4
| @@ -51,20 +51,52 @@ var initialURL string | |||
| 51 | 51 | // restoreSessionID holds the session ID to restore on startup. | |
| 52 | 52 | var restoreSessionID string | |
| 53 | 53 | ||
| 54 | + | type launchMode string | |
| 55 | + | ||
| 56 | + | const ( | |
| 57 | + | launchModeCLI launchMode = "cli" | |
| 58 | + | launchModeBrowse launchMode = "browse" | |
| 59 | + | launchModeStandaloneOmnibox launchMode = "omnibox" | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | func launchModeFromArgs(args []string) (launchMode, string) { | |
| 63 | + | if len(args) > 1 { | |
| 64 | + | switch args[1] { | |
| 65 | + | case "browse": | |
| 66 | + | if len(args) > 2 { | |
| 67 | + | return launchModeBrowse, args[2] | |
| 68 | + | } | |
| 69 | + | return launchModeBrowse, "" | |
| 70 | + | case "omnibox": | |
| 71 | + | if len(args) > 2 { | |
| 72 | + | return launchModeCLI, "" | |
| 73 | + | } | |
| 74 | + | return launchModeStandaloneOmnibox, "" | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return launchModeCLI, "" | |
| 79 | + | } | |
| 80 | + | ||
| 54 | 81 | func main() { | |
| 55 | 82 | enableCrashForensics() | |
| 56 | 83 | ||
| 84 | + | mode, browseURL := launchModeFromArgs(os.Args) | |
| 85 | + | ||
| 57 | 86 | // Run GUI mode for browse command | |
| 58 | - | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 59 | - | if len(os.Args) > 2 { | |
| 60 | - | initialURL = os.Args[2] | |
| 61 | - | } | |
| 87 | + | if mode == launchModeBrowse { | |
| 88 | + | initialURL = browseURL | |
| 62 | 89 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 63 | 90 | os.Args = os.Args[:1] | |
| 64 | 91 | os.Exit(runGUI()) | |
| 65 | 92 | return | |
| 66 | 93 | } | |
| 67 | 94 | ||
| 95 | + | if mode == launchModeStandaloneOmnibox { | |
| 96 | + | os.Exit(runStandaloneOmnibox()) | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | ||
| 68 | 100 | // Pass build info to CLI | |
| 69 | 101 | cmd.SetBuildInfo(build.Info{ | |
| 70 | 102 | Version: version, | |
| @@ -140,6 +172,83 @@ func runGUI() int { | |||
| 140 | 172 | return app.Run(ctx, os.Args) | |
| 141 | 173 | } | |
| 142 | 174 | ||
| 175 | + | func runStandaloneOmnibox() int { | |
| 176 | + | maybeReexecStandaloneOmniboxWithLayerShell() | |
| 177 | + | ||
| 178 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 179 | + | runtime.LockOSThread() | |
| 180 | + | component.SetSkeletonVersion(version) | |
| 181 | + | ||
| 182 | + | cfg := initConfig() | |
| 183 | + | ctx := initStartupContextWithTrace(cfg) | |
| 184 | + | ||
| 185 | + | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 186 | + | if err != nil { | |
| 187 | + | return 1 | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |
| 191 | + | if err != nil { | |
| 192 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime") | |
| 193 | + | return 1 | |
| 194 | + | } | |
| 195 | + | if dbCleanup != nil { | |
| 196 | + | defer dbCleanup() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | useCases := createUseCases(repos, cfg) | |
| 200 | + | uiDeps := buildUIDependencies( | |
| 201 | + | ctx, | |
| 202 | + | cfg, | |
| 203 | + | initResult.ThemeManager, | |
| 204 | + | initResult.ColorResolver, | |
| 205 | + | initResult.AdwaitaDetector, | |
| 206 | + | engine, | |
| 207 | + | repos, | |
| 208 | + | useCases, | |
| 209 | + | nil, | |
| 210 | + | "", | |
| 211 | + | nil, | |
| 212 | + | ) | |
| 213 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |
| 214 | + | ||
| 215 | + | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func maybeReexecStandaloneOmniboxWithLayerShell() { | |
| 219 | + | env := bootstrap.CurrentEnvMap() | |
| 220 | + | if !bootstrap.ShouldPreloadLayerShell(env) { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | libraryPath := bootstrap.LayerShellLibraryPath() | |
| 225 | + | if libraryPath == "" { | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | env = bootstrap.LayerShellPreloadEnv(env, libraryPath) | |
| 230 | + | envSlice := make([]string, 0, len(env)) | |
| 231 | + | for key, value := range env { | |
| 232 | + | envSlice = append(envSlice, key+"="+value) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | execPath, err := resolveCurrentExecutable(os.Executable) | |
| 236 | + | if err != nil { | |
| 237 | + | fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env. | |
| 242 | + | if err := syscall.Exec(execPath, os.Args, envSlice); err != nil { | |
| 243 | + | fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err) | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func resolveCurrentExecutable(executable func() (string, error)) (string, error) { | |
| 249 | + | return executable() | |
| 250 | + | } | |
| 251 | + | ||
| 143 | 252 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | |
| 144 | 253 | logging.InitStartupTrace(cfg.Logging.Level) | |
| 145 | 254 | logging.Trace().Mark("config_loaded") | |
| @@ -581,6 +690,7 @@ func buildUIDependencies( | |||
| 581 | 690 | return config.GetManager().Watch() | |
| 582 | 691 | }, | |
| 583 | 692 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 693 | + | LaunchBrowserURL: desktop.LaunchBrowserURL, | |
| 584 | 694 | ConfigMigrator: config.NewMigrator(), | |
| 585 | 695 | } | |
| 586 | 696 | ||
| @@ -51,20 +51,52 @@ var initialURL string | |||||
| 51 | // restoreSessionID holds the session ID to restore on startup. | 51 | // restoreSessionID holds the session ID to restore on startup. | ||
| 52 | var restoreSessionID string | 52 | var restoreSessionID string | ||
| 53 | 53 | ||||
| 54 | + | type launchMode string | |||
| 55 | + | ||||
| 56 | + | const ( | |||
| 57 | + | launchModeCLI launchMode = "cli" | |||
| 58 | + | launchModeBrowse launchMode = "browse" | |||
| 59 | + | launchModeStandaloneOmnibox launchMode = "omnibox" | |||
| 60 | + | ) | |||
| 61 | + | ||||
| 62 | + | func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 63 | + | if len(args) > 1 { | |||
| 64 | + | switch args[1] { | |||
| 65 | + | case "browse": | |||
| 66 | + | if len(args) > 2 { | |||
| 67 | + | return launchModeBrowse, args[2] | |||
| 68 | + | } | |||
| 69 | + | return launchModeBrowse, "" | |||
| 70 | + | case "omnibox": | |||
| 71 | + | if len(args) > 2 { | |||
| 72 | + | return launchModeCLI, "" | |||
| 73 | + | } | |||
| 74 | + | return launchModeStandaloneOmnibox, "" | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return launchModeCLI, "" | |||
| 79 | + | } | |||
| 80 | + | ||||
| 54 | func main() { | 81 | func main() { | ||
| 55 | enableCrashForensics() | 82 | enableCrashForensics() | ||
| 56 | 83 | ||||
| 84 | + | mode, browseURL := launchModeFromArgs(os.Args) | |||
| 85 | + | ||||
| 57 | // Run GUI mode for browse command | 86 | // Run GUI mode for browse command | ||
| 58 | - | if len(os.Args) > 1 && os.Args[1] == "browse" { | 87 | + | if mode == launchModeBrowse { |
| 59 | - | if len(os.Args) > 2 { | 88 | + | initialURL = browseURL |
| 60 | - | initialURL = os.Args[2] | |||
| 61 | - | } | |||
| 62 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 89 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 63 | os.Args = os.Args[:1] | 90 | os.Args = os.Args[:1] | ||
| 64 | os.Exit(runGUI()) | 91 | os.Exit(runGUI()) | ||
| 65 | return | 92 | return | ||
| 66 | } | 93 | } | ||
| 67 | 94 | ||||
| 95 | + | if mode == launchModeStandaloneOmnibox { | |||
| 96 | + | os.Exit(runStandaloneOmnibox()) | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | ||||
| 68 | // Pass build info to CLI | 100 | // Pass build info to CLI | ||
| 69 | cmd.SetBuildInfo(build.Info{ | 101 | cmd.SetBuildInfo(build.Info{ | ||
| 70 | Version: version, | 102 | Version: version, | ||
| @@ -140,6 +172,83 @@ func runGUI() int { | |||||
| 140 | return app.Run(ctx, os.Args) | 172 | return app.Run(ctx, os.Args) | ||
| 141 | } | 173 | } | ||
| 142 | 174 | ||||
| 175 | + | func runStandaloneOmnibox() int { | |||
| 176 | + | maybeReexecStandaloneOmniboxWithLayerShell() | |||
| 177 | + | ||||
| 178 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |||
| 179 | + | runtime.LockOSThread() | |||
| 180 | + | component.SetSkeletonVersion(version) | |||
| 181 | + | ||||
| 182 | + | cfg := initConfig() | |||
| 183 | + | ctx := initStartupContextWithTrace(cfg) | |||
| 184 | + | ||||
| 185 | + | initResult, err := runParallelInitPhase(ctx, cfg) | |||
| 186 | + | if err != nil { | |||
| 187 | + | return 1 | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |||
| 191 | + | if err != nil { | |||
| 192 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime") | |||
| 193 | + | return 1 | |||
| 194 | + | } | |||
| 195 | + | if dbCleanup != nil { | |||
| 196 | + | defer dbCleanup() | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | useCases := createUseCases(repos, cfg) | |||
| 200 | + | uiDeps := buildUIDependencies( | |||
| 201 | + | ctx, | |||
| 202 | + | cfg, | |||
| 203 | + | initResult.ThemeManager, | |||
| 204 | + | initResult.ColorResolver, | |||
| 205 | + | initResult.AdwaitaDetector, | |||
| 206 | + | engine, | |||
| 207 | + | repos, | |||
| 208 | + | useCases, | |||
| 209 | + | nil, | |||
| 210 | + | "", | |||
| 211 | + | nil, | |||
| 212 | + | ) | |||
| 213 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |||
| 214 | + | ||||
| 215 | + | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func maybeReexecStandaloneOmniboxWithLayerShell() { | |||
| 219 | + | env := bootstrap.CurrentEnvMap() | |||
| 220 | + | if !bootstrap.ShouldPreloadLayerShell(env) { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | libraryPath := bootstrap.LayerShellLibraryPath() | |||
| 225 | + | if libraryPath == "" { | |||
| 226 | + | return | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | env = bootstrap.LayerShellPreloadEnv(env, libraryPath) | |||
| 230 | + | envSlice := make([]string, 0, len(env)) | |||
| 231 | + | for key, value := range env { | |||
| 232 | + | envSlice = append(envSlice, key+"="+value) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | execPath, err := resolveCurrentExecutable(os.Executable) | |||
| 236 | + | if err != nil { | |||
| 237 | + | fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err) | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env. | |||
| 242 | + | if err := syscall.Exec(execPath, os.Args, envSlice); err != nil { | |||
| 243 | + | fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err) | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func resolveCurrentExecutable(executable func() (string, error)) (string, error) { | |||
| 249 | + | return executable() | |||
| 250 | + | } | |||
| 251 | + | ||||
| 143 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | 252 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | ||
| 144 | logging.InitStartupTrace(cfg.Logging.Level) | 253 | logging.InitStartupTrace(cfg.Logging.Level) | ||
| 145 | logging.Trace().Mark("config_loaded") | 254 | logging.Trace().Mark("config_loaded") | ||
| @@ -581,6 +690,7 @@ func buildUIDependencies( | |||||
| 581 | return config.GetManager().Watch() | 690 | return config.GetManager().Watch() | ||
| 582 | }, | 691 | }, | ||
| 583 | LaunchExternalURL: desktop.LaunchExternalURL, | 692 | LaunchExternalURL: desktop.LaunchExternalURL, | ||
| 693 | + | LaunchBrowserURL: desktop.LaunchBrowserURL, | |||
| 584 | ConfigMigrator: config.NewMigrator(), | 694 | ConfigMigrator: config.NewMigrator(), | ||
| 585 | } | 695 | } | ||
| 586 | 696 | ||||
A cmd/dumber/main_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |
| 9 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |
| 10 | + | if mode != launchModeStandaloneOmnibox { | |
| 11 | + | t.Fatalf("expected standalone omnibox mode, got %q", mode) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) { | |
| 16 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"}) | |
| 17 | + | if mode != launchModeBrowse { | |
| 18 | + | t.Fatalf("expected browse mode, got %q", mode) | |
| 19 | + | } | |
| 20 | + | if browseURL != "https://example.com" { | |
| 21 | + | t.Fatalf("expected browse url to be preserved, got %q", browseURL) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) { | |
| 26 | + | mode, browseURL := launchModeFromArgs([]string{"dumber"}) | |
| 27 | + | if mode != launchModeCLI { | |
| 28 | + | t.Fatalf("expected cli mode, got %q", mode) | |
| 29 | + | } | |
| 30 | + | if browseURL != "" { | |
| 31 | + | t.Fatalf("expected empty browse url, got %q", browseURL) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) { | |
| 36 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"}) | |
| 37 | + | if mode != launchModeCLI { | |
| 38 | + | t.Fatalf("expected cli mode for omnibox help, got %q", mode) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) { | |
| 43 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"}) | |
| 44 | + | if mode != launchModeCLI { | |
| 45 | + | t.Fatalf("expected cli mode for omnibox flags, got %q", mode) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |
| 50 | + | got, err := resolveCurrentExecutable(func() (string, error) { | |
| 51 | + | return "/usr/bin/dumber", nil | |
| 52 | + | }) | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatalf("expected no error, got %v", err) | |
| 55 | + | } | |
| 56 | + | if got != "/usr/bin/dumber" { | |
| 57 | + | t.Fatalf("expected executable path to be preserved, got %q", got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |
| 62 | + | wantErr := errors.New("not found") | |
| 63 | + | _, err := resolveCurrentExecutable(func() (string, error) { | |
| 64 | + | return "", wantErr | |
| 65 | + | }) | |
| 66 | + | if !errors.Is(err, wantErr) { | |
| 67 | + | t.Fatalf("expected error %v, got %v", wantErr, err) | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |||
| 9 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |||
| 10 | + | if mode != launchModeStandaloneOmnibox { | |||
| 11 | + | t.Fatalf("expected standalone omnibox mode, got %q", mode) | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) { | |||
| 16 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"}) | |||
| 17 | + | if mode != launchModeBrowse { | |||
| 18 | + | t.Fatalf("expected browse mode, got %q", mode) | |||
| 19 | + | } | |||
| 20 | + | if browseURL != "https://example.com" { | |||
| 21 | + | t.Fatalf("expected browse url to be preserved, got %q", browseURL) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) { | |||
| 26 | + | mode, browseURL := launchModeFromArgs([]string{"dumber"}) | |||
| 27 | + | if mode != launchModeCLI { | |||
| 28 | + | t.Fatalf("expected cli mode, got %q", mode) | |||
| 29 | + | } | |||
| 30 | + | if browseURL != "" { | |||
| 31 | + | t.Fatalf("expected empty browse url, got %q", browseURL) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) { | |||
| 36 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"}) | |||
| 37 | + | if mode != launchModeCLI { | |||
| 38 | + | t.Fatalf("expected cli mode for omnibox help, got %q", mode) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) { | |||
| 43 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"}) | |||
| 44 | + | if mode != launchModeCLI { | |||
| 45 | + | t.Fatalf("expected cli mode for omnibox flags, got %q", mode) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |||
| 50 | + | got, err := resolveCurrentExecutable(func() (string, error) { | |||
| 51 | + | return "/usr/bin/dumber", nil | |||
| 52 | + | }) | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatalf("expected no error, got %v", err) | |||
| 55 | + | } | |||
| 56 | + | if got != "/usr/bin/dumber" { | |||
| 57 | + | t.Fatalf("expected executable path to be preserved, got %q", got) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |||
| 62 | + | wantErr := errors.New("not found") | |||
| 63 | + | _, err := resolveCurrentExecutable(func() (string, error) { | |||
| 64 | + | return "", wantErr | |||
| 65 | + | }) | |||
| 66 | + | if !errors.Is(err, wantErr) { | |||
| 67 | + | t.Fatalf("expected error %v, got %v", wantErr, err) | |||
| 68 | + | } | |||
| 69 | + | } | |||
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego-sqlite v0.1.1 | |
| 7 | - | github.com/bnema/puregotk v0.2.2 | |
| 7 | + | github.com/bnema/puregotk v0.3.0 | |
| 8 | 8 | github.com/bnema/puregotk-webkit v0.0.16 | |
| 9 | 9 | github.com/charmbracelet/bubbles v1.0.0 | |
| 10 | 10 | 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.1 | 6 | github.com/bnema/purego-sqlite v0.1.1 | ||
| 7 | - | github.com/bnema/puregotk v0.2.2 | 7 | + | github.com/bnema/puregotk v0.3.0 |
| 8 | github.com/bnema/puregotk-webkit v0.0.16 | 8 | github.com/bnema/puregotk-webkit v0.0.16 | ||
| 9 | github.com/charmbracelet/bubbles v1.0.0 | 9 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 10 | github.com/charmbracelet/bubbletea v1.3.10 | 10 | github.com/charmbracelet/bubbletea v1.3.10 | ||
M go.sum +2 -0 generated file: collapsed
A internal/bootstrap/layershell_preload.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const layerShellLibrarySubstring = "libgtk4-layer-shell" | |
| 10 | + | ||
| 11 | + | var layerShellLibraryCandidates = []string{ | |
| 12 | + | "/usr/lib/libgtk4-layer-shell.so.0", | |
| 13 | + | "/usr/lib64/libgtk4-layer-shell.so.0", | |
| 14 | + | "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0", | |
| 15 | + | "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |
| 16 | + | "/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func ShouldPreloadLayerShell(env map[string]string) bool { | |
| 20 | + | if env["WAYLAND_DISPLAY"] == "" { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | return !LayerShellPreloadPresent(env) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func LayerShellPreloadPresent(env map[string]string) bool { | |
| 28 | + | return strings.Contains(env["LD_PRELOAD"], layerShellLibrarySubstring) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func CurrentEnvMap() map[string]string { | |
| 32 | + | env := make(map[string]string) | |
| 33 | + | for _, entry := range os.Environ() { | |
| 34 | + | key, value, ok := strings.Cut(entry, "=") | |
| 35 | + | if !ok { | |
| 36 | + | continue | |
| 37 | + | } | |
| 38 | + | env[key] = value | |
| 39 | + | } | |
| 40 | + | return env | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func FindLayerShellLibrary(exists func(path string) bool) string { | |
| 44 | + | for _, path := range layerShellLibrarySearchPaths(CurrentEnvMap()) { | |
| 45 | + | if exists(path) { | |
| 46 | + | return path | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | return "" | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func LayerShellLibraryPath() string { | |
| 54 | + | return FindLayerShellLibrary(func(path string) bool { | |
| 55 | + | info, err := os.Stat(filepath.Clean(path)) | |
| 56 | + | return err == nil && !info.IsDir() | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func layerShellLibrarySearchPaths(env map[string]string) []string { | |
| 61 | + | paths := make([]string, 0, len(layerShellLibraryCandidates)+4) | |
| 62 | + | paths = append(paths, layerShellLibraryCandidates...) | |
| 63 | + | ||
| 64 | + | seen := make(map[string]struct{}, len(paths)) | |
| 65 | + | for _, path := range paths { | |
| 66 | + | seen[path] = struct{}{} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) { | |
| 70 | + | if dir == "" { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0") | |
| 74 | + | if _, ok := seen[candidate]; ok { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | seen[candidate] = struct{}{} | |
| 78 | + | paths = append(paths, candidate) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return paths | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string { | |
| 85 | + | cloned := make(map[string]string, len(env)+1) | |
| 86 | + | for key, value := range env { | |
| 87 | + | cloned[key] = value | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if libraryPath == "" { | |
| 91 | + | return cloned | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | if current := cloned["LD_PRELOAD"]; current != "" { | |
| 95 | + | cloned["LD_PRELOAD"] = libraryPath + " " + current | |
| 96 | + | return cloned | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | cloned["LD_PRELOAD"] = libraryPath | |
| 100 | + | return cloned | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const layerShellLibrarySubstring = "libgtk4-layer-shell" | |||
| 10 | + | ||||
| 11 | + | var layerShellLibraryCandidates = []string{ | |||
| 12 | + | "/usr/lib/libgtk4-layer-shell.so.0", | |||
| 13 | + | "/usr/lib64/libgtk4-layer-shell.so.0", | |||
| 14 | + | "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 15 | + | "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 16 | + | "/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func ShouldPreloadLayerShell(env map[string]string) bool { | |||
| 20 | + | if env["WAYLAND_DISPLAY"] == "" { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return !LayerShellPreloadPresent(env) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func LayerShellPreloadPresent(env map[string]string) bool { | |||
| 28 | + | return strings.Contains(env["LD_PRELOAD"], layerShellLibrarySubstring) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func CurrentEnvMap() map[string]string { | |||
| 32 | + | env := make(map[string]string) | |||
| 33 | + | for _, entry := range os.Environ() { | |||
| 34 | + | key, value, ok := strings.Cut(entry, "=") | |||
| 35 | + | if !ok { | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | env[key] = value | |||
| 39 | + | } | |||
| 40 | + | return env | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func FindLayerShellLibrary(exists func(path string) bool) string { | |||
| 44 | + | for _, path := range layerShellLibrarySearchPaths(CurrentEnvMap()) { | |||
| 45 | + | if exists(path) { | |||
| 46 | + | return path | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | return "" | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func LayerShellLibraryPath() string { | |||
| 54 | + | return FindLayerShellLibrary(func(path string) bool { | |||
| 55 | + | info, err := os.Stat(filepath.Clean(path)) | |||
| 56 | + | return err == nil && !info.IsDir() | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func layerShellLibrarySearchPaths(env map[string]string) []string { | |||
| 61 | + | paths := make([]string, 0, len(layerShellLibraryCandidates)+4) | |||
| 62 | + | paths = append(paths, layerShellLibraryCandidates...) | |||
| 63 | + | ||||
| 64 | + | seen := make(map[string]struct{}, len(paths)) | |||
| 65 | + | for _, path := range paths { | |||
| 66 | + | seen[path] = struct{}{} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) { | |||
| 70 | + | if dir == "" { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0") | |||
| 74 | + | if _, ok := seen[candidate]; ok { | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | seen[candidate] = struct{}{} | |||
| 78 | + | paths = append(paths, candidate) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return paths | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string { | |||
| 85 | + | cloned := make(map[string]string, len(env)+1) | |||
| 86 | + | for key, value := range env { | |||
| 87 | + | cloned[key] = value | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | if libraryPath == "" { | |||
| 91 | + | return cloned | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if current := cloned["LD_PRELOAD"]; current != "" { | |||
| 95 | + | cloned["LD_PRELOAD"] = libraryPath + " " + current | |||
| 96 | + | return cloned | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | cloned["LD_PRELOAD"] = libraryPath | |||
| 100 | + | return cloned | |||
| 101 | + | } | |||
A internal/bootstrap/layershell_preload_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) { | |
| 6 | + | env := map[string]string{ | |
| 7 | + | "WAYLAND_DISPLAY": "wayland-1", | |
| 8 | + | "LD_PRELOAD": "", | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | if !ShouldPreloadLayerShell(env) { | |
| 12 | + | t.Fatalf("expected preload to be required") | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestShouldPreloadLayerShell_SkipsWithoutLibraryPath(t *testing.T) { | |
| 17 | + | env := map[string]string{ | |
| 18 | + | "WAYLAND_DISPLAY": "wayland-1", | |
| 19 | + | "LD_PRELOAD": "", | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | if !ShouldPreloadLayerShell(env) { | |
| 23 | + | t.Fatalf("expected preload to be required before library probing") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) { | |
| 28 | + | env := map[string]string{ | |
| 29 | + | "WAYLAND_DISPLAY": "wayland-1", | |
| 30 | + | "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0", | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | if ShouldPreloadLayerShell(env) { | |
| 34 | + | t.Fatalf("expected preload to be skipped") | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) { | |
| 39 | + | if ShouldPreloadLayerShell(map[string]string{}) { | |
| 40 | + | t.Fatalf("expected preload to be skipped outside Wayland") | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) { | |
| 45 | + | env := map[string]string{ | |
| 46 | + | "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so", | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | if !LayerShellPreloadPresent(env) { | |
| 50 | + | t.Fatalf("expected preload presence to be detected") | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) { | |
| 55 | + | got := FindLayerShellLibrary(func(path string) bool { | |
| 56 | + | return path == layerShellLibraryCandidates[1] | |
| 57 | + | }) | |
| 58 | + | ||
| 59 | + | if got != layerShellLibraryCandidates[1] { | |
| 60 | + | t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) { | |
| 65 | + | arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0" | |
| 66 | + | got := FindLayerShellLibrary(func(path string) bool { | |
| 67 | + | return path == arm64Candidate | |
| 68 | + | }) | |
| 69 | + | ||
| 70 | + | if got != arm64Candidate { | |
| 71 | + | t.Fatalf("expected %q, got %q", arm64Candidate, got) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) { | |
| 76 | + | if got := FindLayerShellLibrary(func(string) bool { return false }); got != "" { | |
| 77 | + | t.Fatalf("expected empty result, got %q", got) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) { | |
| 82 | + | paths := layerShellLibrarySearchPaths(map[string]string{ | |
| 83 | + | "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib", | |
| 84 | + | }) | |
| 85 | + | ||
| 86 | + | wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |
| 87 | + | wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0" | |
| 88 | + | if !containsString(paths, wantCustom) { | |
| 89 | + | t.Fatalf("expected custom search path %q in %#v", wantCustom, paths) | |
| 90 | + | } | |
| 91 | + | if !containsString(paths, wantNix) { | |
| 92 | + | t.Fatalf("expected nix search path %q in %#v", wantNix, paths) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) { | |
| 97 | + | env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0") | |
| 98 | + | ||
| 99 | + | if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" { | |
| 100 | + | t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"]) | |
| 101 | + | } | |
| 102 | + | if env["PATH"] != "/usr/bin" { | |
| 103 | + | t.Fatalf("expected unrelated env to be preserved") | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) { | |
| 108 | + | env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0") | |
| 109 | + | ||
| 110 | + | want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so" | |
| 111 | + | if env["LD_PRELOAD"] != want { | |
| 112 | + | t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"]) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func containsString(items []string, want string) bool { | |
| 117 | + | for _, item := range items { | |
| 118 | + | if item == want { | |
| 119 | + | return true | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | return false | |
| 123 | + | } | |
| @@ -0,0 +1,123 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) { | |||
| 6 | + | env := map[string]string{ | |||
| 7 | + | "WAYLAND_DISPLAY": "wayland-1", | |||
| 8 | + | "LD_PRELOAD": "", | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | if !ShouldPreloadLayerShell(env) { | |||
| 12 | + | t.Fatalf("expected preload to be required") | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestShouldPreloadLayerShell_SkipsWithoutLibraryPath(t *testing.T) { | |||
| 17 | + | env := map[string]string{ | |||
| 18 | + | "WAYLAND_DISPLAY": "wayland-1", | |||
| 19 | + | "LD_PRELOAD": "", | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | if !ShouldPreloadLayerShell(env) { | |||
| 23 | + | t.Fatalf("expected preload to be required before library probing") | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) { | |||
| 28 | + | env := map[string]string{ | |||
| 29 | + | "WAYLAND_DISPLAY": "wayland-1", | |||
| 30 | + | "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0", | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | if ShouldPreloadLayerShell(env) { | |||
| 34 | + | t.Fatalf("expected preload to be skipped") | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) { | |||
| 39 | + | if ShouldPreloadLayerShell(map[string]string{}) { | |||
| 40 | + | t.Fatalf("expected preload to be skipped outside Wayland") | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) { | |||
| 45 | + | env := map[string]string{ | |||
| 46 | + | "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so", | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | if !LayerShellPreloadPresent(env) { | |||
| 50 | + | t.Fatalf("expected preload presence to be detected") | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) { | |||
| 55 | + | got := FindLayerShellLibrary(func(path string) bool { | |||
| 56 | + | return path == layerShellLibraryCandidates[1] | |||
| 57 | + | }) | |||
| 58 | + | ||||
| 59 | + | if got != layerShellLibraryCandidates[1] { | |||
| 60 | + | t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) { | |||
| 65 | + | arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0" | |||
| 66 | + | got := FindLayerShellLibrary(func(path string) bool { | |||
| 67 | + | return path == arm64Candidate | |||
| 68 | + | }) | |||
| 69 | + | ||||
| 70 | + | if got != arm64Candidate { | |||
| 71 | + | t.Fatalf("expected %q, got %q", arm64Candidate, got) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) { | |||
| 76 | + | if got := FindLayerShellLibrary(func(string) bool { return false }); got != "" { | |||
| 77 | + | t.Fatalf("expected empty result, got %q", got) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) { | |||
| 82 | + | paths := layerShellLibrarySearchPaths(map[string]string{ | |||
| 83 | + | "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib", | |||
| 84 | + | }) | |||
| 85 | + | ||||
| 86 | + | wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |||
| 87 | + | wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0" | |||
| 88 | + | if !containsString(paths, wantCustom) { | |||
| 89 | + | t.Fatalf("expected custom search path %q in %#v", wantCustom, paths) | |||
| 90 | + | } | |||
| 91 | + | if !containsString(paths, wantNix) { | |||
| 92 | + | t.Fatalf("expected nix search path %q in %#v", wantNix, paths) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) { | |||
| 97 | + | env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0") | |||
| 98 | + | ||||
| 99 | + | if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" { | |||
| 100 | + | t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"]) | |||
| 101 | + | } | |||
| 102 | + | if env["PATH"] != "/usr/bin" { | |||
| 103 | + | t.Fatalf("expected unrelated env to be preserved") | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) { | |||
| 108 | + | env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0") | |||
| 109 | + | ||||
| 110 | + | want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so" | |||
| 111 | + | if env["LD_PRELOAD"] != want { | |||
| 112 | + | t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"]) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func containsString(items []string, want string) bool { | |||
| 117 | + | for _, item := range items { | |||
| 118 | + | if item == want { | |||
| 119 | + | return true | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | return false | |||
| 123 | + | } | |||
A internal/cli/cmd/omnibox.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/spf13/cobra" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | var omniboxCmd = &cobra.Command{ | |
| 8 | + | Use: "omnibox", | |
| 9 | + | Short: "Launch the standalone omnibox overlay", | |
| 10 | + | Args: cobra.NoArgs, | |
| 11 | + | Long: `Launch the GTK omnibox as a standalone surface. | |
| 12 | + | ||
| 13 | + | On Wayland, dumber will enable layer-shell when available so the omnibox can | |
| 14 | + | appear as its own overlay instead of inside the main browser window.`, | |
| 15 | + | Run: func(_ *cobra.Command, _ []string) { | |
| 16 | + | // Handled by cmd/dumber/main.go before Cobra executes. | |
| 17 | + | }, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func init() { | |
| 21 | + | rootCmd.AddCommand(omniboxCmd) | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/spf13/cobra" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | var omniboxCmd = &cobra.Command{ | |||
| 8 | + | Use: "omnibox", | |||
| 9 | + | Short: "Launch the standalone omnibox overlay", | |||
| 10 | + | Args: cobra.NoArgs, | |||
| 11 | + | Long: `Launch the GTK omnibox as a standalone surface. | |||
| 12 | + | ||||
| 13 | + | On Wayland, dumber will enable layer-shell when available so the omnibox can | |||
| 14 | + | appear as its own overlay instead of inside the main browser window.`, | |||
| 15 | + | Run: func(_ *cobra.Command, _ []string) { | |||
| 16 | + | // Handled by cmd/dumber/main.go before Cobra executes. | |||
| 17 | + | }, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func init() { | |||
| 21 | + | rootCmd.AddCommand(omniboxCmd) | |||
| 22 | + | } | |||
A internal/cli/cmd/omnibox_test.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRootCommand_RegistersOmniboxCommand(t *testing.T) { | |
| 6 | + | cmd, _, err := rootCmd.Find([]string{"omnibox"}) | |
| 7 | + | if err != nil { | |
| 8 | + | t.Fatalf("expected omnibox command to be registered: %v", err) | |
| 9 | + | } | |
| 10 | + | if cmd == nil || cmd.Name() != "omnibox" { | |
| 11 | + | t.Fatalf("expected omnibox command, got %#v", cmd) | |
| 12 | + | } | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestRootCommand_RegistersOmniboxCommand(t *testing.T) { | |||
| 6 | + | cmd, _, err := rootCmd.Find([]string{"omnibox"}) | |||
| 7 | + | if err != nil { | |||
| 8 | + | t.Fatalf("expected omnibox command to be registered: %v", err) | |||
| 9 | + | } | |||
| 10 | + | if cmd == nil || cmd.Name() != "omnibox" { | |||
| 11 | + | t.Fatalf("expected omnibox command, got %#v", cmd) | |||
| 12 | + | } | |||
| 13 | + | } | |||
M internal/cli/cmd/root.go +3 -3
| @@ -34,9 +34,9 @@ Features: | |||
| 34 | 34 | - Search shortcuts via bangs (!g, !gi, etc.) | |
| 35 | 35 | - Session management with auto-save and restore | |
| 36 | 36 | ||
| 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.`, | |
| 40 | 40 | PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { | |
| 41 | 41 | // Skip initialization for commands that don't need app context | |
| 42 | 42 | switch cmd.Name() { | |
| @@ -34,9 +34,9 @@ Features: | |||||
| 34 | - Search shortcuts via bangs (!g, !gi, etc.) | 34 | - Search shortcuts via bangs (!g, !gi, etc.) | ||
| 35 | - Session management with auto-save and restore | 35 | - Session management with auto-save and restore | ||
| 36 | 36 | ||||
| 37 | - | Use 'dumber browse' to launch the graphical browser, or explore the | 37 | + | Use 'dumber browse' to launch the graphical browser, 'dumber omnibox' to open |
| 38 | - | subcommands for CLI-based operations like history search and session | 38 | + | 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 context | 41 | // Skip initialization for commands that don't need app context | ||
| 42 | switch cmd.Name() { | 42 | switch cmd.Name() { | ||
M internal/domain/url/normalize.go +39 -0
| @@ -57,6 +57,10 @@ func Normalize(input string) string { | |||
| 57 | 57 | return input | |
| 58 | 58 | case strings.HasPrefix(input, "about:"): | |
| 59 | 59 | return input | |
| 60 | + | case IsExternalScheme(input): | |
| 61 | + | return input | |
| 62 | + | case hasExplicitSchemeURL(input): | |
| 63 | + | return input | |
| 60 | 64 | } | |
| 61 | 65 | ||
| 62 | 66 | // Check if input looks like a file path | |
| @@ -134,6 +138,10 @@ func LooksLikeURL(input string) bool { | |||
| 134 | 138 | return true | |
| 135 | 139 | case strings.HasPrefix(input, "about:"): | |
| 136 | 140 | return true | |
| 141 | + | case IsExternalScheme(input): | |
| 142 | + | return true | |
| 143 | + | case hasExplicitSchemeURL(input): | |
| 144 | + | return true | |
| 137 | 145 | } | |
| 138 | 146 | ||
| 139 | 147 | // Contains a dot and no spaces = likely a URL | |
| @@ -146,6 +154,19 @@ func LooksLikeURL(input string) bool { | |||
| 146 | 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 | 170 | // ExtractDomain extracts the normalized domain (host) from a URL string. | |
| 150 | 171 | // Normalizes by stripping "www." prefix so youtube.com and www.youtube.com | |
| 151 | 172 | // resolve to the same value. | |
| @@ -220,6 +241,12 @@ func IsExternalScheme(uri string) bool { | |||
| 220 | 241 | if err != nil || parsed.Scheme == "" { | |
| 221 | 242 | 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 | 251 | switch strings.ToLower(parsed.Scheme) { | |
| 225 | 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 | 271 | // ExtractOrigin extracts the origin (scheme://host) from a URI. | |
| 233 | 272 | // This normalizes URIs to origins for permission storage and comparison. | |
| 234 | 273 | // Canonicalizes by lowercasing scheme and host, and omitting default ports. | |
| @@ -57,6 +57,10 @@ func Normalize(input string) string { | |||||
| 57 | return input | 57 | return input | ||
| 58 | case strings.HasPrefix(input, "about:"): | 58 | case strings.HasPrefix(input, "about:"): | ||
| 59 | return input | 59 | 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 path | 66 | // Check if input looks like a file path | ||
| @@ -134,6 +138,10 @@ func LooksLikeURL(input string) bool { | |||||
| 134 | return true | 138 | return true | ||
| 135 | case strings.HasPrefix(input, "about:"): | 139 | case strings.HasPrefix(input, "about:"): | ||
| 136 | return true | 140 | 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 URL | 147 | // 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.com | 171 | // 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 false | 242 | 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) { | |||
| 42 | 42 | input: "about:blank", | |
| 43 | 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 | 61 | name: "domain gets https", | |
| 47 | 62 | input: "example.com", | |
| @@ -335,11 +350,36 @@ func TestLooksLikeURL(t *testing.T) { | |||
| 335 | 350 | input: "dumb://home", | |
| 336 | 351 | want: true, | |
| 337 | 352 | }, | |
| 353 | + | { | |
| 354 | + | name: "obsidian URL", | |
| 355 | + | input: "obsidian://open?vault=foo.bar", | |
| 356 | + | want: true, | |
| 357 | + | }, | |
| 338 | 358 | { | |
| 339 | 359 | name: "about URL", | |
| 340 | 360 | input: "about:blank", | |
| 341 | 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 | 384 | name: "domain", | |
| 345 | 385 | input: "example.com", | |
| @@ -519,6 +559,16 @@ func TestIsExternalScheme(t *testing.T) { | |||
| 519 | 559 | input: "VSCODE://open", | |
| 520 | 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 | 573 | name: "malformed URL", | |
| 524 | 574 | input: "://invalid", | |
| @@ -529,6 +579,11 @@ func TestIsExternalScheme(t *testing.T) { | |||
| 529 | 579 | input: "example.com", | |
| 530 | 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 | 588 | name: "scheme only", | |
| 534 | 589 | 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) { | |||
| 76 | 76 | defaultSearch: testDefaultSearch, | |
| 77 | 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 | 94 | name: "plain search query uses default", | |
| 81 | 95 | input: "how to parse json", | |
| @@ -76,6 +76,20 @@ func TestBuildSearchURL(t *testing.T) { | |||||
| 76 | defaultSearch: testDefaultSearch, | 76 | defaultSearch: testDefaultSearch, | ||
| 77 | want: "dumb://settings", | 77 | want: "dumb://settings", | ||
| 78 | }, | 78 | }, | ||
| 79 | + | { | |||
| 80 | + | name: "custom scheme spotify unchanged", | |||
| 81 | + | input: "spotify://track/123", | |||
| 82 | + | shortcuts: testShortcuts, | |||
| 83 | + | defaultSearch: testDefaultSearch, | |||
| 84 | + | want: "spotify://track/123", | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "mailto scheme unchanged", | |||
| 88 | + | input: "mailto:foo@example.com", | |||
| 89 | + | shortcuts: testShortcuts, | |||
| 90 | + | defaultSearch: testDefaultSearch, | |||
| 91 | + | want: "mailto:foo@example.com", | |||
| 92 | + | }, | |||
| 79 | { | 93 | { | ||
| 80 | name: "plain search query uses default", | 94 | name: "plain search query uses default", | ||
| 81 | input: "how to parse json", | 95 | input: "how to parse json", | ||
M internal/infrastructure/desktop/adapter.go +59 -4
| @@ -392,23 +392,78 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner { | |||
| 392 | 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| 393 | 393 | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | |
| 394 | 394 | ||
| 395 | + | const layerShellPreloadToken = "libgtk4" + "-layer-shell" | |
| 396 | + | ||
| 395 | 397 | // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). | |
| 396 | 398 | // Used for external URL schemes like vscode://, spotify://, steam://, etc. | |
| 397 | 399 | func LaunchExternalURL(uri string) { | |
| 398 | 400 | 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 | + | ||
| 400 | 427 | cmd.Stdin = nil | |
| 401 | 428 | cmd.Stdout = nil | |
| 402 | 429 | cmd.Stderr = nil | |
| 403 | 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) | |
| 405 | - | return | |
| 431 | + | return err | |
| 406 | 432 | } | |
| 407 | 433 | if cmd.Process != nil { | |
| 408 | 434 | if err := cmd.Process.Release(); err != nil { | |
| 409 | - | fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err) | |
| 435 | + | return err | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | return nil | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | func sanitizedChildEnv(env []string) []string { | |
| 443 | + | cleaned := make([]string, 0, len(env)) | |
| 444 | + | for _, entry := range env { | |
| 445 | + | if !strings.HasPrefix(entry, "LD_PRELOAD=") { | |
| 446 | + | cleaned = append(cleaned, entry) | |
| 447 | + | continue | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | value := strings.TrimPrefix(entry, "LD_PRELOAD=") | |
| 451 | + | parts := strings.FieldsFunc(value, func(r rune) bool { | |
| 452 | + | return r == ' ' || r == ':' | |
| 453 | + | }) | |
| 454 | + | kept := make([]string, 0, len(parts)) | |
| 455 | + | for _, part := range parts { | |
| 456 | + | if strings.Contains(part, layerShellPreloadToken) { | |
| 457 | + | continue | |
| 458 | + | } | |
| 459 | + | kept = append(kept, part) | |
| 460 | + | } | |
| 461 | + | if len(kept) > 0 { | |
| 462 | + | cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " ")) | |
| 410 | 463 | } | |
| 411 | 464 | } | |
| 465 | + | ||
| 466 | + | return cleaned | |
| 412 | 467 | } | |
| 413 | 468 | ||
| 414 | 469 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| @@ -392,23 +392,78 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner { | |||||
| 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | ||
| 393 | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | 393 | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | ||
| 394 | 394 | ||||
| 395 | + | const layerShellPreloadToken = "libgtk4" + "-layer-shell" | |||
| 396 | + | ||||
| 395 | // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). | 397 | // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). | ||
| 396 | // Used for external URL schemes like vscode://, spotify://, steam://, etc. | 398 | // Used for external URL schemes like vscode://, spotify://, steam://, etc. | ||
| 397 | func LaunchExternalURL(uri string) { | 399 | func LaunchExternalURL(uri string) { | ||
| 398 | cmd := exec.Command("xdg-open", uri) | 400 | cmd := exec.Command("xdg-open", uri) | ||
| 399 | - | // Detach from current process - we don't care about the 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 | + | ||||
| 400 | cmd.Stdin = nil | 427 | cmd.Stdin = nil | ||
| 401 | cmd.Stdout = nil | 428 | cmd.Stdout = nil | ||
| 402 | cmd.Stderr = nil | 429 | cmd.Stderr = nil | ||
| 403 | if err := cmd.Start(); err != nil { | 430 | if err := cmd.Start(); err != nil { | ||
| 404 | - | fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err) | 431 | + | return err |
| 405 | - | return | |||
| 406 | } | 432 | } | ||
| 407 | if cmd.Process != nil { | 433 | if cmd.Process != nil { | ||
| 408 | if err := cmd.Process.Release(); err != nil { | 434 | if err := cmd.Process.Release(); err != nil { | ||
| 409 | - | fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err) | 435 | + | return err |
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | return nil | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func sanitizedChildEnv(env []string) []string { | |||
| 443 | + | cleaned := make([]string, 0, len(env)) | |||
| 444 | + | for _, entry := range env { | |||
| 445 | + | if !strings.HasPrefix(entry, "LD_PRELOAD=") { | |||
| 446 | + | cleaned = append(cleaned, entry) | |||
| 447 | + | continue | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | value := strings.TrimPrefix(entry, "LD_PRELOAD=") | |||
| 451 | + | parts := strings.FieldsFunc(value, func(r rune) bool { | |||
| 452 | + | return r == ' ' || r == ':' | |||
| 453 | + | }) | |||
| 454 | + | kept := make([]string, 0, len(parts)) | |||
| 455 | + | for _, part := range parts { | |||
| 456 | + | if strings.Contains(part, layerShellPreloadToken) { | |||
| 457 | + | continue | |||
| 458 | + | } | |||
| 459 | + | kept = append(kept, part) | |||
| 460 | + | } | |||
| 461 | + | if len(kept) > 0 { | |||
| 462 | + | cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " ")) | |||
| 410 | } | 463 | } | ||
| 411 | } | 464 | } | ||
| 465 | + | ||||
| 466 | + | return cleaned | |||
| 412 | } | 467 | } | ||
| 413 | 468 | ||||
| 414 | // SpawnWithSession starts a new dumber instance to restore a session. | 469 | // SpawnWithSession starts a new dumber instance to restore a session. | ||
A internal/infrastructure/desktop/adapter_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |
| 6 | + | env := sanitizedChildEnv([]string{ | |
| 7 | + | "PATH=/usr/bin", | |
| 8 | + | "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so", | |
| 9 | + | }) | |
| 10 | + | ||
| 11 | + | if len(env) != 2 { | |
| 12 | + | t.Fatalf("expected two env entries, got %#v", env) | |
| 13 | + | } | |
| 14 | + | if env[0] != "PATH=/usr/bin" { | |
| 15 | + | t.Fatalf("expected PATH to be preserved, got %#v", env) | |
| 16 | + | } | |
| 17 | + | if env[1] != "LD_PRELOAD=/tmp/keep.so" { | |
| 18 | + | t.Fatalf("expected non-layer-shell preload to remain, got %#v", env) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) { | |
| 23 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"}) | |
| 24 | + | if len(env) != 0 { | |
| 25 | + | t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |
| 30 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"}) | |
| 31 | + | if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" { | |
| 32 | + | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package desktop | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |||
| 6 | + | env := sanitizedChildEnv([]string{ | |||
| 7 | + | "PATH=/usr/bin", | |||
| 8 | + | "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so", | |||
| 9 | + | }) | |||
| 10 | + | ||||
| 11 | + | if len(env) != 2 { | |||
| 12 | + | t.Fatalf("expected two env entries, got %#v", env) | |||
| 13 | + | } | |||
| 14 | + | if env[0] != "PATH=/usr/bin" { | |||
| 15 | + | t.Fatalf("expected PATH to be preserved, got %#v", env) | |||
| 16 | + | } | |||
| 17 | + | if env[1] != "LD_PRELOAD=/tmp/keep.so" { | |||
| 18 | + | t.Fatalf("expected non-layer-shell preload to remain, got %#v", env) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) { | |||
| 23 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"}) | |||
| 24 | + | if len(env) != 0 { | |||
| 25 | + | t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |||
| 30 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"}) | |||
| 31 | + | if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" { | |||
| 32 | + | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/ui/app.go +95 -23
| @@ -751,33 +751,101 @@ func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |||
| 751 | 751 | } | |
| 752 | 752 | } | |
| 753 | 753 | ||
| 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 { | |
| 764 | 774 | shortcuts[key] = usecase.SearchShortcut{ | |
| 765 | 775 | URL: shortcut.URL, | |
| 766 | 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). | |
| 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{ | |
| 781 | 849 | OnNavigate: func(url string) { | |
| 782 | 850 | if err := a.navigateFromOmnibox(ctx, url); err != nil { | |
| 783 | 851 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | |
| @@ -803,7 +871,7 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 803 | 871 | OnAccentKeyRelease: func(keyval uint) { | |
| 804 | 872 | a.handleAccentKeyRelease(ctx, keyval) | |
| 805 | 873 | }, | |
| 806 | - | } | |
| 874 | + | }) | |
| 807 | 875 | a.navCoord.SetOmniboxProvider(a) | |
| 808 | 876 | log.Debug().Msg("omnibox config stored, provider set") | |
| 809 | 877 | } | |
| @@ -2410,7 +2478,11 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve | |||
| 2410 | 2478 | 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 | 2486 | overlayWidth := workspaceOverlay.GetAllocatedWidth() | |
| 2415 | 2487 | overlayHeight := workspaceOverlay.GetAllocatedHeight() | |
| 2416 | 2488 | 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 | - | return | 756 | + | 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 type | 761 | + | } |
| 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 | return | 2478 | 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 ( | |||
| 7 | 7 | ||
| 8 | 8 | // ModalSizeConfig holds configuration for modal sizing calculations. | |
| 9 | 9 | 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 | |
| 15 | 18 | } | |
| 16 | 19 | ||
| 17 | 20 | // RowHeightDefaults holds default (unscaled) row heights for list-based modals. | |
| @@ -110,6 +113,36 @@ func EffectiveMaxRows(parentHeight, rowHeight int, sizeCfg ModalSizeConfig, defa | |||
| 110 | 113 | 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 | 146 | // CalculateModalDimensions computes width and top margin based on parent overlay. | |
| 114 | 147 | // Returns calculated width and top margin in pixels. | |
| 115 | 148 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | |
| @@ -129,12 +162,20 @@ func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) | |||
| 129 | 162 | parentHeight = cfg.FallbackHeight | |
| 130 | 163 | } | |
| 131 | 164 | ||
| 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 | + | } | |
| 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 | 179 | return width, marginTop | |
| 139 | 180 | } | |
| 140 | 181 | ||
| @@ -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 pixels | 11 | + | MaxWidth int // Maximum width in pixels |
| 12 | - | TopMarginPct float64 // Top margin as percentage of parent height | 12 | + | TopMarginPct float64 // Top margin as percentage of parent height |
| 13 | - | FallbackWidth int // Fallback when parent not allocated | 13 | + | FallbackWidth int // Fallback when parent not allocated |
| 14 | - | FallbackHeight int // Fallback height | 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 | |||
| 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.MaxVisibleRows | 113 | 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.FallbackHeight | 162 | 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.MaxWidth | 167 | + | } 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, marginTop | 179 | 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) { | |||
| 88 | 88 | assert.Equal(t, 90, marginTop) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func TestCalculateModalDimensions_FixedWidthAndTopMargin(t *testing.T) { | |
| 92 | + | cfg := ModalSizeConfig{ | |
| 93 | + | WidthPct: 0.8, | |
| 94 | + | MaxWidth: 600, | |
| 95 | + | TopMarginPct: 0.2, | |
| 96 | + | FallbackWidth: 1000, | |
| 97 | + | FallbackHeight: 800, | |
| 98 | + | FixedWidth: 800, | |
| 99 | + | FixedTopMargin: 0, | |
| 100 | + | UseFixedTopMargin: true, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |
| 104 | + | parent.EXPECT().GetAllocatedWidth().Return(1920) | |
| 105 | + | parent.EXPECT().GetAllocatedHeight().Return(1080) | |
| 106 | + | ||
| 107 | + | width, marginTop := CalculateModalDimensions(parent, cfg) | |
| 108 | + | ||
| 109 | + | assert.Equal(t, 800, width) | |
| 110 | + | assert.Equal(t, 0, marginTop) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestResolveModalSizeConfig_MergesDefaultsAndOverrides(t *testing.T) { | |
| 114 | + | defaults := OmniboxSizeDefaults | |
| 115 | + | override := ModalSizeConfig{ | |
| 116 | + | FixedWidth: 800, | |
| 117 | + | FixedTopMargin: 0, | |
| 118 | + | UseFixedTopMargin: true, | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | resolved := ResolveModalSizeConfig(override, defaults) | |
| 122 | + | ||
| 123 | + | assert.Equal(t, defaults.WidthPct, resolved.WidthPct) | |
| 124 | + | assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth) | |
| 125 | + | assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth) | |
| 126 | + | assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight) | |
| 127 | + | assert.Equal(t, 800, resolved.FixedWidth) | |
| 128 | + | assert.True(t, resolved.UseFixedTopMargin) | |
| 129 | + | assert.Equal(t, 0, resolved.FixedTopMargin) | |
| 130 | + | } | |
| 131 | + | ||
| 91 | 132 | func TestScaleValue_DefaultScale(t *testing.T) { | |
| 92 | 133 | // When scale is 0 or negative, should default to 1.0 | |
| 93 | 134 | assert.Equal(t, 50, ScaleValue(50, 0)) | |
| @@ -122,11 +163,10 @@ func TestDefaultRowHeights_Values(t *testing.T) { | |||
| 122 | 163 | } | |
| 123 | 164 | ||
| 124 | 165 | 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) | |
| 130 | 170 | } | |
| 131 | 171 | ||
| 132 | 172 | func TestSessionManagerSizeDefaults_Values(t *testing.T) { | |
| @@ -88,6 +88,47 @@ func TestCalculateModalDimensions_WidthPercentage(t *testing.T) { | |||||
| 88 | assert.Equal(t, 90, marginTop) | 88 | assert.Equal(t, 90, marginTop) | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | + | func TestCalculateModalDimensions_FixedWidthAndTopMargin(t *testing.T) { | |||
| 92 | + | cfg := ModalSizeConfig{ | |||
| 93 | + | WidthPct: 0.8, | |||
| 94 | + | MaxWidth: 600, | |||
| 95 | + | TopMarginPct: 0.2, | |||
| 96 | + | FallbackWidth: 1000, | |||
| 97 | + | FallbackHeight: 800, | |||
| 98 | + | FixedWidth: 800, | |||
| 99 | + | FixedTopMargin: 0, | |||
| 100 | + | UseFixedTopMargin: true, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | parent := layoutmocks.NewMockOverlayWidget(t) | |||
| 104 | + | parent.EXPECT().GetAllocatedWidth().Return(1920) | |||
| 105 | + | parent.EXPECT().GetAllocatedHeight().Return(1080) | |||
| 106 | + | ||||
| 107 | + | width, marginTop := CalculateModalDimensions(parent, cfg) | |||
| 108 | + | ||||
| 109 | + | assert.Equal(t, 800, width) | |||
| 110 | + | assert.Equal(t, 0, marginTop) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestResolveModalSizeConfig_MergesDefaultsAndOverrides(t *testing.T) { | |||
| 114 | + | defaults := OmniboxSizeDefaults | |||
| 115 | + | override := ModalSizeConfig{ | |||
| 116 | + | FixedWidth: 800, | |||
| 117 | + | FixedTopMargin: 0, | |||
| 118 | + | UseFixedTopMargin: true, | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | resolved := ResolveModalSizeConfig(override, defaults) | |||
| 122 | + | ||||
| 123 | + | assert.Equal(t, defaults.WidthPct, resolved.WidthPct) | |||
| 124 | + | assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth) | |||
| 125 | + | assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth) | |||
| 126 | + | assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight) | |||
| 127 | + | assert.Equal(t, 800, resolved.FixedWidth) | |||
| 128 | + | assert.True(t, resolved.UseFixedTopMargin) | |||
| 129 | + | assert.Equal(t, 0, resolved.FixedTopMargin) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 91 | func TestScaleValue_DefaultScale(t *testing.T) { | 132 | func TestScaleValue_DefaultScale(t *testing.T) { | ||
| 92 | // When scale is 0 or negative, should default to 1.0 | 133 | // When scale is 0 or negative, should default to 1.0 | ||
| 93 | assert.Equal(t, 50, ScaleValue(50, 0)) | 134 | assert.Equal(t, 50, ScaleValue(50, 0)) | ||
| @@ -122,11 +163,10 @@ func TestDefaultRowHeights_Values(t *testing.T) { | |||||
| 122 | } | 163 | } | ||
| 123 | 164 | ||||
| 124 | func TestOmniboxSizeDefaults_Values(t *testing.T) { | 165 | func TestOmniboxSizeDefaults_Values(t *testing.T) { | ||
| 125 | - | assert.Greater(t, OmniboxSizeDefaults.WidthPct, 0.0) | 166 | + | assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001) |
| 126 | - | assert.LessOrEqual(t, OmniboxSizeDefaults.WidthPct, 1.0) | 167 | + | assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth) |
| 127 | - | assert.Positive(t, OmniboxSizeDefaults.MaxWidth) | 168 | + | assert.InDelta(t, 0.2, OmniboxSizeDefaults.TopMarginPct, 0.0001) |
| 128 | - | assert.Greater(t, OmniboxSizeDefaults.TopMarginPct, 0.0) | 169 | + | assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight) |
| 129 | - | assert.LessOrEqual(t, OmniboxSizeDefaults.TopMarginPct, 1.0) | |||
| 130 | } | 170 | } | ||
| 131 | 171 | ||||
| 132 | func TestSessionManagerSizeDefaults_Values(t *testing.T) { | 172 | func TestSessionManagerSizeDefaults_Values(t *testing.T) { | ||
M internal/ui/component/omnibox.go +220 -78
| @@ -124,6 +124,7 @@ type Omnibox struct { | |||
| 124 | 124 | ||
| 125 | 125 | // Scaling | |
| 126 | 126 | uiScale float64 | |
| 127 | + | sizeCfg ModalSizeConfig | |
| 127 | 128 | ||
| 128 | 129 | // Cached measurements (populated after first layout) | |
| 129 | 130 | measuredHeights struct { | |
| @@ -158,6 +159,7 @@ type OmniboxConfig struct { | |||
| 158 | 159 | OnFocusOut func() // Callback when entry loses focus | |
| 159 | 160 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 160 | 161 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 162 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |
| 161 | 163 | } | |
| 162 | 164 | ||
| 163 | 165 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -170,6 +172,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 170 | 172 | uiScale = 1.0 | |
| 171 | 173 | } | |
| 172 | 174 | ||
| 175 | + | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |
| 176 | + | ||
| 173 | 177 | o := &Omnibox{ | |
| 174 | 178 | viewMode: ViewModeHistory, | |
| 175 | 179 | selectedIndex: -1, | |
| @@ -185,6 +189,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 185 | 189 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 186 | 190 | ctx: ctx, | |
| 187 | 191 | uiScale: uiScale, | |
| 192 | + | sizeCfg: sizeCfg, | |
| 188 | 193 | } | |
| 189 | 194 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 190 | 195 | var cb glib.SourceFunc = func(uintptr) bool { | |
| @@ -317,13 +322,92 @@ func (o *Omnibox) estimateRowHeight() int { | |||
| 317 | 322 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | |
| 318 | 323 | } | |
| 319 | 324 | ||
| 325 | + | func resultsContainerState(rowCount int) (visible bool, expand bool, listVisible bool, emptyBackdrop bool) { | |
| 326 | + | return rowCount > 0, rowCount > 0, rowCount > 0, false | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (o *Omnibox) setResultsContainerState(rowCount int) { | |
| 330 | + | if o.scrolledWin == nil { | |
| 331 | + | return | |
| 332 | + | } | |
| 333 | + | visible, expand, listVisible, emptyBackdrop := resultsContainerState(rowCount) | |
| 334 | + | o.scrolledWin.SetVisible(visible) | |
| 335 | + | o.scrolledWin.SetVexpand(expand) | |
| 336 | + | if o.listBox != nil { | |
| 337 | + | o.listBox.SetVisible(listVisible) | |
| 338 | + | } | |
| 339 | + | if o.mainBox != nil && !emptyBackdrop { | |
| 340 | + | o.mainBox.RemoveCssClass("omnibox-empty") | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func logOmniboxWidgetState(ctx context.Context, overlay layout.OverlayWidget, outerBox, mainBox *gtk.Box, scrolledWin *gtk.ScrolledWindow, listBox *gtk.ListBox) { | |
| 345 | + | log := logging.FromContext(ctx) | |
| 346 | + | var overlayWidth, overlayHeight int | |
| 347 | + | if overlay != nil { | |
| 348 | + | overlayWidth = overlay.GetAllocatedWidth() | |
| 349 | + | overlayHeight = overlay.GetAllocatedHeight() | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | var outerVisible bool | |
| 353 | + | var outerWidth, outerHeight int | |
| 354 | + | if outerBox != nil { | |
| 355 | + | outerVisible = outerBox.GetVisible() | |
| 356 | + | outerWidth = outerBox.GetAllocatedWidth() | |
| 357 | + | outerHeight = outerBox.GetAllocatedHeight() | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | var mainVisible bool | |
| 361 | + | var mainWidth, mainHeight int | |
| 362 | + | if mainBox != nil { | |
| 363 | + | mainVisible = mainBox.GetVisible() | |
| 364 | + | mainWidth = mainBox.GetAllocatedWidth() | |
| 365 | + | mainHeight = mainBox.GetAllocatedHeight() | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | var scrolledVisible, scrolledExpand bool | |
| 369 | + | var scrolledWidth, scrolledHeight int | |
| 370 | + | if scrolledWin != nil { | |
| 371 | + | scrolledVisible = scrolledWin.GetVisible() | |
| 372 | + | scrolledExpand = scrolledWin.GetVexpand() | |
| 373 | + | scrolledWidth = scrolledWin.GetAllocatedWidth() | |
| 374 | + | scrolledHeight = scrolledWin.GetAllocatedHeight() | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | var listVisible bool | |
| 378 | + | var listWidth, listHeight int | |
| 379 | + | if listBox != nil { | |
| 380 | + | listVisible = listBox.GetVisible() | |
| 381 | + | listWidth = listBox.GetAllocatedWidth() | |
| 382 | + | listHeight = listBox.GetAllocatedHeight() | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | log.Debug(). | |
| 386 | + | Int("overlayWidth", overlayWidth). | |
| 387 | + | Int("overlayHeight", overlayHeight). | |
| 388 | + | Bool("outerVisible", outerVisible). | |
| 389 | + | Int("outerWidth", outerWidth). | |
| 390 | + | Int("outerHeight", outerHeight). | |
| 391 | + | Bool("mainVisible", mainVisible). | |
| 392 | + | Int("mainWidth", mainWidth). | |
| 393 | + | Int("mainHeight", mainHeight). | |
| 394 | + | Bool("scrolledVisible", scrolledVisible). | |
| 395 | + | Bool("scrolledExpand", scrolledExpand). | |
| 396 | + | Int("scrolledWidth", scrolledWidth). | |
| 397 | + | Int("scrolledHeight", scrolledHeight). | |
| 398 | + | Bool("listVisible", listVisible). | |
| 399 | + | Int("listWidth", listWidth). | |
| 400 | + | Int("listHeight", listHeight). | |
| 401 | + | Msg("omnibox widget state") | |
| 402 | + | } | |
| 403 | + | ||
| 320 | 404 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | |
| 321 | 405 | // Must be called on the GTK main thread. | |
| 322 | 406 | func (o *Omnibox) effectiveMaxRows() int { | |
| 323 | 407 | if o.parentOverlay == nil { | |
| 324 | 408 | return OmniboxListDefaults.MaxVisibleRows | |
| 325 | 409 | } | |
| 326 | - | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | |
| 410 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) | |
| 327 | 411 | } | |
| 328 | 412 | ||
| 329 | 413 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | |
| @@ -340,7 +424,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 340 | 424 | ||
| 341 | 425 | // Schedule measurement after GTK has laid out widgets | |
| 342 | 426 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 343 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 427 | + | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 344 | 428 | o.measureAndResize(width, rowCount) | |
| 345 | 429 | return false | |
| 346 | 430 | } | |
| @@ -387,6 +471,21 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 387 | 471 | ||
| 388 | 472 | // Force layout recalculation | |
| 389 | 473 | o.outerBox.QueueResize() | |
| 474 | + | if parent := o.parentOverlay; parent != nil { | |
| 475 | + | if widget := parent.GtkWidget(); widget != nil { | |
| 476 | + | widget.QueueResize() | |
| 477 | + | if parentWidget := widget.GetParent(); parentWidget != nil { | |
| 478 | + | parentWidget.QueueResize() | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | if o.listBox != nil { | |
| 483 | + | o.listBox.QueueResize() | |
| 484 | + | } | |
| 485 | + | if o.scrolledWin != nil { | |
| 486 | + | o.scrolledWin.QueueResize() | |
| 487 | + | } | |
| 488 | + | o.mainBox.QueueResize() | |
| 390 | 489 | ||
| 391 | 490 | log.Debug(). | |
| 392 | 491 | Int("width", width). | |
| @@ -395,6 +494,12 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 395 | 494 | Int("rows", rowCount). | |
| 396 | 495 | Bool("measured", o.measuredHeights.valid). | |
| 397 | 496 | Msg("omnibox resized") | |
| 497 | + | ||
| 498 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 499 | + | logOmniboxWidgetState(o.ctx, o.parentOverlay, o.outerBox, o.mainBox, o.scrolledWin, o.listBox) | |
| 500 | + | return false | |
| 501 | + | } | |
| 502 | + | glib.IdleAdd(&cb, 0) | |
| 398 | 503 | } | |
| 399 | 504 | ||
| 400 | 505 | // createWidgets builds the GTK widget hierarchy. | |
| @@ -544,7 +649,7 @@ func (o *Omnibox) initList() error { | |||
| 544 | 649 | return errNilWidget("scrolledWin") | |
| 545 | 650 | } | |
| 546 | 651 | o.scrolledWin.AddCssClass("omnibox-scrolled") | |
| 547 | - | o.scrolledWin.SetVexpand(true) | |
| 652 | + | o.scrolledWin.SetVexpand(false) | |
| 548 | 653 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 549 | 654 | o.scrolledWin.SetPropagateNaturalHeight(true) | |
| 550 | 655 | ||
| @@ -555,19 +660,16 @@ func (o *Omnibox) initList() error { | |||
| 555 | 660 | o.listBox.AddCssClass("omnibox-listbox") | |
| 556 | 661 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 557 | 662 | ||
| 558 | - | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 663 | + | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 559 | 664 | o.restoreEntryToRealInput() | |
| 560 | - | if rowPtr == 0 { | |
| 665 | + | if row == nil { | |
| 561 | 666 | o.mu.Lock() | |
| 562 | 667 | o.selectedIndex = -1 | |
| 563 | 668 | o.mu.Unlock() | |
| 564 | 669 | } else { | |
| 565 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 566 | - | if row != nil { | |
| 567 | - | o.mu.Lock() | |
| 568 | - | o.selectedIndex = row.GetIndex() | |
| 569 | - | o.mu.Unlock() | |
| 570 | - | } | |
| 670 | + | o.mu.Lock() | |
| 671 | + | o.selectedIndex = row.GetIndex() | |
| 672 | + | o.mu.Unlock() | |
| 571 | 673 | } | |
| 572 | 674 | o.updateGhostFromSelection() | |
| 573 | 675 | } | |
| @@ -575,11 +677,7 @@ func (o *Omnibox) initList() error { | |||
| 575 | 677 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 576 | 678 | ||
| 577 | 679 | // Handle row activation (click or Enter) - navigate directly to the URL | |
| 578 | - | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 579 | - | if rowPtr == 0 { | |
| 580 | - | return | |
| 581 | - | } | |
| 582 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 680 | + | rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 583 | 681 | if row == nil { | |
| 584 | 682 | return | |
| 585 | 683 | } | |
| @@ -1598,9 +1696,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 1598 | 1696 | o.rebuildList() | |
| 1599 | 1697 | ||
| 1600 | 1698 | rowCount := len(suggestions) | |
| 1601 | - | if o.scrolledWin != nil { | |
| 1602 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1603 | - | } | |
| 1699 | + | o.setResultsContainerState(rowCount) | |
| 1604 | 1700 | o.resizeAndCenter(rowCount) | |
| 1605 | 1701 | } | |
| 1606 | 1702 | ||
| @@ -1618,9 +1714,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||
| 1618 | 1714 | ||
| 1619 | 1715 | // Hide scrolled window when there are no suggestions | |
| 1620 | 1716 | rowCount := len(suggestions) | |
| 1621 | - | if o.scrolledWin != nil { | |
| 1622 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1623 | - | } | |
| 1717 | + | o.setResultsContainerState(rowCount) | |
| 1624 | 1718 | o.resizeAndCenter(rowCount) | |
| 1625 | 1719 | ||
| 1626 | 1720 | if rowCount > 0 { | |
| @@ -1644,9 +1738,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||
| 1644 | 1738 | ||
| 1645 | 1739 | // Hide scrolled window when there are no favorites | |
| 1646 | 1740 | rowCount := len(favorites) | |
| 1647 | - | if o.scrolledWin != nil { | |
| 1648 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1649 | - | } | |
| 1741 | + | o.setResultsContainerState(rowCount) | |
| 1650 | 1742 | o.resizeAndCenter(rowCount) | |
| 1651 | 1743 | ||
| 1652 | 1744 | if rowCount > 0 { | |
| @@ -1702,7 +1794,7 @@ func (o *Omnibox) rebuildList() { | |||
| 1702 | 1794 | if width <= 0 { | |
| 1703 | 1795 | return false // Overlay not allocated yet, skip | |
| 1704 | 1796 | } | |
| 1705 | - | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | |
| 1797 | + | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 1706 | 1798 | if o.measureComponentHeights(forWidth) { | |
| 1707 | 1799 | // Re-trigger resize with accurate measurements | |
| 1708 | 1800 | o.mu.RLock() | |
| @@ -1749,15 +1841,91 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||
| 1749 | 1841 | return favicon | |
| 1750 | 1842 | } | |
| 1751 | 1843 | ||
| 1844 | + | const favoriteStarBaseSize = 18 | |
| 1845 | + | ||
| 1846 | + | func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) } | |
| 1847 | + | ||
| 1848 | + | func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite } | |
| 1849 | + | ||
| 1850 | + | func (o *Omnibox) createFavoriteStarIcon() *gtk.Image { | |
| 1851 | + | star := gtk.NewImage() | |
| 1852 | + | if star == nil { | |
| 1853 | + | return nil | |
| 1854 | + | } | |
| 1855 | + | iconName := "starred-symbolic" | |
| 1856 | + | star.SetFromIconName(&iconName) | |
| 1857 | + | star.SetPixelSize(favoriteStarSize(o.uiScale)) | |
| 1858 | + | star.AddCssClass("omnibox-favorite-star") | |
| 1859 | + | star.SetValign(gtk.AlignCenterValue) | |
| 1860 | + | return star | |
| 1861 | + | } | |
| 1862 | + | ||
| 1863 | + | func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) { | |
| 1864 | + | displayTitle := title | |
| 1865 | + | if displayTitle == "" { | |
| 1866 | + | displayTitle = displayURL | |
| 1867 | + | } | |
| 1868 | + | ||
| 1869 | + | titleLabel := gtk.NewLabel(nil) | |
| 1870 | + | if titleLabel != nil { | |
| 1871 | + | titleLabel.SetText(displayTitle) | |
| 1872 | + | titleLabel.AddCssClass("omnibox-suggestion-title") | |
| 1873 | + | titleLabel.SetHalign(gtk.AlignStartValue) | |
| 1874 | + | titleLabel.SetEllipsize(2) | |
| 1875 | + | textBox.Append(&titleLabel.Widget) | |
| 1876 | + | } | |
| 1877 | + | ||
| 1878 | + | if title != "" && title != displayURL { | |
| 1879 | + | urlLabel := gtk.NewLabel(nil) | |
| 1880 | + | if urlLabel != nil { | |
| 1881 | + | urlLabel.SetText(displayURL) | |
| 1882 | + | urlLabel.AddCssClass("omnibox-suggestion-url") | |
| 1883 | + | urlLabel.SetHalign(gtk.AlignStartValue) | |
| 1884 | + | urlLabel.SetEllipsize(2) | |
| 1885 | + | textBox.Append(&urlLabel.Widget) | |
| 1886 | + | } | |
| 1887 | + | } | |
| 1888 | + | } | |
| 1889 | + | ||
| 1890 | + | func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) { | |
| 1891 | + | if showFavoriteStar { | |
| 1892 | + | if star := o.createFavoriteStarIcon(); star != nil { | |
| 1893 | + | hbox.Append(&star.Widget) | |
| 1894 | + | } | |
| 1895 | + | } | |
| 1896 | + | ||
| 1897 | + | const maxShortcutIndex = 9 | |
| 1898 | + | if index > maxShortcutIndex { | |
| 1899 | + | return | |
| 1900 | + | } | |
| 1901 | + | ||
| 1902 | + | shortcutLabel := gtk.NewLabel(nil) | |
| 1903 | + | if shortcutLabel == nil { | |
| 1904 | + | return | |
| 1905 | + | } | |
| 1906 | + | if index < maxShortcutIndex { | |
| 1907 | + | shortcutLabel.SetText(formatShortcut(index + 1)) | |
| 1908 | + | } else { | |
| 1909 | + | shortcutLabel.SetText("Ctrl+0") | |
| 1910 | + | } | |
| 1911 | + | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |
| 1912 | + | shortcutLabel.SetValign(gtk.AlignCenterValue) | |
| 1913 | + | hbox.Append(&shortcutLabel.Widget) | |
| 1914 | + | } | |
| 1915 | + | ||
| 1752 | 1916 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |
| 1753 | 1917 | // Uses rawURL for both favicon fetching and display. | |
| 1754 | 1918 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1755 | - | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | |
| 1919 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index) | |
| 1756 | 1920 | } | |
| 1757 | 1921 | ||
| 1758 | 1922 | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |
| 1759 | 1923 | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |
| 1760 | - | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1924 | + | func (o *Omnibox) createRowWithFaviconURL( | |
| 1925 | + | displayURL, title, faviconURL, fallbackIcon string, | |
| 1926 | + | showFavoriteStar bool, | |
| 1927 | + | index int, | |
| 1928 | + | ) *gtk.ListBoxRow { | |
| 1761 | 1929 | row := gtk.NewListBoxRow() | |
| 1762 | 1930 | if row == nil { | |
| 1763 | 1931 | return nil | |
| @@ -1786,49 +1954,10 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||
| 1786 | 1954 | textBox.SetHexpand(true) | |
| 1787 | 1955 | textBox.SetValign(gtk.AlignCenterValue) | |
| 1788 | 1956 | ||
| 1789 | - | // Title label (or URL if no title) | |
| 1790 | - | displayTitle := title | |
| 1791 | - | if displayTitle == "" { | |
| 1792 | - | displayTitle = displayURL | |
| 1793 | - | } | |
| 1794 | - | titleLabel := gtk.NewLabel(nil) | |
| 1795 | - | if titleLabel != nil { | |
| 1796 | - | titleLabel.SetText(displayTitle) | |
| 1797 | - | titleLabel.AddCssClass("omnibox-suggestion-title") | |
| 1798 | - | titleLabel.SetHalign(gtk.AlignStartValue) | |
| 1799 | - | titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| 1800 | - | textBox.Append(&titleLabel.Widget) | |
| 1801 | - | } | |
| 1802 | - | ||
| 1803 | - | // URL label (only if title exists and differs from URL) | |
| 1804 | - | if title != "" && title != displayURL { | |
| 1805 | - | urlLabel := gtk.NewLabel(nil) | |
| 1806 | - | if urlLabel != nil { | |
| 1807 | - | urlLabel.SetText(displayURL) | |
| 1808 | - | urlLabel.AddCssClass("omnibox-suggestion-url") | |
| 1809 | - | urlLabel.SetHalign(gtk.AlignStartValue) | |
| 1810 | - | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| 1811 | - | textBox.Append(&urlLabel.Widget) | |
| 1812 | - | } | |
| 1813 | - | } | |
| 1957 | + | o.appendSuggestionTitleAndURL(textBox, title, displayURL) | |
| 1814 | 1958 | ||
| 1815 | 1959 | hbox.Append(&textBox.Widget) | |
| 1816 | - | ||
| 1817 | - | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | |
| 1818 | - | const maxShortcutIndex = 9 | |
| 1819 | - | if index <= maxShortcutIndex { | |
| 1820 | - | shortcutLabel := gtk.NewLabel(nil) | |
| 1821 | - | if shortcutLabel != nil { | |
| 1822 | - | if index < maxShortcutIndex { | |
| 1823 | - | shortcutLabel.SetText(formatShortcut(index + 1)) | |
| 1824 | - | } else { | |
| 1825 | - | shortcutLabel.SetText("Ctrl+0") | |
| 1826 | - | } | |
| 1827 | - | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |
| 1828 | - | shortcutLabel.SetValign(gtk.AlignCenterValue) | |
| 1829 | - | hbox.Append(&shortcutLabel.Widget) | |
| 1830 | - | } | |
| 1831 | - | } | |
| 1960 | + | o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index) | |
| 1832 | 1961 | ||
| 1833 | 1962 | row.SetChild(&hbox.Widget) | |
| 1834 | 1963 | return row | |
| @@ -1863,7 +1992,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo | |||
| 1863 | 1992 | ||
| 1864 | 1993 | // createSuggestionRow creates a ListBoxRow for a suggestion. | |
| 1865 | 1994 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | |
| 1866 | - | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | |
| 1995 | + | row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index) | |
| 1867 | 1996 | if row != nil && s.IsFavorite { | |
| 1868 | 1997 | row.AddCssClass("omnibox-row-favorite") | |
| 1869 | 1998 | } | |
| @@ -1878,7 +2007,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 1878 | 2007 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |
| 1879 | 2008 | // Pass description as URL param (displayed as secondary label) and empty | |
| 1880 | 2009 | // faviconURL to skip async favicon fetching - bang rows use static icon only | |
| 1881 | - | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | |
| 2010 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index) | |
| 1882 | 2011 | if row != nil { | |
| 1883 | 2012 | row.AddCssClass("omnibox-row-bang") | |
| 1884 | 2013 | } | |
| @@ -2316,20 +2445,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2316 | 2445 | o.entry.SetText(query) | |
| 2317 | 2446 | o.entry.SelectRegion(-1, -1) | |
| 2318 | 2447 | ||
| 2319 | - | // Determine if we expect content initially | |
| 2320 | - | // No content expected if: no query AND initialBehavior is "none" | |
| 2321 | - | expectContent := query != "" || o.initialBehavior != "none" | |
| 2322 | - | ||
| 2323 | - | // Hide scrolled window if no content expected | |
| 2448 | + | // Keep the results container hidden until real rows arrive. | |
| 2449 | + | // Pre-showing an empty, expanding scroller renders a dead area in standalone mode. | |
| 2324 | 2450 | if o.scrolledWin != nil { | |
| 2325 | - | o.scrolledWin.SetVisible(expectContent) | |
| 2451 | + | o.setResultsContainerState(0) | |
| 2326 | 2452 | // Reset content height constraints - will be updated when results arrive | |
| 2327 | 2453 | o.scrolledWin.SetMinContentHeight(-1) | |
| 2328 | 2454 | o.scrolledWin.SetMaxContentHeight(0) | |
| 2329 | 2455 | } | |
| 2330 | 2456 | ||
| 2331 | 2457 | // Calculate dimensions using shared helper | |
| 2332 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 2458 | + | parentWidth, parentHeight := 0, 0 | |
| 2459 | + | if o.parentOverlay != nil { | |
| 2460 | + | parentWidth = o.parentOverlay.GetAllocatedWidth() | |
| 2461 | + | parentHeight = o.parentOverlay.GetAllocatedHeight() | |
| 2462 | + | } | |
| 2463 | + | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 2464 | + | log.Debug(). | |
| 2465 | + | Int("parentWidth", parentWidth). | |
| 2466 | + | Int("parentHeight", parentHeight). | |
| 2467 | + | Int("width", width). | |
| 2468 | + | Int("marginTop", marginTop). | |
| 2469 | + | Int("fixedWidth", o.sizeCfg.FixedWidth). | |
| 2470 | + | Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin). | |
| 2471 | + | Int("fixedTopMargin", o.sizeCfg.FixedTopMargin). | |
| 2472 | + | Float64("widthPct", o.sizeCfg.WidthPct). | |
| 2473 | + | Float64("topMarginPct", o.sizeCfg.TopMarginPct). | |
| 2474 | + | Msg("omnibox geometry decision") | |
| 2333 | 2475 | ||
| 2334 | 2476 | o.mainBox.SetSizeRequest(width, -1) | |
| 2335 | 2477 | o.outerBox.SetMarginTop(marginTop) | |
| @@ -124,6 +124,7 @@ type Omnibox struct { | |||||
| 124 | 124 | ||||
| 125 | // Scaling | 125 | // Scaling | ||
| 126 | uiScale float64 | 126 | uiScale float64 | ||
| 127 | + | sizeCfg ModalSizeConfig | |||
| 127 | 128 | ||||
| 128 | // Cached measurements (populated after first layout) | 129 | // Cached measurements (populated after first layout) | ||
| 129 | measuredHeights struct { | 130 | measuredHeights struct { | ||
| @@ -158,6 +159,7 @@ type OmniboxConfig struct { | |||||
| 158 | OnFocusOut func() // Callback when entry loses focus | 159 | OnFocusOut func() // Callback when entry loses focus | ||
| 159 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | 160 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | ||
| 160 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | 161 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | ||
| 162 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |||
| 161 | } | 163 | } | ||
| 162 | 164 | ||||
| 163 | // NewOmnibox creates a new native GTK4 omnibox widget. | 165 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -170,6 +172,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 170 | uiScale = 1.0 | 172 | uiScale = 1.0 | ||
| 171 | } | 173 | } | ||
| 172 | 174 | ||||
| 175 | + | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |||
| 176 | + | ||||
| 173 | o := &Omnibox{ | 177 | o := &Omnibox{ | ||
| 174 | viewMode: ViewModeHistory, | 178 | viewMode: ViewModeHistory, | ||
| 175 | selectedIndex: -1, | 179 | selectedIndex: -1, | ||
| @@ -185,6 +189,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 185 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | 189 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | ||
| 186 | ctx: ctx, | 190 | ctx: ctx, | ||
| 187 | uiScale: uiScale, | 191 | uiScale: uiScale, | ||
| 192 | + | sizeCfg: sizeCfg, | |||
| 188 | } | 193 | } | ||
| 189 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | 194 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | ||
| 190 | var cb glib.SourceFunc = func(uintptr) bool { | 195 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| @@ -317,13 +322,92 @@ func (o *Omnibox) estimateRowHeight() int { | |||||
| 317 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | 322 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | ||
| 318 | } | 323 | } | ||
| 319 | 324 | ||||
| 325 | + | func resultsContainerState(rowCount int) (visible bool, expand bool, listVisible bool, emptyBackdrop bool) { | |||
| 326 | + | return rowCount > 0, rowCount > 0, rowCount > 0, false | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (o *Omnibox) setResultsContainerState(rowCount int) { | |||
| 330 | + | if o.scrolledWin == nil { | |||
| 331 | + | return | |||
| 332 | + | } | |||
| 333 | + | visible, expand, listVisible, emptyBackdrop := resultsContainerState(rowCount) | |||
| 334 | + | o.scrolledWin.SetVisible(visible) | |||
| 335 | + | o.scrolledWin.SetVexpand(expand) | |||
| 336 | + | if o.listBox != nil { | |||
| 337 | + | o.listBox.SetVisible(listVisible) | |||
| 338 | + | } | |||
| 339 | + | if o.mainBox != nil && !emptyBackdrop { | |||
| 340 | + | o.mainBox.RemoveCssClass("omnibox-empty") | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func logOmniboxWidgetState(ctx context.Context, overlay layout.OverlayWidget, outerBox, mainBox *gtk.Box, scrolledWin *gtk.ScrolledWindow, listBox *gtk.ListBox) { | |||
| 345 | + | log := logging.FromContext(ctx) | |||
| 346 | + | var overlayWidth, overlayHeight int | |||
| 347 | + | if overlay != nil { | |||
| 348 | + | overlayWidth = overlay.GetAllocatedWidth() | |||
| 349 | + | overlayHeight = overlay.GetAllocatedHeight() | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | var outerVisible bool | |||
| 353 | + | var outerWidth, outerHeight int | |||
| 354 | + | if outerBox != nil { | |||
| 355 | + | outerVisible = outerBox.GetVisible() | |||
| 356 | + | outerWidth = outerBox.GetAllocatedWidth() | |||
| 357 | + | outerHeight = outerBox.GetAllocatedHeight() | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | var mainVisible bool | |||
| 361 | + | var mainWidth, mainHeight int | |||
| 362 | + | if mainBox != nil { | |||
| 363 | + | mainVisible = mainBox.GetVisible() | |||
| 364 | + | mainWidth = mainBox.GetAllocatedWidth() | |||
| 365 | + | mainHeight = mainBox.GetAllocatedHeight() | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | var scrolledVisible, scrolledExpand bool | |||
| 369 | + | var scrolledWidth, scrolledHeight int | |||
| 370 | + | if scrolledWin != nil { | |||
| 371 | + | scrolledVisible = scrolledWin.GetVisible() | |||
| 372 | + | scrolledExpand = scrolledWin.GetVexpand() | |||
| 373 | + | scrolledWidth = scrolledWin.GetAllocatedWidth() | |||
| 374 | + | scrolledHeight = scrolledWin.GetAllocatedHeight() | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | var listVisible bool | |||
| 378 | + | var listWidth, listHeight int | |||
| 379 | + | if listBox != nil { | |||
| 380 | + | listVisible = listBox.GetVisible() | |||
| 381 | + | listWidth = listBox.GetAllocatedWidth() | |||
| 382 | + | listHeight = listBox.GetAllocatedHeight() | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | log.Debug(). | |||
| 386 | + | Int("overlayWidth", overlayWidth). | |||
| 387 | + | Int("overlayHeight", overlayHeight). | |||
| 388 | + | Bool("outerVisible", outerVisible). | |||
| 389 | + | Int("outerWidth", outerWidth). | |||
| 390 | + | Int("outerHeight", outerHeight). | |||
| 391 | + | Bool("mainVisible", mainVisible). | |||
| 392 | + | Int("mainWidth", mainWidth). | |||
| 393 | + | Int("mainHeight", mainHeight). | |||
| 394 | + | Bool("scrolledVisible", scrolledVisible). | |||
| 395 | + | Bool("scrolledExpand", scrolledExpand). | |||
| 396 | + | Int("scrolledWidth", scrolledWidth). | |||
| 397 | + | Int("scrolledHeight", scrolledHeight). | |||
| 398 | + | Bool("listVisible", listVisible). | |||
| 399 | + | Int("listWidth", listWidth). | |||
| 400 | + | Int("listHeight", listHeight). | |||
| 401 | + | Msg("omnibox widget state") | |||
| 402 | + | } | |||
| 403 | + | ||||
| 320 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | 404 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | ||
| 321 | // Must be called on the GTK main thread. | 405 | // Must be called on the GTK main thread. | ||
| 322 | func (o *Omnibox) effectiveMaxRows() int { | 406 | func (o *Omnibox) effectiveMaxRows() int { | ||
| 323 | if o.parentOverlay == nil { | 407 | if o.parentOverlay == nil { | ||
| 324 | return OmniboxListDefaults.MaxVisibleRows | 408 | return OmniboxListDefaults.MaxVisibleRows | ||
| 325 | } | 409 | } | ||
| 326 | - | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | 410 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) |
| 327 | } | 411 | } | ||
| 328 | 412 | ||||
| 329 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | 413 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | ||
| @@ -340,7 +424,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||||
| 340 | 424 | ||||
| 341 | // Schedule measurement after GTK has laid out widgets | 425 | // Schedule measurement after GTK has laid out widgets | ||
| 342 | var cb glib.SourceFunc = func(uintptr) bool { | 426 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 343 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | 427 | + | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) |
| 344 | o.measureAndResize(width, rowCount) | 428 | o.measureAndResize(width, rowCount) | ||
| 345 | return false | 429 | return false | ||
| 346 | } | 430 | } | ||
| @@ -387,6 +471,21 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 387 | 471 | ||||
| 388 | // Force layout recalculation | 472 | // Force layout recalculation | ||
| 389 | o.outerBox.QueueResize() | 473 | o.outerBox.QueueResize() | ||
| 474 | + | if parent := o.parentOverlay; parent != nil { | |||
| 475 | + | if widget := parent.GtkWidget(); widget != nil { | |||
| 476 | + | widget.QueueResize() | |||
| 477 | + | if parentWidget := widget.GetParent(); parentWidget != nil { | |||
| 478 | + | parentWidget.QueueResize() | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | if o.listBox != nil { | |||
| 483 | + | o.listBox.QueueResize() | |||
| 484 | + | } | |||
| 485 | + | if o.scrolledWin != nil { | |||
| 486 | + | o.scrolledWin.QueueResize() | |||
| 487 | + | } | |||
| 488 | + | o.mainBox.QueueResize() | |||
| 390 | 489 | ||||
| 391 | log.Debug(). | 490 | log.Debug(). | ||
| 392 | Int("width", width). | 491 | Int("width", width). | ||
| @@ -395,6 +494,12 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 395 | Int("rows", rowCount). | 494 | Int("rows", rowCount). | ||
| 396 | Bool("measured", o.measuredHeights.valid). | 495 | Bool("measured", o.measuredHeights.valid). | ||
| 397 | Msg("omnibox resized") | 496 | Msg("omnibox resized") | ||
| 497 | + | ||||
| 498 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 499 | + | logOmniboxWidgetState(o.ctx, o.parentOverlay, o.outerBox, o.mainBox, o.scrolledWin, o.listBox) | |||
| 500 | + | return false | |||
| 501 | + | } | |||
| 502 | + | glib.IdleAdd(&cb, 0) | |||
| 398 | } | 503 | } | ||
| 399 | 504 | ||||
| 400 | // createWidgets builds the GTK widget hierarchy. | 505 | // createWidgets builds the GTK widget hierarchy. | ||
| @@ -544,7 +649,7 @@ func (o *Omnibox) initList() error { | |||||
| 544 | return errNilWidget("scrolledWin") | 649 | return errNilWidget("scrolledWin") | ||
| 545 | } | 650 | } | ||
| 546 | o.scrolledWin.AddCssClass("omnibox-scrolled") | 651 | o.scrolledWin.AddCssClass("omnibox-scrolled") | ||
| 547 | - | o.scrolledWin.SetVexpand(true) | 652 | + | o.scrolledWin.SetVexpand(false) |
| 548 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | 653 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | ||
| 549 | o.scrolledWin.SetPropagateNaturalHeight(true) | 654 | o.scrolledWin.SetPropagateNaturalHeight(true) | ||
| 550 | 655 | ||||
| @@ -555,19 +660,16 @@ func (o *Omnibox) initList() error { | |||||
| 555 | o.listBox.AddCssClass("omnibox-listbox") | 660 | o.listBox.AddCssClass("omnibox-listbox") | ||
| 556 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 661 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 557 | 662 | ||||
| 558 | - | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 663 | + | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { |
| 559 | o.restoreEntryToRealInput() | 664 | o.restoreEntryToRealInput() | ||
| 560 | - | if rowPtr == 0 { | 665 | + | if row == nil { |
| 561 | o.mu.Lock() | 666 | o.mu.Lock() | ||
| 562 | o.selectedIndex = -1 | 667 | o.selectedIndex = -1 | ||
| 563 | o.mu.Unlock() | 668 | o.mu.Unlock() | ||
| 564 | } else { | 669 | } else { | ||
| 565 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | 670 | + | o.mu.Lock() |
| 566 | - | if row != nil { | 671 | + | o.selectedIndex = row.GetIndex() |
| 567 | - | o.mu.Lock() | 672 | + | o.mu.Unlock() |
| 568 | - | o.selectedIndex = row.GetIndex() | |||
| 569 | - | o.mu.Unlock() | |||
| 570 | - | } | |||
| 571 | } | 673 | } | ||
| 572 | o.updateGhostFromSelection() | 674 | o.updateGhostFromSelection() | ||
| 573 | } | 675 | } | ||
| @@ -575,11 +677,7 @@ func (o *Omnibox) initList() error { | |||||
| 575 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 677 | o.listBox.ConnectRowSelected(&rowSelectedCb) | ||
| 576 | 678 | ||||
| 577 | // Handle row activation (click or Enter) - navigate directly to the URL | 679 | // Handle row activation (click or Enter) - navigate directly to the URL | ||
| 578 | - | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 680 | + | rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { |
| 579 | - | if rowPtr == 0 { | |||
| 580 | - | return | |||
| 581 | - | } | |||
| 582 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |||
| 583 | if row == nil { | 681 | if row == nil { | ||
| 584 | return | 682 | return | ||
| 585 | } | 683 | } | ||
| @@ -1598,9 +1696,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||||
| 1598 | o.rebuildList() | 1696 | o.rebuildList() | ||
| 1599 | 1697 | ||||
| 1600 | rowCount := len(suggestions) | 1698 | rowCount := len(suggestions) | ||
| 1601 | - | if o.scrolledWin != nil { | 1699 | + | o.setResultsContainerState(rowCount) |
| 1602 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1603 | - | } | |||
| 1604 | o.resizeAndCenter(rowCount) | 1700 | o.resizeAndCenter(rowCount) | ||
| 1605 | } | 1701 | } | ||
| 1606 | 1702 | ||||
| @@ -1618,9 +1714,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||||
| 1618 | 1714 | ||||
| 1619 | // Hide scrolled window when there are no suggestions | 1715 | // Hide scrolled window when there are no suggestions | ||
| 1620 | rowCount := len(suggestions) | 1716 | rowCount := len(suggestions) | ||
| 1621 | - | if o.scrolledWin != nil { | 1717 | + | o.setResultsContainerState(rowCount) |
| 1622 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1623 | - | } | |||
| 1624 | o.resizeAndCenter(rowCount) | 1718 | o.resizeAndCenter(rowCount) | ||
| 1625 | 1719 | ||||
| 1626 | if rowCount > 0 { | 1720 | if rowCount > 0 { | ||
| @@ -1644,9 +1738,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||||
| 1644 | 1738 | ||||
| 1645 | // Hide scrolled window when there are no favorites | 1739 | // Hide scrolled window when there are no favorites | ||
| 1646 | rowCount := len(favorites) | 1740 | rowCount := len(favorites) | ||
| 1647 | - | if o.scrolledWin != nil { | 1741 | + | o.setResultsContainerState(rowCount) |
| 1648 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1649 | - | } | |||
| 1650 | o.resizeAndCenter(rowCount) | 1742 | o.resizeAndCenter(rowCount) | ||
| 1651 | 1743 | ||||
| 1652 | if rowCount > 0 { | 1744 | if rowCount > 0 { | ||
| @@ -1702,7 +1794,7 @@ func (o *Omnibox) rebuildList() { | |||||
| 1702 | if width <= 0 { | 1794 | if width <= 0 { | ||
| 1703 | return false // Overlay not allocated yet, skip | 1795 | return false // Overlay not allocated yet, skip | ||
| 1704 | } | 1796 | } | ||
| 1705 | - | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | 1797 | + | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) |
| 1706 | if o.measureComponentHeights(forWidth) { | 1798 | if o.measureComponentHeights(forWidth) { | ||
| 1707 | // Re-trigger resize with accurate measurements | 1799 | // Re-trigger resize with accurate measurements | ||
| 1708 | o.mu.RLock() | 1800 | o.mu.RLock() | ||
| @@ -1749,15 +1841,91 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||||
| 1749 | return favicon | 1841 | return favicon | ||
| 1750 | } | 1842 | } | ||
| 1751 | 1843 | ||||
| 1844 | + | const favoriteStarBaseSize = 18 | |||
| 1845 | + | ||||
| 1846 | + | func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) } | |||
| 1847 | + | ||||
| 1848 | + | func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite } | |||
| 1849 | + | ||||
| 1850 | + | func (o *Omnibox) createFavoriteStarIcon() *gtk.Image { | |||
| 1851 | + | star := gtk.NewImage() | |||
| 1852 | + | if star == nil { | |||
| 1853 | + | return nil | |||
| 1854 | + | } | |||
| 1855 | + | iconName := "starred-symbolic" | |||
| 1856 | + | star.SetFromIconName(&iconName) | |||
| 1857 | + | star.SetPixelSize(favoriteStarSize(o.uiScale)) | |||
| 1858 | + | star.AddCssClass("omnibox-favorite-star") | |||
| 1859 | + | star.SetValign(gtk.AlignCenterValue) | |||
| 1860 | + | return star | |||
| 1861 | + | } | |||
| 1862 | + | ||||
| 1863 | + | func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) { | |||
| 1864 | + | displayTitle := title | |||
| 1865 | + | if displayTitle == "" { | |||
| 1866 | + | displayTitle = displayURL | |||
| 1867 | + | } | |||
| 1868 | + | ||||
| 1869 | + | titleLabel := gtk.NewLabel(nil) | |||
| 1870 | + | if titleLabel != nil { | |||
| 1871 | + | titleLabel.SetText(displayTitle) | |||
| 1872 | + | titleLabel.AddCssClass("omnibox-suggestion-title") | |||
| 1873 | + | titleLabel.SetHalign(gtk.AlignStartValue) | |||
| 1874 | + | titleLabel.SetEllipsize(2) | |||
| 1875 | + | textBox.Append(&titleLabel.Widget) | |||
| 1876 | + | } | |||
| 1877 | + | ||||
| 1878 | + | if title != "" && title != displayURL { | |||
| 1879 | + | urlLabel := gtk.NewLabel(nil) | |||
| 1880 | + | if urlLabel != nil { | |||
| 1881 | + | urlLabel.SetText(displayURL) | |||
| 1882 | + | urlLabel.AddCssClass("omnibox-suggestion-url") | |||
| 1883 | + | urlLabel.SetHalign(gtk.AlignStartValue) | |||
| 1884 | + | urlLabel.SetEllipsize(2) | |||
| 1885 | + | textBox.Append(&urlLabel.Widget) | |||
| 1886 | + | } | |||
| 1887 | + | } | |||
| 1888 | + | } | |||
| 1889 | + | ||||
| 1890 | + | func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) { | |||
| 1891 | + | if showFavoriteStar { | |||
| 1892 | + | if star := o.createFavoriteStarIcon(); star != nil { | |||
| 1893 | + | hbox.Append(&star.Widget) | |||
| 1894 | + | } | |||
| 1895 | + | } | |||
| 1896 | + | ||||
| 1897 | + | const maxShortcutIndex = 9 | |||
| 1898 | + | if index > maxShortcutIndex { | |||
| 1899 | + | return | |||
| 1900 | + | } | |||
| 1901 | + | ||||
| 1902 | + | shortcutLabel := gtk.NewLabel(nil) | |||
| 1903 | + | if shortcutLabel == nil { | |||
| 1904 | + | return | |||
| 1905 | + | } | |||
| 1906 | + | if index < maxShortcutIndex { | |||
| 1907 | + | shortcutLabel.SetText(formatShortcut(index + 1)) | |||
| 1908 | + | } else { | |||
| 1909 | + | shortcutLabel.SetText("Ctrl+0") | |||
| 1910 | + | } | |||
| 1911 | + | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |||
| 1912 | + | shortcutLabel.SetValign(gtk.AlignCenterValue) | |||
| 1913 | + | hbox.Append(&shortcutLabel.Widget) | |||
| 1914 | + | } | |||
| 1915 | + | ||||
| 1752 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | 1916 | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | ||
| 1753 | // Uses rawURL for both favicon fetching and display. | 1917 | // Uses rawURL for both favicon fetching and display. | ||
| 1754 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | 1918 | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | ||
| 1755 | - | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | 1919 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index) |
| 1756 | } | 1920 | } | ||
| 1757 | 1921 | ||||
| 1758 | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | 1922 | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | ||
| 1759 | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | 1923 | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | ||
| 1760 | - | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | 1924 | + | func (o *Omnibox) createRowWithFaviconURL( |
| 1925 | + | displayURL, title, faviconURL, fallbackIcon string, | |||
| 1926 | + | showFavoriteStar bool, | |||
| 1927 | + | index int, | |||
| 1928 | + | ) *gtk.ListBoxRow { | |||
| 1761 | row := gtk.NewListBoxRow() | 1929 | row := gtk.NewListBoxRow() | ||
| 1762 | if row == nil { | 1930 | if row == nil { | ||
| 1763 | return nil | 1931 | return nil | ||
| @@ -1786,49 +1954,10 @@ func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbac | |||||
| 1786 | textBox.SetHexpand(true) | 1954 | textBox.SetHexpand(true) | ||
| 1787 | textBox.SetValign(gtk.AlignCenterValue) | 1955 | textBox.SetValign(gtk.AlignCenterValue) | ||
| 1788 | 1956 | ||||
| 1789 | - | // Title label (or URL if no title) | 1957 | + | o.appendSuggestionTitleAndURL(textBox, title, displayURL) |
| 1790 | - | displayTitle := title | |||
| 1791 | - | if displayTitle == "" { | |||
| 1792 | - | displayTitle = displayURL | |||
| 1793 | - | } | |||
| 1794 | - | titleLabel := gtk.NewLabel(nil) | |||
| 1795 | - | if titleLabel != nil { | |||
| 1796 | - | titleLabel.SetText(displayTitle) | |||
| 1797 | - | titleLabel.AddCssClass("omnibox-suggestion-title") | |||
| 1798 | - | titleLabel.SetHalign(gtk.AlignStartValue) | |||
| 1799 | - | titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |||
| 1800 | - | textBox.Append(&titleLabel.Widget) | |||
| 1801 | - | } | |||
| 1802 | - | ||||
| 1803 | - | // URL label (only if title exists and differs from URL) | |||
| 1804 | - | if title != "" && title != displayURL { | |||
| 1805 | - | urlLabel := gtk.NewLabel(nil) | |||
| 1806 | - | if urlLabel != nil { | |||
| 1807 | - | urlLabel.SetText(displayURL) | |||
| 1808 | - | urlLabel.AddCssClass("omnibox-suggestion-url") | |||
| 1809 | - | urlLabel.SetHalign(gtk.AlignStartValue) | |||
| 1810 | - | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |||
| 1811 | - | textBox.Append(&urlLabel.Widget) | |||
| 1812 | - | } | |||
| 1813 | - | } | |||
| 1814 | 1958 | ||||
| 1815 | hbox.Append(&textBox.Widget) | 1959 | hbox.Append(&textBox.Widget) | ||
| 1816 | - | 1960 | + | o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index) | |
| 1817 | - | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | |||
| 1818 | - | const maxShortcutIndex = 9 | |||
| 1819 | - | if index <= maxShortcutIndex { | |||
| 1820 | - | shortcutLabel := gtk.NewLabel(nil) | |||
| 1821 | - | if shortcutLabel != nil { | |||
| 1822 | - | if index < maxShortcutIndex { | |||
| 1823 | - | shortcutLabel.SetText(formatShortcut(index + 1)) | |||
| 1824 | - | } else { | |||
| 1825 | - | shortcutLabel.SetText("Ctrl+0") | |||
| 1826 | - | } | |||
| 1827 | - | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |||
| 1828 | - | shortcutLabel.SetValign(gtk.AlignCenterValue) | |||
| 1829 | - | hbox.Append(&shortcutLabel.Widget) | |||
| 1830 | - | } | |||
| 1831 | - | } | |||
| 1832 | 1961 | ||||
| 1833 | row.SetChild(&hbox.Widget) | 1962 | row.SetChild(&hbox.Widget) | ||
| 1834 | return row | 1963 | return row | ||
| @@ -1863,7 +1992,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo | |||||
| 1863 | 1992 | ||||
| 1864 | // createSuggestionRow creates a ListBoxRow for a suggestion. | 1993 | // createSuggestionRow creates a ListBoxRow for a suggestion. | ||
| 1865 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | 1994 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | ||
| 1866 | - | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | 1995 | + | row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index) |
| 1867 | if row != nil && s.IsFavorite { | 1996 | if row != nil && s.IsFavorite { | ||
| 1868 | row.AddCssClass("omnibox-row-favorite") | 1997 | row.AddCssClass("omnibox-row-favorite") | ||
| 1869 | } | 1998 | } | ||
| @@ -1878,7 +2007,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||||
| 1878 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | 2007 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | ||
| 1879 | // Pass description as URL param (displayed as secondary label) and empty | 2008 | // Pass description as URL param (displayed as secondary label) and empty | ||
| 1880 | // faviconURL to skip async favicon fetching - bang rows use static icon only | 2009 | // faviconURL to skip async favicon fetching - bang rows use static icon only | ||
| 1881 | - | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | 2010 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index) |
| 1882 | if row != nil { | 2011 | if row != nil { | ||
| 1883 | row.AddCssClass("omnibox-row-bang") | 2012 | row.AddCssClass("omnibox-row-bang") | ||
| 1884 | } | 2013 | } | ||
| @@ -2316,20 +2445,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2316 | o.entry.SetText(query) | 2445 | o.entry.SetText(query) | ||
| 2317 | o.entry.SelectRegion(-1, -1) | 2446 | o.entry.SelectRegion(-1, -1) | ||
| 2318 | 2447 | ||||
| 2319 | - | // Determine if we expect content initially | 2448 | + | // Keep the results container hidden until real rows arrive. |
| 2320 | - | // No content expected if: no query AND initialBehavior is "none" | 2449 | + | // Pre-showing an empty, expanding scroller renders a dead area in standalone mode. |
| 2321 | - | expectContent := query != "" || o.initialBehavior != "none" | |||
| 2322 | - | ||||
| 2323 | - | // Hide scrolled window if no content expected | |||
| 2324 | if o.scrolledWin != nil { | 2450 | if o.scrolledWin != nil { | ||
| 2325 | - | o.scrolledWin.SetVisible(expectContent) | 2451 | + | o.setResultsContainerState(0) |
| 2326 | // Reset content height constraints - will be updated when results arrive | 2452 | // Reset content height constraints - will be updated when results arrive | ||
| 2327 | o.scrolledWin.SetMinContentHeight(-1) | 2453 | o.scrolledWin.SetMinContentHeight(-1) | ||
| 2328 | o.scrolledWin.SetMaxContentHeight(0) | 2454 | o.scrolledWin.SetMaxContentHeight(0) | ||
| 2329 | } | 2455 | } | ||
| 2330 | 2456 | ||||
| 2331 | // Calculate dimensions using shared helper | 2457 | // Calculate dimensions using shared helper | ||
| 2332 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | 2458 | + | parentWidth, parentHeight := 0, 0 |
| 2459 | + | if o.parentOverlay != nil { | |||
| 2460 | + | parentWidth = o.parentOverlay.GetAllocatedWidth() | |||
| 2461 | + | parentHeight = o.parentOverlay.GetAllocatedHeight() | |||
| 2462 | + | } | |||
| 2463 | + | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |||
| 2464 | + | log.Debug(). | |||
| 2465 | + | Int("parentWidth", parentWidth). | |||
| 2466 | + | Int("parentHeight", parentHeight). | |||
| 2467 | + | Int("width", width). | |||
| 2468 | + | Int("marginTop", marginTop). | |||
| 2469 | + | Int("fixedWidth", o.sizeCfg.FixedWidth). | |||
| 2470 | + | Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin). | |||
| 2471 | + | Int("fixedTopMargin", o.sizeCfg.FixedTopMargin). | |||
| 2472 | + | Float64("widthPct", o.sizeCfg.WidthPct). | |||
| 2473 | + | Float64("topMarginPct", o.sizeCfg.TopMarginPct). | |||
| 2474 | + | Msg("omnibox geometry decision") | |||
| 2333 | 2475 | ||||
| 2334 | o.mainBox.SetSizeRequest(width, -1) | 2476 | o.mainBox.SetSizeRequest(width, -1) | ||
| 2335 | o.outerBox.SetMarginTop(marginTop) | 2477 | o.outerBox.SetMarginTop(marginTop) | ||
A internal/ui/component/omnibox_favorite_icon_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestOmniboxFavoriteStarSize(t *testing.T) { | |
| 10 | + | assert.Equal(t, 18, favoriteStarSize(1.0)) | |
| 11 | + | assert.Equal(t, 27, favoriteStarSize(1.5)) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestOmniboxFavoriteStarVisibility(t *testing.T) { | |
| 15 | + | assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true})) | |
| 16 | + | assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false})) | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestOmniboxFavoriteStarSize(t *testing.T) { | |||
| 10 | + | assert.Equal(t, 18, favoriteStarSize(1.0)) | |||
| 11 | + | assert.Equal(t, 27, favoriteStarSize(1.5)) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestOmniboxFavoriteStarVisibility(t *testing.T) { | |||
| 15 | + | assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true})) | |||
| 16 | + | assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false})) | |||
| 17 | + | } | |||
M internal/ui/component/omnibox_navigation_state_test.go +34 -0
| @@ -87,6 +87,40 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | func TestResultsContainerState(t *testing.T) { | |
| 91 | + | tests := []struct { | |
| 92 | + | name string | |
| 93 | + | rowCount int | |
| 94 | + | wantVisible bool | |
| 95 | + | wantExpand bool | |
| 96 | + | wantListVisible bool | |
| 97 | + | wantEmptyBackdrop bool | |
| 98 | + | }{ | |
| 99 | + | {name: "no rows stays hidden without empty backdrop", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false, wantEmptyBackdrop: false}, | |
| 100 | + | {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true, wantEmptyBackdrop: false}, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | for _, tt := range tests { | |
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |
| 105 | + | gotVisible, gotExpand, gotListVisible, gotEmptyBackdrop := resultsContainerState(tt.rowCount) | |
| 106 | + | if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible || gotEmptyBackdrop != tt.wantEmptyBackdrop { | |
| 107 | + | t.Fatalf( | |
| 108 | + | "resultsContainerState(%d) = (%v, %v, %v, %v), want (%v, %v, %v, %v)", | |
| 109 | + | tt.rowCount, | |
| 110 | + | gotVisible, | |
| 111 | + | gotExpand, | |
| 112 | + | gotListVisible, | |
| 113 | + | gotEmptyBackdrop, | |
| 114 | + | tt.wantVisible, | |
| 115 | + | tt.wantExpand, | |
| 116 | + | tt.wantListVisible, | |
| 117 | + | tt.wantEmptyBackdrop, | |
| 118 | + | ) | |
| 119 | + | } | |
| 120 | + | }) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 90 | 124 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | |
| 91 | 125 | entryText := "github.com/bnema/dumber" | |
| 92 | 126 | realInput := "upl" | |
| @@ -87,6 +87,40 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | func TestResultsContainerState(t *testing.T) { | |||
| 91 | + | tests := []struct { | |||
| 92 | + | name string | |||
| 93 | + | rowCount int | |||
| 94 | + | wantVisible bool | |||
| 95 | + | wantExpand bool | |||
| 96 | + | wantListVisible bool | |||
| 97 | + | wantEmptyBackdrop bool | |||
| 98 | + | }{ | |||
| 99 | + | {name: "no rows stays hidden without empty backdrop", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false, wantEmptyBackdrop: false}, | |||
| 100 | + | {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true, wantEmptyBackdrop: false}, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | for _, tt := range tests { | |||
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 105 | + | gotVisible, gotExpand, gotListVisible, gotEmptyBackdrop := resultsContainerState(tt.rowCount) | |||
| 106 | + | if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible || gotEmptyBackdrop != tt.wantEmptyBackdrop { | |||
| 107 | + | t.Fatalf( | |||
| 108 | + | "resultsContainerState(%d) = (%v, %v, %v, %v), want (%v, %v, %v, %v)", | |||
| 109 | + | tt.rowCount, | |||
| 110 | + | gotVisible, | |||
| 111 | + | gotExpand, | |||
| 112 | + | gotListVisible, | |||
| 113 | + | gotEmptyBackdrop, | |||
| 114 | + | tt.wantVisible, | |||
| 115 | + | tt.wantExpand, | |||
| 116 | + | tt.wantListVisible, | |||
| 117 | + | tt.wantEmptyBackdrop, | |||
| 118 | + | ) | |||
| 119 | + | } | |||
| 120 | + | }) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 90 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | 124 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | ||
| 91 | entryText := "github.com/bnema/dumber" | 125 | entryText := "github.com/bnema/dumber" | ||
| 92 | realInput := "upl" | 126 | realInput := "upl" | ||
M internal/ui/component/session_manager.go +1 -5
| @@ -314,11 +314,7 @@ func (sm *SessionManager) initList() error { | |||
| 314 | 314 | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 315 | 315 | ||
| 316 | 316 | // 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) { | |
| 322 | 318 | if row == nil { | |
| 323 | 319 | return | |
| 324 | 320 | } | |
| @@ -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 index | 316 | // 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 | return | 319 | return | ||
| 324 | } | 320 | } | ||
M internal/ui/component/tab_picker.go +1 -5
| @@ -232,11 +232,7 @@ func (tp *TabPicker) createList() error { | |||
| 232 | 232 | tp.listBox.AddCssClass("tab-picker-list") | |
| 233 | 233 | tp.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 234 | 234 | ||
| 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) { | |
| 240 | 236 | if row == nil { | |
| 241 | 237 | return | |
| 242 | 238 | } | |
| @@ -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 | return | 237 | return | ||
| 242 | } | 238 | } | ||
M internal/ui/coordinator/navigation_test.go +11 -0
| @@ -39,3 +39,14 @@ func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *tes | |||
| 39 | 39 | provider.onNavigate("https://example.com") | |
| 40 | 40 | }) | |
| 41 | 41 | } | |
| 42 | + | ||
| 43 | + | func TestNavigationCoordinator_SetOmniboxProviderInstallsNavigateCallback(t *testing.T) { | |
| 44 | + | c := &NavigationCoordinator{} | |
| 45 | + | provider := &fakeOmniboxProvider{} | |
| 46 | + | ||
| 47 | + | c.SetOmniboxProvider(provider) | |
| 48 | + | ||
| 49 | + | if provider.onNavigate == nil { | |
| 50 | + | t.Fatalf("expected omnibox navigate callback to be installed") | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -39,3 +39,14 @@ func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *tes | |||||
| 39 | provider.onNavigate("https://example.com") | 39 | provider.onNavigate("https://example.com") | ||
| 40 | }) | 40 | }) | ||
| 41 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | func TestNavigationCoordinator_SetOmniboxProviderInstallsNavigateCallback(t *testing.T) { | |||
| 44 | + | c := &NavigationCoordinator{} | |||
| 45 | + | provider := &fakeOmniboxProvider{} | |||
| 46 | + | ||||
| 47 | + | c.SetOmniboxProvider(provider) | |||
| 48 | + | ||||
| 49 | + | if provider.onNavigate == nil { | |||
| 50 | + | t.Fatalf("expected omnibox navigate callback to be installed") | |||
| 51 | + | } | |||
| 52 | + | } | |||
M internal/ui/deps.go +3 -0
| @@ -98,6 +98,9 @@ type Dependencies struct { | |||
| 98 | 98 | // Used for external URL schemes (vscode://, spotify://, etc.). | |
| 99 | 99 | // Optional: if nil, external URLs are silently dropped. | |
| 100 | 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 | 106 | // 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 { | |||
| 169 | 169 | } | |
| 170 | 170 | ||
| 171 | 171 | func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint { | |
| 172 | - | cb := func(_ gobject.Object, _ uintptr) { | |
| 172 | + | cb := func(_ gobject.Object, _ *gobject.ParamSpec) { | |
| 173 | 173 | callback() | |
| 174 | 174 | } | |
| 175 | 175 | return p.inner.ConnectNotifyWithDetail("position", &cb) | |
| @@ -169,7 +169,7 @@ func (p *gtkPaned) ConnectMap(callback func()) uint { | |||||
| 169 | } | 169 | } | ||
| 170 | 170 | ||||
| 171 | func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint { | 171 | func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint { | ||
| 172 | - | cb := func(_ gobject.Object, _ uintptr) { | 172 | + | cb := func(_ gobject.Object, _ *gobject.ParamSpec) { |
| 173 | callback() | 173 | callback() | ||
| 174 | } | 174 | } | ||
| 175 | return p.inner.ConnectNotifyWithDetail("position", &cb) | 175 | return p.inner.ConnectNotifyWithDetail("position", &cb) | ||
A internal/ui/standalone_omnibox.go +313 -0
| @@ -0,0 +1,313 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 12 | + | "github.com/bnema/puregotk/v4/adw" | |
| 13 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | + | "github.com/bnema/puregotk/v4/gio" | |
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 17 | + | "github.com/bnema/puregotk/v4/layershell" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var layerShellLibraryAvailable = func() bool { | |
| 21 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) { | |
| 22 | + | return true | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | return bootstrap.LayerShellLibraryPath() != "" | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | standaloneOmniboxNamespace = AppID + ".omnibox" | |
| 30 | + | standaloneOmniboxTitle = "Dumber Omnibox" | |
| 31 | + | standaloneOmniboxWidth = 1200 | |
| 32 | + | standaloneOmniboxHeight = 200 | |
| 33 | + | standaloneOmniboxWindowCSS = "standalone-omnibox-window" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | type StandaloneOmniboxWindowConfig struct { | |
| 37 | + | Namespace string | |
| 38 | + | Title string | |
| 39 | + | Width int | |
| 40 | + | Height int | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type StandaloneOmniboxRuntime struct { | |
| 44 | + | OmniboxCfg component.OmniboxConfig | |
| 45 | + | ApplyTheme func(display *gdk.Display) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type standaloneOmniboxHostLayoutConfig struct { | |
| 49 | + | UseDefaultSize bool | |
| 50 | + | OmniboxSize component.ModalSizeConfig | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | var ( | |
| 54 | + | layerShellIsSupported = layershell.IsSupported | |
| 55 | + | layerShellInitForWindow = func(window *gtk.ApplicationWindow) { | |
| 56 | + | layershell.InitForWindow(&window.Window) | |
| 57 | + | } | |
| 58 | + | layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) { | |
| 59 | + | layershell.SetLayer(&window.Window, layer) | |
| 60 | + | } | |
| 61 | + | layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) { | |
| 62 | + | layershell.SetExclusiveZone(&window.Window, zone) | |
| 63 | + | } | |
| 64 | + | layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) { | |
| 65 | + | layershell.SetNamespace(&window.Window, namespace) | |
| 66 | + | } | |
| 67 | + | layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |
| 68 | + | layershell.SetKeyboardMode(&window.Window, mode) | |
| 69 | + | } | |
| 70 | + | ) | |
| 71 | + | ||
| 72 | + | func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig { | |
| 73 | + | return StandaloneOmniboxWindowConfig{ | |
| 74 | + | Namespace: standaloneOmniboxNamespace, | |
| 75 | + | Title: standaloneOmniboxTitle, | |
| 76 | + | Width: standaloneOmniboxWidth, | |
| 77 | + | Height: standaloneOmniboxHeight, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func standaloneOmniboxArgv(args []string) []string { | |
| 82 | + | if len(args) == 0 { | |
| 83 | + | return []string{"dumber-omnibox"} | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | filtered := []string{args[0]} | |
| 87 | + | for i := 1; i < len(args); i++ { | |
| 88 | + | arg := args[i] | |
| 89 | + | if arg == "omnibox" { | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | if strings.HasPrefix(arg, "-test.") { | |
| 93 | + | if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) { | |
| 94 | + | i++ | |
| 95 | + | } | |
| 96 | + | continue | |
| 97 | + | } | |
| 98 | + | filtered = append(filtered, arg) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return filtered | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func goTestFlagConsumesNextArg(arg string) bool { | |
| 105 | + | switch arg { | |
| 106 | + | case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile", | |
| 107 | + | "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir", | |
| 108 | + | "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile", | |
| 109 | + | "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction", | |
| 110 | + | "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle", | |
| 111 | + | "-test.skip", "-test.timeout", "-test.trace": | |
| 112 | + | return true | |
| 113 | + | default: | |
| 114 | + | return false | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) { | |
| 119 | + | return func(url string) { | |
| 120 | + | if original != nil { | |
| 121 | + | original(url) | |
| 122 | + | } | |
| 123 | + | if quit != nil { | |
| 124 | + | quit() | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func standaloneOmniboxCloseHandler(quit func()) func() { | |
| 130 | + | return func() { | |
| 131 | + | if quit != nil { | |
| 132 | + | quit() | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool { | |
| 138 | + | return !layerShellApplied | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func standaloneOmniboxWindowResizable(layerShellApplied bool) bool { | |
| 142 | + | return !layerShellApplied | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig { | |
| 146 | + | return standaloneOmniboxHostLayoutConfig{ | |
| 147 | + | UseDefaultSize: !layerShellApplied, | |
| 148 | + | OmniboxSize: standaloneOmniboxSizeConfig(), | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) { | |
| 153 | + | if host == nil { | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | host.SetChild(omniboxWidget) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func standaloneOmniboxSizeConfig() component.ModalSizeConfig { | |
| 161 | + | return component.ResolveModalSizeConfig(component.ModalSizeConfig{ | |
| 162 | + | FixedWidth: component.OmniboxSizeDefaults.MaxWidth, | |
| 163 | + | FixedTopMargin: 0, | |
| 164 | + | UseFixedTopMargin: true, | |
| 165 | + | }, component.OmniboxSizeDefaults) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool { | |
| 169 | + | if window == nil { | |
| 170 | + | return false | |
| 171 | + | } | |
| 172 | + | if !safeLayerShellSupported() { | |
| 173 | + | return false | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | layerShellInitForWindow(window) | |
| 177 | + | layerShellSetLayer(window, layershell.LayerOverlayValue) | |
| 178 | + | layerShellSetExclusiveZone(window, 0) | |
| 179 | + | layerShellSetNamespace(window, &cfg.Namespace) | |
| 180 | + | layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue) | |
| 181 | + | return true | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func safeLayerShellSupported() (supported bool) { | |
| 185 | + | defer func() { | |
| 186 | + | if recover() != nil { | |
| 187 | + | supported = false | |
| 188 | + | } | |
| 189 | + | }() | |
| 190 | + | ||
| 191 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() { | |
| 192 | + | return layerShellIsSupported() | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |
| 199 | + | if window == nil { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | title := windowCfg.Title | |
| 204 | + | window.SetTitle(&title) | |
| 205 | + | layerShellApplied := tryInitLayerShell(window, windowCfg) | |
| 206 | + | hostLayout := standaloneOmniboxHostLayout(layerShellApplied) | |
| 207 | + | if hostLayout.UseDefaultSize { | |
| 208 | + | window.SetDefaultSize(windowCfg.Width, windowCfg.Height) | |
| 209 | + | } | |
| 210 | + | if layerShellApplied { | |
| 211 | + | window.AddCssClass(standaloneOmniboxWindowCSS) | |
| 212 | + | } | |
| 213 | + | window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied)) | |
| 214 | + | window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied)) | |
| 215 | + | window.SetChild(child) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func buildStandaloneOmniboxHost( | |
| 219 | + | ctx context.Context, | |
| 220 | + | runtimeCfg *StandaloneOmniboxRuntime, | |
| 221 | + | gtkApp *gtk.Application, | |
| 222 | + | ) (*gtk.Widget, *component.Omnibox) { | |
| 223 | + | factory := layout.NewGtkWidgetFactory() | |
| 224 | + | overlay := factory.NewOverlay() | |
| 225 | + | hostLayout := standaloneOmniboxHostLayout(false) | |
| 226 | + | if overlay == nil { | |
| 227 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets") | |
| 228 | + | return nil, nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | cfg := runtimeCfg.OmniboxCfg | |
| 232 | + | cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit) | |
| 233 | + | cfg.SizeConfig = hostLayout.OmniboxSize | |
| 234 | + | ||
| 235 | + | omnibox := component.NewOmnibox(ctx, cfg) | |
| 236 | + | if omnibox == nil { | |
| 237 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox") | |
| 238 | + | return nil, nil | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | overlay.SetVisible(true) | |
| 242 | + | omnibox.SetParentOverlay(overlay) | |
| 243 | + | omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit)) | |
| 244 | + | ||
| 245 | + | omniboxWidget := omnibox.WidgetAsLayout(factory) | |
| 246 | + | if omniboxWidget == nil { | |
| 247 | + | logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget") | |
| 248 | + | return nil, nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | attachStandaloneOmniboxHostWidget(overlay, omniboxWidget) | |
| 252 | + | logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child") | |
| 253 | + | return overlay.GtkWidget(), omnibox | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) { | |
| 257 | + | if host == nil { | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 262 | + | logging.FromContext(ctx).Debug(). | |
| 263 | + | Int("width", host.GetAllocatedWidth()). | |
| 264 | + | Int("height", host.GetAllocatedHeight()). | |
| 265 | + | Msg("standalone omnibox host allocation") | |
| 266 | + | return false | |
| 267 | + | } | |
| 268 | + | glib.IdleAdd(&cb, 0) | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int { | |
| 272 | + | if runtimeCfg == nil { | |
| 273 | + | logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured") | |
| 274 | + | return 1 | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | adw.Init() | |
| 278 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |
| 279 | + | appID := windowCfg.Namespace | |
| 280 | + | gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags()) | |
| 281 | + | if gtkApp == nil { | |
| 282 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application") | |
| 283 | + | return 1 | |
| 284 | + | } | |
| 285 | + | defer gtkApp.Unref() | |
| 286 | + | ||
| 287 | + | activateCb := func(_ gio.Application) { | |
| 288 | + | child, omnibox := buildStandaloneOmniboxHost(ctx, runtimeCfg, gtkApp) | |
| 289 | + | if child == nil || omnibox == nil { | |
| 290 | + | gtkApp.Quit() | |
| 291 | + | return | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | window := gtk.NewApplicationWindow(gtkApp) | |
| 295 | + | if window == nil { | |
| 296 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window") | |
| 297 | + | gtkApp.Quit() | |
| 298 | + | return | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | configureStandaloneOmniboxWindow(window, windowCfg, child) | |
| 302 | + | if display := window.GetDisplay(); display != nil && runtimeCfg.ApplyTheme != nil { | |
| 303 | + | runtimeCfg.ApplyTheme(display) | |
| 304 | + | } | |
| 305 | + | window.Present() | |
| 306 | + | logStandaloneOmniboxHostAllocation(ctx, child) | |
| 307 | + | omnibox.Show(ctx, "") | |
| 308 | + | } | |
| 309 | + | gtkApp.ConnectActivate(&activateCb) | |
| 310 | + | ||
| 311 | + | argv := standaloneOmniboxArgv(os.Args) | |
| 312 | + | return gtkApp.Run(len(argv), argv) | |
| 313 | + | } | |
| @@ -0,0 +1,313 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/bootstrap" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 12 | + | "github.com/bnema/puregotk/v4/adw" | |||
| 13 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 14 | + | "github.com/bnema/puregotk/v4/gio" | |||
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 17 | + | "github.com/bnema/puregotk/v4/layershell" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var layerShellLibraryAvailable = func() bool { | |||
| 21 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) { | |||
| 22 | + | return true | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | return bootstrap.LayerShellLibraryPath() != "" | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | const ( | |||
| 29 | + | standaloneOmniboxNamespace = AppID + ".omnibox" | |||
| 30 | + | standaloneOmniboxTitle = "Dumber Omnibox" | |||
| 31 | + | standaloneOmniboxWidth = 1200 | |||
| 32 | + | standaloneOmniboxHeight = 200 | |||
| 33 | + | standaloneOmniboxWindowCSS = "standalone-omnibox-window" | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | type StandaloneOmniboxWindowConfig struct { | |||
| 37 | + | Namespace string | |||
| 38 | + | Title string | |||
| 39 | + | Width int | |||
| 40 | + | Height int | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type StandaloneOmniboxRuntime struct { | |||
| 44 | + | OmniboxCfg component.OmniboxConfig | |||
| 45 | + | ApplyTheme func(display *gdk.Display) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type standaloneOmniboxHostLayoutConfig struct { | |||
| 49 | + | UseDefaultSize bool | |||
| 50 | + | OmniboxSize component.ModalSizeConfig | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | var ( | |||
| 54 | + | layerShellIsSupported = layershell.IsSupported | |||
| 55 | + | layerShellInitForWindow = func(window *gtk.ApplicationWindow) { | |||
| 56 | + | layershell.InitForWindow(&window.Window) | |||
| 57 | + | } | |||
| 58 | + | layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) { | |||
| 59 | + | layershell.SetLayer(&window.Window, layer) | |||
| 60 | + | } | |||
| 61 | + | layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) { | |||
| 62 | + | layershell.SetExclusiveZone(&window.Window, zone) | |||
| 63 | + | } | |||
| 64 | + | layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) { | |||
| 65 | + | layershell.SetNamespace(&window.Window, namespace) | |||
| 66 | + | } | |||
| 67 | + | layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |||
| 68 | + | layershell.SetKeyboardMode(&window.Window, mode) | |||
| 69 | + | } | |||
| 70 | + | ) | |||
| 71 | + | ||||
| 72 | + | func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig { | |||
| 73 | + | return StandaloneOmniboxWindowConfig{ | |||
| 74 | + | Namespace: standaloneOmniboxNamespace, | |||
| 75 | + | Title: standaloneOmniboxTitle, | |||
| 76 | + | Width: standaloneOmniboxWidth, | |||
| 77 | + | Height: standaloneOmniboxHeight, | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func standaloneOmniboxArgv(args []string) []string { | |||
| 82 | + | if len(args) == 0 { | |||
| 83 | + | return []string{"dumber-omnibox"} | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | filtered := []string{args[0]} | |||
| 87 | + | for i := 1; i < len(args); i++ { | |||
| 88 | + | arg := args[i] | |||
| 89 | + | if arg == "omnibox" { | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | if strings.HasPrefix(arg, "-test.") { | |||
| 93 | + | if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) { | |||
| 94 | + | i++ | |||
| 95 | + | } | |||
| 96 | + | continue | |||
| 97 | + | } | |||
| 98 | + | filtered = append(filtered, arg) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return filtered | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func goTestFlagConsumesNextArg(arg string) bool { | |||
| 105 | + | switch arg { | |||
| 106 | + | case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile", | |||
| 107 | + | "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir", | |||
| 108 | + | "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile", | |||
| 109 | + | "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction", | |||
| 110 | + | "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle", | |||
| 111 | + | "-test.skip", "-test.timeout", "-test.trace": | |||
| 112 | + | return true | |||
| 113 | + | default: | |||
| 114 | + | return false | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) { | |||
| 119 | + | return func(url string) { | |||
| 120 | + | if original != nil { | |||
| 121 | + | original(url) | |||
| 122 | + | } | |||
| 123 | + | if quit != nil { | |||
| 124 | + | quit() | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func standaloneOmniboxCloseHandler(quit func()) func() { | |||
| 130 | + | return func() { | |||
| 131 | + | if quit != nil { | |||
| 132 | + | quit() | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool { | |||
| 138 | + | return !layerShellApplied | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func standaloneOmniboxWindowResizable(layerShellApplied bool) bool { | |||
| 142 | + | return !layerShellApplied | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig { | |||
| 146 | + | return standaloneOmniboxHostLayoutConfig{ | |||
| 147 | + | UseDefaultSize: !layerShellApplied, | |||
| 148 | + | OmniboxSize: standaloneOmniboxSizeConfig(), | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) { | |||
| 153 | + | if host == nil { | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | host.SetChild(omniboxWidget) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func standaloneOmniboxSizeConfig() component.ModalSizeConfig { | |||
| 161 | + | return component.ResolveModalSizeConfig(component.ModalSizeConfig{ | |||
| 162 | + | FixedWidth: component.OmniboxSizeDefaults.MaxWidth, | |||
| 163 | + | FixedTopMargin: 0, | |||
| 164 | + | UseFixedTopMargin: true, | |||
| 165 | + | }, component.OmniboxSizeDefaults) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool { | |||
| 169 | + | if window == nil { | |||
| 170 | + | return false | |||
| 171 | + | } | |||
| 172 | + | if !safeLayerShellSupported() { | |||
| 173 | + | return false | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | layerShellInitForWindow(window) | |||
| 177 | + | layerShellSetLayer(window, layershell.LayerOverlayValue) | |||
| 178 | + | layerShellSetExclusiveZone(window, 0) | |||
| 179 | + | layerShellSetNamespace(window, &cfg.Namespace) | |||
| 180 | + | layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue) | |||
| 181 | + | return true | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func safeLayerShellSupported() (supported bool) { | |||
| 185 | + | defer func() { | |||
| 186 | + | if recover() != nil { | |||
| 187 | + | supported = false | |||
| 188 | + | } | |||
| 189 | + | }() | |||
| 190 | + | ||||
| 191 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() { | |||
| 192 | + | return layerShellIsSupported() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |||
| 199 | + | if window == nil { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | title := windowCfg.Title | |||
| 204 | + | window.SetTitle(&title) | |||
| 205 | + | layerShellApplied := tryInitLayerShell(window, windowCfg) | |||
| 206 | + | hostLayout := standaloneOmniboxHostLayout(layerShellApplied) | |||
| 207 | + | if hostLayout.UseDefaultSize { | |||
| 208 | + | window.SetDefaultSize(windowCfg.Width, windowCfg.Height) | |||
| 209 | + | } | |||
| 210 | + | if layerShellApplied { | |||
| 211 | + | window.AddCssClass(standaloneOmniboxWindowCSS) | |||
| 212 | + | } | |||
| 213 | + | window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied)) | |||
| 214 | + | window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied)) | |||
| 215 | + | window.SetChild(child) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func buildStandaloneOmniboxHost( | |||
| 219 | + | ctx context.Context, | |||
| 220 | + | runtimeCfg *StandaloneOmniboxRuntime, | |||
| 221 | + | gtkApp *gtk.Application, | |||
| 222 | + | ) (*gtk.Widget, *component.Omnibox) { | |||
| 223 | + | factory := layout.NewGtkWidgetFactory() | |||
| 224 | + | overlay := factory.NewOverlay() | |||
| 225 | + | hostLayout := standaloneOmniboxHostLayout(false) | |||
| 226 | + | if overlay == nil { | |||
| 227 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets") | |||
| 228 | + | return nil, nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | cfg := runtimeCfg.OmniboxCfg | |||
| 232 | + | cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit) | |||
| 233 | + | cfg.SizeConfig = hostLayout.OmniboxSize | |||
| 234 | + | ||||
| 235 | + | omnibox := component.NewOmnibox(ctx, cfg) | |||
| 236 | + | if omnibox == nil { | |||
| 237 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox") | |||
| 238 | + | return nil, nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | overlay.SetVisible(true) | |||
| 242 | + | omnibox.SetParentOverlay(overlay) | |||
| 243 | + | omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit)) | |||
| 244 | + | ||||
| 245 | + | omniboxWidget := omnibox.WidgetAsLayout(factory) | |||
| 246 | + | if omniboxWidget == nil { | |||
| 247 | + | logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget") | |||
| 248 | + | return nil, nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | attachStandaloneOmniboxHostWidget(overlay, omniboxWidget) | |||
| 252 | + | logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child") | |||
| 253 | + | return overlay.GtkWidget(), omnibox | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) { | |||
| 257 | + | if host == nil { | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 262 | + | logging.FromContext(ctx).Debug(). | |||
| 263 | + | Int("width", host.GetAllocatedWidth()). | |||
| 264 | + | Int("height", host.GetAllocatedHeight()). | |||
| 265 | + | Msg("standalone omnibox host allocation") | |||
| 266 | + | return false | |||
| 267 | + | } | |||
| 268 | + | glib.IdleAdd(&cb, 0) | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int { | |||
| 272 | + | if runtimeCfg == nil { | |||
| 273 | + | logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured") | |||
| 274 | + | return 1 | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | adw.Init() | |||
| 278 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 279 | + | appID := windowCfg.Namespace | |||
| 280 | + | gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags()) | |||
| 281 | + | if gtkApp == nil { | |||
| 282 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application") | |||
| 283 | + | return 1 | |||
| 284 | + | } | |||
| 285 | + | defer gtkApp.Unref() | |||
| 286 | + | ||||
| 287 | + | activateCb := func(_ gio.Application) { | |||
| 288 | + | child, omnibox := buildStandaloneOmniboxHost(ctx, runtimeCfg, gtkApp) | |||
| 289 | + | if child == nil || omnibox == nil { | |||
| 290 | + | gtkApp.Quit() | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | window := gtk.NewApplicationWindow(gtkApp) | |||
| 295 | + | if window == nil { | |||
| 296 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window") | |||
| 297 | + | gtkApp.Quit() | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | configureStandaloneOmniboxWindow(window, windowCfg, child) | |||
| 302 | + | if display := window.GetDisplay(); display != nil && runtimeCfg.ApplyTheme != nil { | |||
| 303 | + | runtimeCfg.ApplyTheme(display) | |||
| 304 | + | } | |||
| 305 | + | window.Present() | |||
| 306 | + | logStandaloneOmniboxHostAllocation(ctx, child) | |||
| 307 | + | omnibox.Show(ctx, "") | |||
| 308 | + | } | |||
| 309 | + | gtkApp.ConnectActivate(&activateCb) | |||
| 310 | + | ||||
| 311 | + | argv := standaloneOmniboxArgv(os.Args) | |||
| 312 | + | return gtkApp.Run(len(argv), argv) | |||
| 313 | + | } | |||
A internal/ui/standalone_omnibox_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 7 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | + | "github.com/bnema/puregotk/v4/layershell" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) { | |
| 13 | + | var calls []string | |
| 14 | + | wrapped := wrapStandaloneOmniboxNavigate(func(url string) { | |
| 15 | + | calls = append(calls, "navigate:"+url) | |
| 16 | + | }, func() { | |
| 17 | + | calls = append(calls, "quit") | |
| 18 | + | }) | |
| 19 | + | ||
| 20 | + | wrapped("https://example.com") | |
| 21 | + | ||
| 22 | + | if len(calls) != 2 { | |
| 23 | + | t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls) | |
| 24 | + | } | |
| 25 | + | if calls[0] != "navigate:https://example.com" { | |
| 26 | + | t.Fatalf("expected navigate call first, got %#v", calls) | |
| 27 | + | } | |
| 28 | + | if calls[1] != "quit" { | |
| 29 | + | t.Fatalf("expected quit call second, got %#v", calls) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) { | |
| 34 | + | called := false | |
| 35 | + | wrapped := wrapStandaloneOmniboxNavigate(nil, func() { | |
| 36 | + | called = true | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | wrapped("https://example.com") | |
| 40 | + | ||
| 41 | + | if !called { | |
| 42 | + | t.Fatalf("expected quit to be called") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) { | |
| 47 | + | called := false | |
| 48 | + | onClose := standaloneOmniboxCloseHandler(func() { | |
| 49 | + | called = true | |
| 50 | + | }) | |
| 51 | + | ||
| 52 | + | onClose() | |
| 53 | + | ||
| 54 | + | if !called { | |
| 55 | + | t.Fatalf("expected close handler to quit app") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) { | |
| 60 | + | if standaloneOmniboxWindowDecorated(true) { | |
| 61 | + | t.Fatalf("expected layer-shell window to be undecorated") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) { | |
| 66 | + | if !standaloneOmniboxWindowDecorated(false) { | |
| 67 | + | t.Fatalf("expected fallback window to remain decorated") | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) { | |
| 72 | + | if standaloneOmniboxWindowResizable(true) { | |
| 73 | + | t.Fatalf("expected layer-shell window to stop keeping stale height allocations") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) { | |
| 78 | + | if !standaloneOmniboxWindowResizable(false) { | |
| 79 | + | t.Fatalf("expected fallback window to remain resizable") | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) { | |
| 84 | + | layoutCfg := standaloneOmniboxHostLayout(true) | |
| 85 | + | ||
| 86 | + | if layoutCfg.UseDefaultSize { | |
| 87 | + | t.Fatalf("expected layer-shell host to avoid fixed default size") | |
| 88 | + | } | |
| 89 | + | if layoutCfg.OmniboxSize.FixedWidth != 800 { | |
| 90 | + | t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth) | |
| 91 | + | } | |
| 92 | + | if !layoutCfg.OmniboxSize.UseFixedTopMargin { | |
| 93 | + | t.Fatalf("expected standalone omnibox to use a fixed top margin") | |
| 94 | + | } | |
| 95 | + | if layoutCfg.OmniboxSize.FixedTopMargin != 0 { | |
| 96 | + | t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) { | |
| 101 | + | layoutCfg := standaloneOmniboxHostLayout(false) | |
| 102 | + | ||
| 103 | + | if !layoutCfg.UseDefaultSize { | |
| 104 | + | t.Fatalf("expected non-layer-shell host to keep fixed fallback size") | |
| 105 | + | } | |
| 106 | + | if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() { | |
| 107 | + | t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) { | |
| 112 | + | host := layoutmocks.NewMockOverlayWidget(t) | |
| 113 | + | child := layoutmocks.NewMockWidget(t) | |
| 114 | + | ||
| 115 | + | host.EXPECT().SetChild(child).Once() | |
| 116 | + | ||
| 117 | + | attachStandaloneOmniboxHostWidget(host, child) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) { | |
| 121 | + | cfg := standaloneOmniboxSizeConfig() | |
| 122 | + | ||
| 123 | + | if cfg != (component.ModalSizeConfig{ | |
| 124 | + | WidthPct: component.OmniboxSizeDefaults.WidthPct, | |
| 125 | + | MaxWidth: component.OmniboxSizeDefaults.MaxWidth, | |
| 126 | + | TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct, | |
| 127 | + | FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth, | |
| 128 | + | FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight, | |
| 129 | + | FixedWidth: 800, | |
| 130 | + | FixedTopMargin: 0, | |
| 131 | + | UseFixedTopMargin: true, | |
| 132 | + | }) { | |
| 133 | + | t.Fatalf("unexpected standalone omnibox size config: %#v", cfg) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestStandaloneWindowConfig_Defaults(t *testing.T) { | |
| 138 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |
| 139 | + | if cfg.Namespace == "" { | |
| 140 | + | t.Fatalf("expected namespace") | |
| 141 | + | } | |
| 142 | + | if cfg.Title == "" { | |
| 143 | + | t.Fatalf("expected title") | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) { | |
| 148 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"}) | |
| 149 | + | ||
| 150 | + | if len(argv) != 2 { | |
| 151 | + | t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv) | |
| 152 | + | } | |
| 153 | + | if argv[0] != "/tmp/testbin" { | |
| 154 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |
| 155 | + | } | |
| 156 | + | if argv[1] != "--gtk-debug" { | |
| 157 | + | t.Fatalf("expected non-test arg to be preserved, got %#v", argv) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) { | |
| 162 | + | if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) { | |
| 163 | + | t.Fatalf("expected nil window to skip layer-shell") | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) { | |
| 168 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 169 | + | original := layerShellIsSupported | |
| 170 | + | layerShellLibraryAvailable = func() bool { return true } | |
| 171 | + | layerShellIsSupported = func() bool { return false } | |
| 172 | + | t.Cleanup(func() { | |
| 173 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 174 | + | layerShellIsSupported = original | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |
| 178 | + | t.Fatalf("expected unsupported layer-shell to skip initialization") | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) { | |
| 183 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 184 | + | originalIsSupported := layerShellIsSupported | |
| 185 | + | layerShellLibraryAvailable = func() bool { return false } | |
| 186 | + | layerShellIsSupported = func() bool { | |
| 187 | + | t.Fatalf("expected layer-shell support check to be skipped when library is missing") | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | t.Cleanup(func() { | |
| 191 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 192 | + | layerShellIsSupported = originalIsSupported | |
| 193 | + | }) | |
| 194 | + | ||
| 195 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |
| 196 | + | t.Fatalf("expected missing library to skip layer-shell initialization") | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) { | |
| 201 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 202 | + | originalIsSupported := layerShellIsSupported | |
| 203 | + | t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0") | |
| 204 | + | layerShellLibraryAvailable = func() bool { | |
| 205 | + | t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded") | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | layerShellIsSupported = func() bool { return true } | |
| 209 | + | t.Cleanup(func() { | |
| 210 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 211 | + | layerShellIsSupported = originalIsSupported | |
| 212 | + | }) | |
| 213 | + | ||
| 214 | + | if !safeLayerShellSupported() { | |
| 215 | + | t.Fatalf("expected preloaded layer-shell library to be treated as supported") | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) { | |
| 220 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 221 | + | originalIsSupported := layerShellIsSupported | |
| 222 | + | originalInitForWindow := layerShellInitForWindow | |
| 223 | + | originalSetLayer := layerShellSetLayer | |
| 224 | + | originalSetExclusiveZone := layerShellSetExclusiveZone | |
| 225 | + | originalSetNamespace := layerShellSetNamespace | |
| 226 | + | originalSetKeyboardMode := layerShellSetKeyboardMode | |
| 227 | + | t.Cleanup(func() { | |
| 228 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 229 | + | layerShellIsSupported = originalIsSupported | |
| 230 | + | layerShellInitForWindow = originalInitForWindow | |
| 231 | + | layerShellSetLayer = originalSetLayer | |
| 232 | + | layerShellSetExclusiveZone = originalSetExclusiveZone | |
| 233 | + | layerShellSetNamespace = originalSetNamespace | |
| 234 | + | layerShellSetKeyboardMode = originalSetKeyboardMode | |
| 235 | + | }) | |
| 236 | + | ||
| 237 | + | window := >k.ApplicationWindow{} | |
| 238 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |
| 239 | + | var callOrder []string | |
| 240 | + | ||
| 241 | + | layerShellLibraryAvailable = func() bool { | |
| 242 | + | callOrder = append(callOrder, "library") | |
| 243 | + | return true | |
| 244 | + | } | |
| 245 | + | layerShellIsSupported = func() bool { | |
| 246 | + | callOrder = append(callOrder, "supported") | |
| 247 | + | return true | |
| 248 | + | } | |
| 249 | + | layerShellInitForWindow = func(got *gtk.ApplicationWindow) { | |
| 250 | + | if got != window { | |
| 251 | + | t.Fatalf("expected init window %p, got %p", window, got) | |
| 252 | + | } | |
| 253 | + | callOrder = append(callOrder, "init") | |
| 254 | + | } | |
| 255 | + | layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) { | |
| 256 | + | if got != window { | |
| 257 | + | t.Fatalf("expected layer window %p, got %p", window, got) | |
| 258 | + | } | |
| 259 | + | if layer != layershell.LayerOverlayValue { | |
| 260 | + | t.Fatalf("expected overlay layer, got %v", layer) | |
| 261 | + | } | |
| 262 | + | callOrder = append(callOrder, "layer") | |
| 263 | + | } | |
| 264 | + | layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) { | |
| 265 | + | if got != window { | |
| 266 | + | t.Fatalf("expected exclusive zone window %p, got %p", window, got) | |
| 267 | + | } | |
| 268 | + | if zone != 0 { | |
| 269 | + | t.Fatalf("expected exclusive zone 0, got %d", zone) | |
| 270 | + | } | |
| 271 | + | callOrder = append(callOrder, "zone") | |
| 272 | + | } | |
| 273 | + | layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) { | |
| 274 | + | if got != window { | |
| 275 | + | t.Fatalf("expected namespace window %p, got %p", window, got) | |
| 276 | + | } | |
| 277 | + | if namespace == nil || *namespace != cfg.Namespace { | |
| 278 | + | t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace) | |
| 279 | + | } | |
| 280 | + | callOrder = append(callOrder, "namespace") | |
| 281 | + | } | |
| 282 | + | layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |
| 283 | + | if got != window { | |
| 284 | + | t.Fatalf("expected keyboard mode window %p, got %p", window, got) | |
| 285 | + | } | |
| 286 | + | if mode != layershell.KeyboardModeExclusiveValue { | |
| 287 | + | t.Fatalf("expected exclusive keyboard mode, got %v", mode) | |
| 288 | + | } | |
| 289 | + | callOrder = append(callOrder, "keyboard") | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | if !tryInitLayerShell(window, cfg) { | |
| 293 | + | t.Fatalf("expected supported layer-shell to initialize") | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"} | |
| 297 | + | if len(callOrder) != len(wantOrder) { | |
| 298 | + | t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder) | |
| 299 | + | } | |
| 300 | + | for i := range wantOrder { | |
| 301 | + | if callOrder[i] != wantOrder[i] { | |
| 302 | + | t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i]) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 7 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 9 | + | "github.com/bnema/puregotk/v4/layershell" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) { | |||
| 13 | + | var calls []string | |||
| 14 | + | wrapped := wrapStandaloneOmniboxNavigate(func(url string) { | |||
| 15 | + | calls = append(calls, "navigate:"+url) | |||
| 16 | + | }, func() { | |||
| 17 | + | calls = append(calls, "quit") | |||
| 18 | + | }) | |||
| 19 | + | ||||
| 20 | + | wrapped("https://example.com") | |||
| 21 | + | ||||
| 22 | + | if len(calls) != 2 { | |||
| 23 | + | t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls) | |||
| 24 | + | } | |||
| 25 | + | if calls[0] != "navigate:https://example.com" { | |||
| 26 | + | t.Fatalf("expected navigate call first, got %#v", calls) | |||
| 27 | + | } | |||
| 28 | + | if calls[1] != "quit" { | |||
| 29 | + | t.Fatalf("expected quit call second, got %#v", calls) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) { | |||
| 34 | + | called := false | |||
| 35 | + | wrapped := wrapStandaloneOmniboxNavigate(nil, func() { | |||
| 36 | + | called = true | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | wrapped("https://example.com") | |||
| 40 | + | ||||
| 41 | + | if !called { | |||
| 42 | + | t.Fatalf("expected quit to be called") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) { | |||
| 47 | + | called := false | |||
| 48 | + | onClose := standaloneOmniboxCloseHandler(func() { | |||
| 49 | + | called = true | |||
| 50 | + | }) | |||
| 51 | + | ||||
| 52 | + | onClose() | |||
| 53 | + | ||||
| 54 | + | if !called { | |||
| 55 | + | t.Fatalf("expected close handler to quit app") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) { | |||
| 60 | + | if standaloneOmniboxWindowDecorated(true) { | |||
| 61 | + | t.Fatalf("expected layer-shell window to be undecorated") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) { | |||
| 66 | + | if !standaloneOmniboxWindowDecorated(false) { | |||
| 67 | + | t.Fatalf("expected fallback window to remain decorated") | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) { | |||
| 72 | + | if standaloneOmniboxWindowResizable(true) { | |||
| 73 | + | t.Fatalf("expected layer-shell window to stop keeping stale height allocations") | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) { | |||
| 78 | + | if !standaloneOmniboxWindowResizable(false) { | |||
| 79 | + | t.Fatalf("expected fallback window to remain resizable") | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) { | |||
| 84 | + | layoutCfg := standaloneOmniboxHostLayout(true) | |||
| 85 | + | ||||
| 86 | + | if layoutCfg.UseDefaultSize { | |||
| 87 | + | t.Fatalf("expected layer-shell host to avoid fixed default size") | |||
| 88 | + | } | |||
| 89 | + | if layoutCfg.OmniboxSize.FixedWidth != 800 { | |||
| 90 | + | t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth) | |||
| 91 | + | } | |||
| 92 | + | if !layoutCfg.OmniboxSize.UseFixedTopMargin { | |||
| 93 | + | t.Fatalf("expected standalone omnibox to use a fixed top margin") | |||
| 94 | + | } | |||
| 95 | + | if layoutCfg.OmniboxSize.FixedTopMargin != 0 { | |||
| 96 | + | t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) { | |||
| 101 | + | layoutCfg := standaloneOmniboxHostLayout(false) | |||
| 102 | + | ||||
| 103 | + | if !layoutCfg.UseDefaultSize { | |||
| 104 | + | t.Fatalf("expected non-layer-shell host to keep fixed fallback size") | |||
| 105 | + | } | |||
| 106 | + | if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() { | |||
| 107 | + | t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) { | |||
| 112 | + | host := layoutmocks.NewMockOverlayWidget(t) | |||
| 113 | + | child := layoutmocks.NewMockWidget(t) | |||
| 114 | + | ||||
| 115 | + | host.EXPECT().SetChild(child).Once() | |||
| 116 | + | ||||
| 117 | + | attachStandaloneOmniboxHostWidget(host, child) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) { | |||
| 121 | + | cfg := standaloneOmniboxSizeConfig() | |||
| 122 | + | ||||
| 123 | + | if cfg != (component.ModalSizeConfig{ | |||
| 124 | + | WidthPct: component.OmniboxSizeDefaults.WidthPct, | |||
| 125 | + | MaxWidth: component.OmniboxSizeDefaults.MaxWidth, | |||
| 126 | + | TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct, | |||
| 127 | + | FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth, | |||
| 128 | + | FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight, | |||
| 129 | + | FixedWidth: 800, | |||
| 130 | + | FixedTopMargin: 0, | |||
| 131 | + | UseFixedTopMargin: true, | |||
| 132 | + | }) { | |||
| 133 | + | t.Fatalf("unexpected standalone omnibox size config: %#v", cfg) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestStandaloneWindowConfig_Defaults(t *testing.T) { | |||
| 138 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 139 | + | if cfg.Namespace == "" { | |||
| 140 | + | t.Fatalf("expected namespace") | |||
| 141 | + | } | |||
| 142 | + | if cfg.Title == "" { | |||
| 143 | + | t.Fatalf("expected title") | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) { | |||
| 148 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"}) | |||
| 149 | + | ||||
| 150 | + | if len(argv) != 2 { | |||
| 151 | + | t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv) | |||
| 152 | + | } | |||
| 153 | + | if argv[0] != "/tmp/testbin" { | |||
| 154 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |||
| 155 | + | } | |||
| 156 | + | if argv[1] != "--gtk-debug" { | |||
| 157 | + | t.Fatalf("expected non-test arg to be preserved, got %#v", argv) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) { | |||
| 162 | + | if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 163 | + | t.Fatalf("expected nil window to skip layer-shell") | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) { | |||
| 168 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 169 | + | original := layerShellIsSupported | |||
| 170 | + | layerShellLibraryAvailable = func() bool { return true } | |||
| 171 | + | layerShellIsSupported = func() bool { return false } | |||
| 172 | + | t.Cleanup(func() { | |||
| 173 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 174 | + | layerShellIsSupported = original | |||
| 175 | + | }) | |||
| 176 | + | ||||
| 177 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 178 | + | t.Fatalf("expected unsupported layer-shell to skip initialization") | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) { | |||
| 183 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 184 | + | originalIsSupported := layerShellIsSupported | |||
| 185 | + | layerShellLibraryAvailable = func() bool { return false } | |||
| 186 | + | layerShellIsSupported = func() bool { | |||
| 187 | + | t.Fatalf("expected layer-shell support check to be skipped when library is missing") | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | t.Cleanup(func() { | |||
| 191 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 192 | + | layerShellIsSupported = originalIsSupported | |||
| 193 | + | }) | |||
| 194 | + | ||||
| 195 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 196 | + | t.Fatalf("expected missing library to skip layer-shell initialization") | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) { | |||
| 201 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 202 | + | originalIsSupported := layerShellIsSupported | |||
| 203 | + | t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0") | |||
| 204 | + | layerShellLibraryAvailable = func() bool { | |||
| 205 | + | t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded") | |||
| 206 | + | return false | |||
| 207 | + | } | |||
| 208 | + | layerShellIsSupported = func() bool { return true } | |||
| 209 | + | t.Cleanup(func() { | |||
| 210 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 211 | + | layerShellIsSupported = originalIsSupported | |||
| 212 | + | }) | |||
| 213 | + | ||||
| 214 | + | if !safeLayerShellSupported() { | |||
| 215 | + | t.Fatalf("expected preloaded layer-shell library to be treated as supported") | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) { | |||
| 220 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 221 | + | originalIsSupported := layerShellIsSupported | |||
| 222 | + | originalInitForWindow := layerShellInitForWindow | |||
| 223 | + | originalSetLayer := layerShellSetLayer | |||
| 224 | + | originalSetExclusiveZone := layerShellSetExclusiveZone | |||
| 225 | + | originalSetNamespace := layerShellSetNamespace | |||
| 226 | + | originalSetKeyboardMode := layerShellSetKeyboardMode | |||
| 227 | + | t.Cleanup(func() { | |||
| 228 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 229 | + | layerShellIsSupported = originalIsSupported | |||
| 230 | + | layerShellInitForWindow = originalInitForWindow | |||
| 231 | + | layerShellSetLayer = originalSetLayer | |||
| 232 | + | layerShellSetExclusiveZone = originalSetExclusiveZone | |||
| 233 | + | layerShellSetNamespace = originalSetNamespace | |||
| 234 | + | layerShellSetKeyboardMode = originalSetKeyboardMode | |||
| 235 | + | }) | |||
| 236 | + | ||||
| 237 | + | window := >k.ApplicationWindow{} | |||
| 238 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 239 | + | var callOrder []string | |||
| 240 | + | ||||
| 241 | + | layerShellLibraryAvailable = func() bool { | |||
| 242 | + | callOrder = append(callOrder, "library") | |||
| 243 | + | return true | |||
| 244 | + | } | |||
| 245 | + | layerShellIsSupported = func() bool { | |||
| 246 | + | callOrder = append(callOrder, "supported") | |||
| 247 | + | return true | |||
| 248 | + | } | |||
| 249 | + | layerShellInitForWindow = func(got *gtk.ApplicationWindow) { | |||
| 250 | + | if got != window { | |||
| 251 | + | t.Fatalf("expected init window %p, got %p", window, got) | |||
| 252 | + | } | |||
| 253 | + | callOrder = append(callOrder, "init") | |||
| 254 | + | } | |||
| 255 | + | layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) { | |||
| 256 | + | if got != window { | |||
| 257 | + | t.Fatalf("expected layer window %p, got %p", window, got) | |||
| 258 | + | } | |||
| 259 | + | if layer != layershell.LayerOverlayValue { | |||
| 260 | + | t.Fatalf("expected overlay layer, got %v", layer) | |||
| 261 | + | } | |||
| 262 | + | callOrder = append(callOrder, "layer") | |||
| 263 | + | } | |||
| 264 | + | layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) { | |||
| 265 | + | if got != window { | |||
| 266 | + | t.Fatalf("expected exclusive zone window %p, got %p", window, got) | |||
| 267 | + | } | |||
| 268 | + | if zone != 0 { | |||
| 269 | + | t.Fatalf("expected exclusive zone 0, got %d", zone) | |||
| 270 | + | } | |||
| 271 | + | callOrder = append(callOrder, "zone") | |||
| 272 | + | } | |||
| 273 | + | layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) { | |||
| 274 | + | if got != window { | |||
| 275 | + | t.Fatalf("expected namespace window %p, got %p", window, got) | |||
| 276 | + | } | |||
| 277 | + | if namespace == nil || *namespace != cfg.Namespace { | |||
| 278 | + | t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace) | |||
| 279 | + | } | |||
| 280 | + | callOrder = append(callOrder, "namespace") | |||
| 281 | + | } | |||
| 282 | + | layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |||
| 283 | + | if got != window { | |||
| 284 | + | t.Fatalf("expected keyboard mode window %p, got %p", window, got) | |||
| 285 | + | } | |||
| 286 | + | if mode != layershell.KeyboardModeExclusiveValue { | |||
| 287 | + | t.Fatalf("expected exclusive keyboard mode, got %v", mode) | |||
| 288 | + | } | |||
| 289 | + | callOrder = append(callOrder, "keyboard") | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | if !tryInitLayerShell(window, cfg) { | |||
| 293 | + | t.Fatalf("expected supported layer-shell to initialize") | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"} | |||
| 297 | + | if len(callOrder) != len(wantOrder) { | |||
| 298 | + | t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder) | |||
| 299 | + | } | |||
| 300 | + | for i := range wantOrder { | |||
| 301 | + | if callOrder[i] != wantOrder[i] { | |||
| 302 | + | t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i]) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | } | |||
A internal/ui/standalone_runtime_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) { | |
| 11 | + | browserURL := "" | |
| 12 | + | externalURL := "" | |
| 13 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 14 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 15 | + | LaunchExternalURL: func(uri string) { | |
| 16 | + | externalURL = uri | |
| 17 | + | }, | |
| 18 | + | }, context.Background(), "https://example.com") | |
| 19 | + | ||
| 20 | + | if browserURL != "https://example.com" { | |
| 21 | + | t.Fatalf("expected browser launcher to receive URL, got %q", browserURL) | |
| 22 | + | } | |
| 23 | + | if externalURL != "" { | |
| 24 | + | t.Fatalf("expected external launcher to be unused, got %q", externalURL) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) { | |
| 29 | + | browserURL := "" | |
| 30 | + | externalURL := "" | |
| 31 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 32 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 33 | + | LaunchExternalURL: func(uri string) { | |
| 34 | + | externalURL = uri | |
| 35 | + | }, | |
| 36 | + | }, context.Background(), "vscode://file/tmp/demo") | |
| 37 | + | ||
| 38 | + | if externalURL != "vscode://file/tmp/demo" { | |
| 39 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |
| 40 | + | } | |
| 41 | + | if browserURL != "" { | |
| 42 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) { | |
| 47 | + | browserURL := "" | |
| 48 | + | externalURL := "" | |
| 49 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 50 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 51 | + | LaunchExternalURL: func(uri string) { | |
| 52 | + | externalURL = uri | |
| 53 | + | }, | |
| 54 | + | }, context.Background(), "mailto:foo@example.com") | |
| 55 | + | ||
| 56 | + | if externalURL != "mailto:foo@example.com" { | |
| 57 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |
| 58 | + | } | |
| 59 | + | if browserURL != "" { | |
| 60 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) { | |
| 65 | + | if urlutil.IsExternalScheme("https://example.com") { | |
| 66 | + | t.Fatalf("expected http url to stay in dumber") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |
| 71 | + | if !urlutil.IsExternalScheme("spotify://track/123") { | |
| 72 | + | t.Fatalf("expected custom scheme to be external") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | |
| 77 | + | for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { | |
| 78 | + | if urlutil.IsExternalScheme(rawURL) { | |
| 79 | + | t.Fatalf("expected %q to stay internal", rawURL) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) { | |||
| 11 | + | browserURL := "" | |||
| 12 | + | externalURL := "" | |||
| 13 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 14 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 15 | + | LaunchExternalURL: func(uri string) { | |||
| 16 | + | externalURL = uri | |||
| 17 | + | }, | |||
| 18 | + | }, context.Background(), "https://example.com") | |||
| 19 | + | ||||
| 20 | + | if browserURL != "https://example.com" { | |||
| 21 | + | t.Fatalf("expected browser launcher to receive URL, got %q", browserURL) | |||
| 22 | + | } | |||
| 23 | + | if externalURL != "" { | |||
| 24 | + | t.Fatalf("expected external launcher to be unused, got %q", externalURL) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) { | |||
| 29 | + | browserURL := "" | |||
| 30 | + | externalURL := "" | |||
| 31 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 32 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 33 | + | LaunchExternalURL: func(uri string) { | |||
| 34 | + | externalURL = uri | |||
| 35 | + | }, | |||
| 36 | + | }, context.Background(), "vscode://file/tmp/demo") | |||
| 37 | + | ||||
| 38 | + | if externalURL != "vscode://file/tmp/demo" { | |||
| 39 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |||
| 40 | + | } | |||
| 41 | + | if browserURL != "" { | |||
| 42 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) { | |||
| 47 | + | browserURL := "" | |||
| 48 | + | externalURL := "" | |||
| 49 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 50 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 51 | + | LaunchExternalURL: func(uri string) { | |||
| 52 | + | externalURL = uri | |||
| 53 | + | }, | |||
| 54 | + | }, context.Background(), "mailto:foo@example.com") | |||
| 55 | + | ||||
| 56 | + | if externalURL != "mailto:foo@example.com" { | |||
| 57 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |||
| 58 | + | } | |||
| 59 | + | if browserURL != "" { | |||
| 60 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) { | |||
| 65 | + | if urlutil.IsExternalScheme("https://example.com") { | |||
| 66 | + | t.Fatalf("expected http url to stay in dumber") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |||
| 71 | + | if !urlutil.IsExternalScheme("spotify://track/123") { | |||
| 72 | + | t.Fatalf("expected custom scheme to be external") | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | |||
| 77 | + | for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { | |||
| 78 | + | if urlutil.IsExternalScheme(rawURL) { | |||
| 79 | + | t.Fatalf("expected %q to stay internal", rawURL) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
M internal/ui/theme/css.go +37 -14
| @@ -206,6 +206,10 @@ button.tab-button.tab-button-active { | |||
| 206 | 206 | func generateOmniboxCSS(p Palette) string { | |
| 207 | 207 | return `/* ===== Omnibox Styling ===== */ | |
| 208 | 208 | ||
| 209 | + | window.standalone-omnibox-window { | |
| 210 | + | background-color: transparent; | |
| 211 | + | } | |
| 212 | + | ||
| 209 | 213 | /* Omnibox outer container - for positioning in overlay */ | |
| 210 | 214 | .omnibox-outer { | |
| 211 | 215 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | |
| @@ -213,23 +217,26 @@ func generateOmniboxCSS(p Palette) string { | |||
| 213 | 217 | ||
| 214 | 218 | /* Omnibox main container - the visible popup */ | |
| 215 | 219 | .omnibox-container { | |
| 216 | - | background-color: var(--surface-variant); | |
| 217 | - | border: 0.0625em solid var(--border); | |
| 220 | + | background-color: alpha(var(--surface-variant), 0.94); | |
| 221 | + | background-image: linear-gradient(to bottom, alpha(var(--surface), 0.22), alpha(var(--surface-variant), 0.08)); | |
| 222 | + | border: 0.0625em solid alpha(var(--border), 0.85); | |
| 218 | 223 | border-radius: 0.1875em; | |
| 224 | + | box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset; | |
| 219 | 225 | padding: 0; | |
| 220 | 226 | } | |
| 221 | 227 | ||
| 222 | 228 | /* Header with History/Favorites toggle */ | |
| 223 | 229 | .omnibox-header { | |
| 224 | - | background-color: shade(var(--surface-variant), 1.1); | |
| 225 | - | border-bottom: 0.0625em solid var(--border); | |
| 230 | + | background-color: alpha(var(--surface), 0.34); | |
| 231 | + | background-image: linear-gradient(to bottom, alpha(white, 0.03), alpha(black, 0.04)); | |
| 232 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.75); | |
| 226 | 233 | padding: 0.375em 0.75em; | |
| 227 | 234 | } | |
| 228 | 235 | ||
| 229 | 236 | .omnibox-header-btn { | |
| 230 | - | background-color: transparent; | |
| 237 | + | background-color: alpha(var(--surface), 0.12); | |
| 231 | 238 | background-image: none; | |
| 232 | - | border: none; | |
| 239 | + | border: 0.0625em solid transparent; | |
| 233 | 240 | border-radius: 0.125em; | |
| 234 | 241 | padding: 0.25em 0.75em; | |
| 235 | 242 | margin-right: 0.5em; | |
| @@ -240,26 +247,29 @@ func generateOmniboxCSS(p Palette) string { | |||
| 240 | 247 | } | |
| 241 | 248 | ||
| 242 | 249 | .omnibox-header-btn:hover { | |
| 243 | - | background-color: alpha(var(--accent), 0.15); | |
| 250 | + | background-color: alpha(var(--accent), 0.14); | |
| 251 | + | border-color: alpha(var(--accent), 0.18); | |
| 244 | 252 | color: var(--text); | |
| 245 | 253 | } | |
| 246 | 254 | ||
| 247 | 255 | .omnibox-header-btn.omnibox-header-active { | |
| 248 | - | background-color: alpha(var(--accent), 0.2); | |
| 256 | + | background-color: alpha(var(--accent), 0.18); | |
| 257 | + | border-color: alpha(var(--accent), 0.28); | |
| 249 | 258 | color: var(--accent); | |
| 250 | 259 | font-weight: 600; | |
| 251 | 260 | } | |
| 252 | 261 | ||
| 253 | 262 | /* Search entry field */ | |
| 254 | 263 | .omnibox-entry { | |
| 255 | - | background-color: var(--bg); | |
| 264 | + | background-color: alpha(var(--bg), 0.88); | |
| 256 | 265 | color: var(--text); | |
| 257 | - | border: 0.0625em solid var(--border); | |
| 266 | + | border: 0.0625em solid alpha(var(--border), 0.82); | |
| 258 | 267 | border-radius: 0.125em; | |
| 259 | 268 | padding: 0.625em 0.75em; | |
| 260 | 269 | margin: 0.5em 0.75em; | |
| 261 | 270 | font-size: 1em; | |
| 262 | 271 | caret-color: var(--accent); | |
| 272 | + | box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset; | |
| 263 | 273 | } | |
| 264 | 274 | ||
| 265 | 275 | entry.omnibox-entry:focus, | |
| @@ -281,8 +291,8 @@ entry.omnibox-entry > *:focus-visible { | |||
| 281 | 291 | ||
| 282 | 292 | /* Scrolled window for suggestions */ | |
| 283 | 293 | .omnibox-scrolled { | |
| 284 | - | background-color: shade(var(--surface-variant), 0.95); | |
| 285 | - | border-top: 0.0625em solid var(--border); | |
| 294 | + | background-color: alpha(var(--surface-variant), 0.72); | |
| 295 | + | border-top: 0.0625em solid alpha(var(--border), 0.65); | |
| 286 | 296 | } | |
| 287 | 297 | ||
| 288 | 298 | /* List box */ | |
| @@ -296,7 +306,8 @@ entry.omnibox-entry > *:focus-visible { | |||
| 296 | 306 | margin: 0; | |
| 297 | 307 | border-radius: 0; | |
| 298 | 308 | border-left: 0.1875em solid transparent; | |
| 299 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.5); | |
| 309 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.35); | |
| 310 | + | background-color: transparent; | |
| 300 | 311 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | |
| 301 | 312 | min-height: 2.75em; | |
| 302 | 313 | } | |
| @@ -358,10 +369,22 @@ entry.omnibox-entry > *:focus-visible { | |||
| 358 | 369 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | |
| 359 | 370 | .omnibox-row .omnibox-suggestion-url { | |
| 360 | 371 | font-size: 0.75em; | |
| 361 | - | color: alpha(var(--muted), 0.65); | |
| 372 | + | color: alpha(var(--muted), 0.72); | |
| 362 | 373 | font-weight: 400; | |
| 363 | 374 | } | |
| 364 | 375 | ||
| 376 | + | .omnibox-favorite-star { | |
| 377 | + | color: var(--warning); | |
| 378 | + | opacity: 0.9; | |
| 379 | + | margin-left: 0.5em; | |
| 380 | + | margin-right: 0.125em; | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | .omnibox-row:selected .omnibox-favorite-star { | |
| 384 | + | color: var(--warning); | |
| 385 | + | opacity: 1; | |
| 386 | + | } | |
| 387 | + | ||
| 365 | 388 | .omnibox-row:selected .omnibox-suggestion-url { | |
| 366 | 389 | color: alpha(var(--muted), 0.75); | |
| 367 | 390 | } | |
| @@ -206,6 +206,10 @@ button.tab-button.tab-button-active { | |||||
| 206 | func generateOmniboxCSS(p Palette) string { | 206 | func generateOmniboxCSS(p Palette) string { | ||
| 207 | return `/* ===== Omnibox Styling ===== */ | 207 | return `/* ===== Omnibox Styling ===== */ | ||
| 208 | 208 | ||||
| 209 | + | window.standalone-omnibox-window { | |||
| 210 | + | background-color: transparent; | |||
| 211 | + | } | |||
| 212 | + | ||||
| 209 | /* Omnibox outer container - for positioning in overlay */ | 213 | /* Omnibox outer container - for positioning in overlay */ | ||
| 210 | .omnibox-outer { | 214 | .omnibox-outer { | ||
| 211 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | 215 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | ||
| @@ -213,23 +217,26 @@ func generateOmniboxCSS(p Palette) string { | |||||
| 213 | 217 | ||||
| 214 | /* Omnibox main container - the visible popup */ | 218 | /* Omnibox main container - the visible popup */ | ||
| 215 | .omnibox-container { | 219 | .omnibox-container { | ||
| 216 | - | background-color: var(--surface-variant); | 220 | + | background-color: alpha(var(--surface-variant), 0.94); |
| 217 | - | border: 0.0625em solid var(--border); | 221 | + | background-image: linear-gradient(to bottom, alpha(var(--surface), 0.22), alpha(var(--surface-variant), 0.08)); |
| 222 | + | border: 0.0625em solid alpha(var(--border), 0.85); | |||
| 218 | border-radius: 0.1875em; | 223 | border-radius: 0.1875em; | ||
| 224 | + | box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset; | |||
| 219 | padding: 0; | 225 | padding: 0; | ||
| 220 | } | 226 | } | ||
| 221 | 227 | ||||
| 222 | /* Header with History/Favorites toggle */ | 228 | /* Header with History/Favorites toggle */ | ||
| 223 | .omnibox-header { | 229 | .omnibox-header { | ||
| 224 | - | background-color: shade(var(--surface-variant), 1.1); | 230 | + | background-color: alpha(var(--surface), 0.34); |
| 225 | - | border-bottom: 0.0625em solid var(--border); | 231 | + | background-image: linear-gradient(to bottom, alpha(white, 0.03), alpha(black, 0.04)); |
| 232 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.75); | |||
| 226 | padding: 0.375em 0.75em; | 233 | padding: 0.375em 0.75em; | ||
| 227 | } | 234 | } | ||
| 228 | 235 | ||||
| 229 | .omnibox-header-btn { | 236 | .omnibox-header-btn { | ||
| 230 | - | background-color: transparent; | 237 | + | background-color: alpha(var(--surface), 0.12); |
| 231 | background-image: none; | 238 | background-image: none; | ||
| 232 | - | border: none; | 239 | + | border: 0.0625em solid transparent; |
| 233 | border-radius: 0.125em; | 240 | border-radius: 0.125em; | ||
| 234 | padding: 0.25em 0.75em; | 241 | padding: 0.25em 0.75em; | ||
| 235 | margin-right: 0.5em; | 242 | margin-right: 0.5em; | ||
| @@ -240,26 +247,29 @@ func generateOmniboxCSS(p Palette) string { | |||||
| 240 | } | 247 | } | ||
| 241 | 248 | ||||
| 242 | .omnibox-header-btn:hover { | 249 | .omnibox-header-btn:hover { | ||
| 243 | - | background-color: alpha(var(--accent), 0.15); | 250 | + | background-color: alpha(var(--accent), 0.14); |
| 251 | + | border-color: alpha(var(--accent), 0.18); | |||
| 244 | color: var(--text); | 252 | color: var(--text); | ||
| 245 | } | 253 | } | ||
| 246 | 254 | ||||
| 247 | .omnibox-header-btn.omnibox-header-active { | 255 | .omnibox-header-btn.omnibox-header-active { | ||
| 248 | - | background-color: alpha(var(--accent), 0.2); | 256 | + | background-color: alpha(var(--accent), 0.18); |
| 257 | + | border-color: alpha(var(--accent), 0.28); | |||
| 249 | color: var(--accent); | 258 | color: var(--accent); | ||
| 250 | font-weight: 600; | 259 | font-weight: 600; | ||
| 251 | } | 260 | } | ||
| 252 | 261 | ||||
| 253 | /* Search entry field */ | 262 | /* Search entry field */ | ||
| 254 | .omnibox-entry { | 263 | .omnibox-entry { | ||
| 255 | - | background-color: var(--bg); | 264 | + | background-color: alpha(var(--bg), 0.88); |
| 256 | color: var(--text); | 265 | color: var(--text); | ||
| 257 | - | border: 0.0625em solid var(--border); | 266 | + | border: 0.0625em solid alpha(var(--border), 0.82); |
| 258 | border-radius: 0.125em; | 267 | border-radius: 0.125em; | ||
| 259 | padding: 0.625em 0.75em; | 268 | padding: 0.625em 0.75em; | ||
| 260 | margin: 0.5em 0.75em; | 269 | margin: 0.5em 0.75em; | ||
| 261 | font-size: 1em; | 270 | font-size: 1em; | ||
| 262 | caret-color: var(--accent); | 271 | caret-color: var(--accent); | ||
| 272 | + | box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset; | |||
| 263 | } | 273 | } | ||
| 264 | 274 | ||||
| 265 | entry.omnibox-entry:focus, | 275 | entry.omnibox-entry:focus, | ||
| @@ -281,8 +291,8 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 281 | 291 | ||||
| 282 | /* Scrolled window for suggestions */ | 292 | /* Scrolled window for suggestions */ | ||
| 283 | .omnibox-scrolled { | 293 | .omnibox-scrolled { | ||
| 284 | - | background-color: shade(var(--surface-variant), 0.95); | 294 | + | background-color: alpha(var(--surface-variant), 0.72); |
| 285 | - | border-top: 0.0625em solid var(--border); | 295 | + | border-top: 0.0625em solid alpha(var(--border), 0.65); |
| 286 | } | 296 | } | ||
| 287 | 297 | ||||
| 288 | /* List box */ | 298 | /* List box */ | ||
| @@ -296,7 +306,8 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 296 | margin: 0; | 306 | margin: 0; | ||
| 297 | border-radius: 0; | 307 | border-radius: 0; | ||
| 298 | border-left: 0.1875em solid transparent; | 308 | border-left: 0.1875em solid transparent; | ||
| 299 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.5); | 309 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.35); |
| 310 | + | background-color: transparent; | |||
| 300 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | 311 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | ||
| 301 | min-height: 2.75em; | 312 | min-height: 2.75em; | ||
| 302 | } | 313 | } | ||
| @@ -358,10 +369,22 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 358 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | 369 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | ||
| 359 | .omnibox-row .omnibox-suggestion-url { | 370 | .omnibox-row .omnibox-suggestion-url { | ||
| 360 | font-size: 0.75em; | 371 | font-size: 0.75em; | ||
| 361 | - | color: alpha(var(--muted), 0.65); | 372 | + | color: alpha(var(--muted), 0.72); |
| 362 | font-weight: 400; | 373 | font-weight: 400; | ||
| 363 | } | 374 | } | ||
| 364 | 375 | ||||
| 376 | + | .omnibox-favorite-star { | |||
| 377 | + | color: var(--warning); | |||
| 378 | + | opacity: 0.9; | |||
| 379 | + | margin-left: 0.5em; | |||
| 380 | + | margin-right: 0.125em; | |||
| 381 | + | } | |||
| 382 | + | ||||
| 383 | + | .omnibox-row:selected .omnibox-favorite-star { | |||
| 384 | + | color: var(--warning); | |||
| 385 | + | opacity: 1; | |||
| 386 | + | } | |||
| 387 | + | ||||
| 365 | .omnibox-row:selected .omnibox-suggestion-url { | 388 | .omnibox-row:selected .omnibox-suggestion-url { | ||
| 366 | color: alpha(var(--muted), 0.75); | 389 | color: alpha(var(--muted), 0.75); | ||
| 367 | } | 390 | } | ||
A internal/ui/theme/css_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) { | |
| 9 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 10 | + | ||
| 11 | + | if !strings.Contains(css, "window.standalone-omnibox-window") { | |
| 12 | + | t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css) | |
| 13 | + | } | |
| 14 | + | if !strings.Contains(css, "window.standalone-omnibox-window {\n\tbackground-color: transparent;") { | |
| 15 | + | t.Fatalf("expected standalone omnibox window background to be transparent") | |
| 16 | + | } | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) { | |||
| 9 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 10 | + | ||||
| 11 | + | if !strings.Contains(css, "window.standalone-omnibox-window") { | |||
| 12 | + | t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css) | |||
| 13 | + | } | |||
| 14 | + | if !strings.Contains(css, "window.standalone-omnibox-window {\n\tbackground-color: transparent;") { | |||
| 15 | + | t.Fatalf("expected standalone omnibox window background to be transparent") | |||
| 16 | + | } | |||
| 17 | + | } | |||