detached 75c1a27

feat(omnibox): implement dynamic height calculation using GTK4 Measure API

commit
75c1a27c6c30dfcd8e84d19024f45150faf22627
author
bnema · · 2025-12-16 21:40 UTC
parent
a62999c
signature
unsigned
1 file · +199 -68
M internal/ui/component/omnibox.go +199 -68
@@ -18,10 +18,10 @@ import (
1818 )
1919
2020 const (
21- omniboxWidthPct = 0.5 // 50% of parent window width
21+ omniboxWidthPct = 0.8 // 80% of parent window width
2222 omniboxHeightPct = 0.6 // 60% of parent window height
2323 omniboxMaxResults = 10
24- debounceDelayMs = 250 // Increased for stability
24+ debounceDelayMs = 150
2525 )
2626
2727 // ViewMode distinguishes history search from favorites display.
@@ -51,8 +51,8 @@ type Favorite struct {
5151 // Omnibox is the native GTK4 address bar / command palette.
5252 type Omnibox struct {
5353 // GTK widgets
54- window *gtk.Window
55- mainBox *gtk.Box
54+ outerBox *gtk.Box // Outer container for positioning
55+ mainBox *gtk.Box // Main content box
5656 headerBox *gtk.Box
5757 historyBtn *gtk.Button
5858 favoritesBtn *gtk.Button
@@ -61,7 +61,7 @@ type Omnibox struct {
6161 scrolledWin *gtk.ScrolledWindow
6262 listBox *gtk.ListBox
6363
64- // Parent window reference for positioning
64+ // Parent window reference for sizing
6565 parentWindow *gtk.ApplicationWindow
6666
6767 // State
@@ -92,6 +92,14 @@ type Omnibox struct {
9292
9393 // Scaling
9494 uiScale float64
95+
96+ // Cached measurements (populated after first layout)
97+ measuredHeights struct {
98+ header int // headerBox natural height
99+ entry int // entry natural height
100+ singleRow int // single ListBoxRow natural height
101+ valid bool // whether cache is valid
102+ }
95103 }
96104
97105 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -149,7 +157,7 @@ func (o *Omnibox) updateSize() {
149157 // resizeAndCenter adjusts the omnibox size based on content and centers it.
150158 // rowCount is the number of result rows to display (0 = no content, max 10).
151159 func (o *Omnibox) resizeAndCenter(rowCount int) {
152- if o.parentWindow == nil || o.window == nil || o.mainBox == nil {
160+ if o.parentWindow == nil || o.outerBox == nil || o.mainBox == nil {
153161 return
154162 }
155163
@@ -168,69 +176,86 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
168176
169177 width := int(float64(parentWidth) * omniboxWidthPct)
170178
171- // Base height: header (~40px) + entry (~50px) at scale 1.0
172- baseHeight := int(90 * o.uiScale)
173- // Row height: approximately 50px per row at scale 1.0 (two lines: title + URL)
174- rowHeight := int(50 * o.uiScale)
175-
176179 // Cap at max results
177180 if rowCount > omniboxMaxResults {
178181 rowCount = omniboxMaxResults
179182 }
180183
181- height := baseHeight + (rowCount * rowHeight)
184+ // Schedule measurement after GTK has laid out widgets
185+ // This ensures we get accurate heights from actual rendered content
186+ var cb glib.SourceFunc = func(uintptr) bool {
187+ o.measureAndResize(width, rowCount)
188+ return false // One-shot
189+ }
190+ glib.IdleAdd(&cb, 0)
191+}
192+
193+// measureAndResize calculates and sets the omnibox height based on row count.
194+// Uses measured widget heights when available, falls back to estimates.
195+func (o *Omnibox) measureAndResize(width, rowCount int) {
196+ if o.outerBox == nil || o.mainBox == nil {
197+ return
198+ }
182199
183- o.window.SetDefaultSize(width, height)
184- o.mainBox.SetSizeRequest(width, height)
200+ log := logging.FromContext(o.ctx)
185201
186- // Center the window on the parent
187- x := (parentWidth - width) / 2
188- y := (parentHeight - height) / 2
202+ var rowHeight int
189203
190- // Get parent window position (for Wayland this may be 0,0)
191- // For a transient/modal window, GTK should handle relative positioning
192- // We need to use a surface-based approach for proper centering
193- if surface := o.window.GetSurface(); surface != nil {
194- // Request the window manager to center it
195- // Note: On Wayland, window positioning is limited
204+ if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
205+ // Use measured values from GTK4 Measure API
206+ rowHeight = o.measuredHeights.singleRow
207+ } else {
208+ // Fallback to hardcoded estimate (before first measurement)
209+ rowHeight = int(60 * o.uiScale)
196210 }
197211
198- // For GTK4 popup positioning, we rely on the transient relationship
199- // The window should appear centered relative to parent automatically
200- // But we can hint at position using the allocation
201- o.window.SetDefaultSize(width, height)
212+ // Calculate content height for the scrolled window
213+ // This is the key to dynamic sizing - limit the ScrolledWindow's max height
214+ contentHeight := 0
215+ if rowCount > 0 {
216+ contentHeight = rowCount * rowHeight
217+ }
218+
219+ // Cap at max results
220+ maxContentHeight := omniboxMaxResults * rowHeight
221+ if contentHeight > maxContentHeight {
222+ contentHeight = maxContentHeight
223+ }
224+
225+ // Set size constraints on ScrolledWindow to control dynamic sizing
226+ if o.scrolledWin != nil {
227+ // Reset min first to avoid assertion failure when shrinking
228+ // GTK requires min <= max, so we reset min before setting new values
229+ o.scrolledWin.SetMinContentHeight(-1)
230+ o.scrolledWin.SetMaxContentHeight(contentHeight)
231+ o.scrolledWin.SetMinContentHeight(contentHeight)
232+ }
233+
234+ // Force layout recalculation
235+ o.outerBox.QueueResize()
202236
203- log := logging.FromContext(o.ctx)
204237 log.Debug().
205238 Int("width", width).
206- Int("height", height).
207- Int("x", x).
208- Int("y", y).
239+ Int("contentHeight", contentHeight).
240+ Int("rowHeight", rowHeight).
209241 Int("rows", rowCount).
242+ Bool("measured", o.measuredHeights.valid).
210243 Msg("omnibox resized")
211244 }
212245
213246 // createWidgets builds the GTK widget hierarchy.
214247 func (o *Omnibox) createWidgets() error {
215- // Create the popup window
216- o.window = gtk.NewWindow()
217- if o.window == nil {
218- return errNilWidget("window")
248+ // Create outer container for positioning (will be added to an overlay)
249+ o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
250+ if o.outerBox == nil {
251+ return errNilWidget("outerBox")
219252 }
253+ o.outerBox.AddCssClass("omnibox-outer")
254+ o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
255+ o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
256+ o.outerBox.SetVisible(false) // Hidden by default
220257
221- o.window.SetModal(true)
222- o.window.SetDecorated(false)
223- o.window.AddCssClass("omnibox-window")
224-
225- // Set transient for parent window (positioning)
226- if o.parentWindow != nil {
227- o.window.SetTransientFor(&o.parentWindow.Window)
228- } else {
229- log := logging.FromContext(o.ctx)
230- log.Error().Msg("omnibox: parent window is nil, cannot set transient")
231- }
232-
233- // Main vertical box
258+ // Main content box
234259 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
235260 if o.mainBox == nil {
236261 return errNilWidget("mainBox")
@@ -305,6 +330,8 @@ func (o *Omnibox) createWidgets() error {
305330 o.scrolledWin.AddCssClass("omnibox-scrolled")
306331 o.scrolledWin.SetVexpand(true)
307332 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
333+ // Let GTK handle natural sizing - propagate child height up to max
334+ o.scrolledWin.SetPropagateNaturalHeight(true)
308335
309336 // List box for suggestions
310337 o.listBox = gtk.NewListBox()
@@ -336,7 +363,7 @@ func (o *Omnibox) createWidgets() error {
336363 o.mainBox.Append(&o.headerBox.Widget)
337364 o.mainBox.Append(&o.entry.Widget)
338365 o.mainBox.Append(&o.scrolledWin.Widget)
339- o.window.SetChild(&o.mainBox.Widget)
366+ o.outerBox.Append(&o.mainBox.Widget)
340367
341368 return nil
342369 }
@@ -358,7 +385,7 @@ func (o *Omnibox) setupKeyboardHandling() {
358385 }
359386 controller.ConnectKeyPressed(&keyPressedCb)
360387
361- o.window.AddController(&controller.EventController)
388+ o.outerBox.AddController(&controller.EventController)
362389 }
363390
364391 // setupEntryChanged wires entry text changes to debounced search.
@@ -648,6 +675,31 @@ func (o *Omnibox) rebuildList() {
648675 }
649676 }
650677 }
678+
679+ // Schedule measurement if not yet cached and we have rows
680+ if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
681+ var cb glib.SourceFunc = func(uintptr) bool {
682+ width := o.parentWindow.GetAllocatedWidth()
683+ if width <= 0 {
684+ return false // Window not allocated yet, skip
685+ }
686+ forWidth := int(float64(width) * omniboxWidthPct)
687+ if o.measureComponentHeights(forWidth) {
688+ // Re-trigger resize with accurate measurements
689+ o.mu.RLock()
690+ var count int
691+ if o.viewMode == ViewModeHistory {
692+ count = len(o.suggestions)
693+ } else {
694+ count = len(o.favorites)
695+ }
696+ o.mu.RUnlock()
697+ o.measureAndResize(forWidth, count)
698+ }
699+ return false
700+ }
701+ glib.IdleAdd(&cb, 0)
702+ }
651703 }
652704
653705 // createFaviconImage creates a favicon image with async loading from cache.
@@ -826,7 +878,8 @@ func (o *Omnibox) selectAndNavigate(index int) {
826878 o.navigateToSelected()
827879 }
828880
829-// navigateToSelected navigates to the currently selected item.
881+// navigateToSelected navigates to the currently selected item or typed URL.
882+// If the user typed a URL-like string, prioritize navigating to that directly.
830883 func (o *Omnibox) navigateToSelected() {
831884 o.mu.RLock()
832885 mode := o.viewMode
@@ -835,11 +888,16 @@ func (o *Omnibox) navigateToSelected() {
835888 favorites := o.favorites
836889 o.mu.RUnlock()
837890
891+ entryText := o.entry.GetText()
838892 var url string
839893
840- if idx < 0 {
841- // No selection - use entry text as URL
842- url = o.buildURL(o.entry.GetText())
894+ // Check if user typed a URL-like string (contains . and no spaces)
895+ // If so, navigate to that URL directly instead of the selection
896+ if o.looksLikeURL(entryText) {
897+ url = o.buildURL(entryText)
898+ } else if idx < 0 {
899+ // No selection - use entry text as URL/search
900+ url = o.buildURL(entryText)
843901 } else if mode == ViewModeHistory {
844902 if idx < len(suggestions) {
845903 url = suggestions[idx].URL
@@ -861,6 +919,16 @@ func (o *Omnibox) navigateToSelected() {
861919 }
862920 }
863921
922+// looksLikeURL checks if the input appears to be a URL (not a search query).
923+// Returns true for strings like "github.com", "google.com/search", etc.
924+func (o *Omnibox) looksLikeURL(input string) bool {
925+ if input == "" {
926+ return false
927+ }
928+ // Contains a dot and no spaces = likely a URL
929+ return strings.Contains(input, ".") && !strings.Contains(input, " ")
930+}
931+
864932 // toggleFavorite adds or removes the selected item from favorites.
865933 // In History mode: adds the selected item to favorites
866934 // In Favorites mode: removes the selected item from favorites
@@ -1030,18 +1098,25 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
10301098 // Hide scrolled window if no content expected
10311099 if o.scrolledWin != nil {
10321100 o.scrolledWin.SetVisible(expectContent)
1101+ // Reset content height constraints - will be updated when results arrive
1102+ o.scrolledWin.SetMinContentHeight(-1)
1103+ o.scrolledWin.SetMaxContentHeight(0)
10331104 }
10341105
1035- // Initial size: 0 rows if no content expected, max rows otherwise
1036- // (will be updated by search results)
1037- initialRows := 0
1038- if expectContent {
1039- initialRows = omniboxMaxResults
1040- }
1041- o.resizeAndCenter(initialRows)
1106+ // Set width and vertical positioning (20% from top)
1107+ parentWidth := o.parentWindow.GetAllocatedWidth()
1108+ parentHeight := o.parentWindow.GetAllocatedHeight()
1109+ width := int(float64(parentWidth) * omniboxWidthPct)
1110+ marginTop := int(float64(parentHeight) * 0.20) // 20% from top
1111+
1112+ o.mainBox.SetSizeRequest(width, -1)
1113+ o.outerBox.SetMarginTop(marginTop)
10421114
1043- // Present the window
1044- o.window.Present()
1115+ // Show the omnibox
1116+ o.outerBox.SetVisible(true)
1117+
1118+ // Trigger initial resize (will be updated when results arrive)
1119+ o.resizeAndCenter(0)
10451120
10461121 // Focus the entry
10471122 o.entry.GrabFocus()
@@ -1071,8 +1146,8 @@ func (o *Omnibox) Hide(ctx context.Context) {
10711146 }
10721147 o.debounceMu.Unlock()
10731148
1074- // Hide window
1075- o.window.SetVisible(false)
1149+ // Hide omnibox
1150+ o.outerBox.SetVisible(false)
10761151
10771152 // Clear state
10781153 o.entry.SetText("")
@@ -1150,6 +1225,14 @@ func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
11501225 glib.IdleAdd(&cb, 0)
11511226 }
11521227
1228+// Widget returns the omnibox widget for embedding in an overlay.
1229+func (o *Omnibox) Widget() *gtk.Widget {
1230+ if o.outerBox == nil {
1231+ return nil
1232+ }
1233+ return &o.outerBox.Widget
1234+}
1235+
11531236 // Destroy cleans up omnibox resources.
11541237 func (o *Omnibox) Destroy() {
11551238 o.debounceMu.Lock()
@@ -1158,9 +1241,9 @@ func (o *Omnibox) Destroy() {
11581241 }
11591242 o.debounceMu.Unlock()
11601243
1161- if o.window != nil {
1162- o.window.Destroy()
1163- o.window = nil
1244+ if o.outerBox != nil {
1245+ o.outerBox.Unparent()
1246+ o.outerBox = nil
11641247 }
11651248 }
11661249
@@ -1169,6 +1252,54 @@ func formatShortcut(n int) string {
11691252 return "Ctrl+" + string(rune('0'+n))
11701253 }
11711254
1255+// measureWidgetHeight returns the natural height of a widget for the given width.
1256+// Returns 0 if widget is nil or measurement fails.
1257+func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
1258+ if widget == nil {
1259+ return 0
1260+ }
1261+ var minHeight, naturalHeight, minBaseline, naturalBaseline int
1262+ widget.Measure(
1263+ gtk.OrientationVerticalValue,
1264+ forWidth,
1265+ &minHeight,
1266+ &naturalHeight,
1267+ &minBaseline,
1268+ &naturalBaseline,
1269+ )
1270+ return naturalHeight
1271+}
1272+
1273+// measureComponentHeights measures and caches all component heights.
1274+// Must be called on GTK main thread after widgets have been laid out.
1275+func (o *Omnibox) measureComponentHeights(forWidth int) bool {
1276+ if o.headerBox == nil || o.entry == nil || o.listBox == nil {
1277+ return false
1278+ }
1279+
1280+ headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
1281+ entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
1282+
1283+ // Measure first row if it exists
1284+ var rowH int
1285+ if row := o.listBox.GetRowAtIndex(0); row != nil {
1286+ rowH = measureWidgetHeight(&row.Widget, forWidth)
1287+ }
1288+
1289+ // Validate measurements (header and entry must be positive)
1290+ if headerH <= 0 || entryH <= 0 {
1291+ return false
1292+ }
1293+
1294+ o.measuredHeights.header = headerH
1295+ o.measuredHeights.entry = entryH
1296+ if rowH > 0 {
1297+ o.measuredHeights.singleRow = rowH
1298+ o.measuredHeights.valid = true
1299+ }
1300+ return true
1301+}
1302+
11721303 // errNilWidget creates an error for nil widget creation.
11731304 func errNilWidget(name string) error {
11741305 return &widgetError{name: name}
@@ -18,10 +18,10 @@ import (
18 )18 )
19 19
20 const (20 const (
21- omniboxWidthPct = 0.5 // 50% of parent window width21+ omniboxWidthPct = 0.8 // 80% of parent window width
22 omniboxHeightPct = 0.6 // 60% of parent window height22 omniboxHeightPct = 0.6 // 60% of parent window height
23 omniboxMaxResults = 1023 omniboxMaxResults = 10
24- debounceDelayMs = 250 // Increased for stability24+ debounceDelayMs = 150
25 )25 )
26 26
27 // ViewMode distinguishes history search from favorites display.27 // ViewMode distinguishes history search from favorites display.
@@ -51,8 +51,8 @@ type Favorite struct {
51 // Omnibox is the native GTK4 address bar / command palette.51 // Omnibox is the native GTK4 address bar / command palette.
52 type Omnibox struct {52 type Omnibox struct {
53 // GTK widgets53 // GTK widgets
54- window *gtk.Window54+ outerBox *gtk.Box // Outer container for positioning
55- mainBox *gtk.Box55+ mainBox *gtk.Box // Main content box
56 headerBox *gtk.Box56 headerBox *gtk.Box
57 historyBtn *gtk.Button57 historyBtn *gtk.Button
58 favoritesBtn *gtk.Button58 favoritesBtn *gtk.Button
@@ -61,7 +61,7 @@ type Omnibox struct {
61 scrolledWin *gtk.ScrolledWindow61 scrolledWin *gtk.ScrolledWindow
62 listBox *gtk.ListBox62 listBox *gtk.ListBox
63 63
64- // Parent window reference for positioning64+ // Parent window reference for sizing
65 parentWindow *gtk.ApplicationWindow65 parentWindow *gtk.ApplicationWindow
66 66
67 // State67 // State
@@ -92,6 +92,14 @@ type Omnibox struct {
92 92
93 // Scaling93 // Scaling
94 uiScale float6494 uiScale float64
95+
96+ // Cached measurements (populated after first layout)
97+ measuredHeights struct {
98+ header int // headerBox natural height
99+ entry int // entry natural height
100+ singleRow int // single ListBoxRow natural height
101+ valid bool // whether cache is valid
102+ }
95 }103 }
96 104
97 // OmniboxConfig holds configuration for creating an Omnibox.105 // OmniboxConfig holds configuration for creating an Omnibox.
@@ -149,7 +157,7 @@ func (o *Omnibox) updateSize() {
149 // resizeAndCenter adjusts the omnibox size based on content and centers it.157 // resizeAndCenter adjusts the omnibox size based on content and centers it.
150 // rowCount is the number of result rows to display (0 = no content, max 10).158 // rowCount is the number of result rows to display (0 = no content, max 10).
151 func (o *Omnibox) resizeAndCenter(rowCount int) {159 func (o *Omnibox) resizeAndCenter(rowCount int) {
152- if o.parentWindow == nil || o.window == nil || o.mainBox == nil {160+ if o.parentWindow == nil || o.outerBox == nil || o.mainBox == nil {
153 return161 return
154 }162 }
155 163
@@ -168,69 +176,86 @@ func (o *Omnibox) resizeAndCenter(rowCount int) {
168 176
169 width := int(float64(parentWidth) * omniboxWidthPct)177 width := int(float64(parentWidth) * omniboxWidthPct)
170 178
171- // Base height: header (~40px) + entry (~50px) at scale 1.0
172- baseHeight := int(90 * o.uiScale)
173- // Row height: approximately 50px per row at scale 1.0 (two lines: title + URL)
174- rowHeight := int(50 * o.uiScale)
175-
176 // Cap at max results179 // Cap at max results
177 if rowCount > omniboxMaxResults {180 if rowCount > omniboxMaxResults {
178 rowCount = omniboxMaxResults181 rowCount = omniboxMaxResults
179 }182 }
180 183
181- height := baseHeight + (rowCount * rowHeight)184+ // Schedule measurement after GTK has laid out widgets
185+ // This ensures we get accurate heights from actual rendered content
186+ var cb glib.SourceFunc = func(uintptr) bool {
187+ o.measureAndResize(width, rowCount)
188+ return false // One-shot
189+ }
190+ glib.IdleAdd(&cb, 0)
191+}
192+
193+// measureAndResize calculates and sets the omnibox height based on row count.
194+// Uses measured widget heights when available, falls back to estimates.
195+func (o *Omnibox) measureAndResize(width, rowCount int) {
196+ if o.outerBox == nil || o.mainBox == nil {
197+ return
198+ }
182 199
183- o.window.SetDefaultSize(width, height)200+ log := logging.FromContext(o.ctx)
184- o.mainBox.SetSizeRequest(width, height)
185 201
186- // Center the window on the parent202+ var rowHeight int
187- x := (parentWidth - width) / 2
188- y := (parentHeight - height) / 2
189 203
190- // Get parent window position (for Wayland this may be 0,0)204+ if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 {
191- // For a transient/modal window, GTK should handle relative positioning205+ // Use measured values from GTK4 Measure API
192- // We need to use a surface-based approach for proper centering206+ rowHeight = o.measuredHeights.singleRow
193- if surface := o.window.GetSurface(); surface != nil {207+ } else {
194- // Request the window manager to center it208+ // Fallback to hardcoded estimate (before first measurement)
195- // Note: On Wayland, window positioning is limited209+ rowHeight = int(60 * o.uiScale)
196 }210 }
197 211
198- // For GTK4 popup positioning, we rely on the transient relationship212+ // Calculate content height for the scrolled window
199- // The window should appear centered relative to parent automatically213+ // This is the key to dynamic sizing - limit the ScrolledWindow's max height
200- // But we can hint at position using the allocation214+ contentHeight := 0
201- o.window.SetDefaultSize(width, height)215+ if rowCount > 0 {
216+ contentHeight = rowCount * rowHeight
217+ }
218+
219+ // Cap at max results
220+ maxContentHeight := omniboxMaxResults * rowHeight
221+ if contentHeight > maxContentHeight {
222+ contentHeight = maxContentHeight
223+ }
224+
225+ // Set size constraints on ScrolledWindow to control dynamic sizing
226+ if o.scrolledWin != nil {
227+ // Reset min first to avoid assertion failure when shrinking
228+ // GTK requires min <= max, so we reset min before setting new values
229+ o.scrolledWin.SetMinContentHeight(-1)
230+ o.scrolledWin.SetMaxContentHeight(contentHeight)
231+ o.scrolledWin.SetMinContentHeight(contentHeight)
232+ }
233+
234+ // Force layout recalculation
235+ o.outerBox.QueueResize()
202 236
203- log := logging.FromContext(o.ctx)
204 log.Debug().237 log.Debug().
205 Int("width", width).238 Int("width", width).
206- Int("height", height).239+ Int("contentHeight", contentHeight).
207- Int("x", x).240+ Int("rowHeight", rowHeight).
208- Int("y", y).
209 Int("rows", rowCount).241 Int("rows", rowCount).
242+ Bool("measured", o.measuredHeights.valid).
210 Msg("omnibox resized")243 Msg("omnibox resized")
211 }244 }
212 245
213 // createWidgets builds the GTK widget hierarchy.246 // createWidgets builds the GTK widget hierarchy.
214 func (o *Omnibox) createWidgets() error {247 func (o *Omnibox) createWidgets() error {
215- // Create the popup window248+ // Create outer container for positioning (will be added to an overlay)
216- o.window = gtk.NewWindow()249+ o.outerBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
217- if o.window == nil {250+ if o.outerBox == nil {
218- return errNilWidget("window")251+ return errNilWidget("outerBox")
219 }252 }
253+ o.outerBox.AddCssClass("omnibox-outer")
254+ o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
255+ o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
256+ o.outerBox.SetVisible(false) // Hidden by default
220 257
221- o.window.SetModal(true)258+ // Main content box
222- o.window.SetDecorated(false)
223- o.window.AddCssClass("omnibox-window")
224-
225- // Set transient for parent window (positioning)
226- if o.parentWindow != nil {
227- o.window.SetTransientFor(&o.parentWindow.Window)
228- } else {
229- log := logging.FromContext(o.ctx)
230- log.Error().Msg("omnibox: parent window is nil, cannot set transient")
231- }
232-
233- // Main vertical box
234 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)259 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
235 if o.mainBox == nil {260 if o.mainBox == nil {
236 return errNilWidget("mainBox")261 return errNilWidget("mainBox")
@@ -305,6 +330,8 @@ func (o *Omnibox) createWidgets() error {
305 o.scrolledWin.AddCssClass("omnibox-scrolled")330 o.scrolledWin.AddCssClass("omnibox-scrolled")
306 o.scrolledWin.SetVexpand(true)331 o.scrolledWin.SetVexpand(true)
307 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)332 o.scrolledWin.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
333+ // Let GTK handle natural sizing - propagate child height up to max
334+ o.scrolledWin.SetPropagateNaturalHeight(true)
308 335
309 // List box for suggestions336 // List box for suggestions
310 o.listBox = gtk.NewListBox()337 o.listBox = gtk.NewListBox()
@@ -336,7 +363,7 @@ func (o *Omnibox) createWidgets() error {
336 o.mainBox.Append(&o.headerBox.Widget)363 o.mainBox.Append(&o.headerBox.Widget)
337 o.mainBox.Append(&o.entry.Widget)364 o.mainBox.Append(&o.entry.Widget)
338 o.mainBox.Append(&o.scrolledWin.Widget)365 o.mainBox.Append(&o.scrolledWin.Widget)
339- o.window.SetChild(&o.mainBox.Widget)366+ o.outerBox.Append(&o.mainBox.Widget)
340 367
341 return nil368 return nil
342 }369 }
@@ -358,7 +385,7 @@ func (o *Omnibox) setupKeyboardHandling() {
358 }385 }
359 controller.ConnectKeyPressed(&keyPressedCb)386 controller.ConnectKeyPressed(&keyPressedCb)
360 387
361- o.window.AddController(&controller.EventController)388+ o.outerBox.AddController(&controller.EventController)
362 }389 }
363 390
364 // setupEntryChanged wires entry text changes to debounced search.391 // setupEntryChanged wires entry text changes to debounced search.
@@ -648,6 +675,31 @@ func (o *Omnibox) rebuildList() {
648 }675 }
649 }676 }
650 }677 }
678+
679+ // Schedule measurement if not yet cached and we have rows
680+ if !o.measuredHeights.valid && o.listBox.GetRowAtIndex(0) != nil {
681+ var cb glib.SourceFunc = func(uintptr) bool {
682+ width := o.parentWindow.GetAllocatedWidth()
683+ if width <= 0 {
684+ return false // Window not allocated yet, skip
685+ }
686+ forWidth := int(float64(width) * omniboxWidthPct)
687+ if o.measureComponentHeights(forWidth) {
688+ // Re-trigger resize with accurate measurements
689+ o.mu.RLock()
690+ var count int
691+ if o.viewMode == ViewModeHistory {
692+ count = len(o.suggestions)
693+ } else {
694+ count = len(o.favorites)
695+ }
696+ o.mu.RUnlock()
697+ o.measureAndResize(forWidth, count)
698+ }
699+ return false
700+ }
701+ glib.IdleAdd(&cb, 0)
702+ }
651 }703 }
652 704
653 // createFaviconImage creates a favicon image with async loading from cache.705 // createFaviconImage creates a favicon image with async loading from cache.
@@ -826,7 +878,8 @@ func (o *Omnibox) selectAndNavigate(index int) {
826 o.navigateToSelected()878 o.navigateToSelected()
827 }879 }
828 880
829-// navigateToSelected navigates to the currently selected item.881+// navigateToSelected navigates to the currently selected item or typed URL.
882+// If the user typed a URL-like string, prioritize navigating to that directly.
830 func (o *Omnibox) navigateToSelected() {883 func (o *Omnibox) navigateToSelected() {
831 o.mu.RLock()884 o.mu.RLock()
832 mode := o.viewMode885 mode := o.viewMode
@@ -835,11 +888,16 @@ func (o *Omnibox) navigateToSelected() {
835 favorites := o.favorites888 favorites := o.favorites
836 o.mu.RUnlock()889 o.mu.RUnlock()
837 890
891+ entryText := o.entry.GetText()
838 var url string892 var url string
839 893
840- if idx < 0 {894+ // Check if user typed a URL-like string (contains . and no spaces)
841- // No selection - use entry text as URL895+ // If so, navigate to that URL directly instead of the selection
842- url = o.buildURL(o.entry.GetText())896+ if o.looksLikeURL(entryText) {
897+ url = o.buildURL(entryText)
898+ } else if idx < 0 {
899+ // No selection - use entry text as URL/search
900+ url = o.buildURL(entryText)
843 } else if mode == ViewModeHistory {901 } else if mode == ViewModeHistory {
844 if idx < len(suggestions) {902 if idx < len(suggestions) {
845 url = suggestions[idx].URL903 url = suggestions[idx].URL
@@ -861,6 +919,16 @@ func (o *Omnibox) navigateToSelected() {
861 }919 }
862 }920 }
863 921
922+// looksLikeURL checks if the input appears to be a URL (not a search query).
923+// Returns true for strings like "github.com", "google.com/search", etc.
924+func (o *Omnibox) looksLikeURL(input string) bool {
925+ if input == "" {
926+ return false
927+ }
928+ // Contains a dot and no spaces = likely a URL
929+ return strings.Contains(input, ".") && !strings.Contains(input, " ")
930+}
931+
864 // toggleFavorite adds or removes the selected item from favorites.932 // toggleFavorite adds or removes the selected item from favorites.
865 // In History mode: adds the selected item to favorites933 // In History mode: adds the selected item to favorites
866 // In Favorites mode: removes the selected item from favorites934 // In Favorites mode: removes the selected item from favorites
@@ -1030,18 +1098,25 @@ func (o *Omnibox) Show(ctx context.Context, query string) {
1030 // Hide scrolled window if no content expected1098 // Hide scrolled window if no content expected
1031 if o.scrolledWin != nil {1099 if o.scrolledWin != nil {
1032 o.scrolledWin.SetVisible(expectContent)1100 o.scrolledWin.SetVisible(expectContent)
1101+ // Reset content height constraints - will be updated when results arrive
1102+ o.scrolledWin.SetMinContentHeight(-1)
1103+ o.scrolledWin.SetMaxContentHeight(0)
1033 }1104 }
1034 1105
1035- // Initial size: 0 rows if no content expected, max rows otherwise1106+ // Set width and vertical positioning (20% from top)
1036- // (will be updated by search results)1107+ parentWidth := o.parentWindow.GetAllocatedWidth()
1037- initialRows := 01108+ parentHeight := o.parentWindow.GetAllocatedHeight()
1038- if expectContent {1109+ width := int(float64(parentWidth) * omniboxWidthPct)
1039- initialRows = omniboxMaxResults1110+ marginTop := int(float64(parentHeight) * 0.20) // 20% from top
1040- }1111+
1041- o.resizeAndCenter(initialRows)1112+ o.mainBox.SetSizeRequest(width, -1)
1113+ o.outerBox.SetMarginTop(marginTop)
1042 1114
1043- // Present the window1115+ // Show the omnibox
1044- o.window.Present()1116+ o.outerBox.SetVisible(true)
1117+
1118+ // Trigger initial resize (will be updated when results arrive)
1119+ o.resizeAndCenter(0)
1045 1120
1046 // Focus the entry1121 // Focus the entry
1047 o.entry.GrabFocus()1122 o.entry.GrabFocus()
@@ -1071,8 +1146,8 @@ func (o *Omnibox) Hide(ctx context.Context) {
1071 }1146 }
1072 o.debounceMu.Unlock()1147 o.debounceMu.Unlock()
1073 1148
1074- // Hide window1149+ // Hide omnibox
1075- o.window.SetVisible(false)1150+ o.outerBox.SetVisible(false)
1076 1151
1077 // Clear state1152 // Clear state
1078 o.entry.SetText("")1153 o.entry.SetText("")
@@ -1150,6 +1225,14 @@ func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) {
1150 glib.IdleAdd(&cb, 0)1225 glib.IdleAdd(&cb, 0)
1151 }1226 }
1152 1227
1228+// Widget returns the omnibox widget for embedding in an overlay.
1229+func (o *Omnibox) Widget() *gtk.Widget {
1230+ if o.outerBox == nil {
1231+ return nil
1232+ }
1233+ return &o.outerBox.Widget
1234+}
1235+
1153 // Destroy cleans up omnibox resources.1236 // Destroy cleans up omnibox resources.
1154 func (o *Omnibox) Destroy() {1237 func (o *Omnibox) Destroy() {
1155 o.debounceMu.Lock()1238 o.debounceMu.Lock()
@@ -1158,9 +1241,9 @@ func (o *Omnibox) Destroy() {
1158 }1241 }
1159 o.debounceMu.Unlock()1242 o.debounceMu.Unlock()
1160 1243
1161- if o.window != nil {1244+ if o.outerBox != nil {
1162- o.window.Destroy()1245+ o.outerBox.Unparent()
1163- o.window = nil1246+ o.outerBox = nil
1164 }1247 }
1165 }1248 }
1166 1249
@@ -1169,6 +1252,54 @@ func formatShortcut(n int) string {
1169 return "Ctrl+" + string(rune('0'+n))1252 return "Ctrl+" + string(rune('0'+n))
1170 }1253 }
1171 1254
1255+// measureWidgetHeight returns the natural height of a widget for the given width.
1256+// Returns 0 if widget is nil or measurement fails.
1257+func measureWidgetHeight(widget *gtk.Widget, forWidth int) int {
1258+ if widget == nil {
1259+ return 0
1260+ }
1261+ var minHeight, naturalHeight, minBaseline, naturalBaseline int
1262+ widget.Measure(
1263+ gtk.OrientationVerticalValue,
1264+ forWidth,
1265+ &minHeight,
1266+ &naturalHeight,
1267+ &minBaseline,
1268+ &naturalBaseline,
1269+ )
1270+ return naturalHeight
1271+}
1272+
1273+// measureComponentHeights measures and caches all component heights.
1274+// Must be called on GTK main thread after widgets have been laid out.
1275+func (o *Omnibox) measureComponentHeights(forWidth int) bool {
1276+ if o.headerBox == nil || o.entry == nil || o.listBox == nil {
1277+ return false
1278+ }
1279+
1280+ headerH := measureWidgetHeight(&o.headerBox.Widget, forWidth)
1281+ entryH := measureWidgetHeight(&o.entry.Widget, forWidth)
1282+
1283+ // Measure first row if it exists
1284+ var rowH int
1285+ if row := o.listBox.GetRowAtIndex(0); row != nil {
1286+ rowH = measureWidgetHeight(&row.Widget, forWidth)
1287+ }
1288+
1289+ // Validate measurements (header and entry must be positive)
1290+ if headerH <= 0 || entryH <= 0 {
1291+ return false
1292+ }
1293+
1294+ o.measuredHeights.header = headerH
1295+ o.measuredHeights.entry = entryH
1296+ if rowH > 0 {
1297+ o.measuredHeights.singleRow = rowH
1298+ o.measuredHeights.valid = true
1299+ }
1300+ return true
1301+}
1302+
1172 // errNilWidget creates an error for nil widget creation.1303 // errNilWidget creates an error for nil widget creation.
1173 func errNilWidget(name string) error {1304 func errNilWidget(name string) error {
1174 return &widgetError{name: name}1305 return &widgetError{name: name}

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