Merge pull request #206 from bnema/refactor/omnibox-ghostwriter
fix: rewrite omnibox ghostwriter to eliminate letter-eating on fast typing
9 files · +296 -398
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego-sqlite v0.1.1 | |
| 7 | - | github.com/bnema/puregotk v0.2.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 | |
| @@ -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 | ||
M go.sum +2 -2 generated file: collapsed
M internal/application/usecase/handle_permission.go +46 -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,33 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger { | |||
| 57 | 58 | return log | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 61 | + | const maxPermissionLogURLLen = 96 | |
| 62 | + | ||
| 63 | + | func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool { | |
| 64 | + | for _, permType := range permTypes { | |
| 65 | + | if permType == entity.PermissionTypeWebsiteDataAccess { | |
| 66 | + | return true | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | return false | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger { | |
| 73 | + | if log == nil { | |
| 74 | + | nop := zerolog.Nop() | |
| 75 | + | return &nop | |
| 76 | + | } | |
| 77 | + | ctx := log.With() | |
| 78 | + | if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" { | |
| 79 | + | ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, maxPermissionLogURLLen)) | |
| 80 | + | } | |
| 81 | + | if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" { | |
| 82 | + | ctx = ctx.Str("current_domain", logging.TruncateURL(current, maxPermissionLogURLLen)) | |
| 83 | + | } | |
| 84 | + | enriched := ctx.Logger() | |
| 85 | + | return &enriched | |
| 86 | + | } | |
| 87 | + | ||
| 60 | 88 | // SetDialogPresenter sets the dialog presenter. This can be called after | |
| 61 | 89 | // initialization when the UI window is available. | |
| 62 | 90 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | |
| @@ -127,10 +155,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||
| 127 | 155 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | |
| 128 | 156 | switch decision { | |
| 129 | 157 | case entity.PermissionGranted: | |
| 158 | + | if isWebsiteDataAccessPermission(permTypes) { | |
| 159 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |
| 160 | + | Msg("using stored website data access permission: granted") | |
| 161 | + | } | |
| 130 | 162 | log.Debug().Msg("using stored permission: granted") | |
| 131 | 163 | callback.Allow() | |
| 132 | 164 | return | |
| 133 | 165 | case entity.PermissionDenied: | |
| 166 | + | if isWebsiteDataAccessPermission(permTypes) { | |
| 167 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |
| 168 | + | Msg("using stored website data access permission: denied") | |
| 169 | + | } | |
| 134 | 170 | log.Debug().Msg("using stored permission: denied") | |
| 135 | 171 | callback.Deny() | |
| 136 | 172 | return | |
| @@ -269,6 +305,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||
| 269 | 305 | ||
| 270 | 306 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | |
| 271 | 307 | if result.Allowed { | |
| 308 | + | if isWebsiteDataAccessPermission(permTypes) { | |
| 309 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |
| 310 | + | Bool("persistent", result.Persistent). | |
| 311 | + | Msg("website data access permission allowed") | |
| 312 | + | } | |
| 272 | 313 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | |
| 273 | 314 | callback.Allow() | |
| 274 | 315 | ||
| @@ -277,6 +318,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||
| 277 | 318 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | |
| 278 | 319 | } | |
| 279 | 320 | } else { | |
| 321 | + | if isWebsiteDataAccessPermission(permTypes) { | |
| 322 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |
| 323 | + | Bool("persistent", result.Persistent). | |
| 324 | + | Msg("website data access permission denied") | |
| 325 | + | } | |
| 280 | 326 | log.Debug().Msg("user denied permission") | |
| 281 | 327 | callback.Deny() | |
| 282 | 328 | ||
| @@ -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,33 @@ func (uc *HandlePermissionUseCase) logger(ctx context.Context) *zerolog.Logger { | |||||
| 57 | return log | 58 | return log | ||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 61 | + | const maxPermissionLogURLLen = 96 | |||
| 62 | + | ||||
| 63 | + | func isWebsiteDataAccessPermission(permTypes []entity.PermissionType) bool { | |||
| 64 | + | for _, permType := range permTypes { | |||
| 65 | + | if permType == entity.PermissionTypeWebsiteDataAccess { | |||
| 66 | + | return true | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return false | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func permissionLoggerWithMetadata(log *zerolog.Logger, metadata entity.PermissionMetadata) *zerolog.Logger { | |||
| 73 | + | if log == nil { | |||
| 74 | + | nop := zerolog.Nop() | |||
| 75 | + | return &nop | |||
| 76 | + | } | |||
| 77 | + | ctx := log.With() | |||
| 78 | + | if requesting := metadata[entity.PermissionMetadataKeyRequestingDomain]; requesting != "" { | |||
| 79 | + | ctx = ctx.Str("requesting_domain", logging.TruncateURL(requesting, maxPermissionLogURLLen)) | |||
| 80 | + | } | |||
| 81 | + | if current := metadata[entity.PermissionMetadataKeyCurrentDomain]; current != "" { | |||
| 82 | + | ctx = ctx.Str("current_domain", logging.TruncateURL(current, maxPermissionLogURLLen)) | |||
| 83 | + | } | |||
| 84 | + | enriched := ctx.Logger() | |||
| 85 | + | return &enriched | |||
| 86 | + | } | |||
| 87 | + | ||||
| 60 | // SetDialogPresenter sets the dialog presenter. This can be called after | 88 | // SetDialogPresenter sets the dialog presenter. This can be called after | ||
| 61 | // initialization when the UI window is available. | 89 | // initialization when the UI window is available. | ||
| 62 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | 90 | func (uc *HandlePermissionUseCase) SetDialogPresenter(dialog port.PermissionDialogPresenter) { | ||
| @@ -127,10 +155,18 @@ func (uc *HandlePermissionUseCase) HandlePermissionRequest( | |||||
| 127 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | 155 | decision := uc.checkStoredPermissions(ctx, origin, permTypes) | ||
| 128 | switch decision { | 156 | switch decision { | ||
| 129 | case entity.PermissionGranted: | 157 | case entity.PermissionGranted: | ||
| 158 | + | if isWebsiteDataAccessPermission(permTypes) { | |||
| 159 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |||
| 160 | + | Msg("using stored website data access permission: granted") | |||
| 161 | + | } | |||
| 130 | log.Debug().Msg("using stored permission: granted") | 162 | log.Debug().Msg("using stored permission: granted") | ||
| 131 | callback.Allow() | 163 | callback.Allow() | ||
| 132 | return | 164 | return | ||
| 133 | case entity.PermissionDenied: | 165 | case entity.PermissionDenied: | ||
| 166 | + | if isWebsiteDataAccessPermission(permTypes) { | |||
| 167 | + | permissionLoggerWithMetadata(&log, metadata).Info(). | |||
| 168 | + | Msg("using stored website data access permission: denied") | |||
| 169 | + | } | |||
| 134 | log.Debug().Msg("using stored permission: denied") | 170 | log.Debug().Msg("using stored permission: denied") | ||
| 135 | callback.Deny() | 171 | callback.Deny() | ||
| 136 | return | 172 | return | ||
| @@ -269,6 +305,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||||
| 269 | 305 | ||||
| 270 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | 306 | dialog.ShowPermissionDialog(ctx, origin, permTypes, metadata, func(result port.PermissionDialogResult) { | ||
| 271 | if result.Allowed { | 307 | if result.Allowed { | ||
| 308 | + | if isWebsiteDataAccessPermission(permTypes) { | |||
| 309 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |||
| 310 | + | Bool("persistent", result.Persistent). | |||
| 311 | + | Msg("website data access permission allowed") | |||
| 312 | + | } | |||
| 272 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | 313 | log.Debug().Bool("persistent", result.Persistent).Msg("user allowed permission") | ||
| 273 | callback.Allow() | 314 | callback.Allow() | ||
| 274 | 315 | ||||
| @@ -277,6 +318,11 @@ func (uc *HandlePermissionUseCase) showPermissionDialog( | |||||
| 277 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | 318 | uc.persistPermission(ctx, origin, permTypes, entity.PermissionGranted) | ||
| 278 | } | 319 | } | ||
| 279 | } else { | 320 | } else { | ||
| 321 | + | if isWebsiteDataAccessPermission(permTypes) { | |||
| 322 | + | permissionLoggerWithMetadata(log, metadata).Info(). | |||
| 323 | + | Bool("persistent", result.Persistent). | |||
| 324 | + | Msg("website data access permission denied") | |||
| 325 | + | } | |||
| 280 | log.Debug().Msg("user denied permission") | 326 | log.Debug().Msg("user denied permission") | ||
| 281 | callback.Deny() | 327 | callback.Deny() | ||
| 282 | 328 | ||||
M internal/infrastructure/webkit/webview.go +31 -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 { | |
| @@ -586,6 +609,7 @@ func (wv *WebView) connectFaviconSignal() { | |||
| 586 | 609 | ||
| 587 | 610 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | |
| 588 | 611 | const progressThrottleInterval = 16 * time.Millisecond | |
| 612 | + | const maxPermissionLogURLLen = 96 | |
| 589 | 613 | ||
| 590 | 614 | func (wv *WebView) connectProgressSignal() { | |
| 591 | 615 | progressCb := func() { | |
| @@ -1024,6 +1048,13 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||
| 1024 | 1048 | wv.logger.Warn().Msg("permission request with unknown type, denying") | |
| 1025 | 1049 | return false | |
| 1026 | 1050 | } | |
| 1051 | + | if len(permTypes) == 1 && permTypes[0] == "website_data_access" { | |
| 1052 | + | wv.logger.Info(). | |
| 1053 | + | Str("origin", origin). | |
| 1054 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |
| 1055 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |
| 1056 | + | Msg("website data access permission requested") | |
| 1057 | + | } | |
| 1027 | 1058 | ||
| 1028 | 1059 | // Ref the request object to prevent use-after-free | |
| 1029 | 1060 | // 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 { | ||
| @@ -586,6 +609,7 @@ func (wv *WebView) connectFaviconSignal() { | |||||
| 586 | 609 | ||||
| 587 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | 610 | // progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead. | ||
| 588 | const progressThrottleInterval = 16 * time.Millisecond | 611 | const progressThrottleInterval = 16 * time.Millisecond | ||
| 612 | + | const maxPermissionLogURLLen = 96 | |||
| 589 | 613 | ||||
| 590 | func (wv *WebView) connectProgressSignal() { | 614 | func (wv *WebView) connectProgressSignal() { | ||
| 591 | progressCb := func() { | 615 | progressCb := func() { | ||
| @@ -1024,6 +1048,13 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||||
| 1024 | wv.logger.Warn().Msg("permission request with unknown type, denying") | 1048 | wv.logger.Warn().Msg("permission request with unknown type, denying") | ||
| 1025 | return false | 1049 | return false | ||
| 1026 | } | 1050 | } | ||
| 1051 | + | if len(permTypes) == 1 && permTypes[0] == "website_data_access" { | |||
| 1052 | + | wv.logger.Info(). | |||
| 1053 | + | Str("origin", origin). | |||
| 1054 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |||
| 1055 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |||
| 1056 | + | Msg("website data access permission requested") | |||
| 1057 | + | } | |||
| 1027 | 1058 | ||||
| 1028 | // Ref the request object to prevent use-after-free | 1059 | // Ref the request object to prevent use-after-free | ||
| 1029 | // The request may outlive the signal handler if we show a dialog | 1060 | // The request may outlive the signal handler if we show a dialog | ||
M internal/ui/component/omnibox.go +185 -219
| @@ -95,11 +95,9 @@ 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 | |
| 100 | - | 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 | |
| 98 | + | ghostSuffix string // Completion suffix currently displayed | |
| 99 | + | insertCompletion bool // True on character insert, false on delete — gates ghost completion | |
| 100 | + | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | |
| 103 | 101 | ||
| 104 | 102 | // Dependencies | |
| 105 | 103 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -123,7 +121,6 @@ type Omnibox struct { | |||
| 123 | 121 | debounceMu sync.Mutex | |
| 124 | 122 | lastQuery string // Prevent duplicate searches | |
| 125 | 123 | searchToken uint64 | |
| 126 | - | ghostToken uint64 | |
| 127 | 124 | ||
| 128 | 125 | // Scaling | |
| 129 | 126 | uiScale float64 | |
| @@ -638,7 +635,6 @@ func (o *Omnibox) assembleWidgets() { | |||
| 638 | 635 | ||
| 639 | 636 | // setupEntryChanged wires entry text changes to debounced search. | |
| 640 | 637 | func (o *Omnibox) setupEntryChanged() { | |
| 641 | - | // SearchEntry has built-in debouncing via search-changed signal | |
| 642 | 638 | changedCb := func(_ gtk.SearchEntry) { | |
| 643 | 639 | o.onEntryChanged() | |
| 644 | 640 | } | |
| @@ -681,7 +677,7 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO | |||
| 681 | 677 | func (o *Omnibox) hasGhost() bool { | |
| 682 | 678 | o.mu.RLock() | |
| 683 | 679 | defer o.mu.RUnlock() | |
| 684 | - | return o.hasGhostText | |
| 680 | + | return o.ghostSuffix != "" | |
| 685 | 681 | } | |
| 686 | 682 | ||
| 687 | 683 | // hasUserNavigated returns whether user has navigated with arrow keys. | |
| @@ -694,7 +690,7 @@ func (o *Omnibox) hasUserNavigated() bool { | |||
| 694 | 690 | // handleKeyPress processes keyboard events. | |
| 695 | 691 | // Returns true if the event was handled. | |
| 696 | 692 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 697 | - | o.updateGhostSuppressionFromKey(keyval) | |
| 693 | + | o.updateInsertCompletionFromKey(keyval) | |
| 698 | 694 | ||
| 699 | 695 | ctrl := state&gdk.ControlMaskValue != 0 | |
| 700 | 696 | ||
| @@ -767,16 +763,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 767 | 763 | return false // Let entry handle the key | |
| 768 | 764 | } | |
| 769 | 765 | ||
| 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 | 766 | func isDeletionKey(keyval uint) bool { | |
| 781 | 767 | switch keyval { | |
| 782 | 768 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |
| @@ -786,6 +772,28 @@ func isDeletionKey(keyval uint) bool { | |||
| 786 | 772 | } | |
| 787 | 773 | } | |
| 788 | 774 | ||
| 775 | + | // updateInsertCompletionFromKey sets whether the next text change should trigger | |
| 776 | + | // ghost completion. Character insertion enables it; deletion disables it. | |
| 777 | + | func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) { | |
| 778 | + | o.mu.Lock() | |
| 779 | + | o.insertCompletion = !isDeletionKey(keyval) | |
| 780 | + | o.mu.Unlock() | |
| 781 | + | } | |
| 782 | + | ||
| 783 | + | // withGhostGuard executes fn while the isSettingGhost flag is set, | |
| 784 | + | // so that onEntryChanged skips during programmatic text changes. | |
| 785 | + | func (o *Omnibox) withGhostGuard(fn func()) { | |
| 786 | + | o.mu.Lock() | |
| 787 | + | o.isSettingGhost = true | |
| 788 | + | o.mu.Unlock() | |
| 789 | + | ||
| 790 | + | fn() | |
| 791 | + | ||
| 792 | + | o.mu.Lock() | |
| 793 | + | o.isSettingGhost = false | |
| 794 | + | o.mu.Unlock() | |
| 795 | + | } | |
| 796 | + | ||
| 789 | 797 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | |
| 790 | 798 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | |
| 791 | 799 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | |
| @@ -823,42 +831,60 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||
| 823 | 831 | } | |
| 824 | 832 | ||
| 825 | 833 | // onEntryChanged handles text input changes with debouncing. | |
| 834 | + | // When this fires, GTK has already processed the keystroke — if ghost text was | |
| 835 | + | // selected and the user typed, the selection was replaced naturally by GTK. | |
| 836 | + | // We must NOT call clearGhostText() here (that would delete real user text). | |
| 826 | 837 | func (o *Omnibox) onEntryChanged() { | |
| 827 | - | // Skip if we're programmatically accepting ghost text | |
| 828 | 838 | o.mu.RLock() | |
| 829 | - | isAccepting := o.isAcceptingGhost | |
| 839 | + | isSettingGhost := o.isSettingGhost | |
| 830 | 840 | o.mu.RUnlock() | |
| 831 | - | if isAccepting { | |
| 841 | + | if isSettingGhost { | |
| 832 | 842 | return | |
| 833 | 843 | } | |
| 834 | 844 | ||
| 835 | 845 | entryText := o.entry.GetText() | |
| 836 | - | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |
| 837 | - | o.entry.SetText(trimmed) | |
| 838 | - | o.entry.SetPosition(-1) | |
| 846 | + | ||
| 847 | + | // Detect debounced echo from our own SetText in setGhostText. | |
| 848 | + | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | |
| 849 | + | // the isSettingGhost guard is already off. | |
| 850 | + | o.mu.RLock() | |
| 851 | + | if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | |
| 852 | + | o.mu.RUnlock() | |
| 839 | 853 | return | |
| 840 | 854 | } | |
| 855 | + | o.mu.RUnlock() | |
| 841 | 856 | ||
| 842 | - | if o.isGhostProgrammaticEcho(entryText) { | |
| 843 | - | return | |
| 857 | + | log := logging.FromContext(o.ctx) | |
| 858 | + | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |
| 859 | + | o.withGhostGuard(func() { | |
| 860 | + | o.entry.SetText(trimmed) | |
| 861 | + | o.entry.SetPosition(-1) | |
| 862 | + | }) | |
| 863 | + | entryText = trimmed | |
| 844 | 864 | } | |
| 845 | 865 | ||
| 846 | - | // Reset navigation state when user types - space should type, not toggle favorite | |
| 866 | + | // Reset ghost and selection state (the buffer is already correct — GTK handled it). | |
| 847 | 867 | o.mu.Lock() | |
| 848 | - | previousInput := o.realInput | |
| 868 | + | oldGhost := o.ghostSuffix | |
| 849 | 869 | o.hasNavigated = false | |
| 850 | - | // Update realInput with what user actually typed | |
| 870 | + | o.selectedIndex = -1 | |
| 871 | + | o.ghostSuffix = "" | |
| 851 | 872 | o.realInput = entryText | |
| 873 | + | shouldComplete := o.insertCompletion | |
| 852 | 874 | o.mu.Unlock() | |
| 853 | 875 | ||
| 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) { | |
| 876 | + | log.Debug(). | |
| 877 | + | Str("entryText", entryText). | |
| 878 | + | Str("oldGhost", oldGhost). | |
| 879 | + | Bool("insertCompletion", shouldComplete). | |
| 880 | + | Msg("ghost: onEntryChanged") | |
| 881 | + | ||
| 882 | + | // Compute ghost completion synchronously if this was a character insertion. | |
| 883 | + | if shouldComplete { | |
| 859 | 884 | o.updateGhostFromSelectionWithInput(entryText) | |
| 860 | 885 | } | |
| 861 | 886 | ||
| 887 | + | // Schedule async search for suggestion list updates. | |
| 862 | 888 | o.debounceMu.Lock() | |
| 863 | 889 | if o.debounceTimer != nil { | |
| 864 | 890 | o.debounceTimer.Stop() | |
| @@ -869,131 +895,93 @@ func (o *Omnibox) onEntryChanged() { | |||
| 869 | 895 | o.debounceMu.Unlock() | |
| 870 | 896 | } | |
| 871 | 897 | ||
| 872 | - | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | |
| 873 | - | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | |
| 874 | - | } | |
| 898 | + | // setGhostText applies inline completion by setting the full text (input + suffix) | |
| 899 | + | // and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged. | |
| 900 | + | // Skips if the user has typed since this ghost was computed (stale input check). | |
| 901 | + | func (o *Omnibox) setGhostText(originalInput, suffix string) { | |
| 902 | + | log := logging.FromContext(o.ctx) | |
| 903 | + | if o.entry == nil || suffix == "" { | |
| 904 | + | o.clearGhostText() | |
| 905 | + | return | |
| 906 | + | } | |
| 875 | 907 | ||
| 876 | - | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | |
| 908 | + | // Verify the buffer hasn't been modified by user typing since this ghost | |
| 909 | + | // was computed. We check the actual buffer — not realInput, which may be | |
| 910 | + | // stale due to debounced search-changed signal. | |
| 911 | + | currentBuffer := o.entry.GetText() | |
| 877 | 912 | o.mu.RLock() | |
| 878 | - | hasGhost := o.hasGhostText | |
| 879 | - | realInput := o.realInput | |
| 880 | - | ghostFullText := o.ghostFullText | |
| 913 | + | existingGhost := o.ghostSuffix | |
| 881 | 914 | 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() | |
| 915 | + | expectedWithGhost := originalInput + existingGhost | |
| 916 | + | if currentBuffer != originalInput && currentBuffer != expectedWithGhost { | |
| 917 | + | log.Debug(). | |
| 918 | + | Str("originalInput", originalInput). | |
| 919 | + | Str("currentBuffer", currentBuffer). | |
| 920 | + | Str("suffix", suffix). | |
| 921 | + | Msg("ghost: setGhostText skipped (buffer modified by user)") | |
| 910 | 922 | return | |
| 911 | 923 | } | |
| 912 | 924 | ||
| 913 | 925 | displayText := originalInput + suffix | |
| 914 | - | if fullText != "" { | |
| 915 | - | displayText = fullText | |
| 916 | - | } | |
| 917 | - | selectionStart := utf8.RuneCountInString(originalInput) | |
| 918 | - | selectionEnd := utf8.RuneCountInString(displayText) | |
| 926 | + | cursorPos := utf8.RuneCountInString(originalInput) | |
| 927 | + | endPos := utf8.RuneCountInString(displayText) | |
| 928 | + | ||
| 929 | + | log.Debug(). | |
| 930 | + | Str("input", originalInput). | |
| 931 | + | Str("suffix", suffix). | |
| 932 | + | Int("cursorPos", cursorPos). | |
| 933 | + | Int("endPos", endPos). | |
| 934 | + | Msg("ghost: setGhostText") | |
| 919 | 935 | ||
| 920 | 936 | o.mu.Lock() | |
| 921 | 937 | o.realInput = originalInput | |
| 922 | 938 | o.ghostSuffix = suffix | |
| 923 | - | o.ghostFullText = fullText | |
| 924 | - | o.hasGhostText = true | |
| 925 | - | capturedToken := o.ghostToken | |
| 926 | - | o.mu.Unlock() | |
| 927 | - | ||
| 928 | - | o.idleCoalescer.Post("omnibox-ghost-set", func() { | |
| 929 | - | if o.entry == nil { | |
| 930 | - | return | |
| 931 | - | } | |
| 932 | 939 | ||
| 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() | |
| 940 | + | o.mu.Unlock() | |
| 946 | 941 | ||
| 942 | + | o.withGhostGuard(func() { | |
| 947 | 943 | 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() | |
| 944 | + | o.entry.SetPosition(cursorPos) | |
| 945 | + | o.entry.SelectRegion(cursorPos, endPos) | |
| 954 | 946 | }) | |
| 955 | 947 | } | |
| 956 | 948 | ||
| 957 | - | // clearGhostText hides the ghost completion text. | |
| 949 | + | // clearGhostText removes any ghost suffix from the entry buffer. | |
| 950 | + | // Uses guard flag to prevent re-entrant onEntryChanged. | |
| 958 | 951 | func (o *Omnibox) clearGhostText() { | |
| 952 | + | log := logging.FromContext(o.ctx) | |
| 959 | 953 | o.mu.Lock() | |
| 954 | + | hadGhost := o.ghostSuffix != "" | |
| 955 | + | ghostSuffix := o.ghostSuffix | |
| 960 | 956 | realInput := o.realInput | |
| 961 | - | ghostFullText := o.ghostFullText | |
| 962 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 963 | - | o.ghostToken++ | |
| 964 | - | capturedToken := o.ghostToken | |
| 965 | 957 | o.ghostSuffix = "" | |
| 966 | - | o.ghostFullText = "" | |
| 967 | - | o.hasGhostText = false | |
| 958 | + | ||
| 968 | 959 | o.mu.Unlock() | |
| 969 | 960 | ||
| 970 | - | if !hadGhost || o.entry == nil { | |
| 961 | + | if !hadGhost || ghostSuffix == "" || o.entry == nil { | |
| 971 | 962 | return | |
| 972 | 963 | } | |
| 973 | 964 | ||
| 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 | - | } | |
| 965 | + | // Only touch the buffer if it still shows the expected ghost text. | |
| 966 | + | expectedText := realInput + ghostSuffix | |
| 967 | + | currentText := o.entry.GetText() | |
| 968 | + | if currentText != expectedText { | |
| 969 | + | log.Debug(). | |
| 970 | + | Str("expected", expectedText). | |
| 971 | + | Str("current", currentText). | |
| 972 | + | Msg("ghost: clearGhostText skipped (buffer changed)") | |
| 973 | + | return | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | log.Debug(). | |
| 977 | + | Str("realInput", realInput). | |
| 978 | + | Str("ghostSuffix", ghostSuffix). | |
| 979 | + | Msg("ghost: clearGhostText") | |
| 980 | + | ||
| 981 | + | o.withGhostGuard(func() { | |
| 982 | + | o.entry.SetText(realInput) | |
| 992 | 983 | o.entry.SelectRegion(-1, -1) | |
| 993 | 984 | o.entry.SetPosition(-1) | |
| 994 | - | o.mu.Lock() | |
| 995 | - | o.isAcceptingGhost = false | |
| 996 | - | o.mu.Unlock() | |
| 997 | 985 | }) | |
| 998 | 986 | } | |
| 999 | 987 | ||
| @@ -1009,45 +997,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||
| 1009 | 997 | o.clearGhostText() | |
| 1010 | 998 | } | |
| 1011 | 999 | ||
| 1012 | - | // acceptGhostCompletion accepts the ghost text and fills the input. | |
| 1000 | + | // acceptGhostCompletion accepts the ghost text — the suffix is already in the | |
| 1001 | + | // buffer, so we just deselect and update realInput to include it. | |
| 1013 | 1002 | func (o *Omnibox) acceptGhostCompletion() { | |
| 1003 | + | log := logging.FromContext(o.ctx) | |
| 1014 | 1004 | o.mu.Lock() | |
| 1015 | - | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | |
| 1016 | - | if !accepted { | |
| 1005 | + | if o.ghostSuffix == "" { | |
| 1017 | 1006 | o.mu.Unlock() | |
| 1007 | + | log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept") | |
| 1018 | 1008 | return | |
| 1019 | 1009 | } | |
| 1020 | - | o.isAcceptingGhost = true | |
| 1021 | - | o.realInput = fullText | |
| 1022 | - | o.selectedIndex = -1 | |
| 1023 | - | o.hasNavigated = false | |
| 1024 | - | o.ghostToken++ | |
| 1010 | + | suffix := o.ghostSuffix | |
| 1011 | + | ||
| 1025 | 1012 | o.ghostSuffix = "" | |
| 1026 | - | o.ghostFullText = "" | |
| 1027 | - | o.hasGhostText = false | |
| 1028 | 1013 | o.mu.Unlock() | |
| 1014 | + | log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion") | |
| 1029 | 1015 | ||
| 1030 | 1016 | 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) | |
| 1017 | + | // The suffix is already in the buffer as selected text. | |
| 1018 | + | // Just deselect and move cursor to end. | |
| 1019 | + | fullText := o.entry.GetText() | |
| 1020 | + | o.mu.Lock() | |
| 1021 | + | o.realInput = fullText | |
| 1022 | + | o.selectedIndex = -1 | |
| 1023 | + | o.hasNavigated = false | |
| 1024 | + | o.mu.Unlock() | |
| 1025 | + | ||
| 1026 | + | o.withGhostGuard(func() { | |
| 1027 | + | o.entry.SelectRegion(-1, -1) | |
| 1028 | + | o.entry.SetPosition(-1) | |
| 1029 | + | }) | |
| 1036 | 1030 | } | |
| 1037 | 1031 | if o.listBox != nil { | |
| 1038 | 1032 | o.listBox.SelectRow(nil) | |
| 1039 | 1033 | } | |
| 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 | 1034 | } | |
| 1052 | 1035 | ||
| 1053 | 1036 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | |
| @@ -1064,13 +1047,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||
| 1064 | 1047 | ||
| 1065 | 1048 | // Only show full URL as ghost text when input is empty (initial state) | |
| 1066 | 1049 | if userInput == "" { | |
| 1067 | - | o.setGhostText("", displayURL, displayURL) | |
| 1050 | + | o.setGhostText("", displayURL) | |
| 1068 | 1051 | return true | |
| 1069 | 1052 | } | |
| 1070 | 1053 | ||
| 1071 | 1054 | // 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) | |
| 1055 | + | if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { | |
| 1056 | + | o.setGhostText(userInput, suffix) | |
| 1074 | 1057 | return true | |
| 1075 | 1058 | } | |
| 1076 | 1059 | ||
| @@ -1081,7 +1064,7 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||
| 1081 | 1064 | func (o *Omnibox) updateGhostFromSelection() { | |
| 1082 | 1065 | o.mu.RLock() | |
| 1083 | 1066 | realInput := o.realInput | |
| 1084 | - | hasGhost := o.hasGhostText | |
| 1067 | + | hasGhost := o.ghostSuffix != "" | |
| 1085 | 1068 | o.mu.RUnlock() | |
| 1086 | 1069 | ||
| 1087 | 1070 | entryText := "" | |
| @@ -1099,44 +1082,39 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||
| 1099 | 1082 | return | |
| 1100 | 1083 | } | |
| 1101 | 1084 | ||
| 1085 | + | // Clear ghost text first (removes suffix from buffer with guard). | |
| 1086 | + | o.clearGhostText() | |
| 1087 | + | ||
| 1088 | + | // If the buffer still doesn't match realInput, force-set it. | |
| 1102 | 1089 | o.mu.RLock() | |
| 1103 | 1090 | realInput := o.realInput | |
| 1104 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |
| 1105 | 1091 | o.mu.RUnlock() | |
| 1106 | 1092 | ||
| 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) | |
| 1093 | + | if o.entry.GetText() != realInput { | |
| 1094 | + | o.withGhostGuard(func() { | |
| 1095 | + | o.entry.SetText(realInput) | |
| 1096 | + | }) | |
| 1117 | 1097 | } | |
| 1118 | 1098 | o.entry.SelectRegion(-1, -1) | |
| 1119 | 1099 | o.entry.SetPosition(-1) | |
| 1120 | - | ||
| 1121 | - | o.mu.Lock() | |
| 1122 | - | o.isAcceptingGhost = false | |
| 1123 | - | o.mu.Unlock() | |
| 1124 | 1100 | } | |
| 1125 | 1101 | ||
| 1126 | 1102 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | |
| 1127 | 1103 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |
| 1104 | + | log := logging.FromContext(o.ctx) | |
| 1128 | 1105 | o.mu.RLock() | |
| 1129 | 1106 | idx := o.selectedIndex | |
| 1130 | 1107 | mode := o.viewMode | |
| 1131 | 1108 | bangMode := o.bangMode | |
| 1132 | 1109 | hasNavigated := o.hasNavigated | |
| 1133 | - | suppressGhost := o.suppressGhost | |
| 1110 | + | shouldComplete := o.insertCompletion | |
| 1134 | 1111 | suggestions := o.suggestions | |
| 1135 | 1112 | favorites := o.favorites | |
| 1136 | 1113 | o.mu.RUnlock() | |
| 1137 | 1114 | ||
| 1138 | - | if suppressGhost { | |
| 1139 | - | o.clearGhostTextIfInput(entryText) | |
| 1115 | + | // No ghost text after deletion (backspace/delete key). | |
| 1116 | + | if !shouldComplete { | |
| 1117 | + | log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)") | |
| 1140 | 1118 | return | |
| 1141 | 1119 | } | |
| 1142 | 1120 | ||
| @@ -1170,6 +1148,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||
| 1170 | 1148 | o.clearGhostTextIfInput(entryText) | |
| 1171 | 1149 | } | |
| 1172 | 1150 | ||
| 1151 | + | // resolveGhostCompletion computes and applies ghost text synchronously. | |
| 1152 | + | // All computation is pure string matching — no I/O, no goroutines needed. | |
| 1173 | 1153 | func (o *Omnibox) resolveGhostCompletion( | |
| 1174 | 1154 | entryText, selectedURL string, | |
| 1175 | 1155 | hasExplicitSelection bool, | |
| @@ -1177,33 +1157,29 @@ func (o *Omnibox) resolveGhostCompletion( | |||
| 1177 | 1157 | suggestions []Suggestion, | |
| 1178 | 1158 | favorites []Favorite, | |
| 1179 | 1159 | ) { | |
| 1180 | - | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | |
| 1160 | + | log := logging.FromContext(o.ctx) | |
| 1161 | + | _, completionInput, ok := ghostCompletionInput(entryText) | |
| 1181 | 1162 | if !ok { | |
| 1182 | - | o.clearGhostTextIfInput(entryText) | |
| 1163 | + | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input") | |
| 1183 | 1164 | return | |
| 1184 | 1165 | } | |
| 1185 | 1166 | ||
| 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 | - | }() | |
| 1167 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | |
| 1168 | + | if !found { | |
| 1169 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") | |
| 1170 | + | return | |
| 1171 | + | } | |
| 1172 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | |
| 1173 | + | if fullText == "" || suffix == "" { | |
| 1174 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize") | |
| 1175 | + | return | |
| 1176 | + | } | |
| 1177 | + | log.Debug(). | |
| 1178 | + | Str("input", completionInput). | |
| 1179 | + | Str("fullText", fullText). | |
| 1180 | + | Str("suffix", suffix). | |
| 1181 | + | Msg("ghost: resolveGhost — applying") | |
| 1182 | + | o.setGhostText(entryText, suffix) | |
| 1207 | 1183 | } | |
| 1208 | 1184 | ||
| 1209 | 1185 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | |
| @@ -1300,21 +1276,13 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit | |||
| 1300 | 1276 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | |
| 1301 | 1277 | } | |
| 1302 | 1278 | ||
| 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 | 1279 | // performSearch executes the search based on current view mode and query. | |
| 1312 | 1280 | func (o *Omnibox) performSearch() { | |
| 1313 | 1281 | o.mu.RLock() | |
| 1314 | 1282 | visible := o.visible | |
| 1315 | 1283 | mode := o.viewMode | |
| 1316 | 1284 | realInput := o.realInput | |
| 1317 | - | hasGhost := o.hasGhostText | |
| 1285 | + | hasGhost := o.ghostSuffix != "" | |
| 1318 | 1286 | o.mu.RUnlock() | |
| 1319 | 1287 | ||
| 1320 | 1288 | // Skip search if omnibox is hidden | |
| @@ -1876,7 +1844,7 @@ func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) { | |||
| 1876 | 1844 | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | |
| 1877 | 1845 | o.mu.RLock() | |
| 1878 | 1846 | realInput := o.realInput | |
| 1879 | - | hasGhostText := o.hasGhostText | |
| 1847 | + | hasGhostText := o.ghostSuffix != "" | |
| 1880 | 1848 | hasNavigated := o.hasNavigated | |
| 1881 | 1849 | o.mu.RUnlock() | |
| 1882 | 1850 | ||
| @@ -2341,10 +2309,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2341 | 2309 | // Initialize ghost text state | |
| 2342 | 2310 | o.realInput = query | |
| 2343 | 2311 | o.ghostSuffix = "" | |
| 2344 | - | o.ghostFullText = "" | |
| 2345 | - | o.hasGhostText = false | |
| 2346 | - | o.isAcceptingGhost = false | |
| 2347 | - | o.suppressGhost = false | |
| 2312 | + | ||
| 2313 | + | o.insertCompletion = false | |
| 2348 | 2314 | o.mu.Unlock() | |
| 2349 | 2315 | ||
| 2350 | 2316 | // Set initial query | |
| @@ -2413,10 +2379,10 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 2413 | 2379 | o.entry.SetText("") | |
| 2414 | 2380 | o.listBox.RemoveAll() | |
| 2415 | 2381 | ||
| 2416 | - | // Reset realInput | |
| 2382 | + | // Reset state | |
| 2417 | 2383 | o.mu.Lock() | |
| 2418 | 2384 | o.realInput = "" | |
| 2419 | - | o.suppressGhost = false | |
| 2385 | + | o.insertCompletion = false | |
| 2420 | 2386 | o.mu.Unlock() | |
| 2421 | 2387 | o.resetSearchSessionState() | |
| 2422 | 2388 | ||
| @@ -95,11 +95,9 @@ 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 | 99 | + | insertCompletion bool // True on character insert, false on delete — gates ghost completion |
| 100 | - | hasGhostText bool // Quick check for Tab behavior | 100 | + | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText |
| 101 | - | isAcceptingGhost bool // Prevent re-trigger during acceptance | |||
| 102 | - | suppressGhost bool // Temporarily disable ghost completion after deletion keys | |||
| 103 | 101 | ||||
| 104 | // Dependencies | 102 | // Dependencies | ||
| 105 | historyUC *usecase.SearchHistoryUseCase | 103 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -123,7 +121,6 @@ type Omnibox struct { | |||||
| 123 | debounceMu sync.Mutex | 121 | debounceMu sync.Mutex | ||
| 124 | lastQuery string // Prevent duplicate searches | 122 | lastQuery string // Prevent duplicate searches | ||
| 125 | searchToken uint64 | 123 | searchToken uint64 | ||
| 126 | - | ghostToken uint64 | |||
| 127 | 124 | ||||
| 128 | // Scaling | 125 | // Scaling | ||
| 129 | uiScale float64 | 126 | uiScale float64 | ||
| @@ -638,7 +635,6 @@ func (o *Omnibox) assembleWidgets() { | |||||
| 638 | 635 | ||||
| 639 | // setupEntryChanged wires entry text changes to debounced search. | 636 | // setupEntryChanged wires entry text changes to debounced search. | ||
| 640 | func (o *Omnibox) setupEntryChanged() { | 637 | func (o *Omnibox) setupEntryChanged() { | ||
| 641 | - | // SearchEntry has built-in debouncing via search-changed signal | |||
| 642 | changedCb := func(_ gtk.SearchEntry) { | 638 | changedCb := func(_ gtk.SearchEntry) { | ||
| 643 | o.onEntryChanged() | 639 | o.onEntryChanged() | ||
| 644 | } | 640 | } | ||
| @@ -681,7 +677,7 @@ func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusO | |||||
| 681 | func (o *Omnibox) hasGhost() bool { | 677 | func (o *Omnibox) hasGhost() bool { | ||
| 682 | o.mu.RLock() | 678 | o.mu.RLock() | ||
| 683 | defer o.mu.RUnlock() | 679 | defer o.mu.RUnlock() | ||
| 684 | - | return o.hasGhostText | 680 | + | return o.ghostSuffix != "" |
| 685 | } | 681 | } | ||
| 686 | 682 | ||||
| 687 | // hasUserNavigated returns whether user has navigated with arrow keys. | 683 | // hasUserNavigated returns whether user has navigated with arrow keys. | ||
| @@ -694,7 +690,7 @@ func (o *Omnibox) hasUserNavigated() bool { | |||||
| 694 | // handleKeyPress processes keyboard events. | 690 | // handleKeyPress processes keyboard events. | ||
| 695 | // Returns true if the event was handled. | 691 | // Returns true if the event was handled. | ||
| 696 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 692 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 697 | - | o.updateGhostSuppressionFromKey(keyval) | 693 | + | o.updateInsertCompletionFromKey(keyval) |
| 698 | 694 | ||||
| 699 | ctrl := state&gdk.ControlMaskValue != 0 | 695 | ctrl := state&gdk.ControlMaskValue != 0 | ||
| 700 | 696 | ||||
| @@ -767,16 +763,6 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 767 | return false // Let entry handle the key | 763 | return false // Let entry handle the key | ||
| 768 | } | 764 | } | ||
| 769 | 765 | ||||
| 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 { | 766 | func isDeletionKey(keyval uint) bool { | ||
| 781 | switch keyval { | 767 | switch keyval { | ||
| 782 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | 768 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | ||
| @@ -786,6 +772,28 @@ func isDeletionKey(keyval uint) bool { | |||||
| 786 | } | 772 | } | ||
| 787 | } | 773 | } | ||
| 788 | 774 | ||||
| 775 | + | // updateInsertCompletionFromKey sets whether the next text change should trigger | |||
| 776 | + | // ghost completion. Character insertion enables it; deletion disables it. | |||
| 777 | + | func (o *Omnibox) updateInsertCompletionFromKey(keyval uint) { | |||
| 778 | + | o.mu.Lock() | |||
| 779 | + | o.insertCompletion = !isDeletionKey(keyval) | |||
| 780 | + | o.mu.Unlock() | |||
| 781 | + | } | |||
| 782 | + | ||||
| 783 | + | // withGhostGuard executes fn while the isSettingGhost flag is set, | |||
| 784 | + | // so that onEntryChanged skips during programmatic text changes. | |||
| 785 | + | func (o *Omnibox) withGhostGuard(fn func()) { | |||
| 786 | + | o.mu.Lock() | |||
| 787 | + | o.isSettingGhost = true | |||
| 788 | + | o.mu.Unlock() | |||
| 789 | + | ||||
| 790 | + | fn() | |||
| 791 | + | ||||
| 792 | + | o.mu.Lock() | |||
| 793 | + | o.isSettingGhost = false | |||
| 794 | + | o.mu.Unlock() | |||
| 795 | + | } | |||
| 796 | + | ||||
| 789 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | 797 | // handleCtrlNumberShortcut handles Ctrl+1-9 and Ctrl+0 for quick navigation. | ||
| 790 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | 798 | // Uses hardware keycode as fallback for non-QWERTY keyboards (AZERTY, QWERTZ, etc.). | ||
| 791 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | 799 | func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool { | ||
| @@ -823,42 +831,60 @@ func (o *Omnibox) handleCtrlNumberShortcut(keyval, keycode uint, ctrl bool) bool | |||||
| 823 | } | 831 | } | ||
| 824 | 832 | ||||
| 825 | // onEntryChanged handles text input changes with debouncing. | 833 | // onEntryChanged handles text input changes with debouncing. | ||
| 834 | + | // When this fires, GTK has already processed the keystroke — if ghost text was | |||
| 835 | + | // selected and the user typed, the selection was replaced naturally by GTK. | |||
| 836 | + | // We must NOT call clearGhostText() here (that would delete real user text). | |||
| 826 | func (o *Omnibox) onEntryChanged() { | 837 | func (o *Omnibox) onEntryChanged() { | ||
| 827 | - | // Skip if we're programmatically accepting ghost text | |||
| 828 | o.mu.RLock() | 838 | o.mu.RLock() | ||
| 829 | - | isAccepting := o.isAcceptingGhost | 839 | + | isSettingGhost := o.isSettingGhost |
| 830 | o.mu.RUnlock() | 840 | o.mu.RUnlock() | ||
| 831 | - | if isAccepting { | 841 | + | if isSettingGhost { |
| 832 | return | 842 | return | ||
| 833 | } | 843 | } | ||
| 834 | 844 | ||||
| 835 | entryText := o.entry.GetText() | 845 | entryText := o.entry.GetText() | ||
| 836 | - | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | 846 | + | |
| 837 | - | o.entry.SetText(trimmed) | 847 | + | // Detect debounced echo from our own SetText in setGhostText. |
| 838 | - | o.entry.SetPosition(-1) | 848 | + | // search-changed is debounced by GtkSearchEntry, so it fires AFTER |
| 849 | + | // the isSettingGhost guard is already off. | |||
| 850 | + | o.mu.RLock() | |||
| 851 | + | if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | |||
| 852 | + | o.mu.RUnlock() | |||
| 839 | return | 853 | return | ||
| 840 | } | 854 | } | ||
| 855 | + | o.mu.RUnlock() | |||
| 841 | 856 | ||||
| 842 | - | if o.isGhostProgrammaticEcho(entryText) { | 857 | + | log := logging.FromContext(o.ctx) |
| 843 | - | return | 858 | + | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { |
| 859 | + | o.withGhostGuard(func() { | |||
| 860 | + | o.entry.SetText(trimmed) | |||
| 861 | + | o.entry.SetPosition(-1) | |||
| 862 | + | }) | |||
| 863 | + | entryText = trimmed | |||
| 844 | } | 864 | } | ||
| 845 | 865 | ||||
| 846 | - | // Reset navigation state when user types - space should type, not toggle favorite | 866 | + | // Reset ghost and selection state (the buffer is already correct — GTK handled it). |
| 847 | o.mu.Lock() | 867 | o.mu.Lock() | ||
| 848 | - | previousInput := o.realInput | 868 | + | oldGhost := o.ghostSuffix |
| 849 | o.hasNavigated = false | 869 | o.hasNavigated = false | ||
| 850 | - | // Update realInput with what user actually typed | 870 | + | o.selectedIndex = -1 |
| 871 | + | o.ghostSuffix = "" | |||
| 851 | o.realInput = entryText | 872 | o.realInput = entryText | ||
| 873 | + | shouldComplete := o.insertCompletion | |||
| 852 | o.mu.Unlock() | 874 | o.mu.Unlock() | ||
| 853 | 875 | ||||
| 854 | - | // Clear ghost text when user types (it will be recalculated after search) | 876 | + | log.Debug(). |
| 855 | - | o.clearGhostText() | 877 | + | Str("entryText", entryText). |
| 856 | - | // Recompute immediately from currently visible rows to avoid transient gaps | 878 | + | Str("oldGhost", oldGhost). |
| 857 | - | // where suggestions are visible but ghost text is missing until async search returns. | 879 | + | Bool("insertCompletion", shouldComplete). |
| 858 | - | if shouldUpdateGhostImmediately(previousInput, entryText) { | 880 | + | Msg("ghost: onEntryChanged") |
| 881 | + | ||||
| 882 | + | // Compute ghost completion synchronously if this was a character insertion. | |||
| 883 | + | if shouldComplete { | |||
| 859 | o.updateGhostFromSelectionWithInput(entryText) | 884 | o.updateGhostFromSelectionWithInput(entryText) | ||
| 860 | } | 885 | } | ||
| 861 | 886 | ||||
| 887 | + | // Schedule async search for suggestion list updates. | |||
| 862 | o.debounceMu.Lock() | 888 | o.debounceMu.Lock() | ||
| 863 | if o.debounceTimer != nil { | 889 | if o.debounceTimer != nil { | ||
| 864 | o.debounceTimer.Stop() | 890 | o.debounceTimer.Stop() | ||
| @@ -869,131 +895,93 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 869 | o.debounceMu.Unlock() | 895 | o.debounceMu.Unlock() | ||
| 870 | } | 896 | } | ||
| 871 | 897 | ||||
| 872 | - | func shouldUpdateGhostImmediately(previousInput, entryText string) bool { | 898 | + | // setGhostText applies inline completion by setting the full text (input + suffix) |
| 873 | - | return utf8.RuneCountInString(entryText) >= utf8.RuneCountInString(previousInput) | 899 | + | // and selecting the suffix portion. Uses guard flag to prevent re-entrant onEntryChanged. |
| 874 | - | } | 900 | + | // Skips if the user has typed since this ghost was computed (stale input check). |
| 901 | + | func (o *Omnibox) setGhostText(originalInput, suffix string) { | |||
| 902 | + | log := logging.FromContext(o.ctx) | |||
| 903 | + | if o.entry == nil || suffix == "" { | |||
| 904 | + | o.clearGhostText() | |||
| 905 | + | return | |||
| 906 | + | } | |||
| 875 | 907 | ||||
| 876 | - | func (o *Omnibox) isGhostProgrammaticEcho(entryText string) bool { | 908 | + | // Verify the buffer hasn't been modified by user typing since this ghost |
| 909 | + | // was computed. We check the actual buffer — not realInput, which may be | |||
| 910 | + | // stale due to debounced search-changed signal. | |||
| 911 | + | currentBuffer := o.entry.GetText() | |||
| 877 | o.mu.RLock() | 912 | o.mu.RLock() | ||
| 878 | - | hasGhost := o.hasGhostText | 913 | + | existingGhost := o.ghostSuffix |
| 879 | - | realInput := o.realInput | |||
| 880 | - | ghostFullText := o.ghostFullText | |||
| 881 | o.mu.RUnlock() | 914 | o.mu.RUnlock() | ||
| 882 | - | 915 | + | expectedWithGhost := originalInput + existingGhost | |
| 883 | - | if !hasGhost || ghostFullText == "" || entryText != ghostFullText { | 916 | + | if currentBuffer != originalInput && currentBuffer != expectedWithGhost { |
| 884 | - | return false | 917 | + | log.Debug(). |
| 885 | - | } | 918 | + | Str("originalInput", originalInput). |
| 886 | - | 919 | + | Str("currentBuffer", currentBuffer). | |
| 887 | - | selectionStart := 0 | 920 | + | Str("suffix", suffix). |
| 888 | - | selectionEnd := 0 | 921 | + | Msg("ghost: setGhostText skipped (buffer modified by user)") |
| 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 | 922 | return | ||
| 911 | } | 923 | } | ||
| 912 | 924 | ||||
| 913 | displayText := originalInput + suffix | 925 | displayText := originalInput + suffix | ||
| 914 | - | if fullText != "" { | 926 | + | cursorPos := utf8.RuneCountInString(originalInput) |
| 915 | - | displayText = fullText | 927 | + | endPos := utf8.RuneCountInString(displayText) |
| 916 | - | } | 928 | + | |
| 917 | - | selectionStart := utf8.RuneCountInString(originalInput) | 929 | + | log.Debug(). |
| 918 | - | selectionEnd := utf8.RuneCountInString(displayText) | 930 | + | Str("input", originalInput). |
| 931 | + | Str("suffix", suffix). | |||
| 932 | + | Int("cursorPos", cursorPos). | |||
| 933 | + | Int("endPos", endPos). | |||
| 934 | + | Msg("ghost: setGhostText") | |||
| 919 | 935 | ||||
| 920 | o.mu.Lock() | 936 | o.mu.Lock() | ||
| 921 | o.realInput = originalInput | 937 | o.realInput = originalInput | ||
| 922 | o.ghostSuffix = suffix | 938 | o.ghostSuffix = suffix | ||
| 923 | - | o.ghostFullText = fullText | |||
| 924 | - | o.hasGhostText = true | |||
| 925 | - | capturedToken := o.ghostToken | |||
| 926 | - | o.mu.Unlock() | |||
| 927 | - | ||||
| 928 | - | o.idleCoalescer.Post("omnibox-ghost-set", func() { | |||
| 929 | - | if o.entry == nil { | |||
| 930 | - | return | |||
| 931 | - | } | |||
| 932 | 939 | ||||
| 933 | - | o.mu.RLock() | 940 | + | o.mu.Unlock() |
| 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 | 941 | ||||
| 942 | + | o.withGhostGuard(func() { | |||
| 947 | o.entry.SetText(displayText) | 943 | o.entry.SetText(displayText) | ||
| 948 | - | o.entry.SetPosition(selectionStart) | 944 | + | o.entry.SetPosition(cursorPos) |
| 949 | - | o.entry.SelectRegion(selectionStart, selectionEnd) | 945 | + | o.entry.SelectRegion(cursorPos, endPos) |
| 950 | - | ||||
| 951 | - | o.mu.Lock() | |||
| 952 | - | o.isAcceptingGhost = false | |||
| 953 | - | o.mu.Unlock() | |||
| 954 | }) | 946 | }) | ||
| 955 | } | 947 | } | ||
| 956 | 948 | ||||
| 957 | - | // clearGhostText hides the ghost completion text. | 949 | + | // clearGhostText removes any ghost suffix from the entry buffer. |
| 950 | + | // Uses guard flag to prevent re-entrant onEntryChanged. | |||
| 958 | func (o *Omnibox) clearGhostText() { | 951 | func (o *Omnibox) clearGhostText() { | ||
| 952 | + | log := logging.FromContext(o.ctx) | |||
| 959 | o.mu.Lock() | 953 | o.mu.Lock() | ||
| 954 | + | hadGhost := o.ghostSuffix != "" | |||
| 955 | + | ghostSuffix := o.ghostSuffix | |||
| 960 | realInput := o.realInput | 956 | 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 = "" | 957 | o.ghostSuffix = "" | ||
| 966 | - | o.ghostFullText = "" | 958 | + | |
| 967 | - | o.hasGhostText = false | |||
| 968 | o.mu.Unlock() | 959 | o.mu.Unlock() | ||
| 969 | 960 | ||||
| 970 | - | if !hadGhost || o.entry == nil { | 961 | + | if !hadGhost || ghostSuffix == "" || o.entry == nil { |
| 971 | return | 962 | return | ||
| 972 | } | 963 | } | ||
| 973 | 964 | ||||
| 974 | - | o.idleCoalescer.Post("omnibox-ghost-clear", func() { | 965 | + | // Only touch the buffer if it still shows the expected ghost text. |
| 975 | - | if o.entry == nil { | 966 | + | expectedText := realInput + ghostSuffix |
| 976 | - | return | 967 | + | currentText := o.entry.GetText() |
| 977 | - | } | 968 | + | if currentText != expectedText { |
| 978 | - | o.mu.RLock() | 969 | + | log.Debug(). |
| 979 | - | currentToken := o.ghostToken | 970 | + | Str("expected", expectedText). |
| 980 | - | o.mu.RUnlock() | 971 | + | Str("current", currentText). |
| 981 | - | if capturedToken != currentToken { | 972 | + | Msg("ghost: clearGhostText skipped (buffer changed)") |
| 982 | - | return | 973 | + | return |
| 983 | - | } | 974 | + | } |
| 984 | - | o.mu.Lock() | 975 | + | |
| 985 | - | o.isAcceptingGhost = true | 976 | + | log.Debug(). |
| 986 | - | o.mu.Unlock() | 977 | + | Str("realInput", realInput). |
| 987 | - | // Only revert text when the entry still shows the previous ghost completion. | 978 | + | Str("ghostSuffix", ghostSuffix). |
| 988 | - | // If the user typed since scheduling this idle callback, never overwrite it. | 979 | + | Msg("ghost: clearGhostText") |
| 989 | - | if o.entry.GetText() == ghostFullText && o.entry.GetText() != realInput { | 980 | + | |
| 990 | - | o.entry.SetText(realInput) | 981 | + | o.withGhostGuard(func() { |
| 991 | - | } | 982 | + | o.entry.SetText(realInput) |
| 992 | o.entry.SelectRegion(-1, -1) | 983 | o.entry.SelectRegion(-1, -1) | ||
| 993 | o.entry.SetPosition(-1) | 984 | o.entry.SetPosition(-1) | ||
| 994 | - | o.mu.Lock() | |||
| 995 | - | o.isAcceptingGhost = false | |||
| 996 | - | o.mu.Unlock() | |||
| 997 | }) | 985 | }) | ||
| 998 | } | 986 | } | ||
| 999 | 987 | ||||
| @@ -1009,45 +997,40 @@ func (o *Omnibox) clearGhostTextIfInput(expectedInput string) { | |||||
| 1009 | o.clearGhostText() | 997 | o.clearGhostText() | ||
| 1010 | } | 998 | } | ||
| 1011 | 999 | ||||
| 1012 | - | // acceptGhostCompletion accepts the ghost text and fills the input. | 1000 | + | // acceptGhostCompletion accepts the ghost text — the suffix is already in the |
| 1001 | + | // buffer, so we just deselect and update realInput to include it. | |||
| 1013 | func (o *Omnibox) acceptGhostCompletion() { | 1002 | func (o *Omnibox) acceptGhostCompletion() { | ||
| 1003 | + | log := logging.FromContext(o.ctx) | |||
| 1014 | o.mu.Lock() | 1004 | o.mu.Lock() | ||
| 1015 | - | fullText, accepted := acceptedGhostInput(o.hasGhostText, o.ghostFullText) | 1005 | + | if o.ghostSuffix == "" { |
| 1016 | - | if !accepted { | |||
| 1017 | o.mu.Unlock() | 1006 | o.mu.Unlock() | ||
| 1007 | + | log.Debug().Msg("ghost: acceptGhostCompletion — no ghost to accept") | |||
| 1018 | return | 1008 | return | ||
| 1019 | } | 1009 | } | ||
| 1020 | - | o.isAcceptingGhost = true | 1010 | + | suffix := o.ghostSuffix |
| 1021 | - | o.realInput = fullText | 1011 | + | |
| 1022 | - | o.selectedIndex = -1 | |||
| 1023 | - | o.hasNavigated = false | |||
| 1024 | - | o.ghostToken++ | |||
| 1025 | o.ghostSuffix = "" | 1012 | o.ghostSuffix = "" | ||
| 1026 | - | o.ghostFullText = "" | |||
| 1027 | - | o.hasGhostText = false | |||
| 1028 | o.mu.Unlock() | 1013 | o.mu.Unlock() | ||
| 1014 | + | log.Debug().Str("suffix", suffix).Msg("ghost: acceptGhostCompletion") | |||
| 1029 | 1015 | ||||
| 1030 | if o.entry != nil { | 1016 | if o.entry != nil { | ||
| 1031 | - | if o.entry.GetText() != fullText { | 1017 | + | // The suffix is already in the buffer as selected text. |
| 1032 | - | o.entry.SetText(fullText) | 1018 | + | // Just deselect and move cursor to end. |
| 1033 | - | } | 1019 | + | fullText := o.entry.GetText() |
| 1034 | - | o.entry.SelectRegion(-1, -1) | 1020 | + | o.mu.Lock() |
| 1035 | - | o.entry.SetPosition(-1) | 1021 | + | o.realInput = fullText |
| 1022 | + | o.selectedIndex = -1 | |||
| 1023 | + | o.hasNavigated = false | |||
| 1024 | + | o.mu.Unlock() | |||
| 1025 | + | ||||
| 1026 | + | o.withGhostGuard(func() { | |||
| 1027 | + | o.entry.SelectRegion(-1, -1) | |||
| 1028 | + | o.entry.SetPosition(-1) | |||
| 1029 | + | }) | |||
| 1036 | } | 1030 | } | ||
| 1037 | if o.listBox != nil { | 1031 | if o.listBox != nil { | ||
| 1038 | o.listBox.SelectRow(nil) | 1032 | o.listBox.SelectRow(nil) | ||
| 1039 | } | 1033 | } | ||
| 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 | } | 1034 | } | ||
| 1052 | 1035 | ||||
| 1053 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | 1036 | // updateGhostFromURL updates ghost text based on a specific URL (from row selection). | ||
| @@ -1064,13 +1047,13 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||||
| 1064 | 1047 | ||||
| 1065 | // Only show full URL as ghost text when input is empty (initial state) | 1048 | // Only show full URL as ghost text when input is empty (initial state) | ||
| 1066 | if userInput == "" { | 1049 | if userInput == "" { | ||
| 1067 | - | o.setGhostText("", displayURL, displayURL) | 1050 | + | o.setGhostText("", displayURL) |
| 1068 | return true | 1051 | return true | ||
| 1069 | } | 1052 | } | ||
| 1070 | 1053 | ||||
| 1071 | // Try to compute proper suffix if input is a prefix of the URL | 1054 | // Try to compute proper suffix if input is a prefix of the URL | ||
| 1072 | - | if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { | 1055 | + | if suffix, _, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok { |
| 1073 | - | o.setGhostText(userInput, suffix, matchedURL) | 1056 | + | o.setGhostText(userInput, suffix) |
| 1074 | return true | 1057 | return true | ||
| 1075 | } | 1058 | } | ||
| 1076 | 1059 | ||||
| @@ -1081,7 +1064,7 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||||
| 1081 | func (o *Omnibox) updateGhostFromSelection() { | 1064 | func (o *Omnibox) updateGhostFromSelection() { | ||
| 1082 | o.mu.RLock() | 1065 | o.mu.RLock() | ||
| 1083 | realInput := o.realInput | 1066 | realInput := o.realInput | ||
| 1084 | - | hasGhost := o.hasGhostText | 1067 | + | hasGhost := o.ghostSuffix != "" |
| 1085 | o.mu.RUnlock() | 1068 | o.mu.RUnlock() | ||
| 1086 | 1069 | ||||
| 1087 | entryText := "" | 1070 | entryText := "" | ||
| @@ -1099,44 +1082,39 @@ func (o *Omnibox) restoreEntryToRealInput() { | |||||
| 1099 | return | 1082 | return | ||
| 1100 | } | 1083 | } | ||
| 1101 | 1084 | ||||
| 1085 | + | // Clear ghost text first (removes suffix from buffer with guard). | |||
| 1086 | + | o.clearGhostText() | |||
| 1087 | + | ||||
| 1088 | + | // If the buffer still doesn't match realInput, force-set it. | |||
| 1102 | o.mu.RLock() | 1089 | o.mu.RLock() | ||
| 1103 | realInput := o.realInput | 1090 | realInput := o.realInput | ||
| 1104 | - | hadGhost := o.hasGhostText || o.ghostSuffix != "" || o.ghostFullText != "" | |||
| 1105 | o.mu.RUnlock() | 1091 | o.mu.RUnlock() | ||
| 1106 | 1092 | ||||
| 1107 | - | o.mu.Lock() | 1093 | + | if o.entry.GetText() != realInput { |
| 1108 | - | o.isAcceptingGhost = true | 1094 | + | o.withGhostGuard(func() { |
| 1109 | - | o.ghostToken++ | 1095 | + | o.entry.SetText(realInput) |
| 1110 | - | o.ghostSuffix = "" | 1096 | + | }) |
| 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 | } | 1097 | } | ||
| 1118 | o.entry.SelectRegion(-1, -1) | 1098 | o.entry.SelectRegion(-1, -1) | ||
| 1119 | o.entry.SetPosition(-1) | 1099 | o.entry.SetPosition(-1) | ||
| 1120 | - | ||||
| 1121 | - | o.mu.Lock() | |||
| 1122 | - | o.isAcceptingGhost = false | |||
| 1123 | - | o.mu.Unlock() | |||
| 1124 | } | 1100 | } | ||
| 1125 | 1101 | ||||
| 1126 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | 1102 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | ||
| 1127 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | 1103 | func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | ||
| 1104 | + | log := logging.FromContext(o.ctx) | |||
| 1128 | o.mu.RLock() | 1105 | o.mu.RLock() | ||
| 1129 | idx := o.selectedIndex | 1106 | idx := o.selectedIndex | ||
| 1130 | mode := o.viewMode | 1107 | mode := o.viewMode | ||
| 1131 | bangMode := o.bangMode | 1108 | bangMode := o.bangMode | ||
| 1132 | hasNavigated := o.hasNavigated | 1109 | hasNavigated := o.hasNavigated | ||
| 1133 | - | suppressGhost := o.suppressGhost | 1110 | + | shouldComplete := o.insertCompletion |
| 1134 | suggestions := o.suggestions | 1111 | suggestions := o.suggestions | ||
| 1135 | favorites := o.favorites | 1112 | favorites := o.favorites | ||
| 1136 | o.mu.RUnlock() | 1113 | o.mu.RUnlock() | ||
| 1137 | 1114 | ||||
| 1138 | - | if suppressGhost { | 1115 | + | // No ghost text after deletion (backspace/delete key). |
| 1139 | - | o.clearGhostTextIfInput(entryText) | 1116 | + | if !shouldComplete { |
| 1117 | + | log.Debug().Str("entryText", entryText).Msg("ghost: updateGhost skipped (deletion key)") | |||
| 1140 | return | 1118 | return | ||
| 1141 | } | 1119 | } | ||
| 1142 | 1120 | ||||
| @@ -1170,6 +1148,8 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) { | |||||
| 1170 | o.clearGhostTextIfInput(entryText) | 1148 | o.clearGhostTextIfInput(entryText) | ||
| 1171 | } | 1149 | } | ||
| 1172 | 1150 | ||||
| 1151 | + | // resolveGhostCompletion computes and applies ghost text synchronously. | |||
| 1152 | + | // All computation is pure string matching — no I/O, no goroutines needed. | |||
| 1173 | func (o *Omnibox) resolveGhostCompletion( | 1153 | func (o *Omnibox) resolveGhostCompletion( | ||
| 1174 | entryText, selectedURL string, | 1154 | entryText, selectedURL string, | ||
| 1175 | hasExplicitSelection bool, | 1155 | hasExplicitSelection bool, | ||
| @@ -1177,33 +1157,29 @@ func (o *Omnibox) resolveGhostCompletion( | |||||
| 1177 | suggestions []Suggestion, | 1157 | suggestions []Suggestion, | ||
| 1178 | favorites []Favorite, | 1158 | favorites []Favorite, | ||
| 1179 | ) { | 1159 | ) { | ||
| 1180 | - | leadingWhitespace, completionInput, ok := ghostCompletionInput(entryText) | 1160 | + | log := logging.FromContext(o.ctx) |
| 1161 | + | _, completionInput, ok := ghostCompletionInput(entryText) | |||
| 1181 | if !ok { | 1162 | if !ok { | ||
| 1182 | - | o.clearGhostTextIfInput(entryText) | 1163 | + | log.Debug().Str("entryText", entryText).Msg("ghost: resolveGhost — no completion input") |
| 1183 | return | 1164 | return | ||
| 1184 | } | 1165 | } | ||
| 1185 | 1166 | ||||
| 1186 | - | o.mu.Lock() | 1167 | + | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) |
| 1187 | - | o.ghostToken++ | 1168 | + | if !found { |
| 1188 | - | ghostToken := o.ghostToken | 1169 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — no match found") |
| 1189 | - | o.mu.Unlock() | 1170 | + | return |
| 1190 | - | 1171 | + | } | |
| 1191 | - | go func() { | 1172 | + | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) |
| 1192 | - | fullText, suffix, found := visibleGhostSuggestion(completionInput, selectedURL, hasExplicitSelection, mode, suggestions, favorites) | 1173 | + | if fullText == "" || suffix == "" { |
| 1193 | - | if !o.isGhostTokenCurrent(ghostToken, entryText) { | 1174 | + | log.Debug().Str("input", completionInput).Msg("ghost: resolveGhost — empty after normalize") |
| 1194 | - | return | 1175 | + | return |
| 1195 | - | } | 1176 | + | } |
| 1196 | - | if !found { | 1177 | + | log.Debug(). |
| 1197 | - | o.clearGhostTextIfInput(entryText) | 1178 | + | Str("input", completionInput). |
| 1198 | - | return | 1179 | + | Str("fullText", fullText). |
| 1199 | - | } | 1180 | + | Str("suffix", suffix). |
| 1200 | - | fullText, suffix = normalizeGhostSuggestion(completionInput, fullText, suffix) | 1181 | + | Msg("ghost: resolveGhost — applying") |
| 1201 | - | if fullText == "" || suffix == "" { | 1182 | + | o.setGhostText(entryText, suffix) |
| 1202 | - | o.clearGhostTextIfInput(entryText) | |||
| 1203 | - | return | |||
| 1204 | - | } | |||
| 1205 | - | o.setGhostText(entryText, suffix, leadingWhitespace+fullText) | |||
| 1206 | - | }() | |||
| 1207 | } | 1183 | } | ||
| 1208 | 1184 | ||||
| 1209 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | 1185 | func ghostCompletionInput(entryText string) (leadingWhitespace, completionInput string, ok bool) { | ||
| @@ -1300,21 +1276,13 @@ func selectedTargetURL(mode ViewMode, idx int, suggestions []Suggestion, favorit | |||||
| 1300 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | 1276 | return resolveTargetURLForSelection(mode, idx, suggestions, favorites), true | ||
| 1301 | } | 1277 | } | ||
| 1302 | 1278 | ||||
| 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. | 1279 | // performSearch executes the search based on current view mode and query. | ||
| 1312 | func (o *Omnibox) performSearch() { | 1280 | func (o *Omnibox) performSearch() { | ||
| 1313 | o.mu.RLock() | 1281 | o.mu.RLock() | ||
| 1314 | visible := o.visible | 1282 | visible := o.visible | ||
| 1315 | mode := o.viewMode | 1283 | mode := o.viewMode | ||
| 1316 | realInput := o.realInput | 1284 | realInput := o.realInput | ||
| 1317 | - | hasGhost := o.hasGhostText | 1285 | + | hasGhost := o.ghostSuffix != "" |
| 1318 | o.mu.RUnlock() | 1286 | o.mu.RUnlock() | ||
| 1319 | 1287 | ||||
| 1320 | // Skip search if omnibox is hidden | 1288 | // Skip search if omnibox is hidden | ||
| @@ -1876,7 +1844,7 @@ func (o *Omnibox) attachRowHoverSelection(row *gtk.ListBoxRow, index int) { | |||||
| 1876 | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | 1844 | enterCb := func(_ gtk.EventControllerMotion, _ float64, _ float64) { | ||
| 1877 | o.mu.RLock() | 1845 | o.mu.RLock() | ||
| 1878 | realInput := o.realInput | 1846 | realInput := o.realInput | ||
| 1879 | - | hasGhostText := o.hasGhostText | 1847 | + | hasGhostText := o.ghostSuffix != "" |
| 1880 | hasNavigated := o.hasNavigated | 1848 | hasNavigated := o.hasNavigated | ||
| 1881 | o.mu.RUnlock() | 1849 | o.mu.RUnlock() | ||
| 1882 | 1850 | ||||
| @@ -2341,10 +2309,8 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2341 | // Initialize ghost text state | 2309 | // Initialize ghost text state | ||
| 2342 | o.realInput = query | 2310 | o.realInput = query | ||
| 2343 | o.ghostSuffix = "" | 2311 | o.ghostSuffix = "" | ||
| 2344 | - | o.ghostFullText = "" | 2312 | + | |
| 2345 | - | o.hasGhostText = false | 2313 | + | o.insertCompletion = false |
| 2346 | - | o.isAcceptingGhost = false | |||
| 2347 | - | o.suppressGhost = false | |||
| 2348 | o.mu.Unlock() | 2314 | o.mu.Unlock() | ||
| 2349 | 2315 | ||||
| 2350 | // Set initial query | 2316 | // Set initial query | ||
| @@ -2413,10 +2379,10 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 2413 | o.entry.SetText("") | 2379 | o.entry.SetText("") | ||
| 2414 | o.listBox.RemoveAll() | 2380 | o.listBox.RemoveAll() | ||
| 2415 | 2381 | ||||
| 2416 | - | // Reset realInput | 2382 | + | // Reset state |
| 2417 | o.mu.Lock() | 2383 | o.mu.Lock() | ||
| 2418 | o.realInput = "" | 2384 | o.realInput = "" | ||
| 2419 | - | o.suppressGhost = false | 2385 | + | o.insertCompletion = false |
| 2420 | o.mu.Unlock() | 2386 | o.mu.Unlock() | ||
| 2421 | o.resetSearchSessionState() | 2387 | o.resetSearchSessionState() | ||
| 2422 | 2388 | ||||
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 +21 -1
| @@ -13,6 +13,8 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | const maxPermissionLogURLLen = 96 | |
| 17 | + | ||
| 16 | 18 | type permissionPopup interface { | |
| 17 | 19 | Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) | |
| 18 | 20 | } | |
| @@ -84,6 +86,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 84 | 86 | ctx := req.ctx | |
| 85 | 87 | origin := req.origin | |
| 86 | 88 | permTypes := req.permTypes | |
| 89 | + | metadata := req.metadata | |
| 87 | 90 | callback := req.callback | |
| 88 | 91 | ||
| 89 | 92 | log := logging.FromContext(ctx) | |
| @@ -97,9 +100,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||
| 97 | 100 | ||
| 98 | 101 | // Build dialog text | |
| 99 | 102 | heading := d.buildHeading(permTypes) | |
| 100 | - | body := d.buildBody(origin, permTypes, req.metadata) | |
| 103 | + | body := d.buildBody(origin, permTypes, metadata) | |
| 104 | + | ||
| 105 | + | if parsePermFlags(permTypes).dataAccess { | |
| 106 | + | log.Info(). | |
| 107 | + | Str("origin", origin). | |
| 108 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |
| 109 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |
| 110 | + | Msg("showing website data access permission dialog") | |
| 111 | + | } | |
| 101 | 112 | ||
| 102 | 113 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | |
| 114 | + | if parsePermFlags(permTypes).dataAccess { | |
| 115 | + | log.Info(). | |
| 116 | + | Str("origin", origin). | |
| 117 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |
| 118 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |
| 119 | + | Bool("allowed", allowed). | |
| 120 | + | Bool("persistent", persistent). | |
| 121 | + | Msg("website data access permission dialog response") | |
| 122 | + | } | |
| 103 | 123 | log.Debug(). | |
| 104 | 124 | Bool("allowed", allowed). | |
| 105 | 125 | Bool("persistent", persistent). | |
| @@ -13,6 +13,8 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/ui/component" | 13 | "github.com/bnema/dumber/internal/ui/component" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | const maxPermissionLogURLLen = 96 | |||
| 17 | + | ||||
| 16 | type permissionPopup interface { | 18 | type permissionPopup interface { | ||
| 17 | Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) | 19 | Show(ctx context.Context, heading, body string, callback func(allowed, persistent bool)) | ||
| 18 | } | 20 | } | ||
| @@ -84,6 +86,7 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||||
| 84 | ctx := req.ctx | 86 | ctx := req.ctx | ||
| 85 | origin := req.origin | 87 | origin := req.origin | ||
| 86 | permTypes := req.permTypes | 88 | permTypes := req.permTypes | ||
| 89 | + | metadata := req.metadata | |||
| 87 | callback := req.callback | 90 | callback := req.callback | ||
| 88 | 91 | ||||
| 89 | log := logging.FromContext(ctx) | 92 | log := logging.FromContext(ctx) | ||
| @@ -97,9 +100,26 @@ func (d *PermissionDialog) showRequest(req permissionDialogRequest) { | |||||
| 97 | 100 | ||||
| 98 | // Build dialog text | 101 | // Build dialog text | ||
| 99 | heading := d.buildHeading(permTypes) | 102 | heading := d.buildHeading(permTypes) | ||
| 100 | - | body := d.buildBody(origin, permTypes, req.metadata) | 103 | + | body := d.buildBody(origin, permTypes, metadata) |
| 104 | + | ||||
| 105 | + | if parsePermFlags(permTypes).dataAccess { | |||
| 106 | + | log.Info(). | |||
| 107 | + | Str("origin", origin). | |||
| 108 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |||
| 109 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |||
| 110 | + | Msg("showing website data access permission dialog") | |||
| 111 | + | } | |||
| 101 | 112 | ||||
| 102 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | 113 | d.popup.Show(ctx, heading, body, func(allowed, persistent bool) { | ||
| 114 | + | if parsePermFlags(permTypes).dataAccess { | |||
| 115 | + | log.Info(). | |||
| 116 | + | Str("origin", origin). | |||
| 117 | + | Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], maxPermissionLogURLLen)). | |||
| 118 | + | Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], maxPermissionLogURLLen)). | |||
| 119 | + | Bool("allowed", allowed). | |||
| 120 | + | Bool("persistent", persistent). | |||
| 121 | + | Msg("website data access permission dialog response") | |||
| 122 | + | } | |||
| 103 | log.Debug(). | 123 | log.Debug(). | ||
| 104 | Bool("allowed", allowed). | 124 | Bool("allowed", allowed). | ||
| 105 | Bool("persistent", persistent). | 125 | Bool("persistent", persistent). | ||