fix: rewrite omnibox ghostwriter to eliminate letter-eating on fast typing
The ghost text system had race conditions where SetText calls from async search results would overwrite characters typed between search start and result delivery. Replaced goroutine+token+coalescer machinery with synchronous ghost computation, a guard flag for programmatic SetText, debounced echo detection, and stale input checks. Also bumps puregotk to v0.2.1 (fixes InsertText int* parameter and GError signal callback typing).
- commit
- 3a4e710331b5d60ff69ab6214dad16fa29632864
- author
- brice · · 2026-03-27 19:28 UTC
- parent
- 459eca0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +285 -391
M go.mod +3 -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.0 | |
| 7 | + | github.com/bnema/puregotk v0.2.1 | |
| 8 | 8 | github.com/bnema/puregotk-webkit v0.0.15 | |
| 9 | 9 | github.com/charmbracelet/bubbles v1.0.0 | |
| 10 | 10 | github.com/charmbracelet/bubbletea v1.3.10 | |
| @@ -25,6 +25,8 @@ require ( | |||
| 25 | 25 | ||
| 26 | 26 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | |
| 27 | 27 | ||
| 28 | + | replace github.com/bnema/puregotk => /home/brice/dev/projects/puregotk | |
| 29 | + | ||
| 28 | 30 | require ( | |
| 29 | 31 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 30 | 32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| @@ -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.0 | 7 | + | github.com/bnema/puregotk v0.2.1 |
| 8 | github.com/bnema/puregotk-webkit v0.0.15 | 8 | github.com/bnema/puregotk-webkit v0.0.15 | ||
| 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 | ||
| @@ -25,6 +25,8 @@ require ( | |||||
| 25 | 25 | ||||
| 26 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | 26 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | ||
| 27 | 27 | ||||
| 28 | + | replace github.com/bnema/puregotk => /home/brice/dev/projects/puregotk | |||
| 29 | + | ||||
| 28 | require ( | 30 | require ( | ||
| 29 | github.com/atotto/clipboard v0.1.4 // indirect | 31 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 30 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
M go.sum +0 -2 generated file: collapsed
M internal/application/usecase/handle_permission.go +44 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/rs/zerolog" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -57,6 +58,31 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger { | |||
| 57 | 58 | return log | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 61 | + | func containsPermissionType(permTypes []entity.PermissionType, target entity.PermissionType) bool { | |
| 62 | + | for _, permType := range permTypes { | |
| 63 | + | if permType == target { | |
| 64 | + | return true | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger { | |
| 71 | + | if log == nil { | |
| 72 | + | nop := zerolog.Nop() | |
| 73 | + | return &nop | |
| 74 | + | } | |
| 75 | + | ctx := log.With() | |
| 76 | + | if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" { | |
| 77 | + | ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, 96)) | |
| 78 | + | } | |
| 79 | + | if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" { | |
| 80 | + | ctx = ctx.Str("current_domain", logging.TruncateURL(current, 96)) | |
| 81 | + | } | |
| 82 | + | enriched := ctx.Logger() | |
| 83 | + | return &enriched | |
| 84 | + | } | |
| 85 | + | ||
| 60 | 86 | // SetDialogPresenter sets the dialog presenter. This can be called after | |
| 61 | 87 | // initialization when the UI window is available. | |
| 62 | 88 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | |
| @@ -127,10 +153,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 127 | 153 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | |
| 128 | 154 | switch decision { | |
| 129 | 155 | case entity.PermissionGranted: | |
| 156 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |
| 157 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |
| 158 | + | Msg("using stored website data access permission: granted") | |
| 159 | + | } | |
| 130 | 160 | log.Debug().Msg("using stored permission: granted") | |
| 131 | 161 | callback.Allow() | |
| 132 | 162 | return | |
| 133 | 163 | case entity.PermissionDenied: | |
| 164 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |
| 165 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |
| 166 | + | Msg("using stored website data access permission: denied") | |
| 167 | + | } | |
| 134 | 168 | log.Debug().Msg("using stored permission: denied") | |
| 135 | 169 | callback.Deny() | |
| 136 | 170 | return | |
| @@ -269,6 +303,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||
| 269 | 303 | ||
| 270 | 304 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | |
| 271 | 305 | if result.Allowed { | |
| 306 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |
| 307 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |
| 308 | + | Bool("persistent", result.Persistent). | |
| 309 | + | Msg("website data access permission allowed") | |
| 310 | + | } | |
| 272 | 311 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | |
| 273 | 312 | callback.Allow() | |
| 274 | 313 | ||
| @@ -277,6 +316,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||
| 277 | 316 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | |
| 278 | 317 | } | |
| 279 | 318 | } else { | |
| 319 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |
| 320 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |
| 321 | + | Bool("persistent", result.Persistent). | |
| 322 | + | Msg("website data access permission denied") | |
| 323 | + | } | |
| 280 | 324 | log.Debug().Msg("user denied permission") | |
| 281 | 325 | callback.Deny() | |
| 282 | 326 | ||
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | "github.com/rs/zerolog" | 13 | "github.com/rs/zerolog" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -57,6 +58,31 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger { | |||||
| 57 | return log | 58 | return log | ||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 61 | + | func containsPermissionType(permTypes []entity.PermissionType, target entity.PermissionType) bool { | |||
| 62 | + | for _, permType := range permTypes { | |||
| 63 | + | if permType == target { | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger { | |||
| 71 | + | if log == nil { | |||
| 72 | + | nop := zerolog.Nop() | |||
| 73 | + | return &nop | |||
| 74 | + | } | |||
| 75 | + | ctx := log.With() | |||
| 76 | + | if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" { | |||
| 77 | + | ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, 96)) | |||
| 78 | + | } | |||
| 79 | + | if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" { | |||
| 80 | + | ctx = ctx.Str("current_domain", logging.TruncateURL(current, 96)) | |||
| 81 | + | } | |||
| 82 | + | enriched := ctx.Logger() | |||
| 83 | + | return &enriched | |||
| 84 | + | } | |||
| 85 | + | ||||
| 60 | // SetDialogPresenter sets the dialog presenter. This can be called after | 86 | // SetDialogPresenter sets the dialog presenter. This can be called after | ||
| 61 | // initialization when the UI window is available. | 87 | // initialization when the UI window is available. | ||
| 62 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | 88 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | ||
| @@ -127,10 +153,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||||
| 127 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | 153 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | ||
| 128 | switch decision { | 154 | switch decision { | ||
| 129 | case entity.PermissionGranted: | 155 | case entity.PermissionGranted: | ||
| 156 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |||
| 157 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |||
| 158 | + | Msg("using stored website data access permission: granted") | |||
| 159 | + | } | |||
| 130 | log.Debug().Msg("using stored permission: granted") | 160 | log.Debug().Msg("using stored permission: granted") | ||
| 131 | callback.Allow() | 161 | callback.Allow() | ||
| 132 | return | 162 | return | ||
| 133 | case entity.PermissionDenied: | 163 | case entity.PermissionDenied: | ||
| 164 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |||
| 165 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |||
| 166 | + | Msg("using stored website data access permission: denied") | |||
| 167 | + | } | |||
| 134 | log.Debug().Msg("using stored permission: denied") | 168 | log.Debug().Msg("using stored permission: denied") | ||
| 135 | callback.Deny() | 169 | callback.Deny() | ||
| 136 | return | 170 | return | ||
| @@ -269,6 +303,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||||
| 269 | 303 | ||||
| 270 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | 304 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | ||
| 271 | if result.Allowed { | 305 | if result.Allowed { | ||
| 306 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |||
| 307 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |||
| 308 | + | Bool("persistent", result.Persistent). | |||
| 309 | + | Msg("website data access permission allowed") | |||
| 310 | + | } | |||
| 272 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | 311 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | ||
| 273 | callback.Allow() | 312 | callback.Allow() | ||
| 274 | 313 | ||||
| @@ -277,6 +316,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||||
| 277 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | 316 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | ||
| 278 | } | 317 | } | ||
| 279 | } else { | 318 | } else { | ||
| 319 | + | if containsPermissionType(permTypes, entity.PermissionTypeWebsiteDataAccess) { | |||
| 320 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |||
| 321 | + | Bool("persistent", result.Persistent). | |||
| 322 | + | Msg("website data access permission denied") | |||
| 323 | + | } | |||
| 280 | log.Debug().Msg("user denied permission") | 324 | log.Debug().Msg("user denied permission") | ||
| 281 | callback.Deny() | 325 | callback.Deny() | ||
| 282 | 326 | ||||
M internal/infrastructure/webkit/webview.go +30 -0
| @@ -419,6 +419,7 @@ func (wv *WebView) connectSignals() { | |||
| 419 | 419 | wv.connectFaviconSignal() | |
| 420 | 420 | wv.connectProgressSignal() | |
| 421 | 421 | wv.connectLoadFailedSignal() | |
| 422 | + | wv.connectWebProcessResponsiveSignal() | |
| 422 | 423 | wv.connectDecidePolicySignal() | |
| 423 | 424 | wv.connectEnterFullscreenSignal() | |
| 424 | 425 | wv.connectLeaveFullscreenSignal() | |
| @@ -480,6 +481,28 @@ func (wv *WebView) connectLoadFailedSignal() { | |||
| 480 | 481 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 481 | 482 | } | |
| 482 | 483 | ||
| 484 | + | func (wv *WebView) connectWebProcessResponsiveSignal() { | |
| 485 | + | responsiveCb := func() { | |
| 486 | + | responsive := wv.inner.GetPropertyIsWebProcessResponsive() | |
| 487 | + | event := wv.logger.With(). | |
| 488 | + | Uint64("id", uint64(wv.id)). | |
| 489 | + | Str("uri", wv.inner.GetUri()). | |
| 490 | + | Bool("responsive", responsive). | |
| 491 | + | Logger() | |
| 492 | + | if responsive { | |
| 493 | + | event.Info().Msg("web process responsiveness changed") | |
| 494 | + | return | |
| 495 | + | } | |
| 496 | + | event.Warn().Msg("web process became unresponsive") | |
| 497 | + | } | |
| 498 | + | sigID := gobject.SignalConnect( | |
| 499 | + | wv.inner.GoPointer(), | |
| 500 | + | "notify::is-web-process-responsive", | |
| 501 | + | glib.NewCallback(&responsiveCb), | |
| 502 | + | ) | |
| 503 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |
| 504 | + | } | |
| 505 | + | ||
| 483 | 506 | func (wv *WebView) connectCloseSignal() { | |
| 484 | 507 | closeCb := func(_ webkit.WebView) { | |
| 485 | 508 | if wv.OnClose != nil { | |
| @@ -1024,6 +1047,13 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||
| 1024 | 1047 | wv.logger.Warn().Msg("permission request with unknown type, denying") | |
| 1025 | 1048 | return false | |
| 1026 | 1049 | } | |
| 1050 | + | if len(permTypes) == 1 && permTypes[0] == "website_data_access" { | |
| 1051 | + | wv.logger.Info(). | |
| 1052 | + | Str("origin", origin). | |
| 1053 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |
| 1054 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |
| 1055 | + | Msg("website data access permission requested") | |
| 1056 | + | } | |
| 1027 | 1057 | ||
| 1028 | 1058 | // Ref the request object to prevent use-after-free | |
| 1029 | 1059 | // The request may outlive the signal handler if we show a dialog | |
| @@ -419,6 +419,7 @@ func (wv *WebView) connectSignals() { | |||||
| 419 | wv.connectFaviconSignal() | 419 | wv.connectFaviconSignal() | ||
| 420 | wv.connectProgressSignal() | 420 | wv.connectProgressSignal() | ||
| 421 | wv.connectLoadFailedSignal() | 421 | wv.connectLoadFailedSignal() | ||
| 422 | + | wv.connectWebProcessResponsiveSignal() | |||
| 422 | wv.connectDecidePolicySignal() | 423 | wv.connectDecidePolicySignal() | ||
| 423 | wv.connectEnterFullscreenSignal() | 424 | wv.connectEnterFullscreenSignal() | ||
| 424 | wv.connectLeaveFullscreenSignal() | 425 | wv.connectLeaveFullscreenSignal() | ||
| @@ -480,6 +481,28 @@ func (wv *WebView) connectLoadFailedSignal() { | |||||
| 480 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | 481 | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | ||
| 481 | } | 482 | } | ||
| 482 | 483 | ||||
| 484 | + | func (wv *WebView) connectWebProcessResponsiveSignal() { | |||
| 485 | + | responsiveCb := func() { | |||
| 486 | + | responsive := wv.inner.GetPropertyIsWebProcessResponsive() | |||
| 487 | + | event := wv.logger.With(). | |||
| 488 | + | Uint64("id", uint64(wv.id)). | |||
| 489 | + | Str("uri", wv.inner.GetUri()). | |||
| 490 | + | Bool("responsive", responsive). | |||
| 491 | + | Logger() | |||
| 492 | + | if responsive { | |||
| 493 | + | event.Info().Msg("web process responsiveness changed") | |||
| 494 | + | return | |||
| 495 | + | } | |||
| 496 | + | event.Warn().Msg("web process became unresponsive") | |||
| 497 | + | } | |||
| 498 | + | sigID := gobject.SignalConnect( | |||
| 499 | + | wv.inner.GoPointer(), | |||
| 500 | + | "notify::is-web-process-responsive", | |||
| 501 | + | glib.NewCallback(&responsiveCb), | |||
| 502 | + | ) | |||
| 503 | + | wv.signalIDs = append(wv.signalIDs, uintptr(sigID)) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 483 | func (wv *WebView) connectCloseSignal() { | 506 | func (wv *WebView) connectCloseSignal() { | ||
| 484 | closeCb := func(_ webkit.WebView) { | 507 | closeCb := func(_ webkit.WebView) { | ||
| 485 | if wv.OnClose != nil { | 508 | if wv.OnClose != nil { | ||
| @@ -1024,6 +1047,13 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||||
| 1024 | wv.logger.Warn().Msg("permission request with unknown type, denying") | 1047 | wv.logger.Warn().Msg("permission request with unknown type, denying") | ||
| 1025 | return false | 1048 | return false | ||
| 1026 | } | 1049 | } | ||
| 1050 | + | if len(permTypes) == 1 && permTypes[0] == "website_data_access" { | |||
| 1051 | + | wv.logger.Info(). | |||
| 1052 | + | Str("origin", origin). | |||
| 1053 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |||
| 1054 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |||
| 1055 | + | Msg("website data access permission requested") | |||
| 1056 | + | } | |||
| 1027 | 1057 | ||||
| 1028 | // Ref the request object to prevent use-after-free | 1058 | // Ref the request object to prevent use-after-free | ||
| 1029 | // The request may outlive the signal handler if we show a dialog | 1059 | // The request may outlive the signal handler if we show a dialog | ||
M internal/ui/component/omnibox.go +179 -212
| @@ -95,11 +95,10 @@ type Omnibox struct { | |||
| 95 | 95 | ||
| 96 | 96 | // Ghost text state | |
| 97 | 97 | realInput string // What user actually typed (without ghost suffix) | |
| 98 | - | ghostSuffix string // Completion suffix to display | |
| 99 | - | ghostFullText string // Full text that ghost completes to | |
| 98 | + | ghostSuffix string // Completion suffix currently displayed | |
| 100 | 99 | hasGhostText bool // Quick check for Tab behavior | |
| 101 | - | isAcceptingGhost bool // Prevent re-trigger during acceptance | |
| 102 | - | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |
| 100 | + | insertCompletion bool // True on character insert, false on delete — gates ghost completion | |
| 101 | + | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | |
| 103 | 102 | ||
| 104 | 103 | // Dependencies | |
| 105 | 104 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -123,7 +122,6 @@ type Omnibox struct { | |||
| 123 | 122 | debounceMu sync.Mutex | |
| 124 | 123 | lastQuery string // Prevent duplicate searches | |
| 125 | 124 | searchToken uint64 | |
| 126 | - | ghostToken uint64 | |
| 127 | 125 | ||
| 128 | 126 | // Scaling | |
| 129 | 127 | uiScale float64 | |
| @@ -638,7 +636,6 @@ func (o *Omnibox) assembleWidgets() { | |||
| 638 | 636 | ||
| 639 | 637 | // setupEntryChanged wires entry text changes to debounced search. | |
| 640 | 638 | func (o *Omnibox) setupEntryChanged() { | |
| 641 | - | // SearchEntry has built-in debouncing via search-changed signal | |
| 642 | 639 | changedCb := func(_ gtk.SearchEntry) { | |
| 643 | 640 | o.onEntryChanged() | |
| 644 | 641 | } | |
| @@ -694,7 +691,7 @@ func (o *Omnibox) hasUserNavigated() bool { | |||
| 694 | 691 | // handleKeyPress processes keyboard events. | |
| 695 | 692 | // Returns true if the event was handled. | |
| 696 | 693 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 697 | - | o.updateGhostSuppressionFromKey(keyval) | |
| 694 | + | o.updateInsertCompletionFromKey(keyval) | |
| 698 | 695 | ||
| 699 | 696 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 700 | 697 | ||
| @@ -767,16 +764,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 767 | 764 | return false // Let entry handle the key | |
| 768 | 765 | } | |
| 769 | 766 | ||
| 770 | - | func nextGhostSuppressionState(keyval uint) bool { | |
| 771 | - | return isDeletionKey(keyval) | |
| 772 | - | } | |
| 773 | - | ||
| 774 | - | func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) { | |
| 775 | - | o.mu.Lock() | |
| 776 | - | o.suppressGhost = nextGhostSuppressionState(keyval) | |
| 777 | - | o.mu.Unlock() | |
| 778 | - | } | |
| 779 | - | ||
| 780 | 767 | func isDeletionKey(keyval uint) bool { | |
| 781 | 768 | switch keyval { | |
| 782 | 769 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |
| @@ -786,6 +773,28 @@ func isDeletionKey(keyval uint) bool { | |||
| 786 | 773 | } | |
| 787 | 774 | } | |
| 788 | 775 | ||
| 776 | + | // updateInsertCompletionFromKey sets whether the next text change should trigger | |
| 777 | + | // ghost completion. Character insertion enables it; deletion disables it. | |
| 778 | + | func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) { | |
| 779 | + | o.mu.Lock() | |
| 780 | + | o.insertCompletion = !isDeletionKey(keyval) | |
| 781 | + | o.mu.Unlock() | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | // withGhostGuard executes fn while the isSettingGhost flag is set, | |
| 785 | + | // so that onEntryChanged skips during programmatic text changes. | |
| 786 | + | func (o *Omnibox) withGhostGuard(fn func()) { | |
| 787 | + | o.mu.Lock() | |
| 788 | + | o.isSettingGhost = true | |
| 789 | + | o.mu.Unlock() | |
| 790 | + | ||
| 791 | + | fn() | |
| 792 | + | ||
| 793 | + | o.mu.Lock() | |
| 794 | + | o.isSettingGhost = false | |
| 795 | + | o.mu.Unlock() | |
| 796 | + | } | |
| 797 | + | ||
| 789 | 798 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | |
| 790 | 799 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | |
| 791 | 800 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | |
| @@ -823,42 +832,64 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||
| 823 | 832 | } | |
| 824 | 833 | ||
| 825 | 834 | // onEntryChanged handles text input changes with debouncing. | |
| 835 | + | // When this fires, GTK has already processed the keystroke — if ghost text was | |
| 836 | + | // selected and the user typed, the selection was replaced naturally by GTK. | |
| 837 | + | // We must NOT call clearGhostText() here (that would delete real user text). | |
| 826 | 838 | func (o *Omnibox) onEntryChanged() { | |
| 827 | - | // Skip if we're programmatically accepting ghost text | |
| 839 | + | log := logging.FromContext(o.ctx) | |
| 840 | + | ||
| 841 | + | // Skip if we're programmatically setting ghost text (synchronous guard). | |
| 828 | 842 | o.mu.RLock() | |
| 829 | - | isAccepting := o.isAcceptingGhost | |
| 843 | + | isSettingGhost := o.isSettingGhost | |
| 830 | 844 | o.mu.RUnlock() | |
| 831 | - | if isAccepting { | |
| 845 | + | if isSettingGhost { | |
| 846 | + | log.Debug().Msg("ghost: onEntryChanged skipped (isSettingGhost)") | |
| 832 | 847 | return | |
| 833 | 848 | } | |
| 834 | 849 | ||
| 835 | 850 | entryText := o.entry.GetText() | |
| 836 | - | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |
| 837 | - | o.entry.SetText(trimmed) | |
| 838 | - | o.entry.SetPosition(-1) | |
| 839 | - | return | |
| 840 | - | } | |
| 841 | 851 | ||
| 842 | - | if o.isGhostProgrammaticEcho(entryText) { | |
| 852 | + | // Detect debounced echo from our own SetText in setGhostText. | |
| 853 | + | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | |
| 854 | + | // the isSettingGhost guard is already off. Recognize it by checking | |
| 855 | + | // if the entry text matches realInput + ghostSuffix exactly. | |
| 856 | + | o.mu.RLock() | |
| 857 | + | if o.hasGhostText && o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | |
| 858 | + | o.mu.RUnlock() | |
| 859 | + | log.Debug().Str("entryText", entryText).Msg("ghost: onEntryChanged skipped (debounced echo)") | |
| 843 | 860 | return | |
| 844 | 861 | } | |
| 862 | + | o.mu.RUnlock() | |
| 863 | + | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |
| 864 | + | o.withGhostGuard(func() { | |
| 865 | + | o.entry.SetText(trimmed) | |
| 866 | + | o.entry.SetPosition(-1) | |
| 867 | + | }) | |
| 868 | + | entryText = trimmed | |
| 869 | + | } | |
| 845 | 870 | ||
| 846 | - | // Reset navigation state when user types - space should type, not toggle favorite | |
| 871 | + | // Reset ghost state (the buffer is already correct — GTK handled it). | |
| 847 | 872 | o.mu.Lock() | |
| 848 | - | previousInput := o.realInput | |
| 873 | + | oldGhost := o.ghostSuffix | |
| 849 | 874 | o.hasNavigated = false | |
| 850 | - | // Update realInput with what user actually typed | |
| 875 | + | o.hasGhostText = false | |
| 876 | + | o.ghostSuffix = "" | |
| 851 | 877 | o.realInput = entryText | |
| 878 | + | shouldComplete := o.insertCompletion | |
| 852 | 879 | o.mu.Unlock() | |
| 853 | 880 | ||
| 854 | - | // Clear ghost text when user types (it will be recalculated after search) | |
| 855 | - | o.clearGhostText() | |
| 856 | - | // Recompute immediately from currently visible rows to avoid transient gaps | |
| 857 | - | // where suggestions are visible but ghost text is missing until async search returns. | |
| 858 | - | if shouldUpdateGhostImmediately(previousInput, entryText) { | |
| 881 | + | log.Debug(). | |
| 882 | + | Str("entryText", entryText). | |
| 883 | + | Str("oldGhost", oldGhost). | |
| 884 | + | Bool("insertCompletion", shouldComplete). | |
| 885 | + | Msg("ghost: onEntryChanged") | |
| 886 | + | ||
| 887 | + | // Compute ghost completion synchronously if this was a character insertion. | |
| 888 | + | if shouldComplete { | |
| 859 | 889 | o.updateGhostFromSelectionWithInput(entryText) | |
| 860 | 890 | } | |
| 861 | 891 | ||
| 892 | + | // Schedule async search for suggestion list updates. | |
| 862 | 893 | o.debounceMu.Lock() | |
| 863 | 894 | if o.debounceTimer != nil { | |
| 864 | 895 | o.debounceTimer.Stop() | |
| @@ -869,131 +900,89 @@ func (o *Omnibox) onEntryChanged() { | |||
| 869 | 900 | o.debounceMu.Unlock() | |
| 870 | 901 | } | |
| 871 | 902 | ||
| 872 | - | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | |
| 873 | - | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | |
| 874 | - | } | |
| 903 | + | // setGhostText applies inline completion by setting the full text (input + suffix) | |
| 904 | + | // and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged. | |
| 905 | + | // Skips if the user has typed since this ghost was computed (stale input check). | |
| 906 | + | func (o *Omnibox) setGhostText(originalInput, suffix string) { | |
| 907 | + | log := logging.FromContext(o.ctx) | |
| 908 | + | if o.entry == nil || suffix == "" { | |
| 909 | + | o.clearGhostText() | |
| 910 | + | return | |
| 911 | + | } | |
| 875 | 912 | ||
| 876 | - | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 913 | + | // Verify the user hasn't typed since this ghost was computed. | |
| 877 | 914 | o.mu.RLock() | |
| 878 | - | hasGhost := o.hasGhostText | |
| 879 | - | realInput := o.realInput | |
| 880 | - | ghostFullText := o.ghostFullText | |
| 915 | + | currentInput := o.realInput | |
| 881 | 916 | o.mu.RUnlock() | |
| 882 | - | ||
| 883 | - | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | |
| 884 | - | return false | |
| 885 | - | } | |
| 886 | - | ||
| 887 | - | selectionStart := 0 | |
| 888 | - | selectionEnd := 0 | |
| 889 | - | hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd) | |
| 890 | - | return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection) | |
| 891 | - | } | |
| 892 | - | ||
| 893 | - | func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool { | |
| 894 | - | if !hasSelection { | |
| 895 | - | return false | |
| 896 | - | } | |
| 897 | - | prefixRunes := utf8.RuneCountInString(realInput) | |
| 898 | - | fullRunes := utf8.RuneCountInString(ghostFullText) | |
| 899 | - | if fullRunes <= prefixRunes { | |
| 900 | - | return false | |
| 901 | - | } | |
| 902 | - | return selectionStart == prefixRunes && selectionEnd == fullRunes | |
| 903 | - | } | |
| 904 | - | ||
| 905 | - | // setGhostText applies inline completion using entry selection. | |
| 906 | - | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | |
| 907 | - | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |
| 908 | - | if o.entry == nil || suffix == "" { | |
| 909 | - | o.clearGhostText() | |
| 917 | + | if currentInput != originalInput { | |
| 918 | + | log.Debug(). | |
| 919 | + | Str("staleInput", originalInput). | |
| 920 | + | Str("currentInput", currentInput). | |
| 921 | + | Str("suffix", suffix). | |
| 922 | + | Msg("ghost: setGhostText skipped (stale input)") | |
| 910 | 923 | return | |
| 911 | 924 | } | |
| 912 | 925 | ||
| 913 | 926 | displayText := originalInput + suffix | |
| 914 | - | if fullText != "" { | |
| 915 | - | displayText = fullText | |
| 916 | - | } | |
| 917 | - | selectionStart := utf8.RuneCountInString(originalInput) | |
| 918 | - | selectionEnd := utf8.RuneCountInString(displayText) | |
| 927 | + | cursorPos := utf8.RuneCountInString(originalInput) | |
| 928 | + | endPos := utf8.RuneCountInString(displayText) | |
| 929 | + | ||
| 930 | + | log.Debug(). | |
| 931 | + | Str("input", originalInput). | |
| 932 | + | Str("suffix", suffix). | |
| 933 | + | Int("cursorPos", cursorPos). | |
| 934 | + | Int("endPos", endPos). | |
| 935 | + | Msg("ghost: setGhostText") | |
| 919 | 936 | ||
| 920 | 937 | o.mu.Lock() | |
| 921 | 938 | o.realInput = originalInput | |
| 922 | 939 | o.ghostSuffix = suffix | |
| 923 | - | o.ghostFullText = fullText | |
| 924 | 940 | o.hasGhostText = true | |
| 925 | - | capturedToken := o.ghostToken | |
| 926 | 941 | o.mu.Unlock() | |
| 927 | 942 | ||
| 928 | - | o.idleCoalescer.Post("omnibox-ghost-set", func() { | |
| 929 | - | if o.entry == nil { | |
| 930 | - | return | |
| 931 | - | } | |
| 932 | - | ||
| 933 | - | o.mu.RLock() | |
| 934 | - | currentInput := o.realInput | |
| 935 | - | currentToken := o.ghostToken | |
| 936 | - | o.mu.RUnlock() | |
| 937 | - | if currentInput != originalInput { | |
| 938 | - | return | |
| 939 | - | } | |
| 940 | - | if capturedToken != currentToken { | |
| 941 | - | return | |
| 942 | - | } | |
| 943 | - | o.mu.Lock() | |
| 944 | - | o.isAcceptingGhost = true | |
| 945 | - | o.mu.Unlock() | |
| 946 | - | ||
| 943 | + | o.withGhostGuard(func() { | |
| 947 | 944 | o.entry.SetText(displayText) | |
| 948 | - | o.entry.SetPosition(selectionStart) | |
| 949 | - | o.entry.SelectRegion(selectionStart, selectionEnd) | |
| 950 | - | ||
| 951 | - | o.mu.Lock() | |
| 952 | - | o.isAcceptingGhost = false | |
| 953 | - | o.mu.Unlock() | |
| 945 | + | o.entry.SetPosition(cursorPos) | |
| 946 | + | o.entry.SelectRegion(cursorPos, endPos) | |
| 954 | 947 | }) | |
| 955 | 948 | } | |
| 956 | 949 | ||
| 957 | - | // clearGhostText hides the ghost completion text. | |
| 950 | + | // clearGhostText removes any ghost suffix from the entry buffer. | |
| 951 | + | // Uses guard flag to prevent re-entrant onEntryChanged. | |
| 958 | 952 | func (o *Omnibox) clearGhostText() { | |
| 953 | + | log := logging.FromContext(o.ctx) | |
| 959 | 954 | o.mu.Lock() | |
| 955 | + | hadGhost := o.hasGhostText | |
| 956 | + | ghostSuffix := o.ghostSuffix | |
| 960 | 957 | realInput := o.realInput | |
| 961 | - | ghostFullText := o.ghostFullText | |
| 962 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 963 | - | o.ghostToken++ | |
| 964 | - | capturedToken := o.ghostToken | |
| 965 | 958 | o.ghostSuffix = "" | |
| 966 | - | o.ghostFullText = "" | |
| 967 | 959 | o.hasGhostText = false | |
| 968 | 960 | o.mu.Unlock() | |
| 969 | 961 | ||
| 970 | - | if !hadGhost || o.entry == nil { | |
| 962 | + | if !hadGhost || ghostSuffix == "" || o.entry == nil { | |
| 971 | 963 | return | |
| 972 | 964 | } | |
| 973 | 965 | ||
| 974 | - | o.idleCoalescer.Post("omnibox-ghost-clear", func() { | |
| 975 | - | if o.entry == nil { | |
| 976 | - | return | |
| 977 | - | } | |
| 978 | - | o.mu.RLock() | |
| 979 | - | currentToken := o.ghostToken | |
| 980 | - | o.mu.RUnlock() | |
| 981 | - | if capturedToken != currentToken { | |
| 982 | - | return | |
| 983 | - | } | |
| 984 | - | o.mu.Lock() | |
| 985 | - | o.isAcceptingGhost = true | |
| 986 | - | o.mu.Unlock() | |
| 987 | - | // Only revert text when the entry still shows the previous ghost completion. | |
| 988 | - | // If the user typed since scheduling this idle callback, never overwrite it. | |
| 989 | - | if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput { | |
| 990 | - | o.entry.SetText(realInput) | |
| 991 | - | } | |
| 966 | + | // Only touch the buffer if it still shows the expected ghost text. | |
| 967 | + | expectedText := realInput + ghostSuffix | |
| 968 | + | currentText := o.entry.GetText() | |
| 969 | + | if currentText != expectedText { | |
| 970 | + | log.Debug(). | |
| 971 | + | Str("expected", expectedText). | |
| 972 | + | Str("current", currentText). | |
| 973 | + | Msg("ghost: clearGhostText skipped (buffer changed)") | |
| 974 | + | return | |
| 975 | + | } | |
| 976 | + | ||
| 977 | + | log.Debug(). | |
| 978 | + | Str("realInput", realInput). | |
| 979 | + | Str("ghostSuffix", ghostSuffix). | |
| 980 | + | Msg("ghost: clearGhostText") | |
| 981 | + | ||
| 982 | + | o.withGhostGuard(func() { | |
| 983 | + | o.entry.SetText(realInput) | |
| 992 | 984 | o.entry.SelectRegion(-1, -1) | |
| 993 | 985 | o.entry.SetPosition(-1) | |
| 994 | - | o.mu.Lock() | |
| 995 | - | o.isAcceptingGhost = false | |
| 996 | - | o.mu.Unlock() | |
| 997 | 986 | }) | |
| 998 | 987 | } | |
| 999 | 988 | ||
| @@ -1009,45 +998,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||
| 1009 | 998 | o.clearGhostText() | |
| 1010 | 999 | } | |
| 1011 | 1000 | ||
| 1012 | - | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 1001 | + | // acceptGhostCompletion accepts the ghost text — the suffix is already in the | |
| 1002 | + | // buffer, so we just deselect and update realInput to include it. | |
| 1013 | 1003 | func (o *Omnibox) acceptGhostCompletion() { | |
| 1004 | + | log := logging.FromContext(o.ctx) | |
| 1014 | 1005 | o.mu.Lock() | |
| 1015 | - | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | |
| 1016 | - | if !accepted { | |
| 1006 | + | if !o.hasGhostText || o.ghostSuffix == "" { | |
| 1017 | 1007 | o.mu.Unlock() | |
| 1008 | + | log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept") | |
| 1018 | 1009 | return | |
| 1019 | 1010 | } | |
| 1020 | - | o.isAcceptingGhost = true | |
| 1021 | - | o.realInput = fullText | |
| 1022 | - | o.selectedIndex = -1 | |
| 1023 | - | o.hasNavigated = false | |
| 1024 | - | o.ghostToken++ | |
| 1025 | - | o.ghostSuffix = "" | |
| 1026 | - | o.ghostFullText = "" | |
| 1011 | + | suffix := o.ghostSuffix | |
| 1027 | 1012 | o.hasGhostText = false | |
| 1013 | + | o.ghostSuffix = "" | |
| 1028 | 1014 | o.mu.Unlock() | |
| 1015 | + | log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion") | |
| 1029 | 1016 | ||
| 1030 | 1017 | if o.entry != nil { | |
| 1031 | - | if o.entry.GetText() != fullText { | |
| 1032 | - | o.entry.SetText(fullText) | |
| 1033 | - | } | |
| 1034 | - | o.entry.SelectRegion(-1, -1) | |
| 1035 | - | o.entry.SetPosition(-1) | |
| 1018 | + | // The suffix is already in the buffer as selected text. | |
| 1019 | + | // Just deselect and move cursor to end. | |
| 1020 | + | fullText := o.entry.GetText() | |
| 1021 | + | o.mu.Lock() | |
| 1022 | + | o.realInput = fullText | |
| 1023 | + | o.selectedIndex = -1 | |
| 1024 | + | o.hasNavigated = false | |
| 1025 | + | o.mu.Unlock() | |
| 1026 | + | ||
| 1027 | + | o.withGhostGuard(func() { | |
| 1028 | + | o.entry.SelectRegion(-1, -1) | |
| 1029 | + | o.entry.SetPosition(-1) | |
| 1030 | + | }) | |
| 1036 | 1031 | } | |
| 1037 | 1032 | if o.listBox != nil { | |
| 1038 | 1033 | o.listBox.SelectRow(nil) | |
| 1039 | 1034 | } | |
| 1040 | - | ||
| 1041 | - | o.mu.Lock() | |
| 1042 | - | o.isAcceptingGhost = false | |
| 1043 | - | o.mu.Unlock() | |
| 1044 | - | } | |
| 1045 | - | ||
| 1046 | - | func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) { | |
| 1047 | - | if !hasGhost || ghostFullText == "" { | |
| 1048 | - | return "", false | |
| 1049 | - | } | |
| 1050 | - | return ghostFullText, true | |
| 1051 | 1035 | } | |
| 1052 | 1036 | ||
| 1053 | 1037 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| @@ -1064,13 +1048,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||
| 1064 | 1048 | ||
| 1065 | 1049 | // Only show full URL as ghost text when input is empty (initial state) | |
| 1066 | 1050 | if userInput == "" { | |
| 1067 | - | o.setGhostText("", displayURL, displayURL) | |
| 1051 | + | o.setGhostText("", displayURL) | |
| 1068 | 1052 | return true | |
| 1069 | 1053 | } | |
| 1070 | 1054 | ||
| 1071 | 1055 | // Try to compute proper suffix if input is a prefix of the URL | |
| 1072 | - | if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { | |
| 1073 | - | o.setGhostText(userInput, suffix, matchedURL) | |
| 1056 | + | if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { | |
| 1057 | + | o.setGhostText(userInput, suffix) | |
| 1074 | 1058 | return true | |
| 1075 | 1059 | } | |
| 1076 | 1060 | ||
| @@ -1099,44 +1083,39 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||
| 1099 | 1083 | return | |
| 1100 | 1084 | } | |
| 1101 | 1085 | ||
| 1086 | + | // Clear ghost text first (removes suffix from buffer with guard). | |
| 1087 | + | o.clearGhostText() | |
| 1088 | + | ||
| 1089 | + | // If the buffer still doesn't match realInput, force-set it. | |
| 1102 | 1090 | o.mu.RLock() | |
| 1103 | 1091 | realInput := o.realInput | |
| 1104 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 1105 | 1092 | o.mu.RUnlock() | |
| 1106 | 1093 | ||
| 1107 | - | o.mu.Lock() | |
| 1108 | - | o.isAcceptingGhost = true | |
| 1109 | - | o.ghostToken++ | |
| 1110 | - | o.ghostSuffix = "" | |
| 1111 | - | o.ghostFullText = "" | |
| 1112 | - | o.hasGhostText = false | |
| 1113 | - | o.mu.Unlock() | |
| 1114 | - | ||
| 1115 | - | if hadGhost || o.entry.GetText() != realInput { | |
| 1116 | - | o.entry.SetText(realInput) | |
| 1094 | + | if o.entry.GetText() != realInput { | |
| 1095 | + | o.withGhostGuard(func() { | |
| 1096 | + | o.entry.SetText(realInput) | |
| 1097 | + | }) | |
| 1117 | 1098 | } | |
| 1118 | 1099 | o.entry.SelectRegion(-1, -1) | |
| 1119 | 1100 | o.entry.SetPosition(-1) | |
| 1120 | - | ||
| 1121 | - | o.mu.Lock() | |
| 1122 | - | o.isAcceptingGhost = false | |
| 1123 | - | o.mu.Unlock() | |
| 1124 | 1101 | } | |
| 1125 | 1102 | ||
| 1126 | 1103 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | |
| 1127 | 1104 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |
| 1105 | + | log := logging.FromContext(o.ctx) | |
| 1128 | 1106 | o.mu.RLock() | |
| 1129 | 1107 | idx := o.selectedIndex | |
| 1130 | 1108 | mode := o.viewMode | |
| 1131 | 1109 | bangMode := o.bangMode | |
| 1132 | 1110 | hasNavigated := o.hasNavigated | |
| 1133 | - | suppressGhost := o.suppressGhost | |
| 1111 | + | shouldComplete := o.insertCompletion | |
| 1134 | 1112 | suggestions := o.suggestions | |
| 1135 | 1113 | favorites := o.favorites | |
| 1136 | 1114 | o.mu.RUnlock() | |
| 1137 | 1115 | ||
| 1138 | - | if suppressGhost { | |
| 1139 | - | o.clearGhostTextIfInput(entryText) | |
| 1116 | + | // No ghost text after deletion (backspace/delete key). | |
| 1117 | + | if !shouldComplete { | |
| 1118 | + | log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)") | |
| 1140 | 1119 | return | |
| 1141 | 1120 | } | |
| 1142 | 1121 | ||
| @@ -1170,6 +1149,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1170 | 1149 | o.clearGhostTextIfInput(entryText) | |
| 1171 | 1150 | } | |
| 1172 | 1151 | ||
| 1152 | + | // resolveGhostCompletion computes and applies ghost text synchronously. | |
| 1153 | + | // All computation is pure string matching — no I/O, no goroutines needed. | |
| 1173 | 1154 | func (o *Omnibox) resolveGhostCompletion( | |
| 1174 | 1155 | entryText, selectedURL string, | |
| 1175 | 1156 | hasExplicitSelection bool, | |
| @@ -1177,33 +1158,29 @@ func (o *Omnibox) resolveGhostCompletion( | |||
| 1177 | 1158 | suggestions []Suggestion, | |
| 1178 | 1159 | favorites []Favorite, | |
| 1179 | 1160 | ) { | |
| 1180 | - | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |
| 1161 | + | log := logging.FromContext(o.ctx) | |
| 1162 | + | _, completionInput, ok := ghostCompletionInput(entryText) | |
| 1181 | 1163 | if !ok { | |
| 1182 | - | o.clearGhostTextIfInput(entryText) | |
| 1164 | + | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input") | |
| 1183 | 1165 | return | |
| 1184 | 1166 | } | |
| 1185 | 1167 | ||
| 1186 | - | o.mu.Lock() | |
| 1187 | - | o.ghostToken++ | |
| 1188 | - | ghostToken := o.ghostToken | |
| 1189 | - | o.mu.Unlock() | |
| 1190 | - | ||
| 1191 | - | go func() { | |
| 1192 | - | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1193 | - | if !o.isGhostTokenCurrent(ghostToken, entryText) { | |
| 1194 | - | return | |
| 1195 | - | } | |
| 1196 | - | if !found { | |
| 1197 | - | o.clearGhostTextIfInput(entryText) | |
| 1198 | - | return | |
| 1199 | - | } | |
| 1200 | - | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | |
| 1201 | - | if fullText == "" || suffix == "" { | |
| 1202 | - | o.clearGhostTextIfInput(entryText) | |
| 1203 | - | return | |
| 1204 | - | } | |
| 1205 | - | o.setGhostText(entryText, suffix, leadingWhitespace+fullText) | |
| 1206 | - | }() | |
| 1168 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1169 | + | if !found { | |
| 1170 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") | |
| 1171 | + | return | |
| 1172 | + | } | |
| 1173 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | |
| 1174 | + | if fullText == "" || suffix == "" { | |
| 1175 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize") | |
| 1176 | + | return | |
| 1177 | + | } | |
| 1178 | + | log.Debug(). | |
| 1179 | + | Str("input", completionInput). | |
| 1180 | + | Str("fullText", fullText). | |
| 1181 | + | Str("suffix", suffix). | |
| 1182 | + | Msg("ghost: resolveGhost — applying") | |
| 1183 | + | o.setGhostText(entryText, suffix) | |
| 1207 | 1184 | } | |
| 1208 | 1185 | ||
| 1209 | 1186 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | |
| @@ -1300,14 +1277,6 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit | |||
| 1300 | 1277 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | |
| 1301 | 1278 | } | |
| 1302 | 1279 | ||
| 1303 | - | func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool { | |
| 1304 | - | o.mu.RLock() | |
| 1305 | - | defer o.mu.RUnlock() | |
| 1306 | - | return o.visible && | |
| 1307 | - | o.ghostToken == ghostToken && | |
| 1308 | - | o.realInput == query | |
| 1309 | - | } | |
| 1310 | - | ||
| 1311 | 1280 | // performSearch executes the search based on current view mode and query. | |
| 1312 | 1281 | func (o *Omnibox) performSearch() { | |
| 1313 | 1282 | o.mu.RLock() | |
| @@ -2341,10 +2310,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2341 | 2310 | // Initialize ghost text state | |
| 2342 | 2311 | o.realInput = query | |
| 2343 | 2312 | o.ghostSuffix = "" | |
| 2344 | - | o.ghostFullText = "" | |
| 2345 | 2313 | o.hasGhostText = false | |
| 2346 | - | o.isAcceptingGhost = false | |
| 2347 | - | o.suppressGhost = false | |
| 2314 | + | o.insertCompletion = false | |
| 2348 | 2315 | o.mu.Unlock() | |
| 2349 | 2316 | ||
| 2350 | 2317 | // Set initial query | |
| @@ -2413,10 +2380,10 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 2413 | 2380 | o.entry.SetText("") | |
| 2414 | 2381 | o.listBox.RemoveAll() | |
| 2415 | 2382 | ||
| 2416 | - | // Reset realInput | |
| 2383 | + | // Reset state | |
| 2417 | 2384 | o.mu.Lock() | |
| 2418 | 2385 | o.realInput = "" | |
| 2419 | - | o.suppressGhost = false | |
| 2386 | + | o.insertCompletion = false | |
| 2420 | 2387 | o.mu.Unlock() | |
| 2421 | 2388 | o.resetSearchSessionState() | |
| 2422 | 2389 | ||
| @@ -95,11 +95,10 @@ type Omnibox struct { | |||||
| 95 | 95 | ||||
| 96 | // Ghost text state | 96 | // Ghost text state | ||
| 97 | realInput string // What user actually typed (without ghost suffix) | 97 | realInput string // What user actually typed (without ghost suffix) | ||
| 98 | - | ghostSuffix string // Completion suffix to display | 98 | + | ghostSuffix string // Completion suffix currently displayed |
| 99 | - | ghostFullText string // Full text that ghost completes to | |||
| 100 | hasGhostText bool // Quick check for Tab behavior | 99 | hasGhostText bool // Quick check for Tab behavior | ||
| 101 | - | isAcceptingGhost bool // Prevent re-trigger during acceptance | 100 | + | insertCompletion bool // True on character insert, false on delete — gates ghost completion |
| 102 | - | suppressGhost bool // Temporarily disable ghost completion after deletion keys | 101 | + | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText |
| 103 | 102 | ||||
| 104 | // Dependencies | 103 | // Dependencies | ||
| 105 | historyUC *usecase.SearchHistoryUseCase | 104 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -123,7 +122,6 @@ type Omnibox struct { | |||||
| 123 | debounceMu sync.Mutex | 122 | debounceMu sync.Mutex | ||
| 124 | lastQuery string // Prevent duplicate searches | 123 | lastQuery string // Prevent duplicate searches | ||
| 125 | searchToken uint64 | 124 | searchToken uint64 | ||
| 126 | - | ghostToken uint64 | |||
| 127 | 125 | ||||
| 128 | // Scaling | 126 | // Scaling | ||
| 129 | uiScale float64 | 127 | uiScale float64 | ||
| @@ -638,7 +636,6 @@ func (o *Omnibox) assembleWidgets() { | |||||
| 638 | 636 | ||||
| 639 | // setupEntryChanged wires entry text changes to debounced search. | 637 | // setupEntryChanged wires entry text changes to debounced search. | ||
| 640 | func (o *Omnibox) setupEntryChanged() { | 638 | func (o *Omnibox) setupEntryChanged() { | ||
| 641 | - | // SearchEntry has built-in debouncing via search-changed signal | |||
| 642 | changedCb := func(_ gtk.SearchEntry) { | 639 | changedCb := func(_ gtk.SearchEntry) { | ||
| 643 | o.onEntryChanged() | 640 | o.onEntryChanged() | ||
| 644 | } | 641 | } | ||
| @@ -694,7 +691,7 @@ func (o *Omnibox) hasUserNavigated() bool { | |||||
| 694 | // handleKeyPress processes keyboard events. | 691 | // handleKeyPress processes keyboard events. | ||
| 695 | // Returns true if the event was handled. | 692 | // Returns true if the event was handled. | ||
| 696 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 693 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 697 | - | o.updateGhostSuppressionFromKey(keyval) | 694 | + | o.updateInsertCompletionFromKey(keyval) |
| 698 | 695 | ||||
| 699 | ctrl := state&gdk.ControlMaskValue != 0 | 696 | ctrl := state&gdk.ControlMaskValue != 0 | ||
| 700 | 697 | ||||
| @@ -767,16 +764,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 767 | return false // Let entry handle the key | 764 | return false // Let entry handle the key | ||
| 768 | } | 765 | } | ||
| 769 | 766 | ||||
| 770 | - | func nextGhostSuppressionState(keyval uint) bool { | |||
| 771 | - | return isDeletionKey(keyval) | |||
| 772 | - | } | |||
| 773 | - | ||||
| 774 | - | func (o *Omnibox) updateGhostSuppressionFromKey(keyval uint) { | |||
| 775 | - | o.mu.Lock() | |||
| 776 | - | o.suppressGhost = nextGhostSuppressionState(keyval) | |||
| 777 | - | o.mu.Unlock() | |||
| 778 | - | } | |||
| 779 | - | ||||
| 780 | func isDeletionKey(keyval uint) bool { | 767 | func isDeletionKey(keyval uint) bool { | ||
| 781 | switch keyval { | 768 | switch keyval { | ||
| 782 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | 769 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | ||
| @@ -786,6 +773,28 @@ func isDeletionKey(keyval uint) bool { | |||||
| 786 | } | 773 | } | ||
| 787 | } | 774 | } | ||
| 788 | 775 | ||||
| 776 | + | // updateInsertCompletionFromKey sets whether the next text change should trigger | |||
| 777 | + | // ghost completion. Character insertion enables it; deletion disables it. | |||
| 778 | + | func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) { | |||
| 779 | + | o.mu.Lock() | |||
| 780 | + | o.insertCompletion = !isDeletionKey(keyval) | |||
| 781 | + | o.mu.Unlock() | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | // withGhostGuard executes fn while the isSettingGhost flag is set, | |||
| 785 | + | // so that onEntryChanged skips during programmatic text changes. | |||
| 786 | + | func (o *Omnibox) withGhostGuard(fn func()) { | |||
| 787 | + | o.mu.Lock() | |||
| 788 | + | o.isSettingGhost = true | |||
| 789 | + | o.mu.Unlock() | |||
| 790 | + | ||||
| 791 | + | fn() | |||
| 792 | + | ||||
| 793 | + | o.mu.Lock() | |||
| 794 | + | o.isSettingGhost = false | |||
| 795 | + | o.mu.Unlock() | |||
| 796 | + | } | |||
| 797 | + | ||||
| 789 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | 798 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | ||
| 790 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | 799 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | ||
| 791 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | 800 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | ||
| @@ -823,42 +832,64 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||||
| 823 | } | 832 | } | ||
| 824 | 833 | ||||
| 825 | // onEntryChanged handles text input changes with debouncing. | 834 | // onEntryChanged handles text input changes with debouncing. | ||
| 835 | + | // When this fires, GTK has already processed the keystroke — if ghost text was | |||
| 836 | + | // selected and the user typed, the selection was replaced naturally by GTK. | |||
| 837 | + | // We must NOT call clearGhostText() here (that would delete real user text). | |||
| 826 | func (o *Omnibox) onEntryChanged() { | 838 | func (o *Omnibox) onEntryChanged() { | ||
| 827 | - | // Skip if we're programmatically accepting ghost text | 839 | + | log := logging.FromContext(o.ctx) |
| 840 | + | ||||
| 841 | + | // Skip if we're programmatically setting ghost text (synchronous guard). | |||
| 828 | o.mu.RLock() | 842 | o.mu.RLock() | ||
| 829 | - | isAccepting := o.isAcceptingGhost | 843 | + | isSettingGhost := o.isSettingGhost |
| 830 | o.mu.RUnlock() | 844 | o.mu.RUnlock() | ||
| 831 | - | if isAccepting { | 845 | + | if isSettingGhost { |
| 846 | + | log.Debug().Msg("ghost: onEntryChanged skipped (isSettingGhost)") | |||
| 832 | return | 847 | return | ||
| 833 | } | 848 | } | ||
| 834 | 849 | ||||
| 835 | entryText := o.entry.GetText() | 850 | entryText := o.entry.GetText() | ||
| 836 | - | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |||
| 837 | - | o.entry.SetText(trimmed) | |||
| 838 | - | o.entry.SetPosition(-1) | |||
| 839 | - | return | |||
| 840 | - | } | |||
| 841 | 851 | ||||
| 842 | - | if o.isGhostProgrammaticEcho(entryText) { | 852 | + | // Detect debounced echo from our own SetText in setGhostText. |
| 853 | + | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | |||
| 854 | + | // the isSettingGhost guard is already off. Recognize it by checking | |||
| 855 | + | // if the entry text matches realInput + ghostSuffix exactly. | |||
| 856 | + | o.mu.RLock() | |||
| 857 | + | if o.hasGhostText && o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | |||
| 858 | + | o.mu.RUnlock() | |||
| 859 | + | log.Debug().Str("entryText", entryText).Msg("ghost: onEntryChanged skipped (debounced echo)") | |||
| 843 | return | 860 | return | ||
| 844 | } | 861 | } | ||
| 862 | + | o.mu.RUnlock() | |||
| 863 | + | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |||
| 864 | + | o.withGhostGuard(func() { | |||
| 865 | + | o.entry.SetText(trimmed) | |||
| 866 | + | o.entry.SetPosition(-1) | |||
| 867 | + | }) | |||
| 868 | + | entryText = trimmed | |||
| 869 | + | } | |||
| 845 | 870 | ||||
| 846 | - | // Reset navigation state when user types - space should type, not toggle favorite | 871 | + | // Reset ghost state (the buffer is already correct — GTK handled it). |
| 847 | o.mu.Lock() | 872 | o.mu.Lock() | ||
| 848 | - | previousInput := o.realInput | 873 | + | oldGhost := o.ghostSuffix |
| 849 | o.hasNavigated = false | 874 | o.hasNavigated = false | ||
| 850 | - | // Update realInput with what user actually typed | 875 | + | o.hasGhostText = false |
| 876 | + | o.ghostSuffix = "" | |||
| 851 | o.realInput = entryText | 877 | o.realInput = entryText | ||
| 878 | + | shouldComplete := o.insertCompletion | |||
| 852 | o.mu.Unlock() | 879 | o.mu.Unlock() | ||
| 853 | 880 | ||||
| 854 | - | // Clear ghost text when user types (it will be recalculated after search) | 881 | + | log.Debug(). |
| 855 | - | o.clearGhostText() | 882 | + | Str("entryText", entryText). |
| 856 | - | // Recompute immediately from currently visible rows to avoid transient gaps | 883 | + | Str("oldGhost", oldGhost). |
| 857 | - | // where suggestions are visible but ghost text is missing until async search returns. | 884 | + | Bool("insertCompletion", shouldComplete). |
| 858 | - | if shouldUpdateGhostImmediately(previousInput, entryText) { | 885 | + | Msg("ghost: onEntryChanged") |
| 886 | + | ||||
| 887 | + | // Compute ghost completion synchronously if this was a character insertion. | |||
| 888 | + | if shouldComplete { | |||
| 859 | o.updateGhostFromSelectionWithInput(entryText) | 889 | o.updateGhostFromSelectionWithInput(entryText) | ||
| 860 | } | 890 | } | ||
| 861 | 891 | ||||
| 892 | + | // Schedule async search for suggestion list updates. | |||
| 862 | o.debounceMu.Lock() | 893 | o.debounceMu.Lock() | ||
| 863 | if o.debounceTimer != nil { | 894 | if o.debounceTimer != nil { | ||
| 864 | o.debounceTimer.Stop() | 895 | o.debounceTimer.Stop() | ||
| @@ -869,131 +900,89 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 869 | o.debounceMu.Unlock() | 900 | o.debounceMu.Unlock() | ||
| 870 | } | 901 | } | ||
| 871 | 902 | ||||
| 872 | - | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | 903 | + | // setGhostText applies inline completion by setting the full text (input + suffix) |
| 873 | - | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | 904 | + | // and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged. |
| 874 | - | } | 905 | + | // Skips if the user has typed since this ghost was computed (stale input check). |
| 906 | + | func (o *Omnibox) setGhostText(originalInput, suffix string) { | |||
| 907 | + | log := logging.FromContext(o.ctx) | |||
| 908 | + | if o.entry == nil || suffix == "" { | |||
| 909 | + | o.clearGhostText() | |||
| 910 | + | return | |||
| 911 | + | } | |||
| 875 | 912 | ||||
| 876 | - | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | 913 | + | // Verify the user hasn't typed since this ghost was computed. |
| 877 | o.mu.RLock() | 914 | o.mu.RLock() | ||
| 878 | - | hasGhost := o.hasGhostText | 915 | + | currentInput := o.realInput |
| 879 | - | realInput := o.realInput | |||
| 880 | - | ghostFullText := o.ghostFullText | |||
| 881 | o.mu.RUnlock() | 916 | o.mu.RUnlock() | ||
| 882 | - | 917 | + | if currentInput != originalInput { | |
| 883 | - | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | 918 | + | log.Debug(). |
| 884 | - | return false | 919 | + | Str("staleInput", originalInput). |
| 885 | - | } | 920 | + | Str("currentInput", currentInput). |
| 886 | - | 921 | + | Str("suffix", suffix). | |
| 887 | - | selectionStart := 0 | 922 | + | Msg("ghost: setGhostText skipped (stale input)") |
| 888 | - | selectionEnd := 0 | |||
| 889 | - | hasSelection := o.entry.GetSelectionBounds(&selectionStart, &selectionEnd) | |||
| 890 | - | return isInlineGhostSelection(realInput, ghostFullText, selectionStart, selectionEnd, hasSelection) | |||
| 891 | - | } | |||
| 892 | - | ||||
| 893 | - | func isInlineGhostSelection(realInput, ghostFullText string, selectionStart, selectionEnd int, hasSelection bool) bool { | |||
| 894 | - | if !hasSelection { | |||
| 895 | - | return false | |||
| 896 | - | } | |||
| 897 | - | prefixRunes := utf8.RuneCountInString(realInput) | |||
| 898 | - | fullRunes := utf8.RuneCountInString(ghostFullText) | |||
| 899 | - | if fullRunes <= prefixRunes { | |||
| 900 | - | return false | |||
| 901 | - | } | |||
| 902 | - | return selectionStart == prefixRunes && selectionEnd == fullRunes | |||
| 903 | - | } | |||
| 904 | - | ||||
| 905 | - | // setGhostText applies inline completion using entry selection. | |||
| 906 | - | // It writes full text and selects [len(originalInput):end] as the ghost suffix. | |||
| 907 | - | func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 908 | - | if o.entry == nil || suffix == "" { | |||
| 909 | - | o.clearGhostText() | |||
| 910 | return | 923 | return | ||
| 911 | } | 924 | } | ||
| 912 | 925 | ||||
| 913 | displayText := originalInput + suffix | 926 | displayText := originalInput + suffix | ||
| 914 | - | if fullText != "" { | 927 | + | cursorPos := utf8.RuneCountInString(originalInput) |
| 915 | - | displayText = fullText | 928 | + | endPos := utf8.RuneCountInString(displayText) |
| 916 | - | } | 929 | + | |
| 917 | - | selectionStart := utf8.RuneCountInString(originalInput) | 930 | + | log.Debug(). |
| 918 | - | selectionEnd := utf8.RuneCountInString(displayText) | 931 | + | Str("input", originalInput). |
| 932 | + | Str("suffix", suffix). | |||
| 933 | + | Int("cursorPos", cursorPos). | |||
| 934 | + | Int("endPos", endPos). | |||
| 935 | + | Msg("ghost: setGhostText") | |||
| 919 | 936 | ||||
| 920 | o.mu.Lock() | 937 | o.mu.Lock() | ||
| 921 | o.realInput = originalInput | 938 | o.realInput = originalInput | ||
| 922 | o.ghostSuffix = suffix | 939 | o.ghostSuffix = suffix | ||
| 923 | - | o.ghostFullText = fullText | |||
| 924 | o.hasGhostText = true | 940 | o.hasGhostText = true | ||
| 925 | - | capturedToken := o.ghostToken | |||
| 926 | o.mu.Unlock() | 941 | o.mu.Unlock() | ||
| 927 | 942 | ||||
| 928 | - | o.idleCoalescer.Post("omnibox-ghost-set", func() { | 943 | + | o.withGhostGuard(func() { |
| 929 | - | if o.entry == nil { | |||
| 930 | - | return | |||
| 931 | - | } | |||
| 932 | - | ||||
| 933 | - | o.mu.RLock() | |||
| 934 | - | currentInput := o.realInput | |||
| 935 | - | currentToken := o.ghostToken | |||
| 936 | - | o.mu.RUnlock() | |||
| 937 | - | if currentInput != originalInput { | |||
| 938 | - | return | |||
| 939 | - | } | |||
| 940 | - | if capturedToken != currentToken { | |||
| 941 | - | return | |||
| 942 | - | } | |||
| 943 | - | o.mu.Lock() | |||
| 944 | - | o.isAcceptingGhost = true | |||
| 945 | - | o.mu.Unlock() | |||
| 946 | - | ||||
| 947 | o.entry.SetText(displayText) | 944 | o.entry.SetText(displayText) | ||
| 948 | - | o.entry.SetPosition(selectionStart) | 945 | + | o.entry.SetPosition(cursorPos) |
| 949 | - | o.entry.SelectRegion(selectionStart, selectionEnd) | 946 | + | o.entry.SelectRegion(cursorPos, endPos) |
| 950 | - | ||||
| 951 | - | o.mu.Lock() | |||
| 952 | - | o.isAcceptingGhost = false | |||
| 953 | - | o.mu.Unlock() | |||
| 954 | }) | 947 | }) | ||
| 955 | } | 948 | } | ||
| 956 | 949 | ||||
| 957 | - | // clearGhostText hides the ghost completion text. | 950 | + | // clearGhostText removes any ghost suffix from the entry buffer. |
| 951 | + | // Uses guard flag to prevent re-entrant onEntryChanged. | |||
| 958 | func (o *Omnibox) clearGhostText() { | 952 | func (o *Omnibox) clearGhostText() { | ||
| 953 | + | log := logging.FromContext(o.ctx) | |||
| 959 | o.mu.Lock() | 954 | o.mu.Lock() | ||
| 955 | + | hadGhost := o.hasGhostText | |||
| 956 | + | ghostSuffix := o.ghostSuffix | |||
| 960 | realInput := o.realInput | 957 | realInput := o.realInput | ||
| 961 | - | ghostFullText := o.ghostFullText | |||
| 962 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |||
| 963 | - | o.ghostToken++ | |||
| 964 | - | capturedToken := o.ghostToken | |||
| 965 | o.ghostSuffix = "" | 958 | o.ghostSuffix = "" | ||
| 966 | - | o.ghostFullText = "" | |||
| 967 | o.hasGhostText = false | 959 | o.hasGhostText = false | ||
| 968 | o.mu.Unlock() | 960 | o.mu.Unlock() | ||
| 969 | 961 | ||||
| 970 | - | if !hadGhost || o.entry == nil { | 962 | + | if !hadGhost || ghostSuffix == "" || o.entry == nil { |
| 971 | return | 963 | return | ||
| 972 | } | 964 | } | ||
| 973 | 965 | ||||
| 974 | - | o.idleCoalescer.Post("omnibox-ghost-clear", func() { | 966 | + | // Only touch the buffer if it still shows the expected ghost text. |
| 975 | - | if o.entry == nil { | 967 | + | expectedText := realInput + ghostSuffix |
| 976 | - | return | 968 | + | currentText := o.entry.GetText() |
| 977 | - | } | 969 | + | if currentText != expectedText { |
| 978 | - | o.mu.RLock() | 970 | + | log.Debug(). |
| 979 | - | currentToken := o.ghostToken | 971 | + | Str("expected", expectedText). |
| 980 | - | o.mu.RUnlock() | 972 | + | Str("current", currentText). |
| 981 | - | if capturedToken != currentToken { | 973 | + | Msg("ghost: clearGhostText skipped (buffer changed)") |
| 982 | - | return | 974 | + | return |
| 983 | - | } | 975 | + | } |
| 984 | - | o.mu.Lock() | 976 | + | |
| 985 | - | o.isAcceptingGhost = true | 977 | + | log.Debug(). |
| 986 | - | o.mu.Unlock() | 978 | + | Str("realInput", realInput). |
| 987 | - | // Only revert text when the entry still shows the previous ghost completion. | 979 | + | Str("ghostSuffix", ghostSuffix). |
| 988 | - | // If the user typed since scheduling this idle callback, never overwrite it. | 980 | + | Msg("ghost: clearGhostText") |
| 989 | - | if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput { | 981 | + | |
| 990 | - | o.entry.SetText(realInput) | 982 | + | o.withGhostGuard(func() { |
| 991 | - | } | 983 | + | o.entry.SetText(realInput) |
| 992 | o.entry.SelectRegion(-1, -1) | 984 | o.entry.SelectRegion(-1, -1) | ||
| 993 | o.entry.SetPosition(-1) | 985 | o.entry.SetPosition(-1) | ||
| 994 | - | o.mu.Lock() | |||
| 995 | - | o.isAcceptingGhost = false | |||
| 996 | - | o.mu.Unlock() | |||
| 997 | }) | 986 | }) | ||
| 998 | } | 987 | } | ||
| 999 | 988 | ||||
| @@ -1009,45 +998,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||||
| 1009 | o.clearGhostText() | 998 | o.clearGhostText() | ||
| 1010 | } | 999 | } | ||
| 1011 | 1000 | ||||
| 1012 | - | // acceptGhostCompletion accepts the ghost text and fills the input. | 1001 | + | // acceptGhostCompletion accepts the ghost text — the suffix is already in the |
| 1002 | + | // buffer, so we just deselect and update realInput to include it. | |||
| 1013 | func (o *Omnibox) acceptGhostCompletion() { | 1003 | func (o *Omnibox) acceptGhostCompletion() { | ||
| 1004 | + | log := logging.FromContext(o.ctx) | |||
| 1014 | o.mu.Lock() | 1005 | o.mu.Lock() | ||
| 1015 | - | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | 1006 | + | if !o.hasGhostText || o.ghostSuffix == "" { |
| 1016 | - | if !accepted { | |||
| 1017 | o.mu.Unlock() | 1007 | o.mu.Unlock() | ||
| 1008 | + | log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept") | |||
| 1018 | return | 1009 | return | ||
| 1019 | } | 1010 | } | ||
| 1020 | - | o.isAcceptingGhost = true | 1011 | + | suffix := o.ghostSuffix |
| 1021 | - | o.realInput = fullText | |||
| 1022 | - | o.selectedIndex = -1 | |||
| 1023 | - | o.hasNavigated = false | |||
| 1024 | - | o.ghostToken++ | |||
| 1025 | - | o.ghostSuffix = "" | |||
| 1026 | - | o.ghostFullText = "" | |||
| 1027 | o.hasGhostText = false | 1012 | o.hasGhostText = false | ||
| 1013 | + | o.ghostSuffix = "" | |||
| 1028 | o.mu.Unlock() | 1014 | o.mu.Unlock() | ||
| 1015 | + | log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion") | |||
| 1029 | 1016 | ||||
| 1030 | if o.entry != nil { | 1017 | if o.entry != nil { | ||
| 1031 | - | if o.entry.GetText() != fullText { | 1018 | + | // The suffix is already in the buffer as selected text. |
| 1032 | - | o.entry.SetText(fullText) | 1019 | + | // Just deselect and move cursor to end. |
| 1033 | - | } | 1020 | + | fullText := o.entry.GetText() |
| 1034 | - | o.entry.SelectRegion(-1, -1) | 1021 | + | o.mu.Lock() |
| 1035 | - | o.entry.SetPosition(-1) | 1022 | + | o.realInput = fullText |
| 1023 | + | o.selectedIndex = -1 | |||
| 1024 | + | o.hasNavigated = false | |||
| 1025 | + | o.mu.Unlock() | |||
| 1026 | + | ||||
| 1027 | + | o.withGhostGuard(func() { | |||
| 1028 | + | o.entry.SelectRegion(-1, -1) | |||
| 1029 | + | o.entry.SetPosition(-1) | |||
| 1030 | + | }) | |||
| 1036 | } | 1031 | } | ||
| 1037 | if o.listBox != nil { | 1032 | if o.listBox != nil { | ||
| 1038 | o.listBox.SelectRow(nil) | 1033 | o.listBox.SelectRow(nil) | ||
| 1039 | } | 1034 | } | ||
| 1040 | - | ||||
| 1041 | - | o.mu.Lock() | |||
| 1042 | - | o.isAcceptingGhost = false | |||
| 1043 | - | o.mu.Unlock() | |||
| 1044 | - | } | |||
| 1045 | - | ||||
| 1046 | - | func acceptedGhostInput(hasGhost bool, ghostFullText string) (string, bool) { | |||
| 1047 | - | if !hasGhost || ghostFullText == "" { | |||
| 1048 | - | return "", false | |||
| 1049 | - | } | |||
| 1050 | - | return ghostFullText, true | |||
| 1051 | } | 1035 | } | ||
| 1052 | 1036 | ||||
| 1053 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | 1037 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | ||
| @@ -1064,13 +1048,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||||
| 1064 | 1048 | ||||
| 1065 | // Only show full URL as ghost text when input is empty (initial state) | 1049 | // Only show full URL as ghost text when input is empty (initial state) | ||
| 1066 | if userInput == "" { | 1050 | if userInput == "" { | ||
| 1067 | - | o.setGhostText("", displayURL, displayURL) | 1051 | + | o.setGhostText("", displayURL) |
| 1068 | return true | 1052 | return true | ||
| 1069 | } | 1053 | } | ||
| 1070 | 1054 | ||||
| 1071 | // Try to compute proper suffix if input is a prefix of the URL | 1055 | // Try to compute proper suffix if input is a prefix of the URL | ||
| 1072 | - | if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { | 1056 | + | if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { |
| 1073 | - | o.setGhostText(userInput, suffix, matchedURL) | 1057 | + | o.setGhostText(userInput, suffix) |
| 1074 | return true | 1058 | return true | ||
| 1075 | } | 1059 | } | ||
| 1076 | 1060 | ||||
| @@ -1099,44 +1083,39 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||||
| 1099 | return | 1083 | return | ||
| 1100 | } | 1084 | } | ||
| 1101 | 1085 | ||||
| 1086 | + | // Clear ghost text first (removes suffix from buffer with guard). | |||
| 1087 | + | o.clearGhostText() | |||
| 1088 | + | ||||
| 1089 | + | // If the buffer still doesn't match realInput, force-set it. | |||
| 1102 | o.mu.RLock() | 1090 | o.mu.RLock() | ||
| 1103 | realInput := o.realInput | 1091 | realInput := o.realInput | ||
| 1104 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |||
| 1105 | o.mu.RUnlock() | 1092 | o.mu.RUnlock() | ||
| 1106 | 1093 | ||||
| 1107 | - | o.mu.Lock() | 1094 | + | if o.entry.GetText() != realInput { |
| 1108 | - | o.isAcceptingGhost = true | 1095 | + | o.withGhostGuard(func() { |
| 1109 | - | o.ghostToken++ | 1096 | + | o.entry.SetText(realInput) |
| 1110 | - | o.ghostSuffix = "" | 1097 | + | }) |
| 1111 | - | o.ghostFullText = "" | |||
| 1112 | - | o.hasGhostText = false | |||
| 1113 | - | o.mu.Unlock() | |||
| 1114 | - | ||||
| 1115 | - | if hadGhost || o.entry.GetText() != realInput { | |||
| 1116 | - | o.entry.SetText(realInput) | |||
| 1117 | } | 1098 | } | ||
| 1118 | o.entry.SelectRegion(-1, -1) | 1099 | o.entry.SelectRegion(-1, -1) | ||
| 1119 | o.entry.SetPosition(-1) | 1100 | o.entry.SetPosition(-1) | ||
| 1120 | - | ||||
| 1121 | - | o.mu.Lock() | |||
| 1122 | - | o.isAcceptingGhost = false | |||
| 1123 | - | o.mu.Unlock() | |||
| 1124 | } | 1101 | } | ||
| 1125 | 1102 | ||||
| 1126 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | 1103 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | ||
| 1127 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | 1104 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | ||
| 1105 | + | log := logging.FromContext(o.ctx) | |||
| 1128 | o.mu.RLock() | 1106 | o.mu.RLock() | ||
| 1129 | idx := o.selectedIndex | 1107 | idx := o.selectedIndex | ||
| 1130 | mode := o.viewMode | 1108 | mode := o.viewMode | ||
| 1131 | bangMode := o.bangMode | 1109 | bangMode := o.bangMode | ||
| 1132 | hasNavigated := o.hasNavigated | 1110 | hasNavigated := o.hasNavigated | ||
| 1133 | - | suppressGhost := o.suppressGhost | 1111 | + | shouldComplete := o.insertCompletion |
| 1134 | suggestions := o.suggestions | 1112 | suggestions := o.suggestions | ||
| 1135 | favorites := o.favorites | 1113 | favorites := o.favorites | ||
| 1136 | o.mu.RUnlock() | 1114 | o.mu.RUnlock() | ||
| 1137 | 1115 | ||||
| 1138 | - | if suppressGhost { | 1116 | + | // No ghost text after deletion (backspace/delete key). |
| 1139 | - | o.clearGhostTextIfInput(entryText) | 1117 | + | if !shouldComplete { |
| 1118 | + | log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)") | |||
| 1140 | return | 1119 | return | ||
| 1141 | } | 1120 | } | ||
| 1142 | 1121 | ||||
| @@ -1170,6 +1149,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1170 | o.clearGhostTextIfInput(entryText) | 1149 | o.clearGhostTextIfInput(entryText) | ||
| 1171 | } | 1150 | } | ||
| 1172 | 1151 | ||||
| 1152 | + | // resolveGhostCompletion computes and applies ghost text synchronously. | |||
| 1153 | + | // All computation is pure string matching — no I/O, no goroutines needed. | |||
| 1173 | func (o *Omnibox) resolveGhostCompletion( | 1154 | func (o *Omnibox) resolveGhostCompletion( | ||
| 1174 | entryText, selectedURL string, | 1155 | entryText, selectedURL string, | ||
| 1175 | hasExplicitSelection bool, | 1156 | hasExplicitSelection bool, | ||
| @@ -1177,33 +1158,29 @@ func (o *Omnibox) resolveGhostCompletion( | |||||
| 1177 | suggestions []Suggestion, | 1158 | suggestions []Suggestion, | ||
| 1178 | favorites []Favorite, | 1159 | favorites []Favorite, | ||
| 1179 | ) { | 1160 | ) { | ||
| 1180 | - | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | 1161 | + | log := logging.FromContext(o.ctx) |
| 1162 | + | _, completionInput, ok := ghostCompletionInput(entryText) | |||
| 1181 | if !ok { | 1163 | if !ok { | ||
| 1182 | - | o.clearGhostTextIfInput(entryText) | 1164 | + | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input") |
| 1183 | return | 1165 | return | ||
| 1184 | } | 1166 | } | ||
| 1185 | 1167 | ||||
| 1186 | - | o.mu.Lock() | 1168 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) |
| 1187 | - | o.ghostToken++ | 1169 | + | if !found { |
| 1188 | - | ghostToken := o.ghostToken | 1170 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") |
| 1189 | - | o.mu.Unlock() | 1171 | + | return |
| 1190 | - | 1172 | + | } | |
| 1191 | - | go func() { | 1173 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) |
| 1192 | - | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | 1174 | + | if fullText == "" || suffix == "" { |
| 1193 | - | if !o.isGhostTokenCurrent(ghostToken, entryText) { | 1175 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize") |
| 1194 | - | return | 1176 | + | return |
| 1195 | - | } | 1177 | + | } |
| 1196 | - | if !found { | 1178 | + | log.Debug(). |
| 1197 | - | o.clearGhostTextIfInput(entryText) | 1179 | + | Str("input", completionInput). |
| 1198 | - | return | 1180 | + | Str("fullText", fullText). |
| 1199 | - | } | 1181 | + | Str("suffix", suffix). |
| 1200 | - | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | 1182 | + | Msg("ghost: resolveGhost — applying") |
| 1201 | - | if fullText == "" || suffix == "" { | 1183 | + | o.setGhostText(entryText, suffix) |
| 1202 | - | o.clearGhostTextIfInput(entryText) | |||
| 1203 | - | return | |||
| 1204 | - | } | |||
| 1205 | - | o.setGhostText(entryText, suffix, leadingWhitespace+fullText) | |||
| 1206 | - | }() | |||
| 1207 | } | 1184 | } | ||
| 1208 | 1185 | ||||
| 1209 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | 1186 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | ||
| @@ -1300,14 +1277,6 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit | |||||
| 1300 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | 1277 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | ||
| 1301 | } | 1278 | } | ||
| 1302 | 1279 | ||||
| 1303 | - | func (o *Omnibox) isGhostTokenCurrent(ghostToken uint64, query string) bool { | |||
| 1304 | - | o.mu.RLock() | |||
| 1305 | - | defer o.mu.RUnlock() | |||
| 1306 | - | return o.visible && | |||
| 1307 | - | o.ghostToken == ghostToken && | |||
| 1308 | - | o.realInput == query | |||
| 1309 | - | } | |||
| 1310 | - | ||||
| 1311 | // performSearch executes the search based on current view mode and query. | 1280 | // performSearch executes the search based on current view mode and query. | ||
| 1312 | func (o *Omnibox) performSearch() { | 1281 | func (o *Omnibox) performSearch() { | ||
| 1313 | o.mu.RLock() | 1282 | o.mu.RLock() | ||
| @@ -2341,10 +2310,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2341 | // Initialize ghost text state | 2310 | // Initialize ghost text state | ||
| 2342 | o.realInput = query | 2311 | o.realInput = query | ||
| 2343 | o.ghostSuffix = "" | 2312 | o.ghostSuffix = "" | ||
| 2344 | - | o.ghostFullText = "" | |||
| 2345 | o.hasGhostText = false | 2313 | o.hasGhostText = false | ||
| 2346 | - | o.isAcceptingGhost = false | 2314 | + | o.insertCompletion = false |
| 2347 | - | o.suppressGhost = false | |||
| 2348 | o.mu.Unlock() | 2315 | o.mu.Unlock() | ||
| 2349 | 2316 | ||||
| 2350 | // Set initial query | 2317 | // Set initial query | ||
| @@ -2413,10 +2380,10 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 2413 | o.entry.SetText("") | 2380 | o.entry.SetText("") | ||
| 2414 | o.listBox.RemoveAll() | 2381 | o.listBox.RemoveAll() | ||
| 2415 | 2382 | ||||
| 2416 | - | // Reset realInput | 2383 | + | // Reset state |
| 2417 | o.mu.Lock() | 2384 | o.mu.Lock() | ||
| 2418 | o.realInput = "" | 2385 | o.realInput = "" | ||
| 2419 | - | o.suppressGhost = false | 2386 | + | o.insertCompletion = false |
| 2420 | o.mu.Unlock() | 2387 | o.mu.Unlock() | ||
| 2421 | o.resetSearchSessionState() | 2388 | o.resetSearchSessionState() | ||
| 2422 | 2389 | ||||
M internal/ui/component/omnibox_async_guard_test.go +0 -25
| @@ -20,31 +20,6 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) { | |||
| 20 | 20 | } | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | - | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | |
| 24 | - | o := &Omnibox{} | |
| 25 | - | o.visible = true | |
| 26 | - | o.ghostToken = 5 | |
| 27 | - | o.realInput = "git" | |
| 28 | - | ||
| 29 | - | if !o.isGhostTokenCurrent(5, "git") { | |
| 30 | - | t.Fatalf("expected ghost token to be current") | |
| 31 | - | } | |
| 32 | - | if o.isGhostTokenCurrent(4, "git") { | |
| 33 | - | t.Fatalf("expected stale ghost token to be rejected") | |
| 34 | - | } | |
| 35 | - | if o.isGhostTokenCurrent(5, "github") { | |
| 36 | - | t.Fatalf("expected query mismatch to be rejected") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | o.visible = false | |
| 40 | - | if o.isGhostTokenCurrent(5, "git") { | |
| 41 | - | t.Fatalf("expected hidden omnibox to reject matching ghost token") | |
| 42 | - | } | |
| 43 | - | if o.isGhostTokenCurrent(4, "github") { | |
| 44 | - | t.Fatalf("expected hidden omnibox to reject stale ghost token and query") | |
| 45 | - | } | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 23 | func TestOmnibox_IsBangUpdateCurrent(t *testing.T) { | |
| 49 | 24 | o := &Omnibox{} | |
| 50 | 25 | o.visible = true | |
| @@ -20,31 +20,6 @@ func TestOmnibox_IsSearchTokenCurrent(t *testing.T) { | |||||
| 20 | } | 20 | } | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | - | func TestOmnibox_IsGhostTokenCurrent(t *testing.T) { | |||
| 24 | - | o := &Omnibox{} | |||
| 25 | - | o.visible = true | |||
| 26 | - | o.ghostToken = 5 | |||
| 27 | - | o.realInput = "git" | |||
| 28 | - | ||||
| 29 | - | if !o.isGhostTokenCurrent(5, "git") { | |||
| 30 | - | t.Fatalf("expected ghost token to be current") | |||
| 31 | - | } | |||
| 32 | - | if o.isGhostTokenCurrent(4, "git") { | |||
| 33 | - | t.Fatalf("expected stale ghost token to be rejected") | |||
| 34 | - | } | |||
| 35 | - | if o.isGhostTokenCurrent(5, "github") { | |||
| 36 | - | t.Fatalf("expected query mismatch to be rejected") | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | o.visible = false | |||
| 40 | - | if o.isGhostTokenCurrent(5, "git") { | |||
| 41 | - | t.Fatalf("expected hidden omnibox to reject matching ghost token") | |||
| 42 | - | } | |||
| 43 | - | if o.isGhostTokenCurrent(4, "github") { | |||
| 44 | - | t.Fatalf("expected hidden omnibox to reject stale ghost token and query") | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | func TestOmnibox_IsBangUpdateCurrent(t *testing.T) { | 23 | func TestOmnibox_IsBangUpdateCurrent(t *testing.T) { | ||
| 49 | o := &Omnibox{} | 24 | o := &Omnibox{} | ||
| 50 | o.visible = true | 25 | o.visible = true | ||
D internal/ui/component/omnibox_ghost_state_test.go +0 -111
| @@ -1,111 +0,0 @@ | |||
| 1 | - | package component | |
| 2 | - | ||
| 3 | - | import "testing" | |
| 4 | - | ||
| 5 | - | func TestInlineGhostSelection(t *testing.T) { | |
| 6 | - | tests := []struct { | |
| 7 | - | name string | |
| 8 | - | realInput string | |
| 9 | - | ghostFullText string | |
| 10 | - | selectionStart int | |
| 11 | - | selectionEnd int | |
| 12 | - | hasSelection bool | |
| 13 | - | want bool | |
| 14 | - | }{ | |
| 15 | - | { | |
| 16 | - | name: "selected suffix matches typed prefix", | |
| 17 | - | realInput: "goo", | |
| 18 | - | ghostFullText: "google.com", | |
| 19 | - | selectionStart: 3, | |
| 20 | - | selectionEnd: 10, | |
| 21 | - | hasSelection: true, | |
| 22 | - | want: true, | |
| 23 | - | }, | |
| 24 | - | { | |
| 25 | - | name: "no selection means no ghost suffix", | |
| 26 | - | realInput: "goo", | |
| 27 | - | ghostFullText: "google.com", | |
| 28 | - | selectionStart: 10, | |
| 29 | - | selectionEnd: 10, | |
| 30 | - | hasSelection: false, | |
| 31 | - | want: false, | |
| 32 | - | }, | |
| 33 | - | { | |
| 34 | - | name: "collapsed selection should not be treated as ghost", | |
| 35 | - | realInput: "goo", | |
| 36 | - | ghostFullText: "google.com", | |
| 37 | - | selectionStart: 10, | |
| 38 | - | selectionEnd: 10, | |
| 39 | - | hasSelection: true, | |
| 40 | - | want: false, | |
| 41 | - | }, | |
| 42 | - | { | |
| 43 | - | name: "wrong selection range should not be treated as ghost", | |
| 44 | - | realInput: "goo", | |
| 45 | - | ghostFullText: "google.com", | |
| 46 | - | selectionStart: 1, | |
| 47 | - | selectionEnd: 10, | |
| 48 | - | hasSelection: true, | |
| 49 | - | want: false, | |
| 50 | - | }, | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | for _, tt := range tests { | |
| 54 | - | t.Run(tt.name, func(t *testing.T) { | |
| 55 | - | got := isInlineGhostSelection( | |
| 56 | - | tt.realInput, | |
| 57 | - | tt.ghostFullText, | |
| 58 | - | tt.selectionStart, | |
| 59 | - | tt.selectionEnd, | |
| 60 | - | tt.hasSelection, | |
| 61 | - | ) | |
| 62 | - | if got != tt.want { | |
| 63 | - | t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want) | |
| 64 | - | } | |
| 65 | - | }) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func TestAcceptedGhostInput(t *testing.T) { | |
| 70 | - | tests := []struct { | |
| 71 | - | name string | |
| 72 | - | hasGhost bool | |
| 73 | - | ghostFullText string | |
| 74 | - | wantText string | |
| 75 | - | wantAccepted bool | |
| 76 | - | }{ | |
| 77 | - | { | |
| 78 | - | name: "accepts explicit ghost", | |
| 79 | - | hasGhost: true, | |
| 80 | - | ghostFullText: "google.com", | |
| 81 | - | wantText: "google.com", | |
| 82 | - | wantAccepted: true, | |
| 83 | - | }, | |
| 84 | - | { | |
| 85 | - | name: "rejects without ghost", | |
| 86 | - | hasGhost: false, | |
| 87 | - | ghostFullText: "google.com", | |
| 88 | - | wantText: "", | |
| 89 | - | wantAccepted: false, | |
| 90 | - | }, | |
| 91 | - | { | |
| 92 | - | name: "rejects empty ghost text", | |
| 93 | - | hasGhost: true, | |
| 94 | - | ghostFullText: "", | |
| 95 | - | wantText: "", | |
| 96 | - | wantAccepted: false, | |
| 97 | - | }, | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | for _, tt := range tests { | |
| 101 | - | t.Run(tt.name, func(t *testing.T) { | |
| 102 | - | gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText) | |
| 103 | - | if gotText != tt.wantText { | |
| 104 | - | t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText) | |
| 105 | - | } | |
| 106 | - | if gotAccepted != tt.wantAccepted { | |
| 107 | - | t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted) | |
| 108 | - | } | |
| 109 | - | }) | |
| 110 | - | } | |
| 111 | - | } | |
| @@ -1,111 +0,0 @@ | |||||
| 1 | - | package component | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func TestInlineGhostSelection(t *testing.T) { | |||
| 6 | - | tests := []struct { | |||
| 7 | - | name string | |||
| 8 | - | realInput string | |||
| 9 | - | ghostFullText string | |||
| 10 | - | selectionStart int | |||
| 11 | - | selectionEnd int | |||
| 12 | - | hasSelection bool | |||
| 13 | - | want bool | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "selected suffix matches typed prefix", | |||
| 17 | - | realInput: "goo", | |||
| 18 | - | ghostFullText: "google.com", | |||
| 19 | - | selectionStart: 3, | |||
| 20 | - | selectionEnd: 10, | |||
| 21 | - | hasSelection: true, | |||
| 22 | - | want: true, | |||
| 23 | - | }, | |||
| 24 | - | { | |||
| 25 | - | name: "no selection means no ghost suffix", | |||
| 26 | - | realInput: "goo", | |||
| 27 | - | ghostFullText: "google.com", | |||
| 28 | - | selectionStart: 10, | |||
| 29 | - | selectionEnd: 10, | |||
| 30 | - | hasSelection: false, | |||
| 31 | - | want: false, | |||
| 32 | - | }, | |||
| 33 | - | { | |||
| 34 | - | name: "collapsed selection should not be treated as ghost", | |||
| 35 | - | realInput: "goo", | |||
| 36 | - | ghostFullText: "google.com", | |||
| 37 | - | selectionStart: 10, | |||
| 38 | - | selectionEnd: 10, | |||
| 39 | - | hasSelection: true, | |||
| 40 | - | want: false, | |||
| 41 | - | }, | |||
| 42 | - | { | |||
| 43 | - | name: "wrong selection range should not be treated as ghost", | |||
| 44 | - | realInput: "goo", | |||
| 45 | - | ghostFullText: "google.com", | |||
| 46 | - | selectionStart: 1, | |||
| 47 | - | selectionEnd: 10, | |||
| 48 | - | hasSelection: true, | |||
| 49 | - | want: false, | |||
| 50 | - | }, | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | for _, tt := range tests { | |||
| 54 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | - | got := isInlineGhostSelection( | |||
| 56 | - | tt.realInput, | |||
| 57 | - | tt.ghostFullText, | |||
| 58 | - | tt.selectionStart, | |||
| 59 | - | tt.selectionEnd, | |||
| 60 | - | tt.hasSelection, | |||
| 61 | - | ) | |||
| 62 | - | if got != tt.want { | |||
| 63 | - | t.Fatalf("isInlineGhostSelection() = %v, want %v", got, tt.want) | |||
| 64 | - | } | |||
| 65 | - | }) | |||
| 66 | - | } | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | func TestAcceptedGhostInput(t *testing.T) { | |||
| 70 | - | tests := []struct { | |||
| 71 | - | name string | |||
| 72 | - | hasGhost bool | |||
| 73 | - | ghostFullText string | |||
| 74 | - | wantText string | |||
| 75 | - | wantAccepted bool | |||
| 76 | - | }{ | |||
| 77 | - | { | |||
| 78 | - | name: "accepts explicit ghost", | |||
| 79 | - | hasGhost: true, | |||
| 80 | - | ghostFullText: "google.com", | |||
| 81 | - | wantText: "google.com", | |||
| 82 | - | wantAccepted: true, | |||
| 83 | - | }, | |||
| 84 | - | { | |||
| 85 | - | name: "rejects without ghost", | |||
| 86 | - | hasGhost: false, | |||
| 87 | - | ghostFullText: "google.com", | |||
| 88 | - | wantText: "", | |||
| 89 | - | wantAccepted: false, | |||
| 90 | - | }, | |||
| 91 | - | { | |||
| 92 | - | name: "rejects empty ghost text", | |||
| 93 | - | hasGhost: true, | |||
| 94 | - | ghostFullText: "", | |||
| 95 | - | wantText: "", | |||
| 96 | - | wantAccepted: false, | |||
| 97 | - | }, | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | for _, tt := range tests { | |||
| 101 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 102 | - | gotText, gotAccepted := acceptedGhostInput(tt.hasGhost, tt.ghostFullText) | |||
| 103 | - | if gotText != tt.wantText { | |||
| 104 | - | t.Fatalf("acceptedGhostInput() text = %q, want %q", gotText, tt.wantText) | |||
| 105 | - | } | |||
| 106 | - | if gotAccepted != tt.wantAccepted { | |||
| 107 | - | t.Fatalf("acceptedGhostInput() accepted = %v, want %v", gotAccepted, tt.wantAccepted) | |||
| 108 | - | } | |||
| 109 | - | }) | |||
| 110 | - | } | |||
| 111 | - | } | |||
M internal/ui/component/omnibox_navigation_state_test.go +10 -39
| @@ -44,51 +44,22 @@ func TestResetSearchSessionState(t *testing.T) { | |||
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | - | func TestShouldUpdateGhostImmediately(t *testing.T) { | |
| 47 | + | func TestIsDeletionKey(t *testing.T) { | |
| 48 | 48 | tests := []struct { | |
| 49 | - | name string | |
| 50 | - | previousInput string | |
| 51 | - | entryText string | |
| 52 | - | want bool | |
| 49 | + | name string | |
| 50 | + | key uint | |
| 51 | + | want bool | |
| 53 | 52 | }{ | |
| 54 | - | {name: "typing forward", previousInput: "goo", entryText: "goog", want: true}, | |
| 55 | - | {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true}, | |
| 56 | - | {name: "backspace", previousInput: "goog", entryText: "goo", want: false}, | |
| 57 | - | {name: "clear input", previousInput: "goo", entryText: "", want: false}, | |
| 53 | + | {name: "backspace is deletion", key: uint(gdk.KEY_BackSpace), want: true}, | |
| 54 | + | {name: "delete is deletion", key: uint(gdk.KEY_Delete), want: true}, | |
| 55 | + | {name: "typing key is not deletion", key: uint(gdk.KEY_g), want: false}, | |
| 56 | + | {name: "arrow key is not deletion", key: uint(gdk.KEY_Left), want: false}, | |
| 58 | 57 | } | |
| 59 | 58 | ||
| 60 | 59 | for _, tt := range tests { | |
| 61 | 60 | t.Run(tt.name, func(t *testing.T) { | |
| 62 | - | if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want { | |
| 63 | - | t.Fatalf( | |
| 64 | - | "shouldUpdateGhostImmediately(%q, %q) = %v, want %v", | |
| 65 | - | tt.previousInput, | |
| 66 | - | tt.entryText, | |
| 67 | - | got, | |
| 68 | - | tt.want, | |
| 69 | - | ) | |
| 70 | - | } | |
| 71 | - | }) | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func TestNextGhostSuppressionState(t *testing.T) { | |
| 76 | - | tests := []struct { | |
| 77 | - | name string | |
| 78 | - | current bool | |
| 79 | - | key uint | |
| 80 | - | want bool | |
| 81 | - | }{ | |
| 82 | - | {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true}, | |
| 83 | - | {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true}, | |
| 84 | - | {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false}, | |
| 85 | - | {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false}, | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | for _, tt := range tests { | |
| 89 | - | t.Run(tt.name, func(t *testing.T) { | |
| 90 | - | if got := nextGhostSuppressionState(tt.key); got != tt.want { | |
| 91 | - | t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want) | |
| 61 | + | if got := isDeletionKey(tt.key); got != tt.want { | |
| 62 | + | t.Fatalf("isDeletionKey(%d) = %v, want %v", tt.key, got, tt.want) | |
| 92 | 63 | } | |
| 93 | 64 | }) | |
| 94 | 65 | } | |
| @@ -44,51 +44,22 @@ func TestResetSearchSessionState(t *testing.T) { | |||||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | - | func TestShouldUpdateGhostImmediately(t *testing.T) { | 47 | + | func TestIsDeletionKey(t *testing.T) { |
| 48 | tests := []struct { | 48 | tests := []struct { | ||
| 49 | - | name string | 49 | + | name string |
| 50 | - | previousInput string | 50 | + | key uint |
| 51 | - | entryText string | 51 | + | want bool |
| 52 | - | want bool | |||
| 53 | }{ | 52 | }{ | ||
| 54 | - | {name: "typing forward", previousInput: "goo", entryText: "goog", want: true}, | 53 | + | {name: "backspace is deletion", key: uint(gdk.KEY_BackSpace), want: true}, |
| 55 | - | {name: "same length replace", previousInput: "goo", entryText: "gaa", want: true}, | 54 | + | {name: "delete is deletion", key: uint(gdk.KEY_Delete), want: true}, |
| 56 | - | {name: "backspace", previousInput: "goog", entryText: "goo", want: false}, | 55 | + | {name: "typing key is not deletion", key: uint(gdk.KEY_g), want: false}, |
| 57 | - | {name: "clear input", previousInput: "goo", entryText: "", want: false}, | 56 | + | {name: "arrow key is not deletion", key: uint(gdk.KEY_Left), want: false}, |
| 58 | } | 57 | } | ||
| 59 | 58 | ||||
| 60 | for _, tt := range tests { | 59 | for _, tt := range tests { | ||
| 61 | t.Run(tt.name, func(t *testing.T) { | 60 | t.Run(tt.name, func(t *testing.T) { | ||
| 62 | - | if got := shouldUpdateGhostImmediately(tt.previousInput, tt.entryText); got != tt.want { | 61 | + | if got := isDeletionKey(tt.key); got != tt.want { |
| 63 | - | t.Fatalf( | 62 | + | t.Fatalf("isDeletionKey(%d) = %v, want %v", tt.key, got, tt.want) |
| 64 | - | "shouldUpdateGhostImmediately(%q, %q) = %v, want %v", | |||
| 65 | - | tt.previousInput, | |||
| 66 | - | tt.entryText, | |||
| 67 | - | got, | |||
| 68 | - | tt.want, | |||
| 69 | - | ) | |||
| 70 | - | } | |||
| 71 | - | }) | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func TestNextGhostSuppressionState(t *testing.T) { | |||
| 76 | - | tests := []struct { | |||
| 77 | - | name string | |||
| 78 | - | current bool | |||
| 79 | - | key uint | |||
| 80 | - | want bool | |||
| 81 | - | }{ | |||
| 82 | - | {name: "backspace enables suppression", current: false, key: uint(gdk.KEY_BackSpace), want: true}, | |||
| 83 | - | {name: "delete enables suppression", current: false, key: uint(gdk.KEY_Delete), want: true}, | |||
| 84 | - | {name: "typing key disables suppression", current: true, key: uint(gdk.KEY_g), want: false}, | |||
| 85 | - | {name: "arrow key disables suppression", current: true, key: uint(gdk.KEY_Left), want: false}, | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | for _, tt := range tests { | |||
| 89 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 90 | - | if got := nextGhostSuppressionState(tt.key); got != tt.want { | |||
| 91 | - | t.Fatalf("nextGhostSuppressionState(%v, %d) = %v, want %v", tt.current, tt.key, got, tt.want) | |||
| 92 | } | 63 | } | ||
| 93 | }) | 64 | }) | ||
| 94 | } | 65 | } | ||
M internal/ui/dialog/permission.go +19 -1
| @@ -84,6 +84,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 84 | 84 | ctx := req.ctx | |
| 85 | 85 | origin := req.origin | |
| 86 | 86 | permTypes := req.permTypes | |
| 87 | + | metadata := req.metadata | |
| 87 | 88 | callback := req.callback | |
| 88 | 89 | ||
| 89 | 90 | log := logging.FromContext(ctx) | |
| @@ -97,9 +98,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 97 | 98 | ||
| 98 | 99 | // Build dialog text | |
| 99 | 100 | heading := d.buildHeading(permTypes) | |
| 100 | - | body := d.buildBody(origin, permTypes, req.metadata) | |
| 101 | + | body := d.buildBody(origin, permTypes, metadata) | |
| 102 | + | ||
| 103 | + | if parsePermFlags(permTypes).dataAccess { | |
| 104 | + | log.Info(). | |
| 105 | + | Str("origin", origin). | |
| 106 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |
| 107 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |
| 108 | + | Msg("showing website data access permission dialog") | |
| 109 | + | } | |
| 101 | 110 | ||
| 102 | 111 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | |
| 112 | + | if parsePermFlags(permTypes).dataAccess { | |
| 113 | + | log.Info(). | |
| 114 | + | Str("origin", origin). | |
| 115 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |
| 116 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |
| 117 | + | Bool("allowed", allowed). | |
| 118 | + | Bool("persistent", persistent). | |
| 119 | + | Msg("website data access permission dialog response") | |
| 120 | + | } | |
| 103 | 121 | log.Debug(). | |
| 104 | 122 | Bool("allowed", allowed). | |
| 105 | 123 | Bool("persistent", persistent). | |
| @@ -84,6 +84,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||||
| 84 | ctx := req.ctx | 84 | ctx := req.ctx | ||
| 85 | origin := req.origin | 85 | origin := req.origin | ||
| 86 | permTypes := req.permTypes | 86 | permTypes := req.permTypes | ||
| 87 | + | metadata := req.metadata | |||
| 87 | callback := req.callback | 88 | callback := req.callback | ||
| 88 | 89 | ||||
| 89 | log := logging.FromContext(ctx) | 90 | log := logging.FromContext(ctx) | ||
| @@ -97,9 +98,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||||
| 97 | 98 | ||||
| 98 | // Build dialog text | 99 | // Build dialog text | ||
| 99 | heading := d.buildHeading(permTypes) | 100 | heading := d.buildHeading(permTypes) | ||
| 100 | - | body := d.buildBody(origin, permTypes, req.metadata) | 101 | + | body := d.buildBody(origin, permTypes, metadata) |
| 102 | + | ||||
| 103 | + | if parsePermFlags(permTypes).dataAccess { | |||
| 104 | + | log.Info(). | |||
| 105 | + | Str("origin", origin). | |||
| 106 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |||
| 107 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |||
| 108 | + | Msg("showing website data access permission dialog") | |||
| 109 | + | } | |||
| 101 | 110 | ||||
| 102 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | 111 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | ||
| 112 | + | if parsePermFlags(permTypes).dataAccess { | |||
| 113 | + | log.Info(). | |||
| 114 | + | Str("origin", origin). | |||
| 115 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], 96)). | |||
| 116 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], 96)). | |||
| 117 | + | Bool("allowed", allowed). | |||
| 118 | + | Bool("persistent", persistent). | |||
| 119 | + | Msg("website data access permission dialog response") | |||
| 120 | + | } | |||
| 103 | log.Debug(). | 121 | log.Debug(). | ||
| 104 | Bool("allowed", allowed). | 122 | Bool("allowed", allowed). | ||
| 105 | Bool("persistent", persistent). | 123 | Bool("persistent", persistent). | ||