detached 01028b0

refactor: split ghost positioning helpers

commit
01028b0a2d230c3aaa979930d8e20b7dae18a45a
author
brice · · 2026-01-26 04:51 UTC
parent
dddc3da
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +96 -85
M internal/ui/component/omnibox.go +96 -85
@@ -837,102 +837,113 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
837837 o.hasGhostText = true
838838 o.mu.Unlock()
839839
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- }
840+ o.scheduleGhostPosition(originalInput, suffix, 0)
841+}
846842
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- }
843+func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
844+ var cb glib.SourceFunc = func(uintptr) bool {
845+ if o.ghostLabel == nil || o.entry == nil {
846+ return false
847+ }
848+ if o.shouldSkipGhostUpdate(originalInput) {
849+ return false
850+ }
851+ if !o.ghostPositionReady(attempt) {
852+ o.retryGhostPosition(originalInput, suffix, attempt+1)
853+ return false
854+ }
854855
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- }
856+ marginStart, ok := o.computeGhostMarginStart(originalInput)
857+ if !ok {
858+ return false
859+ }
860+ o.applyGhostPosition(suffix, marginStart)
861+ return false
862+ }
863+ glib.IdleAdd(&cb, 0)
864+}
870865
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- }
866+func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
867+ if originalInput == "" {
868+ return false
869+ }
870+ return o.entry.GetText() != originalInput
871+}
876872
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- }
905- }
906- }
873+func (o *Omnibox) ghostPositionReady(attempt int) bool {
874+ if attempt >= ghostPositionMaxAttempts {
875+ return true
876+ }
877+ entryWidth := o.entry.GetAllocatedWidth()
878+ overlayWidth := 0
879+ if o.entryOverlay != nil {
880+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
881+ }
882+ return entryWidth > 0 && overlayWidth > 0
883+}
907884
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- }
919- }
920- }
885+func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {
886+ var retryCb glib.SourceFunc = func(uintptr) bool {
887+ o.scheduleGhostPosition(originalInput, suffix, attempt)
888+ return false
889+ }
890+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
891+}
892+
893+func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
894+ pangoCtx := o.entry.GetPangoContext()
895+ if pangoCtx == nil {
896+ return 0, false
897+ }
921898
922- o.ghostLabel.SetMarginStart(marginStart)
923- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
924- o.ghostLabel.SetVisible(true)
899+ textLayout := pango.NewLayout(pangoCtx)
900+ textLayout.SetText(originalInput, len(originalInput))
925901
926- // Hide placeholder text so it doesn't show through ghost text
927- emptyPlaceholder := ""
928- o.entry.SetPlaceholderText(&emptyPlaceholder)
902+ var widthPx, heightPx int
903+ textLayout.GetPixelSize(&widthPx, &heightPx)
929904
930- return false
905+ marginStart := widthPx
906+ delegate := o.entry.GetDelegate()
907+ if delegate != nil && o.entryOverlay != nil {
908+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
909+ if textWidget != nil {
910+ var textBounds graphene.Rect
911+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
912+ textStart := float64(textBounds.GetX())
913+ cursorPos := uint(utf8.RuneCountInString(originalInput))
914+ var strongRect, weakRect graphene.Rect
915+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
916+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
917+ marginStart = int(math.Round(cursorX))
918+ if cursorX < textStart {
919+ marginStart = int(math.Round(textStart))
920+ }
921+ }
931922 }
932- glib.IdleAdd(&cb, 0)
933923 }
934924
935- schedulePosition(0)
925+ if marginStart < 0 {
926+ marginStart = 0
927+ }
928+ if o.entryOverlay != nil {
929+ var entryBounds graphene.Rect
930+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
931+ minStart := int(math.Round(float64(entryBounds.GetX())))
932+ if marginStart < minStart {
933+ marginStart = minStart
934+ }
935+ }
936+ }
937+
938+ return marginStart, true
939+}
940+
941+func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
942+ o.ghostLabel.SetText(suffix)
943+ o.ghostLabel.SetMarginStart(marginStart)
944+ o.ghostLabel.SetVisible(true)
945+ emptyPlaceholder := ""
946+ o.entry.SetPlaceholderText(&emptyPlaceholder)
936947 }
937948
938949 // clearGhostText hides the ghost completion text.
@@ -837,102 +837,113 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
837 o.hasGhostText = true837 o.hasGhostText = true
838 o.mu.Unlock()838 o.mu.Unlock()
839 839
840- var schedulePosition func(attempt int)840+ o.scheduleGhostPosition(originalInput, suffix, 0)
841- schedulePosition = func(attempt int) {841+}
842- var cb glib.SourceFunc = func(uintptr) bool {
843- if o.ghostLabel == nil || o.entry == nil {
844- return false
845- }
846 842
847- // Check if input has changed since the autocomplete query was made843+func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) {
848- // This check runs on GTK main thread where entry.GetText() is reliable844+ var cb glib.SourceFunc = func(uintptr) bool {
849- // Skip this check when originalInput is empty (row selection replaces full input)845+ if o.ghostLabel == nil || o.entry == nil {
850- if originalInput != "" && o.entry.GetText() != originalInput {846+ return false
851- // Input changed, skip this stale result - a newer query will handle it847+ }
852- return false848+ if o.shouldSkipGhostUpdate(originalInput) {
853- }849+ return false
850+ }
851+ if !o.ghostPositionReady(attempt) {
852+ o.retryGhostPosition(originalInput, suffix, attempt+1)
853+ return false
854+ }
854 855
855- if attempt < ghostPositionMaxAttempts {856+ marginStart, ok := o.computeGhostMarginStart(originalInput)
856- entryWidth := o.entry.GetAllocatedWidth()857+ if !ok {
857- overlayWidth := 0858+ return false
858- if o.entryOverlay != nil {859+ }
859- overlayWidth = o.entryOverlay.GetAllocatedWidth()860+ o.applyGhostPosition(suffix, marginStart)
860- }861+ return false
861- if entryWidth <= 0 || overlayWidth <= 0 {862+ }
862- retryCb := glib.SourceFunc(func(uintptr) bool {863+ glib.IdleAdd(&cb, 0)
863- schedulePosition(attempt + 1)864+}
864- return false
865- })
866- glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
867- return false
868- }
869- }
870 865
871- // Get the entry's Pango context and create a layout to measure text width866+func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool {
872- pangoCtx := o.entry.GetPangoContext()867+ if originalInput == "" {
873- if pangoCtx == nil {868+ return false
874- return false869+ }
875- }870+ return o.entry.GetText() != originalInput
871+}
876 872
877- // Create layout to measure the input text width873+func (o *Omnibox) ghostPositionReady(attempt int) bool {
878- layout := pango.NewLayout(pangoCtx)874+ if attempt >= ghostPositionMaxAttempts {
879- layout.SetText(originalInput, len(originalInput))875+ return true
880-876+ }
881- // Get the width in pixels877+ entryWidth := o.entry.GetAllocatedWidth()
882- var widthPx, heightPx int878+ overlayWidth := 0
883- layout.GetPixelSize(&widthPx, &heightPx)879+ if o.entryOverlay != nil {
884-880+ overlayWidth = o.entryOverlay.GetAllocatedWidth()
885- // Set the ghost label text (just the suffix) and position it horizontally881+ }
886- o.ghostLabel.SetText(suffix)882+ return entryWidth > 0 && overlayWidth > 0
887-883+}
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- }
905- }
906- }
907 884
908- // Clamp horizontal position to entry bounds885+func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) {
909- if marginStart < 0 {886+ var retryCb glib.SourceFunc = func(uintptr) bool {
910- marginStart = 0887+ o.scheduleGhostPosition(originalInput, suffix, attempt)
911- }888+ return false
912- if o.entryOverlay != nil {889+ }
913- var entryBounds graphene.Rect890+ glib.TimeoutAdd(ghostPositionRetryMs, &retryCb, 0)
914- if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {891+}
915- minStart := int(math.Round(float64(entryBounds.GetX())))892+
916- if marginStart < minStart {893+func (o *Omnibox) computeGhostMarginStart(originalInput string) (int, bool) {
917- marginStart = minStart894+ pangoCtx := o.entry.GetPangoContext()
918- }895+ if pangoCtx == nil {
919- }896+ return 0, false
920- }897+ }
921 898
922- o.ghostLabel.SetMarginStart(marginStart)899+ textLayout := pango.NewLayout(pangoCtx)
923- // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI900+ textLayout.SetText(originalInput, len(originalInput))
924- o.ghostLabel.SetVisible(true)
925 901
926- // Hide placeholder text so it doesn't show through ghost text902+ var widthPx, heightPx int
927- emptyPlaceholder := ""903+ textLayout.GetPixelSize(&widthPx, &heightPx)
928- o.entry.SetPlaceholderText(&emptyPlaceholder)
929 904
930- return false905+ marginStart := widthPx
906+ delegate := o.entry.GetDelegate()
907+ if delegate != nil && o.entryOverlay != nil {
908+ textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer())
909+ if textWidget != nil {
910+ var textBounds graphene.Rect
911+ if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) {
912+ textStart := float64(textBounds.GetX())
913+ cursorPos := uint(utf8.RuneCountInString(originalInput))
914+ var strongRect, weakRect graphene.Rect
915+ textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect)
916+ cursorX := float64(textBounds.GetX() + strongRect.GetX())
917+ marginStart = int(math.Round(cursorX))
918+ if cursorX < textStart {
919+ marginStart = int(math.Round(textStart))
920+ }
921+ }
931 }922 }
932- glib.IdleAdd(&cb, 0)
933 }923 }
934 924
935- schedulePosition(0)925+ if marginStart < 0 {
926+ marginStart = 0
927+ }
928+ if o.entryOverlay != nil {
929+ var entryBounds graphene.Rect
930+ if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) {
931+ minStart := int(math.Round(float64(entryBounds.GetX())))
932+ if marginStart < minStart {
933+ marginStart = minStart
934+ }
935+ }
936+ }
937+
938+ return marginStart, true
939+}
940+
941+func (o *Omnibox) applyGhostPosition(suffix string, marginStart int) {
942+ o.ghostLabel.SetText(suffix)
943+ o.ghostLabel.SetMarginStart(marginStart)
944+ o.ghostLabel.SetVisible(true)
945+ emptyPlaceholder := ""
946+ o.entry.SetPlaceholderText(&emptyPlaceholder)
936 }947 }
937 948
938 // clearGhostText hides the ghost completion text.949 // clearGhostText hides the ghost completion text.

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