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 ( | |||
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | const ( | |
| 21 | - | omniboxWidthPct = 0.5 // 50% of parent window width | |
| 21 | + | omniboxWidthPct = 0.8 // 80% of parent window width | |
| 22 | 22 | omniboxHeightPct = 0.6 // 60% of parent window height | |
| 23 | 23 | omniboxMaxResults = 10 | |
| 24 | - | debounceDelayMs = 250 // Increased for stability | |
| 24 | + | debounceDelayMs = 150 | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | // ViewMode distinguishes history search from favorites display. | |
| @@ -51,8 +51,8 @@ type Favorite struct { | |||
| 51 | 51 | // Omnibox is the native GTK4 address bar / command palette. | |
| 52 | 52 | type Omnibox struct { | |
| 53 | 53 | // 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 | |
| 56 | 56 | headerBox *gtk.Box | |
| 57 | 57 | historyBtn *gtk.Button | |
| 58 | 58 | favoritesBtn *gtk.Button | |
| @@ -61,7 +61,7 @@ type Omnibox struct { | |||
| 61 | 61 | scrolledWin *gtk.ScrolledWindow | |
| 62 | 62 | listBox *gtk.ListBox | |
| 63 | 63 | ||
| 64 | - | // Parent window reference for positioning | |
| 64 | + | // Parent window reference for sizing | |
| 65 | 65 | parentWindow *gtk.ApplicationWindow | |
| 66 | 66 | ||
| 67 | 67 | // State | |
| @@ -92,6 +92,14 @@ type Omnibox struct { | |||
| 92 | 92 | ||
| 93 | 93 | // Scaling | |
| 94 | 94 | 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 | 105 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| @@ -149,7 +157,7 @@ func (o *Omnibox) updateSize() { | |||
| 149 | 157 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | |
| 150 | 158 | // rowCount is the number of result rows to display (0 = no content, max 10). | |
| 151 | 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 | 161 | return | |
| 154 | 162 | } | |
| 155 | 163 | ||
| @@ -168,69 +176,86 @@ func (o *Omnibox) resizeAndCenter(rowCount int) { | |||
| 168 | 176 | ||
| 169 | 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 | 179 | // Cap at max results | |
| 177 | 180 | if rowCount > omniboxMaxResults { | |
| 178 | 181 | 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) | |
| 184 | - | o.mainBox.SetSizeRequest(width, height) | |
| 200 | + | log := logging.FromContext(o.ctx) | |
| 185 | 201 | ||
| 186 | - | // Center the window on the parent | |
| 187 | - | x := (parentWidth - width) / 2 | |
| 188 | - | y := (parentHeight - height) / 2 | |
| 202 | + | var rowHeight int | |
| 189 | 203 | ||
| 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) | |
| 196 | 210 | } | |
| 197 | 211 | ||
| 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() | |
| 202 | 236 | ||
| 203 | - | log := logging.FromContext(o.ctx) | |
| 204 | 237 | log.Debug(). | |
| 205 | 238 | Int("width", width). | |
| 206 | - | Int("height", height). | |
| 207 | - | Int("x", x). | |
| 208 | - | Int("y", y). | |
| 239 | + | Int("contentHeight", contentHeight). | |
| 240 | + | Int("rowHeight", rowHeight). | |
| 209 | 241 | Int("rows", rowCount). | |
| 242 | + | Bool("measured", o.measuredHeights.valid). | |
| 210 | 243 | Msg("omnibox resized") | |
| 211 | 244 | } | |
| 212 | 245 | ||
| 213 | 246 | // createWidgets builds the GTK widget hierarchy. | |
| 214 | 247 | 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") | |
| 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) | |
| 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 | |
| 234 | 259 | o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 235 | 260 | if o.mainBox == nil { | |
| 236 | 261 | return errNilWidget("mainBox") | |
| @@ -305,6 +330,8 @@ func (o *Omnibox) createWidgets() error { | |||
| 305 | 330 | o.scrolledWin.AddCssClass("omnibox-scrolled") | |
| 306 | 331 | o.scrolledWin.SetVexpand(true) | |
| 307 | 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 | 336 | // List box for suggestions | |
| 310 | 337 | o.listBox = gtk.NewListBox() | |
| @@ -336,7 +363,7 @@ func (o *Omnibox) createWidgets() error { | |||
| 336 | 363 | o.mainBox.Append(&o.headerBox.Widget) | |
| 337 | 364 | o.mainBox.Append(&o.entry.Widget) | |
| 338 | 365 | o.mainBox.Append(&o.scrolledWin.Widget) | |
| 339 | - | o.window.SetChild(&o.mainBox.Widget) | |
| 366 | + | o.outerBox.Append(&o.mainBox.Widget) | |
| 340 | 367 | ||
| 341 | 368 | return nil | |
| 342 | 369 | } | |
| @@ -358,7 +385,7 @@ func (o *Omnibox) setupKeyboardHandling() { | |||
| 358 | 385 | } | |
| 359 | 386 | controller.ConnectKeyPressed(&keyPressedCb) | |
| 360 | 387 | ||
| 361 | - | o.window.AddController(&controller.EventController) | |
| 388 | + | o.outerBox.AddController(&controller.EventController) | |
| 362 | 389 | } | |
| 363 | 390 | ||
| 364 | 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 | 705 | // createFaviconImage creates a favicon image with async loading from cache. | |
| @@ -826,7 +878,8 @@ func (o *Omnibox) selectAndNavigate(index int) { | |||
| 826 | 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 | 883 | func (o *Omnibox) navigateToSelected() { | |
| 831 | 884 | o.mu.RLock() | |
| 832 | 885 | mode := o.viewMode | |
| @@ -835,11 +888,16 @@ func (o *Omnibox) navigateToSelected() { | |||
| 835 | 888 | favorites := o.favorites | |
| 836 | 889 | o.mu.RUnlock() | |
| 837 | 890 | ||
| 891 | + | entryText := o.entry.GetText() | |
| 838 | 892 | var url string | |
| 839 | 893 | ||
| 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) | |
| 843 | 901 | } else if mode == ViewModeHistory { | |
| 844 | 902 | if idx < len(suggestions) { | |
| 845 | 903 | 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 | 932 | // toggleFavorite adds or removes the selected item from favorites. | |
| 865 | 933 | // In History mode: adds the selected item to favorites | |
| 866 | 934 | // In Favorites mode: removes the selected item from favorites | |
| @@ -1030,18 +1098,25 @@ func (o *Omnibox) Show(ctx context.Context, query string) { | |||
| 1030 | 1098 | // Hide scrolled window if no content expected | |
| 1031 | 1099 | if o.scrolledWin != nil { | |
| 1032 | 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 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) | |
| 1042 | 1114 | ||
| 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) | |
| 1045 | 1120 | ||
| 1046 | 1121 | // Focus the entry | |
| 1047 | 1122 | o.entry.GrabFocus() | |
| @@ -1071,8 +1146,8 @@ func (o *Omnibox) Hide(ctx context.Context) { | |||
| 1071 | 1146 | } | |
| 1072 | 1147 | o.debounceMu.Unlock() | |
| 1073 | 1148 | ||
| 1074 | - | // Hide window | |
| 1075 | - | o.window.SetVisible(false) | |
| 1149 | + | // Hide omnibox | |
| 1150 | + | o.outerBox.SetVisible(false) | |
| 1076 | 1151 | ||
| 1077 | 1152 | // Clear state | |
| 1078 | 1153 | o.entry.SetText("") | |
| @@ -1150,6 +1225,14 @@ func (o *Omnibox) idleAddUpdateFavorites(favorites []Favorite) { | |||
| 1150 | 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 | 1236 | // Destroy cleans up omnibox resources. | |
| 1154 | 1237 | func (o *Omnibox) Destroy() { | |
| 1155 | 1238 | o.debounceMu.Lock() | |
| @@ -1158,9 +1241,9 @@ func (o *Omnibox) Destroy() { | |||
| 1158 | 1241 | } | |
| 1159 | 1242 | o.debounceMu.Unlock() | |
| 1160 | 1243 | ||
| 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 | |
| 1164 | 1247 | } | |
| 1165 | 1248 | } | |
| 1166 | 1249 | ||
| @@ -1169,6 +1252,54 @@ func formatShortcut(n int) string { | |||
| 1169 | 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 | 1303 | // errNilWidget creates an error for nil widget creation. | |
| 1173 | 1304 | func errNilWidget(name string) error { | |
| 1174 | 1305 | 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 width | 21 | + | omniboxWidthPct = 0.8 // 80% of parent window width |
| 22 | omniboxHeightPct = 0.6 // 60% of parent window height | 22 | omniboxHeightPct = 0.6 // 60% of parent window height | ||
| 23 | omniboxMaxResults = 10 | 23 | omniboxMaxResults = 10 | ||
| 24 | - | debounceDelayMs = 250 // Increased for stability | 24 | + | 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 widgets | 53 | // GTK widgets | ||
| 54 | - | window *gtk.Window | 54 | + | outerBox *gtk.Box // Outer container for positioning |
| 55 | - | mainBox *gtk.Box | 55 | + | mainBox *gtk.Box // Main content box |
| 56 | headerBox *gtk.Box | 56 | headerBox *gtk.Box | ||
| 57 | historyBtn *gtk.Button | 57 | historyBtn *gtk.Button | ||
| 58 | favoritesBtn *gtk.Button | 58 | favoritesBtn *gtk.Button | ||
| @@ -61,7 +61,7 @@ type Omnibox struct { | |||||
| 61 | scrolledWin *gtk.ScrolledWindow | 61 | scrolledWin *gtk.ScrolledWindow | ||
| 62 | listBox *gtk.ListBox | 62 | listBox *gtk.ListBox | ||
| 63 | 63 | ||||
| 64 | - | // Parent window reference for positioning | 64 | + | // Parent window reference for sizing |
| 65 | parentWindow *gtk.ApplicationWindow | 65 | parentWindow *gtk.ApplicationWindow | ||
| 66 | 66 | ||||
| 67 | // State | 67 | // State | ||
| @@ -92,6 +92,14 @@ type Omnibox struct { | |||||
| 92 | 92 | ||||
| 93 | // Scaling | 93 | // Scaling | ||
| 94 | uiScale float64 | 94 | 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 | return | 161 | 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 results | 179 | // Cap at max results | ||
| 177 | if rowCount > omniboxMaxResults { | 180 | if rowCount > omniboxMaxResults { | ||
| 178 | rowCount = omniboxMaxResults | 181 | 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 parent | 202 | + | 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 positioning | 205 | + | // Use measured values from GTK4 Measure API |
| 192 | - | // We need to use a surface-based approach for proper centering | 206 | + | rowHeight = o.measuredHeights.singleRow |
| 193 | - | if surface := o.window.GetSurface(); surface != nil { | 207 | + | } else { |
| 194 | - | // Request the window manager to center it | 208 | + | // Fallback to hardcoded estimate (before first measurement) |
| 195 | - | // Note: On Wayland, window positioning is limited | 209 | + | rowHeight = int(60 * o.uiScale) |
| 196 | } | 210 | } | ||
| 197 | 211 | ||||
| 198 | - | // For GTK4 popup positioning, we rely on the transient relationship | 212 | + | // Calculate content height for the scrolled window |
| 199 | - | // The window should appear centered relative to parent automatically | 213 | + | // This is the key to dynamic sizing - limit the ScrolledWindow's max height |
| 200 | - | // But we can hint at position using the allocation | 214 | + | 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 window | 248 | + | // 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 suggestions | 336 | // 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 nil | 368 | 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.viewMode | 885 | mode := o.viewMode | ||
| @@ -835,11 +888,16 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 835 | favorites := o.favorites | 888 | favorites := o.favorites | ||
| 836 | o.mu.RUnlock() | 889 | o.mu.RUnlock() | ||
| 837 | 890 | ||||
| 891 | + | entryText := o.entry.GetText() | |||
| 838 | var url string | 892 | 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 URL | 895 | + | // 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].URL | 903 | 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 favorites | 933 | // In History mode: adds the selected item to favorites | ||
| 866 | // In Favorites mode: removes the selected item from favorites | 934 | // 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 expected | 1098 | // 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 otherwise | 1106 | + | // Set width and vertical positioning (20% from top) |
| 1036 | - | // (will be updated by search results) | 1107 | + | parentWidth := o.parentWindow.GetAllocatedWidth() |
| 1037 | - | initialRows := 0 | 1108 | + | parentHeight := o.parentWindow.GetAllocatedHeight() |
| 1038 | - | if expectContent { | 1109 | + | width := int(float64(parentWidth) * omniboxWidthPct) |
| 1039 | - | initialRows = omniboxMaxResults | 1110 | + | 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 window | 1115 | + | // 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 entry | 1121 | // 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 window | 1149 | + | // Hide omnibox |
| 1075 | - | o.window.SetVisible(false) | 1150 | + | o.outerBox.SetVisible(false) |
| 1076 | 1151 | ||||
| 1077 | // Clear state | 1152 | // 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 = nil | 1246 | + | 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} | ||