detached 9cc8eee

fix: stabilize omnibox ghost rendering

commit
9cc8eeebfe5a3b021de7e3e30f9861d8f5b905ac
author
brice · · 2026-01-26 04:29 UTC
parent
92a7937
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +188 -130
M internal/ui/component/omnibox.go +188 -123
@@ -25,8 +25,11 @@ import (
2525 )
2626
2727 const (
28- debounceDelayMs = 50
29- endBoxSpacing = 6
28+ debounceDelayMs = 50
29+ endBoxSpacing = 6
30+ defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31+ ghostPositionRetryMs = 16
32+ ghostPositionMaxAttempts = 5
3033 )
3134
3235 // ViewMode distinguishes history search from favorites display.
@@ -488,7 +491,7 @@ func (o *Omnibox) initEntry() error {
488491 o.entry.AddCssClass("omnibox-entry")
489492 o.entry.SetHexpand(true)
490493
491- placeholder := "Search history or enter URL… (! lists bangs)"
494+ placeholder := defaultOmniboxPlaceholder
492495 o.entry.SetPlaceholderText(&placeholder)
493496
494497 // Create overlay for ghost text
@@ -834,77 +837,102 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
834837 o.hasGhostText = true
835838 o.mu.Unlock()
836839
837- var cb glib.SourceFunc = func(uintptr) bool {
838- if o.ghostLabel == nil || o.entry == nil {
839- return false
840- }
840+ var schedulePosition func(attempt int)
841+ schedulePosition = func(attempt int) {
842+ var cb glib.SourceFunc = func(uintptr) bool {
843+ if o.ghostLabel == nil || o.entry == nil {
844+ return false
845+ }
841846
842- // Check if input has changed since the autocomplete query was made
843- // This check runs on GTK main thread where entry.GetText() is reliable
844- // Skip this check when originalInput is empty (row selection replaces full input)
845- if originalInput != "" && o.entry.GetText() != originalInput {
846- // Input changed, skip this stale result - a newer query will handle it
847- return false
848- }
847+ // Check if input has changed since the autocomplete query was made
848+ // This check runs on GTK main thread where entry.GetText() is reliable
849+ // Skip this check when originalInput is empty (row selection replaces full input)
850+ if originalInput != "" && o.entry.GetText() != originalInput {
851+ // Input changed, skip this stale result - a newer query will handle it
852+ return false
853+ }
849854
850- // Get the entry's Pango context and create a layout to measure text width
851- pangoCtx := o.entry.GetPangoContext()
852- if pangoCtx == nil {
853- return false
854- }
855+ if attempt < ghostPositionMaxAttempts {
856+ entryWidth := o.entry.GetAllocatedWidth()
857+ overlayWidth := 0
858+ if o.entryOverlay != nil {
859+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
860+ }
861+ if entryWidth <= 0 || overlayWidth <= 0 {
862+ retryCb := glib.SourceFunc(func(uintptr) bool {
863+ schedulePosition(attempt + 1)
864+ return false
865+ })
866+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
867+ return false
868+ }
869+ }
870+
871+ // Get the entry's Pango context and create a layout to measure text width
872+ pangoCtx := o.entry.GetPangoContext()
873+ if pangoCtx == nil {
874+ return false
875+ }
855876
856- // Create layout to measure the input text width
857- layout := pango.NewLayout(pangoCtx)
858- layout.SetText(originalInput, len(originalInput))
859-
860- // Get the width in pixels
861- var widthPx, heightPx int
862- layout.GetPixelSize(&widthPx, &heightPx)
863-
864- // Set the ghost label text (just the suffix) and position it horizontally
865- o.ghostLabel.SetText(suffix)
866-
867- marginStart := widthPx
868- delegate := o.entry.GetDelegate()
869- if delegate != nil && o.entryOverlay != nil {
870- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
871- if textWidget != nil {
872- var textBounds graphene.Rect
873- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
874- cursorPos := uint(utf8.RuneCountInString(originalInput))
875- var strongRect, weakRect graphene.Rect
876- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
877- cursorX := float64(textBounds.GetX() + strongRect.GetX())
878- marginStart = int(math.Round(cursorX))
877+ // Create layout to measure the input text width
878+ layout := pango.NewLayout(pangoCtx)
879+ layout.SetText(originalInput, len(originalInput))
880+
881+ // Get the width in pixels
882+ var widthPx, heightPx int
883+ layout.GetPixelSize(&widthPx, &heightPx)
884+
885+ // Set the ghost label text (just the suffix) and position it horizontally
886+ o.ghostLabel.SetText(suffix)
887+
888+ marginStart := widthPx
889+ delegate := o.entry.GetDelegate()
890+ if delegate != nil && o.entryOverlay != nil {
891+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
892+ if textWidget != nil {
893+ var textBounds graphene.Rect
894+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
895+ textStart := float64(textBounds.GetX())
896+ cursorPos := uint(utf8.RuneCountInString(originalInput))
897+ var strongRect, weakRect graphene.Rect
898+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
899+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
900+ marginStart = int(math.Round(cursorX))
901+ if cursorX < textStart {
902+ marginStart = int(math.Round(textStart))
903+ }
904+ }
879905 }
880906 }
881- }
882907
883- // Clamp horizontal position to entry bounds
884- if marginStart < 0 {
885- marginStart = 0
886- }
887- if o.entryOverlay != nil {
888- var entryBounds graphene.Rect
889- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
890- minStart := int(math.Round(float64(entryBounds.GetX())))
891- if marginStart < minStart {
892- marginStart = minStart
908+ // Clamp horizontal position to entry bounds
909+ if marginStart < 0 {
910+ marginStart = 0
911+ }
912+ if o.entryOverlay != nil {
913+ var entryBounds graphene.Rect
914+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
915+ minStart := int(math.Round(float64(entryBounds.GetX())))
916+ if marginStart < minStart {
917+ marginStart = minStart
918+ }
893919 }
894920 }
895- }
896921
897- o.ghostLabel.SetMarginStart(marginStart)
898- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
899- o.ghostLabel.SetVisible(true)
922+ o.ghostLabel.SetMarginStart(marginStart)
923+ // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
924+ o.ghostLabel.SetVisible(true)
900925
901- // Hide placeholder text so it doesn't show through ghost text
902- emptyPlaceholder := ""
903- o.entry.SetPlaceholderText(&emptyPlaceholder)
926+ // Hide placeholder text so it doesn't show through ghost text
927+ emptyPlaceholder := ""
928+ o.entry.SetPlaceholderText(&emptyPlaceholder)
904929
905- return false
930+ return false
931+ }
932+ glib.IdleAdd(&cb, 0)
906933 }
907- glib.IdleAdd(&cb, 0)
934+
935+ schedulePosition(0)
908936 }
909937
910938 // clearGhostText hides the ghost completion text.
@@ -926,7 +954,7 @@ func (o *Omnibox) clearGhostText() {
926954 }
927955 // Restore placeholder text
928956 if o.entry != nil {
929- placeholder := "Search history or enter URL… (! lists bangs)"
957+ placeholder := defaultOmniboxPlaceholder
930958 o.entry.SetPlaceholderText(&placeholder)
931959 }
932960 return false
@@ -934,6 +962,36 @@ func (o *Omnibox) clearGhostText() {
934962 glib.IdleAdd(&cb, 0)
935963 }
936964
965+// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
966+// This prevents stale background results from wiping newer ghost text.
967+func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
968+ if o.entry == nil {
969+ return
970+ }
971+
972+ var cb glib.SourceFunc = func(uintptr) bool {
973+ if o.entry == nil {
974+ return false
975+ }
976+ if o.entry.GetText() != expectedInput {
977+ return false
978+ }
979+ o.mu.Lock()
980+ o.ghostSuffix = ""
981+ o.ghostFullText = ""
982+ o.hasGhostText = false
983+ o.mu.Unlock()
984+ if o.ghostLabel != nil {
985+ o.ghostLabel.SetVisible(false)
986+ o.ghostLabel.SetText("")
987+ }
988+ placeholder := defaultOmniboxPlaceholder
989+ o.entry.SetPlaceholderText(&placeholder)
990+ return false
991+ }
992+ glib.IdleAdd(&cb, 0)
993+}
994+
937995 // acceptGhostCompletion accepts the ghost text and fills the input.
938996 func (o *Omnibox) acceptGhostCompletion() {
939997 o.mu.Lock()
@@ -962,75 +1020,40 @@ func (o *Omnibox) acceptGhostCompletion() {
9621020 o.mu.Unlock()
9631021 }
9641022
965-// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966-// The query parameter is the search query that was used, ensuring we don't use stale input.
967-func (o *Omnibox) updateGhostFromSuggestion(query string) {
968- if o.autocompleteUC == nil {
969- return
970- }
971-
972- o.mu.RLock()
973- bangMode := o.bangMode
974- o.mu.RUnlock()
975-
976- // Use the query that triggered this search, not realInput which may have changed
977- userInput := query
978-
979- // Don't show ghost text in bang mode (it has its own completion)
980- if bangMode || userInput == "" {
981- o.clearGhostText()
982- return
983- }
984-
985- go func() {
986- output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987- if !output.Found || output.Suggestion == nil {
988- o.clearGhostText()
989- return
990- }
991- // Pass the original input so setGhostText can verify it's still current
992- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993- }()
994-}
995-
9961023 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
9971024 // When a row is selected via arrow keys, show the URL as ghost text even if
9981025 // the user's input isn't a prefix - this shows what Tab would fill in.
999-func (o *Omnibox) updateGhostFromURL(targetURL string) {
1026+
1027+func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
10001028 if targetURL == "" {
1001- o.clearGhostText()
1002- return
1029+ return false
10031030 }
10041031
1005- o.mu.RLock()
1006- userInput := o.realInput
1007- o.mu.RUnlock()
1008-
10091032 // Strip protocol from URL for cleaner display
10101033 displayURL := autocomplete.StripProtocol(targetURL)
10111034
1012- // Try to compute proper suffix if input is a prefix of the URL
1013- if o.autocompleteUC != nil && userInput != "" {
1014- output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)
1015- if output.Found && output.Suggestion != nil {
1016- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017- return
1018- }
1035+ // Only show full URL as ghost text when input is empty (initial state)
1036+ if userInput == "" {
1037+ o.setGhostText("", displayURL, displayURL)
1038+ return true
10191039 }
10201040
1021- // If user has typed something but it's not a prefix match, don't show ghost text
1022- // This prevents confusing ghost text when scrolling through history
1023- if userInput != "" {
1024- o.clearGhostText()
1025- return
1041+ // Try to compute proper suffix if input is a prefix of the URL
1042+ if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1043+ o.setGhostText(userInput, suffix, matchedURL)
1044+ return true
10261045 }
10271046
1028- // Only show full URL as ghost text when input is empty (initial state)
1029- o.setGhostText("", displayURL, displayURL)
1047+ return false
10301048 }
10311049
10321050 // updateGhostFromSelection updates ghost text based on the currently selected row.
10331051 func (o *Omnibox) updateGhostFromSelection() {
1052+ o.updateGhostFromSelectionWithInput(o.entry.GetText())
1053+}
1054+
1055+// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1056+func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
10341057 o.mu.RLock()
10351058 idx := o.selectedIndex
10361059 mode := o.viewMode
@@ -1041,6 +1064,12 @@ func (o *Omnibox) updateGhostFromSelection() {
10411064
10421065 // No ghost text in bang mode
10431066 if bangMode {
1067+ o.clearGhostTextIfInput(entryText)
1068+ return
1069+ }
1070+
1071+ if entryText != "" {
1072+ o.resolveGhostCompletion(entryText, mode, suggestions, favorites)
10441073 return
10451074 }
10461075
@@ -1056,11 +1085,49 @@ func (o *Omnibox) updateGhostFromSelection() {
10561085 }
10571086
10581087 if targetURL == "" {
1059- o.clearGhostText()
1088+ o.clearGhostTextIfInput(entryText)
1089+ return
1090+ }
1091+
1092+ if o.updateGhostFromURL(entryText, targetURL) {
10601093 return
10611094 }
10621095
1063- o.updateGhostFromURL(targetURL)
1096+ o.clearGhostTextIfInput(entryText)
1097+}
1098+
1099+func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) {
1100+ if o.autocompleteUC == nil {
1101+ o.clearGhostTextIfInput(entryText)
1102+ return
1103+ }
1104+
1105+ visibleURLs := make([]string, 0, len(suggestions))
1106+ if mode == ViewModeHistory {
1107+ for _, s := range suggestions {
1108+ if s.URL != "" {
1109+ visibleURLs = append(visibleURLs, s.URL)
1110+ }
1111+ }
1112+ } else {
1113+ for _, f := range favorites {
1114+ if f.URL != "" {
1115+ visibleURLs = append(visibleURLs, f.URL)
1116+ }
1117+ }
1118+ }
1119+
1120+ go func() {
1121+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1122+ VisibleURLs: visibleURLs,
1123+ AllowBangs: false,
1124+ })
1125+ if suggestion == nil {
1126+ o.clearGhostTextIfInput(entryText)
1127+ return
1128+ }
1129+ o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)
1130+ }()
10641131 }
10651132
10661133 // performSearch executes the search based on current view mode and query.
@@ -1380,10 +1447,9 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
13801447 // Select first item if available and update ghost text
13811448 if rowCount > 0 {
13821449 o.selectIndex(0)
1383- // Update ghost text from first suggestion
1384- o.updateGhostFromSuggestion(query)
1450+ o.updateGhostFromSelectionWithInput(query)
13851451 } else {
1386- o.clearGhostText()
1452+ o.clearGhostTextIfInput(query)
13871453 }
13881454 }
13891455
@@ -1409,10 +1475,9 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
14091475 // Select first item if available and update ghost text
14101476 if rowCount > 0 {
14111477 o.selectIndex(0)
1412- // Update ghost text from first favorite
1413- o.updateGhostFromSuggestion(query)
1478+ o.updateGhostFromSelectionWithInput(query)
14141479 } else {
1415- o.clearGhostText()
1480+ o.clearGhostTextIfInput(query)
14161481 }
14171482 }
14181483
@@ -25,8 +25,11 @@ import (
25 )25 )
26 26
27 const (27 const (
28- debounceDelayMs = 5028+ debounceDelayMs = 50
29- endBoxSpacing = 629+ endBoxSpacing = 6
30+ defaultOmniboxPlaceholder = "Search history or enter URL… (! lists bangs)"
31+ ghostPositionRetryMs = 16
32+ ghostPositionMaxAttempts = 5
30 )33 )
31 34
32 // ViewMode distinguishes history search from favorites display.35 // ViewMode distinguishes history search from favorites display.
@@ -488,7 +491,7 @@ func (o *Omnibox) initEntry() error {
488 o.entry.AddCssClass("omnibox-entry")491 o.entry.AddCssClass("omnibox-entry")
489 o.entry.SetHexpand(true)492 o.entry.SetHexpand(true)
490 493
491- placeholder := "Search history or enter URL… (! lists bangs)"494+ placeholder := defaultOmniboxPlaceholder
492 o.entry.SetPlaceholderText(&placeholder)495 o.entry.SetPlaceholderText(&placeholder)
493 496
494 // Create overlay for ghost text497 // Create overlay for ghost text
@@ -834,77 +837,102 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
834 o.hasGhostText = true837 o.hasGhostText = true
835 o.mu.Unlock()838 o.mu.Unlock()
836 839
837- var cb glib.SourceFunc = func(uintptr) bool {840+ var schedulePosition func(attempt int)
838- if o.ghostLabel == nil || o.entry == nil {841+ schedulePosition = func(attempt int) {
839- return false842+ var cb glib.SourceFunc = func(uintptr) bool {
840- }843+ if o.ghostLabel == nil || o.entry == nil {
844+ return false
845+ }
841 846
842- // Check if input has changed since the autocomplete query was made847+ // Check if input has changed since the autocomplete query was made
843- // This check runs on GTK main thread where entry.GetText() is reliable848+ // This check runs on GTK main thread where entry.GetText() is reliable
844- // Skip this check when originalInput is empty (row selection replaces full input)849+ // Skip this check when originalInput is empty (row selection replaces full input)
845- if originalInput != "" && o.entry.GetText() != originalInput {850+ if originalInput != "" && o.entry.GetText() != originalInput {
846- // Input changed, skip this stale result - a newer query will handle it851+ // Input changed, skip this stale result - a newer query will handle it
847- return false852+ return false
848- }853+ }
849 854
850- // Get the entry's Pango context and create a layout to measure text width855+ if attempt < ghostPositionMaxAttempts {
851- pangoCtx := o.entry.GetPangoContext()856+ entryWidth := o.entry.GetAllocatedWidth()
852- if pangoCtx == nil {857+ overlayWidth := 0
853- return false858+ if o.entryOverlay != nil {
854- }859+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
860+ }
861+ if entryWidth <= 0 || overlayWidth <= 0 {
862+ retryCb := glib.SourceFunc(func(uintptr) bool {
863+ schedulePosition(attempt + 1)
864+ return false
865+ })
866+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
867+ return false
868+ }
869+ }
870+
871+ // Get the entry's Pango context and create a layout to measure text width
872+ pangoCtx := o.entry.GetPangoContext()
873+ if pangoCtx == nil {
874+ return false
875+ }
855 876
856- // Create layout to measure the input text width877+ // Create layout to measure the input text width
857- layout := pango.NewLayout(pangoCtx)878+ layout := pango.NewLayout(pangoCtx)
858- layout.SetText(originalInput, len(originalInput))879+ layout.SetText(originalInput, len(originalInput))
859-880+
860- // Get the width in pixels881+ // Get the width in pixels
861- var widthPx, heightPx int882+ var widthPx, heightPx int
862- layout.GetPixelSize(&widthPx, &heightPx)883+ layout.GetPixelSize(&widthPx, &heightPx)
863-884+
864- // Set the ghost label text (just the suffix) and position it horizontally885+ // Set the ghost label text (just the suffix) and position it horizontally
865- o.ghostLabel.SetText(suffix)886+ o.ghostLabel.SetText(suffix)
866-887+
867- marginStart := widthPx888+ marginStart := widthPx
868- delegate := o.entry.GetDelegate()889+ delegate := o.entry.GetDelegate()
869- if delegate != nil && o.entryOverlay != nil {890+ if delegate != nil && o.entryOverlay != nil {
870- textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())891+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
871- if textWidget != nil {892+ if textWidget != nil {
872- var textBounds graphene.Rect893+ var textBounds graphene.Rect
873- if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {894+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
874- cursorPos := uint(utf8.RuneCountInString(originalInput))895+ textStart := float64(textBounds.GetX())
875- var strongRect, weakRect graphene.Rect896+ cursorPos := uint(utf8.RuneCountInString(originalInput))
876- textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)897+ var strongRect, weakRect graphene.Rect
877- cursorX := float64(textBounds.GetX() + strongRect.GetX())898+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
878- marginStart = int(math.Round(cursorX))899+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
900+ marginStart = int(math.Round(cursorX))
901+ if cursorX < textStart {
902+ marginStart = int(math.Round(textStart))
903+ }
904+ }
879 }905 }
880 }906 }
881- }
882 907
883- // Clamp horizontal position to entry bounds908+ // Clamp horizontal position to entry bounds
884- if marginStart < 0 {909+ if marginStart < 0 {
885- marginStart = 0910+ marginStart = 0
886- }911+ }
887- if o.entryOverlay != nil {912+ if o.entryOverlay != nil {
888- var entryBounds graphene.Rect913+ var entryBounds graphene.Rect
889- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {914+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
890- minStart := int(math.Round(float64(entryBounds.GetX())))915+ minStart := int(math.Round(float64(entryBounds.GetX())))
891- if marginStart < minStart {916+ if marginStart < minStart {
892- marginStart = minStart917+ marginStart = minStart
918+ }
893 }919 }
894 }920 }
895- }
896 921
897- o.ghostLabel.SetMarginStart(marginStart)922+ o.ghostLabel.SetMarginStart(marginStart)
898- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI923+ // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
899- o.ghostLabel.SetVisible(true)924+ o.ghostLabel.SetVisible(true)
900 925
901- // Hide placeholder text so it doesn't show through ghost text926+ // Hide placeholder text so it doesn't show through ghost text
902- emptyPlaceholder := ""927+ emptyPlaceholder := ""
903- o.entry.SetPlaceholderText(&emptyPlaceholder)928+ o.entry.SetPlaceholderText(&emptyPlaceholder)
904 929
905- return false930+ return false
931+ }
932+ glib.IdleAdd(&cb, 0)
906 }933 }
907- glib.IdleAdd(&cb, 0)934+
935+ schedulePosition(0)
908 }936 }
909 937
910 // clearGhostText hides the ghost completion text.938 // clearGhostText hides the ghost completion text.
@@ -926,7 +954,7 @@ func (o *Omnibox) clearGhostText() {
926 }954 }
927 // Restore placeholder text955 // Restore placeholder text
928 if o.entry != nil {956 if o.entry != nil {
929- placeholder := "Search history or enter URL… (! lists bangs)"957+ placeholder := defaultOmniboxPlaceholder
930 o.entry.SetPlaceholderText(&placeholder)958 o.entry.SetPlaceholderText(&placeholder)
931 }959 }
932 return false960 return false
@@ -934,6 +962,36 @@ func (o *Omnibox) clearGhostText() {
934 glib.IdleAdd(&cb, 0)962 glib.IdleAdd(&cb, 0)
935 }963 }
936 964
965+// clearGhostTextIfInput clears ghost text only if the entry text matches expectedInput.
966+// This prevents stale background results from wiping newer ghost text.
967+func (o *Omnibox) clearGhostTextIfInput(expectedInput string) {
968+ if o.entry == nil {
969+ return
970+ }
971+
972+ var cb glib.SourceFunc = func(uintptr) bool {
973+ if o.entry == nil {
974+ return false
975+ }
976+ if o.entry.GetText() != expectedInput {
977+ return false
978+ }
979+ o.mu.Lock()
980+ o.ghostSuffix = ""
981+ o.ghostFullText = ""
982+ o.hasGhostText = false
983+ o.mu.Unlock()
984+ if o.ghostLabel != nil {
985+ o.ghostLabel.SetVisible(false)
986+ o.ghostLabel.SetText("")
987+ }
988+ placeholder := defaultOmniboxPlaceholder
989+ o.entry.SetPlaceholderText(&placeholder)
990+ return false
991+ }
992+ glib.IdleAdd(&cb, 0)
993+}
994+
937 // acceptGhostCompletion accepts the ghost text and fills the input.995 // acceptGhostCompletion accepts the ghost text and fills the input.
938 func (o *Omnibox) acceptGhostCompletion() {996 func (o *Omnibox) acceptGhostCompletion() {
939 o.mu.Lock()997 o.mu.Lock()
@@ -962,75 +1020,40 @@ func (o *Omnibox) acceptGhostCompletion() {
962 o.mu.Unlock()1020 o.mu.Unlock()
963 }1021 }
964 1022
965-// updateGhostFromSuggestion updates ghost text based on the current autocomplete suggestion.
966-// The query parameter is the search query that was used, ensuring we don't use stale input.
967-func (o *Omnibox) updateGhostFromSuggestion(query string) {
968- if o.autocompleteUC == nil {
969- return
970- }
971-
972- o.mu.RLock()
973- bangMode := o.bangMode
974- o.mu.RUnlock()
975-
976- // Use the query that triggered this search, not realInput which may have changed
977- userInput := query
978-
979- // Don't show ghost text in bang mode (it has its own completion)
980- if bangMode || userInput == "" {
981- o.clearGhostText()
982- return
983- }
984-
985- go func() {
986- output := o.autocompleteUC.GetSuggestion(o.ctx, usecase.GetSuggestionInput{Input: userInput})
987- if !output.Found || output.Suggestion == nil {
988- o.clearGhostText()
989- return
990- }
991- // Pass the original input so setGhostText can verify it's still current
992- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
993- }()
994-}
995-
996 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).1023 // updateGhostFromURL updates ghost text based on a specific URL (from row selection).
997 // When a row is selected via arrow keys, show the URL as ghost text even if1024 // When a row is selected via arrow keys, show the URL as ghost text even if
998 // the user's input isn't a prefix - this shows what Tab would fill in.1025 // the user's input isn't a prefix - this shows what Tab would fill in.
999-func (o *Omnibox) updateGhostFromURL(targetURL string) {1026+
1027+func (o *Omnibox) updateGhostFromURL(userInput, targetURL string) bool {
1000 if targetURL == "" {1028 if targetURL == "" {
1001- o.clearGhostText()1029+ return false
1002- return
1003 }1030 }
1004 1031
1005- o.mu.RLock()
1006- userInput := o.realInput
1007- o.mu.RUnlock()
1008-
1009 // Strip protocol from URL for cleaner display1032 // Strip protocol from URL for cleaner display
1010 displayURL := autocomplete.StripProtocol(targetURL)1033 displayURL := autocomplete.StripProtocol(targetURL)
1011 1034
1012- // Try to compute proper suffix if input is a prefix of the URL1035+ // Only show full URL as ghost text when input is empty (initial state)
1013- if o.autocompleteUC != nil && userInput != "" {1036+ if userInput == "" {
1014- output := o.autocompleteUC.GetSuggestionForURL(o.ctx, userInput, targetURL)1037+ o.setGhostText("", displayURL, displayURL)
1015- if output.Found && output.Suggestion != nil {1038+ return true
1016- o.setGhostText(userInput, output.Suggestion.Suffix, output.Suggestion.FullText)
1017- return
1018- }
1019 }1039 }
1020 1040
1021- // If user has typed something but it's not a prefix match, don't show ghost text1041+ // Try to compute proper suffix if input is a prefix of the URL
1022- // This prevents confusing ghost text when scrolling through history1042+ if suffix, matchedURL, ok := autocomplete.ComputeURLCompletionSuffix(userInput, targetURL); ok {
1023- if userInput != "" {1043+ o.setGhostText(userInput, suffix, matchedURL)
1024- o.clearGhostText()1044+ return true
1025- return
1026 }1045 }
1027 1046
1028- // Only show full URL as ghost text when input is empty (initial state)1047+ return false
1029- o.setGhostText("", displayURL, displayURL)
1030 }1048 }
1031 1049
1032 // updateGhostFromSelection updates ghost text based on the currently selected row.1050 // updateGhostFromSelection updates ghost text based on the currently selected row.
1033 func (o *Omnibox) updateGhostFromSelection() {1051 func (o *Omnibox) updateGhostFromSelection() {
1052+ o.updateGhostFromSelectionWithInput(o.entry.GetText())
1053+}
1054+
1055+// updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1056+func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1034 o.mu.RLock()1057 o.mu.RLock()
1035 idx := o.selectedIndex1058 idx := o.selectedIndex
1036 mode := o.viewMode1059 mode := o.viewMode
@@ -1041,6 +1064,12 @@ func (o *Omnibox) updateGhostFromSelection() {
1041 1064
1042 // No ghost text in bang mode1065 // No ghost text in bang mode
1043 if bangMode {1066 if bangMode {
1067+ o.clearGhostTextIfInput(entryText)
1068+ return
1069+ }
1070+
1071+ if entryText != "" {
1072+ o.resolveGhostCompletion(entryText, mode, suggestions, favorites)
1044 return1073 return
1045 }1074 }
1046 1075
@@ -1056,11 +1085,49 @@ func (o *Omnibox) updateGhostFromSelection() {
1056 }1085 }
1057 1086
1058 if targetURL == "" {1087 if targetURL == "" {
1059- o.clearGhostText()1088+ o.clearGhostTextIfInput(entryText)
1089+ return
1090+ }
1091+
1092+ if o.updateGhostFromURL(entryText, targetURL) {
1060 return1093 return
1061 }1094 }
1062 1095
1063- o.updateGhostFromURL(targetURL)1096+ o.clearGhostTextIfInput(entryText)
1097+}
1098+
1099+func (o *Omnibox) resolveGhostCompletion(entryText string, mode ViewMode, suggestions []Suggestion, favorites []Favorite) {
1100+ if o.autocompleteUC == nil {
1101+ o.clearGhostTextIfInput(entryText)
1102+ return
1103+ }
1104+
1105+ visibleURLs := make([]string, 0, len(suggestions))
1106+ if mode == ViewModeHistory {
1107+ for _, s := range suggestions {
1108+ if s.URL != "" {
1109+ visibleURLs = append(visibleURLs, s.URL)
1110+ }
1111+ }
1112+ } else {
1113+ for _, f := range favorites {
1114+ if f.URL != "" {
1115+ visibleURLs = append(visibleURLs, f.URL)
1116+ }
1117+ }
1118+ }
1119+
1120+ go func() {
1121+ suggestion := o.autocompleteUC.ResolveCompletion(o.ctx, entryText, usecase.CompletionOptions{
1122+ VisibleURLs: visibleURLs,
1123+ AllowBangs: false,
1124+ })
1125+ if suggestion == nil {
1126+ o.clearGhostTextIfInput(entryText)
1127+ return
1128+ }
1129+ o.setGhostText(entryText, suggestion.Suffix, suggestion.FullText)
1130+ }()
1064 }1131 }
1065 1132
1066 // performSearch executes the search based on current view mode and query.1133 // performSearch executes the search based on current view mode and query.
@@ -1380,10 +1447,9 @@ func (o *Omnibox) updateSuggestions(suggestions []Suggestion, query string) {
1380 // Select first item if available and update ghost text1447 // Select first item if available and update ghost text
1381 if rowCount > 0 {1448 if rowCount > 0 {
1382 o.selectIndex(0)1449 o.selectIndex(0)
1383- // Update ghost text from first suggestion1450+ o.updateGhostFromSelectionWithInput(query)
1384- o.updateGhostFromSuggestion(query)
1385 } else {1451 } else {
1386- o.clearGhostText()1452+ o.clearGhostTextIfInput(query)
1387 }1453 }
1388 }1454 }
1389 1455
@@ -1409,10 +1475,9 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1409 // Select first item if available and update ghost text1475 // Select first item if available and update ghost text
1410 if rowCount > 0 {1476 if rowCount > 0 {
1411 o.selectIndex(0)1477 o.selectIndex(0)
1412- // Update ghost text from first favorite1478+ o.updateGhostFromSelectionWithInput(query)
1413- o.updateGhostFromSuggestion(query)
1414 } else {1479 } else {
1415- o.clearGhostText()1480+ o.clearGhostTextIfInput(query)
1416 }1481 }
1417 }1482 }
1418 1483
M internal/ui/component/workspace_view.go +0 -7
@@ -213,15 +213,8 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
213213 wv.logger.Debug().
214214 Str("from_pane", string(currentActiveID)).
215215 Str("to_pane", string(paneID)).
216- Bool("omnibox_visible", wv.omnibox != nil).
217- Str("omnibox_pane", string(wv.omniboxPaneID)).
218216 Msg("active pane changing")
219217
220- // Destroy omnibox if active pane is changing
221- if currentActiveID != paneID && wv.omnibox != nil {
222- wv.logger.Debug().Msg("destroying omnibox due to pane change")
223- wv.hideOmniboxInternal()
224- }
225218 // Destroy find bar if active pane is changing
226219 if currentActiveID != paneID && wv.findBar != nil {
227220 wv.hideFindBarInternal()
@@ -213,15 +213,8 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error {
213 wv.logger.Debug().213 wv.logger.Debug().
214 Str("from_pane", string(currentActiveID)).214 Str("from_pane", string(currentActiveID)).
215 Str("to_pane", string(paneID)).215 Str("to_pane", string(paneID)).
216- Bool("omnibox_visible", wv.omnibox != nil).
217- Str("omnibox_pane", string(wv.omniboxPaneID)).
218 Msg("active pane changing")216 Msg("active pane changing")
219 217
220- // Destroy omnibox if active pane is changing
221- if currentActiveID != paneID && wv.omnibox != nil {
222- wv.logger.Debug().Msg("destroying omnibox due to pane change")
223- wv.hideOmniboxInternal()
224- }
225 // Destroy find bar if active pane is changing218 // Destroy find bar if active pane is changing
226 if currentActiveID != paneID && wv.findBar != nil {219 if currentActiveID != paneID && wv.findBar != nil {
227 wv.hideFindBarInternal()220 wv.hideFindBarInternal()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close