Skip to content

Commit 3c0116c

Browse files
authored
fix(select): ensure cursor visibility when navigating multiline options (#749)
This ensures that when navigating options that span multiple lines, the viewport will scroll to keep the cursor visible. This is done by computing the line offset and height of the current cursor position and adjusting the viewport accordingly. Fixes: #748
1 parent ec53694 commit 3c0116c

2 files changed

Lines changed: 74 additions & 27 deletions

File tree

field_multiselect.go

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ func (m *MultiSelect[T]) selectOptions() {
151151
continue
152152
}
153153
m.cursor = i
154-
m.viewport.SetYOffset(i)
154+
m.ensureCursorVisible()
155155
break
156156
}
157157
}
@@ -392,9 +392,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
392392
}
393393

394394
m.cursor = max(m.cursor-1, 0)
395-
if m.cursor < m.viewport.YOffset() {
396-
m.viewport.SetYOffset(m.cursor)
397-
}
395+
m.ensureCursorVisible()
398396
case key.Matches(msg, m.keymap.Down):
399397
//nolint:godox
400398
// FIXME: should use keys in keymap
@@ -403,9 +401,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
403401
}
404402

405403
m.cursor = min(m.cursor+1, len(m.filteredOptions)-1)
406-
if m.cursor >= m.viewport.YOffset()+m.viewport.Height() {
407-
m.viewport.ScrollDown(1)
408-
}
404+
m.ensureCursorVisible()
409405
case key.Matches(msg, m.keymap.GotoTop):
410406
if m.filtering {
411407
break
@@ -420,10 +416,10 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
420416
m.viewport.GotoBottom()
421417
case key.Matches(msg, m.keymap.HalfPageUp):
422418
m.cursor = max(m.cursor-m.viewport.Height()/2, 0)
423-
m.viewport.HalfPageUp()
419+
m.ensureCursorVisible()
424420
case key.Matches(msg, m.keymap.HalfPageDown):
425421
m.cursor = min(m.cursor+m.viewport.Height()/2, len(m.filteredOptions)-1)
426-
m.viewport.HalfPageDown()
422+
m.ensureCursorVisible()
427423
case key.Matches(msg, m.keymap.Toggle) && !m.filtering:
428424
for i, option := range m.options.val {
429425
if option.Key == m.filteredOptions[m.cursor].Key {
@@ -488,10 +484,7 @@ func (m *MultiSelect[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
488484
m.cursor = min(m.cursor, len(m.filteredOptions)-1)
489485
}
490486
}
491-
_, offset, height := m.optionsView()
492-
if offset > -1 && height > 0 && (offset < m.viewport.YOffset() || height+offset >= m.viewport.YOffset()+m.viewport.Height()) {
493-
m.viewport.SetYOffset(offset)
494-
}
487+
m.ensureCursorVisible()
495488
}
496489

497490
return m, tea.Batch(cmds...)
@@ -615,6 +608,28 @@ func (m *MultiSelect[T]) renderOption(option Option[T], cursor, selected bool) s
615608
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
616609
}
617610

611+
// cursorLineOffset computes the line offset and height (in lines) for the
612+
// current cursor position without rendering the full options string.
613+
func (m *MultiSelect[T]) cursorLineOffset() (offset int, height int) {
614+
for i, option := range m.filteredOptions {
615+
line := m.renderOption(option, m.cursor == i, m.filteredOptions[i].selected)
616+
h := lipgloss.Height(line)
617+
if i < m.cursor {
618+
offset += h
619+
}
620+
if i == m.cursor {
621+
height = h
622+
return offset, height
623+
}
624+
}
625+
return offset, height
626+
}
627+
628+
func (m *MultiSelect[T]) ensureCursorVisible() {
629+
offset, height := m.cursorLineOffset()
630+
ensureVisible(&m.viewport, offset, height)
631+
}
632+
618633
func (m *MultiSelect[T]) optionsView() (string, int, int) {
619634
var sb strings.Builder
620635

field_select.go

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ func (s *Select[T]) selectOption() {
200200
break
201201
}
202202
}
203-
s.viewport.SetYOffset(s.selected)
203+
s.ensureCursorVisible()
204204
}
205205

