detached aefccc8

fix(omnibox): center in-pane omnibox and show 5 of 10 scrollable results (#466)

* fix(omnibox): center in-pane omnibox and show 5 of 10 scrollable results

* fix(omnibox): cap rendered rows to navigable results and scroll only on keyboard selection

* fix(omnibox): log applied top margin and tighten result limit tests

* build(lint): pin golangci-lint v2.14.0 for Go 1.27.2 export data

* fix(omnibox): top-align omnibox when a top margin percentage is configured
commit
aefccc8a62499d798b66b3c232be30ff68f89650
author
bnema · · 2026-10-10 01:31 UTC
committer
GitHub · · 2026-10-10 01:31 UTC
parent
e7ebbf7
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +155 -75
M Makefile +1 -1
@@ -14,7 +14,7 @@ MAIN_PATH=./cmd/dumber
1414 DIST_DIR=dist
1515 LOCAL_BIN_DIR?=$(HOME)/.local/bin
1616 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin
17-GOLANGCI_LINT_VERSION?=v2.13.2
17+GOLANGCI_LINT_VERSION?=v2.14.0
1818 STATICCHECK_VERSION?=v0.7.0
1919
2020 # Detect number of CPU cores for parallel compilation
@@ -14,7 +14,7 @@ MAIN_PATH=./cmd/dumber
14 DIST_DIR=dist14 DIST_DIR=dist
15 LOCAL_BIN_DIR?=$(HOME)/.local/bin15 LOCAL_BIN_DIR?=$(HOME)/.local/bin
16 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin16 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin
17-GOLANGCI_LINT_VERSION?=v2.13.217+GOLANGCI_LINT_VERSION?=v2.14.0
18 STATICCHECK_VERSION?=v0.7.018 STATICCHECK_VERSION?=v0.7.0
19 19
20 # Detect number of CPU cores for parallel compilation20 # Detect number of CPU cores for parallel compilation
M internal/ui/component/modal_sizing.go +5 -4
@@ -43,19 +43,20 @@ var DefaultRowHeights = RowHeightDefaults{
4343 }
4444
4545 // OmniboxSizeDefaults provides default sizing for omnibox modal.
46+// The in-pane omnibox is vertically centered, so it has no top margin.
4647 var OmniboxSizeDefaults = ModalSizeConfig{
4748 WidthPct: 0.8,
4849 MaxWidth: 800,
49- TopMarginPct: 0.2,
5050 FallbackWidth: 800,
5151 FallbackHeight: 600,
5252 }
5353
54-// OmniboxListDefaults provides display limits for omnibox modal.
54+// OmniboxListDefaults provides display limits for omnibox modal:
55+// MaxResults rows are listed and navigable, MaxVisibleRows fit before scrolling.
5556 var OmniboxListDefaults = ListDisplayDefaults{
56- MaxVisibleRows: 10,
57+ MaxVisibleRows: 5,
5758 MaxResults: 10,
58- SmallMaxVisibleRows: 5,
59+ SmallMaxVisibleRows: 3,
5960 }
6061
6162 // SessionManagerSizeDefaults provides default sizing for session manager modal.
@@ -43,19 +43,20 @@ var DefaultRowHeights = RowHeightDefaults{
43 }43 }
44 44
45 // OmniboxSizeDefaults provides default sizing for omnibox modal.45 // OmniboxSizeDefaults provides default sizing for omnibox modal.
46+// The in-pane omnibox is vertically centered, so it has no top margin.
46 var OmniboxSizeDefaults = ModalSizeConfig{47 var OmniboxSizeDefaults = ModalSizeConfig{
47 WidthPct: 0.8,48 WidthPct: 0.8,
48 MaxWidth: 800,49 MaxWidth: 800,
49- TopMarginPct: 0.2,
50 FallbackWidth: 800,50 FallbackWidth: 800,
51 FallbackHeight: 600,51 FallbackHeight: 600,
52 }52 }
53 53
54-// OmniboxListDefaults provides display limits for omnibox modal.54+// OmniboxListDefaults provides display limits for omnibox modal:
55+// MaxResults rows are listed and navigable, MaxVisibleRows fit before scrolling.
55 var OmniboxListDefaults = ListDisplayDefaults{56 var OmniboxListDefaults = ListDisplayDefaults{
56- MaxVisibleRows: 10,57+ MaxVisibleRows: 5,
57 MaxResults: 10,58 MaxResults: 10,
58- SmallMaxVisibleRows: 5,59+ SmallMaxVisibleRows: 3,
59 }60 }
60 61
61 // SessionManagerSizeDefaults provides default sizing for session manager modal.62 // SessionManagerSizeDefaults provides default sizing for session manager modal.
M internal/ui/component/modal_sizing_test.go +16 -3
@@ -5,6 +5,7 @@ import (
55 "testing"
66
77 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
8+ "github.com/bnema/puregotk/v4/gtk"
89 "github.com/stretchr/testify/assert"
910 )
1011
@@ -168,10 +169,22 @@ func TestDefaultRowHeights_Values(t *testing.T) {
168169 func TestOmniboxSizeDefaults_Values(t *testing.T) {
169170 assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001)
170171 assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth)
171- assert.InDelta(t, 0.2, OmniboxSizeDefaults.TopMarginPct, 0.0001)
172+ assert.Zero(t, OmniboxSizeDefaults.TopMarginPct)
172173 assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight)
173174 }
174175
176+func TestOmniboxValign(t *testing.T) {
177+ assert.Equal(t, gtk.AlignCenterValue, omniboxValign(OmniboxSizeDefaults))
178+ assert.Equal(t, gtk.AlignStartValue, omniboxValign(ModalSizeConfig{UseFixedTopMargin: true}))
179+ assert.Equal(t, gtk.AlignStartValue, omniboxValign(ResolveModalSizeConfig(
180+ ModalSizeConfig{TopMarginPct: 0.2}, OmniboxSizeDefaults)))
181+}
182+
183+func TestOmniboxListDefaults_ScrollableResults(t *testing.T) {
184+ assert.Less(t, OmniboxListDefaults.MaxVisibleRows, OmniboxListDefaults.MaxResults)
185+ assert.Less(t, OmniboxListDefaults.SmallMaxVisibleRows, OmniboxListDefaults.MaxVisibleRows)
186+}
187+
175188 func TestSessionManagerSizeDefaults_Values(t *testing.T) {
176189 assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)
177190 assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)
@@ -325,7 +338,7 @@ func TestCalculateModalDimensionsWithScale_LargeParent(t *testing.T) {
325338 parent := mockParent(t, 3000, 1200)
326339 w, m := CalculateModalDimensionsWithScale(parent, cfg, tt.scale)
327340 assert.Equal(t, tt.want, w, "scale %v", tt.scale)
328- assert.Equal(t, 240, m, "top margin unchanged")
341+ assert.Zero(t, m, "omnibox is centered, not top-margined")
329342 }
330343 }
331344
@@ -351,7 +364,7 @@ func TestCalculateModalDimensionsWithScale_NormalizesBadScale(t *testing.T) {
351364 parent := mockParent(t, 3000, 1080)
352365 w, m := CalculateModalDimensionsWithScale(parent, cfg, scale)
353366 assert.Equal(t, 800, w, "scale %v", scale)
354- assert.Equal(t, 216, m, "scale %v", scale)
367+ assert.Zero(t, m, "scale %v", scale)
355368 }
356369 }
357370
@@ -5,6 +5,7 @@ import (
5 "testing"5 "testing"
6 6
7 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"7 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
8+ "github.com/bnema/puregotk/v4/gtk"
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 )10 )
10 11
@@ -168,10 +169,22 @@ func TestDefaultRowHeights_Values(t *testing.T) {
168 func TestOmniboxSizeDefaults_Values(t *testing.T) {169 func TestOmniboxSizeDefaults_Values(t *testing.T) {
169 assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001)170 assert.InDelta(t, 0.8, OmniboxSizeDefaults.WidthPct, 0.0001)
170 assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth)171 assert.Equal(t, 800, OmniboxSizeDefaults.MaxWidth)
171- assert.InDelta(t, 0.2, OmniboxSizeDefaults.TopMarginPct, 0.0001)172+ assert.Zero(t, OmniboxSizeDefaults.TopMarginPct)
172 assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight)173 assert.Equal(t, 600, OmniboxSizeDefaults.FallbackHeight)
173 }174 }
174 175
176+func TestOmniboxValign(t *testing.T) {
177+ assert.Equal(t, gtk.AlignCenterValue, omniboxValign(OmniboxSizeDefaults))
178+ assert.Equal(t, gtk.AlignStartValue, omniboxValign(ModalSizeConfig{UseFixedTopMargin: true}))
179+ assert.Equal(t, gtk.AlignStartValue, omniboxValign(ResolveModalSizeConfig(
180+ ModalSizeConfig{TopMarginPct: 0.2}, OmniboxSizeDefaults)))
181+}
182+
183+func TestOmniboxListDefaults_ScrollableResults(t *testing.T) {
184+ assert.Less(t, OmniboxListDefaults.MaxVisibleRows, OmniboxListDefaults.MaxResults)
185+ assert.Less(t, OmniboxListDefaults.SmallMaxVisibleRows, OmniboxListDefaults.MaxVisibleRows)
186+}
187+
175 func TestSessionManagerSizeDefaults_Values(t *testing.T) {188 func TestSessionManagerSizeDefaults_Values(t *testing.T) {
176 assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)189 assert.Greater(t, SessionManagerSizeDefaults.WidthPct, 0.0)
177 assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)190 assert.LessOrEqual(t, SessionManagerSizeDefaults.WidthPct, 1.0)
@@ -325,7 +338,7 @@ func TestCalculateModalDimensionsWithScale_LargeParent(t *testing.T) {
325 parent := mockParent(t, 3000, 1200)338 parent := mockParent(t, 3000, 1200)
326 w, m := CalculateModalDimensionsWithScale(parent, cfg, tt.scale)339 w, m := CalculateModalDimensionsWithScale(parent, cfg, tt.scale)
327 assert.Equal(t, tt.want, w, "scale %v", tt.scale)340 assert.Equal(t, tt.want, w, "scale %v", tt.scale)
328- assert.Equal(t, 240, m, "top margin unchanged")341+ assert.Zero(t, m, "omnibox is centered, not top-margined")
329 }342 }
330 }343 }
331 344
@@ -351,7 +364,7 @@ func TestCalculateModalDimensionsWithScale_NormalizesBadScale(t *testing.T) {
351 parent := mockParent(t, 3000, 1080)364 parent := mockParent(t, 3000, 1080)
352 w, m := CalculateModalDimensionsWithScale(parent, cfg, scale)365 w, m := CalculateModalDimensionsWithScale(parent, cfg, scale)
353 assert.Equal(t, 800, w, "scale %v", scale)366 assert.Equal(t, 800, w, "scale %v", scale)
354- assert.Equal(t, 216, m, "scale %v", scale)367+ assert.Zero(t, m, "scale %v", scale)
355 }368 }
356 }369 }
357 370
M internal/ui/component/omnibox.go +66 -37
@@ -776,12 +776,22 @@ func (o *Omnibox) initOuterBox() error {
776776 return errNilWidget("outerBox")
777777 }
778778 o.outerBox.AddCssClass("omnibox-outer")
779- o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally
780- o.outerBox.SetValign(gtk.AlignStartValue) // Align to top
781- o.outerBox.SetVisible(false) // Hidden by default
779+ o.outerBox.SetHalign(gtk.AlignCenterValue)
780+ o.outerBox.SetValign(omniboxValign(o.sizeCfg))
781+ o.outerBox.SetVisible(false) // Hidden by default
782782 return nil
783783 }
784784
785+// omniboxValign centers the omnibox vertically by default. Any configured top
786+// margin (fixed for the content-sized standalone window, or a percentage
787+// override) keeps it top-aligned so the margin is applied as requested.
788+func omniboxValign(cfg ModalSizeConfig) gtk.Align {
789+ if cfg.UseFixedTopMargin || cfg.TopMarginPct > 0 {
790+ return gtk.AlignStartValue
791+ }
792+ return gtk.AlignCenterValue
793+}
794+
785795 func (o *Omnibox) initMainBox() error {
786796 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
787797 if o.mainBox == nil {
@@ -923,7 +933,7 @@ func (o *Omnibox) initList() error {
923933 return
924934 }
925935
926- targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
936+ targetURL := resolveTargetURLForSelection(mode, idx, OmniboxListDefaults.MaxResults, suggestions, favorites)
927937
928938 if targetURL != "" {
929939 o.submitNavigation(targetURL)
@@ -1503,7 +1513,7 @@ func (o *Omnibox) restoreEntryToRealInput() {
15031513 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
15041514 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
15051515 log := logging.FromContext(o.ctx)
1506- maxVisible := o.effectiveMaxRows()
1516+ maxResults := OmniboxListDefaults.MaxResults
15071517 o.mu.RLock()
15081518 idx := o.selectedIndex
15091519 mode := o.viewMode
@@ -1531,10 +1541,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
15311541 return
15321542 }
15331543
1534- targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)
1544+ targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxResults, suggestions, favorites)
15351545
15361546 if entryText != "" {
1537- o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)
1547+ o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxResults, suggestions, favorites)
15381548 return
15391549 }
15401550
@@ -1556,7 +1566,7 @@ func (o *Omnibox) resolveGhostCompletion(
15561566 entryText, selectedURL string,
15571567 hasExplicitSelection bool,
15581568 mode ViewMode,
1559- maxVisible int,
1569+ maxResults int,
15601570 suggestions []Suggestion,
15611571 favorites []Favorite,
15621572 ) {
@@ -1566,7 +1576,7 @@ func (o *Omnibox) resolveGhostCompletion(
15661576 SelectedURL: selectedURL,
15671577 HasExplicitSelection: hasExplicitSelection,
15681578 Mode: mode,
1569- MaxVisible: maxVisible,
1579+ MaxResults: maxResults,
15701580 Suggestions: suggestions,
15711581 Favorites: favorites,
15721582 })
@@ -1632,7 +1642,7 @@ func (o *Omnibox) performSearch() {
16321642 }
16331643
16341644 // Perform fuzzy history search in background
1635- o.searchHistory(query, o.effectiveMaxRows(), token)
1645+ o.searchHistory(query, OmniboxListDefaults.MaxResults, token)
16361646 }
16371647
16381648 // searchHistory runs a fuzzy history search in a background goroutine.
@@ -1706,7 +1716,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
17061716
17071717 // Capture effective result limit and age window on the GTK main thread
17081718 // before spawning goroutine.
1709- initialLimit := o.effectiveMaxRows()
1719+ initialLimit := OmniboxListDefaults.MaxResults
17101720 initialBehavior := o.initialBehavior
17111721 maxHistoryDays := o.maxHistoryDays
17121722
@@ -1962,8 +1972,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
19621972
19631973 // rebuildList rebuilds the ListBox contents.
19641974 func (o *Omnibox) rebuildList() {
1965- // Clear existing rows
1975+ // Clear existing rows and show new results from the top
19661976 o.listBox.RemoveAll()
1977+ if o.scrolledWin != nil {
1978+ if adj := o.scrolledWin.GetVadjustment(); adj != nil {
1979+ adj.SetValue(0)
1980+ }
1981+ }
19671982
19681983 o.mu.RLock()
19691984 mode := o.viewMode
@@ -1973,6 +1988,11 @@ func (o *Omnibox) rebuildList() {
19731988 bangSuggestions := o.bangSuggestions
19741989 o.mu.RUnlock()
19751990
1991+ limit := OmniboxListDefaults.MaxResults
1992+ bangSuggestions = bangSuggestions[:limitedCount(len(bangSuggestions), limit)]
1993+ suggestions = suggestions[:limitedCount(len(suggestions), limit)]
1994+ favorites = favorites[:limitedCount(len(favorites), limit)]
1995+
19761996 if bangMode {
19771997 for i, b := range bangSuggestions {
19781998 row := o.createBangRow(b, i)
@@ -2259,21 +2279,37 @@ func (o *Omnibox) selectIndex(index int) {
22592279 o.updateGhostFromSelection()
22602280 }
22612281
2282+// scrollSelectionIntoView scrolls the results list so the row at index is
2283+// fully visible. The entry keeps keyboard focus, so GTK does not scroll the
2284+// list itself. Only keyboard navigation calls this: hover must not move rows
2285+// under the pointer.
2286+func (o *Omnibox) scrollSelectionIntoView(index int) {
2287+ if o.scrolledWin == nil || o.listBox == nil {
2288+ return
2289+ }
2290+ row := o.listBox.GetRowAtIndex(index)
2291+ if row == nil {
2292+ return
2293+ }
2294+ adj := o.scrolledWin.GetVadjustment()
2295+ if adj == nil {
2296+ return
2297+ }
2298+ src := &graphene.Point{}
2299+ out := &graphene.Point{}
2300+ if !row.ComputePoint(&o.listBox.Widget, src, out) {
2301+ return
2302+ }
2303+ top := float64(out.Y)
2304+ adj.ClampPage(top, top+float64(row.GetAllocatedHeight()))
2305+}
2306+
22622307 // selectNext moves selection down.
22632308 func (o *Omnibox) selectNext() {
22642309 o.mu.Lock()
22652310 current := o.selectedIndex
2266- mode := o.viewMode
2267- bangMode := o.bangMode
2268- maxVisible := o.effectiveMaxRows()
2269- var maxIndex int
2270- if bangMode {
2271- maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2272- } else if mode == ViewModeHistory {
2273- maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2274- } else {
2275- maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2276- }
2311+ maxIndex := navigableCount(o.bangMode, o.viewMode,
2312+ len(o.bangSuggestions), len(o.suggestions), len(o.favorites)) - 1
22772313 o.hasNavigated = true // User is navigating with arrow keys
22782314 o.mu.Unlock()
22792315
@@ -2286,23 +2322,15 @@ func (o *Omnibox) selectNext() {
22862322 newIndex = 0 // Wrap around
22872323 }
22882324 o.selectIndex(newIndex)
2325+ o.scrollSelectionIntoView(newIndex)
22892326 }
22902327
22912328 // selectPrevious moves selection up.
22922329 func (o *Omnibox) selectPrevious() {
22932330 o.mu.Lock()
22942331 current := o.selectedIndex
2295- mode := o.viewMode
2296- bangMode := o.bangMode
2297- maxVisible := o.effectiveMaxRows()
2298- var maxIndex int
2299- if bangMode {
2300- maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2301- } else if mode == ViewModeHistory {
2302- maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2303- } else {
2304- maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2305- }
2332+ maxIndex := navigableCount(o.bangMode, o.viewMode,
2333+ len(o.bangSuggestions), len(o.suggestions), len(o.favorites)) - 1
23062334 o.hasNavigated = true // User is navigating with arrow keys
23072335 o.mu.Unlock()
23082336
@@ -2315,6 +2343,7 @@ func (o *Omnibox) selectPrevious() {
23152343 newIndex = maxIndex // Wrap around
23162344 }
23172345 o.selectIndex(newIndex)
2346+ o.scrollSelectionIntoView(newIndex)
23182347 }
23192348
23202349 // selectAndNavigate selects an index and navigates to it.
@@ -2327,14 +2356,14 @@ func (o *Omnibox) selectAndNavigate(index int) {
23272356 suggestions := o.suggestions
23282357 favorites := o.favorites
23292358 o.mu.RUnlock()
2330- maxVisible := o.effectiveMaxRows()
2359+ maxResults := OmniboxListDefaults.MaxResults
23312360
23322361 if bangMode {
23332362 o.navigateToSelected()
23342363 return
23352364 }
23362365
2337- targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)
2366+ targetURL := resolveTargetURLForSelection(mode, index, maxResults, suggestions, favorites)
23382367 if targetURL == "" {
23392368 return
23402369 }
@@ -2400,7 +2429,7 @@ func (o *Omnibox) navigateToSelected() {
24002429 var targetURL string
24012430 if idx >= 0 {
24022431 // If user has selected a result, navigate to that result.
2403- targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)
2432+ targetURL = resolveTargetURLForSelection(mode, idx, OmniboxListDefaults.MaxResults, suggestions, favorites)
24042433 } else {
24052434 targetURL = o.buildURL(entryText)
24062435 }
@@ -776,12 +776,22 @@ func (o *Omnibox) initOuterBox() error {
776 return errNilWidget("outerBox")776 return errNilWidget("outerBox")
777 }777 }
778 o.outerBox.AddCssClass("omnibox-outer")778 o.outerBox.AddCssClass("omnibox-outer")
779- o.outerBox.SetHalign(gtk.AlignCenterValue) // Center horizontally779+ o.outerBox.SetHalign(gtk.AlignCenterValue)
780- o.outerBox.SetValign(gtk.AlignStartValue) // Align to top780+ o.outerBox.SetValign(omniboxValign(o.sizeCfg))
781- o.outerBox.SetVisible(false) // Hidden by default781+ o.outerBox.SetVisible(false) // Hidden by default
782 return nil782 return nil
783 }783 }
784 784
785+// omniboxValign centers the omnibox vertically by default. Any configured top
786+// margin (fixed for the content-sized standalone window, or a percentage
787+// override) keeps it top-aligned so the margin is applied as requested.
788+func omniboxValign(cfg ModalSizeConfig) gtk.Align {
789+ if cfg.UseFixedTopMargin || cfg.TopMarginPct > 0 {
790+ return gtk.AlignStartValue
791+ }
792+ return gtk.AlignCenterValue
793+}
794+
785 func (o *Omnibox) initMainBox() error {795 func (o *Omnibox) initMainBox() error {
786 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)796 o.mainBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
787 if o.mainBox == nil {797 if o.mainBox == nil {
@@ -923,7 +933,7 @@ func (o *Omnibox) initList() error {
923 return933 return
924 }934 }
925 935
926- targetURL := resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)936+ targetURL := resolveTargetURLForSelection(mode, idx, OmniboxListDefaults.MaxResults, suggestions, favorites)
927 937
928 if targetURL != "" {938 if targetURL != "" {
929 o.submitNavigation(targetURL)939 o.submitNavigation(targetURL)
@@ -1503,7 +1513,7 @@ func (o *Omnibox) restoreEntryToRealInput() {
1503 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.1513 // updateGhostFromSelectionWithInput updates ghost text based on selected row and input.
1504 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {1514 func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1505 log := logging.FromContext(o.ctx)1515 log := logging.FromContext(o.ctx)
1506- maxVisible := o.effectiveMaxRows()1516+ maxResults := OmniboxListDefaults.MaxResults
1507 o.mu.RLock()1517 o.mu.RLock()
1508 idx := o.selectedIndex1518 idx := o.selectedIndex
1509 mode := o.viewMode1519 mode := o.viewMode
@@ -1531,10 +1541,10 @@ func (o *Omnibox) updateGhostFromSelectionWithInput(entryText string) {
1531 return1541 return
1532 }1542 }
1533 1543
1534- targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxVisible, suggestions, favorites)1544+ targetURL, hasExplicitSelection := selectedTargetURL(mode, idx, maxResults, suggestions, favorites)
1535 1545
1536 if entryText != "" {1546 if entryText != "" {
1537- o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxVisible, suggestions, favorites)1547+ o.resolveGhostCompletion(entryText, targetURL, hasExplicitSelection, mode, maxResults, suggestions, favorites)
1538 return1548 return
1539 }1549 }
1540 1550
@@ -1556,7 +1566,7 @@ func (o *Omnibox) resolveGhostCompletion(
1556 entryText, selectedURL string,1566 entryText, selectedURL string,
1557 hasExplicitSelection bool,1567 hasExplicitSelection bool,
1558 mode ViewMode,1568 mode ViewMode,
1559- maxVisible int,1569+ maxResults int,
1560 suggestions []Suggestion,1570 suggestions []Suggestion,
1561 favorites []Favorite,1571 favorites []Favorite,
1562 ) {1572 ) {
@@ -1566,7 +1576,7 @@ func (o *Omnibox) resolveGhostCompletion(
1566 SelectedURL: selectedURL,1576 SelectedURL: selectedURL,
1567 HasExplicitSelection: hasExplicitSelection,1577 HasExplicitSelection: hasExplicitSelection,
1568 Mode: mode,1578 Mode: mode,
1569- MaxVisible: maxVisible,1579+ MaxResults: maxResults,
1570 Suggestions: suggestions,1580 Suggestions: suggestions,
1571 Favorites: favorites,1581 Favorites: favorites,
1572 })1582 })
@@ -1632,7 +1642,7 @@ func (o *Omnibox) performSearch() {
1632 }1642 }
1633 1643
1634 // Perform fuzzy history search in background1644 // Perform fuzzy history search in background
1635- o.searchHistory(query, o.effectiveMaxRows(), token)1645+ o.searchHistory(query, OmniboxListDefaults.MaxResults, token)
1636 }1646 }
1637 1647
1638 // searchHistory runs a fuzzy history search in a background goroutine.1648 // searchHistory runs a fuzzy history search in a background goroutine.
@@ -1706,7 +1716,7 @@ func (o *Omnibox) loadInitialHistory(token uint64) {
1706 1716
1707 // Capture effective result limit and age window on the GTK main thread1717 // Capture effective result limit and age window on the GTK main thread
1708 // before spawning goroutine.1718 // before spawning goroutine.
1709- initialLimit := o.effectiveMaxRows()1719+ initialLimit := OmniboxListDefaults.MaxResults
1710 initialBehavior := o.initialBehavior1720 initialBehavior := o.initialBehavior
1711 maxHistoryDays := o.maxHistoryDays1721 maxHistoryDays := o.maxHistoryDays
1712 1722
@@ -1962,8 +1972,13 @@ func (o *Omnibox) updateFavorites(favorites []Favorite, query string) {
1962 1972
1963 // rebuildList rebuilds the ListBox contents.1973 // rebuildList rebuilds the ListBox contents.
1964 func (o *Omnibox) rebuildList() {1974 func (o *Omnibox) rebuildList() {
1965- // Clear existing rows1975+ // Clear existing rows and show new results from the top
1966 o.listBox.RemoveAll()1976 o.listBox.RemoveAll()
1977+ if o.scrolledWin != nil {
1978+ if adj := o.scrolledWin.GetVadjustment(); adj != nil {
1979+ adj.SetValue(0)
1980+ }
1981+ }
1967 1982
1968 o.mu.RLock()1983 o.mu.RLock()
1969 mode := o.viewMode1984 mode := o.viewMode
@@ -1973,6 +1988,11 @@ func (o *Omnibox) rebuildList() {
1973 bangSuggestions := o.bangSuggestions1988 bangSuggestions := o.bangSuggestions
1974 o.mu.RUnlock()1989 o.mu.RUnlock()
1975 1990
1991+ limit := OmniboxListDefaults.MaxResults
1992+ bangSuggestions = bangSuggestions[:limitedCount(len(bangSuggestions), limit)]
1993+ suggestions = suggestions[:limitedCount(len(suggestions), limit)]
1994+ favorites = favorites[:limitedCount(len(favorites), limit)]
1995+
1976 if bangMode {1996 if bangMode {
1977 for i, b := range bangSuggestions {1997 for i, b := range bangSuggestions {
1978 row := o.createBangRow(b, i)1998 row := o.createBangRow(b, i)
@@ -2259,21 +2279,37 @@ func (o *Omnibox) selectIndex(index int) {
2259 o.updateGhostFromSelection()2279 o.updateGhostFromSelection()
2260 }2280 }
2261 2281
2282+// scrollSelectionIntoView scrolls the results list so the row at index is
2283+// fully visible. The entry keeps keyboard focus, so GTK does not scroll the
2284+// list itself. Only keyboard navigation calls this: hover must not move rows
2285+// under the pointer.
2286+func (o *Omnibox) scrollSelectionIntoView(index int) {
2287+ if o.scrolledWin == nil || o.listBox == nil {
2288+ return
2289+ }
2290+ row := o.listBox.GetRowAtIndex(index)
2291+ if row == nil {
2292+ return
2293+ }
2294+ adj := o.scrolledWin.GetVadjustment()
2295+ if adj == nil {
2296+ return
2297+ }
2298+ src := &graphene.Point{}
2299+ out := &graphene.Point{}
2300+ if !row.ComputePoint(&o.listBox.Widget, src, out) {
2301+ return
2302+ }
2303+ top := float64(out.Y)
2304+ adj.ClampPage(top, top+float64(row.GetAllocatedHeight()))
2305+}
2306+
2262 // selectNext moves selection down.2307 // selectNext moves selection down.
2263 func (o *Omnibox) selectNext() {2308 func (o *Omnibox) selectNext() {
2264 o.mu.Lock()2309 o.mu.Lock()
2265 current := o.selectedIndex2310 current := o.selectedIndex
2266- mode := o.viewMode2311+ maxIndex := navigableCount(o.bangMode, o.viewMode,
2267- bangMode := o.bangMode2312+ len(o.bangSuggestions), len(o.suggestions), len(o.favorites)) - 1
2268- maxVisible := o.effectiveMaxRows()
2269- var maxIndex int
2270- if bangMode {
2271- maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2272- } else if mode == ViewModeHistory {
2273- maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2274- } else {
2275- maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2276- }
2277 o.hasNavigated = true // User is navigating with arrow keys2313 o.hasNavigated = true // User is navigating with arrow keys
2278 o.mu.Unlock()2314 o.mu.Unlock()
2279 2315
@@ -2286,23 +2322,15 @@ func (o *Omnibox) selectNext() {
2286 newIndex = 0 // Wrap around2322 newIndex = 0 // Wrap around
2287 }2323 }
2288 o.selectIndex(newIndex)2324 o.selectIndex(newIndex)
2325+ o.scrollSelectionIntoView(newIndex)
2289 }2326 }
2290 2327
2291 // selectPrevious moves selection up.2328 // selectPrevious moves selection up.
2292 func (o *Omnibox) selectPrevious() {2329 func (o *Omnibox) selectPrevious() {
2293 o.mu.Lock()2330 o.mu.Lock()
2294 current := o.selectedIndex2331 current := o.selectedIndex
2295- mode := o.viewMode2332+ maxIndex := navigableCount(o.bangMode, o.viewMode,
2296- bangMode := o.bangMode2333+ len(o.bangSuggestions), len(o.suggestions), len(o.favorites)) - 1
2297- maxVisible := o.effectiveMaxRows()
2298- var maxIndex int
2299- if bangMode {
2300- maxIndex = visibleResultCount(len(o.bangSuggestions), maxVisible) - 1
2301- } else if mode == ViewModeHistory {
2302- maxIndex = visibleResultCount(len(o.suggestions), maxVisible) - 1
2303- } else {
2304- maxIndex = visibleResultCount(len(o.favorites), maxVisible) - 1
2305- }
2306 o.hasNavigated = true // User is navigating with arrow keys2334 o.hasNavigated = true // User is navigating with arrow keys
2307 o.mu.Unlock()2335 o.mu.Unlock()
2308 2336
@@ -2315,6 +2343,7 @@ func (o *Omnibox) selectPrevious() {
2315 newIndex = maxIndex // Wrap around2343 newIndex = maxIndex // Wrap around
2316 }2344 }
2317 o.selectIndex(newIndex)2345 o.selectIndex(newIndex)
2346+ o.scrollSelectionIntoView(newIndex)
2318 }2347 }
2319 2348
2320 // selectAndNavigate selects an index and navigates to it.2349 // selectAndNavigate selects an index and navigates to it.
@@ -2327,14 +2356,14 @@ func (o *Omnibox) selectAndNavigate(index int) {
2327 suggestions := o.suggestions2356 suggestions := o.suggestions
2328 favorites := o.favorites2357 favorites := o.favorites
2329 o.mu.RUnlock()2358 o.mu.RUnlock()
2330- maxVisible := o.effectiveMaxRows()2359+ maxResults := OmniboxListDefaults.MaxResults
2331 2360
2332 if bangMode {2361 if bangMode {
2333 o.navigateToSelected()2362 o.navigateToSelected()
2334 return2363 return
2335 }2364 }
2336 2365
2337- targetURL := resolveTargetURLForSelection(mode, index, maxVisible, suggestions, favorites)2366+ targetURL := resolveTargetURLForSelection(mode, index, maxResults, suggestions, favorites)
2338 if targetURL == "" {2367 if targetURL == "" {
2339 return2368 return
2340 }2369 }
@@ -2400,7 +2429,7 @@ func (o *Omnibox) navigateToSelected() {
2400 var targetURL string2429 var targetURL string
2401 if idx >= 0 {2430 if idx >= 0 {
2402 // If user has selected a result, navigate to that result.2431 // If user has selected a result, navigate to that result.
2403- targetURL = resolveTargetURLForSelection(mode, idx, o.effectiveMaxRows(), suggestions, favorites)2432+ targetURL = resolveTargetURLForSelection(mode, idx, OmniboxListDefaults.MaxResults, suggestions, favorites)
2404 } else {2433 } else {
2405 targetURL = o.buildURL(entryText)2434 targetURL = o.buildURL(entryText)
2406 }2435 }
M internal/ui/component/omnibox_initial_behavior_test.go +1 -1
@@ -287,7 +287,7 @@ func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) {
287287 repo := repomocks.NewMockHistoryRepository(t)
288288 done := make(chan struct{})
289289 repo.EXPECT().
290- GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything).
290+ GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxResults, mock.Anything).
291291 Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
292292 Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
293293
@@ -287,7 +287,7 @@ func TestOmniboxLoadInitialHistory_MostVisitedBoundsSQL(t *testing.T) {
287 repo := repomocks.NewMockHistoryRepository(t)287 repo := repomocks.NewMockHistoryRepository(t)
288 done := make(chan struct{})288 done := make(chan struct{})
289 repo.EXPECT().289 repo.EXPECT().
290- GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxVisibleRows, mock.Anything).290+ GetMostVisitedWithin(mock.Anything, OmniboxListDefaults.MaxResults, mock.Anything).
291 Run(func(context.Context, int, repository.HistoryScope) { close(done) }).291 Run(func(context.Context, int, repository.HistoryScope) { close(done) }).
292 Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)292 Return([]*entity.HistoryEntry{{URL: "https://example.com"}}, nil)
293 293
M internal/ui/component/omnibox_navigation_state_test.go +1 -1
@@ -360,7 +360,7 @@ func TestVisibleGhostSuggestion(t *testing.T) {
360360 }
361361 }
362362
363-func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) {
363+func TestVisibleGhostSuggestionRespectsResultLimit(t *testing.T) {
364364 suggestions := []Suggestion{
365365 {URL: "https://github.com/bnema/dumber"},
366366 {URL: "https://gitlab.com/team/project"},
@@ -360,7 +360,7 @@ func TestVisibleGhostSuggestion(t *testing.T) {
360 }360 }
361 }361 }
362 362
363-func TestVisibleGhostSuggestionRespectsVisibleLimit(t *testing.T) {363+func TestVisibleGhostSuggestionRespectsResultLimit(t *testing.T) {
364 suggestions := []Suggestion{364 suggestions := []Suggestion{
365 {URL: "https://github.com/bnema/dumber"},365 {URL: "https://github.com/bnema/dumber"},
366 {URL: "https://gitlab.com/team/project"},366 {URL: "https://gitlab.com/team/project"},
M internal/ui/component/omnibox_suggestions.go +35 -22
@@ -19,7 +19,7 @@ type ghostCompletionRequest struct {
1919 SelectedURL string
2020 HasExplicitSelection bool
2121 Mode ViewMode
22- MaxVisible int
22+ MaxResults int
2323 Suggestions []Suggestion
2424 Favorites []Favorite
2525 }
@@ -34,7 +34,7 @@ func ghostCompletion(req ghostCompletionRequest) (fullText, suffix string, ok bo
3434 return "", "", false
3535 }
3636 fullText, suffix, ok = visibleGhostSuggestion(
37- input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxVisible, req.Suggestions, req.Favorites,
37+ input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxResults, req.Suggestions, req.Favorites,
3838 )
3939 if !ok {
4040 return "", "", false
@@ -122,7 +122,7 @@ func visibleGhostSuggestion(
122122 query, selectedURL string,
123123 hasExplicitSelection bool,
124124 mode ViewMode,
125- maxVisible int,
125+ maxResults int,
126126 suggestions []Suggestion,
127127 favorites []Favorite,
128128 ) (fullText, suffix string, ok bool) {
@@ -131,16 +131,16 @@ func visibleGhostSuggestion(
131131 return fullText, suffix, ok
132132 }
133133
134- visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)
135- suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)
134+ listedURLs := listedURLsForMode(mode, maxResults, suggestions, favorites)
135+ suffix, fullText, ok = autocomplete.BestURLCompletion(query, listedURLs)
136136 return fullText, suffix, ok
137137 }
138138
139-func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {
139+func listedURLsForMode(mode ViewMode, maxResults int, suggestions []Suggestion, favorites []Favorite) []string {
140140 if mode == ViewModeHistory {
141- visibleCount := visibleResultCount(len(suggestions), maxVisible)
142- urls := make([]string, 0, visibleCount)
143- for _, s := range suggestions[:visibleCount] {
141+ count := limitedCount(len(suggestions), maxResults)
142+ urls := make([]string, 0, count)
143+ for _, s := range suggestions[:count] {
144144 if s.URL != "" {
145145 urls = append(urls, s.URL)
146146 }
@@ -148,9 +148,9 @@ func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion,
148148 return urls
149149 }
150150
151- visibleCount := visibleResultCount(len(favorites), maxVisible)
152- urls := make([]string, 0, visibleCount)
153- for _, f := range favorites[:visibleCount] {
151+ count := limitedCount(len(favorites), maxResults)
152+ urls := make([]string, 0, count)
153+ for _, f := range favorites[:count] {
154154 if f.URL != "" {
155155 urls = append(urls, f.URL)
156156 }
@@ -158,11 +158,11 @@ func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion,
158158 return urls
159159 }
160160
161-func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
161+func selectedTargetURL(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
162162 if idx < 0 {
163163 return "", false
164164 }
165- return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
165+ return resolveTargetURLForSelection(mode, idx, maxResults, suggestions, favorites), true
166166 }
167167
168168 func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
@@ -172,14 +172,27 @@ func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bo
172172 return "!" + bangSuggestions[idx].Key + " ", true
173173 }
174174
175-func visibleResultCount(total, maxVisible int) int {
175+func limitedCount(total, maxResults int) int {
176176 if total <= 0 {
177177 return 0
178178 }
179- if maxVisible <= 0 || total < maxVisible {
179+ if maxResults <= 0 || total < maxResults {
180180 return total
181181 }
182- return maxVisible
182+ return maxResults
183+}
184+
185+// navigableCount returns how many rows of the active list are rendered and
186+// reachable by keyboard: the active list length capped at MaxResults.
187+func navigableCount(bangMode bool, mode ViewMode, bangs, suggestions, favorites int) int {
188+ total := favorites
189+ switch {
190+ case bangMode:
191+ total = bangs
192+ case mode == ViewModeHistory:
193+ total = suggestions
194+ }
195+ return limitedCount(total, OmniboxListDefaults.MaxResults)
183196 }
184197
185198 func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
@@ -189,16 +202,16 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
189202 return entryText
190203 }
191204
192-func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {
205+func resolveTargetURLForSelection(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) string {
193206 if mode == ViewModeHistory {
194- visibleCount := visibleResultCount(len(suggestions), maxVisible)
195- if idx >= 0 && idx < visibleCount {
207+ count := limitedCount(len(suggestions), maxResults)
208+ if idx >= 0 && idx < count {
196209 return suggestions[idx].URL
197210 }
198211 return ""
199212 }
200- visibleCount := visibleResultCount(len(favorites), maxVisible)
201- if idx >= 0 && idx < visibleCount {
213+ count := limitedCount(len(favorites), maxResults)
214+ if idx >= 0 && idx < count {
202215 return favorites[idx].URL
203216 }
204217 return ""
@@ -19,7 +19,7 @@ type ghostCompletionRequest struct {
19 SelectedURL string19 SelectedURL string
20 HasExplicitSelection bool20 HasExplicitSelection bool
21 Mode ViewMode21 Mode ViewMode
22- MaxVisible int22+ MaxResults int
23 Suggestions []Suggestion23 Suggestions []Suggestion
24 Favorites []Favorite24 Favorites []Favorite
25 }25 }
@@ -34,7 +34,7 @@ func ghostCompletion(req ghostCompletionRequest) (fullText, suffix string, ok bo
34 return "", "", false34 return "", "", false
35 }35 }
36 fullText, suffix, ok = visibleGhostSuggestion(36 fullText, suffix, ok = visibleGhostSuggestion(
37- input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxVisible, req.Suggestions, req.Favorites,37+ input, req.SelectedURL, req.HasExplicitSelection, req.Mode, req.MaxResults, req.Suggestions, req.Favorites,
38 )38 )
39 if !ok {39 if !ok {
40 return "", "", false40 return "", "", false
@@ -122,7 +122,7 @@ func visibleGhostSuggestion(
122 query, selectedURL string,122 query, selectedURL string,
123 hasExplicitSelection bool,123 hasExplicitSelection bool,
124 mode ViewMode,124 mode ViewMode,
125- maxVisible int,125+ maxResults int,
126 suggestions []Suggestion,126 suggestions []Suggestion,
127 favorites []Favorite,127 favorites []Favorite,
128 ) (fullText, suffix string, ok bool) {128 ) (fullText, suffix string, ok bool) {
@@ -131,16 +131,16 @@ func visibleGhostSuggestion(
131 return fullText, suffix, ok131 return fullText, suffix, ok
132 }132 }
133 133
134- visibleURLs := visibleURLsForMode(mode, maxVisible, suggestions, favorites)134+ listedURLs := listedURLsForMode(mode, maxResults, suggestions, favorites)
135- suffix, fullText, ok = autocomplete.BestURLCompletion(query, visibleURLs)135+ suffix, fullText, ok = autocomplete.BestURLCompletion(query, listedURLs)
136 return fullText, suffix, ok136 return fullText, suffix, ok
137 }137 }
138 138
139-func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion, favorites []Favorite) []string {139+func listedURLsForMode(mode ViewMode, maxResults int, suggestions []Suggestion, favorites []Favorite) []string {
140 if mode == ViewModeHistory {140 if mode == ViewModeHistory {
141- visibleCount := visibleResultCount(len(suggestions), maxVisible)141+ count := limitedCount(len(suggestions), maxResults)
142- urls := make([]string, 0, visibleCount)142+ urls := make([]string, 0, count)
143- for _, s := range suggestions[:visibleCount] {143+ for _, s := range suggestions[:count] {
144 if s.URL != "" {144 if s.URL != "" {
145 urls = append(urls, s.URL)145 urls = append(urls, s.URL)
146 }146 }
@@ -148,9 +148,9 @@ func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion,
148 return urls148 return urls
149 }149 }
150 150
151- visibleCount := visibleResultCount(len(favorites), maxVisible)151+ count := limitedCount(len(favorites), maxResults)
152- urls := make([]string, 0, visibleCount)152+ urls := make([]string, 0, count)
153- for _, f := range favorites[:visibleCount] {153+ for _, f := range favorites[:count] {
154 if f.URL != "" {154 if f.URL != "" {
155 urls = append(urls, f.URL)155 urls = append(urls, f.URL)
156 }156 }
@@ -158,11 +158,11 @@ func visibleURLsForMode(mode ViewMode, maxVisible int, suggestions []Suggestion,
158 return urls158 return urls
159 }159 }
160 160
161-func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) (string, bool) {161+func selectedTargetURL(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) (string, bool) {
162 if idx < 0 {162 if idx < 0 {
163 return "", false163 return "", false
164 }164 }
165- return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true165+ return resolveTargetURLForSelection(mode, idx, maxResults, suggestions, favorites), true
166 }166 }
167 167
168 func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {168 func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
@@ -172,14 +172,27 @@ func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bo
172 return "!" + bangSuggestions[idx].Key + " ", true172 return "!" + bangSuggestions[idx].Key + " ", true
173 }173 }
174 174
175-func visibleResultCount(total, maxVisible int) int {175+func limitedCount(total, maxResults int) int {
176 if total <= 0 {176 if total <= 0 {
177 return 0177 return 0
178 }178 }
179- if maxVisible <= 0 || total < maxVisible {179+ if maxResults <= 0 || total < maxResults {
180 return total180 return total
181 }181 }
182- return maxVisible182+ return maxResults
183+}
184+
185+// navigableCount returns how many rows of the active list are rendered and
186+// reachable by keyboard: the active list length capped at MaxResults.
187+func navigableCount(bangMode bool, mode ViewMode, bangs, suggestions, favorites int) int {
188+ total := favorites
189+ switch {
190+ case bangMode:
191+ total = bangs
192+ case mode == ViewModeHistory:
193+ total = suggestions
194+ }
195+ return limitedCount(total, OmniboxListDefaults.MaxResults)
183 }196 }
184 197
185 func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {198 func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
@@ -189,16 +202,16 @@ func effectiveSearchQuery(entryText, realInput string, hasGhost bool) string {
189 return entryText202 return entryText
190 }203 }
191 204
192-func resolveTargetURLForSelection(mode ViewMode, idx, maxVisible int, suggestions []Suggestion, favorites []Favorite) string {205+func resolveTargetURLForSelection(mode ViewMode, idx, maxResults int, suggestions []Suggestion, favorites []Favorite) string {
193 if mode == ViewModeHistory {206 if mode == ViewModeHistory {
194- visibleCount := visibleResultCount(len(suggestions), maxVisible)207+ count := limitedCount(len(suggestions), maxResults)
195- if idx >= 0 && idx < visibleCount {208+ if idx >= 0 && idx < count {
196 return suggestions[idx].URL209 return suggestions[idx].URL
197 }210 }
198 return ""211 return ""
199 }212 }
200- visibleCount := visibleResultCount(len(favorites), maxVisible)213+ count := limitedCount(len(favorites), maxResults)
201- if idx >= 0 && idx < visibleCount {214+ if idx >= 0 && idx < count {
202 return favorites[idx].URL215 return favorites[idx].URL
203 }216 }
204 return ""217 return ""
M internal/ui/component/omnibox_suggestions_test.go +30 -6
@@ -18,26 +18,26 @@ func TestGhostCompletion(t *testing.T) {
1818 }{
1919 {
2020 name: "host-like input completes to the domain, not the redirect URL",
21- req: ghostCompletionRequest{EntryText: "goo", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
21+ req: ghostCompletionRequest{EntryText: "goo", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
2222 wantFull: "google.com",
2323 wantSuffix: "gle.com",
2424 wantOK: true,
2525 },
2626 {
2727 name: "leading whitespace is ignored",
28- req: ghostCompletionRequest{EntryText: " gith", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
28+ req: ghostCompletionRequest{EntryText: " gith", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
2929 wantFull: "github.com",
3030 wantSuffix: "ub.com",
3131 wantOK: true,
3232 },
3333 {
3434 name: "hidden rows never complete",
35- req: ghostCompletionRequest{EntryText: "gith", Mode: ViewModeHistory, MaxVisible: 1, Suggestions: history},
35+ req: ghostCompletionRequest{EntryText: "gith", Mode: ViewModeHistory, MaxResults: 1, Suggestions: history},
3636 wantOK: false,
3737 },
3838 {
3939 name: "favorites mode uses favorites",
40- req: ghostCompletionRequest{EntryText: "gitl", Mode: ViewModeFavorites, MaxVisible: 5, Favorites: favorites},
40+ req: ghostCompletionRequest{EntryText: "gitl", Mode: ViewModeFavorites, MaxResults: 5, Favorites: favorites},
4141 wantFull: "gitlab.com",
4242 wantSuffix: "ab.com",
4343 wantOK: true,
@@ -46,7 +46,7 @@ func TestGhostCompletion(t *testing.T) {
4646 name: "explicit selection completes from the selected URL, not the best match",
4747 req: ghostCompletionRequest{
4848 EntryText: "gi", SelectedURL: "https://gitlab.com/", HasExplicitSelection: true,
49- Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history,
49+ Mode: ViewModeHistory, MaxResults: 5, Suggestions: history,
5050 },
5151 wantFull: "gitlab.com",
5252 wantSuffix: "tlab.com",
@@ -54,7 +54,7 @@ func TestGhostCompletion(t *testing.T) {
5454 },
5555 {
5656 name: "blank input shows nothing",
57- req: ghostCompletionRequest{EntryText: " ", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},
57+ req: ghostCompletionRequest{EntryText: " ", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
5858 wantOK: false,
5959 },
6060 }
@@ -69,3 +69,27 @@ func TestGhostCompletion(t *testing.T) {
6969 })
7070 }
7171 }
72+
73+func TestNavigableCount(t *testing.T) {
74+ limit := OmniboxListDefaults.MaxResults
75+ tests := []struct {
76+ name string
77+ bangMode bool
78+ mode ViewMode
79+ bangs, suggestions, favorites int
80+ want int
81+ }{
82+ {"history capped at results limit", false, ViewModeHistory, 0, limit + 5, 30, limit},
83+ {"favorites capped at results limit", false, ViewModeFavorites, 0, 3, 30, limit},
84+ {"bangs win over view mode", true, ViewModeHistory, 2, 8, 0, 2},
85+ {"empty list", false, ViewModeFavorites, 4, 4, 0, 0},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ got := navigableCount(tt.bangMode, tt.mode, tt.bangs, tt.suggestions, tt.favorites)
90+ if got != tt.want {
91+ t.Fatalf("navigableCount() = %d, want %d", got, tt.want)
92+ }
93+ })
94+ }
95+}
@@ -18,26 +18,26 @@ func TestGhostCompletion(t *testing.T) {
18 }{18 }{
19 {19 {
20 name: "host-like input completes to the domain, not the redirect URL",20 name: "host-like input completes to the domain, not the redirect URL",
21- req: ghostCompletionRequest{EntryText: "goo", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},21+ req: ghostCompletionRequest{EntryText: "goo", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
22 wantFull: "google.com",22 wantFull: "google.com",
23 wantSuffix: "gle.com",23 wantSuffix: "gle.com",
24 wantOK: true,24 wantOK: true,
25 },25 },
26 {26 {
27 name: "leading whitespace is ignored",27 name: "leading whitespace is ignored",
28- req: ghostCompletionRequest{EntryText: " gith", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},28+ req: ghostCompletionRequest{EntryText: " gith", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
29 wantFull: "github.com",29 wantFull: "github.com",
30 wantSuffix: "ub.com",30 wantSuffix: "ub.com",
31 wantOK: true,31 wantOK: true,
32 },32 },
33 {33 {
34 name: "hidden rows never complete",34 name: "hidden rows never complete",
35- req: ghostCompletionRequest{EntryText: "gith", Mode: ViewModeHistory, MaxVisible: 1, Suggestions: history},35+ req: ghostCompletionRequest{EntryText: "gith", Mode: ViewModeHistory, MaxResults: 1, Suggestions: history},
36 wantOK: false,36 wantOK: false,
37 },37 },
38 {38 {
39 name: "favorites mode uses favorites",39 name: "favorites mode uses favorites",
40- req: ghostCompletionRequest{EntryText: "gitl", Mode: ViewModeFavorites, MaxVisible: 5, Favorites: favorites},40+ req: ghostCompletionRequest{EntryText: "gitl", Mode: ViewModeFavorites, MaxResults: 5, Favorites: favorites},
41 wantFull: "gitlab.com",41 wantFull: "gitlab.com",
42 wantSuffix: "ab.com",42 wantSuffix: "ab.com",
43 wantOK: true,43 wantOK: true,
@@ -46,7 +46,7 @@ func TestGhostCompletion(t *testing.T) {
46 name: "explicit selection completes from the selected URL, not the best match",46 name: "explicit selection completes from the selected URL, not the best match",
47 req: ghostCompletionRequest{47 req: ghostCompletionRequest{
48 EntryText: "gi", SelectedURL: "https://gitlab.com/", HasExplicitSelection: true,48 EntryText: "gi", SelectedURL: "https://gitlab.com/", HasExplicitSelection: true,
49- Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history,49+ Mode: ViewModeHistory, MaxResults: 5, Suggestions: history,
50 },50 },
51 wantFull: "gitlab.com",51 wantFull: "gitlab.com",
52 wantSuffix: "tlab.com",52 wantSuffix: "tlab.com",
@@ -54,7 +54,7 @@ func TestGhostCompletion(t *testing.T) {
54 },54 },
55 {55 {
56 name: "blank input shows nothing",56 name: "blank input shows nothing",
57- req: ghostCompletionRequest{EntryText: " ", Mode: ViewModeHistory, MaxVisible: 5, Suggestions: history},57+ req: ghostCompletionRequest{EntryText: " ", Mode: ViewModeHistory, MaxResults: 5, Suggestions: history},
58 wantOK: false,58 wantOK: false,
59 },59 },
60 }60 }
@@ -69,3 +69,27 @@ func TestGhostCompletion(t *testing.T) {
69 })69 })
70 }70 }
71 }71 }
72+
73+func TestNavigableCount(t *testing.T) {
74+ limit := OmniboxListDefaults.MaxResults
75+ tests := []struct {
76+ name string
77+ bangMode bool
78+ mode ViewMode
79+ bangs, suggestions, favorites int
80+ want int
81+ }{
82+ {"history capped at results limit", false, ViewModeHistory, 0, limit + 5, 30, limit},
83+ {"favorites capped at results limit", false, ViewModeFavorites, 0, 3, 30, limit},
84+ {"bangs win over view mode", true, ViewModeHistory, 2, 8, 0, 2},
85+ {"empty list", false, ViewModeFavorites, 4, 4, 0, 0},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ got := navigableCount(tt.bangMode, tt.mode, tt.bangs, tt.suggestions, tt.favorites)
90+ if got != tt.want {
91+ t.Fatalf("navigableCount() = %d, want %d", got, tt.want)
92+ }
93+ })
94+ }
95+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close