Merge pull request #157 from bnema/feat/omnibox-height-adapt
feat: adapt omnibox result count to parent pane height
7 files · +185 -20
M internal/ui/app.go +6 -2
| @@ -543,7 +543,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 543 | 543 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 544 | 544 | a.handleModeChange(ctx, from, to) | |
| 545 | 545 | }) | |
| 546 | - | a.keyboardHandler.SetShouldBypassInput(func() bool { | |
| 546 | + | a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool { | |
| 547 | 547 | // Bypass keyboard handler when modals are visible | |
| 548 | 548 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 549 | 549 | return true | |
| @@ -555,7 +555,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 555 | 555 | if wsView == nil { | |
| 556 | 556 | return false | |
| 557 | 557 | } | |
| 558 | - | return wsView.IsOmniboxVisible() | |
| 558 | + | if wsView.IsOmniboxVisible() { | |
| 559 | + | // Let Alt-modified keys through for pane navigation | |
| 560 | + | return modifiers&input.ModAlt == 0 | |
| 561 | + | } | |
| 562 | + | return false | |
| 559 | 563 | }) | |
| 560 | 564 | // Wire accent handler for dead keys support | |
| 561 | 565 | if a.insertAccentUC != nil { | |
| @@ -543,7 +543,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 543 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 543 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| 544 | a.handleModeChange(ctx, from, to) | 544 | a.handleModeChange(ctx, from, to) | ||
| 545 | }) | 545 | }) | ||
| 546 | - | a.keyboardHandler.SetShouldBypassInput(func() bool { | 546 | + | a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool { |
| 547 | // Bypass keyboard handler when modals are visible | 547 | // Bypass keyboard handler when modals are visible | ||
| 548 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | 548 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | ||
| 549 | return true | 549 | return true | ||
| @@ -555,7 +555,11 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 555 | if wsView == nil { | 555 | if wsView == nil { | ||
| 556 | return false | 556 | return false | ||
| 557 | } | 557 | } | ||
| 558 | - | return wsView.IsOmniboxVisible() | 558 | + | if wsView.IsOmniboxVisible() { |
| 559 | + | // Let Alt-modified keys through for pane navigation | |||
| 560 | + | return modifiers&input.ModAlt == 0 | |||
| 561 | + | } | |||
| 562 | + | return false | |||
| 559 | }) | 563 | }) | ||
| 560 | // Wire accent handler for dead keys support | 564 | // Wire accent handler for dead keys support | ||
| 561 | if a.insertAccentUC != nil { | 565 | if a.insertAccentUC != nil { | ||
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 chrome height (header tabs + | |
| 88 | + | // search entry, estimated as 2× rowHeight to scale with the UI). If | |
| 89 | + | // MaxVisibleRows worth of rows don't 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 | + | chromeHeight := 2 * rowHeight // header tabs + search entry, scales with UI | |
| 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 chrome height (header tabs + | |||
| 88 | + | // search entry, estimated as 2× rowHeight to scale with the UI). If | |||
| 89 | + | // MaxVisibleRows worth of rows don't 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 | + | chromeHeight := 2 * rowHeight // header tabs + search entry, scales with UI | |||
| 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/modal_sizing_test.go +91 -0
| @@ -143,3 +143,94 @@ func TestListDisplayDefaults_Values(t *testing.T) { | |||
| 143 | 143 | assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows) | |
| 144 | 144 | assert.Positive(t, SessionManagerListDefaults.MaxResults) | |
| 145 | 145 | } | |
| 146 | + | ||
| 147 | + | func TestEffectiveMaxRows(t *testing.T) { | |
| 148 | + | sizeCfg := ModalSizeConfig{TopMarginPct: 0.2} | |
| 149 | + | defaults := ListDisplayDefaults{ | |
| 150 | + | MaxVisibleRows: 10, | |
| 151 | + | MaxResults: 10, | |
| 152 | + | SmallMaxVisibleRows: 5, | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | tests := []struct { | |
| 156 | + | name string | |
| 157 | + | parentHeight int | |
| 158 | + | rowHeight int | |
| 159 | + | want int | |
| 160 | + | }{ | |
| 161 | + | { | |
| 162 | + | name: "tall pane returns MaxVisibleRows", | |
| 163 | + | parentHeight: 1200, | |
| 164 | + | rowHeight: 72, | |
| 165 | + | want: 10, | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "short pane returns SmallMaxVisibleRows", | |
| 169 | + | parentHeight: 800, | |
| 170 | + | rowHeight: 72, | |
| 171 | + | want: 5, | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | name: "zero parentHeight returns MaxVisibleRows", | |
| 175 | + | parentHeight: 0, | |
| 176 | + | rowHeight: 72, | |
| 177 | + | want: 10, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | name: "zero rowHeight returns MaxVisibleRows", | |
| 181 | + | parentHeight: 800, | |
| 182 | + | rowHeight: 0, | |
| 183 | + | want: 10, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "negative parentHeight returns MaxVisibleRows", | |
| 187 | + | parentHeight: -100, | |
| 188 | + | rowHeight: 72, | |
| 189 | + | want: 10, | |
| 190 | + | }, | |
| 191 | + | { | |
| 192 | + | name: "exact fit returns MaxVisibleRows", | |
| 193 | + | parentHeight: 1150, // available = 1150 - 230 - 144 = 776, needed = 720 | |
| 194 | + | rowHeight: 72, | |
| 195 | + | want: 10, | |
| 196 | + | }, | |
| 197 | + | { | |
| 198 | + | name: "boundary: available equals needed still fits", | |
| 199 | + | parentHeight: 1080, // available = 1080 - 216 - 144 = 720, needed = 720 → fits | |
| 200 | + | rowHeight: 72, | |
| 201 | + | want: 10, | |
| 202 | + | }, | |
| 203 | + | { | |
| 204 | + | name: "boundary: one pixel under triggers small", | |
| 205 | + | parentHeight: 1079, // available = 1079 - 215 - 144 = 720, needed = 720 → check | |
| 206 | + | rowHeight: 72, | |
| 207 | + | want: 10, // int(1079*0.2) = 215, 1079-215-144 = 720 → still fits | |
| 208 | + | }, | |
| 209 | + | { | |
| 210 | + | name: "just below boundary triggers small", | |
| 211 | + | parentHeight: 1078, // available = 1078 - 215 - 144 = 719, needed = 720 → doesn't fit | |
| 212 | + | rowHeight: 72, | |
| 213 | + | want: 5, | |
| 214 | + | }, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | for _, tt := range tests { | |
| 218 | + | t.Run(tt.name, func(t *testing.T) { | |
| 219 | + | got := EffectiveMaxRows(tt.parentHeight, tt.rowHeight, sizeCfg, defaults) | |
| 220 | + | assert.Equal(t, tt.want, got) | |
| 221 | + | }) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestEffectiveMaxRows_NoAdaptation(t *testing.T) { | |
| 226 | + | sizeCfg := ModalSizeConfig{TopMarginPct: 0.2} | |
| 227 | + | defaults := ListDisplayDefaults{ | |
| 228 | + | MaxVisibleRows: 10, | |
| 229 | + | MaxResults: 10, | |
| 230 | + | SmallMaxVisibleRows: 0, // adaptation disabled | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Even with a short pane, should return MaxVisibleRows when SmallMaxVisibleRows is 0 | |
| 234 | + | got := EffectiveMaxRows(500, 72, sizeCfg, defaults) | |
| 235 | + | assert.Equal(t, 10, got) | |
| 236 | + | } | |
| @@ -143,3 +143,94 @@ func TestListDisplayDefaults_Values(t *testing.T) { | |||||
| 143 | assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows) | 143 | assert.Positive(t, SessionManagerListDefaults.MaxVisibleRows) | ||
| 144 | assert.Positive(t, SessionManagerListDefaults.MaxResults) | 144 | assert.Positive(t, SessionManagerListDefaults.MaxResults) | ||
| 145 | } | 145 | } | ||
| 146 | + | ||||
| 147 | + | func TestEffectiveMaxRows(t *testing.T) { | |||
| 148 | + | sizeCfg := ModalSizeConfig{TopMarginPct: 0.2} | |||
| 149 | + | defaults := ListDisplayDefaults{ | |||
| 150 | + | MaxVisibleRows: 10, | |||
| 151 | + | MaxResults: 10, | |||
| 152 | + | SmallMaxVisibleRows: 5, | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | tests := []struct { | |||
| 156 | + | name string | |||
| 157 | + | parentHeight int | |||
| 158 | + | rowHeight int | |||
| 159 | + | want int | |||
| 160 | + | }{ | |||
| 161 | + | { | |||
| 162 | + | name: "tall pane returns MaxVisibleRows", | |||
| 163 | + | parentHeight: 1200, | |||
| 164 | + | rowHeight: 72, | |||
| 165 | + | want: 10, | |||
| 166 | + | }, | |||
| 167 | + | { | |||
| 168 | + | name: "short pane returns SmallMaxVisibleRows", | |||
| 169 | + | parentHeight: 800, | |||
| 170 | + | rowHeight: 72, | |||
| 171 | + | want: 5, | |||
| 172 | + | }, | |||
| 173 | + | { | |||
| 174 | + | name: "zero parentHeight returns MaxVisibleRows", | |||
| 175 | + | parentHeight: 0, | |||
| 176 | + | rowHeight: 72, | |||
| 177 | + | want: 10, | |||
| 178 | + | }, | |||
| 179 | + | { | |||
| 180 | + | name: "zero rowHeight returns MaxVisibleRows", | |||
| 181 | + | parentHeight: 800, | |||
| 182 | + | rowHeight: 0, | |||
| 183 | + | want: 10, | |||
| 184 | + | }, | |||
| 185 | + | { | |||
| 186 | + | name: "negative parentHeight returns MaxVisibleRows", | |||
| 187 | + | parentHeight: -100, | |||
| 188 | + | rowHeight: 72, | |||
| 189 | + | want: 10, | |||
| 190 | + | }, | |||
| 191 | + | { | |||
| 192 | + | name: "exact fit returns MaxVisibleRows", | |||
| 193 | + | parentHeight: 1150, // available = 1150 - 230 - 144 = 776, needed = 720 | |||
| 194 | + | rowHeight: 72, | |||
| 195 | + | want: 10, | |||
| 196 | + | }, | |||
| 197 | + | { | |||
| 198 | + | name: "boundary: available equals needed still fits", | |||
| 199 | + | parentHeight: 1080, // available = 1080 - 216 - 144 = 720, needed = 720 → fits | |||
| 200 | + | rowHeight: 72, | |||
| 201 | + | want: 10, | |||
| 202 | + | }, | |||
| 203 | + | { | |||
| 204 | + | name: "boundary: one pixel under triggers small", | |||
| 205 | + | parentHeight: 1079, // available = 1079 - 215 - 144 = 720, needed = 720 → check | |||
| 206 | + | rowHeight: 72, | |||
| 207 | + | want: 10, // int(1079*0.2) = 215, 1079-215-144 = 720 → still fits | |||
| 208 | + | }, | |||
| 209 | + | { | |||
| 210 | + | name: "just below boundary triggers small", | |||
| 211 | + | parentHeight: 1078, // available = 1078 - 215 - 144 = 719, needed = 720 → doesn't fit | |||
| 212 | + | rowHeight: 72, | |||
| 213 | + | want: 5, | |||
| 214 | + | }, | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | for _, tt := range tests { | |||
| 218 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 219 | + | got := EffectiveMaxRows(tt.parentHeight, tt.rowHeight, sizeCfg, defaults) | |||
| 220 | + | assert.Equal(t, tt.want, got) | |||
| 221 | + | }) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestEffectiveMaxRows_NoAdaptation(t *testing.T) { | |||
| 226 | + | sizeCfg := ModalSizeConfig{TopMarginPct: 0.2} | |||
| 227 | + | defaults := ListDisplayDefaults{ | |||
| 228 | + | MaxVisibleRows: 10, | |||
| 229 | + | MaxResults: 10, | |||
| 230 | + | SmallMaxVisibleRows: 0, // adaptation disabled | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // Even with a short pane, should return MaxVisibleRows when SmallMaxVisibleRows is 0 | |||
| 234 | + | got := EffectiveMaxRows(500, 72, sizeCfg, defaults) | |||
| 235 | + | assert.Equal(t, 10, got) | |||
| 236 | + | } | |||
M internal/ui/component/omnibox.go +41 -10
| @@ -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.MaxVisibleRows | |
| 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 | } | |
| @@ -823,7 +840,12 @@ func (o *Omnibox) onEntryChanged() { | |||
| 823 | 840 | o.debounceTimer.Stop() | |
| 824 | 841 | } | |
| 825 | 842 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | |
| 826 | - | o.performSearch() | |
| 843 | + | // Schedule on GTK main thread — performSearch reads GTK widget state | |
| 844 | + | var cb glib.SourceFunc = func(uintptr) bool { | |
| 845 | + | o.performSearch() | |
| 846 | + | return false | |
| 847 | + | } | |
| 848 | + | glib.IdleAdd(&cb, 0) | |
| 827 | 849 | }) | |
| 828 | 850 | o.debounceMu.Unlock() | |
| 829 | 851 | } | |
| @@ -1212,7 +1234,13 @@ func (o *Omnibox) performSearch() { | |||
| 1212 | 1234 | return | |
| 1213 | 1235 | } | |
| 1214 | 1236 | ||
| 1215 | - | // Perform fuzzy search | |
| 1237 | + | // Perform fuzzy history search in background | |
| 1238 | + | o.searchHistory(query, o.effectiveMaxRows()) | |
| 1239 | + | } | |
| 1240 | + | ||
| 1241 | + | // searchHistory runs a fuzzy history search in a background goroutine. | |
| 1242 | + | // query is the search text; limit caps the number of results. | |
| 1243 | + | func (o *Omnibox) searchHistory(query string, limit int) { | |
| 1216 | 1244 | go func() { | |
| 1217 | 1245 | ctx := o.ctx | |
| 1218 | 1246 | log := logging.FromContext(ctx) | |
| @@ -1231,7 +1259,7 @@ func (o *Omnibox) performSearch() { | |||
| 1231 | 1259 | go func() { | |
| 1232 | 1260 | searchInput := usecase.SearchInput{ | |
| 1233 | 1261 | Query: query, | |
| 1234 | - | Limit: OmniboxListDefaults.MaxResults, | |
| 1262 | + | Limit: limit, | |
| 1235 | 1263 | } | |
| 1236 | 1264 | output, err := o.historyUC.Search(ctx, searchInput) | |
| 1237 | 1265 | searchCh <- searchResult{output, err} | |
| @@ -1274,6 +1302,9 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1274 | 1302 | return | |
| 1275 | 1303 | } | |
| 1276 | 1304 | ||
| 1305 | + | // Capture effective result limit on the GTK main thread before spawning goroutine | |
| 1306 | + | initialLimit := o.effectiveMaxRows() | |
| 1307 | + | ||
| 1277 | 1308 | go func() { | |
| 1278 | 1309 | ctx := o.ctx | |
| 1279 | 1310 | log := logging.FromContext(ctx) | |
| @@ -1295,7 +1326,7 @@ func (o *Omnibox) loadInitialHistory() { | |||
| 1295 | 1326 | ||
| 1296 | 1327 | go func() { | |
| 1297 | 1328 | // TODO: Implement GetMostVisited in use case if needed | |
| 1298 | - | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | |
| 1329 | + | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) | |
| 1299 | 1330 | historyCh <- historyResult{results, err} | |
| 1300 | 1331 | }() | |
| 1301 | 1332 | ||
| @@ -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.MaxVisibleRows | |||
| 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 | } | ||
| @@ -823,7 +840,12 @@ func (o *Omnibox) onEntryChanged() { | |||||
| 823 | o.debounceTimer.Stop() | 840 | o.debounceTimer.Stop() | ||
| 824 | } | 841 | } | ||
| 825 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | 842 | o.debounceTimer = time.AfterFunc(debounceDelayMs*time.Millisecond, func() { | ||
| 826 | - | o.performSearch() | 843 | + | // Schedule on GTK main thread — performSearch reads GTK widget state |
| 844 | + | var cb glib.SourceFunc = func(uintptr) bool { | |||
| 845 | + | o.performSearch() | |||
| 846 | + | return false | |||
| 847 | + | } | |||
| 848 | + | glib.IdleAdd(&cb, 0) | |||
| 827 | }) | 849 | }) | ||
| 828 | o.debounceMu.Unlock() | 850 | o.debounceMu.Unlock() | ||
| 829 | } | 851 | } | ||
| @@ -1212,7 +1234,13 @@ func (o *Omnibox) performSearch() { | |||||
| 1212 | return | 1234 | return | ||
| 1213 | } | 1235 | } | ||
| 1214 | 1236 | ||||
| 1215 | - | // Perform fuzzy search | 1237 | + | // Perform fuzzy history search in background |
| 1238 | + | o.searchHistory(query, o.effectiveMaxRows()) | |||
| 1239 | + | } | |||
| 1240 | + | ||||
| 1241 | + | // searchHistory runs a fuzzy history search in a background goroutine. | |||
| 1242 | + | // query is the search text; limit caps the number of results. | |||
| 1243 | + | func (o *Omnibox) searchHistory(query string, limit int) { | |||
| 1216 | go func() { | 1244 | go func() { | ||
| 1217 | ctx := o.ctx | 1245 | ctx := o.ctx | ||
| 1218 | log := logging.FromContext(ctx) | 1246 | log := logging.FromContext(ctx) | ||
| @@ -1231,7 +1259,7 @@ func (o *Omnibox) performSearch() { | |||||
| 1231 | go func() { | 1259 | go func() { | ||
| 1232 | searchInput := usecase.SearchInput{ | 1260 | searchInput := usecase.SearchInput{ | ||
| 1233 | Query: query, | 1261 | Query: query, | ||
| 1234 | - | Limit: OmniboxListDefaults.MaxResults, | 1262 | + | Limit: limit, |
| 1235 | } | 1263 | } | ||
| 1236 | output, err := o.historyUC.Search(ctx, searchInput) | 1264 | output, err := o.historyUC.Search(ctx, searchInput) | ||
| 1237 | searchCh <- searchResult{output, err} | 1265 | searchCh <- searchResult{output, err} | ||
| @@ -1274,6 +1302,9 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1274 | return | 1302 | return | ||
| 1275 | } | 1303 | } | ||
| 1276 | 1304 | ||||
| 1305 | + | // Capture effective result limit on the GTK main thread before spawning goroutine | |||
| 1306 | + | initialLimit := o.effectiveMaxRows() | |||
| 1307 | + | ||||
| 1277 | go func() { | 1308 | go func() { | ||
| 1278 | ctx := o.ctx | 1309 | ctx := o.ctx | ||
| 1279 | log := logging.FromContext(ctx) | 1310 | log := logging.FromContext(ctx) | ||
| @@ -1295,7 +1326,7 @@ func (o *Omnibox) loadInitialHistory() { | |||||
| 1295 | 1326 | ||||
| 1296 | go func() { | 1327 | go func() { | ||
| 1297 | // TODO: Implement GetMostVisited in use case if needed | 1328 | // TODO: Implement GetMostVisited in use case if needed | ||
| 1298 | - | results, err := o.historyUC.GetRecent(ctx, OmniboxListDefaults.MaxResults, 0) | 1329 | + | results, err := o.historyUC.GetRecent(ctx, initialLimit, 0) |
| 1299 | historyCh <- historyResult{results, err} | 1330 | historyCh <- historyResult{results, err} | ||
| 1300 | }() | 1331 | }() | ||
| 1301 | 1332 | ||||
M internal/ui/component/workspace_view.go +5 -0
| @@ -224,6 +224,11 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||
| 224 | 224 | wv.hideFindBarInternal() | |
| 225 | 225 | } | |
| 226 | 226 | ||
| 227 | + | // Hide omnibox if active pane is changing | |
| 228 | + | if currentActiveID != paneID && wv.omnibox != nil { | |
| 229 | + | wv.hideOmniboxInternal() | |
| 230 | + | } | |
| 231 | + | ||
| 227 | 232 | // Deactivate current active pane | |
| 228 | 233 | if currentActiveID != "" { | |
| 229 | 234 | if oldPV, ok := wv.paneViews[currentActiveID]; ok { | |
| @@ -224,6 +224,11 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||||
| 224 | wv.hideFindBarInternal() | 224 | wv.hideFindBarInternal() | ||
| 225 | } | 225 | } | ||
| 226 | 226 | ||||
| 227 | + | // Hide omnibox if active pane is changing | |||
| 228 | + | if currentActiveID != paneID && wv.omnibox != nil { | |||
| 229 | + | wv.hideOmniboxInternal() | |||
| 230 | + | } | |||
| 231 | + | ||||
| 227 | // Deactivate current active pane | 232 | // Deactivate current active pane | ||
| 228 | if currentActiveID != "" { | 233 | if currentActiveID != "" { | ||
| 229 | if oldPV, ok := wv.paneViews[currentActiveID]; ok { | 234 | if oldPV, ok := wv.paneViews[currentActiveID]; ok { | ||
M internal/ui/input/keyboard.go +3 -3
| @@ -41,7 +41,7 @@ type KeyboardHandler struct { | |||
| 41 | 41 | // Action handler callback | |
| 42 | 42 | onAction ActionHandler | |
| 43 | 43 | // Optional bypass check (e.g., omnibox visible) | |
| 44 | - | shouldBypass func() bool | |
| 44 | + | shouldBypass func(modifiers Modifier) bool | |
| 45 | 45 | // Optional accent handler for dead keys support | |
| 46 | 46 | accentHandler AccentHandler | |
| 47 | 47 | ||
| @@ -99,7 +99,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||
| 99 | 99 | ||
| 100 | 100 | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | |
| 101 | 101 | // When true, events propagate to focused widgets instead. | |
| 102 | - | func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) { | |
| 102 | + | func (h *KeyboardHandler) SetShouldBypassInput(fn func(modifiers Modifier) bool) { | |
| 103 | 103 | h.mu.Lock() | |
| 104 | 104 | defer h.mu.Unlock() | |
| 105 | 105 | h.shouldBypass = fn | |
| @@ -191,7 +191,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 191 | 191 | return true | |
| 192 | 192 | } | |
| 193 | 193 | ||
| 194 | - | if shouldBypass != nil && shouldBypass() { | |
| 194 | + | if shouldBypass != nil && shouldBypass(modifiers) { | |
| 195 | 195 | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | |
| 196 | 196 | return false | |
| 197 | 197 | } | |
| @@ -41,7 +41,7 @@ type KeyboardHandler struct { | |||||
| 41 | // Action handler callback | 41 | // Action handler callback | ||
| 42 | onAction ActionHandler | 42 | onAction ActionHandler | ||
| 43 | // Optional bypass check (e.g., omnibox visible) | 43 | // Optional bypass check (e.g., omnibox visible) | ||
| 44 | - | shouldBypass func() bool | 44 | + | shouldBypass func(modifiers Modifier) bool |
| 45 | // Optional accent handler for dead keys support | 45 | // Optional accent handler for dead keys support | ||
| 46 | accentHandler AccentHandler | 46 | accentHandler AccentHandler | ||
| 47 | 47 | ||||
| @@ -99,7 +99,7 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||||
| 99 | 99 | ||||
| 100 | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | 100 | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | ||
| 101 | // When true, events propagate to focused widgets instead. | 101 | // When true, events propagate to focused widgets instead. | ||
| 102 | - | func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) { | 102 | + | func (h *KeyboardHandler) SetShouldBypassInput(fn func(modifiers Modifier) bool) { |
| 103 | h.mu.Lock() | 103 | h.mu.Lock() | ||
| 104 | defer h.mu.Unlock() | 104 | defer h.mu.Unlock() | ||
| 105 | h.shouldBypass = fn | 105 | h.shouldBypass = fn | ||
| @@ -191,7 +191,7 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 191 | return true | 191 | return true | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
| 194 | - | if shouldBypass != nil && shouldBypass() { | 194 | + | if shouldBypass != nil && shouldBypass(modifiers) { |
| 195 | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | 195 | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | ||
| 196 | return false | 196 | return false | ||
| 197 | } | 197 | } | ||