fix(omnibox): align ghost text vertically with entry text
- Use cursor extents for precise horizontal positioning - Center ghost label vertically to match entry's centered text - Add -0.0625em margin-top nudge for baseline alignment - Remove hardcoded icon offset in favor of computed cursor position
- commit
- 18f9d2edece86fc664ec9e672409986d93a1fef5
- author
- bnema · · 2026-01-23 21:39 UTC
- parent
- 5195a15
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +41 -14
M internal/ui/component/omnibox.go +40 -11
| @@ -3,9 +3,11 @@ package component | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "time" | |
| 10 | + | "unicode/utf8" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -16,14 +18,14 @@ import ( | |||
| 16 | 18 | "github.com/bnema/dumber/internal/ui/layout" | |
| 17 | 19 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 18 | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 21 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |
| 19 | 22 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 20 | 23 | "github.com/jwijenbergh/puregotk/v4/pango" | |
| 21 | 24 | ) | |
| 22 | 25 | ||
| 23 | 26 | const ( | |
| 24 | - | debounceDelayMs = 50 | |
| 25 | - | endBoxSpacing = 6 | |
| 26 | - | ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale) | |
| 27 | + | debounceDelayMs = 50 | |
| 28 | + | endBoxSpacing = 6 | |
| 27 | 29 | ) | |
| 28 | 30 | ||
| 29 | 31 | // ViewMode distinguishes history search from favorites display. | |
| @@ -419,7 +421,8 @@ func (o *Omnibox) initEntry() error { | |||
| 419 | 421 | } | |
| 420 | 422 | o.ghostLabel.AddCssClass("omnibox-ghost") | |
| 421 | 423 | o.ghostLabel.SetHalign(gtk.AlignStartValue) | |
| 422 | - | o.ghostLabel.SetValign(gtk.AlignCenterValue) | |
| 424 | + | o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text | |
| 425 | + | o.ghostLabel.SetXalign(0) | |
| 423 | 426 | o.ghostLabel.SetVisible(false) | |
| 424 | 427 | o.ghostLabel.SetCanTarget(false) // Don't intercept clicks | |
| 425 | 428 | ||
| @@ -770,15 +773,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||
| 770 | 773 | var widthPx, heightPx int | |
| 771 | 774 | layout.GetPixelSize(&widthPx, &heightPx) | |
| 772 | 775 | ||
| 773 | - | // Account for the search icon inside the entry | |
| 774 | - | // Ghost label already has same margin/padding as entry via CSS | |
| 775 | - | // Scale the icon offset by UI scale factor | |
| 776 | - | iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale) | |
| 777 | - | marginStart := widthPx + iconOffset | |
| 778 | - | ||
| 779 | - | // Set the ghost label text (just the suffix) and position it | |
| 776 | + | // Set the ghost label text (just the suffix) and position it horizontally | |
| 780 | 777 | o.ghostLabel.SetText(suffix) | |
| 778 | + | ||
| 779 | + | marginStart := widthPx | |
| 780 | + | delegate := o.entry.GetDelegate() | |
| 781 | + | if delegate != nil && o.entryOverlay != nil { | |
| 782 | + | textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer()) | |
| 783 | + | if textWidget != nil { | |
| 784 | + | var textBounds graphene.Rect | |
| 785 | + | if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) { | |
| 786 | + | cursorPos := uint(utf8.RuneCountInString(originalInput)) | |
| 787 | + | var strongRect, weakRect graphene.Rect | |
| 788 | + | textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect) | |
| 789 | + | cursorX := float64(textBounds.GetX() + strongRect.GetX()) | |
| 790 | + | marginStart = int(math.Round(cursorX)) | |
| 791 | + | } | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | ||
| 795 | + | // Clamp horizontal position to entry bounds | |
| 796 | + | if marginStart < 0 { | |
| 797 | + | marginStart = 0 | |
| 798 | + | } | |
| 799 | + | if o.entryOverlay != nil { | |
| 800 | + | var entryBounds graphene.Rect | |
| 801 | + | if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) { | |
| 802 | + | minStart := int(math.Round(float64(entryBounds.GetX()))) | |
| 803 | + | if marginStart < minStart { | |
| 804 | + | marginStart = minStart | |
| 805 | + | } | |
| 806 | + | } | |
| 807 | + | } | |
| 808 | + | ||
| 781 | 809 | o.ghostLabel.SetMarginStart(marginStart) | |
| 810 | + | // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI | |
| 782 | 811 | o.ghostLabel.SetVisible(true) | |
| 783 | 812 | ||
| 784 | 813 | // Hide placeholder text so it doesn't show through ghost text | |
| @@ -3,9 +3,11 @@ package component | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "time" | 9 | "time" | ||
| 10 | + | "unicode/utf8" | |||
| 9 | 11 | ||||
| 10 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -16,14 +18,14 @@ import ( | |||||
| 16 | "github.com/bnema/dumber/internal/ui/layout" | 18 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 17 | "github.com/jwijenbergh/puregotk/v4/gdk" | 19 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 18 | "github.com/jwijenbergh/puregotk/v4/glib" | 20 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| 21 | + | "github.com/jwijenbergh/puregotk/v4/graphene" | |||
| 19 | "github.com/jwijenbergh/puregotk/v4/gtk" | 22 | "github.com/jwijenbergh/puregotk/v4/gtk" | ||
| 20 | "github.com/jwijenbergh/puregotk/v4/pango" | 23 | "github.com/jwijenbergh/puregotk/v4/pango" | ||
| 21 | ) | 24 | ) | ||
| 22 | 25 | ||||
| 23 | const ( | 26 | const ( | ||
| 24 | - | debounceDelayMs = 50 | 27 | + | debounceDelayMs = 50 |
| 25 | - | endBoxSpacing = 6 | 28 | + | endBoxSpacing = 6 |
| 26 | - | ghostIconOffsetBase = 18 // Base icon offset for ghost text positioning (scaled by uiScale) | |||
| 27 | ) | 29 | ) | ||
| 28 | 30 | ||||
| 29 | // ViewMode distinguishes history search from favorites display. | 31 | // ViewMode distinguishes history search from favorites display. | ||
| @@ -419,7 +421,8 @@ func (o *Omnibox) initEntry() error { | |||||
| 419 | } | 421 | } | ||
| 420 | o.ghostLabel.AddCssClass("omnibox-ghost") | 422 | o.ghostLabel.AddCssClass("omnibox-ghost") | ||
| 421 | o.ghostLabel.SetHalign(gtk.AlignStartValue) | 423 | o.ghostLabel.SetHalign(gtk.AlignStartValue) | ||
| 422 | - | o.ghostLabel.SetValign(gtk.AlignCenterValue) | 424 | + | o.ghostLabel.SetValign(gtk.AlignCenterValue) // Center vertically to match entry's centered text |
| 425 | + | o.ghostLabel.SetXalign(0) | |||
| 423 | o.ghostLabel.SetVisible(false) | 426 | o.ghostLabel.SetVisible(false) | ||
| 424 | o.ghostLabel.SetCanTarget(false) // Don't intercept clicks | 427 | o.ghostLabel.SetCanTarget(false) // Don't intercept clicks | ||
| 425 | 428 | ||||
| @@ -770,15 +773,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) { | |||||
| 770 | var widthPx, heightPx int | 773 | var widthPx, heightPx int | ||
| 771 | layout.GetPixelSize(&widthPx, &heightPx) | 774 | layout.GetPixelSize(&widthPx, &heightPx) | ||
| 772 | 775 | ||||
| 773 | - | // Account for the search icon inside the entry | 776 | + | // Set the ghost label text (just the suffix) and position it horizontally |
| 774 | - | // Ghost label already has same margin/padding as entry via CSS | |||
| 775 | - | // Scale the icon offset by UI scale factor | |||
| 776 | - | iconOffset := int(float64(ghostIconOffsetBase) * o.uiScale) | |||
| 777 | - | marginStart := widthPx + iconOffset | |||
| 778 | - | ||||
| 779 | - | // Set the ghost label text (just the suffix) and position it | |||
| 780 | o.ghostLabel.SetText(suffix) | 777 | o.ghostLabel.SetText(suffix) | ||
| 778 | + | ||||
| 779 | + | marginStart := widthPx | |||
| 780 | + | delegate := o.entry.GetDelegate() | |||
| 781 | + | if delegate != nil && o.entryOverlay != nil { | |||
| 782 | + | textWidget := gtk.TextNewFromInternalPtr(delegate.GoPointer()) | |||
| 783 | + | if textWidget != nil { | |||
| 784 | + | var textBounds graphene.Rect | |||
| 785 | + | if textWidget.ComputeBounds(&o.entryOverlay.Widget, &textBounds) { | |||
| 786 | + | cursorPos := uint(utf8.RuneCountInString(originalInput)) | |||
| 787 | + | var strongRect, weakRect graphene.Rect | |||
| 788 | + | textWidget.ComputeCursorExtents(cursorPos, &strongRect, &weakRect) | |||
| 789 | + | cursorX := float64(textBounds.GetX() + strongRect.GetX()) | |||
| 790 | + | marginStart = int(math.Round(cursorX)) | |||
| 791 | + | } | |||
| 792 | + | } | |||
| 793 | + | } | |||
| 794 | + | ||||
| 795 | + | // Clamp horizontal position to entry bounds | |||
| 796 | + | if marginStart < 0 { | |||
| 797 | + | marginStart = 0 | |||
| 798 | + | } | |||
| 799 | + | if o.entryOverlay != nil { | |||
| 800 | + | var entryBounds graphene.Rect | |||
| 801 | + | if o.entry.ComputeBounds(&o.entryOverlay.Widget, &entryBounds) { | |||
| 802 | + | minStart := int(math.Round(float64(entryBounds.GetX()))) | |||
| 803 | + | if marginStart < minStart { | |||
| 804 | + | marginStart = minStart | |||
| 805 | + | } | |||
| 806 | + | } | |||
| 807 | + | } | |||
| 808 | + | ||||
| 781 | o.ghostLabel.SetMarginStart(marginStart) | 809 | o.ghostLabel.SetMarginStart(marginStart) | ||
| 810 | + | // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI | |||
| 782 | o.ghostLabel.SetVisible(true) | 811 | o.ghostLabel.SetVisible(true) | ||
| 783 | 812 | ||||
| 784 | // Hide placeholder text so it doesn't show through ghost text | 813 | // Hide placeholder text so it doesn't show through ghost text | ||
M internal/ui/theme/css.go +1 -3
| @@ -414,9 +414,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |||
| 414 | 414 | .omnibox-ghost { | |
| 415 | 415 | font-size: 1em; | |
| 416 | 416 | color: var(--muted); | |
| 417 | - | padding: 0.625em 0.75em; | |
| 418 | - | margin: 0.5em 0.75em; | |
| 419 | - | /* Match the entry padding/margin exactly for alignment */ | |
| 417 | + | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | |
| 420 | 418 | background-color: transparent; | |
| 421 | 419 | /* Allow clicks to pass through to entry below */ | |
| 422 | 420 | pointer-events: none; | |
| @@ -414,9 +414,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible { | |||||
| 414 | .omnibox-ghost { | 414 | .omnibox-ghost { | ||
| 415 | font-size: 1em; | 415 | font-size: 1em; | ||
| 416 | color: var(--muted); | 416 | color: var(--muted); | ||
| 417 | - | padding: 0.625em 0.75em; | 417 | + | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ |
| 418 | - | margin: 0.5em 0.75em; | |||
| 419 | - | /* Match the entry padding/margin exactly for alignment */ | |||
| 420 | background-color: transparent; | 418 | background-color: transparent; | ||
| 421 | /* Allow clicks to pass through to entry below */ | 419 | /* Allow clicks to pass through to entry below */ | ||
| 422 | pointer-events: none; | 420 | pointer-events: none; | ||