fix(omnibox): preserve submit during async paste (#430)
* fix(omnibox): preserve submit during async paste * fix(omnibox): honor the deferred submit when a paste keeps the same text pasteSubmissionState.textChanged inferred paste completion by comparing the entry text with the text captured at the paste shortcut. GTK emits the change notification for an identical replacement too, so the comparison returned early, left the paste active, and kept a pending deferredEnter armed: the Enter pressed during the clipboard read was never replayed, and the stale state could later fire a submission on an unrelated edit. Completion is now decided by the entry change notification itself, never by the text value, and the deferred Enter is consumed exactly once. The ghost-text echo detection runs before the paste state update so our own SetText notifications cannot clear a paste that is still in flight. * fix(omnibox): replay the deferred enter when a paste keeps the ghost suffix The ghost echo early return in onEntryChanged ran before the paste state was updated, so a paste that replaced the selected ghost suffix with the same text looked exactly like a self-generated notification: the entry change was dropped, the deferred Enter was never replayed, and the omnibox stayed open. The paste state is now consumed first. A self-generated echo still returns early and keeps the ghost state untouched, but a pending deferred Enter takes priority, because the two notifications cannot be told apart from the text alone; the ghost state is then rebuilt from the pasted text and the submission is replayed. The GTK integration test drives the real entry through Ctrl+V with clipboard content equal to the displayed completion, defers Enter, then delivers the entry change and asserts a single navigateToSelected call.
- commit
- b7b10b9eab86ed09dd767c4ee97d33c0bc16e7d2
- author
- bnema · · 2026-09-12 09:15 UTC
- committer
- GitHub · · 2026-09-12 09:15 UTC
- parent
- 3cee55e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
2 files · +246 -5
M internal/ui/component/omnibox.go +104 -5
| @@ -52,6 +52,56 @@ type favoriteToggleResultUpdate struct { | |||
| 52 | 52 | IsFavorite bool | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | + | // pasteSubmissionState tracks a paste whose clipboard read is still in flight so | |
| 56 | + | // that an Enter pressed in the meantime is replayed once the text lands. | |
| 57 | + | type pasteSubmissionState struct { | |
| 58 | + | active bool | |
| 59 | + | initialText string | |
| 60 | + | deferredEnter bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // beginPaste arms the state with the entry text seen when the user pressed the | |
| 64 | + | // paste shortcut. | |
| 65 | + | func (s *pasteSubmissionState) beginPaste(text string) { | |
| 66 | + | s.active = true | |
| 67 | + | s.initialText = text | |
| 68 | + | s.deferredEnter = false | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // requestSubmit reports whether Enter submits now and whether the pasted text is | |
| 72 | + | // already in the entry. Enter pressed while the entry still holds the pre-paste | |
| 73 | + | // text is deferred instead. | |
| 74 | + | func (s *pasteSubmissionState) requestSubmit(text string) (submit, pastedTextReady bool) { | |
| 75 | + | if !s.active { | |
| 76 | + | return true, false | |
| 77 | + | } | |
| 78 | + | if text == s.initialText { | |
| 79 | + | s.deferredEnter = true | |
| 80 | + | return false, false | |
| 81 | + | } | |
| 82 | + | s.active = false | |
| 83 | + | return true, true | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // textChanged reports whether an Enter pressed during the paste must be replayed. | |
| 87 | + | // The first entry change after a paste shortcut completes the paste operation, | |
| 88 | + | // even when the pasted text matches what the entry already contained, so | |
| 89 | + | // completion is never inferred from the text itself. | |
| 90 | + | func (s *pasteSubmissionState) textChanged() bool { | |
| 91 | + | if !s.active { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | s.active = false | |
| 95 | + | deferred := s.deferredEnter | |
| 96 | + | s.deferredEnter = false | |
| 97 | + | return deferred | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // reset drops any paste state, discarding a deferred submission. | |
| 101 | + | func (s *pasteSubmissionState) reset() { | |
| 102 | + | *s = pasteSubmissionState{} | |
| 103 | + | } | |
| 104 | + | ||
| 55 | 105 | // ViewMode distinguishes history search from favorites display. | |
| 56 | 106 | type ViewMode string | |
| 57 | 107 | ||
| @@ -119,6 +169,7 @@ type Omnibox struct { | |||
| 119 | 169 | ghostSuffix string // Completion suffix currently displayed | |
| 120 | 170 | insertCompletion bool // True on character insert, false on delete — gates ghost completion | |
| 121 | 171 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | |
| 172 | + | pasteSubmit pasteSubmissionState | |
| 122 | 173 | ||
| 123 | 174 | // Dependencies | |
| 124 | 175 | historyUC *usecase.SearchHistoryUseCase | |
| @@ -648,6 +699,16 @@ func (o *Omnibox) setupKeyboardHandling() { | |||
| 648 | 699 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 649 | 700 | ||
| 650 | 701 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 702 | + | if isPasteShortcut(keyval, state) && o.entry != nil { | |
| 703 | + | entryText := o.entry.GetText() | |
| 704 | + | o.mu.Lock() | |
| 705 | + | o.pasteSubmit.beginPaste(entryText) | |
| 706 | + | o.mu.Unlock() | |
| 707 | + | } else if keyval != uint(gdk.KEY_Return) && keyval != uint(gdk.KEY_KP_Enter) { | |
| 708 | + | o.mu.Lock() | |
| 709 | + | o.pasteSubmit.reset() | |
| 710 | + | o.mu.Unlock() | |
| 711 | + | } | |
| 651 | 712 | if o.handleKeyPress(keyval, keycode, state) { | |
| 652 | 713 | return true | |
| 653 | 714 | } | |
| @@ -985,7 +1046,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 985 | 1046 | } | |
| 986 | 1047 | ||
| 987 | 1048 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | |
| 988 | - | o.navigateToSelected() | |
| 1049 | + | o.handleSubmitKeyPress() | |
| 989 | 1050 | return true | |
| 990 | 1051 | ||
| 991 | 1052 | case uint(gdk.KEY_Up): | |
| @@ -1036,6 +1097,30 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||
| 1036 | 1097 | return false // Let entry handle the key | |
| 1037 | 1098 | } | |
| 1038 | 1099 | ||
| 1100 | + | func (o *Omnibox) handleSubmitKeyPress() { | |
| 1101 | + | if o.entry == nil { | |
| 1102 | + | return | |
| 1103 | + | } | |
| 1104 | + | entryText := o.entry.GetText() | |
| 1105 | + | o.mu.Lock() | |
| 1106 | + | shouldSubmit, pastedTextReady := o.pasteSubmit.requestSubmit(entryText) | |
| 1107 | + | if pastedTextReady { | |
| 1108 | + | o.selectedIndex = -1 | |
| 1109 | + | o.hasNavigated = false | |
| 1110 | + | } | |
| 1111 | + | o.mu.Unlock() | |
| 1112 | + | if shouldSubmit { | |
| 1113 | + | o.navigateToSelected() | |
| 1114 | + | } | |
| 1115 | + | } | |
| 1116 | + | ||
| 1117 | + | func isPasteShortcut(keyval uint, state gdk.ModifierType) bool { | |
| 1118 | + | ctrl := state&gdk.ControlMaskValue != 0 | |
| 1119 | + | shift := state&gdk.ShiftMaskValue != 0 | |
| 1120 | + | return (ctrl && (keyval == uint(gdk.KEY_v) || keyval == uint(gdk.KEY_V))) || | |
| 1121 | + | (shift && keyval == uint(gdk.KEY_Insert)) | |
| 1122 | + | } | |
| 1123 | + | ||
| 1039 | 1124 | func isDeletionKey(keyval uint) bool { | |
| 1040 | 1125 | switch keyval { | |
| 1041 | 1126 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | |
| @@ -1117,15 +1202,22 @@ func (o *Omnibox) onEntryChanged() { | |||
| 1117 | 1202 | ||
| 1118 | 1203 | entryText := o.entry.GetText() | |
| 1119 | 1204 | ||
| 1205 | + | o.mu.Lock() | |
| 1206 | + | deferredSubmit := o.pasteSubmit.textChanged() | |
| 1207 | + | o.mu.Unlock() | |
| 1208 | + | ||
| 1120 | 1209 | // Detect debounced echo from our own SetText in setGhostText. | |
| 1121 | 1210 | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | |
| 1122 | - | // the isSettingGhost guard is already off. | |
| 1211 | + | // the isSettingGhost guard is already off. A self-inflicted notification | |
| 1212 | + | // must leave the ghost state untouched, but it cannot be told apart from a | |
| 1213 | + | // paste that replaced the selected ghost suffix with the same text, so a | |
| 1214 | + | // deferred Enter always wins and the ghost state is rebuilt from the paste. | |
| 1123 | 1215 | o.mu.RLock() | |
| 1124 | - | if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | |
| 1125 | - | o.mu.RUnlock() | |
| 1216 | + | ghostEcho := o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix | |
| 1217 | + | o.mu.RUnlock() | |
| 1218 | + | if ghostEcho && !deferredSubmit { | |
| 1126 | 1219 | return | |
| 1127 | 1220 | } | |
| 1128 | - | o.mu.RUnlock() | |
| 1129 | 1221 | ||
| 1130 | 1222 | log := logging.FromContext(o.ctx) | |
| 1131 | 1223 | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | |
| @@ -1146,6 +1238,11 @@ func (o *Omnibox) onEntryChanged() { | |||
| 1146 | 1238 | shouldComplete := o.insertCompletion | |
| 1147 | 1239 | o.mu.Unlock() | |
| 1148 | 1240 | ||
| 1241 | + | if deferredSubmit { | |
| 1242 | + | o.navigateToSelected() | |
| 1243 | + | return | |
| 1244 | + | } | |
| 1245 | + | ||
| 1149 | 1246 | log.Debug(). | |
| 1150 | 1247 | Str("entryText", entryText). | |
| 1151 | 1248 | Str("oldGhost", oldGhost). | |
| @@ -2803,6 +2900,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 2803 | 2900 | o.ghostSuffix = "" | |
| 2804 | 2901 | ||
| 2805 | 2902 | o.insertCompletion = false | |
| 2903 | + | o.pasteSubmit.reset() | |
| 2806 | 2904 | o.mu.Unlock() | |
| 2807 | 2905 | ||
| 2808 | 2906 | // Set initial query | |
| @@ -2888,6 +2986,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 2888 | 2986 | o.mu.Lock() | |
| 2889 | 2987 | o.realInput = "" | |
| 2890 | 2988 | o.insertCompletion = false | |
| 2989 | + | o.pasteSubmit.reset() | |
| 2891 | 2990 | o.mu.Unlock() | |
| 2892 | 2991 | o.resetSearchSessionState() | |
| 2893 | 2992 | ||
| @@ -52,6 +52,56 @@ type favoriteToggleResultUpdate struct { | |||||
| 52 | IsFavorite bool | 52 | IsFavorite bool | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | + | // pasteSubmissionState tracks a paste whose clipboard read is still in flight so | |||
| 56 | + | // that an Enter pressed in the meantime is replayed once the text lands. | |||
| 57 | + | type pasteSubmissionState struct { | |||
| 58 | + | active bool | |||
| 59 | + | initialText string | |||
| 60 | + | deferredEnter bool | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // beginPaste arms the state with the entry text seen when the user pressed the | |||
| 64 | + | // paste shortcut. | |||
| 65 | + | func (s *pasteSubmissionState) beginPaste(text string) { | |||
| 66 | + | s.active = true | |||
| 67 | + | s.initialText = text | |||
| 68 | + | s.deferredEnter = false | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // requestSubmit reports whether Enter submits now and whether the pasted text is | |||
| 72 | + | // already in the entry. Enter pressed while the entry still holds the pre-paste | |||
| 73 | + | // text is deferred instead. | |||
| 74 | + | func (s *pasteSubmissionState) requestSubmit(text string) (submit, pastedTextReady bool) { | |||
| 75 | + | if !s.active { | |||
| 76 | + | return true, false | |||
| 77 | + | } | |||
| 78 | + | if text == s.initialText { | |||
| 79 | + | s.deferredEnter = true | |||
| 80 | + | return false, false | |||
| 81 | + | } | |||
| 82 | + | s.active = false | |||
| 83 | + | return true, true | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // textChanged reports whether an Enter pressed during the paste must be replayed. | |||
| 87 | + | // The first entry change after a paste shortcut completes the paste operation, | |||
| 88 | + | // even when the pasted text matches what the entry already contained, so | |||
| 89 | + | // completion is never inferred from the text itself. | |||
| 90 | + | func (s *pasteSubmissionState) textChanged() bool { | |||
| 91 | + | if !s.active { | |||
| 92 | + | return false | |||
| 93 | + | } | |||
| 94 | + | s.active = false | |||
| 95 | + | deferred := s.deferredEnter | |||
| 96 | + | s.deferredEnter = false | |||
| 97 | + | return deferred | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // reset drops any paste state, discarding a deferred submission. | |||
| 101 | + | func (s *pasteSubmissionState) reset() { | |||
| 102 | + | *s = pasteSubmissionState{} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 55 | // ViewMode distinguishes history search from favorites display. | 105 | // ViewMode distinguishes history search from favorites display. | ||
| 56 | type ViewMode string | 106 | type ViewMode string | ||
| 57 | 107 | ||||
| @@ -119,6 +169,7 @@ type Omnibox struct { | |||||
| 119 | ghostSuffix string // Completion suffix currently displayed | 169 | ghostSuffix string // Completion suffix currently displayed | ||
| 120 | insertCompletion bool // True on character insert, false on delete — gates ghost completion | 170 | insertCompletion bool // True on character insert, false on delete — gates ghost completion | ||
| 121 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | 171 | isSettingGhost bool // Guard flag: skip onEntryChanged during programmatic SetText | ||
| 172 | + | pasteSubmit pasteSubmissionState | |||
| 122 | 173 | ||||
| 123 | // Dependencies | 174 | // Dependencies | ||
| 124 | historyUC *usecase.SearchHistoryUseCase | 175 | historyUC *usecase.SearchHistoryUseCase | ||
| @@ -648,6 +699,16 @@ func (o *Omnibox) setupKeyboardHandling() { | |||||
| 648 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 699 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | ||
| 649 | 700 | ||||
| 650 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 701 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 702 | + | if isPasteShortcut(keyval, state) && o.entry != nil { | |||
| 703 | + | entryText := o.entry.GetText() | |||
| 704 | + | o.mu.Lock() | |||
| 705 | + | o.pasteSubmit.beginPaste(entryText) | |||
| 706 | + | o.mu.Unlock() | |||
| 707 | + | } else if keyval != uint(gdk.KEY_Return) && keyval != uint(gdk.KEY_KP_Enter) { | |||
| 708 | + | o.mu.Lock() | |||
| 709 | + | o.pasteSubmit.reset() | |||
| 710 | + | o.mu.Unlock() | |||
| 711 | + | } | |||
| 651 | if o.handleKeyPress(keyval, keycode, state) { | 712 | if o.handleKeyPress(keyval, keycode, state) { | ||
| 652 | return true | 713 | return true | ||
| 653 | } | 714 | } | ||
| @@ -985,7 +1046,7 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 985 | } | 1046 | } | ||
| 986 | 1047 | ||||
| 987 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | 1048 | case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): | ||
| 988 | - | o.navigateToSelected() | 1049 | + | o.handleSubmitKeyPress() |
| 989 | return true | 1050 | return true | ||
| 990 | 1051 | ||||
| 991 | case uint(gdk.KEY_Up): | 1052 | case uint(gdk.KEY_Up): | ||
| @@ -1036,6 +1097,30 @@ func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) b | |||||
| 1036 | return false // Let entry handle the key | 1097 | return false // Let entry handle the key | ||
| 1037 | } | 1098 | } | ||
| 1038 | 1099 | ||||
| 1100 | + | func (o *Omnibox) handleSubmitKeyPress() { | |||
| 1101 | + | if o.entry == nil { | |||
| 1102 | + | return | |||
| 1103 | + | } | |||
| 1104 | + | entryText := o.entry.GetText() | |||
| 1105 | + | o.mu.Lock() | |||
| 1106 | + | shouldSubmit, pastedTextReady := o.pasteSubmit.requestSubmit(entryText) | |||
| 1107 | + | if pastedTextReady { | |||
| 1108 | + | o.selectedIndex = -1 | |||
| 1109 | + | o.hasNavigated = false | |||
| 1110 | + | } | |||
| 1111 | + | o.mu.Unlock() | |||
| 1112 | + | if shouldSubmit { | |||
| 1113 | + | o.navigateToSelected() | |||
| 1114 | + | } | |||
| 1115 | + | } | |||
| 1116 | + | ||||
| 1117 | + | func isPasteShortcut(keyval uint, state gdk.ModifierType) bool { | |||
| 1118 | + | ctrl := state&gdk.ControlMaskValue != 0 | |||
| 1119 | + | shift := state&gdk.ShiftMaskValue != 0 | |||
| 1120 | + | return (ctrl && (keyval == uint(gdk.KEY_v) || keyval == uint(gdk.KEY_V))) || | |||
| 1121 | + | (shift && keyval == uint(gdk.KEY_Insert)) | |||
| 1122 | + | } | |||
| 1123 | + | ||||
| 1039 | func isDeletionKey(keyval uint) bool { | 1124 | func isDeletionKey(keyval uint) bool { | ||
| 1040 | switch keyval { | 1125 | switch keyval { | ||
| 1041 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | 1126 | case uint(gdk.KEY_BackSpace), uint(gdk.KEY_Delete), uint(gdk.KEY_KP_Delete): | ||
| @@ -1117,15 +1202,22 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 1117 | 1202 | ||||
| 1118 | entryText := o.entry.GetText() | 1203 | entryText := o.entry.GetText() | ||
| 1119 | 1204 | ||||
| 1205 | + | o.mu.Lock() | |||
| 1206 | + | deferredSubmit := o.pasteSubmit.textChanged() | |||
| 1207 | + | o.mu.Unlock() | |||
| 1208 | + | ||||
| 1120 | // Detect debounced echo from our own SetText in setGhostText. | 1209 | // Detect debounced echo from our own SetText in setGhostText. | ||
| 1121 | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | 1210 | // search-changed is debounced by GtkSearchEntry, so it fires AFTER | ||
| 1122 | - | // the isSettingGhost guard is already off. | 1211 | + | // the isSettingGhost guard is already off. A self-inflicted notification |
| 1212 | + | // must leave the ghost state untouched, but it cannot be told apart from a | |||
| 1213 | + | // paste that replaced the selected ghost suffix with the same text, so a | |||
| 1214 | + | // deferred Enter always wins and the ghost state is rebuilt from the paste. | |||
| 1123 | o.mu.RLock() | 1215 | o.mu.RLock() | ||
| 1124 | - | if o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix { | 1216 | + | ghostEcho := o.ghostSuffix != "" && entryText == o.realInput+o.ghostSuffix |
| 1125 | - | o.mu.RUnlock() | 1217 | + | o.mu.RUnlock() |
| 1218 | + | if ghostEcho && !deferredSubmit { | |||
| 1126 | return | 1219 | return | ||
| 1127 | } | 1220 | } | ||
| 1128 | - | o.mu.RUnlock() | |||
| 1129 | 1221 | ||||
| 1130 | log := logging.FromContext(o.ctx) | 1222 | log := logging.FromContext(o.ctx) | ||
| 1131 | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | 1223 | if trimmed := url.TrimLeadingSpacesIfURL(entryText); trimmed != entryText { | ||
| @@ -1146,6 +1238,11 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 1146 | shouldComplete := o.insertCompletion | 1238 | shouldComplete := o.insertCompletion | ||
| 1147 | o.mu.Unlock() | 1239 | o.mu.Unlock() | ||
| 1148 | 1240 | ||||
| 1241 | + | if deferredSubmit { | |||
| 1242 | + | o.navigateToSelected() | |||
| 1243 | + | return | |||
| 1244 | + | } | |||
| 1245 | + | ||||
| 1149 | log.Debug(). | 1246 | log.Debug(). | ||
| 1150 | Str("entryText", entryText). | 1247 | Str("entryText", entryText). | ||
| 1151 | Str("oldGhost", oldGhost). | 1248 | Str("oldGhost", oldGhost). | ||
| @@ -2803,6 +2900,7 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||||
| 2803 | o.ghostSuffix = "" | 2900 | o.ghostSuffix = "" | ||
| 2804 | 2901 | ||||
| 2805 | o.insertCompletion = false | 2902 | o.insertCompletion = false | ||
| 2903 | + | o.pasteSubmit.reset() | |||
| 2806 | o.mu.Unlock() | 2904 | o.mu.Unlock() | ||
| 2807 | 2905 | ||||
| 2808 | // Set initial query | 2906 | // Set initial query | ||
| @@ -2888,6 +2986,7 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||||
| 2888 | o.mu.Lock() | 2986 | o.mu.Lock() | ||
| 2889 | o.realInput = "" | 2987 | o.realInput = "" | ||
| 2890 | o.insertCompletion = false | 2988 | o.insertCompletion = false | ||
| 2989 | + | o.pasteSubmit.reset() | |||
| 2891 | o.mu.Unlock() | 2990 | o.mu.Unlock() | ||
| 2892 | o.resetSearchSessionState() | 2991 | o.resetSearchSessionState() | ||
| 2893 | 2992 | ||||
M internal/ui/component/omnibox_navigation_state_test.go +142 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package component | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/puregotk/v4/gdk" | |
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | func TestShouldPreferTypedURLNavigation(t *testing.T) { | |
| @@ -33,6 +35,146 @@ func TestShouldPreferTypedURLNavigation(t *testing.T) { | |||
| 33 | 35 | } | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 38 | + | func TestPasteSubmissionStateDefersEnterUntilTextArrives(t *testing.T) { | |
| 39 | + | state := pasteSubmissionState{} | |
| 40 | + | state.beginPaste("") | |
| 41 | + | ||
| 42 | + | if submit, _ := state.requestSubmit(""); submit { | |
| 43 | + | t.Fatal("Enter must not submit while the paste buffer is still empty") | |
| 44 | + | } | |
| 45 | + | if !state.textChanged() { | |
| 46 | + | t.Fatal("the completed paste must consume the deferred submission") | |
| 47 | + | } | |
| 48 | + | if state.textChanged() { | |
| 49 | + | t.Fatal("the deferred submission must be consumed exactly once") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestPasteSubmissionStateCompletesWhenPasteKeepsTheSameText(t *testing.T) { | |
| 54 | + | state := pasteSubmissionState{} | |
| 55 | + | state.beginPaste("https://example.com") | |
| 56 | + | ||
| 57 | + | if submit, _ := state.requestSubmit("https://example.com"); submit { | |
| 58 | + | t.Fatal("Enter must be deferred while the clipboard read is still in flight") | |
| 59 | + | } | |
| 60 | + | if !state.textChanged() { | |
| 61 | + | t.Fatal("an identical replacement must consume the deferred submission") | |
| 62 | + | } | |
| 63 | + | if state.textChanged() { | |
| 64 | + | t.Fatal("the deferred submission must be consumed exactly once") | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestPasteSubmissionStateRecognizesPasteThatArrivesBeforeEnter(t *testing.T) { | |
| 69 | + | state := pasteSubmissionState{} | |
| 70 | + | state.beginPaste("old") | |
| 71 | + | ||
| 72 | + | submit, pastedTextReady := state.requestSubmit("new") | |
| 73 | + | if !submit || !pastedTextReady { | |
| 74 | + | t.Fatalf("completed paste should submit immediately, got submit=%v ready=%v", submit, pastedTextReady) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestPasteSubmissionStateResetCancelsDeferredEnter(t *testing.T) { | |
| 79 | + | state := pasteSubmissionState{} | |
| 80 | + | state.beginPaste("") | |
| 81 | + | state.requestSubmit("") | |
| 82 | + | state.reset() | |
| 83 | + | ||
| 84 | + | if state.textChanged() { | |
| 85 | + | t.Fatal("a later edit must not consume a canceled paste submission") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // omniboxPasteHarness drives the omnibox paste path against a real GTK entry. | |
| 90 | + | type omniboxPasteHarness struct { | |
| 91 | + | omnibox *Omnibox | |
| 92 | + | navigated []string | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func newOmniboxPasteHarness(t *testing.T) *omniboxPasteHarness { | |
| 96 | + | t.Helper() | |
| 97 | + | if !gtk.InitCheck() { | |
| 98 | + | t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)") | |
| 99 | + | } | |
| 100 | + | entry := gtk.NewSearchEntry() | |
| 101 | + | if entry == nil { | |
| 102 | + | t.Fatal("failed to create a GTK search entry") | |
| 103 | + | } | |
| 104 | + | harness := &omniboxPasteHarness{} | |
| 105 | + | harness.omnibox = &Omnibox{ | |
| 106 | + | ctx: context.Background(), | |
| 107 | + | entry: entry, | |
| 108 | + | onNavigate: func(_ context.Context, targetURL string) error { | |
| 109 | + | harness.navigated = append(harness.navigated, targetURL) | |
| 110 | + | return nil | |
| 111 | + | }, | |
| 112 | + | } | |
| 113 | + | return harness | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // pasteShortcut reproduces what the capture-phase key controller does on Ctrl+V. | |
| 117 | + | func (h *omniboxPasteHarness) pasteShortcut() { | |
| 118 | + | h.omnibox.mu.Lock() | |
| 119 | + | defer h.omnibox.mu.Unlock() | |
| 120 | + | h.omnibox.pasteSubmit.beginPaste(h.omnibox.entry.GetText()) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // showGhostSuffix displays input+suffix with the suffix selected, as the ghost | |
| 124 | + | // completion does, without emitting entry notifications. | |
| 125 | + | func (h *omniboxPasteHarness) showGhostSuffix(input, suffix string) { | |
| 126 | + | h.omnibox.entry.SetText(input + suffix) | |
| 127 | + | h.omnibox.mu.Lock() | |
| 128 | + | defer h.omnibox.mu.Unlock() | |
| 129 | + | h.omnibox.realInput = input | |
| 130 | + | h.omnibox.ghostSuffix = suffix | |
| 131 | + | h.omnibox.selectedIndex = -1 | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestOmniboxReplaysDeferredEnterWhenPasteKeepsTheGhostSuffixText(t *testing.T) { | |
| 135 | + | harness := newOmniboxPasteHarness(t) | |
| 136 | + | harness.showGhostSuffix("https://example.com/gu", "ide") | |
| 137 | + | ||
| 138 | + | // Ctrl+V with clipboard content equal to the whole entry text, then Enter | |
| 139 | + | // before GTK delivers the pasted text. | |
| 140 | + | harness.pasteShortcut() | |
| 141 | + | harness.omnibox.handleSubmitKeyPress() | |
| 142 | + | if len(harness.navigated) != 0 { | |
| 143 | + | t.Fatalf("Enter must be deferred while the clipboard read is in flight, got %v", harness.navigated) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // GTK reports the replacement. The text is unchanged, so it also matches the | |
| 147 | + | // ghost echo pattern; the pasted text still has to win. | |
| 148 | + | harness.omnibox.onEntryChanged() | |
| 149 | + | ||
| 150 | + | if len(harness.navigated) != 1 || harness.navigated[0] != "https://example.com/guide" { | |
| 151 | + | t.Fatalf("the deferred Enter must navigate once the paste lands, got %v", harness.navigated) | |
| 152 | + | } | |
| 153 | + | if harness.omnibox.hasGhost() { | |
| 154 | + | t.Fatal("the pasted text must replace the ghost suffix") | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func TestOmniboxGhostEchoAloneKeepsItsBehavior(t *testing.T) { | |
| 159 | + | harness := newOmniboxPasteHarness(t) | |
| 160 | + | harness.showGhostSuffix("https://example.com/gu", "ide") | |
| 161 | + | ||
| 162 | + | harness.omnibox.onEntryChanged() | |
| 163 | + | ||
| 164 | + | if len(harness.navigated) != 0 { | |
| 165 | + | t.Fatalf("a self-generated ghost echo must not navigate, got %v", harness.navigated) | |
| 166 | + | } | |
| 167 | + | harness.omnibox.mu.RLock() | |
| 168 | + | defer harness.omnibox.mu.RUnlock() | |
| 169 | + | if harness.omnibox.realInput != "https://example.com/gu" || harness.omnibox.ghostSuffix != "ide" { | |
| 170 | + | t.Fatalf( | |
| 171 | + | "a ghost echo must keep the ghost state, got input=%q suffix=%q", | |
| 172 | + | harness.omnibox.realInput, | |
| 173 | + | harness.omnibox.ghostSuffix, | |
| 174 | + | ) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 36 | 178 | func TestResetSearchSessionState(t *testing.T) { | |
| 37 | 179 | o := &Omnibox{} | |
| 38 | 180 | o.lastQuery = "github" | |
| @@ -1,9 +1,11 @@ | |||||
| 1 | package component | 1 | package component | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/puregotk/v4/gdk" | 7 | "github.com/bnema/puregotk/v4/gdk" | ||
| 8 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | func TestShouldPreferTypedURLNavigation(t *testing.T) { | 11 | func TestShouldPreferTypedURLNavigation(t *testing.T) { | ||
| @@ -33,6 +35,146 @@ func TestShouldPreferTypedURLNavigation(t *testing.T) { | |||||
| 33 | } | 35 | } | ||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 38 | + | func TestPasteSubmissionStateDefersEnterUntilTextArrives(t *testing.T) { | |||
| 39 | + | state := pasteSubmissionState{} | |||
| 40 | + | state.beginPaste("") | |||
| 41 | + | ||||
| 42 | + | if submit, _ := state.requestSubmit(""); submit { | |||
| 43 | + | t.Fatal("Enter must not submit while the paste buffer is still empty") | |||
| 44 | + | } | |||
| 45 | + | if !state.textChanged() { | |||
| 46 | + | t.Fatal("the completed paste must consume the deferred submission") | |||
| 47 | + | } | |||
| 48 | + | if state.textChanged() { | |||
| 49 | + | t.Fatal("the deferred submission must be consumed exactly once") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestPasteSubmissionStateCompletesWhenPasteKeepsTheSameText(t *testing.T) { | |||
| 54 | + | state := pasteSubmissionState{} | |||
| 55 | + | state.beginPaste("https://example.com") | |||
| 56 | + | ||||
| 57 | + | if submit, _ := state.requestSubmit("https://example.com"); submit { | |||
| 58 | + | t.Fatal("Enter must be deferred while the clipboard read is still in flight") | |||
| 59 | + | } | |||
| 60 | + | if !state.textChanged() { | |||
| 61 | + | t.Fatal("an identical replacement must consume the deferred submission") | |||
| 62 | + | } | |||
| 63 | + | if state.textChanged() { | |||
| 64 | + | t.Fatal("the deferred submission must be consumed exactly once") | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestPasteSubmissionStateRecognizesPasteThatArrivesBeforeEnter(t *testing.T) { | |||
| 69 | + | state := pasteSubmissionState{} | |||
| 70 | + | state.beginPaste("old") | |||
| 71 | + | ||||
| 72 | + | submit, pastedTextReady := state.requestSubmit("new") | |||
| 73 | + | if !submit || !pastedTextReady { | |||
| 74 | + | t.Fatalf("completed paste should submit immediately, got submit=%v ready=%v", submit, pastedTextReady) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestPasteSubmissionStateResetCancelsDeferredEnter(t *testing.T) { | |||
| 79 | + | state := pasteSubmissionState{} | |||
| 80 | + | state.beginPaste("") | |||
| 81 | + | state.requestSubmit("") | |||
| 82 | + | state.reset() | |||
| 83 | + | ||||
| 84 | + | if state.textChanged() { | |||
| 85 | + | t.Fatal("a later edit must not consume a canceled paste submission") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // omniboxPasteHarness drives the omnibox paste path against a real GTK entry. | |||
| 90 | + | type omniboxPasteHarness struct { | |||
| 91 | + | omnibox *Omnibox | |||
| 92 | + | navigated []string | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func newOmniboxPasteHarness(t *testing.T) *omniboxPasteHarness { | |||
| 96 | + | t.Helper() | |||
| 97 | + | if !gtk.InitCheck() { | |||
| 98 | + | t.Skip("GTK native display prerequisite unavailable (gtk.InitCheck returned false)") | |||
| 99 | + | } | |||
| 100 | + | entry := gtk.NewSearchEntry() | |||
| 101 | + | if entry == nil { | |||
| 102 | + | t.Fatal("failed to create a GTK search entry") | |||
| 103 | + | } | |||
| 104 | + | harness := &omniboxPasteHarness{} | |||
| 105 | + | harness.omnibox = &Omnibox{ | |||
| 106 | + | ctx: context.Background(), | |||
| 107 | + | entry: entry, | |||
| 108 | + | onNavigate: func(_ context.Context, targetURL string) error { | |||
| 109 | + | harness.navigated = append(harness.navigated, targetURL) | |||
| 110 | + | return nil | |||
| 111 | + | }, | |||
| 112 | + | } | |||
| 113 | + | return harness | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // pasteShortcut reproduces what the capture-phase key controller does on Ctrl+V. | |||
| 117 | + | func (h *omniboxPasteHarness) pasteShortcut() { | |||
| 118 | + | h.omnibox.mu.Lock() | |||
| 119 | + | defer h.omnibox.mu.Unlock() | |||
| 120 | + | h.omnibox.pasteSubmit.beginPaste(h.omnibox.entry.GetText()) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // showGhostSuffix displays input+suffix with the suffix selected, as the ghost | |||
| 124 | + | // completion does, without emitting entry notifications. | |||
| 125 | + | func (h *omniboxPasteHarness) showGhostSuffix(input, suffix string) { | |||
| 126 | + | h.omnibox.entry.SetText(input + suffix) | |||
| 127 | + | h.omnibox.mu.Lock() | |||
| 128 | + | defer h.omnibox.mu.Unlock() | |||
| 129 | + | h.omnibox.realInput = input | |||
| 130 | + | h.omnibox.ghostSuffix = suffix | |||
| 131 | + | h.omnibox.selectedIndex = -1 | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestOmniboxReplaysDeferredEnterWhenPasteKeepsTheGhostSuffixText(t *testing.T) { | |||
| 135 | + | harness := newOmniboxPasteHarness(t) | |||
| 136 | + | harness.showGhostSuffix("https://example.com/gu", "ide") | |||
| 137 | + | ||||
| 138 | + | // Ctrl+V with clipboard content equal to the whole entry text, then Enter | |||
| 139 | + | // before GTK delivers the pasted text. | |||
| 140 | + | harness.pasteShortcut() | |||
| 141 | + | harness.omnibox.handleSubmitKeyPress() | |||
| 142 | + | if len(harness.navigated) != 0 { | |||
| 143 | + | t.Fatalf("Enter must be deferred while the clipboard read is in flight, got %v", harness.navigated) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // GTK reports the replacement. The text is unchanged, so it also matches the | |||
| 147 | + | // ghost echo pattern; the pasted text still has to win. | |||
| 148 | + | harness.omnibox.onEntryChanged() | |||
| 149 | + | ||||
| 150 | + | if len(harness.navigated) != 1 || harness.navigated[0] != "https://example.com/guide" { | |||
| 151 | + | t.Fatalf("the deferred Enter must navigate once the paste lands, got %v", harness.navigated) | |||
| 152 | + | } | |||
| 153 | + | if harness.omnibox.hasGhost() { | |||
| 154 | + | t.Fatal("the pasted text must replace the ghost suffix") | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestOmniboxGhostEchoAloneKeepsItsBehavior(t *testing.T) { | |||
| 159 | + | harness := newOmniboxPasteHarness(t) | |||
| 160 | + | harness.showGhostSuffix("https://example.com/gu", "ide") | |||
| 161 | + | ||||
| 162 | + | harness.omnibox.onEntryChanged() | |||
| 163 | + | ||||
| 164 | + | if len(harness.navigated) != 0 { | |||
| 165 | + | t.Fatalf("a self-generated ghost echo must not navigate, got %v", harness.navigated) | |||
| 166 | + | } | |||
| 167 | + | harness.omnibox.mu.RLock() | |||
| 168 | + | defer harness.omnibox.mu.RUnlock() | |||
| 169 | + | if harness.omnibox.realInput != "https://example.com/gu" || harness.omnibox.ghostSuffix != "ide" { | |||
| 170 | + | t.Fatalf( | |||
| 171 | + | "a ghost echo must keep the ghost state, got input=%q suffix=%q", | |||
| 172 | + | harness.omnibox.realInput, | |||
| 173 | + | harness.omnibox.ghostSuffix, | |||
| 174 | + | ) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 36 | func TestResetSearchSessionState(t *testing.T) { | 178 | func TestResetSearchSessionState(t *testing.T) { | ||
| 37 | o := &Omnibox{} | 179 | o := &Omnibox{} | ||
| 38 | o.lastQuery = "github" | 180 | o.lastQuery = "github" | ||