detached 18f9d2e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "strings"
78 "sync"
89 "time"
10+ "unicode/utf8"
911
1012 "github.com/bnema/dumber/internal/application/usecase"
1113 "github.com/bnema/dumber/internal/domain/entity"
@@ -16,14 +18,14 @@ import (
1618 "github.com/bnema/dumber/internal/ui/layout"
1719 "github.com/jwijenbergh/puregotk/v4/gdk"
1820 "github.com/jwijenbergh/puregotk/v4/glib"
21+ "github.com/jwijenbergh/puregotk/v4/graphene"
1922 "github.com/jwijenbergh/puregotk/v4/gtk"
2023 "github.com/jwijenbergh/puregotk/v4/pango"
2124 )
2225
2326 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
2729 )
2830
2931 // ViewMode distinguishes history search from favorites display.
@@ -419,7 +421,8 @@ func (o *Omnibox) initEntry() error {
419421 }
420422 o.ghostLabel.AddCssClass("omnibox-ghost")
421423 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)
423426 o.ghostLabel.SetVisible(false)
424427 o.ghostLabel.SetCanTarget(false) // Don't intercept clicks
425428
@@ -770,15 +773,41 @@ func (o *Omnibox) setGhostText(originalInput, suffix, fullText string) {
770773 var widthPx, heightPx int
771774 layout.GetPixelSize(&widthPx, &heightPx)
772775
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
780777 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+
781809 o.ghostLabel.SetMarginStart(marginStart)
810+ // Vertical positioning handled by CSS (.omnibox-ghost) to scale correctly with UI
782811 o.ghostLabel.SetVisible(true)
783812
784813 // 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 = 5027+ debounceDelayMs = 50
25- endBoxSpacing = 628+ 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 clicks427 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 int773 var widthPx, heightPx int
771 layout.GetPixelSize(&widthPx, &heightPx)774 layout.GetPixelSize(&widthPx, &heightPx)
772 775
773- // Account for the search icon inside the entry776+ // 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 text813 // 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 {
414414 .omnibox-ghost {
415415 font-size: 1em;
416416 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 */
420418 background-color: transparent;
421419 /* Allow clicks to pass through to entry below */
422420 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;

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