@@ -1,7 +1,6 @@
package list
import (
- "slices"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
ID() string
}
-type List[T Item] interface {
- util.Model
- layout.Sizeable
- layout.Focusable
- MoveUp(int) tea.Cmd
- MoveDown(int) tea.Cmd
- GoToTop() tea.Cmd
- GoToBottom() tea.Cmd
- SelectItemAbove() tea.Cmd
- SelectItemBelow() tea.Cmd
- SetItems([]T) tea.Cmd
- SetSelected(string) tea.Cmd
- SelectedItem() *T
- Items() []T
- UpdateItem(string, T)
- DeleteItem(string)
- PrependItem(T) tea.Cmd
- AppendItem(T) tea.Cmd
-}
+type (
+ renderedMsg struct{}
+ List[T Item] interface {
+ util.Model
+ layout.Sizeable
+ layout.Focusable
+
+ // Just change state
+ MoveUp(int) tea.Cmd
+ MoveDown(int) tea.Cmd
+ GoToTop() tea.Cmd
+ GoToBottom() tea.Cmd
+ SelectItemAbove() tea.Cmd
+ SelectItemBelow() tea.Cmd
+ SetItems([]T) tea.Cmd
+ SetSelected(string) tea.Cmd
+ SelectedItem() *T
+ Items() []T
+ UpdateItem(string, T) tea.Cmd
+ DeleteItem(string) tea.Cmd
+ PrependItem(T) tea.Cmd
+ AppendItem(T) tea.Cmd
+ }
+)
type direction int
const (
- Forward direction = iota
- Backward
+ DirectionForward direction = iota
+ DirectionBackward
)
const (
- NotFound = -1
- DefaultScrollSize = 2
+ ItemNotFound = -1
+ ViewportDefaultScrollSize = 2
)
-type setSelectedMsg struct {
- selectedItemID string
-}
-
type renderedItem struct {
id string
view string
+ dirty bool
height int
+ start int
+ end int
}
type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
keyMap KeyMap
direction direction
selectedItem string
+ focused bool
}
+
type list[T Item] struct {
*confOptions
- focused bool
- offset int
- items []T
- renderedItems []renderedItem
- rendered string
- isReady bool
+ offset int
+
+ indexMap map[string]int
+ items []T
+
+ renderedItems map[string]renderedItem
+
+ rendered string
}
type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
}
}
-// WithDirection sets the direction of the list.
-func WithDirection(dir direction) listOption {
+// WithDirectionForward sets the direction to forward
+func WithDirectionForward() listOption {
+ return func(l *confOptions) {
+ l.direction = DirectionForward
+ }
+}
+
+// WithDirectionBackward sets the direction to forward
+func WithDirectionBackward() listOption {
return func(l *confOptions) {
- l.direction = dir
+ l.direction = DirectionBackward
}
}
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
}
}
+func WithFocus(focus bool) listOption {
+ return func(l *confOptions) {
+ l.focused = focus
+ }
+}
+
func New[T Item](items []T, opts ...listOption) List[T] {
list := &list[T]{
confOptions: &confOptions{
- direction: Forward,
+ direction: DirectionForward,
keyMap: DefaultKeyMap(),
+ focused: true,
},
- items: items,
+ items: items,
+ indexMap: make(map[string]int),
+ renderedItems: map[string]renderedItem{},
}
for _, opt := range opts {
opt(list.confOptions)
}
+
+ for inx, item := range items {
+ list.indexMap[item.ID()] = inx
+ }
return list
}
// Init implements List.
func (l *list[T]) Init() tea.Cmd {
- var cmds []tea.Cmd
- for _, item := range l.items {
- cmd := item.Init()
- cmds = append(cmds, cmd)
- }
- cmds = append(cmds, l.renderItems())
- return tea.Batch(cmds...)
+ return l.render()
}
// Update implements List.
func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
- case setSelectedMsg:
- return l, l.SetSelected(msg.selectedItemID)
case tea.KeyPressMsg:
if l.focused {
switch {
case key.Matches(msg, l.keyMap.Down):
- return l, l.MoveDown(DefaultScrollSize)
+ return l, l.MoveDown(ViewportDefaultScrollSize)
case key.Matches(msg, l.keyMap.Up):
- return l, l.MoveUp(DefaultScrollSize)
+ return l, l.MoveUp(ViewportDefaultScrollSize)
case key.Matches(msg, l.keyMap.DownOneItem):
return l, l.SelectItemBelow()
case key.Matches(msg, l.keyMap.UpOneItem):
return l, l.SelectItemAbove()
case key.Matches(msg, l.keyMap.HalfPageDown):
- return l, l.MoveDown(l.listHeight() / 2)
+ return l, l.MoveDown(l.height / 2)
case key.Matches(msg, l.keyMap.HalfPageUp):
- return l, l.MoveUp(l.listHeight() / 2)
+ return l, l.MoveUp(l.height / 2)
case key.Matches(msg, l.keyMap.PageDown):
- return l, l.MoveDown(l.listHeight())
+ return l, l.MoveDown(l.height)
case key.Matches(msg, l.keyMap.PageUp):
- return l, l.MoveUp(l.listHeight())
+ return l, l.MoveUp(l.height)
case key.Matches(msg, l.keyMap.End):
return l, l.GoToBottom()
case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
func (l *list[T]) viewPosition() (int, int) {
start, end := 0, 0
renderedLines := lipgloss.Height(l.rendered) - 1
- if l.direction == Forward {
+ if l.direction == DirectionForward {
start = max(0, l.offset)
- end = min(l.offset+l.listHeight()-1, renderedLines)
+ end = min(l.offset+l.height-1, renderedLines)
} else {
- start = max(0, renderedLines-l.offset-l.listHeight()+1)
+ start = max(0, renderedLines-l.offset-l.height+1)
end = max(0, renderedLines-l.offset)
}
return start, end
}
-func (l *list[T]) renderItem(item Item) renderedItem {
- view := item.View()
- return renderedItem{
- id: item.ID(),
- view: view,
- height: lipgloss.Height(view),
+func (l *list[T]) recalculateItemPositions() {
+ currentContentHeight := 0
+ for _, item := range l.items {
+ rItem, ok := l.renderedItems[item.ID()]
+ if !ok {
+ continue
+ }
+ rItem.start = currentContentHeight
+ rItem.end = currentContentHeight + rItem.height - 1
+ l.renderedItems[item.ID()] = rItem
+ currentContentHeight = rItem.end + 1 + l.gap
}
}
-func (l *list[T]) renderView() {
- var sb strings.Builder
- for i, rendered := range l.renderedItems {
- sb.WriteString(rendered.view)
- if i < len(l.renderedItems)-1 {
- sb.WriteString(strings.Repeat("\n", l.gap+1))
+func (l *list[T]) render() tea.Cmd {
+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
+ return nil
+ }
+ l.setDefaultSelected()
+ focusCmd := l.focusSelectedItem()
+ // we are not rendering the first time
+ if l.rendered != "" {
+ l.rendered = ""
+ // rerender everything will mostly hit cache
+ _ = l.renderIterator(0, false)
+ if l.direction == DirectionBackward {
+ l.recalculateItemPositions()
+ }
+ // in the end scroll to the selected item
+ if l.focused {
+ l.scrollToSelection()
+ }
+ return focusCmd
+ }
+ finishIndex := l.renderIterator(0, true)
+ // recalculate for the initial items
+ if l.direction == DirectionBackward {
+ l.recalculateItemPositions()
+ }
+ renderCmd := func() tea.Msg {
+ // render the rest
+ _ = l.renderIterator(finishIndex, false)
+ // needed for backwards
+ if l.direction == DirectionBackward {
+ l.recalculateItemPositions()
}
+ // in the end scroll to the selected item
+ if l.focused {
+ l.scrollToSelection()
+ }
+ return renderedMsg{}
}
- l.rendered = sb.String()
+ return tea.Batch(focusCmd, renderCmd)
}
-func (l *list[T]) incrementOffset(n int) {
- if !l.isReady {
- return
+func (l *list[T]) setDefaultSelected() {
+ if l.selectedItem == "" {
+ if l.direction == DirectionForward {
+ l.selectFirstItem()
+ } else {
+ l.selectLastItem()
+ }
}
- renderedHeight := lipgloss.Height(l.rendered)
- // no need for offset
- if renderedHeight <= l.listHeight() {
+}
+
+func (l *list[T]) scrollToSelection() {
+ rItem, ok := l.renderedItems[l.selectedItem]
+ if !ok {
+ l.selectedItem = ""
+ l.setDefaultSelected()
return
}
- maxOffset := renderedHeight - l.listHeight()
- n = min(n, maxOffset-l.offset)
- if n <= 0 {
+
+ start, end := l.viewPosition()
+ // item bigger or equal to the viewport do nothing
+ if rItem.start <= start && rItem.end >= end {
return
}
- l.offset += n
-}
-
-func (l *list[T]) decrementOffset(n int) {
- if !l.isReady {
+ // item already in view do nothing
+ if rItem.start >= start && rItem.start <= end {
+ return
+ } else if rItem.end <= end && rItem.end >= start {
return
}
- n = min(n, l.offset)
- if n <= 0 {
+
+ if rItem.height >= l.height {
+ if l.direction == DirectionForward {
+ l.offset = rItem.start
+ } else {
+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+ }
return
}
- l.offset -= n
- if l.offset < 0 {
- l.offset = 0
+
+ itemMiddleStart := rItem.start + rItem.height/2 + 1
+ if l.direction == DirectionForward {
+ l.offset = itemMiddleStart - l.height/2
+ } else {
+ l.offset = max(0, lipgloss.Height(l.rendered)-(itemMiddleStart+l.height/2))
}
}
-// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
-func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
- var cmds []tea.Cmd
+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
+ rItem, ok := l.renderedItems[l.selectedItem]
+ if !ok {
+ return nil
+ }
start, end := l.viewPosition()
- currentPosition := 0
- itemWithinView := NotFound
- needsMove := false
-
- for i, item := range l.items {
- rendered := l.renderedItems[i]
- itemStart := currentPosition
- // we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
- itemEnd := itemStart + rendered.height - 1
- if itemStart >= start && itemEnd <= end {
- itemWithinView = i
- }
- if item.ID() == l.selectedItem {
- // item is completely above the viewport
- if itemStart < start && itemEnd < start {
- needsMove = true
+ // item bigger than the viewport do nothing
+ if rItem.start <= start && rItem.end >= end {
+ return nil
+ }
+ // item already in view do nothing
+ if rItem.start >= start && rItem.end <= end {
+ return nil
+ }
+
+ itemMiddle := rItem.start + rItem.height/2
+
+ if itemMiddle < start {
+ // select the first item in the viewport
+ // the item is most likely an item coming after this item
+ inx := l.indexMap[rItem.id]
+ for {
+ inx = l.firstSelectableItemBelow(inx)
+ if inx == ItemNotFound {
+ return nil
}
- // item is completely below the viewport
- if itemStart > end && itemEnd > end {
- needsMove = true
+ item, ok := l.renderedItems[l.items[inx].ID()]
+ if !ok {
+ continue
}
- if needsMove {
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Blur())
- }
- l.renderedItems[i] = l.renderItem(item)
- } else {
- return nil
+
+ // If the item is bigger than the viewport, select it
+ if item.start <= start && item.end >= end {
+ l.selectedItem = item.id
+ return l.render()
+ }
+ // item is in the view
+ if item.start >= start && item.start <= end {
+ l.selectedItem = item.id
+ return l.render()
}
}
- if itemWithinView != NotFound && needsMove {
- newSelection := l.items[itemWithinView]
- l.selectedItem = newSelection.ID()
- if focusable, ok := any(newSelection).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Focus())
+ } else if itemMiddle > end {
+ // select the first item in the viewport
+ // the item is most likely an item coming after this item
+ inx := l.indexMap[rItem.id]
+ for {
+ inx = l.firstSelectableItemAbove(inx)
+ if inx == ItemNotFound {
+ return nil
+ }
+ item, ok := l.renderedItems[l.items[inx].ID()]
+ if !ok {
+ continue
+ }
+
+ // If the item is bigger than the viewport, select it
+ if item.start <= start && item.end >= end {
+ l.selectedItem = item.id
+ return l.render()
+ }
+ // item is in the view
+ if item.end >= start && item.end <= end {
+ l.selectedItem = item.id
+ return l.render()
}
- l.renderedItems[itemWithinView] = l.renderItem(newSelection)
- break
}
- currentPosition += rendered.height + l.gap
}
- l.renderView()
- return tea.Batch(cmds...)
+ return nil
}
-func (l *list[T]) MoveUp(n int) tea.Cmd {
- if l.direction == Forward {
- l.decrementOffset(n)
- } else {
- l.incrementOffset(n)
+func (l *list[T]) selectFirstItem() {
+ inx := l.firstSelectableItemBelow(-1)
+ if inx != ItemNotFound {
+ l.selectedItem = l.items[inx].ID()
}
- return l.changeSelectedWhenNotVisible()
}
-func (l *list[T]) MoveDown(n int) tea.Cmd {
- if l.direction == Forward {
- l.incrementOffset(n)
- } else {
- l.decrementOffset(n)
+func (l *list[T]) selectLastItem() {
+ inx := l.firstSelectableItemAbove(len(l.items))
+ if inx != ItemNotFound {
+ l.selectedItem = l.items[inx].ID()
}
- return l.changeSelectedWhenNotVisible()
}
-func (l *list[T]) firstSelectableItemBefore(inx int) int {
+func (l *list[T]) firstSelectableItemAbove(inx int) int {
for i := inx - 1; i >= 0; i-- {
if _, ok := any(l.items[i]).(layout.Focusable); ok {
return i
}
}
if inx == 0 && l.wrap {
- return l.firstSelectableItemBefore(len(l.items))
+ return l.firstSelectableItemAbove(len(l.items))
}
- return NotFound
+ return ItemNotFound
}
-func (l *list[T]) firstSelectableItemAfter(inx int) int {
+func (l *list[T]) firstSelectableItemBelow(inx int) int {
for i := inx + 1; i < len(l.items); i++ {
if _, ok := any(l.items[i]).(layout.Focusable); ok {
return i
}
}
if inx == len(l.items)-1 && l.wrap {
- return l.firstSelectableItemAfter(-1)
+ return l.firstSelectableItemBelow(-1)
}
- return NotFound
+ return ItemNotFound
}
-// moveToSelected needs to be called after the view is rendered
-func (l *list[T]) moveToSelected(center bool) tea.Cmd {
- var cmds []tea.Cmd
- if l.selectedItem == "" || !l.isReady {
+func (l *list[T]) focusSelectedItem() tea.Cmd {
+ if l.selectedItem == "" || !l.focused {
return nil
}
- currentPosition := 0
- start, end := l.viewPosition()
- for _, item := range l.renderedItems {
- if item.id == l.selectedItem {
- itemStart := currentPosition
- itemEnd := currentPosition + item.height - 1
-
- if start <= itemStart && itemEnd <= end {
- return nil
- }
-
- if center {
- viewportCenter := l.listHeight() / 2
- itemCenter := itemStart + item.height/2
- targetOffset := itemCenter - viewportCenter
- if l.direction == Forward {
- if targetOffset > l.offset {
- cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
- } else if targetOffset < l.offset {
- cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
- }
- } else {
- renderedHeight := lipgloss.Height(l.rendered)
- backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
- if backwardTargetOffset > l.offset {
- cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
- } else if backwardTargetOffset < l.offset {
- cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
- }
- }
- } else {
- if currentPosition < start {
- cmds = append(cmds, l.MoveUp(start-currentPosition))
+ var cmds []tea.Cmd
+ for _, item := range l.items {
+ if f, ok := any(item).(layout.Focusable); ok {
+ if item.ID() == l.selectedItem && !f.IsFocused() {
+ cmds = append(cmds, f.Focus())
+ if cache, ok := l.renderedItems[item.ID()]; ok {
+ cache.dirty = true
+ l.renderedItems[item.ID()] = cache
}
- if currentPosition > end {
- cmds = append(cmds, l.MoveDown(currentPosition-end))
+ } else if item.ID() != l.selectedItem && f.IsFocused() {
+ cmds = append(cmds, f.Blur())
+ if cache, ok := l.renderedItems[item.ID()]; ok {
+ cache.dirty = true
+ l.renderedItems[item.ID()] = cache
}
}
}
- currentPosition += item.height + l.gap
}
return tea.Batch(cmds...)
}
-func (l *list[T]) SelectItemAbove() tea.Cmd {
- if !l.isReady {
- return nil
- }
+func (l *list[T]) blurItems() tea.Cmd {
var cmds []tea.Cmd
- for i, item := range l.items {
- if l.selectedItem == item.ID() {
- inx := l.firstSelectableItemBefore(i)
- if inx == NotFound {
- // no item above
- return nil
- }
- // blur the current item
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Blur())
- }
- // rerender the item
- l.renderedItems[i] = l.renderItem(item)
- // focus the item above
- above := l.items[inx]
- if focusable, ok := any(above).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Focus())
+ for _, item := range l.items {
+ if f, ok := any(item).(layout.Focusable); ok {
+ if item.ID() == l.selectedItem && f.IsFocused() {
+ cmds = append(cmds, f.Blur())
+ if cache, ok := l.renderedItems[item.ID()]; ok {
+ cache.dirty = true
+ l.renderedItems[item.ID()] = cache
+ }
}
- // rerender the item
- l.renderedItems[inx] = l.renderItem(above)
- l.selectedItem = above.ID()
- break
}
}
- l.renderView()
- l.moveToSelected(false)
return tea.Batch(cmds...)
}
-func (l *list[T]) SelectItemBelow() tea.Cmd {
- if !l.isReady {
- return nil
- }
- var cmds []tea.Cmd
- for i, item := range l.items {
- if l.selectedItem == item.ID() {
- inx := l.firstSelectableItemAfter(i)
- if inx == NotFound {
- // no item below
- return nil
- }
- // blur the current item
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Blur())
- }
- // rerender the item
- l.renderedItems[i] = l.renderItem(item)
+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
+// returns the last index
+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
+ currentContentHeight := lipgloss.Height(l.rendered) - 1
+ for i := startInx; i < len(l.items); i++ {
+ if currentContentHeight >= l.height && limitHeight {
+ return i
+ }
+ // cool way to go through the list in both directions
+ inx := i
- // focus the item below
- below := l.items[inx]
- if focusable, ok := any(below).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Focus())
- }
- // rerender the item
- l.renderedItems[inx] = l.renderItem(below)
- l.selectedItem = below.ID()
- break
+ if l.direction != DirectionForward {
+ inx = (len(l.items) - 1) - i
}
- }
- l.renderView()
- l.moveToSelected(false)
- return tea.Batch(cmds...)
-}
+ item := l.items[inx]
+ var rItem renderedItem
+ if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty {
+ rItem = cache
+ } else {
+ rItem = l.renderItem(item)
+ rItem.start = currentContentHeight
+ rItem.end = currentContentHeight + rItem.height - 1
+ l.renderedItems[item.ID()] = rItem
+ }
+ gap := l.gap + 1
+ if inx == len(l.items)-1 {
+ gap = 0
+ }
-func (l *list[T]) GoToTop() tea.Cmd {
- if !l.isReady {
- return nil
+ if l.direction == DirectionForward {
+ l.rendered += rItem.view + strings.Repeat("\n", gap)
+ } else {
+ l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
+ }
+ currentContentHeight = rItem.end + 1 + l.gap
}
- l.offset = 0
- l.direction = Forward
- return tea.Batch(l.selectFirstItem(), l.renderForward())
+ return len(l.items)
}
-func (l *list[T]) GoToBottom() tea.Cmd {
- if !l.isReady {
- return nil
+func (l *list[T]) renderItem(item Item) renderedItem {
+ view := item.View()
+ return renderedItem{
+ id: item.ID(),
+ view: view,
+ height: lipgloss.Height(view),
}
- l.offset = 0
- l.direction = Backward
-
- return tea.Batch(l.selectLastItem(), l.renderBackward())
}
-func (l *list[T]) renderForward() tea.Cmd {
- // TODO: figure out a way to preserve items that did not change
- l.renderedItems = make([]renderedItem, 0)
- currentHeight := 0
- currentIndex := 0
- for i, item := range l.items {
- currentIndex = i
- if currentHeight-1 > l.listHeight() {
- break
- }
- rendered := l.renderItem(item)
- l.renderedItems = append(l.renderedItems, rendered)
- currentHeight += rendered.height + l.gap
- }
+// AppendItem implements List.
+func (l *list[T]) AppendItem(T) tea.Cmd {
+ panic("unimplemented")
+}
- // initial render
- l.renderView()
+// Blur implements List.
+func (l *list[T]) Blur() tea.Cmd {
+ cmd := l.blurItems()
+ return tea.Batch(cmd, l.render())
+}
- if currentIndex == len(l.items)-1 {
- l.isReady = true
- return nil
- }
- // render the rest
- return func() tea.Msg {
- for i := currentIndex; i < len(l.items); i++ {
- rendered := l.renderItem(l.items[i])
- l.renderedItems = append(l.renderedItems, rendered)
- }
- l.renderView()
- l.isReady = true
- return nil
- }
+// DeleteItem implements List.
+func (l *list[T]) DeleteItem(string) tea.Cmd {
+ panic("unimplemented")
}
-func (l *list[T]) renderBackward() tea.Cmd {
- // TODO: figure out a way to preserve items that did not change
- l.renderedItems = make([]renderedItem, 0)
- currentHeight := 0
- currentIndex := 0
- for i := len(l.items) - 1; i >= 0; i-- {
- currentIndex = i
- if currentHeight > l.listHeight() {
- break
- }
- rendered := l.renderItem(l.items[i])
- l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
- currentHeight += rendered.height + l.gap
- }
- // initial render
- l.renderView()
- if currentIndex == 0 {
- l.isReady = true
- return nil
- }
- return func() tea.Msg {
- for i := currentIndex; i >= 0; i-- {
- rendered := l.renderItem(l.items[i])
- l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
- }
- l.renderView()
- l.isReady = true
- return nil
- }
+// Focus implements List.
+func (l *list[T]) Focus() tea.Cmd {
+ l.focused = true
+ return l.render()
}
-func (l *list[T]) selectFirstItem() tea.Cmd {
- var cmd tea.Cmd
- inx := l.firstSelectableItemAfter(-1)
- if inx != NotFound {
- l.selectedItem = l.items[inx].ID()
- if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
- cmd = focusable.Focus()
- }
- }
- return cmd
+// GetSize implements List.
+func (l *list[T]) GetSize() (int, int) {
+ return l.width, l.height
}
-func (l *list[T]) selectLastItem() tea.Cmd {
- var cmd tea.Cmd
- inx := l.firstSelectableItemBefore(len(l.items))
- if inx != NotFound {
- l.selectedItem = l.items[inx].ID()
- if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
- cmd = focusable.Focus()
- }
- }
- return cmd
+// GoToBottom implements List.
+func (l *list[T]) GoToBottom() tea.Cmd {
+ l.offset = 0
+ l.direction = DirectionBackward
+ l.selectedItem = ""
+ return l.render()
}
-func (l *list[T]) renderItems() tea.Cmd {
- if l.height <= 0 || l.width <= 0 {
- return nil
- }
- if len(l.items) == 0 {
- return nil
- }
+// GoToTop implements List.
+func (l *list[T]) GoToTop() tea.Cmd {
+ l.offset = 0
+ l.direction = DirectionForward
+ l.selectedItem = ""
+ return l.render()
+}
- if l.selectedItem == "" {
- if l.direction == Forward {
- l.selectFirstItem()
- } else {
- l.selectLastItem()
- }
- }
- if l.direction == Forward {
- return l.renderForward()
- }
- return l.renderBackward()
+// IsFocused implements List.
+func (l *list[T]) IsFocused() bool {
+ return l.focused
}
-func (l *list[T]) listHeight() int {
- // for the moment its the same
- return l.height
+// Items implements List.
+func (l *list[T]) Items() []T {
+ return l.items
}
-func (l *list[T]) SetItems(items []T) tea.Cmd {
- l.items = items
- var cmds []tea.Cmd
- for _, item := range l.items {
- cmds = append(cmds, item.Init())
- // Set height to 0 to let the item calculate its own height
- cmds = append(cmds, item.SetSize(l.width, 0))
+func (l *list[T]) incrementOffset(n int) {
+ renderedHeight := lipgloss.Height(l.rendered)
+ // no need for offset
+ if renderedHeight <= l.height {
+ return
+ }
+ maxOffset := renderedHeight - l.height
+ n = min(n, maxOffset-l.offset)
+ if n <= 0 {
+ return
}
+ l.offset += n
+}
- cmds = append(cmds, l.renderItems())
- if l.selectedItem != "" {
- cmds = append(cmds, l.moveToSelected(true))
+func (l *list[T]) decrementOffset(n int) {
+ n = min(n, l.offset)
+ if n <= 0 {
+ return
+ }
+ l.offset -= n
+ if l.offset < 0 {
+ l.offset = 0
}
- return tea.Batch(cmds...)
}
-// GetSize implements List.
-func (l *list[T]) GetSize() (int, int) {
- return l.width, l.height
+// MoveDown implements List.
+func (l *list[T]) MoveDown(n int) tea.Cmd {
+ if l.direction == DirectionForward {
+ l.incrementOffset(n)
+ } else {
+ l.decrementOffset(n)
+ }
+ return l.changeSelectionWhenScrolling()
}
-// SetSize implements List.
-func (l *list[T]) SetSize(width int, height int) tea.Cmd {
- l.width = width
- l.height = height
- var cmds []tea.Cmd
- for _, item := range l.items {
- cmds = append(cmds, item.SetSize(width, height))
+// MoveUp implements List.
+func (l *list[T]) MoveUp(n int) tea.Cmd {
+ if l.direction == DirectionForward {
+ l.decrementOffset(n)
+ } else {
+ l.incrementOffset(n)
}
+ return l.changeSelectionWhenScrolling()
+}
- cmds = append(cmds, l.renderItems())
- return tea.Batch(cmds...)
+// PrependItem implements List.
+func (l *list[T]) PrependItem(T) tea.Cmd {
+ panic("unimplemented")
}
-// Blur implements List.
-func (l *list[T]) Blur() tea.Cmd {
- var cmd tea.Cmd
- l.focused = false
- for i, item := range l.items {
- if item.ID() != l.selectedItem {
- continue
- }
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmd = focusable.Blur()
- }
- l.renderedItems[i] = l.renderItem(item)
+// SelectItemAbove implements List.
+func (l *list[T]) SelectItemAbove() tea.Cmd {
+ inx, ok := l.indexMap[l.selectedItem]
+ if !ok {
+ return nil
}
- l.renderView()
- return cmd
-}
-// Focus implements List.
-func (l *list[T]) Focus() tea.Cmd {
- var cmd tea.Cmd
- l.focused = true
- if l.selectedItem != "" {
- for i, item := range l.items {
- if item.ID() != l.selectedItem {
- continue
- }
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmd = focusable.Focus()
- }
- if len(l.renderedItems) > i {
- l.renderedItems[i] = l.renderItem(item)
- }
- }
- l.renderView()
+ newIndex := l.firstSelectableItemAbove(inx)
+ if newIndex == ItemNotFound {
+ // no item above
+ return nil
}
- return cmd
+ item := l.items[newIndex]
+ l.selectedItem = item.ID()
+ return l.render()
}
-func (l *list[T]) SetSelected(id string) tea.Cmd {
- if l.selectedItem == id {
+// SelectItemBelow implements List.
+func (l *list[T]) SelectItemBelow() tea.Cmd {
+ inx, ok := l.indexMap[l.selectedItem]
+ if !ok {
return nil
}
- var cmds []tea.Cmd
- for i, item := range l.items {
- if item.ID() == l.selectedItem {
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Blur())
- }
- if len(l.renderedItems) > i {
- l.renderedItems[i] = l.renderItem(item)
- }
- } else if item.ID() == id {
- if focusable, ok := any(item).(layout.Focusable); ok {
- cmds = append(cmds, focusable.Focus())
- }
- if len(l.renderedItems) > i {
- l.renderedItems[i] = l.renderItem(item)
- }
- }
+
+ newIndex := l.firstSelectableItemBelow(inx)
+ if newIndex == ItemNotFound {
+ // no item above
+ return nil
}
- l.selectedItem = id
- l.renderView()
- cmds = append(cmds, l.moveToSelected(true))
- return tea.Batch(cmds...)
+ item := l.items[newIndex]
+ l.selectedItem = item.ID()
+ return l.render()
}
+// SelectedItem implements List.
func (l *list[T]) SelectedItem() *T {
- for _, item := range l.items {
- if item.ID() == l.selectedItem {
- return &item
- }
+ inx, ok := l.indexMap[l.selectedItem]
+ if !ok {
+ return nil
}
- return nil
+ if inx > len(l.items)-1 {
+ return nil
+ }
+ item := l.items[inx]
+ return &item
}
-// IsFocused implements List.
-func (l *list[T]) IsFocused() bool {
- return l.focused
+// SetItems implements List.
+func (l *list[T]) SetItems(items []T) tea.Cmd {
+ l.items = items
+ return l.reset()
}
-func (l *list[T]) Items() []T {
- return l.items
+// SetSelected implements List.
+func (l *list[T]) SetSelected(id string) tea.Cmd {
+ l.selectedItem = id
+ return l.render()
}
-func (l *list[T]) UpdateItem(id string, item T) {
- // TODO: preserve offset
+func (l *list[T]) reset() tea.Cmd {
+ var cmds []tea.Cmd
+ l.rendered = ""
+ l.indexMap = make(map[string]int)
+ l.renderedItems = make(map[string]renderedItem)
for inx, item := range l.items {
- if item.ID() == id {
- l.items[inx] = item
- l.renderedItems[inx] = l.renderItem(item)
- l.renderView()
- return
+ l.indexMap[item.ID()] = inx
+ if l.width > 0 && l.height > 0 {
+ cmds = append(cmds, item.SetSize(l.width, l.height))
}
}
+ cmds = append(cmds, l.render())
+ return tea.Batch(cmds...)
}
-func (l *list[T]) DeleteItem(id string) {
- // TODO: preserve offset
- inx := NotFound
- for i, item := range l.items {
- if item.ID() == id {
- inx = i
- break
- }
- }
-
- l.items = slices.Delete(l.items, inx, inx+1)
- l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
- l.renderView()
-}
-
-func (l *list[T]) PrependItem(item T) tea.Cmd {
- // TODO: preserve offset
- var cmd tea.Cmd
- l.items = append([]T{item}, l.items...)
- l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
- if len(l.items) == 1 {
- cmd = l.SetSelected(item.ID())
- }
- // the viewport did not move and the last item was focused
- if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
- cmd = l.SetSelected(item.ID())
+// SetSize implements List.
+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
+ oldWidth := l.width
+ l.width = width
+ l.height = height
+ if oldWidth != width {
+ return l.reset()
}
- l.renderView()
- return cmd
+ return nil
}
-func (l *list[T]) AppendItem(item T) tea.Cmd {
- // TODO: preserve offset
- var cmd tea.Cmd
- l.items = append(l.items, item)
- l.renderedItems = append(l.renderedItems, l.renderItem(item))
- if len(l.items) == 1 {
- cmd = l.SetSelected(item.ID())
- } else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
- // the viewport did not move and the last item was focused
- cmd = l.SetSelected(item.ID())
- } else {
- l.renderView()
- }
- return cmd
+// UpdateItem implements List.
+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
+ panic("unimplemented")
}
@@ -2,7 +2,7 @@ package list
import (
"fmt"
- "sync"
+ "strings"
"testing"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -11,623 +11,344 @@ import (
"github.com/charmbracelet/x/exp/golden"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-func TestListPosition(t *testing.T) {
+func TestList(t *testing.T) {
t.Parallel()
- type positionOffsetTest struct {
- dir direction
- test string
- width int
- height int
- numItems int
-
- moveUp int
- moveDown int
-
- expectedStart int
- expectedEnd int
- }
- tests := []positionOffsetTest{
- {
- dir: Forward,
- test: "should have correct position initially when forward",
- moveUp: 0,
- moveDown: 0,
- width: 10,
- height: 20,
- numItems: 100,
- expectedStart: 0,
- expectedEnd: 19,
- },
- {
- dir: Forward,
- test: "should offset start and end by one when moving down by one",
- moveUp: 0,
- moveDown: 1,
- width: 10,
- height: 20,
- numItems: 100,
- expectedStart: 1,
- expectedEnd: 20,
- },
- {
- dir: Backward,
- test: "should have correct position initially when backward",
- moveUp: 0,
- moveDown: 0,
- width: 10,
- height: 20,
- numItems: 100,
- expectedStart: 80,
- expectedEnd: 99,
- },
- {
- dir: Backward,
- test: "should offset the start and end by one when moving up by one",
- moveUp: 1,
- moveDown: 0,
- width: 10,
- height: 20,
- numItems: 100,
- expectedStart: 79,
- expectedEnd: 98,
- },
- }
- for _, c := range tests {
- t.Run(c.test, func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range c.numItems {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(c.dir)).(*list[Item])
- l.SetSize(c.width, c.height)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
-
- if c.moveUp > 0 {
- l.MoveUp(c.moveUp)
- }
- if c.moveDown > 0 {
- l.MoveDown(c.moveDown)
- }
- start, end := l.viewPosition()
- assert.Equal(t, c.expectedStart, start)
- assert.Equal(t, c.expectedEnd, end)
- })
- }
-}
-
-func TestBackwardList(t *testing.T) {
- t.Parallel()
- t.Run("within height", func(t *testing.T) {
+ t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
- l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
- l.SetSize(10, 20)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
+ execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
+ assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 5)
+ require.Len(t, l.items, 5)
+ require.Len(t, l.renderedItems, 5)
+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, 0, start)
+ assert.Equal(t, 4, end)
+ for i := range 5 {
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
+ }
+
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should not change selected item", func(t *testing.T) {
+ t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
- l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
- l.SetSize(10, 20)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
+ execCmd(l, l.Init())
+
// should select the last item
- assert.Equal(t, l.selectedItem, items[2].ID())
- })
- t.Run("more than height", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Backward))
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
+ assert.Equal(t, items[4].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 5)
+ require.Len(t, l.items, 5)
+ require.Len(t, l.renderedItems, 5)
+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, 0, start)
+ assert.Equal(t, 4, end)
+ for i := range 5 {
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
}
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("more than height multi line", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Backward))
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move up", func(t *testing.T) {
+ t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
+ for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
- l := New(items, WithDirection(Backward))
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
-
- l.MoveUp(1)
- golden.RequireEqual(t, []byte(l.View()))
- })
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- t.Run("should move at max to the top", func(t *testing.T) {
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Backward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
+ // should select the last item
+ assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 30)
+ require.Len(t, l.items, 30)
+ require.Len(t, l.renderedItems, 30)
+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, 0, start)
+ assert.Equal(t, 9, end)
+ for i := range 30 {
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
}
- l.MoveUp(100)
- assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should do nothing with wrong move number", func(t *testing.T) {
+ t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
+ for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
- l := New(items, WithDirection(Backward))
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- l.MoveUp(-10)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move to the top", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Backward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
+ // should select the last item
+ assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 30)
+ require.Len(t, l.items, 30)
+ require.Len(t, l.renderedItems, 30)
+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, 20, start)
+ assert.Equal(t, 29, end)
+ for i := range 30 {
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+ assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
}
- l.GoToTop()
- assert.Equal(t, l.direction, Forward)
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should select the item above", func(t *testing.T) {
+
+ t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Backward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- selectedInx := len(l.items) - 2
- currentItem := items[len(l.items)-1]
- nextItem := items[selectedInx]
- assert.False(t, nextItem.(SelectableItem).IsFocused())
- assert.True(t, currentItem.(SelectableItem).IsFocused())
- cmd = l.SelectItemAbove()
- if cmd != nil {
- cmd()
+ // should select the last item
+ assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 30)
+ require.Len(t, l.items, 30)
+ require.Len(t, l.renderedItems, 30)
+ expectedLines := 0
+ for i := range 30 {
+ expectedLines += (i + 1) * 1
+ }
+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, 0, start)
+ assert.Equal(t, 9, end)
+ currentPosition := 0
+ for i := range 30 {
+ rItem := l.renderedItems[items[i].ID()]
+ assert.Equal(t, currentPosition, rItem.start)
+ assert.Equal(t, currentPosition+i, rItem.end)
+ currentPosition += i + 1
}
- assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
- assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
-
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
+ t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Backward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- for range 5 {
- cmd = l.SelectItemAbove()
- if cmd != nil {
- cmd()
- }
+ // should select the last item
+ assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
+ require.Len(t, l.indexMap, 30)
+ require.Len(t, l.items, 30)
+ require.Len(t, l.renderedItems, 30)
+ expectedLines := 0
+ for i := range 30 {
+ expectedLines += (i + 1) * 1
+ }
+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+ start, end := l.viewPosition()
+ assert.Equal(t, expectedLines-10, start)
+ assert.Equal(t, expectedLines-1, end)
+ currentPosition := 0
+ for i := range 30 {
+ rItem := l.renderedItems[items[i].ID()]
+ assert.Equal(t, currentPosition, rItem.start)
+ assert.Equal(t, currentPosition+i, rItem.end)
+ currentPosition += i + 1
}
+
golden.RequireEqual(t, []byte(l.View()))
})
-}
-func TestForwardList(t *testing.T) {
- t.Parallel()
- t.Run("within height", func(t *testing.T) {
+ t.Run("should go to selected item and center", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
- l.SetSize(10, 20)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
+ execCmd(l, l.Init())
// should select the last item
- assert.Equal(t, l.selectedItem, items[0].ID())
+ assert.Equal(t, items[4].ID(), l.selectedItem)
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should not change selected item", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
- l.SetSize(10, 20)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- // should select the last item
- assert.Equal(t, l.selectedItem, items[2].ID())
- })
- t.Run("more than height", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("more than height multi line", func(t *testing.T) {
+ t.Run("should go to selected item and center backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
+ execCmd(l, l.Init())
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move down", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ // should select the last item
+ assert.Equal(t, items[4].ID(), l.selectedItem)
- l.MoveDown(1)
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should move at max to the bottom", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- l.MoveDown(100)
- assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should do nothing with wrong move number", func(t *testing.T) {
+ t.Run("should go to selected item at the beginning", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+ execCmd(l, l.Init())
- l.MoveDown(-10)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move to the bottom", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ // should select the last item
+ assert.Equal(t, items[10].ID(), l.selectedItem)
- l.GoToBottom()
- assert.Equal(t, l.direction, Backward)
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should select the item below", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- selectedInx := 1
- currentItem := items[0]
- nextItem := items[selectedInx]
- assert.False(t, nextItem.(SelectableItem).IsFocused())
- assert.True(t, currentItem.(SelectableItem).IsFocused())
- cmd = l.SelectItemBelow()
- if cmd != nil {
- cmd()
- }
-
- assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
- assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
-
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
+ t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 10 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(10, 5)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+ execCmd(l, l.Init())
+
+ // should select the last item
+ assert.Equal(t, items[10].ID(), l.selectedItem)
- for range 5 {
- cmd = l.SelectItemBelow()
- if cmd != nil {
- cmd()
- }
- }
golden.RequireEqual(t, []byte(l.View()))
})
}
-func TestListSelection(t *testing.T) {
+func TestListMovement(t *testing.T) {
t.Parallel()
- t.Run("should skip none selectable items initially", func(t *testing.T) {
+ t.Run("should move viewport up", func(t *testing.T) {
t.Parallel()
items := []Item{}
- items = append(items, NewSimpleItem("None Selectable"))
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- assert.Equal(t, items[1].ID(), l.selectedItem)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should select the correct item on startup", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- cmd := l.Init()
- otherCmd := l.SetSelected(items[3].ID())
- var wg sync.WaitGroup
- if cmd != nil {
- wg.Add(1)
- go func() {
- cmd()
- wg.Done()
- }()
- }
- if otherCmd != nil {
- wg.Add(1)
- go func() {
- otherCmd()
- wg.Done()
- }()
- }
- wg.Wait()
- l.SetSize(100, 10)
- assert.Equal(t, items[3].ID(), l.selectedItem)
- golden.RequireEqual(t, []byte(l.View()))
- })
- t.Run("should skip none selectable items in the middle", func(t *testing.T) {
- t.Parallel()
- items := []Item{}
- item := NewSelectableItem("Item initial")
- items = append(items, item)
- items = append(items, NewSimpleItem("None Selectable"))
- for i := range 5 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
- items = append(items, item)
- }
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- l.SelectItemBelow()
- assert.Equal(t, items[2].ID(), l.selectedItem)
+ execCmd(l, l.MoveUp(25))
+
+ assert.Equal(t, 25, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
-}
-
-func TestListSetSelection(t *testing.T) {
- t.Parallel()
- t.Run("should move to the selected item", func(t *testing.T) {
+ t.Run("should move viewport up and down", func(t *testing.T) {
t.Parallel()
items := []Item{}
- for i := range 100 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Forward)).(*list[Item])
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- cmd = l.SetSelected(items[52].ID())
- if cmd != nil {
- cmd()
- }
+ execCmd(l, l.MoveUp(25))
+ execCmd(l, l.MoveDown(25))
- assert.Equal(t, items[52].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
-}
-func TestListChanges(t *testing.T) {
- t.Parallel()
- t.Run("should append an item to the end", func(t *testing.T) {
+ t.Run("should move viewport down", func(t *testing.T) {
t.Parallel()
- items := []SelectableItem{}
- for i := range 20 {
- item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+ items := []Item{}
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Backward)).(*list[SelectableItem])
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- newItem := NewSelectableItem("New Item")
- l.AppendItem(newItem)
+ execCmd(l, l.MoveDown(25))
- assert.Equal(t, 21, len(l.items))
- assert.Equal(t, 21, len(l.renderedItems))
- assert.Equal(t, newItem.ID(), l.selectedItem)
+ assert.Equal(t, 25, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
- t.Run("should should not change the selected if we moved the offset", func(t *testing.T) {
+ t.Run("should move viewport down and up", func(t *testing.T) {
t.Parallel()
- items := []SelectableItem{}
- for i := range 20 {
- item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
+ items := []Item{}
+ for i := range 30 {
+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+ content = strings.TrimSuffix(content, "\n")
+ item := NewSelectableItem(content)
items = append(items, item)
}
- l := New(items, WithDirection(Backward)).(*list[SelectableItem])
- l.SetSize(100, 10)
- cmd := l.Init()
- if cmd != nil {
- cmd()
- }
- l.MoveUp(1)
+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+ execCmd(l, l.Init())
- newItem := NewSelectableItem("New Item")
- l.AppendItem(newItem)
+ execCmd(l, l.MoveDown(25))
+ execCmd(l, l.MoveUp(25))
- assert.Equal(t, 21, len(l.items))
- assert.Equal(t, 21, len(l.renderedItems))
- assert.Equal(t, l.items[19].ID(), l.selectedItem)
+ assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
}
@@ -711,3 +432,10 @@ func (s *selectableItem) Focus() tea.Cmd {
func (s *selectableItem) IsFocused() bool {
return s.focused
}
+
+func execCmd(m tea.Model, cmd tea.Cmd) {
+ for cmd != nil {
+ msg := cmd()
+ m, cmd = m.Update(msg)
+ }
+}