Merge pull request #211 from bnema/feature/omnibox-layer-shell
feat(omnibox): add standalone layer-shell launcher
32 files · +2425 -228
M cmd/dumber/main.go +121 -4
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "os/signal" | |
| 9 | 9 | "runtime" | |
| 10 | + | "strings" | |
| 10 | 11 | "syscall" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -51,20 +52,58 @@ var initialURL string | |||
| 51 | 52 | // restoreSessionID holds the session ID to restore on startup. | |
| 52 | 53 | var restoreSessionID string | |
| 53 | 54 | ||
| 55 | + | type launchMode string | |
| 56 | + | ||
| 57 | + | const ( | |
| 58 | + | launchModeCLI launchMode = "cli" | |
| 59 | + | launchModeBrowse launchMode = "browse" | |
| 60 | + | launchModeStandaloneOmnibox launchMode = "omnibox" | |
| 61 | + | ) | |
| 62 | + | ||
| 63 | + | func launchModeFromArgs(args []string) (launchMode, string) { | |
| 64 | + | if len(args) > 1 { | |
| 65 | + | switch args[1] { | |
| 66 | + | case "browse": | |
| 67 | + | if len(args) > 3 { | |
| 68 | + | return launchModeCLI, "" | |
| 69 | + | } | |
| 70 | + | if len(args) > 2 { | |
| 71 | + | if strings.HasPrefix(args[2], "-") { | |
| 72 | + | return launchModeCLI, "" | |
| 73 | + | } | |
| 74 | + | return launchModeBrowse, args[2] | |
| 75 | + | } | |
| 76 | + | return launchModeBrowse, "" | |
| 77 | + | case "omnibox": | |
| 78 | + | if len(args) > 2 { | |
| 79 | + | return launchModeCLI, "" | |
| 80 | + | } | |
| 81 | + | return launchModeStandaloneOmnibox, "" | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return launchModeCLI, "" | |
| 86 | + | } | |
| 87 | + | ||
| 54 | 88 | func main() { | |
| 55 | 89 | enableCrashForensics() | |
| 56 | 90 | ||
| 91 | + | mode, browseURL := launchModeFromArgs(os.Args) | |
| 92 | + | ||
| 57 | 93 | // Run GUI mode for browse command | |
| 58 | - | if len(os.Args) > 1 && os.Args[1] == "browse" { | |
| 59 | - | if len(os.Args) > 2 { | |
| 60 | - | initialURL = os.Args[2] | |
| 61 | - | } | |
| 94 | + | if mode == launchModeBrowse { | |
| 95 | + | initialURL = browseURL | |
| 62 | 96 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 63 | 97 | os.Args = os.Args[:1] | |
| 64 | 98 | os.Exit(runGUI()) | |
| 65 | 99 | return | |
| 66 | 100 | } | |
| 67 | 101 | ||
| 102 | + | if mode == launchModeStandaloneOmnibox { | |
| 103 | + | os.Exit(runStandaloneOmnibox()) | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | ||
| 68 | 107 | // Pass build info to CLI | |
| 69 | 108 | cmd.SetBuildInfo(build.Info{ | |
| 70 | 109 | Version: version, | |
| @@ -140,6 +179,83 @@ func runGUI() int { | |||
| 140 | 179 | return app.Run(ctx, os.Args) | |
| 141 | 180 | } | |
| 142 | 181 | ||
| 182 | + | func runStandaloneOmnibox() int { | |
| 183 | + | maybeReexecStandaloneOmniboxWithLayerShell() | |
| 184 | + | ||
| 185 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 186 | + | runtime.LockOSThread() | |
| 187 | + | component.SetSkeletonVersion(version) | |
| 188 | + | ||
| 189 | + | cfg := initConfig() | |
| 190 | + | ctx := initStartupContextWithTrace(cfg) | |
| 191 | + | ||
| 192 | + | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 193 | + | if err != nil { | |
| 194 | + | return 1 | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |
| 198 | + | if err != nil { | |
| 199 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime") | |
| 200 | + | return 1 | |
| 201 | + | } | |
| 202 | + | if dbCleanup != nil { | |
| 203 | + | defer dbCleanup() | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | useCases := createUseCases(repos, cfg) | |
| 207 | + | uiDeps := buildUIDependencies( | |
| 208 | + | ctx, | |
| 209 | + | cfg, | |
| 210 | + | initResult.ThemeManager, | |
| 211 | + | initResult.ColorResolver, | |
| 212 | + | initResult.AdwaitaDetector, | |
| 213 | + | engine, | |
| 214 | + | repos, | |
| 215 | + | useCases, | |
| 216 | + | nil, | |
| 217 | + | "", | |
| 218 | + | nil, | |
| 219 | + | ) | |
| 220 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |
| 221 | + | ||
| 222 | + | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func maybeReexecStandaloneOmniboxWithLayerShell() { | |
| 226 | + | env := bootstrap.CurrentEnvMap() | |
| 227 | + | if !bootstrap.ShouldPreloadLayerShell(env) { | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | libraryPath := bootstrap.LayerShellLibraryPath() | |
| 232 | + | if libraryPath == "" { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | env = bootstrap.LayerShellPreloadEnv(env, libraryPath) | |
| 237 | + | envSlice := make([]string, 0, len(env)) | |
| 238 | + | for key, value := range env { | |
| 239 | + | envSlice = append(envSlice, key+"="+value) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | execPath, err := resolveCurrentExecutable(os.Executable) | |
| 243 | + | if err != nil { | |
| 244 | + | fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err) | |
| 245 | + | return | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env. | |
| 249 | + | if err := syscall.Exec(execPath, os.Args, envSlice); err != nil { | |
| 250 | + | fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err) | |
| 251 | + | return | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func resolveCurrentExecutable(executable func() (string, error)) (string, error) { | |
| 256 | + | return executable() | |
| 257 | + | } | |
| 258 | + | ||
| 143 | 259 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | |
| 144 | 260 | logging.InitStartupTrace(cfg.Logging.Level) | |
| 145 | 261 | logging.Trace().Mark("config_loaded") | |
| @@ -581,6 +697,7 @@ func buildUIDependencies( | |||
| 581 | 697 | return config.GetManager().Watch() | |
| 582 | 698 | }, | |
| 583 | 699 | LaunchExternalURL: desktop.LaunchExternalURL, | |
| 700 | + | LaunchBrowserURL: desktop.LaunchBrowserURL, | |
| 584 | 701 | ConfigMigrator: config.NewMigrator(), | |
| 585 | 702 | } | |
| 586 | 703 | ||
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "os/signal" | 8 | "os/signal" | ||
| 9 | "runtime" | 9 | "runtime" | ||
| 10 | + | "strings" | |||
| 10 | "syscall" | 11 | "syscall" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/dumber/internal/application/port" | 13 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -51,20 +52,58 @@ var initialURL string | |||||
| 51 | // restoreSessionID holds the session ID to restore on startup. | 52 | // restoreSessionID holds the session ID to restore on startup. | ||
| 52 | var restoreSessionID string | 53 | var restoreSessionID string | ||
| 53 | 54 | ||||
| 55 | + | type launchMode string | |||
| 56 | + | ||||
| 57 | + | const ( | |||
| 58 | + | launchModeCLI launchMode = "cli" | |||
| 59 | + | launchModeBrowse launchMode = "browse" | |||
| 60 | + | launchModeStandaloneOmnibox launchMode = "omnibox" | |||
| 61 | + | ) | |||
| 62 | + | ||||
| 63 | + | func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 64 | + | if len(args) > 1 { | |||
| 65 | + | switch args[1] { | |||
| 66 | + | case "browse": | |||
| 67 | + | if len(args) > 3 { | |||
| 68 | + | return launchModeCLI, "" | |||
| 69 | + | } | |||
| 70 | + | if len(args) > 2 { | |||
| 71 | + | if strings.HasPrefix(args[2], "-") { | |||
| 72 | + | return launchModeCLI, "" | |||
| 73 | + | } | |||
| 74 | + | return launchModeBrowse, args[2] | |||
| 75 | + | } | |||
| 76 | + | return launchModeBrowse, "" | |||
| 77 | + | case "omnibox": | |||
| 78 | + | if len(args) > 2 { | |||
| 79 | + | return launchModeCLI, "" | |||
| 80 | + | } | |||
| 81 | + | return launchModeStandaloneOmnibox, "" | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return launchModeCLI, "" | |||
| 86 | + | } | |||
| 87 | + | ||||
| 54 | func main() { | 88 | func main() { | ||
| 55 | enableCrashForensics() | 89 | enableCrashForensics() | ||
| 56 | 90 | ||||
| 91 | + | mode, browseURL := launchModeFromArgs(os.Args) | |||
| 92 | + | ||||
| 57 | // Run GUI mode for browse command | 93 | // Run GUI mode for browse command | ||
| 58 | - | if len(os.Args) > 1 && os.Args[1] == "browse" { | 94 | + | if mode == launchModeBrowse { |
| 59 | - | if len(os.Args) > 2 { | 95 | + | initialURL = browseURL |
| 60 | - | initialURL = os.Args[2] | |||
| 61 | - | } | |||
| 62 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 96 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 63 | os.Args = os.Args[:1] | 97 | os.Args = os.Args[:1] | ||
| 64 | os.Exit(runGUI()) | 98 | os.Exit(runGUI()) | ||
| 65 | return | 99 | return | ||
| 66 | } | 100 | } | ||
| 67 | 101 | ||||
| 102 | + | if mode == launchModeStandaloneOmnibox { | |||
| 103 | + | os.Exit(runStandaloneOmnibox()) | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | ||||
| 68 | // Pass build info to CLI | 107 | // Pass build info to CLI | ||
| 69 | cmd.SetBuildInfo(build.Info{ | 108 | cmd.SetBuildInfo(build.Info{ | ||
| 70 | Version: version, | 109 | Version: version, | ||
| @@ -140,6 +179,83 @@ func runGUI() int { | |||||
| 140 | return app.Run(ctx, os.Args) | 179 | return app.Run(ctx, os.Args) | ||
| 141 | } | 180 | } | ||
| 142 | 181 | ||||
| 182 | + | func runStandaloneOmnibox() int { | |||
| 183 | + | maybeReexecStandaloneOmniboxWithLayerShell() | |||
| 184 | + | ||||
| 185 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |||
| 186 | + | runtime.LockOSThread() | |||
| 187 | + | component.SetSkeletonVersion(version) | |||
| 188 | + | ||||
| 189 | + | cfg := initConfig() | |||
| 190 | + | ctx := initStartupContextWithTrace(cfg) | |||
| 191 | + | ||||
| 192 | + | initResult, err := runParallelInitPhase(ctx, cfg) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return 1 | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false) | |||
| 198 | + | if err != nil { | |||
| 199 | + | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime") | |||
| 200 | + | return 1 | |||
| 201 | + | } | |||
| 202 | + | if dbCleanup != nil { | |||
| 203 | + | defer dbCleanup() | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | useCases := createUseCases(repos, cfg) | |||
| 207 | + | uiDeps := buildUIDependencies( | |||
| 208 | + | ctx, | |||
| 209 | + | cfg, | |||
| 210 | + | initResult.ThemeManager, | |||
| 211 | + | initResult.ColorResolver, | |||
| 212 | + | initResult.AdwaitaDetector, | |||
| 213 | + | engine, | |||
| 214 | + | repos, | |||
| 215 | + | useCases, | |||
| 216 | + | nil, | |||
| 217 | + | "", | |||
| 218 | + | nil, | |||
| 219 | + | ) | |||
| 220 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |||
| 221 | + | ||||
| 222 | + | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func maybeReexecStandaloneOmniboxWithLayerShell() { | |||
| 226 | + | env := bootstrap.CurrentEnvMap() | |||
| 227 | + | if !bootstrap.ShouldPreloadLayerShell(env) { | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | libraryPath := bootstrap.LayerShellLibraryPath() | |||
| 232 | + | if libraryPath == "" { | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | env = bootstrap.LayerShellPreloadEnv(env, libraryPath) | |||
| 237 | + | envSlice := make([]string, 0, len(env)) | |||
| 238 | + | for key, value := range env { | |||
| 239 | + | envSlice = append(envSlice, key+"="+value) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | execPath, err := resolveCurrentExecutable(os.Executable) | |||
| 243 | + | if err != nil { | |||
| 244 | + | fmt.Fprintf(os.Stderr, "Failed to resolve standalone omnibox executable path: %v\n", err) | |||
| 245 | + | return | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // #nosec G702 -- execPath comes from os.Executable(), not user input; this re-execs the current binary with adjusted preload env. | |||
| 249 | + | if err := syscall.Exec(execPath, os.Args, envSlice); err != nil { | |||
| 250 | + | fmt.Fprintf(os.Stderr, "Failed to re-exec standalone omnibox with layer-shell preload: %v\n", err) | |||
| 251 | + | return | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func resolveCurrentExecutable(executable func() (string, error)) (string, error) { | |||
| 256 | + | return executable() | |||
| 257 | + | } | |||
| 258 | + | ||||
| 143 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | 259 | func initStartupContextWithTrace(cfg *config.Config) context.Context { | ||
| 144 | logging.InitStartupTrace(cfg.Logging.Level) | 260 | logging.InitStartupTrace(cfg.Logging.Level) | ||
| 145 | logging.Trace().Mark("config_loaded") | 261 | logging.Trace().Mark("config_loaded") | ||
| @@ -581,6 +697,7 @@ func buildUIDependencies( | |||||
| 581 | return config.GetManager().Watch() | 697 | return config.GetManager().Watch() | ||
| 582 | }, | 698 | }, | ||
| 583 | LaunchExternalURL: desktop.LaunchExternalURL, | 699 | LaunchExternalURL: desktop.LaunchExternalURL, | ||
| 700 | + | LaunchBrowserURL: desktop.LaunchBrowserURL, | |||
| 584 | ConfigMigrator: config.NewMigrator(), | 701 | ConfigMigrator: config.NewMigrator(), | ||
| 585 | } | 702 | } | ||
| 586 | 703 | ||||
A cmd/dumber/main_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |
| 9 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |
| 10 | + | if mode != launchModeStandaloneOmnibox { | |
| 11 | + | t.Fatalf("expected standalone omnibox mode, got %q", mode) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) { | |
| 16 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"}) | |
| 17 | + | if mode != launchModeBrowse { | |
| 18 | + | t.Fatalf("expected browse mode, got %q", mode) | |
| 19 | + | } | |
| 20 | + | if browseURL != "https://example.com" { | |
| 21 | + | t.Fatalf("expected browse url to be preserved, got %q", browseURL) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestLaunchModeFromArgs_BrowseHelpFallsBackToCLI(t *testing.T) { | |
| 26 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "--help"}) | |
| 27 | + | if mode != launchModeCLI { | |
| 28 | + | t.Fatalf("expected cli mode for browse help, got %q", mode) | |
| 29 | + | } | |
| 30 | + | if browseURL != "" { | |
| 31 | + | t.Fatalf("expected empty browse url for browse help, got %q", browseURL) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLaunchModeFromArgs_BrowseExtraPositionalFallsBackToCLI(t *testing.T) { | |
| 36 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com", "extra"}) | |
| 37 | + | if mode != launchModeCLI { | |
| 38 | + | t.Fatalf("expected cli mode for browse extra args, got %q", mode) | |
| 39 | + | } | |
| 40 | + | if browseURL != "" { | |
| 41 | + | t.Fatalf("expected empty browse url for browse extra args, got %q", browseURL) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) { | |
| 46 | + | mode, browseURL := launchModeFromArgs([]string{"dumber"}) | |
| 47 | + | if mode != launchModeCLI { | |
| 48 | + | t.Fatalf("expected cli mode, got %q", mode) | |
| 49 | + | } | |
| 50 | + | if browseURL != "" { | |
| 51 | + | t.Fatalf("expected empty browse url, got %q", browseURL) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) { | |
| 56 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"}) | |
| 57 | + | if mode != launchModeCLI { | |
| 58 | + | t.Fatalf("expected cli mode for omnibox help, got %q", mode) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) { | |
| 63 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"}) | |
| 64 | + | if mode != launchModeCLI { | |
| 65 | + | t.Fatalf("expected cli mode for omnibox flags, got %q", mode) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |
| 70 | + | got, err := resolveCurrentExecutable(func() (string, error) { | |
| 71 | + | return "/usr/bin/dumber", nil | |
| 72 | + | }) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatalf("expected no error, got %v", err) | |
| 75 | + | } | |
| 76 | + | if got != "/usr/bin/dumber" { | |
| 77 | + | t.Fatalf("expected executable path to be preserved, got %q", got) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |
| 82 | + | wantErr := errors.New("not found") | |
| 83 | + | _, err := resolveCurrentExecutable(func() (string, error) { | |
| 84 | + | return "", wantErr | |
| 85 | + | }) | |
| 86 | + | if !errors.Is(err, wantErr) { | |
| 87 | + | t.Fatalf("expected error %v, got %v", wantErr, err) | |
| 88 | + | } | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) { | |||
| 9 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"}) | |||
| 10 | + | if mode != launchModeStandaloneOmnibox { | |||
| 11 | + | t.Fatalf("expected standalone omnibox mode, got %q", mode) | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestLaunchModeFromArgs_DetectsBrowseURL(t *testing.T) { | |||
| 16 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com"}) | |||
| 17 | + | if mode != launchModeBrowse { | |||
| 18 | + | t.Fatalf("expected browse mode, got %q", mode) | |||
| 19 | + | } | |||
| 20 | + | if browseURL != "https://example.com" { | |||
| 21 | + | t.Fatalf("expected browse url to be preserved, got %q", browseURL) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestLaunchModeFromArgs_BrowseHelpFallsBackToCLI(t *testing.T) { | |||
| 26 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "--help"}) | |||
| 27 | + | if mode != launchModeCLI { | |||
| 28 | + | t.Fatalf("expected cli mode for browse help, got %q", mode) | |||
| 29 | + | } | |||
| 30 | + | if browseURL != "" { | |||
| 31 | + | t.Fatalf("expected empty browse url for browse help, got %q", browseURL) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestLaunchModeFromArgs_BrowseExtraPositionalFallsBackToCLI(t *testing.T) { | |||
| 36 | + | mode, browseURL := launchModeFromArgs([]string{"dumber", "browse", "https://example.com", "extra"}) | |||
| 37 | + | if mode != launchModeCLI { | |||
| 38 | + | t.Fatalf("expected cli mode for browse extra args, got %q", mode) | |||
| 39 | + | } | |||
| 40 | + | if browseURL != "" { | |||
| 41 | + | t.Fatalf("expected empty browse url for browse extra args, got %q", browseURL) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestLaunchModeFromArgs_DefaultsToCLI(t *testing.T) { | |||
| 46 | + | mode, browseURL := launchModeFromArgs([]string{"dumber"}) | |||
| 47 | + | if mode != launchModeCLI { | |||
| 48 | + | t.Fatalf("expected cli mode, got %q", mode) | |||
| 49 | + | } | |||
| 50 | + | if browseURL != "" { | |||
| 51 | + | t.Fatalf("expected empty browse url, got %q", browseURL) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestLaunchModeFromArgs_OmniboxHelpFallsBackToCLI(t *testing.T) { | |||
| 56 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--help"}) | |||
| 57 | + | if mode != launchModeCLI { | |||
| 58 | + | t.Fatalf("expected cli mode for omnibox help, got %q", mode) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) { | |||
| 63 | + | mode, _ := launchModeFromArgs([]string{"dumber", "omnibox", "--bad-flag"}) | |||
| 64 | + | if mode != launchModeCLI { | |||
| 65 | + | t.Fatalf("expected cli mode for omnibox flags, got %q", mode) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestResolveCurrentExecutable_ReturnsExecutablePath(t *testing.T) { | |||
| 70 | + | got, err := resolveCurrentExecutable(func() (string, error) { | |||
| 71 | + | return "/usr/bin/dumber", nil | |||
| 72 | + | }) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatalf("expected no error, got %v", err) | |||
| 75 | + | } | |||
| 76 | + | if got != "/usr/bin/dumber" { | |||
| 77 | + | t.Fatalf("expected executable path to be preserved, got %q", got) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestResolveCurrentExecutable_PropagatesError(t *testing.T) { | |||
| 82 | + | wantErr := errors.New("not found") | |||
| 83 | + | _, err := resolveCurrentExecutable(func() (string, error) { | |||
| 84 | + | return "", wantErr | |||
| 85 | + | }) | |||
| 86 | + | if !errors.Is(err, wantErr) { | |||
| 87 | + | t.Fatalf("expected error %v, got %v", wantErr, err) | |||
| 88 | + | } | |||
| 89 | + | } | |||
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 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(env map[string]string, exists func(path string) bool) string { | |
| 44 | + | for _, path := range layerShellLibrarySearchPaths(env) { | |
| 45 | + | if exists(path) { | |
| 46 | + | return path | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | return "" | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func LayerShellLibraryPath() string { | |
| 54 | + | return FindLayerShellLibrary(CurrentEnvMap(), func(path string) bool { | |
| 55 | + | info, err := os.Stat(filepath.Clean(path)) | |
| 56 | + | return err == nil && !info.IsDir() | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func layerShellLibrarySearchPaths(env map[string]string) []string { | |
| 61 | + | paths := make([]string, 0, len(layerShellLibraryCandidates)+4) | |
| 62 | + | paths = append(paths, layerShellLibraryCandidates...) | |
| 63 | + | ||
| 64 | + | seen := make(map[string]struct{}, len(paths)) | |
| 65 | + | for _, path := range paths { | |
| 66 | + | seen[path] = struct{}{} | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) { | |
| 70 | + | if dir == "" { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0") | |
| 74 | + | if _, ok := seen[candidate]; ok { | |
| 75 | + | continue | |
| 76 | + | } | |
| 77 | + | seen[candidate] = struct{}{} | |
| 78 | + | paths = append(paths, candidate) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return paths | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string { | |
| 85 | + | cloned := make(map[string]string, len(env)+1) | |
| 86 | + | for key, value := range env { | |
| 87 | + | cloned[key] = value | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | if libraryPath == "" { | |
| 91 | + | return cloned | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | if current := cloned["LD_PRELOAD"]; current != "" { | |
| 95 | + | cloned["LD_PRELOAD"] = libraryPath + " " + current | |
| 96 | + | return cloned | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | cloned["LD_PRELOAD"] = libraryPath | |
| 100 | + | return cloned | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const layerShellLibrarySubstring = "libgtk4-layer-shell" | |||
| 10 | + | ||||
| 11 | + | var layerShellLibraryCandidates = []string{ | |||
| 12 | + | "/usr/lib/libgtk4-layer-shell.so.0", | |||
| 13 | + | "/usr/lib64/libgtk4-layer-shell.so.0", | |||
| 14 | + | "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 15 | + | "/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 16 | + | "/lib/x86_64-linux-gnu/libgtk4-layer-shell.so.0", | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func ShouldPreloadLayerShell(env map[string]string) bool { | |||
| 20 | + | if env["WAYLAND_DISPLAY"] == "" { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | return !LayerShellPreloadPresent(env) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func LayerShellPreloadPresent(env map[string]string) bool { | |||
| 28 | + | return strings.Contains(env["LD_PRELOAD"], layerShellLibrarySubstring) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func CurrentEnvMap() map[string]string { | |||
| 32 | + | env := make(map[string]string) | |||
| 33 | + | for _, entry := range os.Environ() { | |||
| 34 | + | key, value, ok := strings.Cut(entry, "=") | |||
| 35 | + | if !ok { | |||
| 36 | + | continue | |||
| 37 | + | } | |||
| 38 | + | env[key] = value | |||
| 39 | + | } | |||
| 40 | + | return env | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func FindLayerShellLibrary(env map[string]string, exists func(path string) bool) string { | |||
| 44 | + | for _, path := range layerShellLibrarySearchPaths(env) { | |||
| 45 | + | if exists(path) { | |||
| 46 | + | return path | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | return "" | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func LayerShellLibraryPath() string { | |||
| 54 | + | return FindLayerShellLibrary(CurrentEnvMap(), func(path string) bool { | |||
| 55 | + | info, err := os.Stat(filepath.Clean(path)) | |||
| 56 | + | return err == nil && !info.IsDir() | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func layerShellLibrarySearchPaths(env map[string]string) []string { | |||
| 61 | + | paths := make([]string, 0, len(layerShellLibraryCandidates)+4) | |||
| 62 | + | paths = append(paths, layerShellLibraryCandidates...) | |||
| 63 | + | ||||
| 64 | + | seen := make(map[string]struct{}, len(paths)) | |||
| 65 | + | for _, path := range paths { | |||
| 66 | + | seen[path] = struct{}{} | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | for _, dir := range strings.FieldsFunc(env["LD_LIBRARY_PATH"], func(r rune) bool { return r == ':' }) { | |||
| 70 | + | if dir == "" { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | candidate := filepath.Join(dir, "libgtk4-layer-shell.so.0") | |||
| 74 | + | if _, ok := seen[candidate]; ok { | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | seen[candidate] = struct{}{} | |||
| 78 | + | paths = append(paths, candidate) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | return paths | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func LayerShellPreloadEnv(env map[string]string, libraryPath string) map[string]string { | |||
| 85 | + | cloned := make(map[string]string, len(env)+1) | |||
| 86 | + | for key, value := range env { | |||
| 87 | + | cloned[key] = value | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | if libraryPath == "" { | |||
| 91 | + | return cloned | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | if current := cloned["LD_PRELOAD"]; current != "" { | |||
| 95 | + | cloned["LD_PRELOAD"] = libraryPath + " " + current | |||
| 96 | + | return cloned | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | cloned["LD_PRELOAD"] = libraryPath | |||
| 100 | + | return cloned | |||
| 101 | + | } | |||
A internal/bootstrap/layershell_preload_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) { | |
| 6 | + | env := map[string]string{ | |
| 7 | + | "WAYLAND_DISPLAY": "wayland-1", | |
| 8 | + | "LD_PRELOAD": "", | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | if !ShouldPreloadLayerShell(env) { | |
| 12 | + | t.Fatalf("expected preload to be required") | |
| 13 | + | } | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestFindLayerShellLibrary_UsesLDLibraryPathCandidates(t *testing.T) { | |
| 17 | + | customCandidate := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |
| 18 | + | got := FindLayerShellLibrary(map[string]string{ | |
| 19 | + | "LD_LIBRARY_PATH": "/opt/custom/lib", | |
| 20 | + | }, func(path string) bool { | |
| 21 | + | return path == customCandidate | |
| 22 | + | }) | |
| 23 | + | ||
| 24 | + | if got != customCandidate { | |
| 25 | + | t.Fatalf("expected %q, got %q", customCandidate, got) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) { | |
| 30 | + | env := map[string]string{ | |
| 31 | + | "WAYLAND_DISPLAY": "wayland-1", | |
| 32 | + | "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0", | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | if ShouldPreloadLayerShell(env) { | |
| 36 | + | t.Fatalf("expected preload to be skipped") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) { | |
| 41 | + | if ShouldPreloadLayerShell(map[string]string{}) { | |
| 42 | + | t.Fatalf("expected preload to be skipped outside Wayland") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) { | |
| 47 | + | env := map[string]string{ | |
| 48 | + | "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so", | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if !LayerShellPreloadPresent(env) { | |
| 52 | + | t.Fatalf("expected preload presence to be detected") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) { | |
| 57 | + | got := FindLayerShellLibrary(map[string]string{}, func(path string) bool { | |
| 58 | + | return path == layerShellLibraryCandidates[1] | |
| 59 | + | }) | |
| 60 | + | ||
| 61 | + | if got != layerShellLibraryCandidates[1] { | |
| 62 | + | t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) { | |
| 67 | + | arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0" | |
| 68 | + | got := FindLayerShellLibrary(map[string]string{}, func(path string) bool { | |
| 69 | + | return path == arm64Candidate | |
| 70 | + | }) | |
| 71 | + | ||
| 72 | + | if got != arm64Candidate { | |
| 73 | + | t.Fatalf("expected %q, got %q", arm64Candidate, got) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) { | |
| 78 | + | if got := FindLayerShellLibrary(map[string]string{}, func(string) bool { return false }); got != "" { | |
| 79 | + | t.Fatalf("expected empty result, got %q", got) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) { | |
| 84 | + | paths := layerShellLibrarySearchPaths(map[string]string{ | |
| 85 | + | "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib", | |
| 86 | + | }) | |
| 87 | + | ||
| 88 | + | wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |
| 89 | + | wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0" | |
| 90 | + | if !containsString(paths, wantCustom) { | |
| 91 | + | t.Fatalf("expected custom search path %q in %#v", wantCustom, paths) | |
| 92 | + | } | |
| 93 | + | if !containsString(paths, wantNix) { | |
| 94 | + | t.Fatalf("expected nix search path %q in %#v", wantNix, paths) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) { | |
| 99 | + | env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0") | |
| 100 | + | ||
| 101 | + | if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" { | |
| 102 | + | t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"]) | |
| 103 | + | } | |
| 104 | + | if env["PATH"] != "/usr/bin" { | |
| 105 | + | t.Fatalf("expected unrelated env to be preserved") | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) { | |
| 110 | + | env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0") | |
| 111 | + | ||
| 112 | + | want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so" | |
| 113 | + | if env["LD_PRELOAD"] != want { | |
| 114 | + | t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"]) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func containsString(items []string, want string) bool { | |
| 119 | + | for _, item := range items { | |
| 120 | + | if item == want { | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | return false | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestShouldPreloadLayerShell_WaylandWithoutPreloadReturnsTrue(t *testing.T) { | |||
| 6 | + | env := map[string]string{ | |||
| 7 | + | "WAYLAND_DISPLAY": "wayland-1", | |||
| 8 | + | "LD_PRELOAD": "", | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | if !ShouldPreloadLayerShell(env) { | |||
| 12 | + | t.Fatalf("expected preload to be required") | |||
| 13 | + | } | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestFindLayerShellLibrary_UsesLDLibraryPathCandidates(t *testing.T) { | |||
| 17 | + | customCandidate := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |||
| 18 | + | got := FindLayerShellLibrary(map[string]string{ | |||
| 19 | + | "LD_LIBRARY_PATH": "/opt/custom/lib", | |||
| 20 | + | }, func(path string) bool { | |||
| 21 | + | return path == customCandidate | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | if got != customCandidate { | |||
| 25 | + | t.Fatalf("expected %q, got %q", customCandidate, got) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestShouldPreloadLayerShell_AlreadyPreloadedReturnsFalse(t *testing.T) { | |||
| 30 | + | env := map[string]string{ | |||
| 31 | + | "WAYLAND_DISPLAY": "wayland-1", | |||
| 32 | + | "LD_PRELOAD": "/usr/lib/libgtk4-layer-shell.so.0", | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | if ShouldPreloadLayerShell(env) { | |||
| 36 | + | t.Fatalf("expected preload to be skipped") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestShouldPreloadLayerShell_NoWaylandReturnsFalse(t *testing.T) { | |||
| 41 | + | if ShouldPreloadLayerShell(map[string]string{}) { | |||
| 42 | + | t.Fatalf("expected preload to be skipped outside Wayland") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestLayerShellPreloadPresent_DetectsLoadedLibrarySubstring(t *testing.T) { | |||
| 47 | + | env := map[string]string{ | |||
| 48 | + | "LD_PRELOAD": "/nix/store/hash-libgtk4-layer-shell.so.0 /tmp/other.so", | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | if !LayerShellPreloadPresent(env) { | |||
| 52 | + | t.Fatalf("expected preload presence to be detected") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestFindLayerShellLibrary_ReturnsFirstExistingCandidate(t *testing.T) { | |||
| 57 | + | got := FindLayerShellLibrary(map[string]string{}, func(path string) bool { | |||
| 58 | + | return path == layerShellLibraryCandidates[1] | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | if got != layerShellLibraryCandidates[1] { | |||
| 62 | + | t.Fatalf("expected %q, got %q", layerShellLibraryCandidates[1], got) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestFindLayerShellLibrary_ReturnsArm64Candidate(t *testing.T) { | |||
| 67 | + | arm64Candidate := "/usr/lib/aarch64-linux-gnu/libgtk4-layer-shell.so.0" | |||
| 68 | + | got := FindLayerShellLibrary(map[string]string{}, func(path string) bool { | |||
| 69 | + | return path == arm64Candidate | |||
| 70 | + | }) | |||
| 71 | + | ||||
| 72 | + | if got != arm64Candidate { | |||
| 73 | + | t.Fatalf("expected %q, got %q", arm64Candidate, got) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestFindLayerShellLibrary_NoMatchReturnsEmpty(t *testing.T) { | |||
| 78 | + | if got := FindLayerShellLibrary(map[string]string{}, func(string) bool { return false }); got != "" { | |||
| 79 | + | t.Fatalf("expected empty result, got %q", got) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestLayerShellLibrarySearchPaths_IncludesLDLibraryPathCandidates(t *testing.T) { | |||
| 84 | + | paths := layerShellLibrarySearchPaths(map[string]string{ | |||
| 85 | + | "LD_LIBRARY_PATH": "/opt/custom/lib:/nix/store/abc/lib", | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | wantCustom := "/opt/custom/lib/libgtk4-layer-shell.so.0" | |||
| 89 | + | wantNix := "/nix/store/abc/lib/libgtk4-layer-shell.so.0" | |||
| 90 | + | if !containsString(paths, wantCustom) { | |||
| 91 | + | t.Fatalf("expected custom search path %q in %#v", wantCustom, paths) | |||
| 92 | + | } | |||
| 93 | + | if !containsString(paths, wantNix) { | |||
| 94 | + | t.Fatalf("expected nix search path %q in %#v", wantNix, paths) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestLayerShellPreloadEnv_SetsLDPreloadWhenEmpty(t *testing.T) { | |||
| 99 | + | env := LayerShellPreloadEnv(map[string]string{"PATH": "/usr/bin"}, "/usr/lib/libgtk4-layer-shell.so.0") | |||
| 100 | + | ||||
| 101 | + | if env["LD_PRELOAD"] != "/usr/lib/libgtk4-layer-shell.so.0" { | |||
| 102 | + | t.Fatalf("expected LD_PRELOAD to be set, got %q", env["LD_PRELOAD"]) | |||
| 103 | + | } | |||
| 104 | + | if env["PATH"] != "/usr/bin" { | |||
| 105 | + | t.Fatalf("expected unrelated env to be preserved") | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestLayerShellPreloadEnv_PrependsLibraryToExistingLDPreload(t *testing.T) { | |||
| 110 | + | env := LayerShellPreloadEnv(map[string]string{"LD_PRELOAD": "/tmp/other.so"}, "/usr/lib/libgtk4-layer-shell.so.0") | |||
| 111 | + | ||||
| 112 | + | want := "/usr/lib/libgtk4-layer-shell.so.0 /tmp/other.so" | |||
| 113 | + | if env["LD_PRELOAD"] != want { | |||
| 114 | + | t.Fatalf("expected LD_PRELOAD %q, got %q", want, env["LD_PRELOAD"]) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func containsString(items []string, want string) bool { | |||
| 119 | + | for _, item := range items { | |||
| 120 | + | if item == want { | |||
| 121 | + | return true | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | return false | |||
| 125 | + | } | |||
A internal/cli/cmd/omnibox.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/spf13/cobra" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | var omniboxCmd = &cobra.Command{ | |
| 8 | + | Use: "omnibox", | |
| 9 | + | Short: "Launch the standalone omnibox overlay", | |
| 10 | + | Args: cobra.NoArgs, | |
| 11 | + | Long: `Launch the GTK omnibox as a standalone surface. | |
| 12 | + | ||
| 13 | + | On Wayland, dumber will enable layer-shell when available so the omnibox can | |
| 14 | + | appear as its own overlay instead of inside the main browser window.`, | |
| 15 | + | Run: func(_ *cobra.Command, _ []string) { | |
| 16 | + | // Handled by cmd/dumber/main.go before Cobra executes. | |
| 17 | + | }, | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func init() { | |
| 21 | + | rootCmd.AddCommand(omniboxCmd) | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/spf13/cobra" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | var omniboxCmd = &cobra.Command{ | |||
| 8 | + | Use: "omnibox", | |||
| 9 | + | Short: "Launch the standalone omnibox overlay", | |||
| 10 | + | Args: cobra.NoArgs, | |||
| 11 | + | Long: `Launch the GTK omnibox as a standalone surface. | |||
| 12 | + | ||||
| 13 | + | On Wayland, dumber will enable layer-shell when available so the omnibox can | |||
| 14 | + | appear as its own overlay instead of inside the main browser window.`, | |||
| 15 | + | Run: func(_ *cobra.Command, _ []string) { | |||
| 16 | + | // Handled by cmd/dumber/main.go before Cobra executes. | |||
| 17 | + | }, | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func init() { | |||
| 21 | + | rootCmd.AddCommand(omniboxCmd) | |||
| 22 | + | } | |||
A internal/cli/cmd/omnibox_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRootCommand_RegistersOmniboxCommand(t *testing.T) { | |
| 6 | + | cmd, _, err := rootCmd.Find([]string{"omnibox"}) | |
| 7 | + | if err != nil { | |
| 8 | + | t.Fatalf("expected omnibox command to be registered: %v", err) | |
| 9 | + | } | |
| 10 | + | if cmd == nil || cmd.Name() != "omnibox" { | |
| 11 | + | t.Fatalf("expected omnibox command, got %#v", cmd) | |
| 12 | + | } | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func TestRootCommand_BrowseCommandRejectsExtraArgs(t *testing.T) { | |
| 16 | + | cmd, _, err := rootCmd.Find([]string{"browse"}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("expected browse command to be registered: %v", err) | |
| 19 | + | } | |
| 20 | + | if cmd == nil || cmd.Name() != "browse" { | |
| 21 | + | t.Fatalf("expected browse command, got %#v", cmd) | |
| 22 | + | } | |
| 23 | + | if cmd.Args == nil { | |
| 24 | + | t.Fatal("expected browse command to define argument validation") | |
| 25 | + | } | |
| 26 | + | if err := cmd.Args(cmd, []string{"https://example.com", "extra"}); err == nil { | |
| 27 | + | t.Fatal("expected browse command to reject extra args") | |
| 28 | + | } | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestRootCommand_RegistersOmniboxCommand(t *testing.T) { | |||
| 6 | + | cmd, _, err := rootCmd.Find([]string{"omnibox"}) | |||
| 7 | + | if err != nil { | |||
| 8 | + | t.Fatalf("expected omnibox command to be registered: %v", err) | |||
| 9 | + | } | |||
| 10 | + | if cmd == nil || cmd.Name() != "omnibox" { | |||
| 11 | + | t.Fatalf("expected omnibox command, got %#v", cmd) | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestRootCommand_BrowseCommandRejectsExtraArgs(t *testing.T) { | |||
| 16 | + | cmd, _, err := rootCmd.Find([]string{"browse"}) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("expected browse command to be registered: %v", err) | |||
| 19 | + | } | |||
| 20 | + | if cmd == nil || cmd.Name() != "browse" { | |||
| 21 | + | t.Fatalf("expected browse command, got %#v", cmd) | |||
| 22 | + | } | |||
| 23 | + | if cmd.Args == nil { | |||
| 24 | + | t.Fatal("expected browse command to define argument validation") | |||
| 25 | + | } | |||
| 26 | + | if err := cmd.Args(cmd, []string{"https://example.com", "extra"}); err == nil { | |||
| 27 | + | t.Fatal("expected browse command to reject extra args") | |||
| 28 | + | } | |||
| 29 | + | } | |||
M internal/cli/cmd/root.go +4 -3
| @@ -34,9 +34,9 @@ Features: | |||
| 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() { | |
| @@ -107,6 +107,7 @@ func GetApp() *cli.App { | |||
| 107 | 107 | var browseCmd = &cobra.Command{ | |
| 108 | 108 | Use: "browse [url]", | |
| 109 | 109 | Short: "Launch the graphical browser", | |
| 110 | + | Args: cobra.MaximumNArgs(1), | |
| 110 | 111 | Long: `Launch the GTK4 graphical browser. | |
| 111 | 112 | ||
| 112 | 113 | If a URL is provided, navigate to it. Otherwise, open the homepage. | |
| @@ -34,9 +34,9 @@ Features: | |||||
| 34 | - Search shortcuts via bangs (!g, !gi, etc.) | 34 | - Search shortcuts via bangs (!g, !gi, etc.) | ||
| 35 | - Session management with auto-save and 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() { | ||
| @@ -107,6 +107,7 @@ func GetApp() *cli.App { | |||||
| 107 | var browseCmd = &cobra.Command{ | 107 | var browseCmd = &cobra.Command{ | ||
| 108 | Use: "browse [url]", | 108 | Use: "browse [url]", | ||
| 109 | Short: "Launch the graphical browser", | 109 | Short: "Launch the graphical browser", | ||
| 110 | + | Args: cobra.MaximumNArgs(1), | |||
| 110 | Long: `Launch the GTK4 graphical browser. | 111 | Long: `Launch the GTK4 graphical browser. | ||
| 111 | 112 | ||||
| 112 | If a URL is provided, navigate to it. Otherwise, open the homepage. | 113 | If a URL is provided, navigate to it. Otherwise, open the homepage. | ||
M internal/domain/url/normalize.go +39 -0
| @@ -57,6 +57,10 @@ func Normalize(input string) string { | |||
| 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 +66 -8
| @@ -392,23 +392,81 @@ 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 | + | logging.FromContext(context.Background()).Warn(). | |
| 436 | + | Err(err). | |
| 437 | + | Str("path", cmd.Path). | |
| 438 | + | Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)") | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | return nil | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func sanitizedChildEnv(env []string) []string { | |
| 446 | + | cleaned := make([]string, 0, len(env)) | |
| 447 | + | for _, entry := range env { | |
| 448 | + | if !strings.HasPrefix(entry, "LD_PRELOAD=") { | |
| 449 | + | cleaned = append(cleaned, entry) | |
| 450 | + | continue | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | value := strings.TrimPrefix(entry, "LD_PRELOAD=") | |
| 454 | + | parts := strings.FieldsFunc(value, func(r rune) bool { | |
| 455 | + | return r == ' ' || r == ':' | |
| 456 | + | }) | |
| 457 | + | kept := make([]string, 0, len(parts)) | |
| 458 | + | for _, part := range parts { | |
| 459 | + | if strings.Contains(part, layerShellPreloadToken) { | |
| 460 | + | continue | |
| 461 | + | } | |
| 462 | + | kept = append(kept, part) | |
| 463 | + | } | |
| 464 | + | if len(kept) > 0 { | |
| 465 | + | cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " ")) | |
| 410 | 466 | } | |
| 411 | 467 | } | |
| 468 | + | ||
| 469 | + | return cleaned | |
| 412 | 470 | } | |
| 413 | 471 | ||
| 414 | 472 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| @@ -424,9 +482,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 424 | 482 | cmd := exec.Command(execPath, "browse") | |
| 425 | 483 | ||
| 426 | 484 | // Inherit environment and add restore session ID | |
| 427 | - | cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID)) | |
| 485 | + | cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID)) | |
| 428 | 486 | ||
| 429 | - | // Detach from current process group so the new process survives | |
| 487 | + | // Detach from current process group so the new process survives. | |
| 430 | 488 | cmd.Stdin = nil | |
| 431 | 489 | cmd.Stdout = nil | |
| 432 | 490 | cmd.Stderr = nil | |
| @@ -435,10 +493,10 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 435 | 493 | return fmt.Errorf("spawn session: %w", err) | |
| 436 | 494 | } | |
| 437 | 495 | ||
| 438 | - | // Capture PID before releasing | |
| 496 | + | // Capture PID before releasing. | |
| 439 | 497 | pid := cmd.Process.Pid | |
| 440 | 498 | ||
| 441 | - | // Release the process so it continues running after we close | |
| 499 | + | // Release the process so it continues running after we close. | |
| 442 | 500 | if err := cmd.Process.Release(); err != nil { | |
| 443 | 501 | log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)") | |
| 444 | 502 | } | |
| @@ -392,23 +392,81 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner { | |||||
| 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | ||
| 393 | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | 393 | const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" | ||
| 394 | 394 | ||||
| 395 | + | const layerShellPreloadToken = "libgtk4" + "-layer-shell" | |||
| 396 | + | ||||
| 395 | // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). | 397 | // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). | ||
| 396 | // Used for external URL schemes like vscode://, spotify://, steam://, etc. | 398 | // Used for external URL schemes like vscode://, spotify://, steam://, etc. | ||
| 397 | func LaunchExternalURL(uri string) { | 399 | func LaunchExternalURL(uri string) { | ||
| 398 | cmd := exec.Command("xdg-open", uri) | 400 | cmd := exec.Command("xdg-open", uri) | ||
| 399 | - | // Detach from current process - we don't care about the 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 | + | logging.FromContext(context.Background()).Warn(). |
| 436 | + | Err(err). | |||
| 437 | + | Str("path", cmd.Path). | |||
| 438 | + | Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)") | |||
| 439 | + | } | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | return nil | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func sanitizedChildEnv(env []string) []string { | |||
| 446 | + | cleaned := make([]string, 0, len(env)) | |||
| 447 | + | for _, entry := range env { | |||
| 448 | + | if !strings.HasPrefix(entry, "LD_PRELOAD=") { | |||
| 449 | + | cleaned = append(cleaned, entry) | |||
| 450 | + | continue | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | value := strings.TrimPrefix(entry, "LD_PRELOAD=") | |||
| 454 | + | parts := strings.FieldsFunc(value, func(r rune) bool { | |||
| 455 | + | return r == ' ' || r == ':' | |||
| 456 | + | }) | |||
| 457 | + | kept := make([]string, 0, len(parts)) | |||
| 458 | + | for _, part := range parts { | |||
| 459 | + | if strings.Contains(part, layerShellPreloadToken) { | |||
| 460 | + | continue | |||
| 461 | + | } | |||
| 462 | + | kept = append(kept, part) | |||
| 463 | + | } | |||
| 464 | + | if len(kept) > 0 { | |||
| 465 | + | cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " ")) | |||
| 410 | } | 466 | } | ||
| 411 | } | 467 | } | ||
| 468 | + | ||||
| 469 | + | return cleaned | |||
| 412 | } | 470 | } | ||
| 413 | 471 | ||||
| 414 | // SpawnWithSession starts a new dumber instance to restore a session. | 472 | // SpawnWithSession starts a new dumber instance to restore a session. | ||
| @@ -424,9 +482,9 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||||
| 424 | cmd := exec.Command(execPath, "browse") | 482 | cmd := exec.Command(execPath, "browse") | ||
| 425 | 483 | ||||
| 426 | // Inherit environment and add restore session ID | 484 | // Inherit environment and add restore session ID | ||
| 427 | - | cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID)) | 485 | + | cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID)) |
| 428 | 486 | ||||
| 429 | - | // Detach from current process group so the new process survives | 487 | + | // Detach from current process group so the new process survives. |
| 430 | cmd.Stdin = nil | 488 | cmd.Stdin = nil | ||
| 431 | cmd.Stdout = nil | 489 | cmd.Stdout = nil | ||
| 432 | cmd.Stderr = nil | 490 | cmd.Stderr = nil | ||
| @@ -435,10 +493,10 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||||
| 435 | return fmt.Errorf("spawn session: %w", err) | 493 | return fmt.Errorf("spawn session: %w", err) | ||
| 436 | } | 494 | } | ||
| 437 | 495 | ||||
| 438 | - | // Capture PID before releasing | 496 | + | // Capture PID before releasing. |
| 439 | pid := cmd.Process.Pid | 497 | pid := cmd.Process.Pid | ||
| 440 | 498 | ||||
| 441 | - | // Release the process so it continues running after we close | 499 | + | // Release the process so it continues running after we close. |
| 442 | if err := cmd.Process.Release(); err != nil { | 500 | if err := cmd.Process.Release(); err != nil { | ||
| 443 | log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)") | 501 | log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)") | ||
| 444 | } | 502 | } | ||
A internal/infrastructure/desktop/adapter_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package desktop | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |
| 6 | + | env := sanitizedChildEnv([]string{ | |
| 7 | + | "PATH=/usr/bin", | |
| 8 | + | "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so", | |
| 9 | + | }) | |
| 10 | + | ||
| 11 | + | if len(env) != 2 { | |
| 12 | + | t.Fatalf("expected two env entries, got %#v", env) | |
| 13 | + | } | |
| 14 | + | if env[0] != "PATH=/usr/bin" { | |
| 15 | + | t.Fatalf("expected PATH to be preserved, got %#v", env) | |
| 16 | + | } | |
| 17 | + | if env[1] != "LD_PRELOAD=/tmp/keep.so" { | |
| 18 | + | t.Fatalf("expected non-layer-shell preload to remain, got %#v", env) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) { | |
| 23 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"}) | |
| 24 | + | if len(env) != 0 { | |
| 25 | + | t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |
| 30 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"}) | |
| 31 | + | if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" { | |
| 32 | + | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package desktop | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |||
| 6 | + | env := sanitizedChildEnv([]string{ | |||
| 7 | + | "PATH=/usr/bin", | |||
| 8 | + | "LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0 /tmp/keep.so", | |||
| 9 | + | }) | |||
| 10 | + | ||||
| 11 | + | if len(env) != 2 { | |||
| 12 | + | t.Fatalf("expected two env entries, got %#v", env) | |||
| 13 | + | } | |||
| 14 | + | if env[0] != "PATH=/usr/bin" { | |||
| 15 | + | t.Fatalf("expected PATH to be preserved, got %#v", env) | |||
| 16 | + | } | |||
| 17 | + | if env[1] != "LD_PRELOAD=/tmp/keep.so" { | |||
| 18 | + | t.Fatalf("expected non-layer-shell preload to remain, got %#v", env) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestSanitizedChildEnv_DropsEmptyLDPreload(t *testing.T) { | |||
| 23 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=/tmp/libgtk4-layer-shell.so.0"}) | |||
| 24 | + | if len(env) != 0 { | |||
| 25 | + | t.Fatalf("expected layer-shell-only preload to be removed, got %#v", env) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |||
| 30 | + | env := sanitizedChildEnv([]string{"LD_PRELOAD=a.so:/tmp/libgtk4-layer-shell.so.0:b.so"}) | |||
| 31 | + | if len(env) != 1 || env[0] != "LD_PRELOAD=a.so b.so" { | |||
| 32 | + | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/ui/app.go +95 -23
| @@ -751,33 +751,101 @@ func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |||
| 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.InDelta(t, defaults.WidthPct, resolved.WidthPct, 0) | |
| 124 | + | assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth) | |
| 125 | + | assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth) | |
| 126 | + | assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight) | |
| 127 | + | assert.Equal(t, 800, resolved.FixedWidth) | |
| 128 | + | assert.True(t, resolved.UseFixedTopMargin) | |
| 129 | + | assert.Equal(t, 0, resolved.FixedTopMargin) | |
| 130 | + | } | |
| 131 | + | ||
| 91 | 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.InDelta(t, defaults.WidthPct, resolved.WidthPct, 0) | |||
| 124 | + | assert.Equal(t, defaults.MaxWidth, resolved.MaxWidth) | |||
| 125 | + | assert.Equal(t, defaults.FallbackWidth, resolved.FallbackWidth) | |||
| 126 | + | assert.Equal(t, defaults.FallbackHeight, resolved.FallbackHeight) | |||
| 127 | + | assert.Equal(t, 800, resolved.FixedWidth) | |||
| 128 | + | assert.True(t, resolved.UseFixedTopMargin) | |||
| 129 | + | assert.Equal(t, 0, resolved.FixedTopMargin) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 91 | func TestScaleValue_DefaultScale(t *testing.T) { | 132 | func TestScaleValue_DefaultScale(t *testing.T) { | ||
| 92 | // When scale is 0 or negative, should default to 1.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 +300 -102
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | 14 | "github.com/bnema/puregotk/v4/graphene" | |
| 15 | 15 | "github.com/bnema/puregotk/v4/gtk" | |
| 16 | + | "github.com/bnema/puregotk/v4/pango" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/dumber/internal/application/usecase" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/autocomplete" | |
| @@ -32,6 +33,11 @@ const ( | |||
| 32 | 33 | minGhostInputLength = 1 | |
| 33 | 34 | ) | |
| 34 | 35 | ||
| 36 | + | type favoriteRowIndicatorUpdate struct { | |
| 37 | + | Apply bool | |
| 38 | + | ShowStarSlot bool | |
| 39 | + | } | |
| 40 | + | ||
| 35 | 41 | // ViewMode distinguishes history search from favorites display. | |
| 36 | 42 | type ViewMode string | |
| 37 | 43 | ||
| @@ -124,6 +130,7 @@ type Omnibox struct { | |||
| 124 | 130 | ||
| 125 | 131 | // Scaling | |
| 126 | 132 | uiScale float64 | |
| 133 | + | sizeCfg ModalSizeConfig | |
| 127 | 134 | ||
| 128 | 135 | // Cached measurements (populated after first layout) | |
| 129 | 136 | measuredHeights struct { | |
| @@ -158,6 +165,7 @@ type OmniboxConfig struct { | |||
| 158 | 165 | OnFocusOut func() // Callback when entry loses focus | |
| 159 | 166 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 160 | 167 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 168 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |
| 161 | 169 | } | |
| 162 | 170 | ||
| 163 | 171 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -170,6 +178,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 170 | 178 | uiScale = 1.0 | |
| 171 | 179 | } | |
| 172 | 180 | ||
| 181 | + | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |
| 182 | + | ||
| 173 | 183 | o := &Omnibox{ | |
| 174 | 184 | viewMode: ViewModeHistory, | |
| 175 | 185 | selectedIndex: -1, | |
| @@ -185,6 +195,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 185 | 195 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 186 | 196 | ctx: ctx, | |
| 187 | 197 | uiScale: uiScale, | |
| 198 | + | sizeCfg: sizeCfg, | |
| 188 | 199 | } | |
| 189 | 200 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 190 | 201 | var cb glib.SourceFunc = func(uintptr) bool { | |
| @@ -317,13 +328,40 @@ func (o *Omnibox) estimateRowHeight() int { | |||
| 317 | 328 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | |
| 318 | 329 | } | |
| 319 | 330 | ||
| 331 | + | func resultsContainerState(rowCount int) (visible, expand, listVisible bool) { | |
| 332 | + | return rowCount > 0, rowCount > 0, rowCount > 0 | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func shouldApplyEmptyResultsState(rowCount int, visible bool) bool { | |
| 336 | + | return !visible || rowCount <= 0 | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func (o *Omnibox) setResultsContainerState(rowCount int) { | |
| 340 | + | if o.scrolledWin == nil { | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | visible, expand, listVisible := resultsContainerState(rowCount) | |
| 344 | + | o.scrolledWin.SetVisible(visible) | |
| 345 | + | o.scrolledWin.SetVexpand(expand) | |
| 346 | + | if o.listBox != nil { | |
| 347 | + | o.listBox.SetVisible(listVisible) | |
| 348 | + | } | |
| 349 | + | if o.mainBox != nil { | |
| 350 | + | if shouldApplyEmptyResultsState(rowCount, visible) { | |
| 351 | + | o.mainBox.AddCssClass("omnibox-empty") | |
| 352 | + | } else { | |
| 353 | + | o.mainBox.RemoveCssClass("omnibox-empty") | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 320 | 358 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | |
| 321 | 359 | // Must be called on the GTK main thread. | |
| 322 | 360 | func (o *Omnibox) effectiveMaxRows() int { | |
| 323 | 361 | if o.parentOverlay == nil { | |
| 324 | 362 | return OmniboxListDefaults.MaxVisibleRows | |
| 325 | 363 | } | |
| 326 | - | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | |
| 364 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) | |
| 327 | 365 | } | |
| 328 | 366 | ||
| 329 | 367 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | |
| @@ -340,7 +378,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 340 | 378 | ||
| 341 | 379 | // Schedule measurement after GTK has laid out widgets | |
| 342 | 380 | var cb glib.SourceFunc = func(uintptr) bool { | |
| 343 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 381 | + | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 344 | 382 | o.measureAndResize(width, rowCount) | |
| 345 | 383 | return false | |
| 346 | 384 | } | |
| @@ -387,6 +425,15 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 387 | 425 | ||
| 388 | 426 | // Force layout recalculation | |
| 389 | 427 | o.outerBox.QueueResize() | |
| 428 | + | if parent := o.parentOverlay; parent != nil { | |
| 429 | + | if widget := parent.GtkWidget(); widget != nil { | |
| 430 | + | widget.QueueResize() | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | if o.scrolledWin != nil { | |
| 434 | + | o.scrolledWin.QueueResize() | |
| 435 | + | } | |
| 436 | + | o.mainBox.QueueResize() | |
| 390 | 437 | ||
| 391 | 438 | log.Debug(). | |
| 392 | 439 | Int("width", width). | |
| @@ -544,7 +591,7 @@ func (o *Omnibox) initList() error { | |||
| 544 | 591 | return errNilWidget("scrolledWin") | |
| 545 | 592 | } | |
| 546 | 593 | o.scrolledWin.AddCssClass("omnibox-scrolled") | |
| 547 | - | o.scrolledWin.SetVexpand(true) | |
| 594 | + | o.scrolledWin.SetVexpand(false) | |
| 548 | 595 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | |
| 549 | 596 | o.scrolledWin.SetPropagateNaturalHeight(true) | |
| 550 | 597 | ||
| @@ -555,19 +602,16 @@ func (o *Omnibox) initList() error { | |||
| 555 | 602 | o.listBox.AddCssClass("omnibox-listbox") | |
| 556 | 603 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 557 | 604 | ||
| 558 | - | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 605 | + | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 559 | 606 | o.restoreEntryToRealInput() | |
| 560 | - | if rowPtr == 0 { | |
| 607 | + | if row == nil { | |
| 561 | 608 | o.mu.Lock() | |
| 562 | 609 | o.selectedIndex = -1 | |
| 563 | 610 | o.mu.Unlock() | |
| 564 | 611 | } else { | |
| 565 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 566 | - | if row != nil { | |
| 567 | - | o.mu.Lock() | |
| 568 | - | o.selectedIndex = row.GetIndex() | |
| 569 | - | o.mu.Unlock() | |
| 570 | - | } | |
| 612 | + | o.mu.Lock() | |
| 613 | + | o.selectedIndex = row.GetIndex() | |
| 614 | + | o.mu.Unlock() | |
| 571 | 615 | } | |
| 572 | 616 | o.updateGhostFromSelection() | |
| 573 | 617 | } | |
| @@ -575,11 +619,7 @@ func (o *Omnibox) initList() error { | |||
| 575 | 619 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 576 | 620 | ||
| 577 | 621 | // Handle row activation (click or Enter) - navigate directly to the URL | |
| 578 | - | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 579 | - | if rowPtr == 0 { | |
| 580 | - | return | |
| 581 | - | } | |
| 582 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 622 | + | rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 583 | 623 | if row == nil { | |
| 584 | 624 | return | |
| 585 | 625 | } | |
| @@ -596,7 +636,7 @@ func (o *Omnibox) initList() error { | |||
| 596 | 636 | return | |
| 597 | 637 | } | |
| 598 | 638 | ||
| 599 | - | targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites) | |
| 639 | + | targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites) | |
| 600 | 640 | ||
| 601 | 641 | if targetURL != "" && o.onNavigate != nil { | |
| 602 | 642 | o.Hide(o.ctx) | |
| @@ -1101,6 +1141,7 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||
| 1101 | 1141 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | |
| 1102 | 1142 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |
| 1103 | 1143 | log := logging.FromContext(o.ctx) | |
| 1144 | + | maxVisible := o.effectiveMaxRows() | |
| 1104 | 1145 | o.mu.RLock() | |
| 1105 | 1146 | idx := o.selectedIndex | |
| 1106 | 1147 | mode := o.viewMode | |
| @@ -1128,10 +1169,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1128 | 1169 | return | |
| 1129 | 1170 | } | |
| 1130 | 1171 | ||
| 1131 | - | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites) | |
| 1172 | + | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites) | |
| 1132 | 1173 | ||
| 1133 | 1174 | if entryText != "" { | |
| 1134 | - | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1175 | + | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites) | |
| 1135 | 1176 | return | |
| 1136 | 1177 | } | |
| 1137 | 1178 | ||
| @@ -1153,6 +1194,7 @@ func (o *Omnibox) resolveGhostCompletion( | |||
| 1153 | 1194 | entryText, selectedURL string, | |
| 1154 | 1195 | hasExplicitSelection bool, | |
| 1155 | 1196 | mode ViewMode, | |
| 1197 | + | maxVisible int, | |
| 1156 | 1198 | suggestions []Suggestion, | |
| 1157 | 1199 | favorites []Favorite, | |
| 1158 | 1200 | ) { | |
| @@ -1163,7 +1205,15 @@ func (o *Omnibox) resolveGhostCompletion( | |||
| 1163 | 1205 | return | |
| 1164 | 1206 | } | |
| 1165 | 1207 | ||
| 1166 | - | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1208 | + | fullText, suffix, found := visibleGhostSuggestion( | |
| 1209 | + | completionInput, | |
| 1210 | + | selectedURL, | |
| 1211 | + | hasExplicitSelection, | |
| 1212 | + | mode, | |
| 1213 | + | maxVisible, | |
| 1214 | + | suggestions, | |
| 1215 | + | favorites, | |
| 1216 | + | ) | |
| 1167 | 1217 | if !found { | |
| 1168 | 1218 | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") | |
| 1169 | 1219 | return | |
| @@ -1235,6 +1285,7 @@ func visibleGhostSuggestion( | |||
| 1235 | 1285 | query, selectedURL string, | |
| 1236 | 1286 | hasExplicitSelection bool, | |
| 1237 | 1287 | mode ViewMode, | |
| 1288 | + | maxVisible int, | |
| 1238 | 1289 | suggestions []Suggestion, | |
| 1239 | 1290 | favorites []Favorite, | |
| 1240 | 1291 | ) (fullText, suffix string, ok bool) { | |
| @@ -1243,15 +1294,16 @@ func visibleGhostSuggestion( | |||
| 1243 | 1294 | return fullText, suffix, ok | |
| 1244 | 1295 | } | |
| 1245 | 1296 | ||
| 1246 | - | visibleURLs := visibleURLsForMode(mode, suggestions, favorites) | |
| 1297 | + | visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites) | |
| 1247 | 1298 | suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs) | |
| 1248 | 1299 | return fullText, suffix, ok | |
| 1249 | 1300 | } | |
| 1250 | 1301 | ||
| 1251 | - | func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string { | |
| 1302 | + | func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string { | |
| 1252 | 1303 | if mode == ViewModeHistory { | |
| 1253 | - | urls := make([]string, 0, len(suggestions)) | |
| 1254 | - | for _, s := range suggestions { | |
| 1304 | + | visibleCount := visibleResultCount(len(suggestions), maxVisible) | |
| 1305 | + | urls := make([]string, 0, visibleCount) | |
| 1306 | + | for _, s := range suggestions[:visibleCount] { | |
| 1255 | 1307 | if s.URL != "" { | |
| 1256 | 1308 | urls = append(urls, s.URL) | |
| 1257 | 1309 | } | |
| @@ -1259,8 +1311,9 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav | |||
| 1259 | 1311 | return urls | |
| 1260 | 1312 | } | |
| 1261 | 1313 | ||
| 1262 | - | urls := make([]string, 0, len(favorites)) | |
| 1263 | - | for _, f := range favorites { | |
| 1314 | + | visibleCount := visibleResultCount(len(favorites), maxVisible) | |
| 1315 | + | urls := make([]string, 0, visibleCount) | |
| 1316 | + | for _, f := range favorites[:visibleCount] { | |
| 1264 | 1317 | if f.URL != "" { | |
| 1265 | 1318 | urls = append(urls, f.URL) | |
| 1266 | 1319 | } | |
| @@ -1268,11 +1321,21 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav | |||
| 1268 | 1321 | return urls | |
| 1269 | 1322 | } | |
| 1270 | 1323 | ||
| 1271 | - | func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) { | |
| 1324 | + | func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) { | |
| 1272 | 1325 | if idx < 0 { | |
| 1273 | 1326 | return "", false | |
| 1274 | 1327 | } | |
| 1275 | - | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | |
| 1328 | + | return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true | |
| 1329 | + | } | |
| 1330 | + | ||
| 1331 | + | func visibleResultCount(total, maxVisible int) int { | |
| 1332 | + | if total <= 0 { | |
| 1333 | + | return 0 | |
| 1334 | + | } | |
| 1335 | + | if maxVisible <= 0 || total < maxVisible { | |
| 1336 | + | return total | |
| 1337 | + | } | |
| 1338 | + | return maxVisible | |
| 1276 | 1339 | } | |
| 1277 | 1340 | ||
| 1278 | 1341 | // performSearch executes the search based on current view mode and query. | |
| @@ -1598,9 +1661,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||
| 1598 | 1661 | o.rebuildList() | |
| 1599 | 1662 | ||
| 1600 | 1663 | rowCount := len(suggestions) | |
| 1601 | - | if o.scrolledWin != nil { | |
| 1602 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1603 | - | } | |
| 1664 | + | o.setResultsContainerState(rowCount) | |
| 1604 | 1665 | o.resizeAndCenter(rowCount) | |
| 1605 | 1666 | } | |
| 1606 | 1667 | ||
| @@ -1618,9 +1679,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||
| 1618 | 1679 | ||
| 1619 | 1680 | // Hide scrolled window when there are no suggestions | |
| 1620 | 1681 | rowCount := len(suggestions) | |
| 1621 | - | if o.scrolledWin != nil { | |
| 1622 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1623 | - | } | |
| 1682 | + | o.setResultsContainerState(rowCount) | |
| 1624 | 1683 | o.resizeAndCenter(rowCount) | |
| 1625 | 1684 | ||
| 1626 | 1685 | if rowCount > 0 { | |
| @@ -1644,9 +1703,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||
| 1644 | 1703 | ||
| 1645 | 1704 | // Hide scrolled window when there are no favorites | |
| 1646 | 1705 | rowCount := len(favorites) | |
| 1647 | - | if o.scrolledWin != nil { | |
| 1648 | - | o.scrolledWin.SetVisible(rowCount > 0) | |
| 1649 | - | } | |
| 1706 | + | o.setResultsContainerState(rowCount) | |
| 1650 | 1707 | o.resizeAndCenter(rowCount) | |
| 1651 | 1708 | ||
| 1652 | 1709 | if rowCount > 0 { | |
| @@ -1702,7 +1759,7 @@ func (o *Omnibox) rebuildList() { | |||
| 1702 | 1759 | if width <= 0 { | |
| 1703 | 1760 | return false // Overlay not allocated yet, skip | |
| 1704 | 1761 | } | |
| 1705 | - | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | |
| 1762 | + | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 1706 | 1763 | if o.measureComponentHeights(forWidth) { | |
| 1707 | 1764 | // Re-trigger resize with accurate measurements | |
| 1708 | 1765 | o.mu.RLock() | |
| @@ -1749,86 +1806,166 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||
| 1749 | 1806 | return favicon | |
| 1750 | 1807 | } | |
| 1751 | 1808 | ||
| 1752 | - | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |
| 1753 | - | // Uses rawURL for both favicon fetching and display. | |
| 1754 | - | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1755 | - | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | |
| 1809 | + | const favoriteStarBaseSize = 18 | |
| 1810 | + | const favoriteStarSlotClass = "omnibox-favorite-star-slot" | |
| 1811 | + | ||
| 1812 | + | func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) } | |
| 1813 | + | ||
| 1814 | + | func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite } | |
| 1815 | + | ||
| 1816 | + | func clearBoxChildren(box *gtk.Box) { | |
| 1817 | + | if box == nil { | |
| 1818 | + | return | |
| 1819 | + | } | |
| 1820 | + | for child := box.GetFirstChild(); child != nil; { | |
| 1821 | + | next := child.GetNextSibling() | |
| 1822 | + | box.Remove(child) | |
| 1823 | + | child = next | |
| 1824 | + | } | |
| 1756 | 1825 | } | |
| 1757 | 1826 | ||
| 1758 | - | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |
| 1759 | - | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |
| 1760 | - | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1761 | - | row := gtk.NewListBoxRow() | |
| 1827 | + | func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) { | |
| 1828 | + | if slot == nil { | |
| 1829 | + | return | |
| 1830 | + | } | |
| 1831 | + | clearBoxChildren(slot) | |
| 1832 | + | if showFavoriteStar { | |
| 1833 | + | if star := o.createFavoriteStarIcon(); star != nil { | |
| 1834 | + | slot.Append(&star.Widget) | |
| 1835 | + | } | |
| 1836 | + | } | |
| 1837 | + | slot.SetVisible(showFavoriteStar) | |
| 1838 | + | } | |
| 1839 | + | ||
| 1840 | + | func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box { | |
| 1762 | 1841 | if row == nil { | |
| 1763 | 1842 | return nil | |
| 1764 | 1843 | } | |
| 1765 | - | row.AddCssClass("omnibox-row") | |
| 1766 | - | o.attachRowHoverSelection(row, index) | |
| 1767 | - | ||
| 1768 | - | const rowSpacing = 8 | |
| 1769 | - | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |
| 1844 | + | hbox := row.GetChild() | |
| 1770 | 1845 | if hbox == nil { | |
| 1771 | 1846 | return nil | |
| 1772 | 1847 | } | |
| 1773 | - | hbox.SetHexpand(true) | |
| 1774 | - | ||
| 1775 | - | // Favicon image (vertically centered) | |
| 1776 | - | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | |
| 1777 | - | favicon.SetValign(gtk.AlignCenterValue) | |
| 1778 | - | hbox.Append(&favicon.Widget) | |
| 1848 | + | for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() { | |
| 1849 | + | if child.HasCssClass(favoriteStarSlotClass) { | |
| 1850 | + | return gtk.BoxNewFromInternalPtr(child.GoPointer()) | |
| 1851 | + | } | |
| 1779 | 1852 | } | |
| 1853 | + | return nil | |
| 1854 | + | } | |
| 1780 | 1855 | ||
| 1781 | - | // Vertical box for title + URL | |
| 1782 | - | textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | |
| 1783 | - | if textBox == nil { | |
| 1856 | + | func (o *Omnibox) createFavoriteStarIcon() *gtk.Image { | |
| 1857 | + | star := gtk.NewImage() | |
| 1858 | + | if star == nil { | |
| 1784 | 1859 | return nil | |
| 1785 | 1860 | } | |
| 1786 | - | textBox.SetHexpand(true) | |
| 1787 | - | textBox.SetValign(gtk.AlignCenterValue) | |
| 1861 | + | iconName := "starred-symbolic" | |
| 1862 | + | star.SetFromIconName(&iconName) | |
| 1863 | + | star.SetPixelSize(favoriteStarSize(o.uiScale)) | |
| 1864 | + | star.AddCssClass("omnibox-favorite-star") | |
| 1865 | + | star.SetValign(gtk.AlignCenterValue) | |
| 1866 | + | return star | |
| 1867 | + | } | |
| 1788 | 1868 | ||
| 1789 | - | // Title label (or URL if no title) | |
| 1869 | + | func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) { | |
| 1790 | 1870 | displayTitle := title | |
| 1791 | 1871 | if displayTitle == "" { | |
| 1792 | 1872 | displayTitle = displayURL | |
| 1793 | 1873 | } | |
| 1874 | + | ||
| 1794 | 1875 | titleLabel := gtk.NewLabel(nil) | |
| 1795 | 1876 | if titleLabel != nil { | |
| 1796 | 1877 | titleLabel.SetText(displayTitle) | |
| 1797 | 1878 | titleLabel.AddCssClass("omnibox-suggestion-title") | |
| 1798 | 1879 | titleLabel.SetHalign(gtk.AlignStartValue) | |
| 1799 | - | titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| 1880 | + | titleLabel.SetEllipsize(pango.EllipsizeEndValue) | |
| 1800 | 1881 | textBox.Append(&titleLabel.Widget) | |
| 1801 | 1882 | } | |
| 1802 | 1883 | ||
| 1803 | - | // URL label (only if title exists and differs from URL) | |
| 1804 | 1884 | if title != "" && title != displayURL { | |
| 1805 | 1885 | urlLabel := gtk.NewLabel(nil) | |
| 1806 | 1886 | if urlLabel != nil { | |
| 1807 | 1887 | urlLabel.SetText(displayURL) | |
| 1808 | 1888 | urlLabel.AddCssClass("omnibox-suggestion-url") | |
| 1809 | 1889 | urlLabel.SetHalign(gtk.AlignStartValue) | |
| 1810 | - | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | |
| 1890 | + | urlLabel.SetEllipsize(pango.EllipsizeEndValue) | |
| 1811 | 1891 | textBox.Append(&urlLabel.Widget) | |
| 1812 | 1892 | } | |
| 1813 | 1893 | } | |
| 1894 | + | } | |
| 1814 | 1895 | ||
| 1815 | - | hbox.Append(&textBox.Widget) | |
| 1896 | + | func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) { | |
| 1897 | + | starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |
| 1898 | + | if starSlot != nil { | |
| 1899 | + | starSlot.AddCssClass(favoriteStarSlotClass) | |
| 1900 | + | starSlot.SetValign(gtk.AlignCenterValue) | |
| 1901 | + | o.syncFavoriteStarSlot(starSlot, showFavoriteStar) | |
| 1902 | + | hbox.Append(&starSlot.Widget) | |
| 1903 | + | } | |
| 1816 | 1904 | ||
| 1817 | - | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | |
| 1818 | 1905 | const maxShortcutIndex = 9 | |
| 1819 | - | if index <= maxShortcutIndex { | |
| 1820 | - | shortcutLabel := gtk.NewLabel(nil) | |
| 1821 | - | if shortcutLabel != nil { | |
| 1822 | - | if index < maxShortcutIndex { | |
| 1823 | - | shortcutLabel.SetText(formatShortcut(index + 1)) | |
| 1824 | - | } else { | |
| 1825 | - | shortcutLabel.SetText("Ctrl+0") | |
| 1826 | - | } | |
| 1827 | - | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |
| 1828 | - | shortcutLabel.SetValign(gtk.AlignCenterValue) | |
| 1829 | - | hbox.Append(&shortcutLabel.Widget) | |
| 1830 | - | } | |
| 1906 | + | if index > maxShortcutIndex { | |
| 1907 | + | return | |
| 1908 | + | } | |
| 1909 | + | ||
| 1910 | + | shortcutLabel := gtk.NewLabel(nil) | |
| 1911 | + | if shortcutLabel == nil { | |
| 1912 | + | return | |
| 1913 | + | } | |
| 1914 | + | if index < maxShortcutIndex { | |
| 1915 | + | shortcutLabel.SetText(formatShortcut(index + 1)) | |
| 1916 | + | } else { | |
| 1917 | + | shortcutLabel.SetText("Ctrl+0") | |
| 1918 | + | } | |
| 1919 | + | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |
| 1920 | + | shortcutLabel.SetValign(gtk.AlignCenterValue) | |
| 1921 | + | hbox.Append(&shortcutLabel.Widget) | |
| 1922 | + | } | |
| 1923 | + | ||
| 1924 | + | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |
| 1925 | + | // Uses rawURL for both favicon fetching and display. | |
| 1926 | + | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | |
| 1927 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index) | |
| 1928 | + | } | |
| 1929 | + | ||
| 1930 | + | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |
| 1931 | + | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |
| 1932 | + | func (o *Omnibox) createRowWithFaviconURL( | |
| 1933 | + | displayURL, title, faviconURL, fallbackIcon string, | |
| 1934 | + | showFavoriteStar bool, | |
| 1935 | + | index int, | |
| 1936 | + | ) *gtk.ListBoxRow { | |
| 1937 | + | row := gtk.NewListBoxRow() | |
| 1938 | + | if row == nil { | |
| 1939 | + | return nil | |
| 1831 | 1940 | } | |
| 1941 | + | row.AddCssClass("omnibox-row") | |
| 1942 | + | o.attachRowHoverSelection(row, index) | |
| 1943 | + | ||
| 1944 | + | const rowSpacing = 8 | |
| 1945 | + | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |
| 1946 | + | if hbox == nil { | |
| 1947 | + | return nil | |
| 1948 | + | } | |
| 1949 | + | hbox.SetHexpand(true) | |
| 1950 | + | ||
| 1951 | + | // Favicon image (vertically centered) | |
| 1952 | + | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | |
| 1953 | + | favicon.SetValign(gtk.AlignCenterValue) | |
| 1954 | + | hbox.Append(&favicon.Widget) | |
| 1955 | + | } | |
| 1956 | + | ||
| 1957 | + | // Vertical box for title + URL | |
| 1958 | + | textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | |
| 1959 | + | if textBox == nil { | |
| 1960 | + | return nil | |
| 1961 | + | } | |
| 1962 | + | textBox.SetHexpand(true) | |
| 1963 | + | textBox.SetValign(gtk.AlignCenterValue) | |
| 1964 | + | ||
| 1965 | + | o.appendSuggestionTitleAndURL(textBox, title, displayURL) | |
| 1966 | + | ||
| 1967 | + | hbox.Append(&textBox.Widget) | |
| 1968 | + | o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index) | |
| 1832 | 1969 | ||
| 1833 | 1970 | row.SetChild(&hbox.Widget) | |
| 1834 | 1971 | return row | |
| @@ -1863,7 +2000,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo | |||
| 1863 | 2000 | ||
| 1864 | 2001 | // createSuggestionRow creates a ListBoxRow for a suggestion. | |
| 1865 | 2002 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | |
| 1866 | - | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | |
| 2003 | + | row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index) | |
| 1867 | 2004 | if row != nil && s.IsFavorite { | |
| 1868 | 2005 | row.AddCssClass("omnibox-row-favorite") | |
| 1869 | 2006 | } | |
| @@ -1878,7 +2015,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||
| 1878 | 2015 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | |
| 1879 | 2016 | // Pass description as URL param (displayed as secondary label) and empty | |
| 1880 | 2017 | // faviconURL to skip async favicon fetching - bang rows use static icon only | |
| 1881 | - | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | |
| 2018 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index) | |
| 1882 | 2019 | if row != nil { | |
| 1883 | 2020 | row.AddCssClass("omnibox-row-bang") | |
| 1884 | 2021 | } | |
| @@ -1908,13 +2045,14 @@ func (o *Omnibox) selectNext() { | |||
| 1908 | 2045 | current := o.selectedIndex | |
| 1909 | 2046 | mode := o.viewMode | |
| 1910 | 2047 | bangMode := o.bangMode | |
| 2048 | + | maxVisible := o.effectiveMaxRows() | |
| 1911 | 2049 | var maxIndex int | |
| 1912 | 2050 | if bangMode { | |
| 1913 | - | maxIndex = len(o.bangSuggestions) - 1 | |
| 2051 | + | maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1 | |
| 1914 | 2052 | } else if mode == ViewModeHistory { | |
| 1915 | - | maxIndex = len(o.suggestions) - 1 | |
| 2053 | + | maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1 | |
| 1916 | 2054 | } else { | |
| 1917 | - | maxIndex = len(o.favorites) - 1 | |
| 2055 | + | maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1 | |
| 1918 | 2056 | } | |
| 1919 | 2057 | o.hasNavigated = true // User is navigating with arrow keys | |
| 1920 | 2058 | o.mu.Unlock() | |
| @@ -1936,13 +2074,14 @@ func (o *Omnibox) selectPrevious() { | |||
| 1936 | 2074 | current := o.selectedIndex | |
| 1937 | 2075 | mode := o.viewMode | |
| 1938 | 2076 | bangMode := o.bangMode | |
| 2077 | + | maxVisible := o.effectiveMaxRows() | |
| 1939 | 2078 | var maxIndex int | |
| 1940 | 2079 | if bangMode { | |
| 1941 | - | maxIndex = len(o.bangSuggestions) - 1 | |
| 2080 | + | maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1 | |
| 1942 | 2081 | } else if mode == ViewModeHistory { | |
| 1943 | - | maxIndex = len(o.suggestions) - 1 | |
| 2082 | + | maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1 | |
| 1944 | 2083 | } else { | |
| 1945 | - | maxIndex = len(o.favorites) - 1 | |
| 2084 | + | maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1 | |
| 1946 | 2085 | } | |
| 1947 | 2086 | o.hasNavigated = true // User is navigating with arrow keys | |
| 1948 | 2087 | o.mu.Unlock() | |
| @@ -1968,13 +2107,14 @@ func (o *Omnibox) selectAndNavigate(index int) { | |||
| 1968 | 2107 | suggestions := o.suggestions | |
| 1969 | 2108 | favorites := o.favorites | |
| 1970 | 2109 | o.mu.RUnlock() | |
| 2110 | + | maxVisible := o.effectiveMaxRows() | |
| 1971 | 2111 | ||
| 1972 | 2112 | if bangMode { | |
| 1973 | 2113 | o.navigateToSelected() | |
| 1974 | 2114 | return | |
| 1975 | 2115 | } | |
| 1976 | 2116 | ||
| 1977 | - | targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites) | |
| 2117 | + | targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites) | |
| 1978 | 2118 | if targetURL == "" { | |
| 1979 | 2119 | return | |
| 1980 | 2120 | } | |
| @@ -2028,7 +2168,7 @@ func (o *Omnibox) navigateToSelected() { | |||
| 2028 | 2168 | var targetURL string | |
| 2029 | 2169 | if idx >= 0 { | |
| 2030 | 2170 | // If user has selected a result, navigate to that result. | |
| 2031 | - | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | |
| 2171 | + | targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites) | |
| 2032 | 2172 | } else { | |
| 2033 | 2173 | targetURL = o.buildURL(entryText) | |
| 2034 | 2174 | } | |
| @@ -2044,14 +2184,16 @@ func (o *Omnibox) navigateToSelected() { | |||
| 2044 | 2184 | } | |
| 2045 | 2185 | } | |
| 2046 | 2186 | ||
| 2047 | - | func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string { | |
| 2187 | + | func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string { | |
| 2048 | 2188 | if mode == ViewModeHistory { | |
| 2049 | - | if idx >= 0 && idx < len(suggestions) { | |
| 2189 | + | visibleCount := visibleResultCount(len(suggestions), maxVisible) | |
| 2190 | + | if idx >= 0 && idx < visibleCount { | |
| 2050 | 2191 | return suggestions[idx].URL | |
| 2051 | 2192 | } | |
| 2052 | 2193 | return "" | |
| 2053 | 2194 | } | |
| 2054 | - | if idx >= 0 && idx < len(favorites) { | |
| 2195 | + | visibleCount := visibleResultCount(len(favorites), maxVisible) | |
| 2196 | + | if idx >= 0 && idx < visibleCount { | |
| 2055 | 2197 | return favorites[idx].URL | |
| 2056 | 2198 | } | |
| 2057 | 2199 | return "" | |
| @@ -2126,7 +2268,7 @@ func (o *Omnibox) toggleFavorite() { | |||
| 2126 | 2268 | ||
| 2127 | 2269 | // Update row CSS and show toast on GTK main thread | |
| 2128 | 2270 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 2129 | - | o.updateRowFavoriteIndicator(idx, result.Added) | |
| 2271 | + | o.updateRowFavoriteIndicator(idx, s.URL, result.Added) | |
| 2130 | 2272 | if o.onToast != nil { | |
| 2131 | 2273 | o.onToast(ctx, result.Message, ToastSuccess) | |
| 2132 | 2274 | } | |
| @@ -2188,16 +2330,59 @@ func (o *Omnibox) toggleFavorite() { | |||
| 2188 | 2330 | ||
| 2189 | 2331 | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | |
| 2190 | 2332 | // Must be called from GTK main thread (via glib.IdleAdd). | |
| 2191 | - | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | |
| 2333 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) { | |
| 2192 | 2334 | row := o.listBox.GetRowAtIndex(index) | |
| 2193 | 2335 | if row == nil { | |
| 2194 | 2336 | return | |
| 2195 | 2337 | } | |
| 2338 | + | ||
| 2339 | + | o.mu.RLock() | |
| 2340 | + | mode := o.viewMode | |
| 2341 | + | suggestions := append([]Suggestion(nil), o.suggestions...) | |
| 2342 | + | o.mu.RUnlock() | |
| 2343 | + | ||
| 2344 | + | update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite) | |
| 2345 | + | if !update.Apply { | |
| 2346 | + | return | |
| 2347 | + | } | |
| 2348 | + | ||
| 2196 | 2349 | if isFavorite { | |
| 2197 | 2350 | row.AddCssClass("omnibox-row-favorite") | |
| 2198 | 2351 | } else { | |
| 2199 | 2352 | row.RemoveCssClass("omnibox-row-favorite") | |
| 2200 | 2353 | } | |
| 2354 | + | if slot := favoriteStarSlotForRow(row); slot != nil { | |
| 2355 | + | o.syncFavoriteStarSlot(slot, isFavorite) | |
| 2356 | + | slot.QueueDraw() | |
| 2357 | + | if update.ShowStarSlot { | |
| 2358 | + | slot.Show() | |
| 2359 | + | } | |
| 2360 | + | } | |
| 2361 | + | if child := row.GetChild(); child != nil { | |
| 2362 | + | child.QueueDraw() | |
| 2363 | + | child.Show() | |
| 2364 | + | } | |
| 2365 | + | row.QueueDraw() | |
| 2366 | + | row.Show() | |
| 2367 | + | } | |
| 2368 | + | ||
| 2369 | + | func resolveFavoriteRowIndicatorUpdate( | |
| 2370 | + | mode ViewMode, | |
| 2371 | + | index int, | |
| 2372 | + | expectedURL string, | |
| 2373 | + | suggestions []Suggestion, | |
| 2374 | + | isFavorite bool, | |
| 2375 | + | ) favoriteRowIndicatorUpdate { | |
| 2376 | + | if mode != ViewModeHistory { | |
| 2377 | + | return favoriteRowIndicatorUpdate{} | |
| 2378 | + | } | |
| 2379 | + | if index < 0 || index >= len(suggestions) { | |
| 2380 | + | return favoriteRowIndicatorUpdate{} | |
| 2381 | + | } | |
| 2382 | + | if suggestions[index].URL != expectedURL { | |
| 2383 | + | return favoriteRowIndicatorUpdate{} | |
| 2384 | + | } | |
| 2385 | + | return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite} | |
| 2201 | 2386 | } | |
| 2202 | 2387 | ||
| 2203 | 2388 | // yankSelectedURL copies the URL of the selected item to clipboard. | |
| @@ -2316,20 +2501,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2316 | 2501 | o.entry.SetText(query) | |
| 2317 | 2502 | o.entry.SelectRegion(-1, -1) | |
| 2318 | 2503 | ||
| 2319 | - | // Determine if we expect content initially | |
| 2320 | - | // No content expected if: no query AND initialBehavior is "none" | |
| 2321 | - | expectContent := query != "" || o.initialBehavior != "none" | |
| 2322 | - | ||
| 2323 | - | // Hide scrolled window if no content expected | |
| 2504 | + | // Keep the results container hidden until real rows arrive. | |
| 2505 | + | // Pre-showing an empty, expanding scroller renders a dead area in standalone mode. | |
| 2324 | 2506 | if o.scrolledWin != nil { | |
| 2325 | - | o.scrolledWin.SetVisible(expectContent) | |
| 2507 | + | o.setResultsContainerState(0) | |
| 2326 | 2508 | // Reset content height constraints - will be updated when results arrive | |
| 2327 | 2509 | o.scrolledWin.SetMinContentHeight(-1) | |
| 2328 | 2510 | o.scrolledWin.SetMaxContentHeight(0) | |
| 2329 | 2511 | } | |
| 2330 | 2512 | ||
| 2331 | 2513 | // Calculate dimensions using shared helper | |
| 2332 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | |
| 2514 | + | parentWidth, parentHeight := 0, 0 | |
| 2515 | + | if o.parentOverlay != nil { | |
| 2516 | + | parentWidth = o.parentOverlay.GetAllocatedWidth() | |
| 2517 | + | parentHeight = o.parentOverlay.GetAllocatedHeight() | |
| 2518 | + | } | |
| 2519 | + | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |
| 2520 | + | log.Debug(). | |
| 2521 | + | Int("parentWidth", parentWidth). | |
| 2522 | + | Int("parentHeight", parentHeight). | |
| 2523 | + | Int("width", width). | |
| 2524 | + | Int("marginTop", marginTop). | |
| 2525 | + | Int("fixedWidth", o.sizeCfg.FixedWidth). | |
| 2526 | + | Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin). | |
| 2527 | + | Int("fixedTopMargin", o.sizeCfg.FixedTopMargin). | |
| 2528 | + | Float64("widthPct", o.sizeCfg.WidthPct). | |
| 2529 | + | Float64("topMarginPct", o.sizeCfg.TopMarginPct). | |
| 2530 | + | Msg("omnibox geometry decision") | |
| 2333 | 2531 | ||
| 2334 | 2532 | o.mainBox.SetSizeRequest(width, -1) | |
| 2335 | 2533 | o.outerBox.SetMarginTop(marginTop) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/puregotk/v4/glib" | 13 | "github.com/bnema/puregotk/v4/glib" | ||
| 14 | "github.com/bnema/puregotk/v4/graphene" | 14 | "github.com/bnema/puregotk/v4/graphene" | ||
| 15 | "github.com/bnema/puregotk/v4/gtk" | 15 | "github.com/bnema/puregotk/v4/gtk" | ||
| 16 | + | "github.com/bnema/puregotk/v4/pango" | |||
| 16 | 17 | ||||
| 17 | "github.com/bnema/dumber/internal/application/usecase" | 18 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 18 | "github.com/bnema/dumber/internal/domain/autocomplete" | 19 | "github.com/bnema/dumber/internal/domain/autocomplete" | ||
| @@ -32,6 +33,11 @@ const ( | |||||
| 32 | minGhostInputLength = 1 | 33 | minGhostInputLength = 1 | ||
| 33 | ) | 34 | ) | ||
| 34 | 35 | ||||
| 36 | + | type favoriteRowIndicatorUpdate struct { | |||
| 37 | + | Apply bool | |||
| 38 | + | ShowStarSlot bool | |||
| 39 | + | } | |||
| 40 | + | ||||
| 35 | // ViewMode distinguishes history search from favorites display. | 41 | // ViewMode distinguishes history search from favorites display. | ||
| 36 | type ViewMode string | 42 | type ViewMode string | ||
| 37 | 43 | ||||
| @@ -124,6 +130,7 @@ type Omnibox struct { | |||||
| 124 | 130 | ||||
| 125 | // Scaling | 131 | // Scaling | ||
| 126 | uiScale float64 | 132 | uiScale float64 | ||
| 133 | + | sizeCfg ModalSizeConfig | |||
| 127 | 134 | ||||
| 128 | // Cached measurements (populated after first layout) | 135 | // Cached measurements (populated after first layout) | ||
| 129 | measuredHeights struct { | 136 | measuredHeights struct { | ||
| @@ -158,6 +165,7 @@ type OmniboxConfig struct { | |||||
| 158 | OnFocusOut func() // Callback when entry loses focus | 165 | OnFocusOut func() // Callback when entry loses focus | ||
| 159 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | 166 | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | ||
| 160 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | 167 | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | ||
| 168 | + | SizeConfig ModalSizeConfig // Optional geometry override for omnibox sizing | |||
| 161 | } | 169 | } | ||
| 162 | 170 | ||||
| 163 | // NewOmnibox creates a new native GTK4 omnibox widget. | 171 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -170,6 +178,8 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 170 | uiScale = 1.0 | 178 | uiScale = 1.0 | ||
| 171 | } | 179 | } | ||
| 172 | 180 | ||||
| 181 | + | sizeCfg := ResolveModalSizeConfig(cfg.SizeConfig, OmniboxSizeDefaults) | |||
| 182 | + | ||||
| 173 | o := &Omnibox{ | 183 | o := &Omnibox{ | ||
| 174 | viewMode: ViewModeHistory, | 184 | viewMode: ViewModeHistory, | ||
| 175 | selectedIndex: -1, | 185 | selectedIndex: -1, | ||
| @@ -185,6 +195,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 185 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | 195 | onAccentKeyRelease: cfg.OnAccentKeyRelease, | ||
| 186 | ctx: ctx, | 196 | ctx: ctx, | ||
| 187 | uiScale: uiScale, | 197 | uiScale: uiScale, | ||
| 198 | + | sizeCfg: sizeCfg, | |||
| 188 | } | 199 | } | ||
| 189 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | 200 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | ||
| 190 | var cb glib.SourceFunc = func(uintptr) bool { | 201 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| @@ -317,13 +328,40 @@ func (o *Omnibox) estimateRowHeight() int { | |||||
| 317 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | 328 | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | ||
| 318 | } | 329 | } | ||
| 319 | 330 | ||||
| 331 | + | func resultsContainerState(rowCount int) (visible, expand, listVisible bool) { | |||
| 332 | + | return rowCount > 0, rowCount > 0, rowCount > 0 | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func shouldApplyEmptyResultsState(rowCount int, visible bool) bool { | |||
| 336 | + | return !visible || rowCount <= 0 | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func (o *Omnibox) setResultsContainerState(rowCount int) { | |||
| 340 | + | if o.scrolledWin == nil { | |||
| 341 | + | return | |||
| 342 | + | } | |||
| 343 | + | visible, expand, listVisible := resultsContainerState(rowCount) | |||
| 344 | + | o.scrolledWin.SetVisible(visible) | |||
| 345 | + | o.scrolledWin.SetVexpand(expand) | |||
| 346 | + | if o.listBox != nil { | |||
| 347 | + | o.listBox.SetVisible(listVisible) | |||
| 348 | + | } | |||
| 349 | + | if o.mainBox != nil { | |||
| 350 | + | if shouldApplyEmptyResultsState(rowCount, visible) { | |||
| 351 | + | o.mainBox.AddCssClass("omnibox-empty") | |||
| 352 | + | } else { | |||
| 353 | + | o.mainBox.RemoveCssClass("omnibox-empty") | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 320 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | 358 | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | ||
| 321 | // Must be called on the GTK main thread. | 359 | // Must be called on the GTK main thread. | ||
| 322 | func (o *Omnibox) effectiveMaxRows() int { | 360 | func (o *Omnibox) effectiveMaxRows() int { | ||
| 323 | if o.parentOverlay == nil { | 361 | if o.parentOverlay == nil { | ||
| 324 | return OmniboxListDefaults.MaxVisibleRows | 362 | return OmniboxListDefaults.MaxVisibleRows | ||
| 325 | } | 363 | } | ||
| 326 | - | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | 364 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), o.sizeCfg, OmniboxListDefaults) |
| 327 | } | 365 | } | ||
| 328 | 366 | ||||
| 329 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | 367 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | ||
| @@ -340,7 +378,7 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||||
| 340 | 378 | ||||
| 341 | // Schedule measurement after GTK has laid out widgets | 379 | // Schedule measurement after GTK has laid out widgets | ||
| 342 | var cb glib.SourceFunc = func(uintptr) bool { | 380 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| 343 | - | width, _ := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | 381 | + | width, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) |
| 344 | o.measureAndResize(width, rowCount) | 382 | o.measureAndResize(width, rowCount) | ||
| 345 | return false | 383 | return false | ||
| 346 | } | 384 | } | ||
| @@ -387,6 +425,15 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 387 | 425 | ||||
| 388 | // Force layout recalculation | 426 | // Force layout recalculation | ||
| 389 | o.outerBox.QueueResize() | 427 | o.outerBox.QueueResize() | ||
| 428 | + | if parent := o.parentOverlay; parent != nil { | |||
| 429 | + | if widget := parent.GtkWidget(); widget != nil { | |||
| 430 | + | widget.QueueResize() | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | if o.scrolledWin != nil { | |||
| 434 | + | o.scrolledWin.QueueResize() | |||
| 435 | + | } | |||
| 436 | + | o.mainBox.QueueResize() | |||
| 390 | 437 | ||||
| 391 | log.Debug(). | 438 | log.Debug(). | ||
| 392 | Int("width", width). | 439 | Int("width", width). | ||
| @@ -544,7 +591,7 @@ func (o *Omnibox) initList() error { | |||||
| 544 | return errNilWidget("scrolledWin") | 591 | return errNilWidget("scrolledWin") | ||
| 545 | } | 592 | } | ||
| 546 | o.scrolledWin.AddCssClass("omnibox-scrolled") | 593 | o.scrolledWin.AddCssClass("omnibox-scrolled") | ||
| 547 | - | o.scrolledWin.SetVexpand(true) | 594 | + | o.scrolledWin.SetVexpand(false) |
| 548 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | 595 | o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue) | ||
| 549 | o.scrolledWin.SetPropagateNaturalHeight(true) | 596 | o.scrolledWin.SetPropagateNaturalHeight(true) | ||
| 550 | 597 | ||||
| @@ -555,19 +602,16 @@ func (o *Omnibox) initList() error { | |||||
| 555 | o.listBox.AddCssClass("omnibox-listbox") | 602 | o.listBox.AddCssClass("omnibox-listbox") | ||
| 556 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 603 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 557 | 604 | ||||
| 558 | - | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 605 | + | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { |
| 559 | o.restoreEntryToRealInput() | 606 | o.restoreEntryToRealInput() | ||
| 560 | - | if rowPtr == 0 { | 607 | + | if row == nil { |
| 561 | o.mu.Lock() | 608 | o.mu.Lock() | ||
| 562 | o.selectedIndex = -1 | 609 | o.selectedIndex = -1 | ||
| 563 | o.mu.Unlock() | 610 | o.mu.Unlock() | ||
| 564 | } else { | 611 | } else { | ||
| 565 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | 612 | + | o.mu.Lock() |
| 566 | - | if row != nil { | 613 | + | o.selectedIndex = row.GetIndex() |
| 567 | - | o.mu.Lock() | 614 | + | o.mu.Unlock() |
| 568 | - | o.selectedIndex = row.GetIndex() | |||
| 569 | - | o.mu.Unlock() | |||
| 570 | - | } | |||
| 571 | } | 615 | } | ||
| 572 | o.updateGhostFromSelection() | 616 | o.updateGhostFromSelection() | ||
| 573 | } | 617 | } | ||
| @@ -575,11 +619,7 @@ func (o *Omnibox) initList() error { | |||||
| 575 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 619 | o.listBox.ConnectRowSelected(&rowSelectedCb) | ||
| 576 | 620 | ||||
| 577 | // Handle row activation (click or Enter) - navigate directly to the URL | 621 | // Handle row activation (click or Enter) - navigate directly to the URL | ||
| 578 | - | rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 622 | + | rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { |
| 579 | - | if rowPtr == 0 { | |||
| 580 | - | return | |||
| 581 | - | } | |||
| 582 | - | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |||
| 583 | if row == nil { | 623 | if row == nil { | ||
| 584 | return | 624 | return | ||
| 585 | } | 625 | } | ||
| @@ -596,7 +636,7 @@ func (o *Omnibox) initList() error { | |||||
| 596 | return | 636 | return | ||
| 597 | } | 637 | } | ||
| 598 | 638 | ||||
| 599 | - | targetURL := resolveTargetURLForSelection(mode, idx, suggestions, favorites) | 639 | + | targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites) |
| 600 | 640 | ||||
| 601 | if targetURL != "" && o.onNavigate != nil { | 641 | if targetURL != "" && o.onNavigate != nil { | ||
| 602 | o.Hide(o.ctx) | 642 | o.Hide(o.ctx) | ||
| @@ -1101,6 +1141,7 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||||
| 1101 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | 1141 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | ||
| 1102 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | 1142 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | ||
| 1103 | log := logging.FromContext(o.ctx) | 1143 | log := logging.FromContext(o.ctx) | ||
| 1144 | + | maxVisible := o.effectiveMaxRows() | |||
| 1104 | o.mu.RLock() | 1145 | o.mu.RLock() | ||
| 1105 | idx := o.selectedIndex | 1146 | idx := o.selectedIndex | ||
| 1106 | mode := o.viewMode | 1147 | mode := o.viewMode | ||
| @@ -1128,10 +1169,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1128 | return | 1169 | return | ||
| 1129 | } | 1170 | } | ||
| 1130 | 1171 | ||||
| 1131 | - | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, suggestions, favorites) | 1172 | + | targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites) |
| 1132 | 1173 | ||||
| 1133 | if entryText != "" { | 1174 | if entryText != "" { | ||
| 1134 | - | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, suggestions, favorites) | 1175 | + | o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites) |
| 1135 | return | 1176 | return | ||
| 1136 | } | 1177 | } | ||
| 1137 | 1178 | ||||
| @@ -1153,6 +1194,7 @@ func (o *Omnibox) resolveGhostCompletion( | |||||
| 1153 | entryText, selectedURL string, | 1194 | entryText, selectedURL string, | ||
| 1154 | hasExplicitSelection bool, | 1195 | hasExplicitSelection bool, | ||
| 1155 | mode ViewMode, | 1196 | mode ViewMode, | ||
| 1197 | + | maxVisible int, | |||
| 1156 | suggestions []Suggestion, | 1198 | suggestions []Suggestion, | ||
| 1157 | favorites []Favorite, | 1199 | favorites []Favorite, | ||
| 1158 | ) { | 1200 | ) { | ||
| @@ -1163,7 +1205,15 @@ func (o *Omnibox) resolveGhostCompletion( | |||||
| 1163 | return | 1205 | return | ||
| 1164 | } | 1206 | } | ||
| 1165 | 1207 | ||||
| 1166 | - | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | 1208 | + | fullText, suffix, found := visibleGhostSuggestion( |
| 1209 | + | completionInput, | |||
| 1210 | + | selectedURL, | |||
| 1211 | + | hasExplicitSelection, | |||
| 1212 | + | mode, | |||
| 1213 | + | maxVisible, | |||
| 1214 | + | suggestions, | |||
| 1215 | + | favorites, | |||
| 1216 | + | ) | |||
| 1167 | if !found { | 1217 | if !found { | ||
| 1168 | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") | 1218 | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") | ||
| 1169 | return | 1219 | return | ||
| @@ -1235,6 +1285,7 @@ func visibleGhostSuggestion( | |||||
| 1235 | query, selectedURL string, | 1285 | query, selectedURL string, | ||
| 1236 | hasExplicitSelection bool, | 1286 | hasExplicitSelection bool, | ||
| 1237 | mode ViewMode, | 1287 | mode ViewMode, | ||
| 1288 | + | maxVisible int, | |||
| 1238 | suggestions []Suggestion, | 1289 | suggestions []Suggestion, | ||
| 1239 | favorites []Favorite, | 1290 | favorites []Favorite, | ||
| 1240 | ) (fullText, suffix string, ok bool) { | 1291 | ) (fullText, suffix string, ok bool) { | ||
| @@ -1243,15 +1294,16 @@ func visibleGhostSuggestion( | |||||
| 1243 | return fullText, suffix, ok | 1294 | return fullText, suffix, ok | ||
| 1244 | } | 1295 | } | ||
| 1245 | 1296 | ||||
| 1246 | - | visibleURLs := visibleURLsForMode(mode, suggestions, favorites) | 1297 | + | visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites) |
| 1247 | suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs) | 1298 | suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs) | ||
| 1248 | return fullText, suffix, ok | 1299 | return fullText, suffix, ok | ||
| 1249 | } | 1300 | } | ||
| 1250 | 1301 | ||||
| 1251 | - | func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Favorite) []string { | 1302 | + | func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string { |
| 1252 | if mode == ViewModeHistory { | 1303 | if mode == ViewModeHistory { | ||
| 1253 | - | urls := make([]string, 0, len(suggestions)) | 1304 | + | visibleCount := visibleResultCount(len(suggestions), maxVisible) |
| 1254 | - | for _, s := range suggestions { | 1305 | + | urls := make([]string, 0, visibleCount) |
| 1306 | + | for _, s := range suggestions[:visibleCount] { | |||
| 1255 | if s.URL != "" { | 1307 | if s.URL != "" { | ||
| 1256 | urls = append(urls, s.URL) | 1308 | urls = append(urls, s.URL) | ||
| 1257 | } | 1309 | } | ||
| @@ -1259,8 +1311,9 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav | |||||
| 1259 | return urls | 1311 | return urls | ||
| 1260 | } | 1312 | } | ||
| 1261 | 1313 | ||||
| 1262 | - | urls := make([]string, 0, len(favorites)) | 1314 | + | visibleCount := visibleResultCount(len(favorites), maxVisible) |
| 1263 | - | for _, f := range favorites { | 1315 | + | urls := make([]string, 0, visibleCount) |
| 1316 | + | for _, f := range favorites[:visibleCount] { | |||
| 1264 | if f.URL != "" { | 1317 | if f.URL != "" { | ||
| 1265 | urls = append(urls, f.URL) | 1318 | urls = append(urls, f.URL) | ||
| 1266 | } | 1319 | } | ||
| @@ -1268,11 +1321,21 @@ func visibleURLsForMode(mode ViewMode, suggestions []Suggestion, favorites []Fav | |||||
| 1268 | return urls | 1321 | return urls | ||
| 1269 | } | 1322 | } | ||
| 1270 | 1323 | ||||
| 1271 | - | func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) (string, bool) { | 1324 | + | func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) { |
| 1272 | if idx < 0 { | 1325 | if idx < 0 { | ||
| 1273 | return "", false | 1326 | return "", false | ||
| 1274 | } | 1327 | } | ||
| 1275 | - | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | 1328 | + | return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true |
| 1329 | + | } | |||
| 1330 | + | ||||
| 1331 | + | func visibleResultCount(total, maxVisible int) int { | |||
| 1332 | + | if total <= 0 { | |||
| 1333 | + | return 0 | |||
| 1334 | + | } | |||
| 1335 | + | if maxVisible <= 0 || total < maxVisible { | |||
| 1336 | + | return total | |||
| 1337 | + | } | |||
| 1338 | + | return maxVisible | |||
| 1276 | } | 1339 | } | ||
| 1277 | 1340 | ||||
| 1278 | // performSearch executes the search based on current view mode and query. | 1341 | // performSearch executes the search based on current view mode and query. | ||
| @@ -1598,9 +1661,7 @@ func (o *Omnibox) updateBangSuggestions(suggestions []BangSuggestion) { | |||||
| 1598 | o.rebuildList() | 1661 | o.rebuildList() | ||
| 1599 | 1662 | ||||
| 1600 | rowCount := len(suggestions) | 1663 | rowCount := len(suggestions) | ||
| 1601 | - | if o.scrolledWin != nil { | 1664 | + | o.setResultsContainerState(rowCount) |
| 1602 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1603 | - | } | |||
| 1604 | o.resizeAndCenter(rowCount) | 1665 | o.resizeAndCenter(rowCount) | ||
| 1605 | } | 1666 | } | ||
| 1606 | 1667 | ||||
| @@ -1618,9 +1679,7 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) { | |||||
| 1618 | 1679 | ||||
| 1619 | // Hide scrolled window when there are no suggestions | 1680 | // Hide scrolled window when there are no suggestions | ||
| 1620 | rowCount := len(suggestions) | 1681 | rowCount := len(suggestions) | ||
| 1621 | - | if o.scrolledWin != nil { | 1682 | + | o.setResultsContainerState(rowCount) |
| 1622 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1623 | - | } | |||
| 1624 | o.resizeAndCenter(rowCount) | 1683 | o.resizeAndCenter(rowCount) | ||
| 1625 | 1684 | ||||
| 1626 | if rowCount > 0 { | 1685 | if rowCount > 0 { | ||
| @@ -1644,9 +1703,7 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) { | |||||
| 1644 | 1703 | ||||
| 1645 | // Hide scrolled window when there are no favorites | 1704 | // Hide scrolled window when there are no favorites | ||
| 1646 | rowCount := len(favorites) | 1705 | rowCount := len(favorites) | ||
| 1647 | - | if o.scrolledWin != nil { | 1706 | + | o.setResultsContainerState(rowCount) |
| 1648 | - | o.scrolledWin.SetVisible(rowCount > 0) | |||
| 1649 | - | } | |||
| 1650 | o.resizeAndCenter(rowCount) | 1707 | o.resizeAndCenter(rowCount) | ||
| 1651 | 1708 | ||||
| 1652 | if rowCount > 0 { | 1709 | if rowCount > 0 { | ||
| @@ -1702,7 +1759,7 @@ func (o *Omnibox) rebuildList() { | |||||
| 1702 | if width <= 0 { | 1759 | if width <= 0 { | ||
| 1703 | return false // Overlay not allocated yet, skip | 1760 | return false // Overlay not allocated yet, skip | ||
| 1704 | } | 1761 | } | ||
| 1705 | - | forWidth := int(float64(width) * OmniboxSizeDefaults.WidthPct) | 1762 | + | forWidth, _ := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) |
| 1706 | if o.measureComponentHeights(forWidth) { | 1763 | if o.measureComponentHeights(forWidth) { | ||
| 1707 | // Re-trigger resize with accurate measurements | 1764 | // Re-trigger resize with accurate measurements | ||
| 1708 | o.mu.RLock() | 1765 | o.mu.RLock() | ||
| @@ -1749,86 +1806,166 @@ func (o *Omnibox) createFaviconImage(rawURL, fallbackIcon string) *gtk.Image { | |||||
| 1749 | return favicon | 1806 | return favicon | ||
| 1750 | } | 1807 | } | ||
| 1751 | 1808 | ||||
| 1752 | - | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | 1809 | + | const favoriteStarBaseSize = 18 |
| 1753 | - | // Uses rawURL for both favicon fetching and display. | 1810 | + | const favoriteStarSlotClass = "omnibox-favorite-star-slot" |
| 1754 | - | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | 1811 | + | |
| 1755 | - | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, index) | 1812 | + | func favoriteStarSize(scale float64) int { return ScaleValue(favoriteStarBaseSize, scale) } |
| 1813 | + | ||||
| 1814 | + | func shouldShowFavoriteStar(s Suggestion) bool { return s.IsFavorite } | |||
| 1815 | + | ||||
| 1816 | + | func clearBoxChildren(box *gtk.Box) { | |||
| 1817 | + | if box == nil { | |||
| 1818 | + | return | |||
| 1819 | + | } | |||
| 1820 | + | for child := box.GetFirstChild(); child != nil; { | |||
| 1821 | + | next := child.GetNextSibling() | |||
| 1822 | + | box.Remove(child) | |||
| 1823 | + | child = next | |||
| 1824 | + | } | |||
| 1756 | } | 1825 | } | ||
| 1757 | 1826 | ||||
| 1758 | - | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | 1827 | + | func (o *Omnibox) syncFavoriteStarSlot(slot *gtk.Box, showFavoriteStar bool) { |
| 1759 | - | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | 1828 | + | if slot == nil { |
| 1760 | - | func (o *Omnibox) createRowWithFaviconURL(displayURL, title, faviconURL, fallbackIcon string, index int) *gtk.ListBoxRow { | 1829 | + | return |
| 1761 | - | row := gtk.NewListBoxRow() | 1830 | + | } |
| 1831 | + | clearBoxChildren(slot) | |||
| 1832 | + | if showFavoriteStar { | |||
| 1833 | + | if star := o.createFavoriteStarIcon(); star != nil { | |||
| 1834 | + | slot.Append(&star.Widget) | |||
| 1835 | + | } | |||
| 1836 | + | } | |||
| 1837 | + | slot.SetVisible(showFavoriteStar) | |||
| 1838 | + | } | |||
| 1839 | + | ||||
| 1840 | + | func favoriteStarSlotForRow(row *gtk.ListBoxRow) *gtk.Box { | |||
| 1762 | if row == nil { | 1841 | if row == nil { | ||
| 1763 | return nil | 1842 | return nil | ||
| 1764 | } | 1843 | } | ||
| 1765 | - | row.AddCssClass("omnibox-row") | 1844 | + | hbox := row.GetChild() |
| 1766 | - | o.attachRowHoverSelection(row, index) | |||
| 1767 | - | ||||
| 1768 | - | const rowSpacing = 8 | |||
| 1769 | - | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |||
| 1770 | if hbox == nil { | 1845 | if hbox == nil { | ||
| 1771 | return nil | 1846 | return nil | ||
| 1772 | } | 1847 | } | ||
| 1773 | - | hbox.SetHexpand(true) | 1848 | + | for child := hbox.GetFirstChild(); child != nil; child = child.GetNextSibling() { |
| 1774 | - | 1849 | + | if child.HasCssClass(favoriteStarSlotClass) { | |
| 1775 | - | // Favicon image (vertically centered) | 1850 | + | return gtk.BoxNewFromInternalPtr(child.GoPointer()) |
| 1776 | - | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | 1851 | + | } |
| 1777 | - | favicon.SetValign(gtk.AlignCenterValue) | |||
| 1778 | - | hbox.Append(&favicon.Widget) | |||
| 1779 | } | 1852 | } | ||
| 1853 | + | return nil | |||
| 1854 | + | } | |||
| 1780 | 1855 | ||||
| 1781 | - | // Vertical box for title + URL | 1856 | + | func (o *Omnibox) createFavoriteStarIcon() *gtk.Image { |
| 1782 | - | textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | 1857 | + | star := gtk.NewImage() |
| 1783 | - | if textBox == nil { | 1858 | + | if star == nil { |
| 1784 | return nil | 1859 | return nil | ||
| 1785 | } | 1860 | } | ||
| 1786 | - | textBox.SetHexpand(true) | 1861 | + | iconName := "starred-symbolic" |
| 1787 | - | textBox.SetValign(gtk.AlignCenterValue) | 1862 | + | star.SetFromIconName(&iconName) |
| 1863 | + | star.SetPixelSize(favoriteStarSize(o.uiScale)) | |||
| 1864 | + | star.AddCssClass("omnibox-favorite-star") | |||
| 1865 | + | star.SetValign(gtk.AlignCenterValue) | |||
| 1866 | + | return star | |||
| 1867 | + | } | |||
| 1788 | 1868 | ||||
| 1789 | - | // Title label (or URL if no title) | 1869 | + | func (o *Omnibox) appendSuggestionTitleAndURL(textBox *gtk.Box, title, displayURL string) { |
| 1790 | displayTitle := title | 1870 | displayTitle := title | ||
| 1791 | if displayTitle == "" { | 1871 | if displayTitle == "" { | ||
| 1792 | displayTitle = displayURL | 1872 | displayTitle = displayURL | ||
| 1793 | } | 1873 | } | ||
| 1874 | + | ||||
| 1794 | titleLabel := gtk.NewLabel(nil) | 1875 | titleLabel := gtk.NewLabel(nil) | ||
| 1795 | if titleLabel != nil { | 1876 | if titleLabel != nil { | ||
| 1796 | titleLabel.SetText(displayTitle) | 1877 | titleLabel.SetText(displayTitle) | ||
| 1797 | titleLabel.AddCssClass("omnibox-suggestion-title") | 1878 | titleLabel.AddCssClass("omnibox-suggestion-title") | ||
| 1798 | titleLabel.SetHalign(gtk.AlignStartValue) | 1879 | titleLabel.SetHalign(gtk.AlignStartValue) | ||
| 1799 | - | titleLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | 1880 | + | titleLabel.SetEllipsize(pango.EllipsizeEndValue) |
| 1800 | textBox.Append(&titleLabel.Widget) | 1881 | textBox.Append(&titleLabel.Widget) | ||
| 1801 | } | 1882 | } | ||
| 1802 | 1883 | ||||
| 1803 | - | // URL label (only if title exists and differs from URL) | |||
| 1804 | if title != "" && title != displayURL { | 1884 | if title != "" && title != displayURL { | ||
| 1805 | urlLabel := gtk.NewLabel(nil) | 1885 | urlLabel := gtk.NewLabel(nil) | ||
| 1806 | if urlLabel != nil { | 1886 | if urlLabel != nil { | ||
| 1807 | urlLabel.SetText(displayURL) | 1887 | urlLabel.SetText(displayURL) | ||
| 1808 | urlLabel.AddCssClass("omnibox-suggestion-url") | 1888 | urlLabel.AddCssClass("omnibox-suggestion-url") | ||
| 1809 | urlLabel.SetHalign(gtk.AlignStartValue) | 1889 | urlLabel.SetHalign(gtk.AlignStartValue) | ||
| 1810 | - | urlLabel.SetEllipsize(2) // PANGO_ELLIPSIZE_END | 1890 | + | urlLabel.SetEllipsize(pango.EllipsizeEndValue) |
| 1811 | textBox.Append(&urlLabel.Widget) | 1891 | textBox.Append(&urlLabel.Widget) | ||
| 1812 | } | 1892 | } | ||
| 1813 | } | 1893 | } | ||
| 1894 | + | } | |||
| 1814 | 1895 | ||||
| 1815 | - | hbox.Append(&textBox.Widget) | 1896 | + | func (o *Omnibox) appendFavoriteStarAndShortcut(hbox *gtk.Box, showFavoriteStar bool, index int) { |
| 1897 | + | starSlot := gtk.NewBox(gtk.OrientationHorizontalValue, 0) | |||
| 1898 | + | if starSlot != nil { | |||
| 1899 | + | starSlot.AddCssClass(favoriteStarSlotClass) | |||
| 1900 | + | starSlot.SetValign(gtk.AlignCenterValue) | |||
| 1901 | + | o.syncFavoriteStarSlot(starSlot, showFavoriteStar) | |||
| 1902 | + | hbox.Append(&starSlot.Widget) | |||
| 1903 | + | } | |||
| 1816 | 1904 | ||||
| 1817 | - | // Shortcut badge (Ctrl+1-9, Ctrl+0 for 10th) | |||
| 1818 | const maxShortcutIndex = 9 | 1905 | const maxShortcutIndex = 9 | ||
| 1819 | - | if index <= maxShortcutIndex { | 1906 | + | if index > maxShortcutIndex { |
| 1820 | - | shortcutLabel := gtk.NewLabel(nil) | 1907 | + | return |
| 1821 | - | if shortcutLabel != nil { | 1908 | + | } |
| 1822 | - | if index < maxShortcutIndex { | 1909 | + | |
| 1823 | - | shortcutLabel.SetText(formatShortcut(index + 1)) | 1910 | + | shortcutLabel := gtk.NewLabel(nil) |
| 1824 | - | } else { | 1911 | + | if shortcutLabel == nil { |
| 1825 | - | shortcutLabel.SetText("Ctrl+0") | 1912 | + | return |
| 1826 | - | } | 1913 | + | } |
| 1827 | - | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | 1914 | + | if index < maxShortcutIndex { |
| 1828 | - | shortcutLabel.SetValign(gtk.AlignCenterValue) | 1915 | + | shortcutLabel.SetText(formatShortcut(index + 1)) |
| 1829 | - | hbox.Append(&shortcutLabel.Widget) | 1916 | + | } else { |
| 1830 | - | } | 1917 | + | shortcutLabel.SetText("Ctrl+0") |
| 1918 | + | } | |||
| 1919 | + | shortcutLabel.AddCssClass("omnibox-shortcut-badge") | |||
| 1920 | + | shortcutLabel.SetValign(gtk.AlignCenterValue) | |||
| 1921 | + | hbox.Append(&shortcutLabel.Widget) | |||
| 1922 | + | } | |||
| 1923 | + | ||||
| 1924 | + | // createRowWithFavicon creates a ListBoxRow with favicon, title, URL, and shortcut badge. | |||
| 1925 | + | // Uses rawURL for both favicon fetching and display. | |||
| 1926 | + | func (o *Omnibox) createRowWithFavicon(rawURL, title, fallbackIcon string, index int) *gtk.ListBoxRow { | |||
| 1927 | + | return o.createRowWithFaviconURL(rawURL, title, rawURL, fallbackIcon, false, index) | |||
| 1928 | + | } | |||
| 1929 | + | ||||
| 1930 | + | // createRowWithFaviconURL creates a ListBoxRow with favicon, title, URL label, and shortcut badge. | |||
| 1931 | + | // faviconURL is used for async favicon fetching (can be empty to skip), displayURL is shown as secondary label. | |||
| 1932 | + | func (o *Omnibox) createRowWithFaviconURL( | |||
| 1933 | + | displayURL, title, faviconURL, fallbackIcon string, | |||
| 1934 | + | showFavoriteStar bool, | |||
| 1935 | + | index int, | |||
| 1936 | + | ) *gtk.ListBoxRow { | |||
| 1937 | + | row := gtk.NewListBoxRow() | |||
| 1938 | + | if row == nil { | |||
| 1939 | + | return nil | |||
| 1831 | } | 1940 | } | ||
| 1941 | + | row.AddCssClass("omnibox-row") | |||
| 1942 | + | o.attachRowHoverSelection(row, index) | |||
| 1943 | + | ||||
| 1944 | + | const rowSpacing = 8 | |||
| 1945 | + | hbox := gtk.NewBox(gtk.OrientationHorizontalValue, rowSpacing) | |||
| 1946 | + | if hbox == nil { | |||
| 1947 | + | return nil | |||
| 1948 | + | } | |||
| 1949 | + | hbox.SetHexpand(true) | |||
| 1950 | + | ||||
| 1951 | + | // Favicon image (vertically centered) | |||
| 1952 | + | if favicon := o.createFaviconImage(faviconURL, fallbackIcon); favicon != nil { | |||
| 1953 | + | favicon.SetValign(gtk.AlignCenterValue) | |||
| 1954 | + | hbox.Append(&favicon.Widget) | |||
| 1955 | + | } | |||
| 1956 | + | ||||
| 1957 | + | // Vertical box for title + URL | |||
| 1958 | + | textBox := gtk.NewBox(gtk.OrientationVerticalValue, 2) | |||
| 1959 | + | if textBox == nil { | |||
| 1960 | + | return nil | |||
| 1961 | + | } | |||
| 1962 | + | textBox.SetHexpand(true) | |||
| 1963 | + | textBox.SetValign(gtk.AlignCenterValue) | |||
| 1964 | + | ||||
| 1965 | + | o.appendSuggestionTitleAndURL(textBox, title, displayURL) | |||
| 1966 | + | ||||
| 1967 | + | hbox.Append(&textBox.Widget) | |||
| 1968 | + | o.appendFavoriteStarAndShortcut(hbox, showFavoriteStar, index) | |||
| 1832 | 1969 | ||||
| 1833 | row.SetChild(&hbox.Widget) | 1970 | row.SetChild(&hbox.Widget) | ||
| 1834 | return row | 1971 | return row | ||
| @@ -1863,7 +2000,7 @@ func shouldPromoteHoverSelection(realInput string, hasGhostText, hasNavigated bo | |||||
| 1863 | 2000 | ||||
| 1864 | // createSuggestionRow creates a ListBoxRow for a suggestion. | 2001 | // createSuggestionRow creates a ListBoxRow for a suggestion. | ||
| 1865 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | 2002 | func (o *Omnibox) createSuggestionRow(s Suggestion, index int) *gtk.ListBoxRow { | ||
| 1866 | - | row := o.createRowWithFavicon(s.URL, s.Title, "web-browser-symbolic", index) | 2003 | + | row := o.createRowWithFaviconURL(s.URL, s.Title, s.URL, "web-browser-symbolic", shouldShowFavoriteStar(s), index) |
| 1867 | if row != nil && s.IsFavorite { | 2004 | if row != nil && s.IsFavorite { | ||
| 1868 | row.AddCssClass("omnibox-row-favorite") | 2005 | row.AddCssClass("omnibox-row-favorite") | ||
| 1869 | } | 2006 | } | ||
| @@ -1878,7 +2015,7 @@ func (o *Omnibox) createFavoriteRow(f Favorite, index int) *gtk.ListBoxRow { | |||||
| 1878 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | 2015 | func (o *Omnibox) createBangRow(b BangSuggestion, index int) *gtk.ListBoxRow { | ||
| 1879 | // Pass description as URL param (displayed as secondary label) and empty | 2016 | // Pass description as URL param (displayed as secondary label) and empty | ||
| 1880 | // faviconURL to skip async favicon fetching - bang rows use static icon only | 2017 | // faviconURL to skip async favicon fetching - bang rows use static icon only | ||
| 1881 | - | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", index) | 2018 | + | row := o.createRowWithFaviconURL(b.Description, "!"+b.Key, "", "system-search-symbolic", false, index) |
| 1882 | if row != nil { | 2019 | if row != nil { | ||
| 1883 | row.AddCssClass("omnibox-row-bang") | 2020 | row.AddCssClass("omnibox-row-bang") | ||
| 1884 | } | 2021 | } | ||
| @@ -1908,13 +2045,14 @@ func (o *Omnibox) selectNext() { | |||||
| 1908 | current := o.selectedIndex | 2045 | current := o.selectedIndex | ||
| 1909 | mode := o.viewMode | 2046 | mode := o.viewMode | ||
| 1910 | bangMode := o.bangMode | 2047 | bangMode := o.bangMode | ||
| 2048 | + | maxVisible := o.effectiveMaxRows() | |||
| 1911 | var maxIndex int | 2049 | var maxIndex int | ||
| 1912 | if bangMode { | 2050 | if bangMode { | ||
| 1913 | - | maxIndex = len(o.bangSuggestions) - 1 | 2051 | + | maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1 |
| 1914 | } else if mode == ViewModeHistory { | 2052 | } else if mode == ViewModeHistory { | ||
| 1915 | - | maxIndex = len(o.suggestions) - 1 | 2053 | + | maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1 |
| 1916 | } else { | 2054 | } else { | ||
| 1917 | - | maxIndex = len(o.favorites) - 1 | 2055 | + | maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1 |
| 1918 | } | 2056 | } | ||
| 1919 | o.hasNavigated = true // User is navigating with arrow keys | 2057 | o.hasNavigated = true // User is navigating with arrow keys | ||
| 1920 | o.mu.Unlock() | 2058 | o.mu.Unlock() | ||
| @@ -1936,13 +2074,14 @@ func (o *Omnibox) selectPrevious() { | |||||
| 1936 | current := o.selectedIndex | 2074 | current := o.selectedIndex | ||
| 1937 | mode := o.viewMode | 2075 | mode := o.viewMode | ||
| 1938 | bangMode := o.bangMode | 2076 | bangMode := o.bangMode | ||
| 2077 | + | maxVisible := o.effectiveMaxRows() | |||
| 1939 | var maxIndex int | 2078 | var maxIndex int | ||
| 1940 | if bangMode { | 2079 | if bangMode { | ||
| 1941 | - | maxIndex = len(o.bangSuggestions) - 1 | 2080 | + | maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1 |
| 1942 | } else if mode == ViewModeHistory { | 2081 | } else if mode == ViewModeHistory { | ||
| 1943 | - | maxIndex = len(o.suggestions) - 1 | 2082 | + | maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1 |
| 1944 | } else { | 2083 | } else { | ||
| 1945 | - | maxIndex = len(o.favorites) - 1 | 2084 | + | maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1 |
| 1946 | } | 2085 | } | ||
| 1947 | o.hasNavigated = true // User is navigating with arrow keys | 2086 | o.hasNavigated = true // User is navigating with arrow keys | ||
| 1948 | o.mu.Unlock() | 2087 | o.mu.Unlock() | ||
| @@ -1968,13 +2107,14 @@ func (o *Omnibox) selectAndNavigate(index int) { | |||||
| 1968 | suggestions := o.suggestions | 2107 | suggestions := o.suggestions | ||
| 1969 | favorites := o.favorites | 2108 | favorites := o.favorites | ||
| 1970 | o.mu.RUnlock() | 2109 | o.mu.RUnlock() | ||
| 2110 | + | maxVisible := o.effectiveMaxRows() | |||
| 1971 | 2111 | ||||
| 1972 | if bangMode { | 2112 | if bangMode { | ||
| 1973 | o.navigateToSelected() | 2113 | o.navigateToSelected() | ||
| 1974 | return | 2114 | return | ||
| 1975 | } | 2115 | } | ||
| 1976 | 2116 | ||||
| 1977 | - | targetURL := resolveTargetURLForSelection(mode, index, suggestions, favorites) | 2117 | + | targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites) |
| 1978 | if targetURL == "" { | 2118 | if targetURL == "" { | ||
| 1979 | return | 2119 | return | ||
| 1980 | } | 2120 | } | ||
| @@ -2028,7 +2168,7 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 2028 | var targetURL string | 2168 | var targetURL string | ||
| 2029 | if idx >= 0 { | 2169 | if idx >= 0 { | ||
| 2030 | // If user has selected a result, navigate to that result. | 2170 | // If user has selected a result, navigate to that result. | ||
| 2031 | - | targetURL = resolveTargetURLForSelection(mode, idx, suggestions, favorites) | 2171 | + | targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites) |
| 2032 | } else { | 2172 | } else { | ||
| 2033 | targetURL = o.buildURL(entryText) | 2173 | targetURL = o.buildURL(entryText) | ||
| 2034 | } | 2174 | } | ||
| @@ -2044,14 +2184,16 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 2044 | } | 2184 | } | ||
| 2045 | } | 2185 | } | ||
| 2046 | 2186 | ||||
| 2047 | - | func resolveTargetURLForSelection(mode ViewMode, idx int, suggestions []Suggestion, favorites []Favorite) string { | 2187 | + | func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string { |
| 2048 | if mode == ViewModeHistory { | 2188 | if mode == ViewModeHistory { | ||
| 2049 | - | if idx >= 0 && idx < len(suggestions) { | 2189 | + | visibleCount := visibleResultCount(len(suggestions), maxVisible) |
| 2190 | + | if idx >= 0 && idx < visibleCount { | |||
| 2050 | return suggestions[idx].URL | 2191 | return suggestions[idx].URL | ||
| 2051 | } | 2192 | } | ||
| 2052 | return "" | 2193 | return "" | ||
| 2053 | } | 2194 | } | ||
| 2054 | - | if idx >= 0 && idx < len(favorites) { | 2195 | + | visibleCount := visibleResultCount(len(favorites), maxVisible) |
| 2196 | + | if idx >= 0 && idx < visibleCount { | |||
| 2055 | return favorites[idx].URL | 2197 | return favorites[idx].URL | ||
| 2056 | } | 2198 | } | ||
| 2057 | return "" | 2199 | return "" | ||
| @@ -2126,7 +2268,7 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 2126 | 2268 | ||||
| 2127 | // Update row CSS and show toast on GTK main thread | 2269 | // Update row CSS and show toast on GTK main thread | ||
| 2128 | cb := glib.SourceFunc(func(_ uintptr) bool { | 2270 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 2129 | - | o.updateRowFavoriteIndicator(idx, result.Added) | 2271 | + | o.updateRowFavoriteIndicator(idx, s.URL, result.Added) |
| 2130 | if o.onToast != nil { | 2272 | if o.onToast != nil { | ||
| 2131 | o.onToast(ctx, result.Message, ToastSuccess) | 2273 | o.onToast(ctx, result.Message, ToastSuccess) | ||
| 2132 | } | 2274 | } | ||
| @@ -2188,16 +2330,59 @@ func (o *Omnibox) toggleFavorite() { | |||||
| 2188 | 2330 | ||||
| 2189 | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | 2331 | // updateRowFavoriteIndicator updates a single row's favorite indicator CSS class. | ||
| 2190 | // Must be called from GTK main thread (via glib.IdleAdd). | 2332 | // Must be called from GTK main thread (via glib.IdleAdd). | ||
| 2191 | - | func (o *Omnibox) updateRowFavoriteIndicator(index int, isFavorite bool) { | 2333 | + | func (o *Omnibox) updateRowFavoriteIndicator(index int, expectedURL string, isFavorite bool) { |
| 2192 | row := o.listBox.GetRowAtIndex(index) | 2334 | row := o.listBox.GetRowAtIndex(index) | ||
| 2193 | if row == nil { | 2335 | if row == nil { | ||
| 2194 | return | 2336 | return | ||
| 2195 | } | 2337 | } | ||
| 2338 | + | ||||
| 2339 | + | o.mu.RLock() | |||
| 2340 | + | mode := o.viewMode | |||
| 2341 | + | suggestions := append([]Suggestion(nil), o.suggestions...) | |||
| 2342 | + | o.mu.RUnlock() | |||
| 2343 | + | ||||
| 2344 | + | update := resolveFavoriteRowIndicatorUpdate(mode, index, expectedURL, suggestions, isFavorite) | |||
| 2345 | + | if !update.Apply { | |||
| 2346 | + | return | |||
| 2347 | + | } | |||
| 2348 | + | ||||
| 2196 | if isFavorite { | 2349 | if isFavorite { | ||
| 2197 | row.AddCssClass("omnibox-row-favorite") | 2350 | row.AddCssClass("omnibox-row-favorite") | ||
| 2198 | } else { | 2351 | } else { | ||
| 2199 | row.RemoveCssClass("omnibox-row-favorite") | 2352 | row.RemoveCssClass("omnibox-row-favorite") | ||
| 2200 | } | 2353 | } | ||
| 2354 | + | if slot := favoriteStarSlotForRow(row); slot != nil { | |||
| 2355 | + | o.syncFavoriteStarSlot(slot, isFavorite) | |||
| 2356 | + | slot.QueueDraw() | |||
| 2357 | + | if update.ShowStarSlot { | |||
| 2358 | + | slot.Show() | |||
| 2359 | + | } | |||
| 2360 | + | } | |||
| 2361 | + | if child := row.GetChild(); child != nil { | |||
| 2362 | + | child.QueueDraw() | |||
| 2363 | + | child.Show() | |||
| 2364 | + | } | |||
| 2365 | + | row.QueueDraw() | |||
| 2366 | + | row.Show() | |||
| 2367 | + | } | |||
| 2368 | + | ||||
| 2369 | + | func resolveFavoriteRowIndicatorUpdate( | |||
| 2370 | + | mode ViewMode, | |||
| 2371 | + | index int, | |||
| 2372 | + | expectedURL string, | |||
| 2373 | + | suggestions []Suggestion, | |||
| 2374 | + | isFavorite bool, | |||
| 2375 | + | ) favoriteRowIndicatorUpdate { | |||
| 2376 | + | if mode != ViewModeHistory { | |||
| 2377 | + | return favoriteRowIndicatorUpdate{} | |||
| 2378 | + | } | |||
| 2379 | + | if index < 0 || index >= len(suggestions) { | |||
| 2380 | + | return favoriteRowIndicatorUpdate{} | |||
| 2381 | + | } | |||
| 2382 | + | if suggestions[index].URL != expectedURL { | |||
| 2383 | + | return favoriteRowIndicatorUpdate{} | |||
| 2384 | + | } | |||
| 2385 | + | return favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: isFavorite} | |||
| 2201 | } | 2386 | } | ||
| 2202 | 2387 | ||||
| 2203 | // yankSelectedURL copies the URL of the selected item to clipboard. | 2388 | // yankSelectedURL copies the URL of the selected item to clipboard. | ||
| @@ -2316,20 +2501,33 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2316 | o.entry.SetText(query) | 2501 | o.entry.SetText(query) | ||
| 2317 | o.entry.SelectRegion(-1, -1) | 2502 | o.entry.SelectRegion(-1, -1) | ||
| 2318 | 2503 | ||||
| 2319 | - | // Determine if we expect content initially | 2504 | + | // Keep the results container hidden until real rows arrive. |
| 2320 | - | // No content expected if: no query AND initialBehavior is "none" | 2505 | + | // Pre-showing an empty, expanding scroller renders a dead area in standalone mode. |
| 2321 | - | expectContent := query != "" || o.initialBehavior != "none" | |||
| 2322 | - | ||||
| 2323 | - | // Hide scrolled window if no content expected | |||
| 2324 | if o.scrolledWin != nil { | 2506 | if o.scrolledWin != nil { | ||
| 2325 | - | o.scrolledWin.SetVisible(expectContent) | 2507 | + | o.setResultsContainerState(0) |
| 2326 | // Reset content height constraints - will be updated when results arrive | 2508 | // Reset content height constraints - will be updated when results arrive | ||
| 2327 | o.scrolledWin.SetMinContentHeight(-1) | 2509 | o.scrolledWin.SetMinContentHeight(-1) | ||
| 2328 | o.scrolledWin.SetMaxContentHeight(0) | 2510 | o.scrolledWin.SetMaxContentHeight(0) | ||
| 2329 | } | 2511 | } | ||
| 2330 | 2512 | ||||
| 2331 | // Calculate dimensions using shared helper | 2513 | // Calculate dimensions using shared helper | ||
| 2332 | - | width, marginTop := CalculateModalDimensions(o.parentOverlay, OmniboxSizeDefaults) | 2514 | + | parentWidth, parentHeight := 0, 0 |
| 2515 | + | if o.parentOverlay != nil { | |||
| 2516 | + | parentWidth = o.parentOverlay.GetAllocatedWidth() | |||
| 2517 | + | parentHeight = o.parentOverlay.GetAllocatedHeight() | |||
| 2518 | + | } | |||
| 2519 | + | width, marginTop := CalculateModalDimensions(o.parentOverlay, o.sizeCfg) | |||
| 2520 | + | log.Debug(). | |||
| 2521 | + | Int("parentWidth", parentWidth). | |||
| 2522 | + | Int("parentHeight", parentHeight). | |||
| 2523 | + | Int("width", width). | |||
| 2524 | + | Int("marginTop", marginTop). | |||
| 2525 | + | Int("fixedWidth", o.sizeCfg.FixedWidth). | |||
| 2526 | + | Bool("useFixedTopMargin", o.sizeCfg.UseFixedTopMargin). | |||
| 2527 | + | Int("fixedTopMargin", o.sizeCfg.FixedTopMargin). | |||
| 2528 | + | Float64("widthPct", o.sizeCfg.WidthPct). | |||
| 2529 | + | Float64("topMarginPct", o.sizeCfg.TopMarginPct). | |||
| 2530 | + | Msg("omnibox geometry decision") | |||
| 2333 | 2531 | ||||
| 2334 | o.mainBox.SetSizeRequest(width, -1) | 2532 | o.mainBox.SetSizeRequest(width, -1) | ||
| 2335 | o.outerBox.SetMarginTop(marginTop) | 2533 | o.outerBox.SetMarginTop(marginTop) | ||
A internal/ui/component/omnibox_favorite_icon_test.go +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestOmniboxFavoriteStarSize(t *testing.T) { | |
| 10 | + | assert.Equal(t, 18, favoriteStarSize(1.0)) | |
| 11 | + | assert.Equal(t, 27, favoriteStarSize(1.5)) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestOmniboxFavoriteStarVisibility(t *testing.T) { | |
| 15 | + | assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true})) | |
| 16 | + | assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false})) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestShouldApplyEmptyResultsState(t *testing.T) { | |
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | rowCount int | |
| 23 | + | visible bool | |
| 24 | + | want bool | |
| 25 | + | }{ | |
| 26 | + | {name: "negative rows uses empty state", rowCount: -1, visible: false, want: true}, | |
| 27 | + | {name: "zero rows uses empty state", rowCount: 0, visible: false, want: true}, | |
| 28 | + | {name: "positive rows clears empty state", rowCount: 2, visible: true, want: false}, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | assert.Equal(t, tt.want, shouldApplyEmptyResultsState(tt.rowCount, tt.visible)) | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestResolveFavoriteRowIndicatorUpdate(t *testing.T) { | |
| 39 | + | tests := []struct { | |
| 40 | + | name string | |
| 41 | + | mode ViewMode | |
| 42 | + | index int | |
| 43 | + | expectedURL string | |
| 44 | + | suggestions []Suggestion | |
| 45 | + | isFavorite bool | |
| 46 | + | want favoriteRowIndicatorUpdate | |
| 47 | + | }{ | |
| 48 | + | { | |
| 49 | + | name: "matching history row keeps update and shows star", | |
| 50 | + | mode: ViewModeHistory, | |
| 51 | + | index: 0, | |
| 52 | + | expectedURL: "https://example.com", | |
| 53 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |
| 54 | + | isFavorite: true, | |
| 55 | + | want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: true}, | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "matching history row keeps update and hides star when removed", | |
| 59 | + | mode: ViewModeHistory, | |
| 60 | + | index: 0, | |
| 61 | + | expectedURL: "https://example.com", | |
| 62 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |
| 63 | + | isFavorite: false, | |
| 64 | + | want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: false}, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "mismatched URL skips stale row update", | |
| 68 | + | mode: ViewModeHistory, | |
| 69 | + | index: 0, | |
| 70 | + | expectedURL: "https://expected.example.com", | |
| 71 | + | suggestions: []Suggestion{{URL: "https://current.example.com"}}, | |
| 72 | + | isFavorite: true, | |
| 73 | + | want: favoriteRowIndicatorUpdate{}, | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | name: "favorites mode never applies history row update", | |
| 77 | + | mode: ViewModeFavorites, | |
| 78 | + | index: 0, | |
| 79 | + | expectedURL: "https://example.com", | |
| 80 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |
| 81 | + | isFavorite: true, | |
| 82 | + | want: favoriteRowIndicatorUpdate{}, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "out of range index skips update", | |
| 86 | + | mode: ViewModeHistory, | |
| 87 | + | index: 1, | |
| 88 | + | expectedURL: "https://example.com", | |
| 89 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |
| 90 | + | isFavorite: true, | |
| 91 | + | want: favoriteRowIndicatorUpdate{}, | |
| 92 | + | }, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | for _, tt := range tests { | |
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |
| 97 | + | got := resolveFavoriteRowIndicatorUpdate(tt.mode, tt.index, tt.expectedURL, tt.suggestions, tt.isFavorite) | |
| 98 | + | assert.Equal(t, tt.want, got) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestOmniboxFavoriteStarSize(t *testing.T) { | |||
| 10 | + | assert.Equal(t, 18, favoriteStarSize(1.0)) | |||
| 11 | + | assert.Equal(t, 27, favoriteStarSize(1.5)) | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestOmniboxFavoriteStarVisibility(t *testing.T) { | |||
| 15 | + | assert.True(t, shouldShowFavoriteStar(Suggestion{IsFavorite: true})) | |||
| 16 | + | assert.False(t, shouldShowFavoriteStar(Suggestion{IsFavorite: false})) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestShouldApplyEmptyResultsState(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | rowCount int | |||
| 23 | + | visible bool | |||
| 24 | + | want bool | |||
| 25 | + | }{ | |||
| 26 | + | {name: "negative rows uses empty state", rowCount: -1, visible: false, want: true}, | |||
| 27 | + | {name: "zero rows uses empty state", rowCount: 0, visible: false, want: true}, | |||
| 28 | + | {name: "positive rows clears empty state", rowCount: 2, visible: true, want: false}, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | + | assert.Equal(t, tt.want, shouldApplyEmptyResultsState(tt.rowCount, tt.visible)) | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestResolveFavoriteRowIndicatorUpdate(t *testing.T) { | |||
| 39 | + | tests := []struct { | |||
| 40 | + | name string | |||
| 41 | + | mode ViewMode | |||
| 42 | + | index int | |||
| 43 | + | expectedURL string | |||
| 44 | + | suggestions []Suggestion | |||
| 45 | + | isFavorite bool | |||
| 46 | + | want favoriteRowIndicatorUpdate | |||
| 47 | + | }{ | |||
| 48 | + | { | |||
| 49 | + | name: "matching history row keeps update and shows star", | |||
| 50 | + | mode: ViewModeHistory, | |||
| 51 | + | index: 0, | |||
| 52 | + | expectedURL: "https://example.com", | |||
| 53 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |||
| 54 | + | isFavorite: true, | |||
| 55 | + | want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: true}, | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "matching history row keeps update and hides star when removed", | |||
| 59 | + | mode: ViewModeHistory, | |||
| 60 | + | index: 0, | |||
| 61 | + | expectedURL: "https://example.com", | |||
| 62 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |||
| 63 | + | isFavorite: false, | |||
| 64 | + | want: favoriteRowIndicatorUpdate{Apply: true, ShowStarSlot: false}, | |||
| 65 | + | }, | |||
| 66 | + | { | |||
| 67 | + | name: "mismatched URL skips stale row update", | |||
| 68 | + | mode: ViewModeHistory, | |||
| 69 | + | index: 0, | |||
| 70 | + | expectedURL: "https://expected.example.com", | |||
| 71 | + | suggestions: []Suggestion{{URL: "https://current.example.com"}}, | |||
| 72 | + | isFavorite: true, | |||
| 73 | + | want: favoriteRowIndicatorUpdate{}, | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | name: "favorites mode never applies history row update", | |||
| 77 | + | mode: ViewModeFavorites, | |||
| 78 | + | index: 0, | |||
| 79 | + | expectedURL: "https://example.com", | |||
| 80 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |||
| 81 | + | isFavorite: true, | |||
| 82 | + | want: favoriteRowIndicatorUpdate{}, | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "out of range index skips update", | |||
| 86 | + | mode: ViewModeHistory, | |||
| 87 | + | index: 1, | |||
| 88 | + | expectedURL: "https://example.com", | |||
| 89 | + | suggestions: []Suggestion{{URL: "https://example.com"}}, | |||
| 90 | + | isFavorite: true, | |||
| 91 | + | want: favoriteRowIndicatorUpdate{}, | |||
| 92 | + | }, | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | for _, tt := range tests { | |||
| 96 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 97 | + | got := resolveFavoriteRowIndicatorUpdate(tt.mode, tt.index, tt.expectedURL, tt.suggestions, tt.isFavorite) | |||
| 98 | + | assert.Equal(t, tt.want, got) | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
M internal/ui/component/omnibox_navigation_state_test.go +63 -8
| @@ -87,6 +87,38 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||
| 87 | 87 | } | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | func TestResultsContainerState(t *testing.T) { | |
| 91 | + | tests := []struct { | |
| 92 | + | name string | |
| 93 | + | rowCount int | |
| 94 | + | wantVisible bool | |
| 95 | + | wantExpand bool | |
| 96 | + | wantListVisible bool | |
| 97 | + | }{ | |
| 98 | + | {name: "negative rows stays hidden", rowCount: -1, wantVisible: false, wantExpand: false, wantListVisible: false}, | |
| 99 | + | {name: "no rows stays hidden", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false}, | |
| 100 | + | {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true}, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | for _, tt := range tests { | |
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |
| 105 | + | gotVisible, gotExpand, gotListVisible := resultsContainerState(tt.rowCount) | |
| 106 | + | if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible { | |
| 107 | + | t.Fatalf( | |
| 108 | + | "resultsContainerState(%d) = (%v, %v, %v), want (%v, %v, %v)", | |
| 109 | + | tt.rowCount, | |
| 110 | + | gotVisible, | |
| 111 | + | gotExpand, | |
| 112 | + | gotListVisible, | |
| 113 | + | tt.wantVisible, | |
| 114 | + | tt.wantExpand, | |
| 115 | + | tt.wantListVisible, | |
| 116 | + | ) | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 90 | 122 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | |
| 91 | 123 | entryText := "github.com/bnema/dumber" | |
| 92 | 124 | realInput := "upl" | |
| @@ -101,25 +133,30 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||
| 101 | 133 | suggestions := []Suggestion{ | |
| 102 | 134 | {URL: "https://github.com/bnema/dumber"}, | |
| 103 | 135 | {URL: "https://github.com/bnema/dumber/pulls"}, | |
| 136 | + | {URL: "https://github.com/bnema/dumber/issues"}, | |
| 104 | 137 | } | |
| 105 | 138 | favorites := []Favorite{ | |
| 106 | 139 | {URL: "https://dumber.bnema.dev"}, | |
| 140 | + | {URL: "https://docs.dumber.bnema.dev"}, | |
| 107 | 141 | } | |
| 108 | 142 | ||
| 109 | 143 | tests := []struct { | |
| 110 | 144 | name string | |
| 111 | 145 | mode ViewMode | |
| 112 | 146 | index int | |
| 147 | + | limit int | |
| 113 | 148 | wantURL string | |
| 114 | 149 | }{ | |
| 115 | - | {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"}, | |
| 116 | - | {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"}, | |
| 117 | - | {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""}, | |
| 150 | + | {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"}, | |
| 151 | + | {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev"}, | |
| 152 | + | {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""}, | |
| 153 | + | {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""}, | |
| 154 | + | {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""}, | |
| 118 | 155 | } | |
| 119 | 156 | ||
| 120 | 157 | for _, tt := range tests { | |
| 121 | 158 | t.Run(tt.name, func(t *testing.T) { | |
| 122 | - | got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites) | |
| 159 | + | got := resolveTargetURLForSelection(tt.mode, tt.index, tt.limit, suggestions, favorites) | |
| 123 | 160 | if got != tt.wantURL { | |
| 124 | 161 | t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL) | |
| 125 | 162 | } | |
| @@ -130,26 +167,31 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||
| 130 | 167 | func TestSelectedTargetURL(t *testing.T) { | |
| 131 | 168 | suggestions := []Suggestion{ | |
| 132 | 169 | {URL: "https://github.com/bnema/dumber"}, | |
| 170 | + | {URL: "https://github.com/bnema/dumber/pulls"}, | |
| 133 | 171 | } | |
| 134 | 172 | favorites := []Favorite{ | |
| 135 | 173 | {URL: "https://dumber.bnema.dev"}, | |
| 174 | + | {URL: "https://docs.dumber.bnema.dev"}, | |
| 136 | 175 | } | |
| 137 | 176 | ||
| 138 | 177 | tests := []struct { | |
| 139 | 178 | name string | |
| 140 | 179 | mode ViewMode | |
| 141 | 180 | index int | |
| 181 | + | limit int | |
| 142 | 182 | wantURL string | |
| 143 | 183 | wantBool bool | |
| 144 | 184 | }{ | |
| 145 | - | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false}, | |
| 146 | - | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true}, | |
| 147 | - | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true}, | |
| 185 | + | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false}, | |
| 186 | + | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true}, | |
| 187 | + | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev", wantBool: true}, | |
| 188 | + | {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true}, | |
| 189 | + | {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true}, | |
| 148 | 190 | } | |
| 149 | 191 | ||
| 150 | 192 | for _, tt := range tests { | |
| 151 | 193 | t.Run(tt.name, func(t *testing.T) { | |
| 152 | - | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites) | |
| 194 | + | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, tt.limit, suggestions, favorites) | |
| 153 | 195 | if gotURL != tt.wantURL || gotBool != tt.wantBool { | |
| 154 | 196 | t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool) | |
| 155 | 197 | } | |
| @@ -204,6 +246,7 @@ func TestVisibleGhostSuggestion(t *testing.T) { | |||
| 204 | 246 | tt.selectedURL, | |
| 205 | 247 | tt.hasExplicitSelection, | |
| 206 | 248 | ViewModeHistory, | |
| 249 | + | 10, | |
| 207 | 250 | suggestions, | |
| 208 | 251 | nil, | |
| 209 | 252 | ) | |
| @@ -223,6 +266,18 @@ func TestVisibleGhostSuggestion(t *testing.T) { | |||
| 223 | 266 | } | |
| 224 | 267 | } | |
| 225 | 268 | ||
| 269 | + | func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) { | |
| 270 | + | suggestions := []Suggestion{ | |
| 271 | + | {URL: "https://github.com/bnema/dumber"}, | |
| 272 | + | {URL: "https://gitlab.com/team/project"}, | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | gotFull, gotSuffix, gotOK := visibleGhostSuggestion("gitl", "", false, ViewModeHistory, 1, suggestions, nil) | |
| 276 | + | if gotFull != "" || gotSuffix != "" || gotOK { | |
| 277 | + | t.Fatalf("visibleGhostSuggestion should ignore hidden suggestions, got (%q, %q, %v)", gotFull, gotSuffix, gotOK) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 226 | 281 | func TestShouldPromoteHoverSelection(t *testing.T) { | |
| 227 | 282 | tests := []struct { | |
| 228 | 283 | name string | |
| @@ -87,6 +87,38 @@ func TestEffectiveSearchQuery(t *testing.T) { | |||||
| 87 | } | 87 | } | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | func TestResultsContainerState(t *testing.T) { | |||
| 91 | + | tests := []struct { | |||
| 92 | + | name string | |||
| 93 | + | rowCount int | |||
| 94 | + | wantVisible bool | |||
| 95 | + | wantExpand bool | |||
| 96 | + | wantListVisible bool | |||
| 97 | + | }{ | |||
| 98 | + | {name: "negative rows stays hidden", rowCount: -1, wantVisible: false, wantExpand: false, wantListVisible: false}, | |||
| 99 | + | {name: "no rows stays hidden", rowCount: 0, wantVisible: false, wantExpand: false, wantListVisible: false}, | |||
| 100 | + | {name: "rows show and expand", rowCount: 3, wantVisible: true, wantExpand: true, wantListVisible: true}, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | for _, tt := range tests { | |||
| 104 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 105 | + | gotVisible, gotExpand, gotListVisible := resultsContainerState(tt.rowCount) | |||
| 106 | + | if gotVisible != tt.wantVisible || gotExpand != tt.wantExpand || gotListVisible != tt.wantListVisible { | |||
| 107 | + | t.Fatalf( | |||
| 108 | + | "resultsContainerState(%d) = (%v, %v, %v), want (%v, %v, %v)", | |||
| 109 | + | tt.rowCount, | |||
| 110 | + | gotVisible, | |||
| 111 | + | gotExpand, | |||
| 112 | + | gotListVisible, | |||
| 113 | + | tt.wantVisible, | |||
| 114 | + | tt.wantExpand, | |||
| 115 | + | tt.wantListVisible, | |||
| 116 | + | ) | |||
| 117 | + | } | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 90 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | 122 | func TestUpdateGhostFromSelectionUsesRealInputWhenGhostVisible(t *testing.T) { | ||
| 91 | entryText := "github.com/bnema/dumber" | 123 | entryText := "github.com/bnema/dumber" | ||
| 92 | realInput := "upl" | 124 | realInput := "upl" | ||
| @@ -101,25 +133,30 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||||
| 101 | suggestions := []Suggestion{ | 133 | suggestions := []Suggestion{ | ||
| 102 | {URL: "https://github.com/bnema/dumber"}, | 134 | {URL: "https://github.com/bnema/dumber"}, | ||
| 103 | {URL: "https://github.com/bnema/dumber/pulls"}, | 135 | {URL: "https://github.com/bnema/dumber/pulls"}, | ||
| 136 | + | {URL: "https://github.com/bnema/dumber/issues"}, | |||
| 104 | } | 137 | } | ||
| 105 | favorites := []Favorite{ | 138 | favorites := []Favorite{ | ||
| 106 | {URL: "https://dumber.bnema.dev"}, | 139 | {URL: "https://dumber.bnema.dev"}, | ||
| 140 | + | {URL: "https://docs.dumber.bnema.dev"}, | |||
| 107 | } | 141 | } | ||
| 108 | 142 | ||||
| 109 | tests := []struct { | 143 | tests := []struct { | ||
| 110 | name string | 144 | name string | ||
| 111 | mode ViewMode | 145 | mode ViewMode | ||
| 112 | index int | 146 | index int | ||
| 147 | + | limit int | |||
| 113 | wantURL string | 148 | wantURL string | ||
| 114 | }{ | 149 | }{ | ||
| 115 | - | {name: "history index", mode: ViewModeHistory, index: 1, wantURL: "https://github.com/bnema/dumber/pulls"}, | 150 | + | {name: "history index", mode: ViewModeHistory, index: 1, limit: 10, wantURL: "https://github.com/bnema/dumber/pulls"}, |
| 116 | - | {name: "favorites index", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev"}, | 151 | + | {name: "favorites index", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev"}, |
| 117 | - | {name: "invalid index", mode: ViewModeHistory, index: 99, wantURL: ""}, | 152 | + | {name: "invalid index", mode: ViewModeHistory, index: 99, limit: 10, wantURL: ""}, |
| 153 | + | {name: "history index beyond visible limit is hidden", mode: ViewModeHistory, index: 2, limit: 2, wantURL: ""}, | |||
| 154 | + | {name: "favorites index beyond visible limit is hidden", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: ""}, | |||
| 118 | } | 155 | } | ||
| 119 | 156 | ||||
| 120 | for _, tt := range tests { | 157 | for _, tt := range tests { | ||
| 121 | t.Run(tt.name, func(t *testing.T) { | 158 | t.Run(tt.name, func(t *testing.T) { | ||
| 122 | - | got := resolveTargetURLForSelection(tt.mode, tt.index, suggestions, favorites) | 159 | + | got := resolveTargetURLForSelection(tt.mode, tt.index, tt.limit, suggestions, favorites) |
| 123 | if got != tt.wantURL { | 160 | if got != tt.wantURL { | ||
| 124 | t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL) | 161 | t.Fatalf("resolveTargetURLForSelection(%s, %d) = %q, want %q", tt.mode, tt.index, got, tt.wantURL) | ||
| 125 | } | 162 | } | ||
| @@ -130,26 +167,31 @@ func TestResolveTargetURLForSelection(t *testing.T) { | |||||
| 130 | func TestSelectedTargetURL(t *testing.T) { | 167 | func TestSelectedTargetURL(t *testing.T) { | ||
| 131 | suggestions := []Suggestion{ | 168 | suggestions := []Suggestion{ | ||
| 132 | {URL: "https://github.com/bnema/dumber"}, | 169 | {URL: "https://github.com/bnema/dumber"}, | ||
| 170 | + | {URL: "https://github.com/bnema/dumber/pulls"}, | |||
| 133 | } | 171 | } | ||
| 134 | favorites := []Favorite{ | 172 | favorites := []Favorite{ | ||
| 135 | {URL: "https://dumber.bnema.dev"}, | 173 | {URL: "https://dumber.bnema.dev"}, | ||
| 174 | + | {URL: "https://docs.dumber.bnema.dev"}, | |||
| 136 | } | 175 | } | ||
| 137 | 176 | ||||
| 138 | tests := []struct { | 177 | tests := []struct { | ||
| 139 | name string | 178 | name string | ||
| 140 | mode ViewMode | 179 | mode ViewMode | ||
| 141 | index int | 180 | index int | ||
| 181 | + | limit int | |||
| 142 | wantURL string | 182 | wantURL string | ||
| 143 | wantBool bool | 183 | wantBool bool | ||
| 144 | }{ | 184 | }{ | ||
| 145 | - | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, wantURL: "", wantBool: false}, | 185 | + | {name: "negative index is not explicit selection", mode: ViewModeHistory, index: -1, limit: 10, wantURL: "", wantBool: false}, |
| 146 | - | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, wantURL: "https://github.com/bnema/dumber", wantBool: true}, | 186 | + | {name: "history selection is explicit", mode: ViewModeHistory, index: 0, limit: 10, wantURL: "https://github.com/bnema/dumber", wantBool: true}, |
| 147 | - | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, wantURL: "https://dumber.bnema.dev", wantBool: true}, | 187 | + | {name: "favorites selection is explicit", mode: ViewModeFavorites, index: 0, limit: 10, wantURL: "https://dumber.bnema.dev", wantBool: true}, |
| 188 | + | {name: "hidden history selection is ignored", mode: ViewModeHistory, index: 1, limit: 1, wantURL: "", wantBool: true}, | |||
| 189 | + | {name: "hidden favorites selection is ignored", mode: ViewModeFavorites, index: 1, limit: 1, wantURL: "", wantBool: true}, | |||
| 148 | } | 190 | } | ||
| 149 | 191 | ||||
| 150 | for _, tt := range tests { | 192 | for _, tt := range tests { | ||
| 151 | t.Run(tt.name, func(t *testing.T) { | 193 | t.Run(tt.name, func(t *testing.T) { | ||
| 152 | - | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, suggestions, favorites) | 194 | + | gotURL, gotBool := selectedTargetURL(tt.mode, tt.index, tt.limit, suggestions, favorites) |
| 153 | if gotURL != tt.wantURL || gotBool != tt.wantBool { | 195 | if gotURL != tt.wantURL || gotBool != tt.wantBool { | ||
| 154 | t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool) | 196 | t.Fatalf("selectedTargetURL(%s, %d) = (%q, %v), want (%q, %v)", tt.mode, tt.index, gotURL, gotBool, tt.wantURL, tt.wantBool) | ||
| 155 | } | 197 | } | ||
| @@ -204,6 +246,7 @@ func TestVisibleGhostSuggestion(t *testing.T) { | |||||
| 204 | tt.selectedURL, | 246 | tt.selectedURL, | ||
| 205 | tt.hasExplicitSelection, | 247 | tt.hasExplicitSelection, | ||
| 206 | ViewModeHistory, | 248 | ViewModeHistory, | ||
| 249 | + | 10, | |||
| 207 | suggestions, | 250 | suggestions, | ||
| 208 | nil, | 251 | nil, | ||
| 209 | ) | 252 | ) | ||
| @@ -223,6 +266,18 @@ func TestVisibleGhostSuggestion(t *testing.T) { | |||||
| 223 | } | 266 | } | ||
| 224 | } | 267 | } | ||
| 225 | 268 | ||||
| 269 | + | func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) { | |||
| 270 | + | suggestions := []Suggestion{ | |||
| 271 | + | {URL: "https://github.com/bnema/dumber"}, | |||
| 272 | + | {URL: "https://gitlab.com/team/project"}, | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | gotFull, gotSuffix, gotOK := visibleGhostSuggestion("gitl", "", false, ViewModeHistory, 1, suggestions, nil) | |||
| 276 | + | if gotFull != "" || gotSuffix != "" || gotOK { | |||
| 277 | + | t.Fatalf("visibleGhostSuggestion should ignore hidden suggestions, got (%q, %q, %v)", gotFull, gotSuffix, gotOK) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | ||||
| 226 | func TestShouldPromoteHoverSelection(t *testing.T) { | 281 | func TestShouldPromoteHoverSelection(t *testing.T) { | ||
| 227 | tests := []struct { | 282 | tests := []struct { | ||
| 228 | name string | 283 | name string | ||
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/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 +368 -0
| @@ -0,0 +1,368 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |
| 12 | + | "github.com/bnema/puregotk/v4/adw" | |
| 13 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | + | "github.com/bnema/puregotk/v4/gio" | |
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 17 | + | "github.com/bnema/puregotk/v4/layershell" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var layerShellLibraryAvailable = func() bool { | |
| 21 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) { | |
| 22 | + | return true | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | return bootstrap.LayerShellLibraryPath() != "" | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | standaloneOmniboxNamespace = AppID + ".omnibox" | |
| 30 | + | standaloneOmniboxTitle = "Dumber Omnibox" | |
| 31 | + | standaloneOmniboxWidth = 1200 | |
| 32 | + | standaloneOmniboxHeight = 200 | |
| 33 | + | standaloneOmniboxWindowCSS = "standalone-omnibox-window" | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | type StandaloneOmniboxWindowConfig struct { | |
| 37 | + | Namespace string | |
| 38 | + | Title string | |
| 39 | + | Width int | |
| 40 | + | Height int | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type StandaloneOmniboxRuntime struct { | |
| 44 | + | OmniboxCfg component.OmniboxConfig | |
| 45 | + | ApplyTheme func(display *gdk.Display) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type standaloneOmniboxHostLayoutConfig struct { | |
| 49 | + | UseDefaultSize bool | |
| 50 | + | OmniboxSize component.ModalSizeConfig | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | var ( | |
| 54 | + | layerShellIsSupported = layershell.IsSupported | |
| 55 | + | layerShellInitForWindow = func(window *gtk.ApplicationWindow) { | |
| 56 | + | layershell.InitForWindow(&window.Window) | |
| 57 | + | } | |
| 58 | + | layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) { | |
| 59 | + | layershell.SetLayer(&window.Window, layer) | |
| 60 | + | } | |
| 61 | + | layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) { | |
| 62 | + | layershell.SetExclusiveZone(&window.Window, zone) | |
| 63 | + | } | |
| 64 | + | layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) { | |
| 65 | + | layershell.SetNamespace(&window.Window, namespace) | |
| 66 | + | } | |
| 67 | + | layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |
| 68 | + | layershell.SetKeyboardMode(&window.Window, mode) | |
| 69 | + | } | |
| 70 | + | buildStandaloneOmniboxHostFn = buildStandaloneOmniboxHost | |
| 71 | + | newStandaloneOmniboxWindow = gtk.NewApplicationWindow | |
| 72 | + | configureStandaloneOmniboxWindowFn = configureStandaloneOmniboxWindow | |
| 73 | + | presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) { | |
| 74 | + | window.Present() | |
| 75 | + | } | |
| 76 | + | logStandaloneOmniboxHostAllocationFn = logStandaloneOmniboxHostAllocation | |
| 77 | + | showStandaloneOmniboxFn = func(ctx context.Context, omnibox *component.Omnibox) { | |
| 78 | + | omnibox.Show(ctx, "") | |
| 79 | + | } | |
| 80 | + | ) | |
| 81 | + | ||
| 82 | + | type standaloneOmniboxActivationRetention struct { | |
| 83 | + | window *gtk.ApplicationWindow | |
| 84 | + | host *gtk.Widget | |
| 85 | + | omnibox *component.Omnibox | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (r *standaloneOmniboxActivationRetention) retain( | |
| 89 | + | window *gtk.ApplicationWindow, | |
| 90 | + | host *gtk.Widget, | |
| 91 | + | omnibox *component.Omnibox, | |
| 92 | + | ) { | |
| 93 | + | if r == nil { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | r.window = window | |
| 97 | + | r.host = host | |
| 98 | + | r.omnibox = omnibox | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (r *standaloneOmniboxActivationRetention) release() { | |
| 102 | + | if r == nil { | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | r.window = nil | |
| 106 | + | r.host = nil | |
| 107 | + | r.omnibox = nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig { | |
| 111 | + | return StandaloneOmniboxWindowConfig{ | |
| 112 | + | Namespace: standaloneOmniboxNamespace, | |
| 113 | + | Title: standaloneOmniboxTitle, | |
| 114 | + | Width: standaloneOmniboxWidth, | |
| 115 | + | Height: standaloneOmniboxHeight, | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func standaloneOmniboxArgv(args []string) []string { | |
| 120 | + | if len(args) == 0 { | |
| 121 | + | return []string{"dumber-omnibox"} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | filtered := []string{args[0]} | |
| 125 | + | subcommandSkipped := false | |
| 126 | + | for i := 1; i < len(args); i++ { | |
| 127 | + | arg := args[i] | |
| 128 | + | if arg == "omnibox" && !subcommandSkipped { | |
| 129 | + | subcommandSkipped = true | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | if strings.HasPrefix(arg, "-test.") { | |
| 133 | + | if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) { | |
| 134 | + | i++ | |
| 135 | + | } | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | filtered = append(filtered, arg) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | return filtered | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func goTestFlagConsumesNextArg(arg string) bool { | |
| 145 | + | switch arg { | |
| 146 | + | case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile", | |
| 147 | + | "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir", | |
| 148 | + | "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile", | |
| 149 | + | "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction", | |
| 150 | + | "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle", | |
| 151 | + | "-test.skip", "-test.timeout", "-test.trace": | |
| 152 | + | return true | |
| 153 | + | default: | |
| 154 | + | return false | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) { | |
| 159 | + | return func(url string) { | |
| 160 | + | if original != nil { | |
| 161 | + | original(url) | |
| 162 | + | } | |
| 163 | + | if quit != nil { | |
| 164 | + | quit() | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func standaloneOmniboxCloseHandler(quit func()) func() { | |
| 170 | + | return func() { | |
| 171 | + | if quit != nil { | |
| 172 | + | quit() | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool { | |
| 178 | + | return !layerShellApplied | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func standaloneOmniboxWindowResizable(layerShellApplied bool) bool { | |
| 182 | + | return !layerShellApplied | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig { | |
| 186 | + | return standaloneOmniboxHostLayoutConfig{ | |
| 187 | + | UseDefaultSize: !layerShellApplied, | |
| 188 | + | OmniboxSize: standaloneOmniboxSizeConfig(), | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) { | |
| 193 | + | if host == nil { | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | host.SetChild(omniboxWidget) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func standaloneOmniboxSizeConfig() component.ModalSizeConfig { | |
| 201 | + | return component.ResolveModalSizeConfig(component.ModalSizeConfig{ | |
| 202 | + | FixedWidth: component.OmniboxSizeDefaults.MaxWidth, | |
| 203 | + | FixedTopMargin: 0, | |
| 204 | + | UseFixedTopMargin: true, | |
| 205 | + | }, component.OmniboxSizeDefaults) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool { | |
| 209 | + | if window == nil { | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | if !safeLayerShellSupported() { | |
| 213 | + | return false | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | layerShellInitForWindow(window) | |
| 217 | + | layerShellSetLayer(window, layershell.LayerOverlayValue) | |
| 218 | + | layerShellSetExclusiveZone(window, 0) | |
| 219 | + | layerShellSetNamespace(window, &cfg.Namespace) | |
| 220 | + | layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue) | |
| 221 | + | return true | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func safeLayerShellSupported() (supported bool) { | |
| 225 | + | defer func() { | |
| 226 | + | if recover() != nil { | |
| 227 | + | supported = false | |
| 228 | + | } | |
| 229 | + | }() | |
| 230 | + | ||
| 231 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() { | |
| 232 | + | return layerShellIsSupported() | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | return false | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |
| 239 | + | if window == nil { | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | title := windowCfg.Title | |
| 244 | + | window.SetTitle(&title) | |
| 245 | + | layerShellApplied := tryInitLayerShell(window, windowCfg) | |
| 246 | + | hostLayout := standaloneOmniboxHostLayout(layerShellApplied) | |
| 247 | + | if hostLayout.UseDefaultSize { | |
| 248 | + | window.SetDefaultSize(windowCfg.Width, windowCfg.Height) | |
| 249 | + | } | |
| 250 | + | if layerShellApplied { | |
| 251 | + | window.AddCssClass(standaloneOmniboxWindowCSS) | |
| 252 | + | } | |
| 253 | + | window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied)) | |
| 254 | + | window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied)) | |
| 255 | + | window.SetChild(child) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func buildStandaloneOmniboxHost( | |
| 259 | + | ctx context.Context, | |
| 260 | + | runtimeCfg *StandaloneOmniboxRuntime, | |
| 261 | + | gtkApp *gtk.Application, | |
| 262 | + | ) (*gtk.Widget, *component.Omnibox) { | |
| 263 | + | factory := layout.NewGtkWidgetFactory() | |
| 264 | + | overlay := factory.NewOverlay() | |
| 265 | + | if overlay == nil { | |
| 266 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets") | |
| 267 | + | return nil, nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | cfg := runtimeCfg.OmniboxCfg | |
| 271 | + | cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit) | |
| 272 | + | cfg.SizeConfig = standaloneOmniboxSizeConfig() | |
| 273 | + | ||
| 274 | + | omnibox := component.NewOmnibox(ctx, cfg) | |
| 275 | + | if omnibox == nil { | |
| 276 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox") | |
| 277 | + | return nil, nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | overlay.SetVisible(true) | |
| 281 | + | omnibox.SetParentOverlay(overlay) | |
| 282 | + | omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit)) | |
| 283 | + | ||
| 284 | + | omniboxWidget := omnibox.WidgetAsLayout(factory) | |
| 285 | + | if omniboxWidget == nil { | |
| 286 | + | logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget") | |
| 287 | + | return nil, nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | attachStandaloneOmniboxHostWidget(overlay, omniboxWidget) | |
| 291 | + | logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child") | |
| 292 | + | return overlay.GtkWidget(), omnibox | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) { | |
| 296 | + | if host == nil { | |
| 297 | + | return | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 301 | + | logging.FromContext(ctx).Debug(). | |
| 302 | + | Int("width", host.GetAllocatedWidth()). | |
| 303 | + | Int("height", host.GetAllocatedHeight()). | |
| 304 | + | Msg("standalone omnibox host allocation") | |
| 305 | + | return false | |
| 306 | + | } | |
| 307 | + | glib.IdleAdd(&cb, 0) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func activateStandaloneOmnibox( | |
| 311 | + | ctx context.Context, | |
| 312 | + | runtimeCfg *StandaloneOmniboxRuntime, | |
| 313 | + | gtkApp *gtk.Application, | |
| 314 | + | windowCfg StandaloneOmniboxWindowConfig, | |
| 315 | + | retention *standaloneOmniboxActivationRetention, | |
| 316 | + | ) { | |
| 317 | + | child, omnibox := buildStandaloneOmniboxHostFn(ctx, runtimeCfg, gtkApp) | |
| 318 | + | if child == nil || omnibox == nil { | |
| 319 | + | gtkApp.Quit() | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | window := newStandaloneOmniboxWindow(gtkApp) | |
| 324 | + | if window == nil { | |
| 325 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window") | |
| 326 | + | gtkApp.Quit() | |
| 327 | + | return | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | retention.retain(window, child, omnibox) | |
| 331 | + | configureStandaloneOmniboxWindowFn(window, windowCfg, child) | |
| 332 | + | if runtimeCfg.ApplyTheme != nil { | |
| 333 | + | if display := window.GetDisplay(); display != nil { | |
| 334 | + | runtimeCfg.ApplyTheme(display) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | presentStandaloneOmniboxWindow(window) | |
| 338 | + | logStandaloneOmniboxHostAllocationFn(ctx, child) | |
| 339 | + | showStandaloneOmniboxFn(ctx, omnibox) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int { | |
| 343 | + | if runtimeCfg == nil { | |
| 344 | + | logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured") | |
| 345 | + | return 1 | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | adw.Init() | |
| 349 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |
| 350 | + | appID := windowCfg.Namespace | |
| 351 | + | gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags()) | |
| 352 | + | if gtkApp == nil { | |
| 353 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application") | |
| 354 | + | return 1 | |
| 355 | + | } | |
| 356 | + | defer gtkApp.Unref() | |
| 357 | + | retention := &standaloneOmniboxActivationRetention{} | |
| 358 | + | ||
| 359 | + | activateCb := func(_ gio.Application) { | |
| 360 | + | activateStandaloneOmnibox(ctx, runtimeCfg, gtkApp, windowCfg, retention) | |
| 361 | + | } | |
| 362 | + | gtkApp.ConnectActivate(&activateCb) | |
| 363 | + | ||
| 364 | + | argv := standaloneOmniboxArgv(os.Args) | |
| 365 | + | code := gtkApp.Run(len(argv), argv) | |
| 366 | + | retention.release() | |
| 367 | + | return code | |
| 368 | + | } | |
| @@ -0,0 +1,368 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/bootstrap" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 11 | + | "github.com/bnema/dumber/internal/ui/layout" | |||
| 12 | + | "github.com/bnema/puregotk/v4/adw" | |||
| 13 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 14 | + | "github.com/bnema/puregotk/v4/gio" | |||
| 15 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 16 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 17 | + | "github.com/bnema/puregotk/v4/layershell" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | var layerShellLibraryAvailable = func() bool { | |||
| 21 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) { | |||
| 22 | + | return true | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | return bootstrap.LayerShellLibraryPath() != "" | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | const ( | |||
| 29 | + | standaloneOmniboxNamespace = AppID + ".omnibox" | |||
| 30 | + | standaloneOmniboxTitle = "Dumber Omnibox" | |||
| 31 | + | standaloneOmniboxWidth = 1200 | |||
| 32 | + | standaloneOmniboxHeight = 200 | |||
| 33 | + | standaloneOmniboxWindowCSS = "standalone-omnibox-window" | |||
| 34 | + | ) | |||
| 35 | + | ||||
| 36 | + | type StandaloneOmniboxWindowConfig struct { | |||
| 37 | + | Namespace string | |||
| 38 | + | Title string | |||
| 39 | + | Width int | |||
| 40 | + | Height int | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type StandaloneOmniboxRuntime struct { | |||
| 44 | + | OmniboxCfg component.OmniboxConfig | |||
| 45 | + | ApplyTheme func(display *gdk.Display) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type standaloneOmniboxHostLayoutConfig struct { | |||
| 49 | + | UseDefaultSize bool | |||
| 50 | + | OmniboxSize component.ModalSizeConfig | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | var ( | |||
| 54 | + | layerShellIsSupported = layershell.IsSupported | |||
| 55 | + | layerShellInitForWindow = func(window *gtk.ApplicationWindow) { | |||
| 56 | + | layershell.InitForWindow(&window.Window) | |||
| 57 | + | } | |||
| 58 | + | layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) { | |||
| 59 | + | layershell.SetLayer(&window.Window, layer) | |||
| 60 | + | } | |||
| 61 | + | layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) { | |||
| 62 | + | layershell.SetExclusiveZone(&window.Window, zone) | |||
| 63 | + | } | |||
| 64 | + | layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) { | |||
| 65 | + | layershell.SetNamespace(&window.Window, namespace) | |||
| 66 | + | } | |||
| 67 | + | layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |||
| 68 | + | layershell.SetKeyboardMode(&window.Window, mode) | |||
| 69 | + | } | |||
| 70 | + | buildStandaloneOmniboxHostFn = buildStandaloneOmniboxHost | |||
| 71 | + | newStandaloneOmniboxWindow = gtk.NewApplicationWindow | |||
| 72 | + | configureStandaloneOmniboxWindowFn = configureStandaloneOmniboxWindow | |||
| 73 | + | presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) { | |||
| 74 | + | window.Present() | |||
| 75 | + | } | |||
| 76 | + | logStandaloneOmniboxHostAllocationFn = logStandaloneOmniboxHostAllocation | |||
| 77 | + | showStandaloneOmniboxFn = func(ctx context.Context, omnibox *component.Omnibox) { | |||
| 78 | + | omnibox.Show(ctx, "") | |||
| 79 | + | } | |||
| 80 | + | ) | |||
| 81 | + | ||||
| 82 | + | type standaloneOmniboxActivationRetention struct { | |||
| 83 | + | window *gtk.ApplicationWindow | |||
| 84 | + | host *gtk.Widget | |||
| 85 | + | omnibox *component.Omnibox | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (r *standaloneOmniboxActivationRetention) retain( | |||
| 89 | + | window *gtk.ApplicationWindow, | |||
| 90 | + | host *gtk.Widget, | |||
| 91 | + | omnibox *component.Omnibox, | |||
| 92 | + | ) { | |||
| 93 | + | if r == nil { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | r.window = window | |||
| 97 | + | r.host = host | |||
| 98 | + | r.omnibox = omnibox | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (r *standaloneOmniboxActivationRetention) release() { | |||
| 102 | + | if r == nil { | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | r.window = nil | |||
| 106 | + | r.host = nil | |||
| 107 | + | r.omnibox = nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig { | |||
| 111 | + | return StandaloneOmniboxWindowConfig{ | |||
| 112 | + | Namespace: standaloneOmniboxNamespace, | |||
| 113 | + | Title: standaloneOmniboxTitle, | |||
| 114 | + | Width: standaloneOmniboxWidth, | |||
| 115 | + | Height: standaloneOmniboxHeight, | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func standaloneOmniboxArgv(args []string) []string { | |||
| 120 | + | if len(args) == 0 { | |||
| 121 | + | return []string{"dumber-omnibox"} | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | filtered := []string{args[0]} | |||
| 125 | + | subcommandSkipped := false | |||
| 126 | + | for i := 1; i < len(args); i++ { | |||
| 127 | + | arg := args[i] | |||
| 128 | + | if arg == "omnibox" && !subcommandSkipped { | |||
| 129 | + | subcommandSkipped = true | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | if strings.HasPrefix(arg, "-test.") { | |||
| 133 | + | if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) { | |||
| 134 | + | i++ | |||
| 135 | + | } | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 138 | + | filtered = append(filtered, arg) | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | return filtered | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func goTestFlagConsumesNextArg(arg string) bool { | |||
| 145 | + | switch arg { | |||
| 146 | + | case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile", | |||
| 147 | + | "-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir", | |||
| 148 | + | "-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile", | |||
| 149 | + | "-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction", | |||
| 150 | + | "-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle", | |||
| 151 | + | "-test.skip", "-test.timeout", "-test.trace": | |||
| 152 | + | return true | |||
| 153 | + | default: | |||
| 154 | + | return false | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func wrapStandaloneOmniboxNavigate(original func(string), quit func()) func(string) { | |||
| 159 | + | return func(url string) { | |||
| 160 | + | if original != nil { | |||
| 161 | + | original(url) | |||
| 162 | + | } | |||
| 163 | + | if quit != nil { | |||
| 164 | + | quit() | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func standaloneOmniboxCloseHandler(quit func()) func() { | |||
| 170 | + | return func() { | |||
| 171 | + | if quit != nil { | |||
| 172 | + | quit() | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool { | |||
| 178 | + | return !layerShellApplied | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func standaloneOmniboxWindowResizable(layerShellApplied bool) bool { | |||
| 182 | + | return !layerShellApplied | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig { | |||
| 186 | + | return standaloneOmniboxHostLayoutConfig{ | |||
| 187 | + | UseDefaultSize: !layerShellApplied, | |||
| 188 | + | OmniboxSize: standaloneOmniboxSizeConfig(), | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) { | |||
| 193 | + | if host == nil { | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | host.SetChild(omniboxWidget) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func standaloneOmniboxSizeConfig() component.ModalSizeConfig { | |||
| 201 | + | return component.ResolveModalSizeConfig(component.ModalSizeConfig{ | |||
| 202 | + | FixedWidth: component.OmniboxSizeDefaults.MaxWidth, | |||
| 203 | + | FixedTopMargin: 0, | |||
| 204 | + | UseFixedTopMargin: true, | |||
| 205 | + | }, component.OmniboxSizeDefaults) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool { | |||
| 209 | + | if window == nil { | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 212 | + | if !safeLayerShellSupported() { | |||
| 213 | + | return false | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | layerShellInitForWindow(window) | |||
| 217 | + | layerShellSetLayer(window, layershell.LayerOverlayValue) | |||
| 218 | + | layerShellSetExclusiveZone(window, 0) | |||
| 219 | + | layerShellSetNamespace(window, &cfg.Namespace) | |||
| 220 | + | layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue) | |||
| 221 | + | return true | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func safeLayerShellSupported() (supported bool) { | |||
| 225 | + | defer func() { | |||
| 226 | + | if recover() != nil { | |||
| 227 | + | supported = false | |||
| 228 | + | } | |||
| 229 | + | }() | |||
| 230 | + | ||||
| 231 | + | if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() { | |||
| 232 | + | return layerShellIsSupported() | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |||
| 239 | + | if window == nil { | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | title := windowCfg.Title | |||
| 244 | + | window.SetTitle(&title) | |||
| 245 | + | layerShellApplied := tryInitLayerShell(window, windowCfg) | |||
| 246 | + | hostLayout := standaloneOmniboxHostLayout(layerShellApplied) | |||
| 247 | + | if hostLayout.UseDefaultSize { | |||
| 248 | + | window.SetDefaultSize(windowCfg.Width, windowCfg.Height) | |||
| 249 | + | } | |||
| 250 | + | if layerShellApplied { | |||
| 251 | + | window.AddCssClass(standaloneOmniboxWindowCSS) | |||
| 252 | + | } | |||
| 253 | + | window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied)) | |||
| 254 | + | window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied)) | |||
| 255 | + | window.SetChild(child) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func buildStandaloneOmniboxHost( | |||
| 259 | + | ctx context.Context, | |||
| 260 | + | runtimeCfg *StandaloneOmniboxRuntime, | |||
| 261 | + | gtkApp *gtk.Application, | |||
| 262 | + | ) (*gtk.Widget, *component.Omnibox) { | |||
| 263 | + | factory := layout.NewGtkWidgetFactory() | |||
| 264 | + | overlay := factory.NewOverlay() | |||
| 265 | + | if overlay == nil { | |||
| 266 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets") | |||
| 267 | + | return nil, nil | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | cfg := runtimeCfg.OmniboxCfg | |||
| 271 | + | cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit) | |||
| 272 | + | cfg.SizeConfig = standaloneOmniboxSizeConfig() | |||
| 273 | + | ||||
| 274 | + | omnibox := component.NewOmnibox(ctx, cfg) | |||
| 275 | + | if omnibox == nil { | |||
| 276 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox") | |||
| 277 | + | return nil, nil | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | overlay.SetVisible(true) | |||
| 281 | + | omnibox.SetParentOverlay(overlay) | |||
| 282 | + | omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit)) | |||
| 283 | + | ||||
| 284 | + | omniboxWidget := omnibox.WidgetAsLayout(factory) | |||
| 285 | + | if omniboxWidget == nil { | |||
| 286 | + | logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget") | |||
| 287 | + | return nil, nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | attachStandaloneOmniboxHostWidget(overlay, omniboxWidget) | |||
| 291 | + | logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child") | |||
| 292 | + | return overlay.GtkWidget(), omnibox | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) { | |||
| 296 | + | if host == nil { | |||
| 297 | + | return | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 301 | + | logging.FromContext(ctx).Debug(). | |||
| 302 | + | Int("width", host.GetAllocatedWidth()). | |||
| 303 | + | Int("height", host.GetAllocatedHeight()). | |||
| 304 | + | Msg("standalone omnibox host allocation") | |||
| 305 | + | return false | |||
| 306 | + | } | |||
| 307 | + | glib.IdleAdd(&cb, 0) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func activateStandaloneOmnibox( | |||
| 311 | + | ctx context.Context, | |||
| 312 | + | runtimeCfg *StandaloneOmniboxRuntime, | |||
| 313 | + | gtkApp *gtk.Application, | |||
| 314 | + | windowCfg StandaloneOmniboxWindowConfig, | |||
| 315 | + | retention *standaloneOmniboxActivationRetention, | |||
| 316 | + | ) { | |||
| 317 | + | child, omnibox := buildStandaloneOmniboxHostFn(ctx, runtimeCfg, gtkApp) | |||
| 318 | + | if child == nil || omnibox == nil { | |||
| 319 | + | gtkApp.Quit() | |||
| 320 | + | return | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | window := newStandaloneOmniboxWindow(gtkApp) | |||
| 324 | + | if window == nil { | |||
| 325 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window") | |||
| 326 | + | gtkApp.Quit() | |||
| 327 | + | return | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | retention.retain(window, child, omnibox) | |||
| 331 | + | configureStandaloneOmniboxWindowFn(window, windowCfg, child) | |||
| 332 | + | if runtimeCfg.ApplyTheme != nil { | |||
| 333 | + | if display := window.GetDisplay(); display != nil { | |||
| 334 | + | runtimeCfg.ApplyTheme(display) | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | presentStandaloneOmniboxWindow(window) | |||
| 338 | + | logStandaloneOmniboxHostAllocationFn(ctx, child) | |||
| 339 | + | showStandaloneOmniboxFn(ctx, omnibox) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int { | |||
| 343 | + | if runtimeCfg == nil { | |||
| 344 | + | logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured") | |||
| 345 | + | return 1 | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | adw.Init() | |||
| 349 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 350 | + | appID := windowCfg.Namespace | |||
| 351 | + | gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags()) | |||
| 352 | + | if gtkApp == nil { | |||
| 353 | + | logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application") | |||
| 354 | + | return 1 | |||
| 355 | + | } | |||
| 356 | + | defer gtkApp.Unref() | |||
| 357 | + | retention := &standaloneOmniboxActivationRetention{} | |||
| 358 | + | ||||
| 359 | + | activateCb := func(_ gio.Application) { | |||
| 360 | + | activateStandaloneOmnibox(ctx, runtimeCfg, gtkApp, windowCfg, retention) | |||
| 361 | + | } | |||
| 362 | + | gtkApp.ConnectActivate(&activateCb) | |||
| 363 | + | ||||
| 364 | + | argv := standaloneOmniboxArgv(os.Args) | |||
| 365 | + | code := gtkApp.Run(len(argv), argv) | |||
| 366 | + | retention.release() | |||
| 367 | + | return code | |||
| 368 | + | } | |||
A internal/ui/standalone_omnibox_test.go +389 -0
| @@ -0,0 +1,389 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 8 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | + | "github.com/bnema/puregotk/v4/layershell" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) { | |
| 14 | + | var calls []string | |
| 15 | + | wrapped := wrapStandaloneOmniboxNavigate(func(url string) { | |
| 16 | + | calls = append(calls, "navigate:"+url) | |
| 17 | + | }, func() { | |
| 18 | + | calls = append(calls, "quit") | |
| 19 | + | }) | |
| 20 | + | ||
| 21 | + | wrapped("https://example.com") | |
| 22 | + | ||
| 23 | + | if len(calls) != 2 { | |
| 24 | + | t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls) | |
| 25 | + | } | |
| 26 | + | if calls[0] != "navigate:https://example.com" { | |
| 27 | + | t.Fatalf("expected navigate call first, got %#v", calls) | |
| 28 | + | } | |
| 29 | + | if calls[1] != "quit" { | |
| 30 | + | t.Fatalf("expected quit call second, got %#v", calls) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) { | |
| 35 | + | called := false | |
| 36 | + | wrapped := wrapStandaloneOmniboxNavigate(nil, func() { | |
| 37 | + | called = true | |
| 38 | + | }) | |
| 39 | + | ||
| 40 | + | wrapped("https://example.com") | |
| 41 | + | ||
| 42 | + | if !called { | |
| 43 | + | t.Fatalf("expected quit to be called") | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) { | |
| 48 | + | called := false | |
| 49 | + | onClose := standaloneOmniboxCloseHandler(func() { | |
| 50 | + | called = true | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | onClose() | |
| 54 | + | ||
| 55 | + | if !called { | |
| 56 | + | t.Fatalf("expected close handler to quit app") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) { | |
| 61 | + | if standaloneOmniboxWindowDecorated(true) { | |
| 62 | + | t.Fatalf("expected layer-shell window to be undecorated") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) { | |
| 67 | + | if !standaloneOmniboxWindowDecorated(false) { | |
| 68 | + | t.Fatalf("expected fallback window to remain decorated") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) { | |
| 73 | + | if standaloneOmniboxWindowResizable(true) { | |
| 74 | + | t.Fatalf("expected layer-shell window to stop keeping stale height allocations") | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) { | |
| 79 | + | if !standaloneOmniboxWindowResizable(false) { | |
| 80 | + | t.Fatalf("expected fallback window to remain resizable") | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) { | |
| 85 | + | layoutCfg := standaloneOmniboxHostLayout(true) | |
| 86 | + | ||
| 87 | + | if layoutCfg.UseDefaultSize { | |
| 88 | + | t.Fatalf("expected layer-shell host to avoid fixed default size") | |
| 89 | + | } | |
| 90 | + | if layoutCfg.OmniboxSize.FixedWidth != 800 { | |
| 91 | + | t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth) | |
| 92 | + | } | |
| 93 | + | if !layoutCfg.OmniboxSize.UseFixedTopMargin { | |
| 94 | + | t.Fatalf("expected standalone omnibox to use a fixed top margin") | |
| 95 | + | } | |
| 96 | + | if layoutCfg.OmniboxSize.FixedTopMargin != 0 { | |
| 97 | + | t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) { | |
| 102 | + | layoutCfg := standaloneOmniboxHostLayout(false) | |
| 103 | + | ||
| 104 | + | if !layoutCfg.UseDefaultSize { | |
| 105 | + | t.Fatalf("expected non-layer-shell host to keep fixed fallback size") | |
| 106 | + | } | |
| 107 | + | if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() { | |
| 108 | + | t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) { | |
| 113 | + | host := layoutmocks.NewMockOverlayWidget(t) | |
| 114 | + | child := layoutmocks.NewMockWidget(t) | |
| 115 | + | ||
| 116 | + | host.EXPECT().SetChild(child).Once() | |
| 117 | + | ||
| 118 | + | attachStandaloneOmniboxHostWidget(host, child) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) { | |
| 122 | + | cfg := standaloneOmniboxSizeConfig() | |
| 123 | + | ||
| 124 | + | if cfg != (component.ModalSizeConfig{ | |
| 125 | + | WidthPct: component.OmniboxSizeDefaults.WidthPct, | |
| 126 | + | MaxWidth: component.OmniboxSizeDefaults.MaxWidth, | |
| 127 | + | TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct, | |
| 128 | + | FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth, | |
| 129 | + | FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight, | |
| 130 | + | FixedWidth: 800, | |
| 131 | + | FixedTopMargin: 0, | |
| 132 | + | UseFixedTopMargin: true, | |
| 133 | + | }) { | |
| 134 | + | t.Fatalf("unexpected standalone omnibox size config: %#v", cfg) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestStandaloneWindowConfig_Defaults(t *testing.T) { | |
| 139 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |
| 140 | + | if cfg.Namespace == "" { | |
| 141 | + | t.Fatalf("expected namespace") | |
| 142 | + | } | |
| 143 | + | if cfg.Title == "" { | |
| 144 | + | t.Fatalf("expected title") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) { | |
| 149 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"}) | |
| 150 | + | ||
| 151 | + | if len(argv) != 2 { | |
| 152 | + | t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv) | |
| 153 | + | } | |
| 154 | + | if argv[0] != "/tmp/testbin" { | |
| 155 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |
| 156 | + | } | |
| 157 | + | if argv[1] != "--gtk-debug" { | |
| 158 | + | t.Fatalf("expected non-test arg to be preserved, got %#v", argv) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestStandaloneOmniboxArgv_RemovesOnlyLeadingOmniboxSubcommand(t *testing.T) { | |
| 163 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "omnibox", "https://example.com/omnibox", "omnibox"}) | |
| 164 | + | ||
| 165 | + | if len(argv) != 3 { | |
| 166 | + | t.Fatalf("expected 3 args, got %d: %#v", len(argv), argv) | |
| 167 | + | } | |
| 168 | + | if argv[0] != "/tmp/testbin" { | |
| 169 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |
| 170 | + | } | |
| 171 | + | if argv[1] != "https://example.com/omnibox" { | |
| 172 | + | t.Fatalf("expected non-subcommand omnibox value to be preserved, got %#v", argv) | |
| 173 | + | } | |
| 174 | + | if argv[2] != "omnibox" { | |
| 175 | + | t.Fatalf("expected later omnibox arg to be preserved, got %#v", argv) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestActivateStandaloneOmnibox_RetainsObjectsUntilReleased(t *testing.T) { | |
| 180 | + | originalBuildHost := buildStandaloneOmniboxHostFn | |
| 181 | + | originalNewWindow := newStandaloneOmniboxWindow | |
| 182 | + | originalConfigureWindow := configureStandaloneOmniboxWindowFn | |
| 183 | + | originalPresentWindow := presentStandaloneOmniboxWindow | |
| 184 | + | originalLogHostAllocation := logStandaloneOmniboxHostAllocationFn | |
| 185 | + | originalShowOmnibox := showStandaloneOmniboxFn | |
| 186 | + | t.Cleanup(func() { | |
| 187 | + | buildStandaloneOmniboxHostFn = originalBuildHost | |
| 188 | + | newStandaloneOmniboxWindow = originalNewWindow | |
| 189 | + | configureStandaloneOmniboxWindowFn = originalConfigureWindow | |
| 190 | + | presentStandaloneOmniboxWindow = originalPresentWindow | |
| 191 | + | logStandaloneOmniboxHostAllocationFn = originalLogHostAllocation | |
| 192 | + | showStandaloneOmniboxFn = originalShowOmnibox | |
| 193 | + | }) | |
| 194 | + | ||
| 195 | + | expectedHost := >k.Widget{} | |
| 196 | + | expectedOmnibox := &component.Omnibox{} | |
| 197 | + | expectedWindow := >k.ApplicationWindow{} | |
| 198 | + | refs := &standaloneOmniboxActivationRetention{} | |
| 199 | + | runtimeCfg := &StandaloneOmniboxRuntime{} | |
| 200 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |
| 201 | + | ||
| 202 | + | buildStandaloneOmniboxHostFn = func(_ context.Context, _ *StandaloneOmniboxRuntime, _ *gtk.Application) (*gtk.Widget, *component.Omnibox) { | |
| 203 | + | return expectedHost, expectedOmnibox | |
| 204 | + | } | |
| 205 | + | newStandaloneOmniboxWindow = func(_ *gtk.Application) *gtk.ApplicationWindow { | |
| 206 | + | return expectedWindow | |
| 207 | + | } | |
| 208 | + | configureStandaloneOmniboxWindowFn = func(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |
| 209 | + | if window != expectedWindow || child != expectedHost || cfg != windowCfg { | |
| 210 | + | t.Fatalf("unexpected configure arguments: window=%p child=%p cfg=%#v", window, child, cfg) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) { | |
| 214 | + | if window != expectedWindow { | |
| 215 | + | t.Fatalf("unexpected window presented: %p", window) | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | logStandaloneOmniboxHostAllocationFn = func(_ context.Context, host *gtk.Widget) { | |
| 219 | + | if host != expectedHost { | |
| 220 | + | t.Fatalf("unexpected host logged: %p", host) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | showStandaloneOmniboxFn = func(_ context.Context, omnibox *component.Omnibox) { | |
| 224 | + | if omnibox != expectedOmnibox { | |
| 225 | + | t.Fatalf("unexpected omnibox shown: %p", omnibox) | |
| 226 | + | } | |
| 227 | + | if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox { | |
| 228 | + | t.Fatalf("expected standalone activation refs to retain GTK objects during show, got %#v", refs) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | activateStandaloneOmnibox(context.Background(), runtimeCfg, nil, windowCfg, refs) | |
| 233 | + | ||
| 234 | + | if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox { | |
| 235 | + | t.Fatalf("expected standalone activation refs to retain GTK objects, got %#v", refs) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | refs.release() | |
| 239 | + | ||
| 240 | + | if refs.window != nil || refs.host != nil || refs.omnibox != nil { | |
| 241 | + | t.Fatalf("expected standalone activation refs to release GTK objects, got %#v", refs) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) { | |
| 246 | + | if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) { | |
| 247 | + | t.Fatalf("expected nil window to skip layer-shell") | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) { | |
| 252 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 253 | + | original := layerShellIsSupported | |
| 254 | + | layerShellLibraryAvailable = func() bool { return true } | |
| 255 | + | layerShellIsSupported = func() bool { return false } | |
| 256 | + | t.Cleanup(func() { | |
| 257 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 258 | + | layerShellIsSupported = original | |
| 259 | + | }) | |
| 260 | + | ||
| 261 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |
| 262 | + | t.Fatalf("expected unsupported layer-shell to skip initialization") | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) { | |
| 267 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 268 | + | originalIsSupported := layerShellIsSupported | |
| 269 | + | layerShellLibraryAvailable = func() bool { return false } | |
| 270 | + | layerShellIsSupported = func() bool { | |
| 271 | + | t.Fatalf("expected layer-shell support check to be skipped when library is missing") | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | t.Cleanup(func() { | |
| 275 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 276 | + | layerShellIsSupported = originalIsSupported | |
| 277 | + | }) | |
| 278 | + | ||
| 279 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |
| 280 | + | t.Fatalf("expected missing library to skip layer-shell initialization") | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) { | |
| 285 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 286 | + | originalIsSupported := layerShellIsSupported | |
| 287 | + | t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0") | |
| 288 | + | layerShellLibraryAvailable = func() bool { | |
| 289 | + | t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded") | |
| 290 | + | return false | |
| 291 | + | } | |
| 292 | + | layerShellIsSupported = func() bool { return true } | |
| 293 | + | t.Cleanup(func() { | |
| 294 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 295 | + | layerShellIsSupported = originalIsSupported | |
| 296 | + | }) | |
| 297 | + | ||
| 298 | + | if !safeLayerShellSupported() { | |
| 299 | + | t.Fatalf("expected preloaded layer-shell library to be treated as supported") | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) { | |
| 304 | + | originalLibraryAvailable := layerShellLibraryAvailable | |
| 305 | + | originalIsSupported := layerShellIsSupported | |
| 306 | + | originalInitForWindow := layerShellInitForWindow | |
| 307 | + | originalSetLayer := layerShellSetLayer | |
| 308 | + | originalSetExclusiveZone := layerShellSetExclusiveZone | |
| 309 | + | originalSetNamespace := layerShellSetNamespace | |
| 310 | + | originalSetKeyboardMode := layerShellSetKeyboardMode | |
| 311 | + | t.Cleanup(func() { | |
| 312 | + | layerShellLibraryAvailable = originalLibraryAvailable | |
| 313 | + | layerShellIsSupported = originalIsSupported | |
| 314 | + | layerShellInitForWindow = originalInitForWindow | |
| 315 | + | layerShellSetLayer = originalSetLayer | |
| 316 | + | layerShellSetExclusiveZone = originalSetExclusiveZone | |
| 317 | + | layerShellSetNamespace = originalSetNamespace | |
| 318 | + | layerShellSetKeyboardMode = originalSetKeyboardMode | |
| 319 | + | }) | |
| 320 | + | ||
| 321 | + | window := >k.ApplicationWindow{} | |
| 322 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |
| 323 | + | var callOrder []string | |
| 324 | + | ||
| 325 | + | layerShellLibraryAvailable = func() bool { | |
| 326 | + | callOrder = append(callOrder, "library") | |
| 327 | + | return true | |
| 328 | + | } | |
| 329 | + | layerShellIsSupported = func() bool { | |
| 330 | + | callOrder = append(callOrder, "supported") | |
| 331 | + | return true | |
| 332 | + | } | |
| 333 | + | layerShellInitForWindow = func(got *gtk.ApplicationWindow) { | |
| 334 | + | if got != window { | |
| 335 | + | t.Fatalf("expected init window %p, got %p", window, got) | |
| 336 | + | } | |
| 337 | + | callOrder = append(callOrder, "init") | |
| 338 | + | } | |
| 339 | + | layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) { | |
| 340 | + | if got != window { | |
| 341 | + | t.Fatalf("expected layer window %p, got %p", window, got) | |
| 342 | + | } | |
| 343 | + | if layer != layershell.LayerOverlayValue { | |
| 344 | + | t.Fatalf("expected overlay layer, got %v", layer) | |
| 345 | + | } | |
| 346 | + | callOrder = append(callOrder, "layer") | |
| 347 | + | } | |
| 348 | + | layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) { | |
| 349 | + | if got != window { | |
| 350 | + | t.Fatalf("expected exclusive zone window %p, got %p", window, got) | |
| 351 | + | } | |
| 352 | + | if zone != 0 { | |
| 353 | + | t.Fatalf("expected exclusive zone 0, got %d", zone) | |
| 354 | + | } | |
| 355 | + | callOrder = append(callOrder, "zone") | |
| 356 | + | } | |
| 357 | + | layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) { | |
| 358 | + | if got != window { | |
| 359 | + | t.Fatalf("expected namespace window %p, got %p", window, got) | |
| 360 | + | } | |
| 361 | + | if namespace == nil || *namespace != cfg.Namespace { | |
| 362 | + | t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace) | |
| 363 | + | } | |
| 364 | + | callOrder = append(callOrder, "namespace") | |
| 365 | + | } | |
| 366 | + | layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |
| 367 | + | if got != window { | |
| 368 | + | t.Fatalf("expected keyboard mode window %p, got %p", window, got) | |
| 369 | + | } | |
| 370 | + | if mode != layershell.KeyboardModeExclusiveValue { | |
| 371 | + | t.Fatalf("expected exclusive keyboard mode, got %v", mode) | |
| 372 | + | } | |
| 373 | + | callOrder = append(callOrder, "keyboard") | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | if !tryInitLayerShell(window, cfg) { | |
| 377 | + | t.Fatalf("expected supported layer-shell to initialize") | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"} | |
| 381 | + | if len(callOrder) != len(wantOrder) { | |
| 382 | + | t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder) | |
| 383 | + | } | |
| 384 | + | for i := range wantOrder { | |
| 385 | + | if callOrder[i] != wantOrder[i] { | |
| 386 | + | t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i]) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | } | |
| @@ -0,0 +1,389 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 8 | + | layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks" | |||
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 10 | + | "github.com/bnema/puregotk/v4/layershell" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestWrapStandaloneOmniboxNavigate_CallsOriginalThenQuit(t *testing.T) { | |||
| 14 | + | var calls []string | |||
| 15 | + | wrapped := wrapStandaloneOmniboxNavigate(func(url string) { | |||
| 16 | + | calls = append(calls, "navigate:"+url) | |||
| 17 | + | }, func() { | |||
| 18 | + | calls = append(calls, "quit") | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | wrapped("https://example.com") | |||
| 22 | + | ||||
| 23 | + | if len(calls) != 2 { | |||
| 24 | + | t.Fatalf("expected 2 calls, got %d: %#v", len(calls), calls) | |||
| 25 | + | } | |||
| 26 | + | if calls[0] != "navigate:https://example.com" { | |||
| 27 | + | t.Fatalf("expected navigate call first, got %#v", calls) | |||
| 28 | + | } | |||
| 29 | + | if calls[1] != "quit" { | |||
| 30 | + | t.Fatalf("expected quit call second, got %#v", calls) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestWrapStandaloneOmniboxNavigate_QuitsWithoutOriginalNavigate(t *testing.T) { | |||
| 35 | + | called := false | |||
| 36 | + | wrapped := wrapStandaloneOmniboxNavigate(nil, func() { | |||
| 37 | + | called = true | |||
| 38 | + | }) | |||
| 39 | + | ||||
| 40 | + | wrapped("https://example.com") | |||
| 41 | + | ||||
| 42 | + | if !called { | |||
| 43 | + | t.Fatalf("expected quit to be called") | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestStandaloneOmniboxCloseHandler_QuitsApp(t *testing.T) { | |||
| 48 | + | called := false | |||
| 49 | + | onClose := standaloneOmniboxCloseHandler(func() { | |||
| 50 | + | called = true | |||
| 51 | + | }) | |||
| 52 | + | ||||
| 53 | + | onClose() | |||
| 54 | + | ||||
| 55 | + | if !called { | |||
| 56 | + | t.Fatalf("expected close handler to quit app") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestStandaloneOmniboxWindowDecorated_LayerShellAppliedReturnsFalse(t *testing.T) { | |||
| 61 | + | if standaloneOmniboxWindowDecorated(true) { | |||
| 62 | + | t.Fatalf("expected layer-shell window to be undecorated") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestStandaloneOmniboxWindowDecorated_FallbackReturnsTrue(t *testing.T) { | |||
| 67 | + | if !standaloneOmniboxWindowDecorated(false) { | |||
| 68 | + | t.Fatalf("expected fallback window to remain decorated") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestStandaloneOmniboxWindowResizable_LayerShellAppliedReturnsFalse(t *testing.T) { | |||
| 73 | + | if standaloneOmniboxWindowResizable(true) { | |||
| 74 | + | t.Fatalf("expected layer-shell window to stop keeping stale height allocations") | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestStandaloneOmniboxWindowResizable_FallbackReturnsTrue(t *testing.T) { | |||
| 79 | + | if !standaloneOmniboxWindowResizable(false) { | |||
| 80 | + | t.Fatalf("expected fallback window to remain resizable") | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestStandaloneOmniboxHostLayout_LayerShellUsesNaturalSizingWithoutDefaultSize(t *testing.T) { | |||
| 85 | + | layoutCfg := standaloneOmniboxHostLayout(true) | |||
| 86 | + | ||||
| 87 | + | if layoutCfg.UseDefaultSize { | |||
| 88 | + | t.Fatalf("expected layer-shell host to avoid fixed default size") | |||
| 89 | + | } | |||
| 90 | + | if layoutCfg.OmniboxSize.FixedWidth != 800 { | |||
| 91 | + | t.Fatalf("expected standalone omnibox fixed width 800, got %d", layoutCfg.OmniboxSize.FixedWidth) | |||
| 92 | + | } | |||
| 93 | + | if !layoutCfg.OmniboxSize.UseFixedTopMargin { | |||
| 94 | + | t.Fatalf("expected standalone omnibox to use a fixed top margin") | |||
| 95 | + | } | |||
| 96 | + | if layoutCfg.OmniboxSize.FixedTopMargin != 0 { | |||
| 97 | + | t.Fatalf("expected standalone omnibox top margin 0, got %d", layoutCfg.OmniboxSize.FixedTopMargin) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestStandaloneOmniboxHostLayout_FallbackUsesDefaultSize(t *testing.T) { | |||
| 102 | + | layoutCfg := standaloneOmniboxHostLayout(false) | |||
| 103 | + | ||||
| 104 | + | if !layoutCfg.UseDefaultSize { | |||
| 105 | + | t.Fatalf("expected non-layer-shell host to keep fixed fallback size") | |||
| 106 | + | } | |||
| 107 | + | if layoutCfg.OmniboxSize != standaloneOmniboxSizeConfig() { | |||
| 108 | + | t.Fatalf("expected fallback host to use standalone omnibox sizing, got %#v", layoutCfg.OmniboxSize) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestAttachStandaloneOmniboxHostWidget_UsesPrimaryChild(t *testing.T) { | |||
| 113 | + | host := layoutmocks.NewMockOverlayWidget(t) | |||
| 114 | + | child := layoutmocks.NewMockWidget(t) | |||
| 115 | + | ||||
| 116 | + | host.EXPECT().SetChild(child).Once() | |||
| 117 | + | ||||
| 118 | + | attachStandaloneOmniboxHostWidget(host, child) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestStandaloneOmniboxSizeConfig_UsesFixedGeometry(t *testing.T) { | |||
| 122 | + | cfg := standaloneOmniboxSizeConfig() | |||
| 123 | + | ||||
| 124 | + | if cfg != (component.ModalSizeConfig{ | |||
| 125 | + | WidthPct: component.OmniboxSizeDefaults.WidthPct, | |||
| 126 | + | MaxWidth: component.OmniboxSizeDefaults.MaxWidth, | |||
| 127 | + | TopMarginPct: component.OmniboxSizeDefaults.TopMarginPct, | |||
| 128 | + | FallbackWidth: component.OmniboxSizeDefaults.FallbackWidth, | |||
| 129 | + | FallbackHeight: component.OmniboxSizeDefaults.FallbackHeight, | |||
| 130 | + | FixedWidth: 800, | |||
| 131 | + | FixedTopMargin: 0, | |||
| 132 | + | UseFixedTopMargin: true, | |||
| 133 | + | }) { | |||
| 134 | + | t.Fatalf("unexpected standalone omnibox size config: %#v", cfg) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestStandaloneWindowConfig_Defaults(t *testing.T) { | |||
| 139 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 140 | + | if cfg.Namespace == "" { | |||
| 141 | + | t.Fatalf("expected namespace") | |||
| 142 | + | } | |||
| 143 | + | if cfg.Title == "" { | |||
| 144 | + | t.Fatalf("expected title") | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestStandaloneOmniboxArgv_RemovesGoTestFlags(t *testing.T) { | |||
| 149 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "-test.v", "-test.run", "TestFoo", "--gtk-debug"}) | |||
| 150 | + | ||||
| 151 | + | if len(argv) != 2 { | |||
| 152 | + | t.Fatalf("expected 2 args, got %d: %#v", len(argv), argv) | |||
| 153 | + | } | |||
| 154 | + | if argv[0] != "/tmp/testbin" { | |||
| 155 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |||
| 156 | + | } | |||
| 157 | + | if argv[1] != "--gtk-debug" { | |||
| 158 | + | t.Fatalf("expected non-test arg to be preserved, got %#v", argv) | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestStandaloneOmniboxArgv_RemovesOnlyLeadingOmniboxSubcommand(t *testing.T) { | |||
| 163 | + | argv := standaloneOmniboxArgv([]string{"/tmp/testbin", "omnibox", "https://example.com/omnibox", "omnibox"}) | |||
| 164 | + | ||||
| 165 | + | if len(argv) != 3 { | |||
| 166 | + | t.Fatalf("expected 3 args, got %d: %#v", len(argv), argv) | |||
| 167 | + | } | |||
| 168 | + | if argv[0] != "/tmp/testbin" { | |||
| 169 | + | t.Fatalf("expected binary path to be preserved, got %q", argv[0]) | |||
| 170 | + | } | |||
| 171 | + | if argv[1] != "https://example.com/omnibox" { | |||
| 172 | + | t.Fatalf("expected non-subcommand omnibox value to be preserved, got %#v", argv) | |||
| 173 | + | } | |||
| 174 | + | if argv[2] != "omnibox" { | |||
| 175 | + | t.Fatalf("expected later omnibox arg to be preserved, got %#v", argv) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestActivateStandaloneOmnibox_RetainsObjectsUntilReleased(t *testing.T) { | |||
| 180 | + | originalBuildHost := buildStandaloneOmniboxHostFn | |||
| 181 | + | originalNewWindow := newStandaloneOmniboxWindow | |||
| 182 | + | originalConfigureWindow := configureStandaloneOmniboxWindowFn | |||
| 183 | + | originalPresentWindow := presentStandaloneOmniboxWindow | |||
| 184 | + | originalLogHostAllocation := logStandaloneOmniboxHostAllocationFn | |||
| 185 | + | originalShowOmnibox := showStandaloneOmniboxFn | |||
| 186 | + | t.Cleanup(func() { | |||
| 187 | + | buildStandaloneOmniboxHostFn = originalBuildHost | |||
| 188 | + | newStandaloneOmniboxWindow = originalNewWindow | |||
| 189 | + | configureStandaloneOmniboxWindowFn = originalConfigureWindow | |||
| 190 | + | presentStandaloneOmniboxWindow = originalPresentWindow | |||
| 191 | + | logStandaloneOmniboxHostAllocationFn = originalLogHostAllocation | |||
| 192 | + | showStandaloneOmniboxFn = originalShowOmnibox | |||
| 193 | + | }) | |||
| 194 | + | ||||
| 195 | + | expectedHost := >k.Widget{} | |||
| 196 | + | expectedOmnibox := &component.Omnibox{} | |||
| 197 | + | expectedWindow := >k.ApplicationWindow{} | |||
| 198 | + | refs := &standaloneOmniboxActivationRetention{} | |||
| 199 | + | runtimeCfg := &StandaloneOmniboxRuntime{} | |||
| 200 | + | windowCfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 201 | + | ||||
| 202 | + | buildStandaloneOmniboxHostFn = func(_ context.Context, _ *StandaloneOmniboxRuntime, _ *gtk.Application) (*gtk.Widget, *component.Omnibox) { | |||
| 203 | + | return expectedHost, expectedOmnibox | |||
| 204 | + | } | |||
| 205 | + | newStandaloneOmniboxWindow = func(_ *gtk.Application) *gtk.ApplicationWindow { | |||
| 206 | + | return expectedWindow | |||
| 207 | + | } | |||
| 208 | + | configureStandaloneOmniboxWindowFn = func(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig, child *gtk.Widget) { | |||
| 209 | + | if window != expectedWindow || child != expectedHost || cfg != windowCfg { | |||
| 210 | + | t.Fatalf("unexpected configure arguments: window=%p child=%p cfg=%#v", window, child, cfg) | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | presentStandaloneOmniboxWindow = func(window *gtk.ApplicationWindow) { | |||
| 214 | + | if window != expectedWindow { | |||
| 215 | + | t.Fatalf("unexpected window presented: %p", window) | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | logStandaloneOmniboxHostAllocationFn = func(_ context.Context, host *gtk.Widget) { | |||
| 219 | + | if host != expectedHost { | |||
| 220 | + | t.Fatalf("unexpected host logged: %p", host) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | showStandaloneOmniboxFn = func(_ context.Context, omnibox *component.Omnibox) { | |||
| 224 | + | if omnibox != expectedOmnibox { | |||
| 225 | + | t.Fatalf("unexpected omnibox shown: %p", omnibox) | |||
| 226 | + | } | |||
| 227 | + | if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox { | |||
| 228 | + | t.Fatalf("expected standalone activation refs to retain GTK objects during show, got %#v", refs) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | activateStandaloneOmnibox(context.Background(), runtimeCfg, nil, windowCfg, refs) | |||
| 233 | + | ||||
| 234 | + | if refs.window != expectedWindow || refs.host != expectedHost || refs.omnibox != expectedOmnibox { | |||
| 235 | + | t.Fatalf("expected standalone activation refs to retain GTK objects, got %#v", refs) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | refs.release() | |||
| 239 | + | ||||
| 240 | + | if refs.window != nil || refs.host != nil || refs.omnibox != nil { | |||
| 241 | + | t.Fatalf("expected standalone activation refs to release GTK objects, got %#v", refs) | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func TestTryInitLayerShell_NilWindowReturnsFalse(t *testing.T) { | |||
| 246 | + | if tryInitLayerShell(nil, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 247 | + | t.Fatalf("expected nil window to skip layer-shell") | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func TestTryInitLayerShell_UnsupportedReturnsFalse(t *testing.T) { | |||
| 252 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 253 | + | original := layerShellIsSupported | |||
| 254 | + | layerShellLibraryAvailable = func() bool { return true } | |||
| 255 | + | layerShellIsSupported = func() bool { return false } | |||
| 256 | + | t.Cleanup(func() { | |||
| 257 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 258 | + | layerShellIsSupported = original | |||
| 259 | + | }) | |||
| 260 | + | ||||
| 261 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 262 | + | t.Fatalf("expected unsupported layer-shell to skip initialization") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestTryInitLayerShell_MissingLibraryReturnsFalseWithoutSymbolCalls(t *testing.T) { | |||
| 267 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 268 | + | originalIsSupported := layerShellIsSupported | |||
| 269 | + | layerShellLibraryAvailable = func() bool { return false } | |||
| 270 | + | layerShellIsSupported = func() bool { | |||
| 271 | + | t.Fatalf("expected layer-shell support check to be skipped when library is missing") | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | t.Cleanup(func() { | |||
| 275 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 276 | + | layerShellIsSupported = originalIsSupported | |||
| 277 | + | }) | |||
| 278 | + | ||||
| 279 | + | if tryInitLayerShell(>k.ApplicationWindow{}, DefaultStandaloneOmniboxWindowConfig()) { | |||
| 280 | + | t.Fatalf("expected missing library to skip layer-shell initialization") | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func TestSafeLayerShellSupported_PreloadedLibrarySkipsPathProbe(t *testing.T) { | |||
| 285 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 286 | + | originalIsSupported := layerShellIsSupported | |||
| 287 | + | t.Setenv("LD_PRELOAD", "/custom/libgtk4-layer-shell.so.0") | |||
| 288 | + | layerShellLibraryAvailable = func() bool { | |||
| 289 | + | t.Fatalf("expected path probe to be skipped when layer-shell is already preloaded") | |||
| 290 | + | return false | |||
| 291 | + | } | |||
| 292 | + | layerShellIsSupported = func() bool { return true } | |||
| 293 | + | t.Cleanup(func() { | |||
| 294 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 295 | + | layerShellIsSupported = originalIsSupported | |||
| 296 | + | }) | |||
| 297 | + | ||||
| 298 | + | if !safeLayerShellSupported() { | |||
| 299 | + | t.Fatalf("expected preloaded layer-shell library to be treated as supported") | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestTryInitLayerShell_SupportedConfiguresWindow(t *testing.T) { | |||
| 304 | + | originalLibraryAvailable := layerShellLibraryAvailable | |||
| 305 | + | originalIsSupported := layerShellIsSupported | |||
| 306 | + | originalInitForWindow := layerShellInitForWindow | |||
| 307 | + | originalSetLayer := layerShellSetLayer | |||
| 308 | + | originalSetExclusiveZone := layerShellSetExclusiveZone | |||
| 309 | + | originalSetNamespace := layerShellSetNamespace | |||
| 310 | + | originalSetKeyboardMode := layerShellSetKeyboardMode | |||
| 311 | + | t.Cleanup(func() { | |||
| 312 | + | layerShellLibraryAvailable = originalLibraryAvailable | |||
| 313 | + | layerShellIsSupported = originalIsSupported | |||
| 314 | + | layerShellInitForWindow = originalInitForWindow | |||
| 315 | + | layerShellSetLayer = originalSetLayer | |||
| 316 | + | layerShellSetExclusiveZone = originalSetExclusiveZone | |||
| 317 | + | layerShellSetNamespace = originalSetNamespace | |||
| 318 | + | layerShellSetKeyboardMode = originalSetKeyboardMode | |||
| 319 | + | }) | |||
| 320 | + | ||||
| 321 | + | window := >k.ApplicationWindow{} | |||
| 322 | + | cfg := DefaultStandaloneOmniboxWindowConfig() | |||
| 323 | + | var callOrder []string | |||
| 324 | + | ||||
| 325 | + | layerShellLibraryAvailable = func() bool { | |||
| 326 | + | callOrder = append(callOrder, "library") | |||
| 327 | + | return true | |||
| 328 | + | } | |||
| 329 | + | layerShellIsSupported = func() bool { | |||
| 330 | + | callOrder = append(callOrder, "supported") | |||
| 331 | + | return true | |||
| 332 | + | } | |||
| 333 | + | layerShellInitForWindow = func(got *gtk.ApplicationWindow) { | |||
| 334 | + | if got != window { | |||
| 335 | + | t.Fatalf("expected init window %p, got %p", window, got) | |||
| 336 | + | } | |||
| 337 | + | callOrder = append(callOrder, "init") | |||
| 338 | + | } | |||
| 339 | + | layerShellSetLayer = func(got *gtk.ApplicationWindow, layer layershell.Layer) { | |||
| 340 | + | if got != window { | |||
| 341 | + | t.Fatalf("expected layer window %p, got %p", window, got) | |||
| 342 | + | } | |||
| 343 | + | if layer != layershell.LayerOverlayValue { | |||
| 344 | + | t.Fatalf("expected overlay layer, got %v", layer) | |||
| 345 | + | } | |||
| 346 | + | callOrder = append(callOrder, "layer") | |||
| 347 | + | } | |||
| 348 | + | layerShellSetExclusiveZone = func(got *gtk.ApplicationWindow, zone int) { | |||
| 349 | + | if got != window { | |||
| 350 | + | t.Fatalf("expected exclusive zone window %p, got %p", window, got) | |||
| 351 | + | } | |||
| 352 | + | if zone != 0 { | |||
| 353 | + | t.Fatalf("expected exclusive zone 0, got %d", zone) | |||
| 354 | + | } | |||
| 355 | + | callOrder = append(callOrder, "zone") | |||
| 356 | + | } | |||
| 357 | + | layerShellSetNamespace = func(got *gtk.ApplicationWindow, namespace *string) { | |||
| 358 | + | if got != window { | |||
| 359 | + | t.Fatalf("expected namespace window %p, got %p", window, got) | |||
| 360 | + | } | |||
| 361 | + | if namespace == nil || *namespace != cfg.Namespace { | |||
| 362 | + | t.Fatalf("expected namespace %q, got %#v", cfg.Namespace, namespace) | |||
| 363 | + | } | |||
| 364 | + | callOrder = append(callOrder, "namespace") | |||
| 365 | + | } | |||
| 366 | + | layerShellSetKeyboardMode = func(got *gtk.ApplicationWindow, mode layershell.KeyboardMode) { | |||
| 367 | + | if got != window { | |||
| 368 | + | t.Fatalf("expected keyboard mode window %p, got %p", window, got) | |||
| 369 | + | } | |||
| 370 | + | if mode != layershell.KeyboardModeExclusiveValue { | |||
| 371 | + | t.Fatalf("expected exclusive keyboard mode, got %v", mode) | |||
| 372 | + | } | |||
| 373 | + | callOrder = append(callOrder, "keyboard") | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | if !tryInitLayerShell(window, cfg) { | |||
| 377 | + | t.Fatalf("expected supported layer-shell to initialize") | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | wantOrder := []string{"library", "supported", "init", "layer", "zone", "namespace", "keyboard"} | |||
| 381 | + | if len(callOrder) != len(wantOrder) { | |||
| 382 | + | t.Fatalf("expected %d calls, got %d: %#v", len(wantOrder), len(callOrder), callOrder) | |||
| 383 | + | } | |||
| 384 | + | for i := range wantOrder { | |||
| 385 | + | if callOrder[i] != wantOrder[i] { | |||
| 386 | + | t.Fatalf("expected call %d to be %q, got %q", i, wantOrder[i], callOrder[i]) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | } | |||
A internal/ui/standalone_runtime_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) { | |
| 11 | + | browserURL := "" | |
| 12 | + | externalURL := "" | |
| 13 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 14 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 15 | + | LaunchExternalURL: func(uri string) { | |
| 16 | + | externalURL = uri | |
| 17 | + | }, | |
| 18 | + | }, context.Background(), "https://example.com") | |
| 19 | + | ||
| 20 | + | if browserURL != "https://example.com" { | |
| 21 | + | t.Fatalf("expected browser launcher to receive URL, got %q", browserURL) | |
| 22 | + | } | |
| 23 | + | if externalURL != "" { | |
| 24 | + | t.Fatalf("expected external launcher to be unused, got %q", externalURL) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) { | |
| 29 | + | browserURL := "" | |
| 30 | + | externalURL := "" | |
| 31 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 32 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 33 | + | LaunchExternalURL: func(uri string) { | |
| 34 | + | externalURL = uri | |
| 35 | + | }, | |
| 36 | + | }, context.Background(), "vscode://file/tmp/demo") | |
| 37 | + | ||
| 38 | + | if externalURL != "vscode://file/tmp/demo" { | |
| 39 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |
| 40 | + | } | |
| 41 | + | if browserURL != "" { | |
| 42 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) { | |
| 47 | + | browserURL := "" | |
| 48 | + | externalURL := "" | |
| 49 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |
| 50 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |
| 51 | + | LaunchExternalURL: func(uri string) { | |
| 52 | + | externalURL = uri | |
| 53 | + | }, | |
| 54 | + | }, context.Background(), "mailto:foo@example.com") | |
| 55 | + | ||
| 56 | + | if externalURL != "mailto:foo@example.com" { | |
| 57 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |
| 58 | + | } | |
| 59 | + | if browserURL != "" { | |
| 60 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) { | |
| 65 | + | if urlutil.IsExternalScheme("https://example.com") { | |
| 66 | + | t.Fatalf("expected http url to stay in dumber") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |
| 71 | + | if !urlutil.IsExternalScheme("spotify://track/123") { | |
| 72 | + | t.Fatalf("expected custom scheme to be external") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | |
| 77 | + | for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { | |
| 78 | + | if urlutil.IsExternalScheme(rawURL) { | |
| 79 | + | t.Fatalf("expected %q to stay internal", rawURL) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) { | |||
| 11 | + | browserURL := "" | |||
| 12 | + | externalURL := "" | |||
| 13 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 14 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 15 | + | LaunchExternalURL: func(uri string) { | |||
| 16 | + | externalURL = uri | |||
| 17 | + | }, | |||
| 18 | + | }, context.Background(), "https://example.com") | |||
| 19 | + | ||||
| 20 | + | if browserURL != "https://example.com" { | |||
| 21 | + | t.Fatalf("expected browser launcher to receive URL, got %q", browserURL) | |||
| 22 | + | } | |||
| 23 | + | if externalURL != "" { | |||
| 24 | + | t.Fatalf("expected external launcher to be unused, got %q", externalURL) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForCustomScheme(t *testing.T) { | |||
| 29 | + | browserURL := "" | |||
| 30 | + | externalURL := "" | |||
| 31 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 32 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 33 | + | LaunchExternalURL: func(uri string) { | |||
| 34 | + | externalURL = uri | |||
| 35 | + | }, | |||
| 36 | + | }, context.Background(), "vscode://file/tmp/demo") | |||
| 37 | + | ||||
| 38 | + | if externalURL != "vscode://file/tmp/demo" { | |||
| 39 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |||
| 40 | + | } | |||
| 41 | + | if browserURL != "" { | |||
| 42 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHandleStandaloneOmniboxNavigation_UsesExternalLauncherForMailto(t *testing.T) { | |||
| 47 | + | browserURL := "" | |||
| 48 | + | externalURL := "" | |||
| 49 | + | handleStandaloneOmniboxNavigation(&Dependencies{ | |||
| 50 | + | LaunchBrowserURL: func(uri string) { browserURL = uri }, | |||
| 51 | + | LaunchExternalURL: func(uri string) { | |||
| 52 | + | externalURL = uri | |||
| 53 | + | }, | |||
| 54 | + | }, context.Background(), "mailto:foo@example.com") | |||
| 55 | + | ||||
| 56 | + | if externalURL != "mailto:foo@example.com" { | |||
| 57 | + | t.Fatalf("expected external launcher to receive URL, got %q", externalURL) | |||
| 58 | + | } | |||
| 59 | + | if browserURL != "" { | |||
| 60 | + | t.Fatalf("expected browser launcher to be unused, got %q", browserURL) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestStandaloneExternalSchemeRules_HTTPIsNotExternal(t *testing.T) { | |||
| 65 | + | if urlutil.IsExternalScheme("https://example.com") { | |||
| 66 | + | t.Fatalf("expected http url to stay in dumber") | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) { | |||
| 71 | + | if !urlutil.IsExternalScheme("spotify://track/123") { | |||
| 72 | + | t.Fatalf("expected custom scheme to be external") | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) { | |||
| 77 | + | for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} { | |||
| 78 | + | if urlutil.IsExternalScheme(rawURL) { | |||
| 79 | + | t.Fatalf("expected %q to stay internal", rawURL) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
M internal/ui/theme/css.go +67 -53
| @@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string { | |||
| 12 | 12 | } | |
| 13 | 13 | ||
| 14 | 14 | // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor. | |
| 15 | - | // Scale affects font sizes and widget padding/margins proportionally. | |
| 15 | + | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. | |
| 16 | 16 | func GenerateCSSWithScale(p Palette, scale float64) string { | |
| 17 | 17 | return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig()) | |
| 18 | 18 | } | |
| @@ -49,19 +49,15 @@ window, tooltip, popover { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | |
| 52 | - | // Scale affects font sizes and widget padding/margins proportionally. | |
| 52 | + | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. | |
| 53 | 53 | // Uses default mode colors. | |
| 54 | 54 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | |
| 55 | 55 | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | |
| 59 | - | // Scale affects font sizes and widget padding/margins proportionally. | |
| 60 | - | func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string { | |
| 61 | - | if scale <= 0 { | |
| 62 | - | scale = 1.0 | |
| 63 | - | } | |
| 64 | - | ||
| 59 | + | // Text scaling is handled via GtkSettings; CSS here stays scale-independent. | |
| 60 | + | func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string { | |
| 65 | 61 | defaults := DefaultFontConfig() | |
| 66 | 62 | fonts = FontConfig{ | |
| 67 | 63 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | |
| @@ -82,12 +78,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||
| 82 | 78 | sb.WriteString(generateFontCSS()) | |
| 83 | 79 | sb.WriteString("\n") | |
| 84 | 80 | ||
| 85 | - | // Global UI scaling via font-size on all widgets | |
| 86 | - | if scale != 1.0 { | |
| 87 | - | sb.WriteString(generateScalingCSS(scale)) | |
| 88 | - | sb.WriteString("\n") | |
| 89 | - | } | |
| 90 | - | ||
| 91 | 81 | // Tab bar styling | |
| 92 | 82 | sb.WriteString(generateTabBarCSS(p)) | |
| 93 | 83 | sb.WriteString("\n") | |
| @@ -146,20 +136,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||
| 146 | 136 | return sb.String() | |
| 147 | 137 | } | |
| 148 | 138 | ||
| 149 | - | // generateScalingCSS creates CSS rules that scale the UI. | |
| 150 | - | // GTK4 CSS doesn't support transform:scale() well, so we scale font-size | |
| 151 | - | // and use relative units (em) for padding/margins in other rules. | |
| 152 | - | func generateScalingCSS(scale float64) string { | |
| 153 | - | // Use 16px base so em conversions are correct: | |
| 154 | - | // 1px = 0.0625em (1/16), 4px = 0.25em, etc. | |
| 155 | - | basePx := int(16 * scale) | |
| 156 | - | return fmt.Sprintf(`/* UI Scaling (%.0f%%) */ | |
| 157 | - | window { | |
| 158 | - | font-size: %dpx; | |
| 159 | - | } | |
| 160 | - | `, scale*100, basePx) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | 139 | // generateTabBarCSS creates tab bar styles. | |
| 164 | 140 | // Uses em units for scalable UI. | |
| 165 | 141 | func generateTabBarCSS(p Palette) string { | |
| @@ -206,6 +182,10 @@ button.tab-button.tab-button-active { | |||
| 206 | 182 | func generateOmniboxCSS(p Palette) string { | |
| 207 | 183 | return `/* ===== Omnibox Styling ===== */ | |
| 208 | 184 | ||
| 185 | + | window.standalone-omnibox-window { | |
| 186 | + | background-color: transparent; | |
| 187 | + | } | |
| 188 | + | ||
| 209 | 189 | /* Omnibox outer container - for positioning in overlay */ | |
| 210 | 190 | .omnibox-outer { | |
| 211 | 191 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | |
| @@ -213,23 +193,26 @@ func generateOmniboxCSS(p Palette) string { | |||
| 213 | 193 | ||
| 214 | 194 | /* Omnibox main container - the visible popup */ | |
| 215 | 195 | .omnibox-container { | |
| 216 | - | background-color: var(--surface-variant); | |
| 217 | - | border: 0.0625em solid var(--border); | |
| 196 | + | background-color: var(--surface); | |
| 197 | + | background-image: none; | |
| 198 | + | border: 0.0625em solid alpha(var(--border), 0.85); | |
| 218 | 199 | border-radius: 0.1875em; | |
| 200 | + | box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset; | |
| 219 | 201 | padding: 0; | |
| 220 | 202 | } | |
| 221 | 203 | ||
| 222 | 204 | /* Header with History/Favorites toggle */ | |
| 223 | 205 | .omnibox-header { | |
| 224 | - | background-color: shade(var(--surface-variant), 1.1); | |
| 225 | - | border-bottom: 0.0625em solid var(--border); | |
| 206 | + | background-color: var(--surface); | |
| 207 | + | background-image: none; | |
| 208 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.75); | |
| 226 | 209 | padding: 0.375em 0.75em; | |
| 227 | 210 | } | |
| 228 | 211 | ||
| 229 | 212 | .omnibox-header-btn { | |
| 230 | - | background-color: transparent; | |
| 213 | + | background-color: alpha(var(--surface), 0.12); | |
| 231 | 214 | background-image: none; | |
| 232 | - | border: none; | |
| 215 | + | border: 0.0625em solid transparent; | |
| 233 | 216 | border-radius: 0.125em; | |
| 234 | 217 | padding: 0.25em 0.75em; | |
| 235 | 218 | margin-right: 0.5em; | |
| @@ -240,49 +223,61 @@ func generateOmniboxCSS(p Palette) string { | |||
| 240 | 223 | } | |
| 241 | 224 | ||
| 242 | 225 | .omnibox-header-btn:hover { | |
| 243 | - | background-color: alpha(var(--accent), 0.15); | |
| 226 | + | background-color: alpha(var(--accent), 0.14); | |
| 227 | + | border-color: alpha(var(--accent), 0.18); | |
| 244 | 228 | color: var(--text); | |
| 245 | 229 | } | |
| 246 | 230 | ||
| 247 | 231 | .omnibox-header-btn.omnibox-header-active { | |
| 248 | - | background-color: alpha(var(--accent), 0.2); | |
| 232 | + | background-color: alpha(var(--accent), 0.18); | |
| 233 | + | border-color: alpha(var(--accent), 0.28); | |
| 249 | 234 | color: var(--accent); | |
| 250 | 235 | font-weight: 600; | |
| 251 | 236 | } | |
| 252 | 237 | ||
| 253 | 238 | /* Search entry field */ | |
| 254 | - | .omnibox-entry { | |
| 255 | - | background-color: var(--bg); | |
| 239 | + | entry.omnibox-entry, | |
| 240 | + | entry.omnibox-entry > text { | |
| 241 | + | background-color: alpha(var(--bg), 0.88); | |
| 242 | + | background-image: none; | |
| 256 | 243 | color: var(--text); | |
| 257 | - | border: 0.0625em solid var(--border); | |
| 244 | + | caret-color: var(--accent); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | entry.omnibox-entry { | |
| 248 | + | border: 0.0625em solid alpha(var(--border), 0.82); | |
| 258 | 249 | border-radius: 0.125em; | |
| 259 | 250 | padding: 0.625em 0.75em; | |
| 260 | 251 | margin: 0.5em 0.75em; | |
| 261 | 252 | font-size: 1em; | |
| 262 | - | caret-color: var(--accent); | |
| 253 | + | box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset; | |
| 263 | 254 | } | |
| 264 | 255 | ||
| 265 | 256 | entry.omnibox-entry:focus, | |
| 266 | 257 | entry.omnibox-entry:focus-within, | |
| 267 | - | entry.omnibox-entry:focus-visible { | |
| 258 | + | entry.omnibox-entry:focus-visible, | |
| 259 | + | entry.omnibox-entry:focus > text, | |
| 260 | + | entry.omnibox-entry:focus-within > text, | |
| 261 | + | entry.omnibox-entry:focus-visible > text { | |
| 268 | 262 | border-color: var(--accent); | |
| 269 | 263 | background-color: shade(var(--bg), 1.05); | |
| 264 | + | background-image: none; | |
| 270 | 265 | outline-style: none; | |
| 271 | 266 | outline-width: 0px; | |
| 272 | 267 | outline-color: transparent; | |
| 273 | 268 | } | |
| 274 | 269 | ||
| 275 | 270 | /* Override any internal focus styling */ | |
| 276 | - | entry.omnibox-entry > *:focus, | |
| 277 | - | entry.omnibox-entry > *:focus-visible { | |
| 271 | + | entry.omnibox-entry > text:focus, | |
| 272 | + | entry.omnibox-entry > text:focus-visible { | |
| 278 | 273 | outline-style: none; | |
| 279 | 274 | outline-color: transparent; | |
| 280 | 275 | } | |
| 281 | 276 | ||
| 282 | 277 | /* Scrolled window for suggestions */ | |
| 283 | 278 | .omnibox-scrolled { | |
| 284 | - | background-color: shade(var(--surface-variant), 0.95); | |
| 285 | - | border-top: 0.0625em solid var(--border); | |
| 279 | + | background-color: var(--surface); | |
| 280 | + | border-top: 0.0625em solid alpha(var(--border), 0.65); | |
| 286 | 281 | } | |
| 287 | 282 | ||
| 288 | 283 | /* List box */ | |
| @@ -296,7 +291,9 @@ entry.omnibox-entry > *:focus-visible { | |||
| 296 | 291 | margin: 0; | |
| 297 | 292 | border-radius: 0; | |
| 298 | 293 | border-left: 0.1875em solid transparent; | |
| 299 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.5); | |
| 294 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.35); | |
| 295 | + | background-color: var(--surface); | |
| 296 | + | background-image: none; | |
| 300 | 297 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | |
| 301 | 298 | min-height: 2.75em; | |
| 302 | 299 | } | |
| @@ -315,19 +312,19 @@ entry.omnibox-entry > *:focus-visible { | |||
| 315 | 312 | border-left: 0.1875em solid var(--accent); | |
| 316 | 313 | } | |
| 317 | 314 | ||
| 318 | - | /* Favorite indicator in history mode - yellowish accent */ | |
| 315 | + | /* Favorite indicator in history mode - keep the row neutral and use the left selection stripe */ | |
| 319 | 316 | .omnibox-row.omnibox-row-favorite { | |
| 320 | - | background-color: alpha(var(--warning), 0.08); | |
| 317 | + | background-color: transparent; | |
| 321 | 318 | border-left: 0.1875em solid var(--warning); | |
| 322 | 319 | } | |
| 323 | 320 | ||
| 324 | 321 | .omnibox-row.omnibox-row-favorite:hover { | |
| 325 | - | background-color: alpha(var(--warning), 0.15); | |
| 322 | + | background-color: alpha(var(--accent), 0.12); | |
| 326 | 323 | border-left: 0.1875em solid var(--warning); | |
| 327 | 324 | } | |
| 328 | 325 | ||
| 329 | 326 | .omnibox-row.omnibox-row-favorite:selected { | |
| 330 | - | background-color: alpha(var(--warning), 0.2); | |
| 327 | + | background-color: alpha(var(--accent), 0.2); | |
| 331 | 328 | border-left: 0.1875em solid var(--warning); | |
| 332 | 329 | } | |
| 333 | 330 | ||
| @@ -342,7 +339,7 @@ entry.omnibox-entry > *:focus-visible { | |||
| 342 | 339 | .omnibox-suggestion-title { | |
| 343 | 340 | font-size: 0.875em; | |
| 344 | 341 | color: var(--text); | |
| 345 | - | font-weight: 400; | |
| 342 | + | font-weight: 500; | |
| 346 | 343 | } | |
| 347 | 344 | ||
| 348 | 345 | /* Also style labels inside omnibox rows directly */ | |
| @@ -358,10 +355,22 @@ entry.omnibox-entry > *:focus-visible { | |||
| 358 | 355 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | |
| 359 | 356 | .omnibox-row .omnibox-suggestion-url { | |
| 360 | 357 | font-size: 0.75em; | |
| 361 | - | color: alpha(var(--muted), 0.65); | |
| 358 | + | color: alpha(var(--muted), 0.72); | |
| 362 | 359 | font-weight: 400; | |
| 363 | 360 | } | |
| 364 | 361 | ||
| 362 | + | .omnibox-favorite-star { | |
| 363 | + | color: mix(var(--warning), var(--muted), 0.45); | |
| 364 | + | opacity: 0.75; | |
| 365 | + | margin-left: 0.625em; | |
| 366 | + | margin-right: 0.125em; | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | .omnibox-row:selected .omnibox-favorite-star { | |
| 370 | + | color: mix(var(--warning), var(--text), 0.35); | |
| 371 | + | opacity: 0.88; | |
| 372 | + | } | |
| 373 | + | ||
| 365 | 374 | .omnibox-row:selected .omnibox-suggestion-url { | |
| 366 | 375 | color: alpha(var(--muted), 0.75); | |
| 367 | 376 | } | |
| @@ -412,9 +421,14 @@ entry.omnibox-entry > *:focus-visible { | |||
| 412 | 421 | entry.omnibox-entry.omnibox-entry-bang-active, | |
| 413 | 422 | entry.omnibox-entry.omnibox-entry-bang-active:focus, | |
| 414 | 423 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within, | |
| 415 | - | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |
| 424 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible, | |
| 425 | + | entry.omnibox-entry.omnibox-entry-bang-active > text, | |
| 426 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus > text, | |
| 427 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text, | |
| 428 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | |
| 416 | 429 | border-color: var(--accent); | |
| 417 | 430 | background-color: shade(var(--bg), 1.05); | |
| 431 | + | background-image: none; | |
| 418 | 432 | } | |
| 419 | 433 | ||
| 420 | 434 | .omnibox-row.omnibox-row-bang .omnibox-suggestion-title { | |
| @@ -12,7 +12,7 @@ func GenerateCSS(p Palette) string { | |||||
| 12 | } | 12 | } | ||
| 13 | 13 | ||||
| 14 | // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor. | 14 | // GenerateCSSWithScale creates GTK4 CSS using the provided palette and UI scale factor. | ||
| 15 | - | // Scale affects font sizes and widget padding/margins proportionally. | 15 | + | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. |
| 16 | func GenerateCSSWithScale(p Palette, scale float64) string { | 16 | func GenerateCSSWithScale(p Palette, scale float64) string { | ||
| 17 | return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig()) | 17 | return GenerateCSSWithScaleAndFonts(p, scale, DefaultFontConfig()) | ||
| 18 | } | 18 | } | ||
| @@ -49,19 +49,15 @@ window, tooltip, popover { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | 51 | // GenerateCSSWithScaleAndFonts creates GTK4 CSS using the provided palette, UI scale factor and fonts. | ||
| 52 | - | // Scale affects font sizes and widget padding/margins proportionally. | 52 | + | // Scale affects widget sizing that uses relative units; text scaling is handled via GtkSettings. |
| 53 | // Uses default mode colors. | 53 | // Uses default mode colors. | ||
| 54 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | 54 | func GenerateCSSWithScaleAndFonts(p Palette, scale float64, fonts FontConfig) string { | ||
| 55 | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | 55 | return GenerateCSSFull(p, scale, fonts, DefaultModeColors()) | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | 58 | // GenerateCSSFull creates GTK4 CSS using all provided configuration. | ||
| 59 | - | // Scale affects font sizes and widget padding/margins proportionally. | 59 | + | // Text scaling is handled via GtkSettings; CSS here stays scale-independent. |
| 60 | - | func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors ModeColors) string { | 60 | + | func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColors) string { |
| 61 | - | if scale <= 0 { | |||
| 62 | - | scale = 1.0 | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | defaults := DefaultFontConfig() | 61 | defaults := DefaultFontConfig() | ||
| 66 | fonts = FontConfig{ | 62 | fonts = FontConfig{ | ||
| 67 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | 63 | SansFont: Coalesce(fonts.SansFont, defaults.SansFont), | ||
| @@ -82,12 +78,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||||
| 82 | sb.WriteString(generateFontCSS()) | 78 | sb.WriteString(generateFontCSS()) | ||
| 83 | sb.WriteString("\n") | 79 | sb.WriteString("\n") | ||
| 84 | 80 | ||||
| 85 | - | // Global UI scaling via font-size on all widgets | |||
| 86 | - | if scale != 1.0 { | |||
| 87 | - | sb.WriteString(generateScalingCSS(scale)) | |||
| 88 | - | sb.WriteString("\n") | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | // Tab bar styling | 81 | // Tab bar styling | ||
| 92 | sb.WriteString(generateTabBarCSS(p)) | 82 | sb.WriteString(generateTabBarCSS(p)) | ||
| 93 | sb.WriteString("\n") | 83 | sb.WriteString("\n") | ||
| @@ -146,20 +136,6 @@ func GenerateCSSFull(p Palette, scale float64, fonts FontConfig, modeColors Mode | |||||
| 146 | return sb.String() | 136 | return sb.String() | ||
| 147 | } | 137 | } | ||
| 148 | 138 | ||||
| 149 | - | // generateScalingCSS creates CSS rules that scale the UI. | |||
| 150 | - | // GTK4 CSS doesn't support transform:scale() well, so we scale font-size | |||
| 151 | - | // and use relative units (em) for padding/margins in other rules. | |||
| 152 | - | func generateScalingCSS(scale float64) string { | |||
| 153 | - | // Use 16px base so em conversions are correct: | |||
| 154 | - | // 1px = 0.0625em (1/16), 4px = 0.25em, etc. | |||
| 155 | - | basePx := int(16 * scale) | |||
| 156 | - | return fmt.Sprintf(`/* UI Scaling (%.0f%%) */ | |||
| 157 | - | window { | |||
| 158 | - | font-size: %dpx; | |||
| 159 | - | } | |||
| 160 | - | `, scale*100, basePx) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | // generateTabBarCSS creates tab bar styles. | 139 | // generateTabBarCSS creates tab bar styles. | ||
| 164 | // Uses em units for scalable UI. | 140 | // Uses em units for scalable UI. | ||
| 165 | func generateTabBarCSS(p Palette) string { | 141 | func generateTabBarCSS(p Palette) string { | ||
| @@ -206,6 +182,10 @@ button.tab-button.tab-button-active { | |||||
| 206 | func generateOmniboxCSS(p Palette) string { | 182 | func generateOmniboxCSS(p Palette) string { | ||
| 207 | return `/* ===== Omnibox Styling ===== */ | 183 | return `/* ===== Omnibox Styling ===== */ | ||
| 208 | 184 | ||||
| 185 | + | window.standalone-omnibox-window { | |||
| 186 | + | background-color: transparent; | |||
| 187 | + | } | |||
| 188 | + | ||||
| 209 | /* Omnibox outer container - for positioning in overlay */ | 189 | /* Omnibox outer container - for positioning in overlay */ | ||
| 210 | .omnibox-outer { | 190 | .omnibox-outer { | ||
| 211 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | 191 | /* Positioning is handled via SetHalign/SetValign/SetMarginTop in Go */ | ||
| @@ -213,23 +193,26 @@ func generateOmniboxCSS(p Palette) string { | |||||
| 213 | 193 | ||||
| 214 | /* Omnibox main container - the visible popup */ | 194 | /* Omnibox main container - the visible popup */ | ||
| 215 | .omnibox-container { | 195 | .omnibox-container { | ||
| 216 | - | background-color: var(--surface-variant); | 196 | + | background-color: var(--surface); |
| 217 | - | border: 0.0625em solid var(--border); | 197 | + | background-image: none; |
| 198 | + | border: 0.0625em solid alpha(var(--border), 0.85); | |||
| 218 | border-radius: 0.1875em; | 199 | border-radius: 0.1875em; | ||
| 200 | + | box-shadow: 0 0.875em 2.5em alpha(black, 0.24), 0 0.0625em 0 alpha(white, 0.05) inset; | |||
| 219 | padding: 0; | 201 | padding: 0; | ||
| 220 | } | 202 | } | ||
| 221 | 203 | ||||
| 222 | /* Header with History/Favorites toggle */ | 204 | /* Header with History/Favorites toggle */ | ||
| 223 | .omnibox-header { | 205 | .omnibox-header { | ||
| 224 | - | background-color: shade(var(--surface-variant), 1.1); | 206 | + | background-color: var(--surface); |
| 225 | - | border-bottom: 0.0625em solid var(--border); | 207 | + | background-image: none; |
| 208 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.75); | |||
| 226 | padding: 0.375em 0.75em; | 209 | padding: 0.375em 0.75em; | ||
| 227 | } | 210 | } | ||
| 228 | 211 | ||||
| 229 | .omnibox-header-btn { | 212 | .omnibox-header-btn { | ||
| 230 | - | background-color: transparent; | 213 | + | background-color: alpha(var(--surface), 0.12); |
| 231 | background-image: none; | 214 | background-image: none; | ||
| 232 | - | border: none; | 215 | + | border: 0.0625em solid transparent; |
| 233 | border-radius: 0.125em; | 216 | border-radius: 0.125em; | ||
| 234 | padding: 0.25em 0.75em; | 217 | padding: 0.25em 0.75em; | ||
| 235 | margin-right: 0.5em; | 218 | margin-right: 0.5em; | ||
| @@ -240,49 +223,61 @@ func generateOmniboxCSS(p Palette) string { | |||||
| 240 | } | 223 | } | ||
| 241 | 224 | ||||
| 242 | .omnibox-header-btn:hover { | 225 | .omnibox-header-btn:hover { | ||
| 243 | - | background-color: alpha(var(--accent), 0.15); | 226 | + | background-color: alpha(var(--accent), 0.14); |
| 227 | + | border-color: alpha(var(--accent), 0.18); | |||
| 244 | color: var(--text); | 228 | color: var(--text); | ||
| 245 | } | 229 | } | ||
| 246 | 230 | ||||
| 247 | .omnibox-header-btn.omnibox-header-active { | 231 | .omnibox-header-btn.omnibox-header-active { | ||
| 248 | - | background-color: alpha(var(--accent), 0.2); | 232 | + | background-color: alpha(var(--accent), 0.18); |
| 233 | + | border-color: alpha(var(--accent), 0.28); | |||
| 249 | color: var(--accent); | 234 | color: var(--accent); | ||
| 250 | font-weight: 600; | 235 | font-weight: 600; | ||
| 251 | } | 236 | } | ||
| 252 | 237 | ||||
| 253 | /* Search entry field */ | 238 | /* Search entry field */ | ||
| 254 | - | .omnibox-entry { | 239 | + | entry.omnibox-entry, |
| 255 | - | background-color: var(--bg); | 240 | + | entry.omnibox-entry > text { |
| 241 | + | background-color: alpha(var(--bg), 0.88); | |||
| 242 | + | background-image: none; | |||
| 256 | color: var(--text); | 243 | color: var(--text); | ||
| 257 | - | border: 0.0625em solid var(--border); | 244 | + | caret-color: var(--accent); |
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | entry.omnibox-entry { | |||
| 248 | + | border: 0.0625em solid alpha(var(--border), 0.82); | |||
| 258 | border-radius: 0.125em; | 249 | border-radius: 0.125em; | ||
| 259 | padding: 0.625em 0.75em; | 250 | padding: 0.625em 0.75em; | ||
| 260 | margin: 0.5em 0.75em; | 251 | margin: 0.5em 0.75em; | ||
| 261 | font-size: 1em; | 252 | font-size: 1em; | ||
| 262 | - | caret-color: var(--accent); | 253 | + | box-shadow: 0 0.0625em 0 alpha(white, 0.04) inset; |
| 263 | } | 254 | } | ||
| 264 | 255 | ||||
| 265 | entry.omnibox-entry:focus, | 256 | entry.omnibox-entry:focus, | ||
| 266 | entry.omnibox-entry:focus-within, | 257 | entry.omnibox-entry:focus-within, | ||
| 267 | - | entry.omnibox-entry:focus-visible { | 258 | + | entry.omnibox-entry:focus-visible, |
| 259 | + | entry.omnibox-entry:focus > text, | |||
| 260 | + | entry.omnibox-entry:focus-within > text, | |||
| 261 | + | entry.omnibox-entry:focus-visible > text { | |||
| 268 | border-color: var(--accent); | 262 | border-color: var(--accent); | ||
| 269 | background-color: shade(var(--bg), 1.05); | 263 | background-color: shade(var(--bg), 1.05); | ||
| 264 | + | background-image: none; | |||
| 270 | outline-style: none; | 265 | outline-style: none; | ||
| 271 | outline-width: 0px; | 266 | outline-width: 0px; | ||
| 272 | outline-color: transparent; | 267 | outline-color: transparent; | ||
| 273 | } | 268 | } | ||
| 274 | 269 | ||||
| 275 | /* Override any internal focus styling */ | 270 | /* Override any internal focus styling */ | ||
| 276 | - | entry.omnibox-entry > *:focus, | 271 | + | entry.omnibox-entry > text:focus, |
| 277 | - | entry.omnibox-entry > *:focus-visible { | 272 | + | entry.omnibox-entry > text:focus-visible { |
| 278 | outline-style: none; | 273 | outline-style: none; | ||
| 279 | outline-color: transparent; | 274 | outline-color: transparent; | ||
| 280 | } | 275 | } | ||
| 281 | 276 | ||||
| 282 | /* Scrolled window for suggestions */ | 277 | /* Scrolled window for suggestions */ | ||
| 283 | .omnibox-scrolled { | 278 | .omnibox-scrolled { | ||
| 284 | - | background-color: shade(var(--surface-variant), 0.95); | 279 | + | background-color: var(--surface); |
| 285 | - | border-top: 0.0625em solid var(--border); | 280 | + | border-top: 0.0625em solid alpha(var(--border), 0.65); |
| 286 | } | 281 | } | ||
| 287 | 282 | ||||
| 288 | /* List box */ | 283 | /* List box */ | ||
| @@ -296,7 +291,9 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 296 | margin: 0; | 291 | margin: 0; | ||
| 297 | border-radius: 0; | 292 | border-radius: 0; | ||
| 298 | border-left: 0.1875em solid transparent; | 293 | border-left: 0.1875em solid transparent; | ||
| 299 | - | border-bottom: 0.0625em solid alpha(var(--border), 0.5); | 294 | + | border-bottom: 0.0625em solid alpha(var(--border), 0.35); |
| 295 | + | background-color: var(--surface); | |||
| 296 | + | background-image: none; | |||
| 300 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | 297 | transition: background-color 100ms ease-in-out, border-left 100ms ease-in-out; | ||
| 301 | min-height: 2.75em; | 298 | min-height: 2.75em; | ||
| 302 | } | 299 | } | ||
| @@ -315,19 +312,19 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 315 | border-left: 0.1875em solid var(--accent); | 312 | border-left: 0.1875em solid var(--accent); | ||
| 316 | } | 313 | } | ||
| 317 | 314 | ||||
| 318 | - | /* Favorite indicator in history mode - yellowish accent */ | 315 | + | /* Favorite indicator in history mode - keep the row neutral and use the left selection stripe */ |
| 319 | .omnibox-row.omnibox-row-favorite { | 316 | .omnibox-row.omnibox-row-favorite { | ||
| 320 | - | background-color: alpha(var(--warning), 0.08); | 317 | + | background-color: transparent; |
| 321 | border-left: 0.1875em solid var(--warning); | 318 | border-left: 0.1875em solid var(--warning); | ||
| 322 | } | 319 | } | ||
| 323 | 320 | ||||
| 324 | .omnibox-row.omnibox-row-favorite:hover { | 321 | .omnibox-row.omnibox-row-favorite:hover { | ||
| 325 | - | background-color: alpha(var(--warning), 0.15); | 322 | + | background-color: alpha(var(--accent), 0.12); |
| 326 | border-left: 0.1875em solid var(--warning); | 323 | border-left: 0.1875em solid var(--warning); | ||
| 327 | } | 324 | } | ||
| 328 | 325 | ||||
| 329 | .omnibox-row.omnibox-row-favorite:selected { | 326 | .omnibox-row.omnibox-row-favorite:selected { | ||
| 330 | - | background-color: alpha(var(--warning), 0.2); | 327 | + | background-color: alpha(var(--accent), 0.2); |
| 331 | border-left: 0.1875em solid var(--warning); | 328 | border-left: 0.1875em solid var(--warning); | ||
| 332 | } | 329 | } | ||
| 333 | 330 | ||||
| @@ -342,7 +339,7 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 342 | .omnibox-suggestion-title { | 339 | .omnibox-suggestion-title { | ||
| 343 | font-size: 0.875em; | 340 | font-size: 0.875em; | ||
| 344 | color: var(--text); | 341 | color: var(--text); | ||
| 345 | - | font-weight: 400; | 342 | + | font-weight: 500; |
| 346 | } | 343 | } | ||
| 347 | 344 | ||||
| 348 | /* Also style labels inside omnibox rows directly */ | 345 | /* Also style labels inside omnibox rows directly */ | ||
| @@ -358,10 +355,22 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 358 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | 355 | /* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */ | ||
| 359 | .omnibox-row .omnibox-suggestion-url { | 356 | .omnibox-row .omnibox-suggestion-url { | ||
| 360 | font-size: 0.75em; | 357 | font-size: 0.75em; | ||
| 361 | - | color: alpha(var(--muted), 0.65); | 358 | + | color: alpha(var(--muted), 0.72); |
| 362 | font-weight: 400; | 359 | font-weight: 400; | ||
| 363 | } | 360 | } | ||
| 364 | 361 | ||||
| 362 | + | .omnibox-favorite-star { | |||
| 363 | + | color: mix(var(--warning), var(--muted), 0.45); | |||
| 364 | + | opacity: 0.75; | |||
| 365 | + | margin-left: 0.625em; | |||
| 366 | + | margin-right: 0.125em; | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | .omnibox-row:selected .omnibox-favorite-star { | |||
| 370 | + | color: mix(var(--warning), var(--text), 0.35); | |||
| 371 | + | opacity: 0.88; | |||
| 372 | + | } | |||
| 373 | + | ||||
| 365 | .omnibox-row:selected .omnibox-suggestion-url { | 374 | .omnibox-row:selected .omnibox-suggestion-url { | ||
| 366 | color: alpha(var(--muted), 0.75); | 375 | color: alpha(var(--muted), 0.75); | ||
| 367 | } | 376 | } | ||
| @@ -412,9 +421,14 @@ entry.omnibox-entry > *:focus-visible { | |||||
| 412 | entry.omnibox-entry.omnibox-entry-bang-active, | 421 | entry.omnibox-entry.omnibox-entry-bang-active, | ||
| 413 | entry.omnibox-entry.omnibox-entry-bang-active:focus, | 422 | entry.omnibox-entry.omnibox-entry-bang-active:focus, | ||
| 414 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within, | 423 | entry.omnibox-entry.omnibox-entry-bang-active:focus-within, | ||
| 415 | - | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | 424 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible, |
| 425 | + | entry.omnibox-entry.omnibox-entry-bang-active > text, | |||
| 426 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus > text, | |||
| 427 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text, | |||
| 428 | + | entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | |||
| 416 | border-color: var(--accent); | 429 | border-color: var(--accent); | ||
| 417 | background-color: shade(var(--bg), 1.05); | 430 | background-color: shade(var(--bg), 1.05); | ||
| 431 | + | background-image: none; | |||
| 418 | } | 432 | } | ||
| 419 | 433 | ||||
| 420 | .omnibox-row.omnibox-row-bang .omnibox-suggestion-title { | 434 | .omnibox-row.omnibox-row-bang .omnibox-suggestion-title { | ||
A internal/ui/theme/css_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) { | |
| 12 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 13 | + | ||
| 14 | + | if !strings.Contains(css, "window.standalone-omnibox-window") { | |
| 15 | + | t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css) | |
| 16 | + | } | |
| 17 | + | re := regexp.MustCompile(`window\.standalone-omnibox-window\s*\{\s*background-color:\s*transparent\s*;?\s*\}`) | |
| 18 | + | if !re.MatchString(css) { | |
| 19 | + | t.Fatalf("expected standalone omnibox window background to be transparent") | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) { | |
| 24 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 25 | + | ||
| 26 | + | if !strings.Contains(css, ".omnibox-header {") { | |
| 27 | + | t.Fatalf("expected omnibox header selector in CSS") | |
| 28 | + | } | |
| 29 | + | if !strings.Contains(css, "background-color: var(--surface);") { | |
| 30 | + | t.Fatalf("expected omnibox header to use an opaque surface background") | |
| 31 | + | } | |
| 32 | + | if !strings.Contains(css, "background-image: none;") { | |
| 33 | + | t.Fatalf("expected omnibox header gradient to be disabled") | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) { | |
| 38 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 39 | + | ||
| 40 | + | if !strings.Contains(css, ".omnibox-row.omnibox-row-favorite {") { | |
| 41 | + | t.Fatalf("expected favorite row selector in CSS") | |
| 42 | + | } | |
| 43 | + | if !strings.Contains(css, "border-left: 0.1875em solid var(--warning);") { | |
| 44 | + | t.Fatalf("expected favorite rows to mark the left edge with the warning color") | |
| 45 | + | } | |
| 46 | + | if strings.Contains(css, "border-right: 0.125em solid alpha(var(--warning), 0.55);") { | |
| 47 | + | t.Fatalf("expected favorite indicator to move off the right edge") | |
| 48 | + | } | |
| 49 | + | if !strings.Contains(css, "color: mix(var(--warning), var(--muted), 0.45);") { | |
| 50 | + | t.Fatalf("expected favorite star color to be softened") | |
| 51 | + | } | |
| 52 | + | if strings.Contains(css, ".omnibox-row.omnibox-row-favorite .omnibox-favorite-star-slot") { | |
| 53 | + | t.Fatalf("expected favorite highlight to move off the star slot") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) { | |
| 58 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 59 | + | ||
| 60 | + | containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`) | |
| 61 | + | if !containerRe.MatchString(css) { | |
| 62 | + | t.Fatalf("expected omnibox container to use the header surface color") | |
| 63 | + | } | |
| 64 | + | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`) | |
| 65 | + | if !entryRe.MatchString(css) { | |
| 66 | + | t.Fatalf("expected omnibox entry and its text node to use the darker background fill") | |
| 67 | + | } | |
| 68 | + | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`) | |
| 69 | + | if !focusedEntryRe.MatchString(css) { | |
| 70 | + | t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill") | |
| 71 | + | } | |
| 72 | + | scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`) | |
| 73 | + | if !scrolledRe.MatchString(css) { | |
| 74 | + | t.Fatalf("expected omnibox results area to use the header surface color") | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) { | |
| 79 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |
| 80 | + | ||
| 81 | + | assert.NotContains(t, css, "/* UI Scaling (130%) */") | |
| 82 | + | assert.NotContains(t, css, "window {\n\tfont-size:") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) { | |
| 86 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |
| 87 | + | ||
| 88 | + | assert.NotContains(t, css, "line-height:") | |
| 89 | + | assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:") | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) { | |
| 93 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |
| 94 | + | ||
| 95 | + | assert.Contains(t, css, ".omnibox-row {") | |
| 96 | + | assert.Contains(t, css, "background-color: var(--surface);") | |
| 97 | + | assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) { | |
| 101 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |
| 102 | + | ||
| 103 | + | assert.Contains(t, css, ".omnibox-suggestion-title {") | |
| 104 | + | assert.Contains(t, css, "font-weight: 500;") | |
| 105 | + | assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;") | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestGenerateCSS_StandaloneOmniboxWindowUsesTransparentBackground(t *testing.T) { | |||
| 12 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 13 | + | ||||
| 14 | + | if !strings.Contains(css, "window.standalone-omnibox-window") { | |||
| 15 | + | t.Fatalf("expected standalone omnibox window selector in CSS, got %q", css) | |||
| 16 | + | } | |||
| 17 | + | re := regexp.MustCompile(`window\.standalone-omnibox-window\s*\{\s*background-color:\s*transparent\s*;?\s*\}`) | |||
| 18 | + | if !re.MatchString(css) { | |||
| 19 | + | t.Fatalf("expected standalone omnibox window background to be transparent") | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestGenerateCSS_OmniboxHeaderIsOpaque(t *testing.T) { | |||
| 24 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 25 | + | ||||
| 26 | + | if !strings.Contains(css, ".omnibox-header {") { | |||
| 27 | + | t.Fatalf("expected omnibox header selector in CSS") | |||
| 28 | + | } | |||
| 29 | + | if !strings.Contains(css, "background-color: var(--surface);") { | |||
| 30 | + | t.Fatalf("expected omnibox header to use an opaque surface background") | |||
| 31 | + | } | |||
| 32 | + | if !strings.Contains(css, "background-image: none;") { | |||
| 33 | + | t.Fatalf("expected omnibox header gradient to be disabled") | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) { | |||
| 38 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 39 | + | ||||
| 40 | + | if !strings.Contains(css, ".omnibox-row.omnibox-row-favorite {") { | |||
| 41 | + | t.Fatalf("expected favorite row selector in CSS") | |||
| 42 | + | } | |||
| 43 | + | if !strings.Contains(css, "border-left: 0.1875em solid var(--warning);") { | |||
| 44 | + | t.Fatalf("expected favorite rows to mark the left edge with the warning color") | |||
| 45 | + | } | |||
| 46 | + | if strings.Contains(css, "border-right: 0.125em solid alpha(var(--warning), 0.55);") { | |||
| 47 | + | t.Fatalf("expected favorite indicator to move off the right edge") | |||
| 48 | + | } | |||
| 49 | + | if !strings.Contains(css, "color: mix(var(--warning), var(--muted), 0.45);") { | |||
| 50 | + | t.Fatalf("expected favorite star color to be softened") | |||
| 51 | + | } | |||
| 52 | + | if strings.Contains(css, ".omnibox-row.omnibox-row-favorite .omnibox-favorite-star-slot") { | |||
| 53 | + | t.Fatalf("expected favorite highlight to move off the star slot") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) { | |||
| 58 | + | css := GenerateCSS(DefaultDarkPalette()) | |||
| 59 | + | ||||
| 60 | + | containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`) | |||
| 61 | + | if !containerRe.MatchString(css) { | |||
| 62 | + | t.Fatalf("expected omnibox container to use the header surface color") | |||
| 63 | + | } | |||
| 64 | + | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`) | |||
| 65 | + | if !entryRe.MatchString(css) { | |||
| 66 | + | t.Fatalf("expected omnibox entry and its text node to use the darker background fill") | |||
| 67 | + | } | |||
| 68 | + | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`) | |||
| 69 | + | if !focusedEntryRe.MatchString(css) { | |||
| 70 | + | t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill") | |||
| 71 | + | } | |||
| 72 | + | scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`) | |||
| 73 | + | if !scrolledRe.MatchString(css) { | |||
| 74 | + | t.Fatalf("expected omnibox results area to use the header surface color") | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestGenerateCSSWithScale_DoesNotEmitRootTextScaling(t *testing.T) { | |||
| 79 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |||
| 80 | + | ||||
| 81 | + | assert.NotContains(t, css, "/* UI Scaling (130%) */") | |||
| 82 | + | assert.NotContains(t, css, "window {\n\tfont-size:") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestGenerateCSSWithScale_DoesNotEmitLineHeightOverrides(t *testing.T) { | |||
| 86 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |||
| 87 | + | ||||
| 88 | + | assert.NotContains(t, css, "line-height:") | |||
| 89 | + | assert.NotContains(t, css, "label,\nentry,\nentry > text {\n\tline-height:") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestGenerateCSSWithScale_UsesOpaqueOmniboxRowBaseBackground(t *testing.T) { | |||
| 93 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |||
| 94 | + | ||||
| 95 | + | assert.Contains(t, css, ".omnibox-row {") | |||
| 96 | + | assert.Contains(t, css, "background-color: var(--surface);") | |||
| 97 | + | assert.NotContains(t, css, ".omnibox-row {\n\tpadding: 0.5em 0.75em;\n\tmargin: 0;\n\tborder-radius: 0;\n\tborder-left: 0.1875em solid transparent;\n\tborder-bottom: 0.0625em solid alpha(var(--border), 0.35);\n\tbackground-color: transparent;") | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testing.T) { | |||
| 101 | + | css := GenerateCSSWithScale(DefaultDarkPalette(), 1.3) | |||
| 102 | + | ||||
| 103 | + | assert.Contains(t, css, ".omnibox-suggestion-title {") | |||
| 104 | + | assert.Contains(t, css, "font-weight: 500;") | |||
| 105 | + | assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;") | |||
| 106 | + | } | |||
M internal/ui/theme/manager.go +36 -0
| @@ -3,6 +3,8 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 7 | + | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -21,6 +23,7 @@ type Manager struct { | |||
| 21 | 23 | fonts FontConfig | |
| 22 | 24 | modeColors ModeColors // Modal mode indicator colors | |
| 23 | 25 | cssProvider *gtk.CssProvider | |
| 26 | + | appliedFont string | |
| 24 | 27 | colorResolver port.ColorSchemeResolver // Resolver for dynamic detection | |
| 25 | 28 | } | |
| 26 | 29 | ||
| @@ -150,6 +153,27 @@ func (m *Manager) GetWebUIThemeCSS() string { | |||
| 150 | 153 | return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars) | |
| 151 | 154 | } | |
| 152 | 155 | ||
| 156 | + | const gtkDefaultFontPointSize = 11 | |
| 157 | + | ||
| 158 | + | func formatGTKFontName(family string, uiScale float64) string { | |
| 159 | + | family = strings.TrimSpace(family) | |
| 160 | + | if family == "" { | |
| 161 | + | family = DefaultFontConfig().SansFont | |
| 162 | + | } | |
| 163 | + | if uiScale <= 0 { | |
| 164 | + | uiScale = 1.0 | |
| 165 | + | } | |
| 166 | + | size := int(math.Round(gtkDefaultFontPointSize * uiScale)) | |
| 167 | + | if size < 1 { | |
| 168 | + | size = gtkDefaultFontPointSize | |
| 169 | + | } | |
| 170 | + | return fmt.Sprintf("%s %d", family, size) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func shouldApplyGTKFontName(current, next string) bool { | |
| 174 | + | return next != "" && next != current | |
| 175 | + | } | |
| 176 | + | ||
| 153 | 177 | // ApplyToDisplay loads the theme CSS into the display. | |
| 154 | 178 | func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |
| 155 | 179 | log := logging.FromContext(ctx) | |
| @@ -162,6 +186,18 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||
| 162 | 186 | // Generate CSS with current palette, UI scale, fonts, and mode colors | |
| 163 | 187 | palette := m.GetCurrentPalette() | |
| 164 | 188 | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) | |
| 189 | + | fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale) | |
| 190 | + | ||
| 191 | + | settings := gtk.SettingsGetForDisplay(display) | |
| 192 | + | if settings == nil { | |
| 193 | + | log.Warn().Msg("cannot apply theme font scaling: settings unavailable") | |
| 194 | + | } else if shouldApplyGTKFontName(m.appliedFont, fontName) { | |
| 195 | + | settings.SetPropertyGtkFontName(fontName) | |
| 196 | + | m.appliedFont = fontName | |
| 197 | + | settings.Unref() | |
| 198 | + | } else { | |
| 199 | + | settings.Unref() | |
| 200 | + | } | |
| 165 | 201 | ||
| 166 | 202 | // Create CSS provider if needed | |
| 167 | 203 | if m.cssProvider == nil { | |
| @@ -3,6 +3,8 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 7 | + | "strings" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -21,6 +23,7 @@ type Manager struct { | |||||
| 21 | fonts FontConfig | 23 | fonts FontConfig | ||
| 22 | modeColors ModeColors // Modal mode indicator colors | 24 | modeColors ModeColors // Modal mode indicator colors | ||
| 23 | cssProvider *gtk.CssProvider | 25 | cssProvider *gtk.CssProvider | ||
| 26 | + | appliedFont string | |||
| 24 | colorResolver port.ColorSchemeResolver // Resolver for dynamic detection | 27 | colorResolver port.ColorSchemeResolver // Resolver for dynamic detection | ||
| 25 | } | 28 | } | ||
| 26 | 29 | ||||
| @@ -150,6 +153,27 @@ func (m *Manager) GetWebUIThemeCSS() string { | |||||
| 150 | return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars) | 153 | return fmt.Sprintf(":root{\n%s}\n\n.dark{\n%s}\n", lightVars, darkVars) | ||
| 151 | } | 154 | } | ||
| 152 | 155 | ||||
| 156 | + | const gtkDefaultFontPointSize = 11 | |||
| 157 | + | ||||
| 158 | + | func formatGTKFontName(family string, uiScale float64) string { | |||
| 159 | + | family = strings.TrimSpace(family) | |||
| 160 | + | if family == "" { | |||
| 161 | + | family = DefaultFontConfig().SansFont | |||
| 162 | + | } | |||
| 163 | + | if uiScale <= 0 { | |||
| 164 | + | uiScale = 1.0 | |||
| 165 | + | } | |||
| 166 | + | size := int(math.Round(gtkDefaultFontPointSize * uiScale)) | |||
| 167 | + | if size < 1 { | |||
| 168 | + | size = gtkDefaultFontPointSize | |||
| 169 | + | } | |||
| 170 | + | return fmt.Sprintf("%s %d", family, size) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func shouldApplyGTKFontName(current, next string) bool { | |||
| 174 | + | return next != "" && next != current | |||
| 175 | + | } | |||
| 176 | + | ||||
| 153 | // ApplyToDisplay loads the theme CSS into the display. | 177 | // ApplyToDisplay loads the theme CSS into the display. | ||
| 154 | func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | 178 | func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | ||
| 155 | log := logging.FromContext(ctx) | 179 | log := logging.FromContext(ctx) | ||
| @@ -162,6 +186,18 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) { | |||||
| 162 | // Generate CSS with current palette, UI scale, fonts, and mode colors | 186 | // Generate CSS with current palette, UI scale, fonts, and mode colors | ||
| 163 | palette := m.GetCurrentPalette() | 187 | palette := m.GetCurrentPalette() | ||
| 164 | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) | 188 | css := GenerateCSSFull(palette, m.uiScale, m.fonts, m.modeColors) | ||
| 189 | + | fontName := formatGTKFontName(m.fonts.SansFont, m.uiScale) | |||
| 190 | + | ||||
| 191 | + | settings := gtk.SettingsGetForDisplay(display) | |||
| 192 | + | if settings == nil { | |||
| 193 | + | log.Warn().Msg("cannot apply theme font scaling: settings unavailable") | |||
| 194 | + | } else if shouldApplyGTKFontName(m.appliedFont, fontName) { | |||
| 195 | + | settings.SetPropertyGtkFontName(fontName) | |||
| 196 | + | m.appliedFont = fontName | |||
| 197 | + | settings.Unref() | |||
| 198 | + | } else { | |||
| 199 | + | settings.Unref() | |||
| 200 | + | } | |||
| 165 | 201 | ||||
| 166 | // Create CSS provider if needed | 202 | // Create CSS provider if needed | ||
| 167 | if m.cssProvider == nil { | 203 | if m.cssProvider == nil { | ||
M internal/ui/theme/manager_test.go +15 -0
| @@ -313,3 +313,18 @@ func TestManager_ModeColors(t *testing.T) { | |||
| 313 | 313 | assert.Equal(t, "#0000ff", modeColors.SessionMode) | |
| 314 | 314 | assert.Equal(t, "#ffff00", modeColors.ResizeMode) | |
| 315 | 315 | } | |
| 316 | + | ||
| 317 | + | func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) { | |
| 318 | + | assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3)) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) { | |
| 322 | + | assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0)) | |
| 323 | + | assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05)) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestShouldApplyGTKFontName(t *testing.T) { | |
| 327 | + | assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14")) | |
| 328 | + | assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14")) | |
| 329 | + | assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "")) | |
| 330 | + | } | |
| @@ -313,3 +313,18 @@ func TestManager_ModeColors(t *testing.T) { | |||||
| 313 | assert.Equal(t, "#0000ff", modeColors.SessionMode) | 313 | assert.Equal(t, "#0000ff", modeColors.SessionMode) | ||
| 314 | assert.Equal(t, "#ffff00", modeColors.ResizeMode) | 314 | assert.Equal(t, "#ffff00", modeColors.ResizeMode) | ||
| 315 | } | 315 | } | ||
| 316 | + | ||||
| 317 | + | func TestFormatGTKFontName_UsesScaledConfiguredSansFont(t *testing.T) { | |||
| 318 | + | assert.Equal(t, "Fira Sans 14", formatGTKFontName("Fira Sans", 1.3)) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func TestFormatGTKFontName_DefaultsScaleAndRoundsToNearestPoint(t *testing.T) { | |||
| 322 | + | assert.Equal(t, "Inter 11", formatGTKFontName("Inter", 0)) | |||
| 323 | + | assert.Equal(t, "Inter 12", formatGTKFontName("Inter", 1.05)) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func TestShouldApplyGTKFontName(t *testing.T) { | |||
| 327 | + | assert.True(t, shouldApplyGTKFontName("", "Fira Sans 14")) | |||
| 328 | + | assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "Fira Sans 14")) | |||
| 329 | + | assert.False(t, shouldApplyGTKFontName("Fira Sans 14", "")) | |||
| 330 | + | } | |||