fix(omnibox): reconcil entry buffer
- commit
- c1cf747102010fde52c9329348893b4f64bfc0ba
- author
- brice · · 2026-07-02 18:17 UTC
- parent
- d64caed
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +118 -20
M internal/ui/component/omnibox.go +29 -20
| @@ -748,7 +748,6 @@ func (o *Omnibox) initList() error { | |||
| 748 | 748 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 749 | 749 | ||
| 750 | 750 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 751 | - | o.restoreEntryToRealInput() | |
| 752 | 751 | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 753 | 752 | if row == nil { | |
| 754 | 753 | o.mu.Lock() | |
| @@ -1295,41 +1294,51 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||
| 1295 | 1294 | ||
| 1296 | 1295 | // updateGhostFromSelection updates ghost text based on the currently selected row. | |
| 1297 | 1296 | func (o *Omnibox) updateGhostFromSelection() { | |
| 1297 | + | o.restoreEntryToRealInput() | |
| 1298 | + | ||
| 1298 | 1299 | o.mu.RLock() | |
| 1299 | - | realInput := o.realInput | |
| 1300 | - | hasGhost := o.ghostSuffix != "" | |
| 1300 | + | query := o.realInput | |
| 1301 | 1301 | o.mu.RUnlock() | |
| 1302 | 1302 | ||
| 1303 | - | entryText := "" | |
| 1304 | - | if o.entry != nil { | |
| 1305 | - | entryText = o.entry.GetText() | |
| 1306 | - | } | |
| 1307 | - | ||
| 1308 | - | query := effectiveSearchQuery(entryText, realInput, hasGhost) | |
| 1309 | - | o.restoreEntryToRealInput() | |
| 1310 | 1303 | o.updateGhostFromSelectionWithInput(query) | |
| 1311 | 1304 | } | |
| 1312 | 1305 | ||
| 1306 | + | // reconcileEntryState decides how to resync the entry buffer with the | |
| 1307 | + | // realInput/ghostSuffix shadow state. The buffer is the source of truth: | |
| 1308 | + | // search-changed is debounced, so realInput lags behind fresh keystrokes. | |
| 1309 | + | // The buffer is only ever rewritten to strip a still-intact ghost suffix — | |
| 1310 | + | // never to restore older text, which would delete characters the user just | |
| 1311 | + | // typed. | |
| 1312 | + | func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) { | |
| 1313 | + | if ghostSuffix != "" && buffer == realInput+ghostSuffix { | |
| 1314 | + | return realInput, true | |
| 1315 | + | } | |
| 1316 | + | return buffer, false | |
| 1317 | + | } | |
| 1318 | + | ||
| 1319 | + | // restoreEntryToRealInput strips any intact ghost suffix from the entry buffer | |
| 1320 | + | // and resyncs realInput from the buffer. It never writes stale realInput over | |
| 1321 | + | // the buffer (see reconcileEntryState). | |
| 1313 | 1322 | func (o *Omnibox) restoreEntryToRealInput() { | |
| 1314 | 1323 | if o.entry == nil { | |
| 1315 | 1324 | return | |
| 1316 | 1325 | } | |
| 1317 | 1326 | ||
| 1318 | - | // Clear ghost text first (removes suffix from buffer with guard). | |
| 1319 | - | o.clearGhostText() | |
| 1327 | + | buffer := o.entry.GetText() | |
| 1320 | 1328 | ||
| 1321 | - | // If the buffer still doesn't match realInput, force-set it. | |
| 1322 | - | o.mu.RLock() | |
| 1323 | - | realInput := o.realInput | |
| 1324 | - | o.mu.RUnlock() | |
| 1329 | + | o.mu.Lock() | |
| 1330 | + | newRealInput, rewrite := reconcileEntryState(buffer, o.realInput, o.ghostSuffix) | |
| 1331 | + | o.realInput = newRealInput | |
| 1332 | + | o.ghostSuffix = "" | |
| 1333 | + | o.mu.Unlock() | |
| 1325 | 1334 | ||
| 1326 | - | if o.entry.GetText() != realInput { | |
| 1335 | + | if rewrite { | |
| 1327 | 1336 | o.withGhostGuard(func() { | |
| 1328 | - | o.entry.SetText(realInput) | |
| 1337 | + | o.entry.SetText(newRealInput) | |
| 1338 | + | o.entry.SelectRegion(-1, -1) | |
| 1339 | + | o.entry.SetPosition(-1) | |
| 1329 | 1340 | }) | |
| 1330 | 1341 | } | |
| 1331 | - | o.entry.SelectRegion(-1, -1) | |
| 1332 | - | o.entry.SetPosition(-1) | |
| 1333 | 1342 | } | |
| 1334 | 1343 | ||
| 1335 | 1344 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | |
| @@ -748,7 +748,6 @@ func (o *Omnibox) initList() error { | |||||
| 748 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 748 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 749 | 749 | ||||
| 750 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | 750 | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | ||
| 751 | - | o.restoreEntryToRealInput() | |||
| 752 | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | 751 | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | ||
| 753 | if row == nil { | 752 | if row == nil { | ||
| 754 | o.mu.Lock() | 753 | o.mu.Lock() | ||
| @@ -1295,41 +1294,51 @@ func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool { | |||||
| 1295 | 1294 | ||||
| 1296 | // updateGhostFromSelection updates ghost text based on the currently selected row. | 1295 | // updateGhostFromSelection updates ghost text based on the currently selected row. | ||
| 1297 | func (o *Omnibox) updateGhostFromSelection() { | 1296 | func (o *Omnibox) updateGhostFromSelection() { | ||
| 1297 | + | o.restoreEntryToRealInput() | |||
| 1298 | + | ||||
| 1298 | o.mu.RLock() | 1299 | o.mu.RLock() | ||
| 1299 | - | realInput := o.realInput | 1300 | + | query := o.realInput |
| 1300 | - | hasGhost := o.ghostSuffix != "" | |||
| 1301 | o.mu.RUnlock() | 1301 | o.mu.RUnlock() | ||
| 1302 | 1302 | ||||
| 1303 | - | entryText := "" | |||
| 1304 | - | if o.entry != nil { | |||
| 1305 | - | entryText = o.entry.GetText() | |||
| 1306 | - | } | |||
| 1307 | - | ||||
| 1308 | - | query := effectiveSearchQuery(entryText, realInput, hasGhost) | |||
| 1309 | - | o.restoreEntryToRealInput() | |||
| 1310 | o.updateGhostFromSelectionWithInput(query) | 1303 | o.updateGhostFromSelectionWithInput(query) | ||
| 1311 | } | 1304 | } | ||
| 1312 | 1305 | ||||
| 1306 | + | // reconcileEntryState decides how to resync the entry buffer with the | |||
| 1307 | + | // realInput/ghostSuffix shadow state. The buffer is the source of truth: | |||
| 1308 | + | // search-changed is debounced, so realInput lags behind fresh keystrokes. | |||
| 1309 | + | // The buffer is only ever rewritten to strip a still-intact ghost suffix — | |||
| 1310 | + | // never to restore older text, which would delete characters the user just | |||
| 1311 | + | // typed. | |||
| 1312 | + | func reconcileEntryState(buffer, realInput, ghostSuffix string) (newRealInput string, rewrite bool) { | |||
| 1313 | + | if ghostSuffix != "" && buffer == realInput+ghostSuffix { | |||
| 1314 | + | return realInput, true | |||
| 1315 | + | } | |||
| 1316 | + | return buffer, false | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1319 | + | // restoreEntryToRealInput strips any intact ghost suffix from the entry buffer | |||
| 1320 | + | // and resyncs realInput from the buffer. It never writes stale realInput over | |||
| 1321 | + | // the buffer (see reconcileEntryState). | |||
| 1313 | func (o *Omnibox) restoreEntryToRealInput() { | 1322 | func (o *Omnibox) restoreEntryToRealInput() { | ||
| 1314 | if o.entry == nil { | 1323 | if o.entry == nil { | ||
| 1315 | return | 1324 | return | ||
| 1316 | } | 1325 | } | ||
| 1317 | 1326 | ||||
| 1318 | - | // Clear ghost text first (removes suffix from buffer with guard). | 1327 | + | buffer := o.entry.GetText() |
| 1319 | - | o.clearGhostText() | |||
| 1320 | 1328 | ||||
| 1321 | - | // If the buffer still doesn't match realInput, force-set it. | 1329 | + | o.mu.Lock() |
| 1322 | - | o.mu.RLock() | 1330 | + | newRealInput, rewrite := reconcileEntryState(buffer, o.realInput, o.ghostSuffix) |
| 1323 | - | realInput := o.realInput | 1331 | + | o.realInput = newRealInput |
| 1324 | - | o.mu.RUnlock() | 1332 | + | o.ghostSuffix = "" |
| 1333 | + | o.mu.Unlock() | |||
| 1325 | 1334 | ||||
| 1326 | - | if o.entry.GetText() != realInput { | 1335 | + | if rewrite { |
| 1327 | o.withGhostGuard(func() { | 1336 | o.withGhostGuard(func() { | ||
| 1328 | - | o.entry.SetText(realInput) | 1337 | + | o.entry.SetText(newRealInput) |
| 1338 | + | o.entry.SelectRegion(-1, -1) | |||
| 1339 | + | o.entry.SetPosition(-1) | |||
| 1329 | }) | 1340 | }) | ||
| 1330 | } | 1341 | } | ||
| 1331 | - | o.entry.SelectRegion(-1, -1) | |||
| 1332 | - | o.entry.SetPosition(-1) | |||
| 1333 | } | 1342 | } | ||
| 1334 | 1343 | ||||
| 1335 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | 1344 | // updateGhostFromSelectionWithInput updates ghost text based on selected row and input. | ||
A internal/ui/component/omnibox_entry_reconcile_test.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package component | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | // reconcileEntryState decides how row-selected handlers resync the entry | |
| 6 | + | // buffer with the realInput/ghostSuffix shadow. The entry buffer is the source | |
| 7 | + | // of truth: search-changed is debounced (~150ms), so realInput lags behind | |
| 8 | + | // what the user actually typed. The old force-restore behavior rewrote the | |
| 9 | + | // buffer to the stale realInput, deleting characters typed during the | |
| 10 | + | // debounce window (fast typing dropped letters). | |
| 11 | + | func TestReconcileEntryState(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | buffer string | |
| 15 | + | realInput string | |
| 16 | + | ghostSuffix string | |
| 17 | + | wantRealInput string | |
| 18 | + | wantRewrite bool | |
| 19 | + | }{ | |
| 20 | + | { | |
| 21 | + | name: "ghost visible and untouched: strip it from the buffer", | |
| 22 | + | buffer: "google.com", | |
| 23 | + | realInput: "goo", | |
| 24 | + | ghostSuffix: "gle.com", | |
| 25 | + | wantRealInput: "goo", | |
| 26 | + | wantRewrite: true, | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "buffer in sync, no ghost: nothing to do", | |
| 30 | + | buffer: "goo", | |
| 31 | + | realInput: "goo", | |
| 32 | + | ghostSuffix: "", | |
| 33 | + | wantRealInput: "goo", | |
| 34 | + | wantRewrite: false, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "user typed ahead of debounced realInput: buffer wins, never rewrite", | |
| 38 | + | buffer: "bne", | |
| 39 | + | realInput: "bn", | |
| 40 | + | ghostSuffix: "", | |
| 41 | + | wantRealInput: "bne", | |
| 42 | + | wantRewrite: false, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "user typed over the selected ghost suffix: buffer wins", | |
| 46 | + | buffer: "goog", | |
| 47 | + | realInput: "goo", | |
| 48 | + | ghostSuffix: "gle.com", | |
| 49 | + | wantRealInput: "goog", | |
| 50 | + | wantRewrite: false, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "stale ghost flag but buffer already shows real input only", | |
| 54 | + | buffer: "goo", | |
| 55 | + | realInput: "goo", | |
| 56 | + | ghostSuffix: "gle.com", | |
| 57 | + | wantRealInput: "goo", | |
| 58 | + | wantRewrite: false, | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "user cleared the entry: buffer wins", | |
| 62 | + | buffer: "", | |
| 63 | + | realInput: "goo", | |
| 64 | + | ghostSuffix: "", | |
| 65 | + | wantRealInput: "", | |
| 66 | + | wantRewrite: false, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "empty ghost with empty input stays empty", | |
| 70 | + | buffer: "", | |
| 71 | + | realInput: "", | |
| 72 | + | ghostSuffix: "", | |
| 73 | + | wantRealInput: "", | |
| 74 | + | wantRewrite: false, | |
| 75 | + | }, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | gotRealInput, gotRewrite := reconcileEntryState(tt.buffer, tt.realInput, tt.ghostSuffix) | |
| 81 | + | if gotRealInput != tt.wantRealInput { | |
| 82 | + | t.Errorf("realInput = %q, want %q", gotRealInput, tt.wantRealInput) | |
| 83 | + | } | |
| 84 | + | if gotRewrite != tt.wantRewrite { | |
| 85 | + | t.Errorf("rewrite = %v, want %v", gotRewrite, tt.wantRewrite) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | // reconcileEntryState decides how row-selected handlers resync the entry | |||
| 6 | + | // buffer with the realInput/ghostSuffix shadow. The entry buffer is the source | |||
| 7 | + | // of truth: search-changed is debounced (~150ms), so realInput lags behind | |||
| 8 | + | // what the user actually typed. The old force-restore behavior rewrote the | |||
| 9 | + | // buffer to the stale realInput, deleting characters typed during the | |||
| 10 | + | // debounce window (fast typing dropped letters). | |||
| 11 | + | func TestReconcileEntryState(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | buffer string | |||
| 15 | + | realInput string | |||
| 16 | + | ghostSuffix string | |||
| 17 | + | wantRealInput string | |||
| 18 | + | wantRewrite bool | |||
| 19 | + | }{ | |||
| 20 | + | { | |||
| 21 | + | name: "ghost visible and untouched: strip it from the buffer", | |||
| 22 | + | buffer: "google.com", | |||
| 23 | + | realInput: "goo", | |||
| 24 | + | ghostSuffix: "gle.com", | |||
| 25 | + | wantRealInput: "goo", | |||
| 26 | + | wantRewrite: true, | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "buffer in sync, no ghost: nothing to do", | |||
| 30 | + | buffer: "goo", | |||
| 31 | + | realInput: "goo", | |||
| 32 | + | ghostSuffix: "", | |||
| 33 | + | wantRealInput: "goo", | |||
| 34 | + | wantRewrite: false, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "user typed ahead of debounced realInput: buffer wins, never rewrite", | |||
| 38 | + | buffer: "bne", | |||
| 39 | + | realInput: "bn", | |||
| 40 | + | ghostSuffix: "", | |||
| 41 | + | wantRealInput: "bne", | |||
| 42 | + | wantRewrite: false, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "user typed over the selected ghost suffix: buffer wins", | |||
| 46 | + | buffer: "goog", | |||
| 47 | + | realInput: "goo", | |||
| 48 | + | ghostSuffix: "gle.com", | |||
| 49 | + | wantRealInput: "goog", | |||
| 50 | + | wantRewrite: false, | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "stale ghost flag but buffer already shows real input only", | |||
| 54 | + | buffer: "goo", | |||
| 55 | + | realInput: "goo", | |||
| 56 | + | ghostSuffix: "gle.com", | |||
| 57 | + | wantRealInput: "goo", | |||
| 58 | + | wantRewrite: false, | |||
| 59 | + | }, | |||
| 60 | + | { | |||
| 61 | + | name: "user cleared the entry: buffer wins", | |||
| 62 | + | buffer: "", | |||
| 63 | + | realInput: "goo", | |||
| 64 | + | ghostSuffix: "", | |||
| 65 | + | wantRealInput: "", | |||
| 66 | + | wantRewrite: false, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "empty ghost with empty input stays empty", | |||
| 70 | + | buffer: "", | |||
| 71 | + | realInput: "", | |||
| 72 | + | ghostSuffix: "", | |||
| 73 | + | wantRealInput: "", | |||
| 74 | + | wantRewrite: false, | |||
| 75 | + | }, | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | for _, tt := range tests { | |||
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | + | gotRealInput, gotRewrite := reconcileEntryState(tt.buffer, tt.realInput, tt.ghostSuffix) | |||
| 81 | + | if gotRealInput != tt.wantRealInput { | |||
| 82 | + | t.Errorf("realInput = %q, want %q", gotRealInput, tt.wantRealInput) | |||
| 83 | + | } | |||
| 84 | + | if gotRewrite != tt.wantRewrite { | |||
| 85 | + | t.Errorf("rewrite = %v, want %v", gotRewrite, tt.wantRewrite) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||