206206
// OptionsFunc sets the options func of the select field.
@@ -426,9 +426,8 @@ func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
426426
if s.selected < 0 {
427427
s.selected = len(s.filteredOptions) - 1
428428
s.viewport.GotoBottom()
429-
}
430-
if s.selected < s.viewport.YOffset() {
431-
s.viewport.SetYOffset(s.selected)
429+
} else {
430+
s.ensureCursorVisible()
432431
}
433432
s.updateValue()
434433
case key.Matches(msg, s.keymap.GotoTop):
@@ -446,11 +445,11 @@ func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
446445
s.viewport.GotoBottom()
447446
case key.Matches(msg, s.keymap.HalfPageUp):
448447
s.selected = max(s.selected-s.viewport.Height()/2, 0)
449-
s.viewport.HalfPageUp()
448+
s.ensureCursorVisible()
450449
s.updateValue()
451450
case key.Matches(msg, s.keymap.HalfPageDown):
452451
s.selected = min(s.selected+s.viewport.Height()/2, len(s.filteredOptions)-1)
453-
s.viewport.HalfPageDown()
452+
s.ensureCursorVisible()
454453
s.updateValue()
455454
case key.Matches(msg, s.keymap.Down, s.keymap.Right):
456455
// When filtering we should ignore j/k keybindings
@@ -463,9 +462,8 @@ func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
463462
if s.selected > len(s.filteredOptions)-1 {
464463
s.selected = 0
465464
s.viewport.GotoTop()
466-
}
467-
if s.selected >= s.viewport.YOffset()+s.viewport.Height() {
468-
s.viewport.ScrollDown(1)
465+
} else {
466+
s.ensureCursorVisible()
469467
}
470468
s.updateValue()
471469
case key.Matches(msg, s.keymap.Prev):
@@ -508,10 +506,7 @@ func (s *Select[T]) Update(msg tea.Msg) (Model, tea.Cmd) {
508506
}
509507
}
510508

511-
_, offset, height := s.optionsView()
512-
if offset > -1 && height > 0 && (offset < s.viewport.YOffset() || height+offset >= s.viewport.YOffset()+s.viewport.Height()) {
513-
s.viewport.SetYOffset(offset)
514-
}
509+
s.ensureCursorVisible()
515510
}
516511

517512
return s, cmd
@@ -534,8 +529,8 @@ func (s *Select[T]) updateViewportSize() {
534529
if ss := s.descriptionView(); ss != "" {
535530
yoffset += lipgloss.Height(ss)
536531
}
537-
s.viewport.SetYOffset(s.selected)
538532
s.viewport.SetHeight(max(minHeight, s.height-yoffset))
533+
s.ensureCursorVisible()
539534
} else {
540535
// If no height is set size the viewport to the number of options.
541536
v, _, _ := s.optionsView()
@@ -640,6 +635,43 @@ func (s *Select[T]) optionsView() (string, int, int) {
640635
return sb.String(), cursorOffset, cursorHeight
641636
}
642637

638+
// cursorLineOffset computes the line offset and height (in lines) for the
639+
// currently selected option without rendering the full options string.
640+
func (s *Select[T]) cursorLineOffset() (offset int, height int) {
641+
for i, option := range s.filteredOptions {
642+
line := s.renderOption(option, s.selected == i)
643+
h := lipgloss.Height(line)
644+
if i < s.selected {
645+
offset += h
646+
}
647+
if i == s.selected {
648+
height = h
649+
return offset, height
650+
}
651+
}
652+
return offset, height
653+
}
654+
655+
// ensureVisible scrolls a viewport the minimum amount so that the region
656+
// [offset, offset+height) is within the visible area.
657+
func ensureVisible(vp *viewport.Model, offset, height int) {
658+
if height <= 0 {
659+
return
660+
}
661+
yOff := vp.YOffset()
662+
vHeight := vp.Height()
663+
if offset < yOff {
664+
vp.ScrollUp(yOff - offset)
665+
} else if offset+height > yOff+vHeight {
666+
vp.ScrollDown(offset + height - yOff - vHeight)
667+
}
668+
}
669+
670+
func (s *Select[T]) ensureCursorVisible() {
671+
offset, height := s.cursorLineOffset()
672+
ensureVisible(&s.viewport, offset, height)
673+
}
674+
643675
func (s *Select[T]) renderOption(option Option[T], selected bool) string {
644676
var (
645677
styles = s.activeStyles()

0 commit comments

Comments
 (0)