mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
chore: initial implementation
This commit is contained in:
@@ -1,41 +1,60 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
|
||||
"github.com/charmbracelet/crush/internal/tui/util"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
)
|
||||
|
||||
type Item interface {
|
||||
util.Model
|
||||
layout.Sizeable
|
||||
ID() string
|
||||
}
|
||||
|
||||
type List interface {
|
||||
util.Model
|
||||
layout.Sizeable
|
||||
layout.Focusable
|
||||
SetItems(items []Item) tea.Cmd
|
||||
}
|
||||
|
||||
type direction int
|
||||
|
||||
const (
|
||||
Forward direction = iota
|
||||
Backward
|
||||
)
|
||||
|
||||
const (
|
||||
NotFound = -1
|
||||
)
|
||||
|
||||
type renderedItem struct {
|
||||
id string
|
||||
view string
|
||||
height int
|
||||
}
|
||||
|
||||
type list struct {
|
||||
width, height int
|
||||
offset int
|
||||
gap int
|
||||
direction direction
|
||||
selectedItem string
|
||||
focused bool
|
||||
|
||||
items []Item
|
||||
|
||||
// Filter options
|
||||
filterable bool
|
||||
filterPlaceholder string
|
||||
items []Item
|
||||
renderedItems []renderedItem
|
||||
rendered string
|
||||
isReady bool
|
||||
}
|
||||
|
||||
type listOption func(*list)
|
||||
|
||||
// WithFilterable enables filtering on the list.
|
||||
func WithFilterable(placeholder string) listOption {
|
||||
return func(l *list) {
|
||||
l.filterable = true
|
||||
l.filterPlaceholder = placeholder
|
||||
}
|
||||
}
|
||||
|
||||
// WithItems sets the initial items for the list.
|
||||
func WithItems(items ...Item) listOption {
|
||||
return func(l *list) {
|
||||
@@ -58,9 +77,24 @@ func WithGap(gap int) listOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDirection sets the direction of the list.
|
||||
func WithDirection(dir direction) listOption {
|
||||
return func(l *list) {
|
||||
l.direction = dir
|
||||
}
|
||||
}
|
||||
|
||||
// WithSelectedItem sets the initially selected item in the list.
|
||||
func WithSelectedItem(id string) listOption {
|
||||
return func(l *list) {
|
||||
l.selectedItem = id
|
||||
}
|
||||
}
|
||||
|
||||
func New(opts ...listOption) List {
|
||||
list := &list{
|
||||
items: make([]Item, 0),
|
||||
items: make([]Item, 0),
|
||||
direction: Forward,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(list)
|
||||
@@ -73,15 +107,422 @@ func (l *list) Init() tea.Cmd {
|
||||
if l.height <= 0 || l.width <= 0 {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
if len(l.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
// Update implements List.
|
||||
func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
|
||||
panic("unimplemented")
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// View implements List.
|
||||
func (l *list) View() string {
|
||||
panic("unimplemented")
|
||||
if l.height <= 0 || l.width <= 0 {
|
||||
return ""
|
||||
}
|
||||
view := l.rendered
|
||||
lines := strings.Split(view, "\n")
|
||||
|
||||
start, end := l.viewPosition(len(lines))
|
||||
lines = lines[start:end]
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (l *list) viewPosition(total int) (int, int) {
|
||||
start, end := 0, 0
|
||||
if l.direction == Forward {
|
||||
start = max(0, l.offset)
|
||||
end = min(l.offset+l.listHeight(), total)
|
||||
} else {
|
||||
start = max(0, total-l.offset-l.listHeight())
|
||||
end = max(0, total-l.offset)
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func (l *list) renderItem(item Item) renderedItem {
|
||||
view := item.View()
|
||||
return renderedItem{
|
||||
id: item.ID(),
|
||||
view: view,
|
||||
height: lipgloss.Height(view),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) 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))
|
||||
}
|
||||
}
|
||||
l.rendered = sb.String()
|
||||
}
|
||||
|
||||
func (l *list) incrementOffset(n int) {
|
||||
if !l.isReady {
|
||||
return
|
||||
}
|
||||
renderedHeight := lipgloss.Height(l.rendered)
|
||||
// no need for offset
|
||||
if renderedHeight <= l.listHeight() {
|
||||
return
|
||||
}
|
||||
maxOffset := renderedHeight - l.listHeight()
|
||||
n = min(n, maxOffset-l.offset)
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
l.offset += n
|
||||
}
|
||||
|
||||
func (l *list) decrementOffset(n int) {
|
||||
if !l.isReady {
|
||||
return
|
||||
}
|
||||
n = min(n, l.offset)
|
||||
if n <= 0 {
|
||||
return
|
||||
}
|
||||
l.offset -= n
|
||||
if l.offset < 0 {
|
||||
l.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) MoveUp(n int) {
|
||||
if l.direction == Forward {
|
||||
l.decrementOffset(n)
|
||||
} else {
|
||||
l.incrementOffset(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) MoveDown(n int) {
|
||||
if l.direction == Forward {
|
||||
l.incrementOffset(n)
|
||||
} else {
|
||||
l.decrementOffset(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) firstSelectableItemBefore(inx int) int {
|
||||
for i := inx - 1; i >= 0; i-- {
|
||||
if _, ok := l.items[i].(layout.Focusable); ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return NotFound
|
||||
}
|
||||
|
||||
func (l *list) firstSelectableItemAfter(inx int) int {
|
||||
for i := inx + 1; i < len(l.items); i++ {
|
||||
if _, ok := l.items[i].(layout.Focusable); ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return NotFound
|
||||
}
|
||||
|
||||
func (l *list) moveToSelected() {
|
||||
if l.selectedItem == "" || !l.isReady {
|
||||
return
|
||||
}
|
||||
currentPosition := 0
|
||||
start, end := l.viewPosition(lipgloss.Height(l.rendered))
|
||||
for _, item := range l.renderedItems {
|
||||
if item.id == l.selectedItem {
|
||||
if start <= currentPosition && currentPosition <= end {
|
||||
return
|
||||
}
|
||||
// we need to go up
|
||||
if currentPosition < start {
|
||||
l.MoveUp(start - currentPosition)
|
||||
}
|
||||
// we need to go down
|
||||
if currentPosition > end {
|
||||
l.MoveDown(currentPosition - end)
|
||||
}
|
||||
}
|
||||
currentPosition += item.height + l.gap
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) SelectItemAbove() tea.Cmd {
|
||||
if !l.isReady {
|
||||
return nil
|
||||
}
|
||||
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 := 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 := above.(layout.Focusable); ok {
|
||||
cmds = append(cmds, focusable.Focus())
|
||||
}
|
||||
// rerender the item
|
||||
l.renderedItems[inx] = l.renderItem(above)
|
||||
l.selectedItem = above.ID()
|
||||
break
|
||||
}
|
||||
}
|
||||
l.renderView()
|
||||
l.moveToSelected()
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (l *list) 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 := item.(layout.Focusable); ok {
|
||||
cmds = append(cmds, focusable.Blur())
|
||||
}
|
||||
// rerender the item
|
||||
l.renderedItems[i] = l.renderItem(item)
|
||||
|
||||
// focus the item below
|
||||
below := l.items[inx]
|
||||
if focusable, ok := below.(layout.Focusable); ok {
|
||||
cmds = append(cmds, focusable.Focus())
|
||||
}
|
||||
// rerender the item
|
||||
l.renderedItems[inx] = l.renderItem(below)
|
||||
l.selectedItem = below.ID()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
l.renderView()
|
||||
l.moveToSelected()
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (l *list) GoToTop() tea.Cmd {
|
||||
if !l.isReady {
|
||||
return nil
|
||||
}
|
||||
l.offset = 0
|
||||
l.direction = Forward
|
||||
return tea.Batch(l.selectFirstItem(), l.renderForward())
|
||||
}
|
||||
|
||||
func (l *list) GoToBottom() tea.Cmd {
|
||||
if !l.isReady {
|
||||
return nil
|
||||
}
|
||||
l.offset = 0
|
||||
l.direction = Backward
|
||||
|
||||
return tea.Batch(l.selectLastItem(), l.renderBackward())
|
||||
}
|
||||
|
||||
func (l *list) 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 > l.listHeight() {
|
||||
break
|
||||
}
|
||||
rendered := l.renderItem(item)
|
||||
l.renderedItems = append(l.renderedItems, rendered)
|
||||
currentHeight += rendered.height + l.gap
|
||||
}
|
||||
|
||||
// initial render
|
||||
l.renderView()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) 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 == len(l.items)-1 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func (l *list) selectFirstItem() tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
inx := l.firstSelectableItemAfter(-1)
|
||||
if inx != NotFound {
|
||||
l.selectedItem = l.items[inx].ID()
|
||||
if focusable, ok := l.items[inx].(layout.Focusable); ok {
|
||||
cmd = focusable.Focus()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (l *list) 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 := l.items[inx].(layout.Focusable); ok {
|
||||
cmd = focusable.Focus()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (l *list) renderItems() tea.Cmd {
|
||||
if l.height <= 0 || l.width <= 0 {
|
||||
return nil
|
||||
}
|
||||
if len(l.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if l.selectedItem == "" {
|
||||
if l.direction == Forward {
|
||||
l.selectFirstItem()
|
||||
} else {
|
||||
l.selectLastItem()
|
||||
}
|
||||
}
|
||||
return l.renderBackward()
|
||||
}
|
||||
|
||||
func (l *list) listHeight() int {
|
||||
// for the moment its the same
|
||||
return l.height
|
||||
}
|
||||
|
||||
func (l *list) SetItems(items []Item) 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))
|
||||
}
|
||||
cmds = append(cmds, l.renderItems())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// GetSize implements List.
|
||||
func (l *list) GetSize() (int, int) {
|
||||
return l.width, l.height
|
||||
}
|
||||
|
||||
// SetSize implements List.
|
||||
func (l *list) 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))
|
||||
}
|
||||
cmds = append(cmds, l.renderItems())
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Blur implements List.
|
||||
func (l *list) 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 := item.(layout.Focusable); ok {
|
||||
cmd = focusable.Blur()
|
||||
}
|
||||
l.renderedItems[i] = l.renderItem(item)
|
||||
}
|
||||
l.renderView()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Focus implements List.
|
||||
func (l *list) Focus() tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
l.focused = true
|
||||
for i, item := range l.items {
|
||||
if item.ID() != l.selectedItem {
|
||||
continue
|
||||
}
|
||||
if focusable, ok := item.(layout.Focusable); ok {
|
||||
cmd = focusable.Focus()
|
||||
}
|
||||
l.renderedItems[i] = l.renderItem(item)
|
||||
}
|
||||
l.renderView()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// IsFocused implements List.
|
||||
func (l *list) IsFocused() bool {
|
||||
return l.focused
|
||||
}
|
||||
|
||||
462
internal/tui/exp/list/list_test.go
Normal file
462
internal/tui/exp/list/list_test.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/exp/golden"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBackwardList(t *testing.T) {
|
||||
t.Run("within height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Backward), WithGap(1)).(*list)
|
||||
l.SetSize(10, 20)
|
||||
items := []Item{}
|
||||
for i := range 5 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
// should select the last item
|
||||
assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
|
||||
|
||||
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 := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
|
||||
l.SetSize(10, 20)
|
||||
cmd := l.SetItems(items)
|
||||
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()
|
||||
l := New(WithDirection(Backward))
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("more than height multi line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Backward))
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move up", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
l.MoveUp(1)
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move at max to the top", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
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.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
l.MoveUp(-10)
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move to the top", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
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.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
selectedInx := len(l.items) - 2
|
||||
currentItem := items[len(l.items)-1]
|
||||
nextItem := items[selectedInx]
|
||||
assert.False(t, nextItem.(SimpleItem).IsFocused())
|
||||
assert.True(t, currentItem.(SimpleItem).IsFocused())
|
||||
cmd = l.SelectItemAbove()
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
|
||||
assert.True(t, l.items[selectedInx].(SimpleItem).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.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
for range 5 {
|
||||
cmd = l.SelectItemAbove()
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
}
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestForwardList(t *testing.T) {
|
||||
t.Run("within height", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Forward), WithGap(1)).(*list)
|
||||
l.SetSize(10, 20)
|
||||
items := []Item{}
|
||||
for i := range 5 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
// should select the last item
|
||||
assert.Equal(t, l.selectedItem, items[0].ID())
|
||||
|
||||
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 := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
|
||||
l.SetSize(10, 20)
|
||||
cmd := l.SetItems(items)
|
||||
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()
|
||||
l := New(WithDirection(Forward))
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("more than height multi line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Forward))
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move down", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Forward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
l.MoveDown(1)
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move at max to the top", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Forward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
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.Parallel()
|
||||
l := New(WithDirection(Forward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
l.MoveDown(-10)
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
t.Run("should move to the bottom", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(WithDirection(Forward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
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()
|
||||
l := New(WithDirection(Forward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
selectedInx := 1
|
||||
currentItem := items[0]
|
||||
nextItem := items[selectedInx]
|
||||
assert.False(t, nextItem.(SimpleItem).IsFocused())
|
||||
assert.True(t, currentItem.(SimpleItem).IsFocused())
|
||||
cmd = l.SelectItemBelow()
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
|
||||
assert.True(t, l.items[selectedInx].(SimpleItem).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.Parallel()
|
||||
l := New(WithDirection(Backward)).(*list)
|
||||
l.SetSize(10, 5)
|
||||
items := []Item{}
|
||||
for i := range 10 {
|
||||
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
|
||||
items = append(items, item)
|
||||
}
|
||||
cmd := l.SetItems(items)
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
|
||||
for range 5 {
|
||||
cmd = l.SelectItemBelow()
|
||||
if cmd != nil {
|
||||
cmd()
|
||||
}
|
||||
}
|
||||
golden.RequireEqual(t, []byte(l.View()))
|
||||
})
|
||||
}
|
||||
|
||||
type SimpleItem interface {
|
||||
Item
|
||||
layout.Focusable
|
||||
}
|
||||
|
||||
type simpleItem struct {
|
||||
width int
|
||||
content string
|
||||
id string
|
||||
focused bool
|
||||
}
|
||||
|
||||
func NewSimpleItem(content string) SimpleItem {
|
||||
return &simpleItem{
|
||||
width: 0,
|
||||
content: content,
|
||||
focused: false,
|
||||
id: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *simpleItem) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *simpleItem) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *simpleItem) View() string {
|
||||
if s.focused {
|
||||
return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
|
||||
}
|
||||
return lipgloss.NewStyle().Width(s.width).Render(s.content)
|
||||
}
|
||||
|
||||
func (l *simpleItem) GetSize() (int, int) {
|
||||
return l.width, 0
|
||||
}
|
||||
|
||||
// SetSize implements Item.
|
||||
func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
|
||||
s.width = width
|
||||
return nil
|
||||
}
|
||||
|
||||
// Blur implements SimpleItem.
|
||||
func (s *simpleItem) Blur() tea.Cmd {
|
||||
s.focused = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Focus implements SimpleItem.
|
||||
func (s *simpleItem) Focus() tea.Cmd {
|
||||
s.focused = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFocused implements SimpleItem.
|
||||
func (s *simpleItem) IsFocused() bool {
|
||||
return s.focused
|
||||
}
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
│Item 9
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Line2
|
||||
Item 8
|
||||
Line2
|
||||
│Item 9
|
||||
│Line2
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
│Item 9
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
│Item 4
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 4
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
5
internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
│Item 8
|
||||
Item 9
|
||||
11
internal/tui/exp/list/testdata/TestBackwardList/within_height.golden
generated
vendored
Normal file
11
internal/tui/exp/list/testdata/TestBackwardList/within_height.golden
generated
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
Item 0
|
||||
|
||||
Item 0
|
||||
|
||||
Item 1
|
||||
|
||||
Item 2
|
||||
|
||||
Item 3
|
||||
|
||||
│Item 4
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
│Item 0
|
||||
│Line2
|
||||
Item 1
|
||||
Line2
|
||||
Item 2
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
Item 9
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
Item 5
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
│Item 9
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
│Item 9
|
||||
5
internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 0
|
||||
│Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
11
internal/tui/exp/list/testdata/TestForwardList/within_height.golden
generated
vendored
Normal file
11
internal/tui/exp/list/testdata/TestForwardList/within_height.golden
generated
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
│Item 0
|
||||
|
||||
│Item 0
|
||||
|
||||
Item 1
|
||||
|
||||
Item 2
|
||||
|
||||
Item 3
|
||||
|
||||
Item 4
|
||||
Reference in New Issue
Block a user