refactor: split ghost positioning helpers
- commit
- 01028b0a2d230c3aaa979930d8e20b7dae18a45a
- author
- brice · · 2026-01-26 04:51 UTC
- parent
- dddc3da
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 837 | 837 | o.hasGhostText = true | |
| 838 | 838 | o.mu.Unlock() | |
| 839 | 839 | ||
| 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 | + | } | |
| 846 | 842 | ||
| 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 | + | } | |
| 854 | 855 | ||
| 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 | + | } | |
| 870 | 865 | ||
| 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 | + | } | |
| 876 | 872 | ||
| 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 | + | } | |
| 907 | 884 | ||
| 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 | + | } | |
| 921 | 898 | ||
| 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)) | |
| 925 | 901 | ||
| 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) | |
| 929 | 904 | ||
| 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 | + | } | |
| 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 | 949 | // clearGhostText hides the ghost completion text. | |
| @@ -837,102 +837,113 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 837 | o.hasGhostText = true | 837 | 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 made | 843 | + | func (o *Omnibox) scheduleGhostPosition(originalInput, suffix string, attempt int) { |
| 848 | - | // This check runs on GTK main thread where entry.GetText() is reliable | 844 | + | 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 it | 847 | + | } |
| 852 | - | return false | 848 | + | 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 := 0 | 858 | + | 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 width | 866 | + | func (o *Omnibox) shouldSkipGhostUpdate(originalInput string) bool { |
| 872 | - | pangoCtx := o.entry.GetPangoContext() | 867 | + | if originalInput == "" { |
| 873 | - | if pangoCtx == nil { | 868 | + | return false |
| 874 | - | return false | 869 | + | } |
| 875 | - | } | 870 | + | return o.entry.GetText() != originalInput |
| 871 | + | } | |||
| 876 | 872 | ||||
| 877 | - | // Create layout to measure the input text width | 873 | + | 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 pixels | 877 | + | entryWidth := o.entry.GetAllocatedWidth() |
| 882 | - | var widthPx, heightPx int | 878 | + | 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 horizontally | 881 | + | } |
| 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 bounds | 885 | + | func (o *Omnibox) retryGhostPosition(originalInput, suffix string, attempt int) { |
| 909 | - | if marginStart < 0 { | 886 | + | var retryCb glib.SourceFunc = func(uintptr) bool { |
| 910 | - | marginStart = 0 | 887 | + | o.scheduleGhostPosition(originalInput, suffix, attempt) |
| 911 | - | } | 888 | + | return false |
| 912 | - | if o.entryOverlay != nil { | 889 | + | } |
| 913 | - | var entryBounds graphene.Rect | 890 | + | 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 = minStart | 894 | + | 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 UI | 900 | + | textLayout.SetText(originalInput, len(originalInput)) |
| 924 | - | o.ghostLabel.SetVisible(true) | |||
| 925 | 901 | ||||
| 926 | - | // Hide placeholder text so it doesn't show through ghost text | 902 | + | var widthPx, heightPx int |
| 927 | - | emptyPlaceholder := "" | 903 | + | textLayout.GetPixelSize(&widthPx, &heightPx) |
| 928 | - | o.entry.SetPlaceholderText(&emptyPlaceholder) | |||
| 929 | 904 | ||||
| 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 | + | } | |||
| 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. | ||