feat: adapt omnibox result count to parent pane height
When the parent pane is too short to fit 10 results, the omnibox now dynamically switches to showing 5 results. This prevents the bottom of the omnibox from being clipped off-screen in small panes. The effective row count is computed from available space (parent height minus top margin and chrome) rather than a fixed threshold, so it adapts correctly across different UI scales.
- commit
- 5b585ba6b1593a26b5dd1bc3e1b91216032160ce
- author
- brice · · 2026-01-29 06:08 UTC
- committer
- brice · · 2026-01-29 08:42 UTC
- parent
- 62a3808
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +60 -13
M internal/ui/component/modal_sizing.go +25 -4
| @@ -24,8 +24,9 @@ type RowHeightDefaults struct { | |||
| 24 | 24 | ||
| 25 | 25 | // ListDisplayDefaults holds display limits for list-based modals. | |
| 26 | 26 | type ListDisplayDefaults struct { | |
| 27 | - | MaxVisibleRows int // Maximum rows visible before scrolling | |
| 28 | - | MaxResults int // Maximum results to fetch/display | |
| 27 | + | MaxVisibleRows int // Maximum rows visible before scrolling | |
| 28 | + | MaxResults int // Maximum results to fetch/display | |
| 29 | + | SmallMaxVisibleRows int // Reduced row count when rows don't fit (0 = no adaptation) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // Package-level defaults for row heights. | |
| @@ -46,8 +47,9 @@ var OmniboxSizeDefaults = ModalSizeConfig{ | |||
| 46 | 47 | ||
| 47 | 48 | // OmniboxListDefaults provides display limits for omnibox modal. | |
| 48 | 49 | var OmniboxListDefaults = ListDisplayDefaults{ | |
| 49 | - | MaxVisibleRows: 10, | |
| 50 | - | MaxResults: 10, | |
| 50 | + | MaxVisibleRows: 10, | |
| 51 | + | MaxResults: 10, | |
| 52 | + | SmallMaxVisibleRows: 5, | |
| 51 | 53 | } | |
| 52 | 54 | ||
| 53 | 55 | // SessionManagerSizeDefaults provides default sizing for session manager modal. | |
| @@ -80,6 +82,25 @@ var TabPickerListDefaults = ListDisplayDefaults{ | |||
| 80 | 82 | MaxResults: 20, | |
| 81 | 83 | } | |
| 82 | 84 | ||
| 85 | + | // EffectiveMaxRows returns the maximum number of visible rows that fit | |
| 86 | + | // in the available space. It computes available height by subtracting the | |
| 87 | + | // top margin (TopMarginPct of parentHeight) and an estimated chrome height | |
| 88 | + | // (header + search entry ≈ 120px). If MaxVisibleRows worth of rows don't | |
| 89 | + | // fit, it falls back to SmallMaxVisibleRows. | |
| 90 | + | func EffectiveMaxRows(parentHeight, rowHeight int, sizeCfg ModalSizeConfig, defaults ListDisplayDefaults) int { | |
| 91 | + | if parentHeight <= 0 || rowHeight <= 0 || defaults.SmallMaxVisibleRows <= 0 { | |
| 92 | + | return defaults.MaxVisibleRows | |
| 93 | + | } | |
| 94 | + | topMargin := int(float64(parentHeight) * sizeCfg.TopMarginPct) | |
| 95 | + | const chromeHeight = 150 // header tabs + search entry + bottom padding | |
| 96 | + | available := parentHeight - topMargin - chromeHeight | |
| 97 | + | needed := defaults.MaxVisibleRows * rowHeight | |
| 98 | + | if available < needed { | |
| 99 | + | return defaults.SmallMaxVisibleRows | |
| 100 | + | } | |
| 101 | + | return defaults.MaxVisibleRows | |
| 102 | + | } | |
| 103 | + | ||
| 83 | 104 | // CalculateModalDimensions computes width and top margin based on parent overlay. | |
| 84 | 105 | // Returns calculated width and top margin in pixels. | |
| 85 | 106 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | |
| @@ -24,8 +24,9 @@ type RowHeightDefaults struct { | |||||
| 24 | 24 | ||||
| 25 | // ListDisplayDefaults holds display limits for list-based modals. | 25 | // ListDisplayDefaults holds display limits for list-based modals. | ||
| 26 | type ListDisplayDefaults struct { | 26 | type ListDisplayDefaults struct { | ||
| 27 | - | MaxVisibleRows int // Maximum rows visible before scrolling | 27 | + | MaxVisibleRows int // Maximum rows visible before scrolling |
| 28 | - | MaxResults int // Maximum results to fetch/display | 28 | + | MaxResults int // Maximum results to fetch/display |
| 29 | + | SmallMaxVisibleRows int // Reduced row count when rows don't fit (0 = no adaptation) | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // Package-level defaults for row heights. | 32 | // Package-level defaults for row heights. | ||
| @@ -46,8 +47,9 @@ var OmniboxSizeDefaults = ModalSizeConfig{ | |||||
| 46 | 47 | ||||
| 47 | // OmniboxListDefaults provides display limits for omnibox modal. | 48 | // OmniboxListDefaults provides display limits for omnibox modal. | ||
| 48 | var OmniboxListDefaults = ListDisplayDefaults{ | 49 | var OmniboxListDefaults = ListDisplayDefaults{ | ||
| 49 | - | MaxVisibleRows: 10, | 50 | + | MaxVisibleRows: 10, |
| 50 | - | MaxResults: 10, | 51 | + | MaxResults: 10, |
| 52 | + | SmallMaxVisibleRows: 5, | |||
| 51 | } | 53 | } | ||
| 52 | 54 | ||||
| 53 | // SessionManagerSizeDefaults provides default sizing for session manager modal. | 55 | // SessionManagerSizeDefaults provides default sizing for session manager modal. | ||
| @@ -80,6 +82,25 @@ var TabPickerListDefaults = ListDisplayDefaults{ | |||||
| 80 | MaxResults: 20, | 82 | MaxResults: 20, | ||
| 81 | } | 83 | } | ||
| 82 | 84 | ||||
| 85 | + | // EffectiveMaxRows returns the maximum number of visible rows that fit | |||
| 86 | + | // in the available space. It computes available height by subtracting the | |||
| 87 | + | // top margin (TopMarginPct of parentHeight) and an estimated chrome height | |||
| 88 | + | // (header + search entry ≈ 120px). If MaxVisibleRows worth of rows don't | |||
| 89 | + | // fit, it falls back to SmallMaxVisibleRows. | |||
| 90 | + | func EffectiveMaxRows(parentHeight, rowHeight int, sizeCfg ModalSizeConfig, defaults ListDisplayDefaults) int { | |||
| 91 | + | if parentHeight <= 0 || rowHeight <= 0 || defaults.SmallMaxVisibleRows <= 0 { | |||
| 92 | + | return defaults.MaxVisibleRows | |||
| 93 | + | } | |||
| 94 | + | topMargin := int(float64(parentHeight) * sizeCfg.TopMarginPct) | |||
| 95 | + | const chromeHeight = 150 // header tabs + search entry + bottom padding | |||
| 96 | + | available := parentHeight - topMargin - chromeHeight | |||
| 97 | + | needed := defaults.MaxVisibleRows * rowHeight | |||
| 98 | + | if available < needed { | |||
| 99 | + | return defaults.SmallMaxVisibleRows | |||
| 100 | + | } | |||
| 101 | + | return defaults.MaxVisibleRows | |||
| 102 | + | } | |||
| 103 | + | ||||
| 83 | // CalculateModalDimensions computes width and top margin based on parent overlay. | 104 | // CalculateModalDimensions computes width and top margin based on parent overlay. | ||
| 84 | // Returns calculated width and top margin in pixels. | 105 | // Returns calculated width and top margin in pixels. | ||
| 85 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | 106 | func CalculateModalDimensions(parent layout.OverlayWidget, cfg ModalSizeConfig) (width, marginTop int) { | ||
M internal/ui/component/omnibox.go +35 -9
| @@ -301,16 +301,33 @@ func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget { | |||
| 301 | 301 | return factory.WrapWidget(&o.outerBox.Widget) | |
| 302 | 302 | } | |
| 303 | 303 | ||
| 304 | + | // estimateRowHeight returns the current best estimate for a single row height. | |
| 305 | + | func (o *Omnibox) estimateRowHeight() int { | |
| 306 | + | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | |
| 307 | + | return o.measuredHeights.singleRow | |
| 308 | + | } | |
| 309 | + | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | |
| 313 | + | // Must be called on the GTK main thread. | |
| 314 | + | func (o *Omnibox) effectiveMaxRows() int { | |
| 315 | + | if o.parentOverlay == nil { | |
| 316 | + | return OmniboxListDefaults.MaxResults | |
| 317 | + | } | |
| 318 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | |
| 319 | + | } | |
| 320 | + | ||
| 304 | 321 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | |
| 305 | - | // rowCount is the number of result rows to display (0 = no content, max 10). | |
| 322 | + | // rowCount is the number of result rows to display (0 = no content, adapts to parent height). | |
| 306 | 323 | func (o *Omnibox) resizeAndCenter(rowCount int) { | |
| 307 | 324 | if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil { | |
| 308 | 325 | return | |
| 309 | 326 | } | |
| 310 | 327 | ||
| 311 | - | // Cap at max results | |
| 312 | - | if rowCount > OmniboxListDefaults.MaxVisibleRows { | |
| 313 | - | rowCount = OmniboxListDefaults.MaxVisibleRows | |
| 328 | + | // Cap at effective max rows (adapts to parent pane height) | |
| 329 | + | if maxRows := o.effectiveMaxRows(); rowCount > maxRows { | |
| 330 | + | rowCount = maxRows | |
| 314 | 331 | } | |
| 315 | 332 | ||
| 316 | 333 | // Schedule measurement after GTK has laid out widgets | |
| @@ -351,8 +368,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||
| 351 | 368 | contentHeight = rowCount * rowHeight | |
| 352 | 369 | } | |
| 353 | 370 | ||
| 354 | - | // Cap at max results | |
| 355 | - | maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight | |
| 371 | + | // Cap at effective max rows (adapts to parent pane height) | |
| 372 | + | maxContentHeight := o.effectiveMaxRows() * rowHeight | |
| 356 | 373 | if contentHeight > maxContentHeight { | |
| 357 | 374 | contentHeight = maxContentHeight | |
| 358 | 375 | } | |
| @@ -1212,7 +1229,13 @@ func (o *Omnibox) performSearch() { | |||
| 1212 | 1229 | return | |
| 1213 | 1230 | } | |
| 1214 | 1231 | ||
| 1215 | - | // Perform fuzzy search | |
| 1232 | + | // Perform fuzzy history search in background | |
| 1233 | + | o.searchHistory(query, o.effectiveMaxRows()) | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1237 | + | // query is the search text; limit caps the number of results. | |
| 1238 | + | func (o *Omnibox) searchHistory(query string, limit int) { | |
| 1216 | 1239 | go func() { | |
| 1217 | 1240 | ctx := o.ctx | |
| 1218 | 1241 | log := logging.FromContext(ctx) | |
| @@ -1231,7 +1254,7 @@ func (o *Omnibox) performSearch() { | |||
| 1231 | 1254 | go func() { | |
| 1232 | 1255 | searchInput := usecase.SearchInput{ | |
| 1233 | 1256 | Query: query, | |
| 1234 | - | Limit: OmniboxListDefaults.MaxResults, | |
| 1257 | + | Limit: limit, | |
| 1235 | 1258 | } | |
| 1236 | 1259 | output, err := o.historyUC.Search(ctx, searchInput) | |
| 1237 | 1260 | searchCh <- searchResult{output, err} | |
| @@ -1274,6 +1297,9 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1274 | 1297 | return | |
| 1275 | 1298 | } | |
| 1276 | 1299 | ||
| 1300 | + | // Capture effective result limit on the GTK main thread before spawning goroutine | |
| 1301 | + | initialLimit := o.effectiveMaxRows() | |
| 1302 | + | ||
| 1277 | 1303 | go func() { | |
| 1278 | 1304 | ctx := o.ctx | |
| 1279 | 1305 | log := logging.FromContext(ctx) | |
| @@ -1295,7 +1321,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1295 | 1321 | ||
| 1296 | 1322 | go func() { | |
| 1297 | 1323 | // TODO: Implement GetMostVisited in use case if needed | |
| 1298 | - | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | |
| 1324 | + | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) | |
| 1299 | 1325 | historyCh <- historyResult{results, err} | |
| 1300 | 1326 | }() | |
| 1301 | 1327 | ||
| @@ -301,16 +301,33 @@ func (o *Omnibox) WidgetAsLayout(factory layout.WidgetFactory) layout.Widget { | |||||
| 301 | return factory.WrapWidget(&o.outerBox.Widget) | 301 | return factory.WrapWidget(&o.outerBox.Widget) | ||
| 302 | } | 302 | } | ||
| 303 | 303 | ||||
| 304 | + | // estimateRowHeight returns the current best estimate for a single row height. | |||
| 305 | + | func (o *Omnibox) estimateRowHeight() int { | |||
| 306 | + | if o.measuredHeights.valid && o.measuredHeights.singleRow > 0 { | |||
| 307 | + | return o.measuredHeights.singleRow | |||
| 308 | + | } | |||
| 309 | + | return ScaleValue(DefaultRowHeights.Standard, o.uiScale) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // effectiveMaxRows returns the max visible rows adapted to the current parent pane height. | |||
| 313 | + | // Must be called on the GTK main thread. | |||
| 314 | + | func (o *Omnibox) effectiveMaxRows() int { | |||
| 315 | + | if o.parentOverlay == nil { | |||
| 316 | + | return OmniboxListDefaults.MaxResults | |||
| 317 | + | } | |||
| 318 | + | return EffectiveMaxRows(o.parentOverlay.GetAllocatedHeight(), o.estimateRowHeight(), OmniboxSizeDefaults, OmniboxListDefaults) | |||
| 319 | + | } | |||
| 320 | + | ||||
| 304 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | 321 | // resizeAndCenter adjusts the omnibox size based on content and centers it. | ||
| 305 | - | // rowCount is the number of result rows to display (0 = no content, max 10). | 322 | + | // rowCount is the number of result rows to display (0 = no content, adapts to parent height). |
| 306 | func (o *Omnibox) resizeAndCenter(rowCount int) { | 323 | func (o *Omnibox) resizeAndCenter(rowCount int) { | ||
| 307 | if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil { | 324 | if o.parentOverlay == nil || o.outerBox == nil || o.mainBox == nil { | ||
| 308 | return | 325 | return | ||
| 309 | } | 326 | } | ||
| 310 | 327 | ||||
| 311 | - | // Cap at max results | 328 | + | // Cap at effective max rows (adapts to parent pane height) |
| 312 | - | if rowCount > OmniboxListDefaults.MaxVisibleRows { | 329 | + | if maxRows := o.effectiveMaxRows(); rowCount > maxRows { |
| 313 | - | rowCount = OmniboxListDefaults.MaxVisibleRows | 330 | + | rowCount = maxRows |
| 314 | } | 331 | } | ||
| 315 | 332 | ||||
| 316 | // Schedule measurement after GTK has laid out widgets | 333 | // Schedule measurement after GTK has laid out widgets | ||
| @@ -351,8 +368,8 @@ func (o *Omnibox) measureAndResize(width, rowCount int) { | |||||
| 351 | contentHeight = rowCount * rowHeight | 368 | contentHeight = rowCount * rowHeight | ||
| 352 | } | 369 | } | ||
| 353 | 370 | ||||
| 354 | - | // Cap at max results | 371 | + | // Cap at effective max rows (adapts to parent pane height) |
| 355 | - | maxContentHeight := OmniboxListDefaults.MaxVisibleRows * rowHeight | 372 | + | maxContentHeight := o.effectiveMaxRows() * rowHeight |
| 356 | if contentHeight > maxContentHeight { | 373 | if contentHeight > maxContentHeight { | ||
| 357 | contentHeight = maxContentHeight | 374 | contentHeight = maxContentHeight | ||
| 358 | } | 375 | } | ||
| @@ -1212,7 +1229,13 @@ func (o *Omnibox) performSearch() { | |||||
| 1212 | return | 1229 | return | ||
| 1213 | } | 1230 | } | ||
| 1214 | 1231 | ||||
| 1215 | - | // Perform fuzzy search | 1232 | + | // Perform fuzzy history search in background |
| 1233 | + | o.searchHistory(query, o.effectiveMaxRows()) | |||
| 1234 | + | } | |||
| 1235 | + | ||||
| 1236 | + | // searchHistory runs a fuzzy history search in a background goroutine. | |||
| 1237 | + | // query is the search text; limit caps the number of results. | |||
| 1238 | + | func (o *Omnibox) searchHistory(query string, limit int) { | |||
| 1216 | go func() { | 1239 | go func() { | ||
| 1217 | ctx := o.ctx | 1240 | ctx := o.ctx | ||
| 1218 | log := logging.FromContext(ctx) | 1241 | log := logging.FromContext(ctx) | ||
| @@ -1231,7 +1254,7 @@ func (o *Omnibox) performSearch() { | |||||
| 1231 | go func() { | 1254 | go func() { | ||
| 1232 | searchInput := usecase.SearchInput{ | 1255 | searchInput := usecase.SearchInput{ | ||
| 1233 | Query: query, | 1256 | Query: query, | ||
| 1234 | - | Limit: OmniboxListDefaults.MaxResults, | 1257 | + | Limit: limit, |
| 1235 | } | 1258 | } | ||
| 1236 | output, err := o.historyUC.Search(ctx, searchInput) | 1259 | output, err := o.historyUC.Search(ctx, searchInput) | ||
| 1237 | searchCh <- searchResult{output, err} | 1260 | searchCh <- searchResult{output, err} | ||
| @@ -1274,6 +1297,9 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1274 | return | 1297 | return | ||
| 1275 | } | 1298 | } | ||
| 1276 | 1299 | ||||
| 1300 | + | // Capture effective result limit on the GTK main thread before spawning goroutine | |||
| 1301 | + | initialLimit := o.effectiveMaxRows() | |||
| 1302 | + | ||||
| 1277 | go func() { | 1303 | go func() { | ||
| 1278 | ctx := o.ctx | 1304 | ctx := o.ctx | ||
| 1279 | log := logging.FromContext(ctx) | 1305 | log := logging.FromContext(ctx) | ||
| @@ -1295,7 +1321,7 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1295 | 1321 | ||||
| 1296 | go func() { | 1322 | go func() { | ||
| 1297 | // TODO: Implement GetMostVisited in use case if needed | 1323 | // TODO: Implement GetMostVisited in use case if needed | ||
| 1298 | - | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | 1324 | + | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) |
| 1299 | historyCh <- historyResult{results, err} | 1325 | historyCh <- historyResult{results, err} | ||
| 1300 | }() | 1326 | }() | ||
| 1301 | 1327 | ||||