From 99bbcce22427c1eb8ba24a996e766867aa96d2ee Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sat, 19 Jul 2025 21:06:38 +0200 Subject: [PATCH 01/18] chore: initial implementation --- internal/tui/exp/list/list.go | 475 +++++++++++++++++- internal/tui/exp/list/list_test.go | 462 +++++++++++++++++ .../TestBackwardList/more_than_height.golden | 5 + .../more_than_height_multi_line.golden | 5 + ...d_do_nothing_with_wrong_move_number.golden | 5 + .../should_move_at_max_to_the_top.golden | 5 + ...to_be_able_to_see_the_selected_item.golden | 5 + .../should_move_to_the_top.golden | 5 + .../TestBackwardList/should_move_up.golden | 5 + .../should_select_the_item_above.golden | 5 + .../TestBackwardList/within_height.golden | 11 + .../TestForwardList/more_than_height.golden | 5 + .../more_than_height_multi_line.golden | 5 + ...d_do_nothing_with_wrong_move_number.golden | 5 + .../should_move_at_max_to_the_top.golden | 5 + .../TestForwardList/should_move_down.golden | 5 + ...to_be_able_to_see_the_selected_item.golden | 5 + .../should_move_to_the_bottom.golden | 5 + .../should_select_the_item_below.golden | 5 + .../TestForwardList/within_height.golden | 11 + 20 files changed, 1022 insertions(+), 17 deletions(-) create mode 100644 internal/tui/exp/list/list_test.go create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden create mode 100644 internal/tui/exp/list/testdata/TestBackwardList/within_height.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden create mode 100644 internal/tui/exp/list/testdata/TestForwardList/within_height.golden diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index d2cb49d7ab09048e518dbff8ce55427d4a16dc75..e9e304fac8463cfce42dc02e71962879abaa5643 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -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 } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..54f6f3fcac9878e29f2e309e1aee902cefdf46c0 --- /dev/null +++ b/internal/tui/exp/list/list_test.go @@ -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 +} diff --git a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden new file mode 100644 index 0000000000000000000000000000000000000000..b8fd0efdb00bce286317007e40b2af335d22942f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +Item 8 +│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden new file mode 100644 index 0000000000000000000000000000000000000000..785a18b24f21ee9f6a58bf0a0b540bff4b5c097d --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden @@ -0,0 +1,5 @@ +Line2 +Item 8 +Line2 +│Item 9 +│Line2 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden new file mode 100644 index 0000000000000000000000000000000000000000..b8fd0efdb00bce286317007e40b2af335d22942f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +Item 8 +│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden new file mode 100644 index 0000000000000000000000000000000000000000..b811a8818ab2e87fdd09b64f69dcca8694a5b77c --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden @@ -0,0 +1,5 @@ +Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden new file mode 100644 index 0000000000000000000000000000000000000000..5fb57a04f2136e6f00fa780620e61301a4d5fe9c --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden @@ -0,0 +1,5 @@ +│Item 4 +Item 5 +Item 6 +Item 7 +Item 8 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden new file mode 100644 index 0000000000000000000000000000000000000000..7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden @@ -0,0 +1,5 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..97a5e679968d701bfd0c4df1ab01e4d64891bbfe --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden @@ -0,0 +1,5 @@ +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden new file mode 100644 index 0000000000000000000000000000000000000000..d2e6c78521781d77668673db6447d67dacd4097a --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +│Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden b/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden new file mode 100644 index 0000000000000000000000000000000000000000..a4b10135f97530577ee977b4325e30fe34184f21 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden @@ -0,0 +1,11 @@ +Item 0 + +Item 0 + +Item 1 + +Item 2 + +Item 3 + +│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden b/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden new file mode 100644 index 0000000000000000000000000000000000000000..7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden @@ -0,0 +1,5 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden b/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden new file mode 100644 index 0000000000000000000000000000000000000000..f8a79a980eb0cd28f70f1593705e63c6736f2eaa --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden @@ -0,0 +1,5 @@ +│Item 0 +│Line2 +Item 1 +Line2 +Item 2 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden b/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden new file mode 100644 index 0000000000000000000000000000000000000000..7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden @@ -0,0 +1,5 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden new file mode 100644 index 0000000000000000000000000000000000000000..33aff077aeecc3b1f74ca93b7c7589b908abf16d --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden new file mode 100644 index 0000000000000000000000000000000000000000..f01acdd8274b7ebb8307ba004b05ab205868cf71 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden @@ -0,0 +1,5 @@ +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden new file mode 100644 index 0000000000000000000000000000000000000000..b8fd0efdb00bce286317007e40b2af335d22942f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +Item 8 +│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden new file mode 100644 index 0000000000000000000000000000000000000000..b8fd0efdb00bce286317007e40b2af335d22942f --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden @@ -0,0 +1,5 @@ +Item 5 +Item 6 +Item 7 +Item 8 +│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden b/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden new file mode 100644 index 0000000000000000000000000000000000000000..7368e97d2ca758d46ae230cd5267cd93c4ee4dec --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden @@ -0,0 +1,5 @@ +Item 0 +│Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/within_height.golden b/internal/tui/exp/list/testdata/TestForwardList/within_height.golden new file mode 100644 index 0000000000000000000000000000000000000000..1450d9e9ac0bc14266454379b8736df4b7775bb0 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestForwardList/within_height.golden @@ -0,0 +1,11 @@ +│Item 0 + +│Item 0 + +Item 1 + +Item 2 + +Item 3 + +Item 4 \ No newline at end of file From 3e18da56ec60fa96c497aee5c49d962a534ccc77 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Jul 2025 09:56:05 +0200 Subject: [PATCH 02/18] chore: more tests --- internal/tui/exp/list/list.go | 82 ++++++-- internal/tui/exp/list/list_test.go | 197 ++++++++++++++---- .../should_move_at_max_to_the_top.golden | 2 +- .../TestBackwardList/should_move_up.golden | 2 +- .../TestBackwardList/within_height.golden | 2 - ...> should_move_at_max_to_the_bottom.golden} | 2 +- .../TestForwardList/should_move_down.golden | 2 +- .../TestForwardList/within_height.golden | 2 - ...kip_none_selectable_items_initially.golden | 6 + 9 files changed, 239 insertions(+), 58 deletions(-) rename internal/tui/exp/list/testdata/TestForwardList/{should_move_at_max_to_the_top.golden => should_move_at_max_to_the_bottom.golden} (58%) create mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index e9e304fac8463cfce42dc02e71962879abaa5643..92c9a38c2a2fbd4c6338a8873e73450bf64c8d70 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,6 +1,7 @@ package list import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea/v2" @@ -132,19 +133,20 @@ func (l *list) View() string { view := l.rendered lines := strings.Split(view, "\n") - start, end := l.viewPosition(len(lines)) - lines = lines[start:end] + start, end := l.viewPosition() + lines = lines[start : end+1] return strings.Join(lines, "\n") } -func (l *list) viewPosition(total int) (int, int) { +func (l *list) viewPosition() (int, int) { start, end := 0, 0 + renderedLines := lipgloss.Height(l.rendered) - 1 if l.direction == Forward { start = max(0, l.offset) - end = min(l.offset+l.listHeight(), total) + end = min(l.offset+l.listHeight()-1, renderedLines) } else { - start = max(0, total-l.offset-l.listHeight()) - end = max(0, total-l.offset) + start = max(0, renderedLines-l.offset-l.listHeight()+1) + end = max(0, renderedLines-l.offset) } return start, end } @@ -200,20 +202,71 @@ func (l *list) decrementOffset(n int) { } } -func (l *list) MoveUp(n int) { +// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible +func (l *list) changeSelectedWhenNotVisible() tea.Cmd { + var cmds []tea.Cmd + 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 is completely below the viewport + if itemStart > end && itemEnd > end { + needsMove = true + } + if needsMove { + if focusable, ok := item.(layout.Focusable); ok { + cmds = append(cmds, focusable.Blur()) + } + l.renderedItems[i] = l.renderItem(item) + } else { + return nil + } + } + if itemWithinView != NotFound && needsMove { + newSelection := l.items[itemWithinView] + l.selectedItem = newSelection.ID() + if focusable, ok := newSelection.(layout.Focusable); ok { + cmds = append(cmds, focusable.Focus()) + } + l.renderedItems[itemWithinView] = l.renderItem(newSelection) + break + } + currentPosition += rendered.height + l.gap + } + l.renderView() + return tea.Batch(cmds...) +} + +func (l *list) MoveUp(n int) tea.Cmd { if l.direction == Forward { l.decrementOffset(n) } else { l.incrementOffset(n) } + return l.changeSelectedWhenNotVisible() } -func (l *list) MoveDown(n int) { +func (l *list) MoveDown(n int) tea.Cmd { if l.direction == Forward { l.incrementOffset(n) } else { l.decrementOffset(n) } + return l.changeSelectedWhenNotVisible() } func (l *list) firstSelectableItemBefore(inx int) int { @@ -239,10 +292,10 @@ func (l *list) moveToSelected() { return } currentPosition := 0 - start, end := l.viewPosition(lipgloss.Height(l.rendered)) + start, end := l.viewPosition() for _, item := range l.renderedItems { if item.id == l.selectedItem { - if start <= currentPosition && currentPosition <= end { + if start <= currentPosition && (currentPosition+item.height) <= end { return } // we need to go up @@ -354,7 +407,7 @@ func (l *list) renderForward() tea.Cmd { currentIndex := 0 for i, item := range l.items { currentIndex = i - if currentHeight > l.listHeight() { + if currentHeight-1 > l.listHeight() { break } rendered := l.renderItem(item) @@ -387,6 +440,7 @@ func (l *list) renderBackward() tea.Cmd { currentHeight := 0 currentIndex := 0 for i := len(l.items) - 1; i >= 0; i-- { + fmt.Printf("rendering item %d\n", i) currentIndex = i if currentHeight > l.listHeight() { break @@ -397,12 +451,13 @@ func (l *list) renderBackward() tea.Cmd { } // initial render l.renderView() - if currentIndex == len(l.items)-1 { + if currentIndex == 0 { l.isReady = true return nil } return func() tea.Msg { for i := currentIndex; i >= 0; i-- { + fmt.Printf("rendering item after %d\n", i) rendered := l.renderItem(l.items[i]) l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...) } @@ -451,6 +506,9 @@ func (l *list) renderItems() tea.Cmd { l.selectLastItem() } } + if l.direction == Forward { + return l.renderForward() + } return l.renderBackward() } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 54f6f3fcac9878e29f2e309e1aee902cefdf46c0..4623d7e025dda586e78853cd4b26889705eb40de 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -12,6 +12,93 @@ import ( "github.com/stretchr/testify/assert" ) +func TestListPosition(t *testing.T) { + 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) { + l := New(WithDirection(c.dir)).(*list) + l.SetSize(c.width, c.height) + items := []Item{} + for i := range c.numItems { + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + cmd := l.SetItems(items) + 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.Run("within height", func(t *testing.T) { t.Parallel() @@ -19,7 +106,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 20) items := []Item{} for i := range 5 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -36,7 +123,7 @@ func TestBackwardList(t *testing.T) { t.Parallel() items := []Item{} for i := range 5 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) @@ -54,7 +141,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -70,7 +157,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -86,7 +173,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -97,13 +184,13 @@ func TestBackwardList(t *testing.T) { 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)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -121,7 +208,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -138,7 +225,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -156,7 +243,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -167,15 +254,15 @@ func TestBackwardList(t *testing.T) { 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()) + assert.False(t, nextItem.(SelectableItem).IsFocused()) + assert.True(t, currentItem.(SelectableItem).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()) + assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused()) golden.RequireEqual(t, []byte(l.View())) }) @@ -185,7 +272,7 @@ func TestBackwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -210,7 +297,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 20) items := []Item{} for i := range 5 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -227,7 +314,7 @@ func TestForwardList(t *testing.T) { t.Parallel() items := []Item{} for i := range 5 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) @@ -245,7 +332,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -261,7 +348,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -277,7 +364,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -288,13 +375,13 @@ func TestForwardList(t *testing.T) { l.MoveDown(1) golden.RequireEqual(t, []byte(l.View())) }) - t.Run("should move at max to the top", func(t *testing.T) { + t.Run("should move at max 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)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -312,7 +399,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -329,7 +416,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -347,7 +434,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -358,15 +445,15 @@ func TestForwardList(t *testing.T) { selectedInx := 1 currentItem := items[0] nextItem := items[selectedInx] - assert.False(t, nextItem.(SimpleItem).IsFocused()) - assert.True(t, currentItem.(SimpleItem).IsFocused()) + 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].(SimpleItem).IsFocused()) + assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused()) golden.RequireEqual(t, []byte(l.View())) }) @@ -376,7 +463,7 @@ func TestForwardList(t *testing.T) { l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSimpleItem(fmt.Sprintf("Item %d", i)) + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } cmd := l.SetItems(items) @@ -394,7 +481,28 @@ func TestForwardList(t *testing.T) { }) } -type SimpleItem interface { +func TestListSelection(t *testing.T) { + t.Run("should skip none selectable items initially", func(t *testing.T) { + t.Parallel() + l := New(WithDirection(Forward)).(*list) + l.SetSize(100, 10) + items := []Item{} + items = append(items, NewSimpleItem("None Selectable")) + for i := range 5 { + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + cmd := l.SetItems(items) + if cmd != nil { + cmd() + } + + assert.Equal(t, items[1].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +type SelectableItem interface { Item layout.Focusable } @@ -403,15 +511,24 @@ type simpleItem struct { width int content string id string +} +type selectableItem struct { + *simpleItem focused bool } -func NewSimpleItem(content string) SimpleItem { +func NewSimpleItem(content string) *simpleItem { return &simpleItem{ + id: uuid.NewString(), width: 0, content: content, - focused: false, - id: uuid.NewString(), + } +} + +func NewSelectsableItem(content string) SelectableItem { + return &selectableItem{ + simpleItem: NewSimpleItem(content), + focused: false, } } @@ -428,9 +545,6 @@ func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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) } @@ -444,19 +558,26 @@ func (s *simpleItem) SetSize(width int, height int) tea.Cmd { return nil } +func (s *selectableItem) 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) +} + // Blur implements SimpleItem. -func (s *simpleItem) Blur() tea.Cmd { +func (s *selectableItem) Blur() tea.Cmd { s.focused = false return nil } // Focus implements SimpleItem. -func (s *simpleItem) Focus() tea.Cmd { +func (s *selectableItem) Focus() tea.Cmd { s.focused = true return nil } // IsFocused implements SimpleItem. -func (s *simpleItem) IsFocused() bool { +func (s *selectableItem) IsFocused() bool { return s.focused } diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden index b811a8818ab2e87fdd09b64f69dcca8694a5b77c..a92d5cd50b42ac4e59b2fac2fc21355b30d4c1d0 100644 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden @@ -2,4 +2,4 @@ Item 0 Item 1 Item 2 Item 3 -Item 4 \ No newline at end of file +│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden index 97a5e679968d701bfd0c4df1ab01e4d64891bbfe..b34ef9acef9960d727b203566011bb66953079d4 100644 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden +++ b/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden @@ -2,4 +2,4 @@ Item 4 Item 5 Item 6 Item 7 -Item 8 \ No newline at end of file +│Item 8 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden b/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden index a4b10135f97530577ee977b4325e30fe34184f21..4406faf046ad8229b1dc8908091ad47d555ddaf6 100644 --- a/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden +++ b/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden @@ -1,7 +1,5 @@ Item 0 -Item 0 - Item 1 Item 2 diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden similarity index 58% rename from internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden rename to internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden index 33aff077aeecc3b1f74ca93b7c7589b908abf16d..d5091ddac1b9d427f257f37dd7fe57ebf871da62 100644 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_top.golden +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden @@ -1,4 +1,4 @@ -Item 5 +│Item 5 Item 6 Item 7 Item 8 diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden index f01acdd8274b7ebb8307ba004b05ab205868cf71..691521bf35b5d15776b6c7cef93c0c1bbd4a26ba 100644 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden @@ -1,4 +1,4 @@ -Item 1 +│Item 1 Item 2 Item 3 Item 4 diff --git a/internal/tui/exp/list/testdata/TestForwardList/within_height.golden b/internal/tui/exp/list/testdata/TestForwardList/within_height.golden index 1450d9e9ac0bc14266454379b8736df4b7775bb0..676da068c53cadc771497892ae66daeb786aaaa2 100644 --- a/internal/tui/exp/list/testdata/TestForwardList/within_height.golden +++ b/internal/tui/exp/list/testdata/TestForwardList/within_height.golden @@ -1,7 +1,5 @@ │Item 0 -│Item 0 - Item 1 Item 2 diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden new file mode 100644 index 0000000000000000000000000000000000000000..12d86d00139c82ff088421a1dfac9b66d82747cc --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden @@ -0,0 +1,6 @@ +None Selectable +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file From 966d24da22a62b3307f9ff11b73d9da59148f858 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Jul 2025 10:00:19 +0200 Subject: [PATCH 03/18] chore: test selection - it should skip unselectable items in the middle --- internal/tui/exp/list/list_test.go | 20 +++++++++++++++++++ ...none_selectable_items_in_the_middle.golden | 7 +++++++ 2 files changed, 27 insertions(+) create mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 4623d7e025dda586e78853cd4b26889705eb40de..a9d1541dee784d2ce1b652773d84138bedcb8241 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -500,6 +500,26 @@ func TestListSelection(t *testing.T) { assert.Equal(t, items[1].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() + l := New(WithDirection(Forward)).(*list) + l.SetSize(100, 10) + items := []Item{} + item := NewSelectsableItem("Item initial") + items = append(items, item) + items = append(items, NewSimpleItem("None Selectable")) + for i := range 5 { + item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + cmd := l.SetItems(items) + if cmd != nil { + cmd() + } + l.SelectItemBelow() + assert.Equal(t, items[2].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) } type SelectableItem interface { diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden new file mode 100644 index 0000000000000000000000000000000000000000..81eb3372876da6a55bf90efe3bc3c5da96c3ef54 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden @@ -0,0 +1,7 @@ +Item initial +None Selectable +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file From aab9b3b8d668090b73d62407e7b06fb97d1d8be0 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Jul 2025 16:59:54 +0200 Subject: [PATCH 04/18] chore: implement new filterable list - use this list in the sessions selector --- cspell.json | 109 +----- internal/tui/components/core/list/list.go | 1 - .../components/dialogs/sessions/sessions.go | 63 ++-- internal/tui/exp/list/filterable.go | 297 +++++++++++++++++ internal/tui/exp/list/filterable_test.go | 67 ++++ internal/tui/exp/list/items.go | 308 +++++++++++++++++ internal/tui/exp/list/keys.go | 63 ++++ internal/tui/exp/list/list.go | 315 ++++++++++++------ internal/tui/exp/list/list_test.go | 241 ++++++++------ ...hould_create_simple_filterable_list.golden | 6 + ...to_be_able_to_see_the_selected_item.golden | 10 +- ..._select_the_correct_item_on_startup.golden | 5 + .../should_move_to_the_selected_item.golden | 10 + 13 files changed, 1159 insertions(+), 336 deletions(-) create mode 100644 internal/tui/exp/list/filterable.go create mode 100644 internal/tui/exp/list/filterable_test.go create mode 100644 internal/tui/exp/list/items.go create mode 100644 internal/tui/exp/list/keys.go create mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden create mode 100644 internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden diff --git a/cspell.json b/cspell.json index 2cffa38ca36558d9273f2781dd7a686be1b3820d..797efddbfc2ba8dbbb8b121f4192f2449b2025ae 100644 --- a/cspell.json +++ b/cspell.json @@ -1,108 +1 @@ -{ - "words": [ - "afero", - "agentic", - "alecthomas", - "anthropics", - "aymanbagabas", - "azidentity", - "bmatcuk", - "bubbletea", - "charlievieth", - "charmbracelet", - "charmtone", - "Charple", - "chkconfig", - "crush", - "curlie", - "cursorrules", - "diffview", - "doas", - "Dockerfiles", - "doublestar", - "dpkg", - "Emph", - "fastwalk", - "fdisk", - "filepicker", - "Focusable", - "fseventsd", - "fsext", - "genai", - "goquery", - "GROQ", - "Guac", - "imageorient", - "Inex", - "jetta", - "jsons", - "jsonschema", - "jspm", - "Kaufmann", - "killall", - "Lanczos", - "lipgloss", - "LOCALAPPDATA", - "lsps", - "lucasb", - "makepkg", - "mcps", - "MSYS", - "mvdan", - "natefinch", - "nfnt", - "noctx", - "nohup", - "nolint", - "nslookup", - "oksvg", - "Oneshot", - "openrouter", - "opkg", - "pacman", - "paru", - "pfctl", - "postamble", - "postambles", - "preconfigured", - "Preproc", - "Proactiveness", - "Puerkito", - "pycache", - "pytest", - "qjebbs", - "rasterx", - "rivo", - "sabhiram", - "sess", - "shortlog", - "sjson", - "Sourcegraph", - "srwiley", - "SSEMCP", - "Streamable", - "stretchr", - "Strikethrough", - "substrs", - "Suscriber", - "systeminfo", - "tasklist", - "termenv", - "textinput", - "tidwall", - "timedout", - "trashhalo", - "udiff", - "uniseg", - "Unticked", - "urllib", - "USERPROFILE", - "VERTEXAI", - "webp", - "whatis", - "whereis" - ], - "flagWords": [], - "language": "en", - "version": "0.2" -} +{"flagWords":[],"version":"0.2","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm"],"language":"en"} \ No newline at end of file diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 3f99eda5d979e72f0497a120e056df10aca228c3..c6b0a8b1590792b20e05eb7a834b219bd1c00c10 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel { } if m.filterable && !m.hideFilterInput { - t := styles.CurrentTheme() ti := textinput.New() ti.Placeholder = m.filterPlaceholder ti.SetVirtualCursor(false) diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index a95ae0c5ce9b07d499d4f78834a69ccd7ed5635f..7822256d9afb5b8583144142acb55ea3ec287483 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -6,10 +6,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -22,6 +21,8 @@ type SessionDialog interface { dialogs.DialogModel } +type SessionsList = list.FilterableList[list.CompletionItem[session.Session]] + type sessionDialogCmp struct { selectedInx int wWidth int @@ -29,8 +30,7 @@ type sessionDialogCmp struct { width int selectedSessionID string keyMap KeyMap - sessionsList list.ListModel - renderedSelected bool + sessionsList SessionsList help help.Model } @@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD t := styles.CurrentTheme() listKeyMap := list.DefaultKeyMap() keyMap := DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - selectedInx := 0 - items := make([]util.Model, len(sessions)) + items := make([]list.CompletionItem[session.Session], len(sessions)) if len(sessions) > 0 { for i, session := range sessions { - items[i] = completions.NewCompletionItem(session.Title, session) - if session.ID == selectedID { - selectedInx = i - } + items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID)) } } - sessionsList := list.New( - list.WithFilterable(true), + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + sessionsList := list.NewFilterableList( + items, list.WithFilterPlaceholder("Enter a session name"), - list.WithKeyMap(listKeyMap), - list.WithItems(items), - list.WithWrapNavigation(true), + list.WithFilterInputStyle(inputStyle), + list.WithFilterListOptions( + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(), + ), ) help := help.New() help.Styles = t.S().Help s := &sessionDialogCmp{ - selectedInx: selectedInx, selectedSessionID: selectedID, keyMap: DefaultKeyMap(), sessionsList: sessionsList, @@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD } func (s *sessionDialogCmp) Init() tea.Cmd { - return s.sessionsList.Init() + var cmds []tea.Cmd + cmds = append(cmds, s.sessionsList.Init()) + cmds = append(cmds, s.sessionsList.Focus()) + return tea.Sequence(cmds...) } func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + var cmds []tea.Cmd s.wWidth = msg.Width s.wHeight = msg.Height - s.width = s.wWidth / 2 - var cmds []tea.Cmd + s.width = min(120, s.wWidth-8) + s.sessionsList.SetInputWidth(s.listWidth() - 2) cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) - if !s.renderedSelected { - cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx)) - s.renderedSelected = true + if s.selectedSessionID != "" { + cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID)) } - return s, tea.Sequence(cmds...) + return s, tea.Batch(cmds...) case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Select): - if len(s.sessionsList.Items()) > 0 { - items := s.sessionsList.Items() - selectedItemInx := s.sessionsList.SelectedIndex() + selectedItem := s.sessionsList.SelectedItem() + if selectedItem != nil { + selected := *selectedItem return s, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler( - chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)), + chat.SessionSelectedMsg(selected.Value()), ), ) } @@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := s.sessionsList.Update(msg) - s.sessionsList = u.(list.ListModel) + s.sessionsList = u.(SessionsList) return s, cmd } } diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..4e2ac9a3e87766efc95a022db3d0adddb15a7544 --- /dev/null +++ b/internal/tui/exp/list/filterable.go @@ -0,0 +1,297 @@ +package list + +import ( + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +type FilterableItem interface { + Item + FilterValue() string +} + +type FilterableList[T FilterableItem] interface { + List[T] + Cursor() *tea.Cursor + SetInputWidth(int) +} + +type HasMatchIndexes interface { + MatchIndexes([]int) +} + +type filterableOptions struct { + listOptions []listOption + placeholder string + inputHidden bool + inputWidth int + inputStyle lipgloss.Style +} +type filterableList[T FilterableItem] struct { + *list[T] + filterableOptions + width, height int + // stores all available items + items []T + input textinput.Model + inputWidth int + query string +} + +type filterableListOption func(*filterableOptions) + +func WithFilterPlaceholder(ph string) filterableListOption { + return func(f *filterableOptions) { + f.placeholder = ph + } +} + +func WithFilterInputHidden() filterableListOption { + return func(f *filterableOptions) { + f.inputHidden = true + } +} + +func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { + return func(f *filterableOptions) { + f.inputStyle = inputStyle + } +} + +func WithFilterListOptions(opts ...listOption) filterableListOption { + return func(f *filterableOptions) { + f.listOptions = opts + } +} + +func WithFilterInputWidth(inputWidth int) filterableListOption { + return func(f *filterableOptions) { + f.inputWidth = inputWidth + } +} + +func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] { + t := styles.CurrentTheme() + + f := &filterableList[T]{ + filterableOptions: filterableOptions{ + inputStyle: t.S().Base, + placeholder: "Type to filter", + }, + } + for _, opt := range opts { + opt(&f.filterableOptions) + } + f.list = New[T](items, f.listOptions...).(*list[T]) + + f.updateKeyMaps() + f.items = f.list.items + + if f.inputHidden { + return f + } + + ti := textinput.New() + ti.Placeholder = f.placeholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + f.input = ti + return f +} + +func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // handle movements + case key.Matches(msg, f.keyMap.Down), + key.Matches(msg, f.keyMap.Up), + key.Matches(msg, f.keyMap.DownOneItem), + key.Matches(msg, f.keyMap.UpOneItem), + key.Matches(msg, f.keyMap.HalfPageDown), + key.Matches(msg, f.keyMap.HalfPageUp), + key.Matches(msg, f.keyMap.PageDown), + key.Matches(msg, f.keyMap.PageUp), + key.Matches(msg, f.keyMap.End), + key.Matches(msg, f.keyMap.Home): + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd + default: + if !f.inputHidden { + var cmds []tea.Cmd + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + cmds = append(cmds, cmd) + + if f.query != f.input.Value() { + cmd = f.Filter(f.input.Value()) + cmds = append(cmds, cmd) + } + f.query = f.input.Value() + return f, tea.Batch(cmds...) + } + } + } + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd +} + +func (f *filterableList[T]) View() string { + if f.inputHidden { + return f.list.View() + } + + return lipgloss.JoinVertical( + lipgloss.Left, + f.inputStyle.Render(f.input.View()), + f.list.View(), + ) +} + +// removes bindings that are used for search +func (f *filterableList[T]) updateKeyMaps() { + alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$") + + removeLettersAndNumbers := func(bindings []string) []string { + var keep []string + for _, b := range bindings { + if len(b) != 1 { + keep = append(keep, b) + continue + } + if b == " " { + continue + } + m := alphanumeric.MatchString(b) + if !m { + keep = append(keep, b) + } + } + return keep + } + + updateBinding := func(binding key.Binding) key.Binding { + newKeys := removeLettersAndNumbers(binding.Keys()) + if len(newKeys) == 0 { + binding.SetEnabled(false) + return binding + } + binding.SetKeys(newKeys...) + return binding + } + + f.keyMap.Down = updateBinding(f.keyMap.Down) + f.keyMap.Up = updateBinding(f.keyMap.Up) + f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) + f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) + f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) + f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) + f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) + f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) + f.keyMap.End = updateBinding(f.keyMap.End) + f.keyMap.Home = updateBinding(f.keyMap.Home) +} + +func (m *filterableList[T]) GetSize() (int, int) { + return m.width, m.height +} + +func (f *filterableList[T]) SetSize(w, h int) tea.Cmd { + f.width = w + f.height = h + if f.inputHidden { + return f.list.SetSize(w, h) + } + if f.inputWidth == 0 { + f.input.SetWidth(w) + } else { + f.input.SetWidth(f.inputWidth) + } + return f.list.SetSize(w, h-(f.inputHeight())) +} + +func (f *filterableList[T]) inputHeight() int { + return lipgloss.Height(f.inputStyle.Render(f.input.View())) +} + +func (f *filterableList[T]) Filter(query string) tea.Cmd { + var cmds []tea.Cmd + for _, item := range f.items { + if i, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + f.selectedItem = "" + if query == "" { + return f.list.SetItems(f.items) + } + + words := make([]string, len(f.items)) + for i, item := range f.items { + words[i] = strings.ToLower(item.FilterValue()) + } + + matches := fuzzy.Find(query, words) + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + var matchedItems []T + for _, match := range matches { + item := f.items[match.Index] + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + + cmds = append(cmds, f.list.SetItems(matchedItems)) + return tea.Batch(cmds...) +} + +func (f *filterableList[T]) SetItems(items []T) tea.Cmd { + f.items = items + return f.list.SetItems(items) +} + +func (f *filterableList[T]) Cursor() *tea.Cursor { + if f.inputHidden { + return nil + } + return f.input.Cursor() +} + +func (f *filterableList[T]) Blur() tea.Cmd { + f.input.Blur() + return f.list.Blur() +} + +func (f *filterableList[T]) Focus() tea.Cmd { + f.input.Focus() + return f.list.Focus() +} + +func (f *filterableList[T]) IsFocused() bool { + return f.list.IsFocused() +} + +func (f *filterableList[T]) SetInputWidth(w int) { + f.inputWidth = w +} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go new file mode 100644 index 0000000000000000000000000000000000000000..688058cbaa404d378210f815e276cef78254e296 --- /dev/null +++ b/internal/tui/exp/list/filterable_test.go @@ -0,0 +1,67 @@ +package list + +import ( + "fmt" + "slices" + "testing" + + "github.com/charmbracelet/x/exp/golden" + "github.com/stretchr/testify/assert" +) + +func TestFilterableList(t *testing.T) { + t.Run("should create simple filterable list", func(t *testing.T) { + t.Parallel() + items := []FilterableItem{} + for i := range 5 { + item := NewFilterableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := NewFilterableList( + items, + WithFilterListOptions(WithDirection(Forward)), + ).(*filterableList[FilterableItem]) + + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + assert.Equal(t, items[0].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +func TestUpdateKeyMap(t *testing.T) { + t.Parallel() + l := NewFilterableList( + []FilterableItem{}, + WithFilterListOptions(WithDirection(Forward)), + ).(*filterableList[FilterableItem]) + + hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") + fmt.Println(l.keyMap.Down.Keys()) + hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") + + hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") + + assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") + assert.False(t, hasJ, "should not contain j") + assert.False(t, hasUpperCaseK, "should also remove upper case K") + assert.True(t, hasCtrlJ, "should still have ctrl+j") +} + +type filterableItem struct { + *selectableItem +} + +func NewFilterableItem(content string) FilterableItem { + return &filterableItem{ + selectableItem: NewSelectableItem(content).(*selectableItem), + } +} + +func (f *filterableItem) FilterValue() string { + return f.content +} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go new file mode 100644 index 0000000000000000000000000000000000000000..005b72048a5962559e1bac202a17c8297757c746 --- /dev/null +++ b/internal/tui/exp/list/items.go @@ -0,0 +1,308 @@ +package list + +import ( + "image/color" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/google/uuid" + "github.com/rivo/uniseg" +) + +type CompletionItem[T any] interface { + FilterableItem + layout.Focusable + layout.Sizeable + HasMatchIndexes + Value() T +} + +type completionItemCmp[T any] struct { + width int + id string + text string + value T + focus bool + matchIndexes []int + bgColor color.Color + shortcut string +} + +type options struct { + id string + text string + bgColor color.Color + matchIndexes []int + shortcut string +} + +type completionOption func(*options) + +func WithBackgroundColor(c color.Color) completionOption { + return func(cmp *options) { + cmp.bgColor = c + } +} + +func WithMatchIndexes(indexes ...int) completionOption { + return func(cmp *options) { + cmp.matchIndexes = indexes + } +} + +func WithShortcut(shortcut string) completionOption { + return func(cmp *options) { + cmp.shortcut = shortcut + } +} + +func WithID(id string) completionOption { + return func(cmp *options) { + cmp.id = id + } +} + +func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] { + c := &completionItemCmp[T]{ + text: text, + value: value, + } + o := &options{} + + for _, opt := range opts { + opt(o) + } + if o.id == "" { + o.id = uuid.NewString() + } + c.id = o.id + c.bgColor = o.bgColor + c.matchIndexes = o.matchIndexes + c.shortcut = o.shortcut + return c +} + +// Init implements CommandItem. +func (c *completionItemCmp[T]) Init() tea.Cmd { + return nil +} + +// Update implements CommandItem. +func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View implements CommandItem. +func (c *completionItemCmp[T]) View() string { + t := styles.CurrentTheme() + + itemStyle := t.S().Base.Padding(0, 1).Width(c.width) + innerWidth := c.width - 2 // Account for padding + + if c.shortcut != "" { + innerWidth -= lipgloss.Width(c.shortcut) + } + + titleStyle := t.S().Text.Width(innerWidth) + titleMatchStyle := t.S().Text.Underline(true) + if c.bgColor != nil { + titleStyle = titleStyle.Background(c.bgColor) + titleMatchStyle = titleMatchStyle.Background(c.bgColor) + itemStyle = itemStyle.Background(c.bgColor) + } + + if c.focus { + titleStyle = t.S().TextSelected.Width(innerWidth) + titleMatchStyle = t.S().TextSelected.Underline(true) + itemStyle = itemStyle.Background(t.Primary) + } + + var truncatedTitle string + + if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { + // Smart truncation: ensure the last matching part is visible + truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) + } else { + // No matches, use regular truncation + truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") + } + + text := titleStyle.Render(truncatedTitle) + if len(c.matchIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(c.matchIndexes) { + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) + ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) + } + text = lipgloss.StyleRanges(text, ranges...) + } + parts := []string{text} + if c.shortcut != "" { + // Add the shortcut at the end + shortcutStyle := t.S().Muted + if c.focus { + shortcutStyle = t.S().TextSelected + } + parts = append(parts, shortcutStyle.Render(c.shortcut)) + } + item := itemStyle.Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + parts..., + ), + ) + return item +} + +// Blur implements CommandItem. +func (c *completionItemCmp[T]) Blur() tea.Cmd { + c.focus = false + return nil +} + +// Focus implements CommandItem. +func (c *completionItemCmp[T]) Focus() tea.Cmd { + c.focus = true + return nil +} + +// GetSize implements CommandItem. +func (c *completionItemCmp[T]) GetSize() (int, int) { + return c.width, 1 +} + +// IsFocused implements CommandItem. +func (c *completionItemCmp[T]) IsFocused() bool { + return c.focus +} + +// SetSize implements CommandItem. +func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd { + c.width = width + return nil +} + +func (c *completionItemCmp[T]) MatchIndexes(indexes []int) { + c.matchIndexes = indexes +} + +func (c *completionItemCmp[T]) FilterValue() string { + return c.text +} + +func (c *completionItemCmp[T]) Value() T { + return c.value +} + +// smartTruncate implements fzf-style truncation that ensures the last matching part is visible +func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string { + if width <= 0 { + return "" + } + + textLen := ansi.StringWidth(text) + if textLen <= width { + return text + } + + if len(matchIndexes) == 0 { + return ansi.Truncate(text, width, "…") + } + + // Find the last match position + lastMatchPos := matchIndexes[len(matchIndexes)-1] + + // Convert byte position to visual width position + lastMatchVisualPos := 0 + bytePos := 0 + gr := uniseg.NewGraphemes(text) + for bytePos < lastMatchPos && gr.Next() { + bytePos += len(gr.Str()) + lastMatchVisualPos += max(1, gr.Width()) + } + + // Calculate how much space we need for the ellipsis + ellipsisWidth := 1 // "…" character width + availableWidth := width - ellipsisWidth + + // If the last match is within the available width, truncate from the end + if lastMatchVisualPos < availableWidth { + return ansi.Truncate(text, width, "…") + } + + // Calculate the start position to ensure the last match is visible + // We want to show some context before the last match if possible + startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) + + // Convert visual position back to byte position + startBytePos := 0 + currentVisualPos := 0 + gr = uniseg.NewGraphemes(text) + for currentVisualPos < startVisualPos && gr.Next() { + startBytePos += len(gr.Str()) + currentVisualPos += max(1, gr.Width()) + } + + // Extract the substring starting from startBytePos + truncatedText := text[startBytePos:] + + // Truncate to fit width with ellipsis + truncatedText = ansi.Truncate(truncatedText, availableWidth, "") + truncatedText = "…" + truncatedText + return truncatedText +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} + +// ID implements CompletionItem. +func (c *completionItemCmp[T]) ID() string { + return c.id +} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..271ad1a8e644f2ecd44d5d76e8af6a9b513abab3 --- /dev/null +++ b/internal/tui/exp/list/keys.go @@ -0,0 +1,63 @@ +package list + +import ( + "github.com/charmbracelet/bubbles/v2/key" +) + +type KeyMap struct { + Down, + Up, + DownOneItem, + UpOneItem, + PageDown, + PageUp, + HalfPageDown, + HalfPageUp, + Home, + End key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), + key.WithHelp("↓", "down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), + key.WithHelp("↑", "up"), + ), + UpOneItem: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "up one item"), + ), + DownOneItem: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "down one item"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + } +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 92c9a38c2a2fbd4c6338a8873e73450bf64c8d70..94a9e13e0904c9df0a1477b5674738b33425fc81 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,9 +1,9 @@ package list import ( - "fmt" "strings" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/util" @@ -16,11 +16,19 @@ type Item interface { ID() string } -type List interface { +type List[T Item] interface { util.Model layout.Sizeable layout.Focusable - SetItems(items []Item) tea.Cmd + 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 } type direction int @@ -31,41 +39,45 @@ const ( ) const ( - NotFound = -1 + NotFound = -1 + DefaultScrollSize = 2 ) +type setSelectedMsg struct { + selectedItemID string +} + type renderedItem struct { id string view string height int } -type list struct { +type confOptions struct { width, height int - offset int gap int - direction direction - selectedItem string - focused bool + // if you are at the last item and go down it will wrap to the top + wrap bool + keyMap KeyMap + direction direction + selectedItem string +} +type list[T Item] struct { + confOptions - items []Item + focused bool + offset int + items []T renderedItems []renderedItem rendered string isReady bool } -type listOption func(*list) - -// WithItems sets the initial items for the list. -func WithItems(items ...Item) listOption { - return func(l *list) { - l.items = items - } -} +type listOption func(*confOptions) // WithSize sets the size of the list. func WithSize(width, height int) listOption { - return func(l *list) { + return func(l *confOptions) { l.width = width l.height = height } @@ -73,44 +85,53 @@ func WithSize(width, height int) listOption { // WithGap sets the gap between items in the list. func WithGap(gap int) listOption { - return func(l *list) { + return func(l *confOptions) { l.gap = gap } } // WithDirection sets the direction of the list. func WithDirection(dir direction) listOption { - return func(l *list) { + return func(l *confOptions) { l.direction = dir } } // WithSelectedItem sets the initially selected item in the list. func WithSelectedItem(id string) listOption { - return func(l *list) { + return func(l *confOptions) { l.selectedItem = id } } -func New(opts ...listOption) List { - list := &list{ - items: make([]Item, 0), - direction: Forward, +func WithKeyMap(keyMap KeyMap) listOption { + return func(l *confOptions) { + l.keyMap = keyMap + } +} + +func WithWrapNavigation() listOption { + return func(l *confOptions) { + l.wrap = true + } +} + +func New[T Item](items []T, opts ...listOption) List[T] { + list := &list[T]{ + confOptions: confOptions{ + direction: Forward, + keyMap: DefaultKeyMap(), + }, + items: items, } for _, opt := range opts { - opt(list) + opt(&list.confOptions) } return list } // Init implements List. -func (l *list) Init() tea.Cmd { - if l.height <= 0 || l.width <= 0 { - return nil - } - if len(l.items) == 0 { - return nil - } +func (l *list[T]) Init() tea.Cmd { var cmds []tea.Cmd for _, item := range l.items { cmd := item.Init() @@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd { } // Update implements List. -func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) { +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) + case key.Matches(msg, l.keyMap.Up): + return l, l.MoveUp(DefaultScrollSize) + 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) + case key.Matches(msg, l.keyMap.HalfPageUp): + return l, l.MoveUp(l.listHeight() / 2) + case key.Matches(msg, l.keyMap.PageDown): + return l, l.MoveDown(l.listHeight()) + case key.Matches(msg, l.keyMap.PageUp): + return l, l.MoveUp(l.listHeight()) + case key.Matches(msg, l.keyMap.End): + return l, l.GoToBottom() + case key.Matches(msg, l.keyMap.Home): + return l, l.GoToTop() + } + } + } return l, nil } // View implements List. -func (l *list) View() string { +func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { return "" } @@ -138,7 +188,7 @@ func (l *list) View() string { return strings.Join(lines, "\n") } -func (l *list) viewPosition() (int, int) { +func (l *list[T]) viewPosition() (int, int) { start, end := 0, 0 renderedLines := lipgloss.Height(l.rendered) - 1 if l.direction == Forward { @@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) { return start, end } -func (l *list) renderItem(item Item) renderedItem { +func (l *list[T]) renderItem(item Item) renderedItem { view := item.View() return renderedItem{ id: item.ID(), @@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem { } } -func (l *list) renderView() { +func (l *list[T]) renderView() { var sb strings.Builder for i, rendered := range l.renderedItems { sb.WriteString(rendered.view) @@ -171,7 +221,7 @@ func (l *list) renderView() { l.rendered = sb.String() } -func (l *list) incrementOffset(n int) { +func (l *list[T]) incrementOffset(n int) { if !l.isReady { return } @@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) { l.offset += n } -func (l *list) decrementOffset(n int) { +func (l *list[T]) decrementOffset(n int) { if !l.isReady { return } @@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) { } // changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible -func (l *list) changeSelectedWhenNotVisible() tea.Cmd { +func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd { var cmds []tea.Cmd start, end := l.viewPosition() currentPosition := 0 @@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { needsMove = true } if needsMove { - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, focusable.Blur()) } l.renderedItems[i] = l.renderItem(item) @@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { if itemWithinView != NotFound && needsMove { newSelection := l.items[itemWithinView] l.selectedItem = newSelection.ID() - if focusable, ok := newSelection.(layout.Focusable); ok { + if focusable, ok := any(newSelection).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } l.renderedItems[itemWithinView] = l.renderItem(newSelection) @@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { return tea.Batch(cmds...) } -func (l *list) MoveUp(n int) tea.Cmd { +func (l *list[T]) MoveUp(n int) tea.Cmd { if l.direction == Forward { l.decrementOffset(n) } else { @@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd { return l.changeSelectedWhenNotVisible() } -func (l *list) MoveDown(n int) tea.Cmd { +func (l *list[T]) MoveDown(n int) tea.Cmd { if l.direction == Forward { l.incrementOffset(n) } else { @@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd { return l.changeSelectedWhenNotVisible() } -func (l *list) firstSelectableItemBefore(inx int) int { +func (l *list[T]) firstSelectableItemBefore(inx int) int { for i := inx - 1; i >= 0; i-- { - if _, ok := l.items[i].(layout.Focusable); ok { + if _, ok := any(l.items[i]).(layout.Focusable); ok { return i } } + if inx == 0 && l.wrap { + return l.firstSelectableItemBefore(len(l.items)) + } return NotFound } -func (l *list) firstSelectableItemAfter(inx int) int { +func (l *list[T]) firstSelectableItemAfter(inx int) int { for i := inx + 1; i < len(l.items); i++ { - if _, ok := l.items[i].(layout.Focusable); ok { + if _, ok := any(l.items[i]).(layout.Focusable); ok { return i } } + if inx == len(l.items)-1 && l.wrap { + return l.firstSelectableItemAfter(-1) + } return NotFound } -func (l *list) moveToSelected() { +func (l *list[T]) moveToSelected(center bool) tea.Cmd { + var cmds []tea.Cmd if l.selectedItem == "" || !l.isReady { - return + return nil } currentPosition := 0 start, end := l.viewPosition() for _, item := range l.renderedItems { if item.id == l.selectedItem { - if start <= currentPosition && (currentPosition+item.height) <= end { - return - } - // we need to go up - if currentPosition < start { - l.MoveUp(start - currentPosition) + itemStart := currentPosition + itemEnd := currentPosition + item.height - 1 + + if start <= itemStart && itemEnd <= end { + return nil } - // we need to go down - if currentPosition > end { - l.MoveDown(currentPosition - end) + + 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)) + } + if currentPosition > end { + cmds = append(cmds, l.MoveDown(currentPosition-end)) + } } } currentPosition += item.height + l.gap } + return tea.Batch(cmds...) } -func (l *list) SelectItemAbove() tea.Cmd { +func (l *list[T]) SelectItemAbove() tea.Cmd { if !l.isReady { return nil } @@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd { return nil } // blur the current item - if focusable, ok := item.(layout.Focusable); ok { + 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 := above.(layout.Focusable); ok { + if focusable, ok := any(above).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } // rerender the item @@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd { break } } + l.moveToSelected(false) l.renderView() - l.moveToSelected() return tea.Batch(cmds...) } -func (l *list) SelectItemBelow() tea.Cmd { +func (l *list[T]) SelectItemBelow() tea.Cmd { if !l.isReady { return nil } @@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd { return nil } // blur the current item - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, focusable.Blur()) } // rerender the item @@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd { // focus the item below below := l.items[inx] - if focusable, ok := below.(layout.Focusable); ok { + if focusable, ok := any(below).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } // rerender the item @@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd { } } + l.moveToSelected(false) l.renderView() - l.moveToSelected() return tea.Batch(cmds...) } -func (l *list) GoToTop() tea.Cmd { +func (l *list[T]) GoToTop() tea.Cmd { if !l.isReady { return nil } @@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd { return tea.Batch(l.selectFirstItem(), l.renderForward()) } -func (l *list) GoToBottom() tea.Cmd { +func (l *list[T]) GoToBottom() tea.Cmd { if !l.isReady { return nil } @@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd { return tea.Batch(l.selectLastItem(), l.renderBackward()) } -func (l *list) renderForward() tea.Cmd { +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 @@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd { } } -func (l *list) renderBackward() tea.Cmd { +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-- { - fmt.Printf("rendering item %d\n", i) currentIndex = i if currentHeight > l.listHeight() { break @@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd { } return func() tea.Msg { for i := currentIndex; i >= 0; i-- { - fmt.Printf("rendering item after %d\n", i) rendered := l.renderItem(l.items[i]) l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...) } @@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd { } } -func (l *list) selectFirstItem() tea.Cmd { +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 := l.items[inx].(layout.Focusable); ok { + if focusable, ok := any(l.items[inx]).(layout.Focusable); ok { cmd = focusable.Focus() } } return cmd } -func (l *list) selectLastItem() tea.Cmd { +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 := l.items[inx].(layout.Focusable); ok { + if focusable, ok := any(l.items[inx]).(layout.Focusable); ok { cmd = focusable.Focus() } } return cmd } -func (l *list) renderItems() tea.Cmd { +func (l *list[T]) renderItems() tea.Cmd { if l.height <= 0 || l.width <= 0 { return nil } @@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd { return l.renderBackward() } -func (l *list) listHeight() int { +func (l *list[T]) listHeight() int { // for the moment its the same return l.height } -func (l *list) SetItems(items []Item) tea.Cmd { +func (l *list[T]) SetItems(items []T) tea.Cmd { l.items = items var cmds []tea.Cmd for _, item := range l.items { @@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd { // Set height to 0 to let the item calculate its own height cmds = append(cmds, item.SetSize(l.width, 0)) } + + if l.selectedItem != "" { + cmds = append(cmds, l.moveToSelected(true)) + } cmds = append(cmds, l.renderItems()) return tea.Batch(cmds...) } // GetSize implements List. -func (l *list) GetSize() (int, int) { +func (l *list[T]) GetSize() (int, int) { return l.width, l.height } // SetSize implements List. -func (l *list) SetSize(width int, height int) tea.Cmd { +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)) } + cmds = append(cmds, l.renderItems()) return tea.Batch(cmds...) } // Blur implements List. -func (l *list) Blur() tea.Cmd { +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 := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmd = focusable.Blur() } l.renderedItems[i] = l.renderItem(item) @@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd { } // Focus implements List. -func (l *list) Focus() tea.Cmd { +func (l *list[T]) Focus() tea.Cmd { var cmd tea.Cmd l.focused = true - for i, item := range l.items { - if item.ID() != l.selectedItem { - continue + 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) + } } - if focusable, ok := item.(layout.Focusable); ok { - cmd = focusable.Focus() + l.renderView() + } + return cmd +} + +func (l *list[T]) SetSelected(id string) tea.Cmd { + if l.selectedItem == id { + 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) + } } - l.renderedItems[i] = l.renderItem(item) } + l.selectedItem = id + cmds = append(cmds, l.moveToSelected(true)) l.renderView() - return cmd + return tea.Batch(cmds...) +} + +func (l *list[T]) SelectedItem() *T { + for _, item := range l.items { + if item.ID() == l.selectedItem { + return &item + } + } + return nil } // IsFocused implements List. -func (l *list) IsFocused() bool { +func (l *list[T]) IsFocused() bool { return l.focused } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index a9d1541dee784d2ce1b652773d84138bedcb8241..3dd2e94666df982f186474e5a96da5d721e71c2e 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "sync" "testing" tea "github.com/charmbracelet/bubbletea/v2" @@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) { } for _, c := range tests { t.Run(c.test, func(t *testing.T) { - l := New(WithDirection(c.dir)).(*list) - l.SetSize(c.width, c.height) items := []Item{} for i := range c.numItems { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(c.dir)).(*list[Item]) + l.SetSize(c.width, c.height) + cmd := l.Init() if cmd != nil { cmd() } @@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) { 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item]) + l.SetSize(10, 20) + cmd := l.Init() 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) + l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item]) l.SetSize(10, 20) - cmd := l.SetItems(items) + cmd := l.Init() if cmd != nil { cmd() } @@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should move at max to the top", func(t *testing.T) { - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -268,14 +268,14 @@ func TestBackwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) { 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item]) + l.SetSize(10, 20) + cmd := l.Init() if cmd != nil { cmd() } @@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) { t.Parallel() items := []Item{} for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) + l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item]) l.SetSize(10, 20) - cmd := l.SetItems(items) + cmd := l.Init() if cmd != nil { cmd() } @@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should move at max 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -459,14 +459,14 @@ func TestForwardList(t *testing.T) { }) 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 := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) { func TestListSelection(t *testing.T) { t.Run("should skip none selectable items initially", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(100, 10) items := []Item{} items = append(items, NewSimpleItem("None Selectable")) for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(100, 10) + cmd := l.Init() if cmd != nil { cmd() } @@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) { assert.Equal(t, items[1].ID(), l.selectedItem) golden.RequireEqual(t, []byte(l.View())) }) - t.Run("should skip none selectable items in the middle", func(t *testing.T) { + t.Run("should select the correct item on startup", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) + 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 := NewSelectsableItem("Item initial") + item := NewSelectableItem("Item initial") items = append(items, item) items = append(items, NewSimpleItem("None Selectable")) for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(100, 10) + cmd := l.Init() if cmd != nil { cmd() } @@ -522,6 +552,31 @@ func TestListSelection(t *testing.T) { }) } +func TestListSetSelection(t *testing.T) { + t.Run("should move to the selected item", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 100 { + 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() + } + + cmd = l.SetSelected(items[52].ID()) + if cmd != nil { + cmd() + } + + assert.Equal(t, items[52].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + type SelectableItem interface { Item layout.Focusable @@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem { } } -func NewSelectsableItem(content string) SelectableItem { +func NewSelectableItem(content string) SelectableItem { return &selectableItem{ simpleItem: NewSimpleItem(content), focused: false, diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..8aac1155586865e3db5a87839b9d430b419d00ec --- /dev/null +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -0,0 +1,6 @@ +> Type to filter  +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden index b8fd0efdb00bce286317007e40b2af335d22942f..9b99c5dff003cfe111724b6a8fbb146d81b2f0e3 100644 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden @@ -1,5 +1,5 @@ -Item 5 -Item 6 -Item 7 -Item 8 -│Item 9 \ No newline at end of file +Item 1 +Item 2 +Item 3 +Item 4 +│Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden b/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden new file mode 100644 index 0000000000000000000000000000000000000000..83638680c8cc7538d2843dabf9dd874782e09669 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden @@ -0,0 +1,5 @@ +Item 0 +Item 1 +Item 2 +│Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden b/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden new file mode 100644 index 0000000000000000000000000000000000000000..bd6e2219113ebea6cbd53d775866d0e2401fbc41 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden @@ -0,0 +1,10 @@ +Item 47 +Item 48 +Item 49 +Item 50 +Item 51 +│Item 52 +Item 53 +Item 54 +Item 55 +Item 56 \ No newline at end of file From a80d6c609feaceb07a9e113e26c8827d8f7c532f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Jul 2025 18:48:01 +0200 Subject: [PATCH 05/18] wip: add to messages list --- internal/tui/components/chat/chat.go | 44 +++++----- .../tui/components/chat/messages/messages.go | 18 +++- internal/tui/components/chat/messages/tool.go | 5 ++ internal/tui/exp/list/filterable.go | 6 +- internal/tui/exp/list/filterable_test.go | 1 + internal/tui/exp/list/keys.go | 13 +++ internal/tui/exp/list/list.go | 84 +++++++++++++++++-- internal/tui/exp/list/list_test.go | 55 ++++++++++++ .../should_append_an_item_to_the_end.golden | 10 +++ ...the_selected_if_we_moved_the_offset.golden | 10 +++ 10 files changed, 212 insertions(+), 34 deletions(-) create mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden create mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 8d857ea38463e9d61dc25794e492b33cab0b487b..044241e295afb34a27b462d721130a3ed638ba00 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -13,7 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat/messages" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -49,8 +49,8 @@ type messageListCmp struct { app *app.App width, height int session session.Session - listCmp list.ListModel - previousSelected int // Last selected item index for restoring focus + listCmp list.List[list.Item] + previousSelected string // Last selected item index for restoring focus lastUserMessageTime int64 defaultListKeyMap list.KeyMap @@ -61,14 +61,15 @@ type messageListCmp struct { func New(app *app.App) MessageListCmp { defaultListKeyMap := list.DefaultKeyMap() listCmp := list.New( - list.WithGapSize(1), - list.WithReverse(true), + []list.Item{}, + list.WithGap(1), + list.WithDirection(list.Backward), list.WithKeyMap(defaultListKeyMap), ) return &messageListCmp{ app: app, listCmp: listCmp, - previousSelected: list.NoSelection, + previousSelected: "", defaultListKeyMap: defaultListKeyMap, } } @@ -89,7 +90,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case SessionClearedMsg: m.session = session.Session{} - return m, m.listCmp.SetItems([]util.Model{}) + return m, m.listCmp.SetItems([]list.Item{}) case pubsub.Event[message.Message]: cmd := m.handleMessageEvent(msg) @@ -97,7 +98,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: var cmds []tea.Cmd u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.ListModel) + m.listCmp = u.(list.List[list.Item]) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } @@ -169,7 +170,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) toolCall.SetNestedToolCalls(nestedToolCalls) m.listCmp.UpdateItem( - toolCallInx, + toolCall.ID(), toolCall, ) return tea.Batch(cmds...) @@ -233,7 +234,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound { toolCall := items[toolCallIndex].(messages.ToolCallCmp) toolCall.SetToolResult(tr) - m.listCmp.UpdateItem(toolCallIndex, toolCall) + m.listCmp.UpdateItem(toolCall.ID(), toolCall) } } return nil @@ -241,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd { // findToolCallByID searches for a tool call with the specified ID. // Returns the index if found, NotFound otherwise. -func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int { +func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int { // Search backwards as tool calls are more likely to be recent for i := len(items) - 1; i >= 0; i-- { if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID { @@ -274,7 +275,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C } // findAssistantMessageAndToolCalls locates the assistant message and its tool calls. -func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) { +func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) { assistantIndex := NotFound toolCalls := make(map[int]messages.ToolCallCmp) @@ -310,7 +311,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi uiMsg := items[assistantIndex].(messages.MessageCmp) uiMsg.SetMessage(msg) m.listCmp.UpdateItem( - assistantIndex, + items[assistantIndex].ID(), uiMsg, ) if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn { @@ -322,7 +323,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi ) } } else if hasToolCallsOnly { - m.listCmp.DeleteItem(assistantIndex) + items := m.listCmp.Items() + m.listCmp.DeleteItem(items[assistantIndex].ID()) } return cmd @@ -349,13 +351,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls // updateOrAddToolCall updates an existing tool call or adds a new one. func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd { // Try to find existing tool call - for index, existingTC := range existingToolCalls { + for _, existingTC := range existingToolCalls { if tc.ID == existingTC.GetToolCall().ID { existingTC.SetToolCall(tc) if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled { existingTC.SetCancelled() } - m.listCmp.UpdateItem(index, existingTC) + m.listCmp.UpdateItem(tc.ID, existingTC) return nil } } @@ -400,7 +402,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd { } if len(sessionMessages) == 0 { - return m.listCmp.SetItems([]util.Model{}) + return m.listCmp.SetItems([]list.Item{}) } // Initialize with first message timestamp @@ -427,8 +429,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri } // convertMessagesToUI converts database messages to UI components. -func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model { - uiMessages := make([]util.Model, 0) +func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item { + uiMessages := make([]list.Item, 0) for _, msg := range sessionMessages { switch msg.Role { @@ -447,8 +449,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, } // convertAssistantMessage converts an assistant message and its tool calls to UI components. -func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model { - var uiMessages []util.Model +func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item { + var uiMessages []list.Item // Add assistant message if it should be displayed if m.shouldShowAssistantMessage(msg) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index d5aca88108cad83115cad5bd046c72e146935f78..9f70691aa9843b8d823b26be247636b31212d2eb 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -11,13 +11,14 @@ import ( "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/google/uuid" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -31,6 +32,7 @@ type MessageCmp interface { GetMessage() message.Message // Access to underlying message data SetMessage(msg message.Message) // Update the message content Spinning() bool // Animation state for loading messages + ID() string } // messageCmp implements the MessageCmp interface for displaying chat messages. @@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool { } type AssistantSection interface { - util.Model + list.Item layout.Sizeable - list.SectionHeader } type assistantSectionModel struct { width int + id string message message.Message lastUserMessageTime time.Time } +// ID implements AssistantSection. +func (m *assistantSectionModel) ID() string { + return m.id +} + func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection { return &assistantSectionModel{ width: 0, + id: uuid.NewString(), message: message, lastUserMessageTime: lastUserMessageTime, } @@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd { func (m *assistantSectionModel) IsSectionHeader() bool { return true } + +func (m *messageCmp) ID() string { + return m.message.ID +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 90ced40eeb54c0509dae9e74775462a179e0ad28..2f639c5c5d192ba9c59402976e552462d8ebcd0b 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -29,6 +29,7 @@ type ToolCallCmp interface { GetNestedToolCalls() []ToolCallCmp // Get nested tool calls SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls SetIsNested(bool) // Set whether this tool call is nested + ID() string } // toolCallCmp implements the ToolCallCmp interface for displaying tool calls. @@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool { } return m.spinning } + +func (m *toolCallCmp) ID() string { + return m.call.ID +} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 4e2ac9a3e87766efc95a022db3d0adddb15a7544..cc2d0e1264621b10efd6df03916f5ccd3e70987e 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -38,7 +38,7 @@ type filterableOptions struct { } type filterableList[T FilterableItem] struct { *list[T] - filterableOptions + *filterableOptions width, height int // stores all available items items []T @@ -83,13 +83,13 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption t := styles.CurrentTheme() f := &filterableList[T]{ - filterableOptions: filterableOptions{ + filterableOptions: &filterableOptions{ inputStyle: t.S().Base, placeholder: "Type to filter", }, } for _, opt := range opts { - opt(&f.filterableOptions) + opt(f.filterableOptions) } f.list = New[T](items, f.listOptions...).(*list[T]) diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 688058cbaa404d378210f815e276cef78254e296..cb88c70fe2e2f86fc3bd648f20f2591f5eb6581d 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -10,6 +10,7 @@ import ( ) func TestFilterableList(t *testing.T) { + t.Parallel() t.Run("should create simple filterable list", func(t *testing.T) { t.Parallel() items := []FilterableItem{} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go index 271ad1a8e644f2ecd44d5d76e8af6a9b513abab3..ba0f6cec97ed1d0cdc91ff70f69a8f2e1cd386d7 100644 --- a/internal/tui/exp/list/keys.go +++ b/internal/tui/exp/list/keys.go @@ -61,3 +61,16 @@ func DefaultKeyMap() KeyMap { ), } } + +func (k KeyMap) KeyBindings() []key.Binding { + return []key.Binding{ + k.Down, + k.Up, + k.DownOneItem, + k.UpOneItem, + k.HalfPageDown, + k.HalfPageUp, + k.Home, + k.End, + } +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 94a9e13e0904c9df0a1477b5674738b33425fc81..98082d3c0dd4f9ff99212cd3700685810c540ace 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,6 +1,7 @@ package list import ( + "slices" "strings" "github.com/charmbracelet/bubbles/v2/key" @@ -29,6 +30,11 @@ type List[T Item] interface { 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 direction int @@ -63,7 +69,7 @@ type confOptions struct { selectedItem string } type list[T Item] struct { - confOptions + *confOptions focused bool offset int @@ -118,14 +124,14 @@ func WithWrapNavigation() listOption { func New[T Item](items []T, opts ...listOption) List[T] { list := &list[T]{ - confOptions: confOptions{ + confOptions: &confOptions{ direction: Forward, keyMap: DefaultKeyMap(), }, items: items, } for _, opt := range opts { - opt(&list.confOptions) + opt(list.confOptions) } return list } @@ -343,6 +349,7 @@ func (l *list[T]) firstSelectableItemAfter(inx int) int { return NotFound } +// 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 { @@ -421,8 +428,8 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { break } } - l.moveToSelected(false) l.renderView() + l.moveToSelected(false) return tea.Batch(cmds...) } @@ -457,8 +464,8 @@ func (l *list[T]) SelectItemBelow() tea.Cmd { } } - l.moveToSelected(false) l.renderView() + l.moveToSelected(false) return tea.Batch(cmds...) } @@ -605,10 +612,10 @@ func (l *list[T]) SetItems(items []T) tea.Cmd { cmds = append(cmds, item.SetSize(l.width, 0)) } + cmds = append(cmds, l.renderItems()) if l.selectedItem != "" { cmds = append(cmds, l.moveToSelected(true)) } - cmds = append(cmds, l.renderItems()) return tea.Batch(cmds...) } @@ -691,8 +698,8 @@ func (l *list[T]) SetSelected(id string) tea.Cmd { } } l.selectedItem = id - cmds = append(cmds, l.moveToSelected(true)) l.renderView() + cmds = append(cmds, l.moveToSelected(true)) return tea.Batch(cmds...) } @@ -709,3 +716,66 @@ func (l *list[T]) SelectedItem() *T { func (l *list[T]) IsFocused() bool { return l.focused } + +func (l *list[T]) Items() []T { + return l.items +} + +func (l *list[T]) UpdateItem(id string, item T) { + // TODO: preserve offset + for inx, item := range l.items { + if item.ID() == id { + l.items[inx] = item + l.renderedItems[inx] = l.renderItem(item) + l.renderView() + return + } + } +} + +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()) + } + l.renderView() + return cmd +} + +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 +} diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 3dd2e94666df982f186474e5a96da5d721e71c2e..6b5c92acd9d302e4bdd63b92cfff4cbb869f6ab4 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -14,6 +14,7 @@ import ( ) func TestListPosition(t *testing.T) { + t.Parallel() type positionOffsetTest struct { dir direction test string @@ -75,6 +76,7 @@ func TestListPosition(t *testing.T) { } 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)) @@ -101,6 +103,7 @@ func TestListPosition(t *testing.T) { } func TestBackwardList(t *testing.T) { + t.Parallel() t.Run("within height", func(t *testing.T) { t.Parallel() items := []Item{} @@ -291,6 +294,7 @@ func TestBackwardList(t *testing.T) { } func TestForwardList(t *testing.T) { + t.Parallel() t.Run("within height", func(t *testing.T) { t.Parallel() items := []Item{} @@ -482,6 +486,7 @@ func TestForwardList(t *testing.T) { } func TestListSelection(t *testing.T) { + t.Parallel() t.Run("should skip none selectable items initially", func(t *testing.T) { t.Parallel() items := []Item{} @@ -553,6 +558,7 @@ func TestListSelection(t *testing.T) { } func TestListSetSelection(t *testing.T) { + t.Parallel() t.Run("should move to the selected item", func(t *testing.T) { t.Parallel() items := []Item{} @@ -577,6 +583,55 @@ func TestListSetSelection(t *testing.T) { }) } +func TestListChanges(t *testing.T) { + t.Parallel() + t.Run("should append an item to the end", func(t *testing.T) { + t.Parallel() + items := []SelectableItem{} + for i := range 20 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirection(Backward)).(*list[SelectableItem]) + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + newItem := NewSelectableItem("New Item") + l.AppendItem(newItem) + + assert.Equal(t, 21, len(l.items)) + assert.Equal(t, 21, len(l.renderedItems)) + assert.Equal(t, newItem.ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should should not change the selected if we moved the offset", func(t *testing.T) { + t.Parallel() + items := []SelectableItem{} + for i := range 20 { + item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i)) + 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) + + newItem := NewSelectableItem("New Item") + l.AppendItem(newItem) + + assert.Equal(t, 21, len(l.items)) + assert.Equal(t, 21, len(l.renderedItems)) + assert.Equal(t, l.items[19].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + type SelectableItem interface { Item layout.Focusable diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden b/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden new file mode 100644 index 0000000000000000000000000000000000000000..fe55231e951955234b57f1c341d2ceecf3101bf0 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden @@ -0,0 +1,10 @@ +Item 11 +Item 12 +Item 13 +Item 14 +Item 15 +Item 16 +Item 17 +Item 18 +Item 19 +│New Item \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden b/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden new file mode 100644 index 0000000000000000000000000000000000000000..5e8610df6e3c2247e7879fb2ba3fa09694ba9d25 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden @@ -0,0 +1,10 @@ +Item 15 +Line2 +Item 16 +Line2 +Item 17 +Line2 +Item 18 +Line2 +│Item 19 +│Line2 \ No newline at end of file From 74f0b2097097f8b8e256f15e8bab159f29b765fb Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 23 Jul 2025 13:00:44 +0200 Subject: [PATCH 06/18] wip: initial rework --- internal/tui/components/chat/chat.go | 2 +- internal/tui/exp/list/filterable_test.go | 124 ++- internal/tui/exp/list/list.go | 932 ++++++++---------- internal/tui/exp/list/list_test.go | 706 ++++--------- .../TestBackwardList/more_than_height.golden | 5 - .../more_than_height_multi_line.golden | 5 - ...d_do_nothing_with_wrong_move_number.golden | 5 - .../should_move_at_max_to_the_top.golden | 5 - ...to_be_able_to_see_the_selected_item.golden | 5 - .../should_move_to_the_top.golden | 5 - .../TestBackwardList/should_move_up.golden | 5 - .../should_select_the_item_above.golden | 5 - .../TestBackwardList/within_height.golden | 9 - ...hould_create_simple_filterable_list.golden | 6 - .../TestForwardList/more_than_height.golden | 5 - .../more_than_height_multi_line.golden | 5 - ...d_do_nothing_with_wrong_move_number.golden | 5 - .../should_move_at_max_to_the_bottom.golden | 5 - .../TestForwardList/should_move_down.golden | 5 - ...to_be_able_to_see_the_selected_item.golden | 5 - .../should_move_to_the_bottom.golden | 5 - .../should_select_the_item_below.golden | 5 - .../TestForwardList/within_height.golden | 9 - ...n_list_that_does_not_fits_the_items.golden | 10 + ..._the_items_and_has_multi_line_items.golden | 10 + ..._and_has_multi_line_items_backwards.golden | 10 + ...t_does_not_fits_the_items_backwards.golden | 10 + ...sitions_in_list_that_fits_the_items.golden | 5 + ..._list_that_fits_the_items_backwards.golden | 5 + ...ould_go_to_selected_item_and_center.golden | 10 + ..._selected_item_and_center_backwards.golden | 10 + ...o_to_selected_item_at_the_beginning.golden | 10 + ...ted_item_at_the_beginning_backwards.golden | 10 + ...n_list_that_does_not_fits_the_items.golden | 10 + ..._the_items_and_has_multi_line_items.golden | 10 + ..._and_has_multi_line_items_backwards.golden | 10 + ...t_does_not_fits_the_items_backwards.golden | 10 + ...sitions_in_list_that_fits_the_items.golden | 5 + ..._list_that_fits_the_items_backwards.golden | 5 + .../should_append_an_item_to_the_end.golden | 10 - ...the_selected_if_we_moved_the_offset.golden | 10 - .../should_move_viewport_down.golden | 10 + .../should_move_viewport_down_and_up.golden | 10 + .../should_move_viewport_up.golden | 10 + .../should_move_viewport_up_and_down.golden | 10 + ..._select_the_correct_item_on_startup.golden | 5 - ...none_selectable_items_in_the_middle.golden | 7 - ...kip_none_selectable_items_initially.golden | 6 - .../should_move_to_the_selected_item.golden | 10 - 49 files changed, 892 insertions(+), 1204 deletions(-) delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden delete mode 100644 internal/tui/exp/list/testdata/TestBackwardList/within_height.golden delete mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden delete mode 100644 internal/tui/exp/list/testdata/TestForwardList/within_height.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden create mode 100644 internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden delete mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden delete mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden delete mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden delete mode 100644 internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden delete mode 100644 internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 044241e295afb34a27b462d721130a3ed638ba00..1629bc4c5638e24274c0a658f0f8c5de5bb71b59 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -63,7 +63,7 @@ func New(app *app.App) MessageListCmp { listCmp := list.New( []list.Item{}, list.WithGap(1), - list.WithDirection(list.Backward), + list.WithDirectionBackward(), list.WithKeyMap(defaultListKeyMap), ) return &messageListCmp{ diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index cb88c70fe2e2f86fc3bd648f20f2591f5eb6581d..09020b5b2af7d4255b8e5954a9bcab6220d2848b 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -1,68 +1,60 @@ package list -import ( - "fmt" - "slices" - "testing" - - "github.com/charmbracelet/x/exp/golden" - "github.com/stretchr/testify/assert" -) - -func TestFilterableList(t *testing.T) { - t.Parallel() - t.Run("should create simple filterable list", func(t *testing.T) { - t.Parallel() - items := []FilterableItem{} - for i := range 5 { - item := NewFilterableItem(fmt.Sprintf("Item %d", i)) - items = append(items, item) - } - l := NewFilterableList( - items, - WithFilterListOptions(WithDirection(Forward)), - ).(*filterableList[FilterableItem]) - - l.SetSize(100, 10) - cmd := l.Init() - if cmd != nil { - cmd() - } - - assert.Equal(t, items[0].ID(), l.selectedItem) - golden.RequireEqual(t, []byte(l.View())) - }) -} - -func TestUpdateKeyMap(t *testing.T) { - t.Parallel() - l := NewFilterableList( - []FilterableItem{}, - WithFilterListOptions(WithDirection(Forward)), - ).(*filterableList[FilterableItem]) - - hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") - fmt.Println(l.keyMap.Down.Keys()) - hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") - - hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") - - assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") - assert.False(t, hasJ, "should not contain j") - assert.False(t, hasUpperCaseK, "should also remove upper case K") - assert.True(t, hasCtrlJ, "should still have ctrl+j") -} - -type filterableItem struct { - *selectableItem -} - -func NewFilterableItem(content string) FilterableItem { - return &filterableItem{ - selectableItem: NewSelectableItem(content).(*selectableItem), - } -} - -func (f *filterableItem) FilterValue() string { - return f.content -} +// +// func TestFilterableList(t *testing.T) { +// t.Parallel() +// t.Run("should create simple filterable list", func(t *testing.T) { +// t.Parallel() +// items := []FilterableItem{} +// for i := range 5 { +// item := NewFilterableItem(fmt.Sprintf("Item %d", i)) +// items = append(items, item) +// } +// l := NewFilterableList( +// items, +// WithFilterListOptions(WithDirection(Forward)), +// ).(*filterableList[FilterableItem]) +// +// l.SetSize(100, 10) +// cmd := l.Init() +// if cmd != nil { +// cmd() +// } +// +// assert.Equal(t, items[0].ID(), l.selectedItem) +// golden.RequireEqual(t, []byte(l.View())) +// }) +// } +// +// func TestUpdateKeyMap(t *testing.T) { +// t.Parallel() +// l := NewFilterableList( +// []FilterableItem{}, +// WithFilterListOptions(WithDirection(Forward)), +// ).(*filterableList[FilterableItem]) +// +// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") +// fmt.Println(l.keyMap.Down.Keys()) +// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") +// +// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") +// +// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") +// assert.False(t, hasJ, "should not contain j") +// assert.False(t, hasUpperCaseK, "should also remove upper case K") +// assert.True(t, hasCtrlJ, "should still have ctrl+j") +// } +// +// type filterableItem struct { +// *selectableItem +// } +// +// func NewFilterableItem(content string) FilterableItem { +// return &filterableItem{ +// selectableItem: NewSelectableItem(content).(*selectableItem), +// } +// } +// +// func (f *filterableItem) FilterValue() string { +// return f.content +// } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 98082d3c0dd4f9ff99212cd3700685810c540ace..96e8cceba3bd814afc2ca7b7820a87007786fe08 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -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") } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 6b5c92acd9d302e4bdd63b92cfff4cbb869f6ab4..e7f523834002e3ea2007505a2ed7930172e108dc 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -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) + } +} diff --git a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden deleted file mode 100644 index b8fd0efdb00bce286317007e40b2af335d22942f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 5 -Item 6 -Item 7 -Item 8 -│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden b/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden deleted file mode 100644 index 785a18b24f21ee9f6a58bf0a0b540bff4b5c097d..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden +++ /dev/null @@ -1,5 +0,0 @@ -Line2 -Item 8 -Line2 -│Item 9 -│Line2 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden deleted file mode 100644 index b8fd0efdb00bce286317007e40b2af335d22942f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 5 -Item 6 -Item 7 -Item 8 -│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden deleted file mode 100644 index a92d5cd50b42ac4e59b2fac2fc21355b30d4c1d0..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden deleted file mode 100644 index 5fb57a04f2136e6f00fa780620e61301a4d5fe9c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 4 -Item 5 -Item 6 -Item 7 -Item 8 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden deleted file mode 100644 index 7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden deleted file mode 100644 index b34ef9acef9960d727b203566011bb66953079d4..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 4 -Item 5 -Item 6 -Item 7 -│Item 8 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden b/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden deleted file mode 100644 index d2e6c78521781d77668673db6447d67dacd4097a..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 5 -Item 6 -Item 7 -│Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden b/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden deleted file mode 100644 index 4406faf046ad8229b1dc8908091ad47d555ddaf6..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestBackwardList/within_height.golden +++ /dev/null @@ -1,9 +0,0 @@ -Item 0 - -Item 1 - -Item 2 - -Item 3 - -│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden deleted file mode 100644 index 8aac1155586865e3db5a87839b9d430b419d00ec..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden +++ /dev/null @@ -1,6 +0,0 @@ -> Type to filter  -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden b/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden deleted file mode 100644 index 7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden b/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden deleted file mode 100644 index f8a79a980eb0cd28f70f1593705e63c6736f2eaa..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -│Line2 -Item 1 -Line2 -Item 2 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden b/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden deleted file mode 100644 index 7ca9b9f9cec94e77c1978e1265a1ee4f10da2e4b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden deleted file mode 100644 index d5091ddac1b9d427f257f37dd7fe57ebf871da62..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden deleted file mode 100644 index 691521bf35b5d15776b6c7cef93c0c1bbd4a26ba..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 1 -Item 2 -Item 3 -Item 4 -Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden deleted file mode 100644 index 9b99c5dff003cfe111724b6a8fbb146d81b2f0e3..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 1 -Item 2 -Item 3 -Item 4 -│Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden deleted file mode 100644 index b8fd0efdb00bce286317007e40b2af335d22942f..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 5 -Item 6 -Item 7 -Item 8 -│Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden b/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden deleted file mode 100644 index 7368e97d2ca758d46ae230cd5267cd93c4ee4dec..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 0 -│Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/within_height.golden b/internal/tui/exp/list/testdata/TestForwardList/within_height.golden deleted file mode 100644 index 676da068c53cadc771497892ae66daeb786aaaa2..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestForwardList/within_height.golden +++ /dev/null @@ -1,9 +0,0 @@ -│Item 0 - -Item 1 - -Item 2 - -Item 3 - -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..46269dd405b643eef664dafb388d2001ffacc923 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..828d986cba48a879f1e3e0c7fd9a35b70bacd52e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..6e558d7a093312cf4911bbe3ffc18a6c02583cc6 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..3531c59b4121a3d85effd1e0779742f98b7b1ac7 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden @@ -0,0 +1,10 @@ +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..f6b9a64ae1d6aea57fe9c014f5d748801c3b04fd --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden @@ -0,0 +1,5 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..f81aca7680744374be81be4e15315468d5c3db8c --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden @@ -0,0 +1,5 @@ +Item 0 +Item 1 +Item 2 +Item 3 +│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden new file mode 100644 index 0000000000000000000000000000000000000000..50e62a320d3797ee21ea68fa25371e20ef7c150b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden @@ -0,0 +1,10 @@ +Item 3 +Item 3 +│Item 4 +│Item 4 +│Item 4 +│Item 4 +│Item 4 +Item 5 +Item 5 +Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..50e62a320d3797ee21ea68fa25371e20ef7c150b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden @@ -0,0 +1,10 @@ +Item 3 +Item 3 +│Item 4 +│Item 4 +│Item 4 +│Item 4 +│Item 4 +Item 5 +Item 5 +Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden new file mode 100644 index 0000000000000000000000000000000000000000..1331375f5b46cbf692df512e6b0383fb2776b472 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden @@ -0,0 +1,10 @@ +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..1331375f5b46cbf692df512e6b0383fb2776b472 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden @@ -0,0 +1,10 @@ +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..46269dd405b643eef664dafb388d2001ffacc923 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..828d986cba48a879f1e3e0c7fd9a35b70bacd52e --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..6e558d7a093312cf4911bbe3ffc18a6c02583cc6 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..3531c59b4121a3d85effd1e0779742f98b7b1ac7 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden @@ -0,0 +1,10 @@ +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden new file mode 100644 index 0000000000000000000000000000000000000000..f6b9a64ae1d6aea57fe9c014f5d748801c3b04fd --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden @@ -0,0 +1,5 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden new file mode 100644 index 0000000000000000000000000000000000000000..f81aca7680744374be81be4e15315468d5c3db8c --- /dev/null +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden @@ -0,0 +1,5 @@ +Item 0 +Item 1 +Item 2 +Item 3 +│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden b/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden deleted file mode 100644 index fe55231e951955234b57f1c341d2ceecf3101bf0..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 11 -Item 12 -Item 13 -Item 14 -Item 15 -Item 16 -Item 17 -Item 18 -Item 19 -│New Item \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden b/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden deleted file mode 100644 index 5e8610df6e3c2247e7879fb2ba3fa09694ba9d25..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 15 -Line2 -Item 16 -Line2 -Item 17 -Line2 -Item 18 -Line2 -│Item 19 -│Line2 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden new file mode 100644 index 0000000000000000000000000000000000000000..67a32bfedb0c3941c99e6693fad9612bceb61932 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden @@ -0,0 +1,10 @@ +Item 6 +Item 6 +Item 6 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..1662abeb712a883c930bfbe91b33a20a81bc616d --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden @@ -0,0 +1,10 @@ +Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +│Item 3 +│Item 3 +│Item 3 +│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..9469e26363b59f1d98fbb46e2dd4f194028927ab --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden @@ -0,0 +1,10 @@ +│Item 28 +│Item 28 +│Item 28 +│Item 28 +│Item 28 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden new file mode 100644 index 0000000000000000000000000000000000000000..6e558d7a093312cf4911bbe3ffc18a6c02583cc6 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden @@ -0,0 +1,10 @@ +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden b/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden deleted file mode 100644 index 83638680c8cc7538d2843dabf9dd874782e09669..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 0 -Item 1 -Item 2 -│Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden deleted file mode 100644 index 81eb3372876da6a55bf90efe3bc3c5da96c3ef54..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden +++ /dev/null @@ -1,7 +0,0 @@ -Item initial -None Selectable -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden b/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden deleted file mode 100644 index 12d86d00139c82ff088421a1dfac9b66d82747cc..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden +++ /dev/null @@ -1,6 +0,0 @@ -None Selectable -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden b/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden deleted file mode 100644 index bd6e2219113ebea6cbd53d775866d0e2401fbc41..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 47 -Item 48 -Item 49 -Item 50 -Item 51 -│Item 52 -Item 53 -Item 54 -Item 55 -Item 56 \ No newline at end of file From a6c4855fb55f2eb289e6d12ef351004b4e555e86 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 23 Jul 2025 14:50:06 +0200 Subject: [PATCH 07/18] chore: improve anim performance --- internal/tui/components/anim/anim.go | 172 ++++++++++++++++++--------- 1 file changed, 114 insertions(+), 58 deletions(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 241522c8989c89bf8eb877c69b9a72f01508c5f4..b1dab3c4b816e1ace67a4caa8d69c8273a188928 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -2,9 +2,12 @@ package anim import ( + "crypto/sha256" + "fmt" "image/color" "math/rand/v2" "strings" + "sync" "sync/atomic" "time" @@ -58,6 +61,29 @@ func nextID() int { return int(atomic.AddInt64(&lastID, 1)) } +// Cache for expensive animation calculations +type animCache struct { + initialFrames [][]string + cyclingFrames [][]string + width int + labelWidth int + label []string + ellipsisFrames []string +} + +var ( + animCacheMutex sync.RWMutex + animCacheMap = make(map[string]*animCache) +) + +// settingsHash creates a hash key for the settings to use for caching +func settingsHash(opts Settings) string { + h := sha256.New() + fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", + opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) + return fmt.Sprintf("%x", h.Sum(nil)) +} + // StepMsg is a message type used to trigger the next step in the animation. type StepMsg struct{ id int } @@ -109,79 +135,109 @@ func New(opts Settings) (a Anim) { } a.id = nextID() - a.startTime = time.Now() a.cyclingCharWidth = opts.Size - a.labelWidth = lipgloss.Width(opts.Label) a.labelColor = opts.LabelColor - // Total width of anim, in cells. - a.width = opts.Size - if opts.Label != "" { - a.width += labelGapWidth + lipgloss.Width(opts.Label) - } - - // Render the label - a.renderLabel(opts.Label) - - // Pre-generate gradient. - var ramp []color.Color - numFrames := prerenderedFrames - if opts.CycleColors { - ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) - numFrames = a.width * 2 + // Check cache first + cacheKey := settingsHash(opts) + animCacheMutex.RLock() + cached, exists := animCacheMap[cacheKey] + animCacheMutex.RUnlock() + + if exists { + // Use cached values + a.width = cached.width + a.labelWidth = cached.labelWidth + a.label = cached.label + a.ellipsisFrames = cached.ellipsisFrames + a.initialFrames = cached.initialFrames + a.cyclingFrames = cached.cyclingFrames } else { - ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) - } + // Generate new values and cache them + a.labelWidth = lipgloss.Width(opts.Label) - // Pre-render initial characters. - a.initialFrames = make([][]string, numFrames) - offset := 0 - for i := range a.initialFrames { - a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) - for j := range a.initialFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors - } + // Total width of anim, in cells. + a.width = opts.Size + if opts.Label != "" { + a.width += labelGapWidth + lipgloss.Width(opts.Label) + } - var c color.Color - if j <= a.cyclingCharWidth { - c = ramp[j+offset] - } else { - c = opts.LabelColor - } + // Render the label + a.renderLabel(opts.Label) - // Also prerender the initial character with Lip Gloss to avoid - // processing in the render loop. - a.initialFrames[i][j] = lipgloss.NewStyle(). - Foreground(c). - Render(string(initialChar)) - } + // Pre-generate gradient. + var ramp []color.Color + numFrames := prerenderedFrames if opts.CycleColors { - offset++ + ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB) + numFrames = a.width * 2 + } else { + ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB) } - } - // Prerender scrambled rune frames for the animation. - a.cyclingFrames = make([][]string, numFrames) - offset = 0 - for i := range a.cyclingFrames { - a.cyclingFrames[i] = make([]string, a.width) - for j := range a.cyclingFrames[i] { - if j+offset >= len(ramp) { - continue // skip if we run out of colors + // Pre-render initial characters. + a.initialFrames = make([][]string, numFrames) + offset := 0 + for i := range a.initialFrames { + a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth) + for j := range a.initialFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + var c color.Color + if j <= a.cyclingCharWidth { + c = ramp[j+offset] + } else { + c = opts.LabelColor + } + + // Also prerender the initial character with Lip Gloss to avoid + // processing in the render loop. + a.initialFrames[i][j] = lipgloss.NewStyle(). + Foreground(c). + Render(string(initialChar)) + } + if opts.CycleColors { + offset++ } + } - // Also prerender the color with Lip Gloss here to avoid processing - // in the render loop. - r := availableRunes[rand.IntN(len(availableRunes))] - a.cyclingFrames[i][j] = lipgloss.NewStyle(). - Foreground(ramp[j+offset]). - Render(string(r)) + // Prerender scrambled rune frames for the animation. + a.cyclingFrames = make([][]string, numFrames) + offset = 0 + for i := range a.cyclingFrames { + a.cyclingFrames[i] = make([]string, a.width) + for j := range a.cyclingFrames[i] { + if j+offset >= len(ramp) { + continue // skip if we run out of colors + } + + // Also prerender the color with Lip Gloss here to avoid processing + // in the render loop. + r := availableRunes[rand.IntN(len(availableRunes))] + a.cyclingFrames[i][j] = lipgloss.NewStyle(). + Foreground(ramp[j+offset]). + Render(string(r)) + } + if opts.CycleColors { + offset++ + } } - if opts.CycleColors { - offset++ + + // Cache the results + cached = &animCache{ + initialFrames: a.initialFrames, + cyclingFrames: a.cyclingFrames, + width: a.width, + labelWidth: a.labelWidth, + label: a.label, + ellipsisFrames: a.ellipsisFrames, } + animCacheMutex.Lock() + animCacheMap[cacheKey] = cached + animCacheMutex.Unlock() } // Random assign a birth to each character for a stagged entrance effect. From 2a13723ac3e953ef873a77ec32d1440d262cc30f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 23 Jul 2025 14:53:21 +0200 Subject: [PATCH 08/18] chore: small improvements --- internal/tui/exp/list/list.go | 91 ++++++++++++------- internal/tui/exp/list/list_test.go | 36 -------- ...ould_go_to_selected_item_and_center.golden | 10 -- ..._selected_item_and_center_backwards.golden | 10 -- 4 files changed, 60 insertions(+), 87 deletions(-) delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 96e8cceba3bd814afc2ca7b7820a87007786fe08..201e02ca04dbc8f7d2e5b6152cebb835fe16c347 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -56,7 +56,6 @@ const ( type renderedItem struct { id string view string - dirty bool height int start int end int @@ -84,6 +83,8 @@ type list[T Item] struct { renderedItems map[string]renderedItem rendered string + + movingByItem bool } type listOption func(*confOptions) @@ -209,7 +210,9 @@ func (l *list[T]) View() string { lines := strings.Split(view, "\n") start, end := l.viewPosition() - lines = lines[start : end+1] + viewStart := max(0, start) + viewEnd := min(len(lines), end+1) + lines = lines[viewStart:viewEnd] return strings.Join(lines, "\n") } @@ -245,7 +248,13 @@ func (l *list[T]) render() tea.Cmd { return nil } l.setDefaultSelected() - focusCmd := l.focusSelectedItem() + + var focusChangeCmd tea.Cmd + if l.focused { + focusChangeCmd = l.focusSelectedItem() + } else { + focusChangeCmd = l.blurSelectedItem() + } // we are not rendering the first time if l.rendered != "" { l.rendered = "" @@ -258,7 +267,7 @@ func (l *list[T]) render() tea.Cmd { if l.focused { l.scrollToSelection() } - return focusCmd + return focusChangeCmd } finishIndex := l.renderIterator(0, true) // recalculate for the initial items @@ -276,9 +285,10 @@ func (l *list[T]) render() tea.Cmd { if l.focused { l.scrollToSelection() } + return renderedMsg{} } - return tea.Batch(focusCmd, renderCmd) + return tea.Batch(focusChangeCmd, renderCmd) } func (l *list[T]) setDefaultSelected() { @@ -304,11 +314,21 @@ func (l *list[T]) scrollToSelection() { if rItem.start <= start && rItem.end >= end { return } - // item already in view do nothing - if rItem.start >= start && rItem.start <= end { - return - } else if rItem.end <= end && rItem.end >= start { - return + // if we are moving by item we want to move the offset so that the + // whole item is visible not just portions of it + if l.movingByItem { + if rItem.start >= start && rItem.end <= end { + return + } + defer func() { l.movingByItem = false }() + } else { + // item already in view do nothing + if rItem.start >= start && rItem.start <= end { + return + } + if rItem.end >= start && rItem.end <= end { + return + } } if rItem.height >= l.height { @@ -320,11 +340,22 @@ func (l *list[T]) scrollToSelection() { return } - 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)) + renderedLines := lipgloss.Height(l.rendered) - 1 + + // If item is above the viewport, make it the first item + if rItem.start < start { + if l.direction == DirectionForward { + l.offset = rItem.start + } else { + l.offset = max(0, renderedLines-rItem.start-l.height+1) + } + } else if rItem.end > end { + // If item is below the viewport, make it the last item + if l.direction == DirectionForward { + l.offset = max(0, rItem.end-l.height+1) + } else { + l.offset = max(0, renderedLines-rItem.end) + } } } @@ -446,32 +477,26 @@ func (l *list[T]) focusSelectedItem() tea.Cmd { 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 - } + delete(l.renderedItems, item.ID()) } 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 - } + delete(l.renderedItems, item.ID()) } } } return tea.Batch(cmds...) } -func (l *list[T]) blurItems() tea.Cmd { +func (l *list[T]) blurSelectedItem() tea.Cmd { + if l.selectedItem == "" || l.focused { + return nil + } 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.Blur()) - if cache, ok := l.renderedItems[item.ID()]; ok { - cache.dirty = true - l.renderedItems[item.ID()] = cache - } + delete(l.renderedItems, item.ID()) } } } @@ -495,7 +520,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { item := l.items[inx] var rItem renderedItem - if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty { + if cache, ok := l.renderedItems[item.ID()]; ok { rItem = cache } else { rItem = l.renderItem(item) @@ -534,8 +559,8 @@ func (l *list[T]) AppendItem(T) tea.Cmd { // Blur implements List. func (l *list[T]) Blur() tea.Cmd { - cmd := l.blurItems() - return tea.Batch(cmd, l.render()) + l.focused = false + return l.render() } // DeleteItem implements List. @@ -644,6 +669,7 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { } item := l.items[newIndex] l.selectedItem = item.ID() + l.movingByItem = true return l.render() } @@ -661,6 +687,7 @@ func (l *list[T]) SelectItemBelow() tea.Cmd { } item := l.items[newIndex] l.selectedItem = item.ID() + l.movingByItem = true return l.render() } @@ -692,6 +719,8 @@ func (l *list[T]) SetSelected(id string) tea.Cmd { func (l *list[T]) reset() tea.Cmd { var cmds []tea.Cmd l.rendered = "" + l.offset = 0 + l.selectedItem = "" l.indexMap = make(map[string]int) l.renderedItems = make(map[string]renderedItem) for inx, item := range l.items { diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index e7f523834002e3ea2007505a2ed7930172e108dc..e822632502b8ee26b884e825cf3219a411af20dc 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -205,42 +205,6 @@ func TestList(t *testing.T) { golden.RequireEqual(t, []byte(l.View())) }) - t.Run("should go to selected item and center", func(t *testing.T) { - t.Parallel() - 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, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, items[4].ID(), l.selectedItem) - - golden.RequireEqual(t, []byte(l.View())) - }) - - t.Run("should go to selected item and center backwards", func(t *testing.T) { - t.Parallel() - 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, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item]) - execCmd(l, l.Init()) - - // should select the last item - assert.Equal(t, items[4].ID(), l.selectedItem) - - golden.RequireEqual(t, []byte(l.View())) - }) - t.Run("should go to selected item at the beginning", func(t *testing.T) { t.Parallel() items := []Item{} diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden deleted file mode 100644 index 50e62a320d3797ee21ea68fa25371e20ef7c150b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 3 -Item 3 -│Item 4 -│Item 4 -│Item 4 -│Item 4 -│Item 4 -Item 5 -Item 5 -Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden deleted file mode 100644 index 50e62a320d3797ee21ea68fa25371e20ef7c150b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 3 -Item 3 -│Item 4 -│Item 4 -│Item 4 -│Item 4 -│Item 4 -Item 5 -Item 5 -Item 5 \ No newline at end of file From ce47eb0fca04b87f0b37ff809a7f425db9c917f0 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 23 Jul 2025 14:55:34 +0200 Subject: [PATCH 09/18] chore: change checksum algo --- internal/tui/components/anim/anim.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index b1dab3c4b816e1ace67a4caa8d69c8273a188928..6f69e7b5332c7bbbb1aee6f1191acda5c7ddcfd8 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -2,7 +2,6 @@ package anim import ( - "crypto/sha256" "fmt" "image/color" "math/rand/v2" @@ -11,6 +10,8 @@ import ( "sync/atomic" "time" + "github.com/zeebo/xxh3" + tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/lucasb-eyer/go-colorful" @@ -78,7 +79,7 @@ var ( // settingsHash creates a hash key for the settings to use for caching func settingsHash(opts Settings) string { - h := sha256.New() + h := xxh3.New() fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t", opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors) return fmt.Sprintf("%x", h.Sum(nil)) From 6b3fe6aa20319a23941325cf89e57cc75cd55226 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 13:14:03 +0200 Subject: [PATCH 10/18] chore: fix viewport offset --- crush.json | 9 + internal/tui/components/chat/chat.go | 8 +- internal/tui/exp/list/list.go | 166 +++++++++++- internal/tui/exp/list/list_test.go | 237 ++++++++++++++++++ ...are_at_the_bottom_in_backwards_list.golden | 10 + ...e_appended_and_we_are_at_the_botton.golden | 10 + ...d_we_are_at_the_top_in_forward_list.golden | 10 + ...appended_and_we_are_in_forward_list.golden | 10 + ...pended_and_we_are_in_backwards_list.golden | 10 + ...d_but_we_moved_down_in_forward_list.golden | 10 + ...new_items_are_added_but_we_moved_up.golden | 10 + ...d_but_we_moved_up_in_backwards_list.golden | 10 + ..._above_is_decreases_in_forward_list.golden | 10 + ...hight_of_an_item_above_is_increased.golden | 10 + ...bove_is_increased_in_backwards_list.golden | 10 + ..._above_is_increased_in_forward_list.golden | 10 + ...hight_of_an_item_below_is_decreases.golden | 10 + ...elow_is_decreases_in_backwards_list.golden | 10 + ...ht_of_an_item_below_is_increased#01.golden | 10 + ...hight_of_an_item_below_is_increased.golden | 10 + ...elow_is_increased_in_backwards_list.golden | 10 + ..._below_is_increased_in_forward_list.golden | 10 + internal/tui/page/chat/chat.go | 1 + 23 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden create mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden diff --git a/crush.json b/crush.json index 1b04ea6c24f8b64a3a12ceb47551f3177fa66302..1857ae8fe1bc925326aeccb1a0ceb26362f1f062 100644 --- a/crush.json +++ b/crush.json @@ -3,5 +3,14 @@ "Go": { "command": "gopls" } + }, + "mcp": { + "linear": { + "type": "stdio", + "command": "mcp-remote", + "args": [ + "https://mcp.linear.app/sse" + ] + } } } diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 1629bc4c5638e24274c0a658f0f8c5de5bb71b59..fe1df1fb4fc9014407095db465e79409e693462d 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -40,6 +40,7 @@ type MessageListCmp interface { layout.Help SetSession(session.Session) tea.Cmd + GoToBottom() tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -64,6 +65,7 @@ func New(app *app.App) MessageListCmp { []list.Item{}, list.WithGap(1), list.WithDirectionBackward(), + list.WithFocus(false), list.WithKeyMap(defaultListKeyMap), ) return &messageListCmp{ @@ -76,7 +78,7 @@ func New(app *app.App) MessageListCmp { // Init initializes the component. func (m *messageListCmp) Init() tea.Cmd { - return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur()) + return m.listCmp.Init() } // Update handles incoming messages and updates the component state. @@ -531,3 +533,7 @@ func (m *messageListCmp) IsFocused() bool { func (m *messageListCmp) Bindings() []key.Binding { return m.defaultListKeyMap.KeyBindings() } + +func (m *messageListCmp) GoToBottom() tea.Cmd { + return m.listCmp.GoToBottom() +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 201e02ca04dbc8f7d2e5b6152cebb835fe16c347..afcf0a2bc9c1148b559d878445d8be169ca6ea9f 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,10 +1,12 @@ package list import ( + "slices" "strings" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -16,6 +18,10 @@ type Item interface { ID() string } +type HasAnim interface { + Item + Spinning() bool +} type ( renderedMsg struct{} List[T Item] interface { @@ -172,6 +178,18 @@ func (l *list[T]) Init() tea.Cmd { // Update implements List. func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case anim.StepMsg: + var cmds []tea.Cmd + for _, item := range l.items { + if i, ok := any(item).(HasAnim); ok && i.Spinning() { + updated, cmd := i.Update(msg) + cmds = append(cmds, cmd) + if u, ok := updated.(T); ok { + cmds = append(cmds, l.UpdateItem(u.ID(), u)) + } + } + } + return l, tea.Batch(cmds...) case tea.KeyPressMsg: if l.focused { switch { @@ -553,8 +571,44 @@ func (l *list[T]) renderItem(item Item) renderedItem { } // AppendItem implements List. -func (l *list[T]) AppendItem(T) tea.Cmd { - panic("unimplemented") +func (l *list[T]) AppendItem(item T) tea.Cmd { + var cmds []tea.Cmd + cmd := item.Init() + if cmd != nil { + cmds = append(cmds, cmd) + } + + l.items = append(l.items, item) + l.indexMap = make(map[string]int) + for inx, item := range l.items { + l.indexMap[item.ID()] = inx + } + if l.width > 0 && l.height > 0 { + cmd = item.SetSize(l.width, l.height) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + cmd = l.render() + if cmd != nil { + cmds = append(cmds, cmd) + } + if l.direction == DirectionBackward { + if l.offset == 0 { + cmd = l.GoToBottom() + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + newItem := l.renderedItems[item.ID()] + newLines := newItem.height + if len(l.items) > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + } + } + return tea.Sequence(cmds...) } // Blur implements List. @@ -564,8 +618,34 @@ func (l *list[T]) Blur() tea.Cmd { } // DeleteItem implements List. -func (l *list[T]) DeleteItem(string) tea.Cmd { - panic("unimplemented") +func (l *list[T]) DeleteItem(id string) tea.Cmd { + inx := l.indexMap[id] + l.items = slices.Delete(l.items, inx, inx+1) + delete(l.renderedItems, id) + for inx, item := range l.items { + l.indexMap[item.ID()] = inx + } + + if l.selectedItem == id { + if inx > 0 { + l.selectedItem = l.items[inx-1].ID() + } else { + l.selectedItem = "" + } + } + cmd := l.render() + if l.rendered != "" { + renderedHeight := lipgloss.Height(l.rendered) + if renderedHeight <= l.height { + l.offset = 0 + } else { + maxOffset := renderedHeight - l.height + if l.offset > maxOffset { + l.offset = maxOffset + } + } + } + return cmd } // Focus implements List. @@ -651,8 +731,35 @@ func (l *list[T]) MoveUp(n int) tea.Cmd { } // PrependItem implements List. -func (l *list[T]) PrependItem(T) tea.Cmd { - panic("unimplemented") +func (l *list[T]) PrependItem(item T) tea.Cmd { + cmds := []tea.Cmd{ + item.Init(), + } + l.items = append([]T{item}, l.items...) + l.indexMap = make(map[string]int) + for inx, item := range l.items { + 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()) + if l.direction == DirectionForward { + if l.offset == 0 { + cmd := l.GoToTop() + if cmd != nil { + cmds = append(cmds, cmd) + } + } else { + newItem := l.renderedItems[item.ID()] + newLines := newItem.height + if len(l.items) > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) + } + } + return tea.Batch(cmds...) } // SelectItemAbove implements List. @@ -707,7 +814,12 @@ func (l *list[T]) SelectedItem() *T { // SetItems implements List. func (l *list[T]) SetItems(items []T) tea.Cmd { l.items = items - return l.reset() + var cmds []tea.Cmd + for _, item := range l.items { + cmds = append(cmds, item.Init()) + } + cmds = append(cmds, l.reset()) + return tea.Batch(cmds...) } // SetSelected implements List. @@ -745,6 +857,42 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { } // UpdateItem implements List. -func (l *list[T]) UpdateItem(string, T) tea.Cmd { - panic("unimplemented") +func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { + var cmds []tea.Cmd + if inx, ok := l.indexMap[id]; ok { + l.items[inx] = item + oldItem := l.renderedItems[id] + oldPosition := l.offset + if l.direction == DirectionBackward { + oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset + } + + delete(l.renderedItems, id) + cmd := l.render() + + // need to check for nil because of sequence not handling nil + if cmd != nil { + cmds = append(cmds, cmd) + } + if l.direction == DirectionBackward { + // if we are the last item and there is no offset + // make sure to go to the bottom + if inx == len(l.items)-1 && l.offset == 0 { + cmd = l.GoToBottom() + if cmd != nil { + cmds = append(cmds, cmd) + } + // if the item is at least partially below the viewport + } else if oldPosition < oldItem.end { + newItem := l.renderedItems[item.ID()] + newLines := newItem.height - oldItem.height + l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + } + } else if l.offset > oldItem.start { + newItem := l.renderedItems[item.ID()] + newLines := newItem.height - oldItem.height + l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + } + } + return tea.Sequence(cmds...) } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index e822632502b8ee26b884e825cf3219a411af20dc..933f061760653f38c8dc49787017740bcc5d58e5 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -315,6 +315,243 @@ func TestListMovement(t *testing.T) { assert.Equal(t, 0, l.offset) golden.RequireEqual(t, []byte(l.View())) }) + + t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) { + t.Parallel() + 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, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + execCmd(l, l.AppendItem(NewSelectableItem("Testing"))) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 5, l.offset) + assert.Equal(t, 33, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[29] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 4, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3")) + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[30] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 0, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + item := items[1] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveUp(2)) + viewBefore := l.View() + execCmd(l, l.PrependItem(NewSelectableItem("New"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) { + t.Parallel() + 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, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + execCmd(l, l.PrependItem(NewSelectableItem("Testing"))) + + assert.Equal(t, 0, l.offset) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 5, l.offset) + assert.Equal(t, 33, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + item := items[0] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 4, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + items = append(items, NewSelectableItem("At top\nLine 2\nLine 3")) + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(3)) + viewBefore := l.View() + item := items[0] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 1, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + + t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + item := items[29] + execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 32, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 30 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item]) + execCmd(l, l.Init()) + + execCmd(l, l.MoveDown(2)) + viewBefore := l.View() + execCmd(l, l.AppendItem(NewSelectableItem("New"))) + viewAfter := l.View() + assert.Equal(t, viewBefore, viewAfter) + assert.Equal(t, 2, l.offset) + assert.Equal(t, 31, lipgloss.Height(l.rendered)) + golden.RequireEqual(t, []byte(l.View())) + }) } type SelectableItem interface { diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..03dce1dac791cad0516fd70cfa5bf5d1ec73bee4 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +│Testing \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden new file mode 100644 index 0000000000000000000000000000000000000000..03dce1dac791cad0516fd70cfa5bf5d1ec73bee4 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden @@ -0,0 +1,10 @@ +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +│Testing \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..efcdc73a7d9573692365723a1ba65a8773d3a3c2 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden @@ -0,0 +1,10 @@ +│Testing +Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..90740d28955595061e79772cf8011a7571712205 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..90740d28955595061e79772cf8011a7571712205 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..46269dd405b643eef664dafb388d2001ffacc923 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..90740d28955595061e79772cf8011a7571712205 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden new file mode 100644 index 0000000000000000000000000000000000000000..77d3450cede66562f85e422c7c4199240231f11b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden @@ -0,0 +1,10 @@ +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 +Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..77d3450cede66562f85e422c7c4199240231f11b --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 +Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0ed052f256cca2d93c47364d1e719c112819d86 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden @@ -0,0 +1,10 @@ +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..90740d28955595061e79772cf8011a7571712205 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden @@ -0,0 +1,10 @@ +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 9deac1e9e48c1cff576e84746d3976b4b670a700..0ec245be671e78803bb86cfbf28d2d0eb342bd67 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -604,6 +604,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te if err != nil { return util.ReportError(err) } + cmds = append(cmds, p.chat.GoToBottom()) return tea.Batch(cmds...) } From dcf069ea376f2fefa3bd8d4ed7cba171bdfd5211 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 16:27:30 +0200 Subject: [PATCH 11/18] chore: grouped list --- internal/tui/components/chat/splash/splash.go | 26 +- .../components/dialogs/commands/commands.go | 67 +++-- .../tui/components/dialogs/models/list.go | 105 ++++--- .../tui/components/dialogs/models/models.go | 28 +- .../components/dialogs/sessions/sessions.go | 2 +- internal/tui/exp/list/filterable.go | 9 +- internal/tui/exp/list/filterable_group.go | 260 ++++++++++++++++++ internal/tui/exp/list/filterable_test.go | 124 +++++---- internal/tui/exp/list/grouped.go | 99 +++++++ internal/tui/exp/list/items.go | 89 +++++- internal/tui/exp/list/list.go | 119 +++++--- ...hould_create_simple_filterable_list.golden | 6 + ...n_list_that_does_not_fits_the_items.golden | 10 - ..._the_items_and_has_multi_line_items.golden | 10 - ..._and_has_multi_line_items_backwards.golden | 10 - ...t_does_not_fits_the_items_backwards.golden | 10 - ...sitions_in_list_that_fits_the_items.golden | 5 - ..._list_that_fits_the_items_backwards.golden | 5 - ...e_appended_and_we_are_at_the_botton.golden | 10 - ...new_items_are_added_but_we_moved_up.golden | 10 - ...hight_of_an_item_above_is_increased.golden | 10 - ...hight_of_an_item_below_is_decreases.golden | 10 - ...ht_of_an_item_below_is_increased#01.golden | 10 - ...hight_of_an_item_below_is_increased.golden | 10 - 24 files changed, 733 insertions(+), 311 deletions(-) create mode 100644 internal/tui/exp/list/filterable_group.go create mode 100644 internal/tui/exp/list/grouped.go create mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index b7291b3b59ae2bec879739e384e495776bb84f23..0e754e083ff0ef88e6f39689a577e72f894e05b5 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -14,12 +14,11 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/logo" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/crush/internal/version" @@ -86,9 +85,7 @@ func New() Splash { listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - t := styles.CurrentTheme() - inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave") + modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false) apiKeyInput := models.NewAPIKeyInput() return &splashCmp{ @@ -195,17 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.saveAPIKeyAndContinue(s.apiKeyValue) } if s.isOnboarding && !s.needsAPIKey { - modelInx := s.modelList.SelectedIndex() - items := s.modelList.Items() - selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) + selectedItem := s.modelList.SelectedModel() + if selectedItem == nil { + return s, nil + } if s.isProviderConfigured(string(selectedItem.Provider.ID)) { - cmd := s.setPreferredModel(selectedItem) + cmd := s.setPreferredModel(*selectedItem) s.isOnboarding = false return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) } else { // Provider not configured, show API key input s.needsAPIKey = true - s.selectedModel = &selectedItem + s.selectedModel = selectedItem s.apiKeyInput.SetProviderName(selectedItem.Provider.Name) return s, nil } @@ -264,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } case key.Matches(msg, s.keyMap.Yes): + if s.isOnboarding { + return s, nil + } if s.needsAPIKey { u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -274,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.initializeProject() } case key.Matches(msg, s.keyMap.No): + if s.isOnboarding { + return s, nil + } if s.needsAPIKey { u, cmd := s.apiKeyInput.Update(msg) s.apiKeyInput = u.(*models.APIKeyInput) @@ -605,7 +609,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { cursor.Y += offset cursor.X = cursor.X + 1 } else if s.isOnboarding { - offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3 + offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2 cursor.Y += offset cursor.X = cursor.X + 1 } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index c1b96f0bac7d0b665aad77794392b7417d60457a..50a67b77be373f987849953d0d60d9773caeb752 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -10,10 +10,9 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -29,6 +28,8 @@ const ( UserCommands ) +type listModel = list.FilterableList[list.CompletionItem[Command]] + // Command represents a command that can be executed type Command struct { ID string @@ -48,7 +49,7 @@ type commandDialogCmp struct { wWidth int // Width of the terminal window wHeight int // Height of the terminal window - commandList list.ListModel + commandList listModel keyMap CommandsDialogKeyMap help help.Model commandType int // SystemCommands or UserCommands @@ -67,24 +68,23 @@ type ( ) func NewCommandDialog(sessionID string) CommandsDialog { - listKeyMap := list.DefaultKeyMap() keyMap := DefaultCommandsDialogKeyMap() - + listKeyMap := list.DefaultKeyMap() listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous t := styles.CurrentTheme() - commandList := list.New( - list.WithFilterable(true), - list.WithKeyMap(listKeyMap), - list.WithWrapNavigation(true), + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + commandList := list.NewFilterableList( + []list.CompletionItem[Command]{}, + list.WithFilterInputStyle(inputStyle), + list.WithFilterListOptions( + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(), + list.WithResizeByList(), + ), ) help := help.New() help.Styles = t.S().Help @@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd { if err != nil { return util.ReportError(err) } - c.userCommands = commands - c.SetCommandType(c.commandType) - return c.commandList.Init() + return c.SetCommandType(c.commandType) } func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: c.wWidth = msg.Width c.wHeight = msg.Height - c.SetCommandType(c.commandType) return c, c.commandList.SetSize(c.listWidth(), c.listHeight()) case tea.KeyPressMsg: switch { case key.Matches(msg, c.keyMap.Select): - selectedItemInx := c.commandList.SelectedIndex() - if selectedItemInx == list.NoSelection { + selectedItem := c.commandList.SelectedItem() + if selectedItem == nil { return c, nil // No item selected, do nothing } - items := c.commandList.Items() - selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command) + command := (*selectedItem).Value() return c, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), - selectedItem.Handler(selectedItem), + command.Handler(command), ) case key.Matches(msg, c.keyMap.Tab): + if len(c.userCommands) == 0 { + return c, nil + } // Toggle command type between System and User commands if c.commandType == SystemCommands { return c, c.SetCommandType(UserCommands) @@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := c.commandList.Update(msg) - c.commandList = u.(list.ListModel) + c.commandList = u.(listModel) return c, cmd } } @@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string { t := styles.CurrentTheme() listView := c.commandList radio := c.commandTypeRadio() + + header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio) + if len(c.userCommands) == 0 { + header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4)) + } content := lipgloss.JoinVertical( lipgloss.Left, - t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio), + header, listView.View(), "", t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), @@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd { commands = c.userCommands } - commandItems := []util.Model{} + commandItems := []list.CompletionItem[Command]{} for _, cmd := range commands { - opts := []completions.CompletionOption{} + opts := []list.CompletionItemOption{ + list.WithCompletionID(cmd.ID), + } if cmd.Shortcut != "" { - opts = append(opts, completions.WithShortcut(cmd.Shortcut)) + opts = append( + opts, + list.WithCompletionShortcut(cmd.Shortcut), + ) } - commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...)) + commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...)) } return c.commandList.SetItems(commandItems) } diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 5a36ab736351f2c92154da997f01ba7360470d8a..a8a23874dd2b603999d231675e2f13334948b578 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -7,27 +7,36 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/completions" - "github.com/charmbracelet/crush/internal/tui/components/core/list" - "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" ) +type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]] + type ModelListComponent struct { - list list.ListModel + list listModel modelType int providers []catwalk.Provider } -func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { - modelList := list.New( - list.WithFilterable(true), +func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent { + t := styles.CurrentTheme() + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + options := []list.ListOption{ list.WithKeyMap(keyMap), - list.WithInputStyle(inputStyle), + list.WithWrapNavigation(), + } + if shouldResize { + options = append(options, list.WithResizeByList()) + } + modelList := list.NewFilterableGroupedList( + []list.Group[list.CompletionItem[ModelOption]]{}, + list.WithFilterInputStyle(inputStyle), list.WithFilterPlaceholder(inputPlaceholder), - list.WithWrapNavigation(true), + list.WithFilterListOptions( + options..., + ), ) return &ModelListComponent{ @@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd { func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { u, cmd := m.list.Update(msg) - m.list = u.(list.ListModel) + m.list = u.(listModel) return m, cmd } @@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd { return m.list.SetSize(width, height) } -func (m *ModelListComponent) Items() []util.Model { - return m.list.Items() -} - -func (m *ModelListComponent) SelectedIndex() int { - return m.list.SelectedIndex() +func (m *ModelListComponent) SelectedModel() *ModelOption { + s := m.list.SelectedItem() + if s == nil { + return nil + } + sv := *s + model := sv.Value() + return &model } func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { t := styles.CurrentTheme() m.modelType = modelType - modelItems := []util.Model{} + var groups []list.Group[list.CompletionItem[ModelOption]] // first none section - selectIndex := 1 + selectedItemID := "" cfg := config.Get() var currentModel config.SelectedModel @@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { if name == "" { name = string(configProvider.ID) } - section := commands.NewItemSection(name) + section := list.NewItemSection(name) section.SetInfo(configured) - modelItems = append(modelItems, section) + group := list.Group[list.CompletionItem[ModelOption]]{ + Section: section, + } for _, model := range configProvider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + item := list.NewCompletionItem(model.Model, ModelOption{ Provider: configProvider, Model: model, - })) + }, + list.WithCompletionID( + fmt.Sprintf("%s:%s", providerConfig.ID, model.ID), + ), + ) + + group.Items = append(group.Items, item) if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model + selectedItemID = item.ID() } } + groups = append(groups, group) + addedProviders[providerID] = true } } @@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { name = string(provider.ID) } - section := commands.NewItemSection(name) - if _, ok := cfg.Providers.Get(string(provider.ID)); ok { + section := list.NewItemSection(name) + if _, ok := cfg.Providers[string(provider.ID)]; ok { section.SetInfo(configured) } - modelItems = append(modelItems, section) + group := list.Group[list.CompletionItem[ModelOption]]{ + Section: section, + } for _, model := range provider.Models { - modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{ + item := list.NewCompletionItem(model.Model, ModelOption{ Provider: provider, Model: model, - })) + }, + list.WithCompletionID( + fmt.Sprintf("%s:%s", provider.ID, model.ID), + ), + ) + group.Items = append(group.Items, item) if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider { - selectIndex = len(modelItems) - 1 // Set the selected index to the current model + selectedItemID = item.ID() } } + groups = append(groups, group) + } + + var cmds []tea.Cmd + + cmd := m.list.SetGroups(groups) + + if cmd != nil { + cmds = append(cmds, cmd) + } + cmd = m.list.SetSelected(selectedItemID) + if cmd != nil { + cmds = append(cmds, cmd) } - return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex)) + return tea.Sequence(cmds...) } // GetModelType returns the current model type @@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int { } func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { - m.list.SetFilterPlaceholder(placeholder) + m.list.SetInputPlaceholder(placeholder) } func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) { diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 795e2585760391bcd711491533a156f9b2c810ba..e09b040a52ebf911ceefc455b0892c7c9ceba754 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -10,10 +10,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -71,22 +70,16 @@ type modelDialogCmp struct { } func NewModelDialogCmp() ModelDialog { - listKeyMap := list.DefaultKeyMap() keyMap := DefaultKeyMap() + listKeyMap := list.DefaultKeyMap() listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous t := styles.CurrentTheme() - inputStyle := t.S().Base.Padding(0, 1, 0, 1) - modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks") + modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true) apiKeyInput := NewAPIKeyInput() apiKeyInput.SetShowTitle(false) help := help.New() @@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) } // Normal model selection - selectedItemInx := m.modelList.SelectedIndex() - if selectedItemInx == list.NoSelection { - return m, nil - } - items := m.modelList.Items() - selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption) + selectedItem := m.modelList.SelectedModel() var modelType config.SelectedModelType if m.modelList.GetModelType() == LargeModelType { @@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // Provider not configured, show API key input m.needsAPIKey = true - m.selectedModel = &selectedItem + m.selectedModel = selectedItem m.selectedModelType = modelType m.apiKeyInput.SetProviderName(selectedItem.Provider.Name) return m, nil @@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style { } func (m *modelDialogCmp) listWidth() int { - return defaultWidth - 2 // 4 for padding + return m.width - 2 } func (m *modelDialogCmp) listHeight() int { - items := m.modelList.Items() - listHeigh := len(items) + 2 + 4 - return min(listHeigh, m.wHeight/2) + return m.wHeight / 2 } func (m *modelDialogCmp) Position() (int, int) { diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index 7822256d9afb5b8583144142acb55ea3ec287483..4e5cbdef7fdb42f4c667de7ac5bdd5066e7be4df 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -47,7 +47,7 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD items := make([]list.CompletionItem[session.Session], len(sessions)) if len(sessions) > 0 { for i, session := range sessions { - items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID)) + items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID)) } } diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index cc2d0e1264621b10efd6df03916f5ccd3e70987e..6ef6487e4d04176ed50fe0db16de14f9593e96fb 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -23,6 +23,7 @@ type FilterableList[T FilterableItem] interface { List[T] Cursor() *tea.Cursor SetInputWidth(int) + SetInputPlaceholder(string) } type HasMatchIndexes interface { @@ -30,7 +31,7 @@ type HasMatchIndexes interface { } type filterableOptions struct { - listOptions []listOption + listOptions []ListOption placeholder string inputHidden bool inputWidth int @@ -67,7 +68,7 @@ func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { } } -func WithFilterListOptions(opts ...listOption) filterableListOption { +func WithFilterListOptions(opts ...ListOption) filterableListOption { return func(f *filterableOptions) { f.listOptions = opts } @@ -295,3 +296,7 @@ func (f *filterableList[T]) IsFocused() bool { func (f *filterableList[T]) SetInputWidth(w int) { f.inputWidth = w } + +func (f *filterableList[T]) SetInputPlaceholder(ph string) { + f.placeholder = ph +} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go new file mode 100644 index 0000000000000000000000000000000000000000..c1b885da00f02529cb54dcc4505afe1f34807e38 --- /dev/null +++ b/internal/tui/exp/list/filterable_group.go @@ -0,0 +1,260 @@ +package list + +import ( + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +type FilterableGroupList[T FilterableItem] interface { + GroupedList[T] + Cursor() *tea.Cursor + SetInputWidth(int) + SetInputPlaceholder(string) +} +type filterableGroupList[T FilterableItem] struct { + *groupedList[T] + *filterableOptions + width, height int + groups []Group[T] + // stores all available items + input textinput.Model + inputWidth int + query string +} + +func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] { + t := styles.CurrentTheme() + + f := &filterableGroupList[T]{ + filterableOptions: &filterableOptions{ + inputStyle: t.S().Base, + placeholder: "Type to filter", + }, + } + for _, opt := range opts { + opt(f.filterableOptions) + } + f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T]) + + f.updateKeyMaps() + + if f.inputHidden { + return f + } + + ti := textinput.New() + ti.Placeholder = f.placeholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + f.input = ti + return f +} + +func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // handle movements + case key.Matches(msg, f.keyMap.Down), + key.Matches(msg, f.keyMap.Up), + key.Matches(msg, f.keyMap.DownOneItem), + key.Matches(msg, f.keyMap.UpOneItem), + key.Matches(msg, f.keyMap.HalfPageDown), + key.Matches(msg, f.keyMap.HalfPageUp), + key.Matches(msg, f.keyMap.PageDown), + key.Matches(msg, f.keyMap.PageUp), + key.Matches(msg, f.keyMap.End), + key.Matches(msg, f.keyMap.Home): + u, cmd := f.groupedList.Update(msg) + f.groupedList = u.(*groupedList[T]) + return f, cmd + default: + if !f.inputHidden { + var cmds []tea.Cmd + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + cmds = append(cmds, cmd) + + if f.query != f.input.Value() { + cmd = f.Filter(f.input.Value()) + cmds = append(cmds, cmd) + } + f.query = f.input.Value() + return f, tea.Batch(cmds...) + } + } + } + u, cmd := f.groupedList.Update(msg) + f.groupedList = u.(*groupedList[T]) + return f, cmd +} + +func (f *filterableGroupList[T]) View() string { + if f.inputHidden { + return f.groupedList.View() + } + + return lipgloss.JoinVertical( + lipgloss.Left, + f.inputStyle.Render(f.input.View()), + f.groupedList.View(), + ) +} + +// removes bindings that are used for search +func (f *filterableGroupList[T]) updateKeyMaps() { + alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$") + + removeLettersAndNumbers := func(bindings []string) []string { + var keep []string + for _, b := range bindings { + if len(b) != 1 { + keep = append(keep, b) + continue + } + if b == " " { + continue + } + m := alphanumeric.MatchString(b) + if !m { + keep = append(keep, b) + } + } + return keep + } + + updateBinding := func(binding key.Binding) key.Binding { + newKeys := removeLettersAndNumbers(binding.Keys()) + if len(newKeys) == 0 { + binding.SetEnabled(false) + return binding + } + binding.SetKeys(newKeys...) + return binding + } + + f.keyMap.Down = updateBinding(f.keyMap.Down) + f.keyMap.Up = updateBinding(f.keyMap.Up) + f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) + f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) + f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) + f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) + f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) + f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) + f.keyMap.End = updateBinding(f.keyMap.End) + f.keyMap.Home = updateBinding(f.keyMap.Home) +} + +func (m *filterableGroupList[T]) GetSize() (int, int) { + return m.width, m.height +} + +func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd { + f.width = w + f.height = h + if f.inputHidden { + return f.groupedList.SetSize(w, h) + } + if f.inputWidth == 0 { + f.input.SetWidth(w) + } else { + f.input.SetWidth(f.inputWidth) + } + return f.groupedList.SetSize(w, h-(f.inputHeight())) +} + +func (f *filterableGroupList[T]) inputHeight() int { + return lipgloss.Height(f.inputStyle.Render(f.input.View())) +} + +func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { + var cmds []tea.Cmd + for _, item := range f.items { + if i, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + f.selectedItem = "" + if query == "" { + return f.groupedList.SetGroups(f.groups) + } + + var newGroups []Group[T] + for _, g := range f.groups { + words := make([]string, len(g.Items)) + for i, item := range g.Items { + words[i] = strings.ToLower(item.FilterValue()) + } + + matches := fuzzy.Find(query, words) + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + var matchedItems []T + for _, match := range matches { + item := g.Items[match.Index] + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + if len(matchedItems) > 0 { + newGroups = append(newGroups, Group[T]{ + Section: g.Section, + Items: matchedItems, + }) + } + } + cmds = append(cmds, f.groupedList.SetGroups(newGroups)) + return tea.Batch(cmds...) +} + +func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd { + f.groups = groups + return f.groupedList.SetGroups(groups) +} + +func (f *filterableGroupList[T]) Cursor() *tea.Cursor { + if f.inputHidden { + return nil + } + return f.input.Cursor() +} + +func (f *filterableGroupList[T]) Blur() tea.Cmd { + f.input.Blur() + return f.groupedList.Blur() +} + +func (f *filterableGroupList[T]) Focus() tea.Cmd { + f.input.Focus() + return f.groupedList.Focus() +} + +func (f *filterableGroupList[T]) IsFocused() bool { + return f.groupedList.IsFocused() +} + +func (f *filterableGroupList[T]) SetInputWidth(w int) { + f.inputWidth = w +} + +func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) { + f.placeholder = ph +} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 09020b5b2af7d4255b8e5954a9bcab6220d2848b..13208d393ab1086a48b06ab6e8cfd8a72a849ace 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -1,60 +1,68 @@ package list -// -// func TestFilterableList(t *testing.T) { -// t.Parallel() -// t.Run("should create simple filterable list", func(t *testing.T) { -// t.Parallel() -// items := []FilterableItem{} -// for i := range 5 { -// item := NewFilterableItem(fmt.Sprintf("Item %d", i)) -// items = append(items, item) -// } -// l := NewFilterableList( -// items, -// WithFilterListOptions(WithDirection(Forward)), -// ).(*filterableList[FilterableItem]) -// -// l.SetSize(100, 10) -// cmd := l.Init() -// if cmd != nil { -// cmd() -// } -// -// assert.Equal(t, items[0].ID(), l.selectedItem) -// golden.RequireEqual(t, []byte(l.View())) -// }) -// } -// -// func TestUpdateKeyMap(t *testing.T) { -// t.Parallel() -// l := NewFilterableList( -// []FilterableItem{}, -// WithFilterListOptions(WithDirection(Forward)), -// ).(*filterableList[FilterableItem]) -// -// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") -// fmt.Println(l.keyMap.Down.Keys()) -// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") -// -// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") -// -// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") -// assert.False(t, hasJ, "should not contain j") -// assert.False(t, hasUpperCaseK, "should also remove upper case K") -// assert.True(t, hasCtrlJ, "should still have ctrl+j") -// } -// -// type filterableItem struct { -// *selectableItem -// } -// -// func NewFilterableItem(content string) FilterableItem { -// return &filterableItem{ -// selectableItem: NewSelectableItem(content).(*selectableItem), -// } -// } -// -// func (f *filterableItem) FilterValue() string { -// return f.content -// } +import ( + "fmt" + "slices" + "testing" + + "github.com/charmbracelet/x/exp/golden" + "github.com/stretchr/testify/assert" +) + +func TestFilterableList(t *testing.T) { + t.Parallel() + t.Run("should create simple filterable list", func(t *testing.T) { + t.Parallel() + items := []FilterableItem{} + for i := range 5 { + item := NewFilterableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := NewFilterableList( + items, + WithFilterListOptions(WithDirectionForward()), + ).(*filterableList[FilterableItem]) + + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + assert.Equal(t, items[0].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +func TestUpdateKeyMap(t *testing.T) { + t.Parallel() + l := NewFilterableList( + []FilterableItem{}, + WithFilterListOptions(WithDirectionForward()), + ).(*filterableList[FilterableItem]) + + hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") + fmt.Println(l.keyMap.Down.Keys()) + hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") + + hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") + + assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") + assert.False(t, hasJ, "should not contain j") + assert.False(t, hasUpperCaseK, "should also remove upper case K") + assert.True(t, hasCtrlJ, "should still have ctrl+j") +} + +type filterableItem struct { + *selectableItem +} + +func NewFilterableItem(content string) FilterableItem { + return &filterableItem{ + selectableItem: NewSelectableItem(content).(*selectableItem), + } +} + +func (f *filterableItem) FilterValue() string { + return f.content +} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go new file mode 100644 index 0000000000000000000000000000000000000000..74f58ca13cddf797dccc6a02baa0fab1b6e0c952 --- /dev/null +++ b/internal/tui/exp/list/grouped.go @@ -0,0 +1,99 @@ +package list + +import ( + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/util" +) + +type Group[T Item] struct { + Section ItemSection + Items []T +} +type GroupedList[T Item] interface { + util.Model + layout.Sizeable + Items() []Item + Groups() []Group[T] + SetGroups([]Group[T]) tea.Cmd + MoveUp(int) tea.Cmd + MoveDown(int) tea.Cmd + GoToTop() tea.Cmd + GoToBottom() tea.Cmd + SelectItemAbove() tea.Cmd + SelectItemBelow() tea.Cmd + SetSelected(string) tea.Cmd + SelectedItem() *T +} +type groupedList[T Item] struct { + *list[Item] + groups []Group[T] +} + +func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] { + list := &list[Item]{ + confOptions: &confOptions{ + direction: DirectionForward, + keyMap: DefaultKeyMap(), + focused: true, + }, + indexMap: make(map[string]int), + renderedItems: map[string]renderedItem{}, + } + for _, opt := range opts { + opt(list.confOptions) + } + + return &groupedList[T]{ + list: list, + } +} + +func (g *groupedList[T]) Init() tea.Cmd { + g.convertItems() + return g.render() +} + +func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + u, cmd := l.list.Update(msg) + l.list = u.(*list[Item]) + return l, cmd +} + +func (g *groupedList[T]) SelectedItem() *T { + item := g.list.SelectedItem() + if item == nil { + return nil + } + dRef := *item + c, ok := any(dRef).(T) + if !ok { + return nil + } + return &c +} + +func (g *groupedList[T]) convertItems() { + var items []Item + for _, g := range g.groups { + items = append(items, g.Section) + for _, g := range g.Items { + items = append(items, g) + } + } + g.items = items +} + +func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { + g.groups = groups + g.convertItems() + return g.SetItems(g.items) +} + +func (g *groupedList[T]) Groups() []Group[T] { + return g.groups +} + +func (g *groupedList[T]) Items() []Item { + return g.list.Items() +} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go index 005b72048a5962559e1bac202a17c8297757c746..1c09e402352b0d354f01f551279c198c387042a0 100644 --- a/internal/tui/exp/list/items.go +++ b/internal/tui/exp/list/items.go @@ -4,6 +4,7 @@ import ( "image/color" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/lipgloss/v2" @@ -12,6 +13,10 @@ import ( "github.com/rivo/uniseg" ) +type Indexable interface { + SetIndex(int) +} + type CompletionItem[T any] interface { FilterableItem layout.Focusable @@ -39,33 +44,33 @@ type options struct { shortcut string } -type completionOption func(*options) +type CompletionItemOption func(*options) -func WithBackgroundColor(c color.Color) completionOption { +func WithCompletionBackgroundColor(c color.Color) CompletionItemOption { return func(cmp *options) { cmp.bgColor = c } } -func WithMatchIndexes(indexes ...int) completionOption { +func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption { return func(cmp *options) { cmp.matchIndexes = indexes } } -func WithShortcut(shortcut string) completionOption { +func WithCompletionShortcut(shortcut string) CompletionItemOption { return func(cmp *options) { cmp.shortcut = shortcut } } -func WithID(id string) completionOption { +func WithCompletionID(id string) CompletionItemOption { return func(cmp *options) { cmp.id = id } } -func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] { +func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] { c := &completionItemCmp[T]{ text: text, value: value, @@ -306,3 +311,75 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { func (c *completionItemCmp[T]) ID() string { return c.id } + +type ItemSection interface { + Item + layout.Sizeable + Indexable + SetInfo(info string) +} +type itemSectionModel struct { + width int + title string + inx int + info string +} + +// ID implements ItemSection. +func (m *itemSectionModel) ID() string { + return uuid.NewString() +} + +func NewItemSection(title string) ItemSection { + return &itemSectionModel{ + title: title, + inx: -1, + } +} + +func (m *itemSectionModel) Init() tea.Cmd { + return nil +} + +func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m *itemSectionModel) View() string { + t := styles.CurrentTheme() + title := ansi.Truncate(m.title, m.width-2, "…") + style := t.S().Base.Padding(1, 1, 0, 1) + if m.inx == 0 { + style = style.Padding(0, 1, 0, 1) + } + title = t.S().Muted.Render(title) + section := "" + if m.info != "" { + section = core.SectionWithInfo(title, m.width-2, m.info) + } else { + section = core.Section(title, m.width-2) + } + + return style.Render(section) +} + +func (m *itemSectionModel) GetSize() (int, int) { + return m.width, 1 +} + +func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { + m.width = width + return nil +} + +func (m *itemSectionModel) IsSectionHeader() bool { + return true +} + +func (m *itemSectionModel) SetInfo(info string) { + m.info = info +} + +func (m *itemSectionModel) SetIndex(inx int) { + m.inx = inx +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index afcf0a2bc9c1148b559d878445d8be169ca6ea9f..c4ad464b8755394b75cd3e2e5512592bd45a9868 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" ) @@ -22,30 +23,29 @@ type HasAnim interface { Item Spinning() bool } -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 renderedMsg struct{} + +type 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 @@ -76,6 +76,7 @@ type confOptions struct { direction direction selectedItem string focused bool + resize bool } type list[T Item] struct { @@ -93,10 +94,10 @@ type list[T Item] struct { movingByItem bool } -type listOption func(*confOptions) +type ListOption func(*confOptions) // WithSize sets the size of the list. -func WithSize(width, height int) listOption { +func WithSize(width, height int) ListOption { return func(l *confOptions) { l.width = width l.height = height @@ -104,52 +105,58 @@ func WithSize(width, height int) listOption { } // WithGap sets the gap between items in the list. -func WithGap(gap int) listOption { +func WithGap(gap int) ListOption { return func(l *confOptions) { l.gap = gap } } // WithDirectionForward sets the direction to forward -func WithDirectionForward() listOption { +func WithDirectionForward() ListOption { return func(l *confOptions) { l.direction = DirectionForward } } // WithDirectionBackward sets the direction to forward -func WithDirectionBackward() listOption { +func WithDirectionBackward() ListOption { return func(l *confOptions) { l.direction = DirectionBackward } } // WithSelectedItem sets the initially selected item in the list. -func WithSelectedItem(id string) listOption { +func WithSelectedItem(id string) ListOption { return func(l *confOptions) { l.selectedItem = id } } -func WithKeyMap(keyMap KeyMap) listOption { +func WithKeyMap(keyMap KeyMap) ListOption { return func(l *confOptions) { l.keyMap = keyMap } } -func WithWrapNavigation() listOption { +func WithWrapNavigation() ListOption { return func(l *confOptions) { l.wrap = true } } -func WithFocus(focus bool) listOption { +func WithFocus(focus bool) ListOption { return func(l *confOptions) { l.focused = focus } } -func New[T Item](items []T, opts ...listOption) List[T] { +func WithResizeByList() ListOption { + return func(l *confOptions) { + l.resize = true + } +} + +func New[T Item](items []T, opts ...ListOption) List[T] { list := &list[T]{ confOptions: &confOptions{ direction: DirectionForward, @@ -165,6 +172,9 @@ func New[T Item](items []T, opts ...listOption) List[T] { } for inx, item := range items { + if i, ok := any(item).(Indexable); ok { + i.SetIndex(inx) + } list.indexMap[item.ID()] = inx } return list @@ -224,6 +234,7 @@ func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { return "" } + t := styles.CurrentTheme() view := l.rendered lines := strings.Split(view, "\n") @@ -231,7 +242,13 @@ func (l *list[T]) View() string { viewStart := max(0, start) viewEnd := min(len(lines), end+1) lines = lines[viewStart:viewEnd] - return strings.Join(lines, "\n") + if l.resize { + return strings.Join(lines, "\n") + } + return t.S().Base. + Height(l.height). + Width(l.width). + Render(strings.Join(lines, "\n")) } func (l *list[T]) viewPosition() (int, int) { @@ -774,10 +791,26 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { // no item above return nil } + var cmds []tea.Cmd + if newIndex == 1 { + peakAboveIndex := l.firstSelectableItemAbove(newIndex) + if peakAboveIndex == ItemNotFound { + // this means there is a section above move to the top + cmd := l.GoToTop() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + + } item := l.items[newIndex] l.selectedItem = item.ID() l.movingByItem = true - return l.render() + renderCmd := l.render() + if renderCmd != nil { + cmds = append(cmds, renderCmd) + } + return tea.Sequence(cmds...) } // SelectItemBelow implements List. @@ -815,10 +848,13 @@ func (l *list[T]) SelectedItem() *T { func (l *list[T]) SetItems(items []T) tea.Cmd { l.items = items var cmds []tea.Cmd - for _, item := range l.items { + for inx, item := range l.items { + if i, ok := any(item).(Indexable); ok { + i.SetIndex(inx) + } cmds = append(cmds, item.Init()) } - cmds = append(cmds, l.reset()) + cmds = append(cmds, l.reset("")) return tea.Batch(cmds...) } @@ -828,11 +864,11 @@ func (l *list[T]) SetSelected(id string) tea.Cmd { return l.render() } -func (l *list[T]) reset() tea.Cmd { +func (l *list[T]) reset(selectedItem string) tea.Cmd { var cmds []tea.Cmd l.rendered = "" l.offset = 0 - l.selectedItem = "" + l.selectedItem = selectedItem l.indexMap = make(map[string]int) l.renderedItems = make(map[string]renderedItem) for inx, item := range l.items { @@ -851,7 +887,8 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { l.width = width l.height = height if oldWidth != width { - return l.reset() + cmd := l.reset(l.selectedItem) + return cmd } return nil } diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..8aac1155586865e3db5a87839b9d430b419d00ec --- /dev/null +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -0,0 +1,6 @@ +> Type to filter  +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden deleted file mode 100644 index 46269dd405b643eef664dafb388d2001ffacc923..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden deleted file mode 100644 index 828d986cba48a879f1e3e0c7fd9a35b70bacd52e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden deleted file mode 100644 index 6e558d7a093312cf4911bbe3ffc18a6c02583cc6..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden deleted file mode 100644 index 3531c59b4121a3d85effd1e0779742f98b7b1ac7..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden deleted file mode 100644 index f6b9a64ae1d6aea57fe9c014f5d748801c3b04fd..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden deleted file mode 100644 index f81aca7680744374be81be4e15315468d5c3db8c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden deleted file mode 100644 index 03dce1dac791cad0516fd70cfa5bf5d1ec73bee4..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden deleted file mode 100644 index 77d3450cede66562f85e422c7c4199240231f11b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file From 20d16fd2acf04c10c9eca972be1eafe44b25c6c2 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 20:52:08 +0200 Subject: [PATCH 12/18] chore: use new list in completions --- crush.json | 9 - go.mod | 1 + go.sum | 2 + .../tui/components/completions/completions.go | 38 +- internal/tui/components/completions/item.go | 282 ---- internal/tui/components/core/list/keys.go | 76 - internal/tui/components/core/list/list.go | 1370 ----------------- .../tui/components/dialogs/commands/item.go | 69 - internal/tui/exp/list/filterable.go | 6 + 9 files changed, 32 insertions(+), 1821 deletions(-) delete mode 100644 internal/tui/components/completions/item.go delete mode 100644 internal/tui/components/core/list/keys.go delete mode 100644 internal/tui/components/core/list/list.go delete mode 100644 internal/tui/components/dialogs/commands/item.go diff --git a/crush.json b/crush.json index 1857ae8fe1bc925326aeccb1a0ceb26362f1f062..1b04ea6c24f8b64a3a12ceb47551f3177fa66302 100644 --- a/crush.json +++ b/crush.json @@ -3,14 +3,5 @@ "Go": { "command": "gopls" } - }, - "mcp": { - "linear": { - "type": "stdio", - "command": "mcp-remote", - "args": [ - "https://mcp.linear.app/sse" - ] - } } } diff --git a/go.mod b/go.mod index 1f24cb1d27dc197ff662d1b4caf3a4aadf828cb9..ddccaeb96d8b91445485a7a431d2d9fa0c8d6740 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac github.com/charmbracelet/catwalk v0.3.1 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112 diff --git a/go.sum b/go.sum index 755edeb81ead60da60196e2834c9e6354af168b7..3eabe5e49378bcf9696d33f7b54bcf33f580aa6e 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6a github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw= github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U= github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0= diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 6c63afd22e982e5ba40f5d175fc71449bcd0879e..8ff4f7e8aa26df53d23f396a7c900e5dd9752846 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -5,7 +5,7 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/list" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -50,6 +50,8 @@ type Completions interface { Height() int } +type listModel = list.FilterableList[list.CompletionItem[any]] + type completionsCmp struct { width int height int // Height of the completions component` @@ -58,7 +60,7 @@ type completionsCmp struct { open bool // Indicates if the completions are open keyMap KeyMap - list list.ListModel + list listModel query string // The current filter query } @@ -76,10 +78,13 @@ func New() Completions { keyMap.UpOneItem = completionsKeyMap.Up keyMap.DownOneItem = completionsKeyMap.Down - l := list.New( - list.WithReverse(true), - list.WithKeyMap(keyMap), - list.WithHideFilterInput(true), + l := list.NewFilterableList( + []list.CompletionItem[any]{}, + list.WithFilterInputHidden(), + list.WithFilterListOptions( + list.WithDirectionBackward(), + list.WithKeyMap(keyMap), + ), ) return &completionsCmp{ width: 0, @@ -109,12 +114,12 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, c.keyMap.Up): u, cmd := c.list.Update(msg) - c.list = u.(list.ListModel) + c.list = u.(listModel) return c, cmd case key.Matches(msg, c.keyMap.Down): d, cmd := c.list.Update(msg) - c.list = d.(list.ListModel) + c.list = d.(listModel) return c, cmd case key.Matches(msg, c.keyMap.UpInsert): selectedItemInx := c.list.SelectedIndex() - 1 @@ -141,12 +146,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Insert: true, }) case key.Matches(msg, c.keyMap.Select): - selectedItemInx := c.list.SelectedIndex() - if selectedItemInx == list.NoSelection { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - items := c.list.Items() - selectedItem := items[selectedItemInx].(CompletionItem).Value() + selectedItem := *s c.open = false // Close completions after selection return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, @@ -162,10 +166,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.query = "" c.x = msg.X c.y = msg.Y - items := []util.Model{} + items := []list.CompletionItem[any]{} t := styles.CurrentTheme() for _, completion := range msg.Completions { - item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) + item := list.NewCompletionItem( + completion.Title, + completion.Value, + list.WithCompletionBackgroundColor(t.BgSubtle), + ) items = append(items, item) } c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go deleted file mode 100644 index 414ad94b9ffaae3792f80169feb4cdfff9a71d64..0000000000000000000000000000000000000000 --- a/internal/tui/components/completions/item.go +++ /dev/null @@ -1,282 +0,0 @@ -package completions - -import ( - "image/color" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/rivo/uniseg" -) - -type CompletionItem interface { - util.Model - layout.Focusable - layout.Sizeable - list.HasMatchIndexes - list.HasFilterValue - Value() any -} - -type completionItemCmp struct { - width int - text string - value any - focus bool - matchIndexes []int - bgColor color.Color - shortcut string -} - -type CompletionOption func(*completionItemCmp) - -func WithBackgroundColor(c color.Color) CompletionOption { - return func(cmp *completionItemCmp) { - cmp.bgColor = c - } -} - -func WithMatchIndexes(indexes ...int) CompletionOption { - return func(cmp *completionItemCmp) { - cmp.matchIndexes = indexes - } -} - -func WithShortcut(shortcut string) CompletionOption { - return func(cmp *completionItemCmp) { - cmp.shortcut = shortcut - } -} - -func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem { - c := &completionItemCmp{ - text: text, - value: value, - } - - for _, opt := range opts { - opt(c) - } - return c -} - -// Init implements CommandItem. -func (c *completionItemCmp) Init() tea.Cmd { - return nil -} - -// Update implements CommandItem. -func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { - return c, nil -} - -// View implements CommandItem. -func (c *completionItemCmp) View() string { - t := styles.CurrentTheme() - - itemStyle := t.S().Base.Padding(0, 1).Width(c.width) - innerWidth := c.width - 2 // Account for padding - - if c.shortcut != "" { - innerWidth -= lipgloss.Width(c.shortcut) - } - - titleStyle := t.S().Text.Width(innerWidth) - titleMatchStyle := t.S().Text.Underline(true) - if c.bgColor != nil { - titleStyle = titleStyle.Background(c.bgColor) - titleMatchStyle = titleMatchStyle.Background(c.bgColor) - itemStyle = itemStyle.Background(c.bgColor) - } - - if c.focus { - titleStyle = t.S().TextSelected.Width(innerWidth) - titleMatchStyle = t.S().TextSelected.Underline(true) - itemStyle = itemStyle.Background(t.Primary) - } - - var truncatedTitle string - - if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { - // Smart truncation: ensure the last matching part is visible - truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) - } else { - // No matches, use regular truncation - truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") - } - - text := titleStyle.Render(truncatedTitle) - if len(c.matchIndexes) > 0 { - var ranges []lipgloss.Range - for _, rng := range matchedRanges(c.matchIndexes) { - // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. - // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. - // so we need to adjust it here: - start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) - ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) - } - text = lipgloss.StyleRanges(text, ranges...) - } - parts := []string{text} - if c.shortcut != "" { - // Add the shortcut at the end - shortcutStyle := t.S().Muted - if c.focus { - shortcutStyle = t.S().TextSelected - } - parts = append(parts, shortcutStyle.Render(c.shortcut)) - } - item := itemStyle.Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - parts..., - ), - ) - return item -} - -// Blur implements CommandItem. -func (c *completionItemCmp) Blur() tea.Cmd { - c.focus = false - return nil -} - -// Focus implements CommandItem. -func (c *completionItemCmp) Focus() tea.Cmd { - c.focus = true - return nil -} - -// GetSize implements CommandItem. -func (c *completionItemCmp) GetSize() (int, int) { - return c.width, 1 -} - -// IsFocused implements CommandItem. -func (c *completionItemCmp) IsFocused() bool { - return c.focus -} - -// SetSize implements CommandItem. -func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd { - c.width = width - return nil -} - -func (c *completionItemCmp) MatchIndexes(indexes []int) { - c.matchIndexes = indexes -} - -func (c *completionItemCmp) FilterValue() string { - return c.text -} - -func (c *completionItemCmp) Value() any { - return c.value -} - -// smartTruncate implements fzf-style truncation that ensures the last matching part is visible -func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string { - if width <= 0 { - return "" - } - - textLen := ansi.StringWidth(text) - if textLen <= width { - return text - } - - if len(matchIndexes) == 0 { - return ansi.Truncate(text, width, "…") - } - - // Find the last match position - lastMatchPos := matchIndexes[len(matchIndexes)-1] - - // Convert byte position to visual width position - lastMatchVisualPos := 0 - bytePos := 0 - gr := uniseg.NewGraphemes(text) - for bytePos < lastMatchPos && gr.Next() { - bytePos += len(gr.Str()) - lastMatchVisualPos += max(1, gr.Width()) - } - - // Calculate how much space we need for the ellipsis - ellipsisWidth := 1 // "…" character width - availableWidth := width - ellipsisWidth - - // If the last match is within the available width, truncate from the end - if lastMatchVisualPos < availableWidth { - return ansi.Truncate(text, width, "…") - } - - // Calculate the start position to ensure the last match is visible - // We want to show some context before the last match if possible - startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) - - // Convert visual position back to byte position - startBytePos := 0 - currentVisualPos := 0 - gr = uniseg.NewGraphemes(text) - for currentVisualPos < startVisualPos && gr.Next() { - startBytePos += len(gr.Str()) - currentVisualPos += max(1, gr.Width()) - } - - // Extract the substring starting from startBytePos - truncatedText := text[startBytePos:] - - // Truncate to fit width with ellipsis - truncatedText = ansi.Truncate(truncatedText, availableWidth, "") - truncatedText = "…" + truncatedText - return truncatedText -} - -func matchedRanges(in []int) [][2]int { - if len(in) == 0 { - return [][2]int{} - } - current := [2]int{in[0], in[0]} - if len(in) == 1 { - return [][2]int{current} - } - var out [][2]int - for i := 1; i < len(in); i++ { - if in[i] == current[1]+1 { - current[1] = in[i] - } else { - out = append(out, current) - current = [2]int{in[i], in[i]} - } - } - out = append(out, current) - return out -} - -func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { - bytePos, byteStart, byteStop := 0, rng[0], rng[1] - pos, start, stop := 0, 0, 0 - gr := uniseg.NewGraphemes(str) - for byteStart > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - start = pos - for byteStop > bytePos { - if !gr.Next() { - break - } - bytePos += len(gr.Str()) - pos += max(1, gr.Width()) - } - stop = pos - return start, stop -} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go deleted file mode 100644 index fb0f461d810b74039ad466bfc5ade6e4be36d56f..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/list/keys.go +++ /dev/null @@ -1,76 +0,0 @@ -package list - -import ( - "github.com/charmbracelet/bubbles/v2/key" -) - -type KeyMap struct { - Down, - Up, - DownOneItem, - UpOneItem, - PageDown, - PageUp, - HalfPageDown, - HalfPageUp, - Home, - End key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - Down: key.NewBinding( - key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), - key.WithHelp("↓", "down"), - ), - Up: key.NewBinding( - key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), - key.WithHelp("↑", "up"), - ), - UpOneItem: key.NewBinding( - key.WithKeys("shift+up", "K"), - key.WithHelp("shift+↑", "up one item"), - ), - DownOneItem: key.NewBinding( - key.WithKeys("shift+down", "J"), - key.WithHelp("shift+↓", "down one item"), - ), - HalfPageDown: key.NewBinding( - key.WithKeys("d"), - key.WithHelp("d", "half page down"), - ), - PageDown: key.NewBinding( - key.WithKeys("pgdown", " ", "f"), - key.WithHelp("f/pgdn", "page down"), - ), - PageUp: key.NewBinding( - key.WithKeys("pgup", "b"), - key.WithHelp("b/pgup", "page up"), - ), HalfPageUp: key.NewBinding( - key.WithKeys("u"), - key.WithHelp("u", "half page up"), - ), - Home: key.NewBinding( - key.WithKeys("g", "home"), - key.WithHelp("g", "home"), - ), - End: key.NewBinding( - key.WithKeys("G", "end"), - key.WithHelp("G", "end"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.Down, - k.Up, - k.DownOneItem, - k.UpOneItem, - k.HalfPageDown, - k.HalfPageUp, - k.Home, - k.End, - } -} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go deleted file mode 100644 index c6b0a8b1590792b20e05eb7a834b219bd1c00c10..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/list/list.go +++ /dev/null @@ -1,1370 +0,0 @@ -package list - -import ( - "slices" - "sort" - "strings" - - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/anim" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/lipgloss/v2" - "github.com/sahilm/fuzzy" -) - -// Constants for special index values and defaults -const ( - NoSelection = -1 // Indicates no item is currently selected - NotRendered = -1 // Indicates an item hasn't been rendered yet - NoFinalHeight = -1 // Indicates final height hasn't been calculated - DefaultGapSize = 0 // Default spacing between list items -) - -// ListModel defines the interface for a scrollable, selectable list component. -// It combines the basic Model interface with sizing capabilities and list-specific operations. -type ListModel interface { - util.Model - layout.Sizeable - layout.Focusable - SetItems([]util.Model) tea.Cmd // Replace all items in the list - AppendItem(util.Model) tea.Cmd // Add an item to the end of the list - PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list - DeleteItem(int) // Remove an item at the specified index - UpdateItem(int, util.Model) // Replace an item at the specified index - ResetView() // Clear rendering cache and reset scroll position - Items() []util.Model // Get all items in the list - SelectedIndex() int // Get the index of the currently selected item - SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it - Filter(string) tea.Cmd // Filter items based on a search term - SetFilterPlaceholder(string) // Set the placeholder text for the filter input - Cursor() *tea.Cursor // Get the current cursor position in the filter input -} - -// HasAnim interface identifies items that support animation. -// Items implementing this interface will receive animation update messages. -type HasAnim interface { - util.Model - Spinning() bool // Returns true if the item is currently animating -} - -// HasFilterValue interface allows items to provide a filter value for searching. -type HasFilterValue interface { - FilterValue() string // Returns a string value used for filtering/searching -} - -// HasMatchIndexes interface allows items to set matched character indexes. -type HasMatchIndexes interface { - MatchIndexes([]int) // Sets the indexes of matched characters in the item's content -} - -// SectionHeader interface identifies items that are section headers. -// Section headers are rendered differently and are skipped during navigation. -type SectionHeader interface { - util.Model - IsSectionHeader() bool // Returns true if this item is a section header -} - -// renderedItem represents a cached rendered item with its position and content. -type renderedItem struct { - lines []string // The rendered lines of text for this item - start int // Starting line position in the overall rendered content - height int // Number of lines this item occupies -} - -// renderState manages the rendering cache and state for the list. -// It tracks which items have been rendered and their positions. -type renderState struct { - items map[int]renderedItem // Cache of rendered items by index - lines []string // All rendered lines concatenated - lastIndex int // Index of the last rendered item - finalHeight int // Total height when all items are rendered - needsRerender bool // Flag indicating if re-rendering is needed -} - -// newRenderState creates a new render state with default values. -func newRenderState() *renderState { - return &renderState{ - items: make(map[int]renderedItem), - lines: []string{}, - lastIndex: NotRendered, - finalHeight: NoFinalHeight, - needsRerender: true, - } -} - -// reset clears all cached rendering data and resets state to initial values. -func (rs *renderState) reset() { - rs.items = make(map[int]renderedItem) - rs.lines = []string{} - rs.lastIndex = NotRendered - rs.finalHeight = NoFinalHeight - rs.needsRerender = true -} - -// viewState manages the visual display properties of the list. -type viewState struct { - width, height int // Dimensions of the list viewport - offset int // Current scroll offset in lines - reverse bool // Whether to render in reverse order (bottom-up) - content string // The final rendered content to display -} - -// selectionState manages which item is currently selected. -type selectionState struct { - selectedIndex int // Index of the currently selected item, or NoSelection -} - -// isValidIndex checks if the selected index is within the valid range of items. -func (ss *selectionState) isValidIndex(itemCount int) bool { - return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount -} - -// model is the main implementation of the ListModel interface. -// It coordinates between view state, render state, and selection state. -type model struct { - viewState viewState // Display and scrolling state - renderState *renderState // Rendering cache and state - selectionState selectionState // Item selection state - help help.Model // Help system for keyboard shortcuts - keyMap KeyMap // Key bindings for navigation - allItems []util.Model // The actual list items - gapSize int // Number of empty lines between items - padding []int // Padding around the list content - wrapNavigation bool // Whether to wrap navigation at the ends - - filterable bool // Whether items can be filtered - filterPlaceholder string // Placeholder text for filter input - filteredItems []util.Model // Filtered items based on current search - input textinput.Model // Input field for filtering items - inputStyle lipgloss.Style // Style for the input field - hideFilterInput bool // Whether to hide the filter input field - currentSearch string // Current search term for filtering - - isFocused bool // Whether the list is currently focused -} - -// listOptions is a function type for configuring list options. -type listOptions func(*model) - -// WithKeyMap sets custom key bindings for the list. -func WithKeyMap(k KeyMap) listOptions { - return func(m *model) { - m.keyMap = k - } -} - -// WithReverse sets whether the list should render in reverse order (newest items at bottom). -func WithReverse(reverse bool) listOptions { - return func(m *model) { - m.setReverse(reverse) - } -} - -// WithGapSize sets the number of empty lines to insert between list items. -func WithGapSize(gapSize int) listOptions { - return func(m *model) { - m.gapSize = gapSize - } -} - -// WithPadding sets the padding around the list content. -// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal, -// 4 values = top/right/bottom/left. -func WithPadding(padding ...int) listOptions { - return func(m *model) { - m.padding = padding - } -} - -// WithItems sets the initial items for the list. -func WithItems(items []util.Model) listOptions { - return func(m *model) { - m.allItems = items - m.filteredItems = items // Initially, all items are visible - } -} - -// WithFilterable enables filtering of items based on their FilterValue. -func WithFilterable(filterable bool) listOptions { - return func(m *model) { - m.filterable = filterable - } -} - -// WithHideFilterInput hides the filter input field. -func WithHideFilterInput(hide bool) listOptions { - return func(m *model) { - m.hideFilterInput = hide - } -} - -// WithFilterPlaceholder sets the placeholder text for the filter input field. -func WithFilterPlaceholder(placeholder string) listOptions { - return func(m *model) { - m.filterPlaceholder = placeholder - } -} - -// WithInputStyle sets the style for the filter input field. -func WithInputStyle(style lipgloss.Style) listOptions { - return func(m *model) { - m.inputStyle = style - } -} - -// WithWrapNavigation enables wrapping navigation at the ends of the list. -func WithWrapNavigation(wrap bool) listOptions { - return func(m *model) { - m.wrapNavigation = wrap - } -} - -// New creates a new list model with the specified options. -// The list starts with no items selected and requires SetItems to be called -// or items to be provided via WithItems option. -func New(opts ...listOptions) ListModel { - t := styles.CurrentTheme() - - m := &model{ - help: help.New(), - keyMap: DefaultKeyMap(), - allItems: []util.Model{}, - filteredItems: []util.Model{}, - renderState: newRenderState(), - gapSize: DefaultGapSize, - padding: []int{}, - selectionState: selectionState{selectedIndex: NoSelection}, - filterPlaceholder: "Type to filter...", - inputStyle: t.S().Base.Padding(0, 1, 1, 1), - isFocused: true, - } - for _, opt := range opts { - opt(m) - } - - if m.filterable && !m.hideFilterInput { - ti := textinput.New() - ti.Placeholder = m.filterPlaceholder - ti.SetVirtualCursor(false) - ti.Focus() - ti.SetStyles(t.S().TextInput) - m.input = ti - } - return m -} - -// Init initializes the list component and sets up the initial items. -// This is called automatically by the Bubble Tea framework. -func (m *model) Init() tea.Cmd { - return m.SetItems(m.filteredItems) -} - -// Update handles incoming messages and updates the list state accordingly. -// It processes keyboard input, animation messages, and forwards other messages -// to the currently selected item. -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - return m.handleKeyPress(msg) - case anim.StepMsg: - return m.handleAnimationMsg(msg) - } - if m.selectionState.isValidIndex(len(m.filteredItems)) { - return m.updateSelectedItem(msg) - } - - return m, nil -} - -// Cursor returns the current cursor position in the input field. -func (m *model) Cursor() *tea.Cursor { - if m.filterable && !m.hideFilterInput { - return m.input.Cursor() - } - return nil -} - -// View renders the list to a string for display. -// Returns empty string if the list has no dimensions. -// Triggers re-rendering if needed before returning content. -func (m *model) View() string { - if m.viewState.height == 0 || m.viewState.width == 0 { - return "" // No content to display - } - if m.renderState.needsRerender { - m.renderVisible() - } - - content := lipgloss.NewStyle(). - Padding(m.padding...). - Height(m.viewState.height). - Render(m.viewState.content) - - if m.filterable && !m.hideFilterInput { - content = lipgloss.JoinVertical( - lipgloss.Left, - m.inputStyle.Render(m.input.View()), - content, - ) - } - return content -} - -// handleKeyPress processes keyboard input for list navigation. -// Supports scrolling, item selection, and navigation to top/bottom. -func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - switch { - case key.Matches(msg, m.keyMap.Down): - m.scrollDown(1) - case key.Matches(msg, m.keyMap.Up): - m.scrollUp(1) - case key.Matches(msg, m.keyMap.DownOneItem): - return m, m.selectNextItem() - case key.Matches(msg, m.keyMap.UpOneItem): - return m, m.selectPreviousItem() - case key.Matches(msg, m.keyMap.HalfPageDown): - m.scrollDown(m.listHeight() / 2) - case key.Matches(msg, m.keyMap.HalfPageUp): - m.scrollUp(m.listHeight() / 2) - case key.Matches(msg, m.keyMap.PageDown): - m.scrollDown(m.listHeight()) - case key.Matches(msg, m.keyMap.PageUp): - m.scrollUp(m.listHeight()) - case key.Matches(msg, m.keyMap.Home): - return m, m.goToTop() - case key.Matches(msg, m.keyMap.End): - return m, m.goToBottom() - default: - if !m.filterable || m.hideFilterInput { - return m, nil // Ignore other keys if not filterable or input is hidden - } - var cmds []tea.Cmd - u, cmd := m.input.Update(msg) - m.input = u - cmds = append(cmds, cmd) - if m.currentSearch != m.input.Value() { - cmd = m.Filter(m.input.Value()) - cmds = append(cmds, cmd) - } - m.currentSearch = m.input.Value() - return m, tea.Batch(cmds...) - } - return m, nil -} - -// handleAnimationMsg forwards animation messages to items that support animation. -// Only items implementing HasAnim and currently spinning receive these messages. -func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - for inx, item := range m.filteredItems { - if i, ok := item.(HasAnim); ok && i.Spinning() { - updated, cmd := i.Update(msg) - cmds = append(cmds, cmd) - if u, ok := updated.(util.Model); ok { - m.UpdateItem(inx, u) - } - } - } - return m, tea.Batch(cmds...) -} - -// updateSelectedItem forwards messages to the currently selected item. -// This allows the selected item to handle its own input and state changes. -func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg) - cmds = append(cmds, cmd) - if updated, ok := u.(util.Model); ok { - m.UpdateItem(m.selectionState.selectedIndex, updated) - } - return m, tea.Batch(cmds...) -} - -// scrollDown scrolls the list down by the specified amount. -// Direction is automatically adjusted based on reverse mode. -func (m *model) scrollDown(amount int) { - if m.viewState.reverse { - m.decreaseOffset(amount) - } else { - m.increaseOffset(amount) - } -} - -// scrollUp scrolls the list up by the specified amount. -// Direction is automatically adjusted based on reverse mode. -func (m *model) scrollUp(amount int) { - if m.viewState.reverse { - m.increaseOffset(amount) - } else { - m.decreaseOffset(amount) - } -} - -// Items returns a copy of all items in the list. -func (m *model) Items() []util.Model { - return m.filteredItems -} - -// renderVisible determines which rendering strategy to use and triggers rendering. -// Uses forward rendering for normal mode and reverse rendering for reverse mode. -func (m *model) renderVisible() { - if m.viewState.reverse { - m.renderVisibleReverse() - } else { - m.renderVisibleForward() - } -} - -// renderVisibleForward renders items from top to bottom (normal mode). -// Only renders items that are currently visible or near the viewport. -func (m *model) renderVisibleForward() { - renderer := &forwardRenderer{ - model: m, - start: 0, - cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd - items: m.filteredItems, - realIdx: m.renderState.lastIndex, - } - - if m.renderState.lastIndex > NotRendered { - renderer.items = m.filteredItems[m.renderState.lastIndex+1:] - renderer.start = len(m.renderState.lines) - } - - renderer.render() - m.finalizeRender() -} - -// renderVisibleReverse renders items from bottom to top (reverse mode). -// Used when new items should appear at the bottom (like chat messages). -func (m *model) renderVisibleReverse() { - renderer := &reverseRenderer{ - model: m, - start: 0, - cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, - items: m.filteredItems, - realIdx: m.renderState.lastIndex, - } - - if m.renderState.lastIndex > NotRendered { - renderer.items = m.filteredItems[:m.renderState.lastIndex] - renderer.start = len(m.renderState.lines) - } else { - m.renderState.lastIndex = len(m.filteredItems) - renderer.realIdx = len(m.filteredItems) - } - - renderer.render() - m.finalizeRender() -} - -// finalizeRender completes the rendering process by updating scroll bounds and content. -func (m *model) finalizeRender() { - m.renderState.needsRerender = false - if m.renderState.finalHeight > NoFinalHeight { - m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight) - } - m.updateContent() -} - -// updateContent extracts the visible portion of rendered content for display. -// Handles both normal and reverse rendering modes. -func (m *model) updateContent() { - maxHeight := min(m.listHeight(), len(m.renderState.lines)) - if m.viewState.offset >= len(m.renderState.lines) { - m.viewState.content = "" - return - } - - if m.viewState.reverse { - end := len(m.renderState.lines) - m.viewState.offset - start := max(0, end-maxHeight) - m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n") - } else { - endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines)) - m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n") - } -} - -// forwardRenderer handles rendering items from top to bottom. -// It builds up the rendered content incrementally, caching results for performance. -type forwardRenderer struct { - model *model // Reference to the parent list model - start int // Current line position in the overall content - cutoff int // Line position where we can stop rendering - items []util.Model // Items to render (may be a subset) - realIdx int // Real index in the full item list -} - -// render processes items in forward order, building up the rendered content. -func (r *forwardRenderer) render() { - for _, item := range r.items { - r.realIdx++ - if r.start > r.cutoff { - break - } - - itemLines := r.getOrRenderItem(item) - if r.realIdx == len(r.model.filteredItems)-1 { - r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) - } - - r.model.renderState.lines = append(r.model.renderState.lines, itemLines...) - r.model.renderState.lastIndex = r.realIdx - r.start += len(itemLines) - } -} - -// getOrRenderItem retrieves cached content or renders the item if not cached. -func (r *forwardRenderer) getOrRenderItem(item util.Model) []string { - if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { - return cachedContent.lines - } - - itemLines := r.renderItemLines(item) - r.model.renderState.items[r.realIdx] = renderedItem{ - lines: itemLines, - start: r.start, - height: len(itemLines), - } - return itemLines -} - -// renderItemLines converts an item to its string representation with gaps. -func (r *forwardRenderer) renderItemLines(item util.Model) []string { - return r.model.getItemLines(item) -} - -// reverseRenderer handles rendering items from bottom to top. -// Used in reverse mode where new items appear at the bottom. -type reverseRenderer struct { - model *model // Reference to the parent list model - start int // Current line position in the overall content - cutoff int // Line position where we can stop rendering - items []util.Model // Items to render (may be a subset) - realIdx int // Real index in the full item list -} - -// render processes items in reverse order, prepending to the rendered content. -func (r *reverseRenderer) render() { - for i := len(r.items) - 1; i >= 0; i-- { - r.realIdx-- - if r.start > r.cutoff { - break - } - - itemLines := r.getOrRenderItem(r.items[i]) - if r.realIdx == 0 { - r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight()) - } - - r.model.renderState.lines = append(itemLines, r.model.renderState.lines...) - r.model.renderState.lastIndex = r.realIdx - r.start += len(itemLines) - } -} - -// getOrRenderItem retrieves cached content or renders the item if not cached. -func (r *reverseRenderer) getOrRenderItem(item util.Model) []string { - if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok { - return cachedContent.lines - } - - itemLines := r.renderItemLines(item) - r.model.renderState.items[r.realIdx] = renderedItem{ - lines: itemLines, - start: r.start, - height: len(itemLines), - } - return itemLines -} - -// renderItemLines converts an item to its string representation with gaps. -func (r *reverseRenderer) renderItemLines(item util.Model) []string { - return r.model.getItemLines(item) -} - -// selectPreviousItem moves selection to the previous item in the list. -// Handles focus management and ensures the selected item remains visible. -// Skips section headers during navigation. -func (m *model) selectPreviousItem() tea.Cmd { - if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation { - // If at the beginning and wrapping is enabled, go to the last item - return m.goToBottom() - } - if m.selectionState.selectedIndex <= 0 { - return nil - } - - cmds := []tea.Cmd{m.blurSelected()} - m.selectionState.selectedIndex-- - - // Skip section headers - for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) { - m.selectionState.selectedIndex-- - } - - // If we went past the beginning, stay at the first non-header item - if m.selectionState.selectedIndex <= 0 { - cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed - return tea.Batch(cmds...) - } - - cmds = append(cmds, m.focusSelected()) - m.ensureSelectedItemVisible() - return tea.Batch(cmds...) -} - -// selectNextItem moves selection to the next item in the list. -// Handles focus management and ensures the selected item remains visible. -// Skips section headers during navigation. -func (m *model) selectNextItem() tea.Cmd { - if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation { - // If at the end and wrapping is enabled, go to the first item - return m.goToTop() - } - if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 { - return nil - } - - cmds := []tea.Cmd{m.blurSelected()} - m.selectionState.selectedIndex++ - - // Skip section headers - for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) { - m.selectionState.selectedIndex++ - } - - // If we went past the end, stay at the last non-header item - if m.selectionState.selectedIndex >= len(m.filteredItems) { - m.selectionState.selectedIndex = m.findLastSelectableItem() - } - - cmds = append(cmds, m.focusSelected()) - m.ensureSelectedItemVisible() - return tea.Batch(cmds...) -} - -// isSectionHeader checks if the item at the given index is a section header. -func (m *model) isSectionHeader(index int) bool { - if index < 0 || index >= len(m.filteredItems) { - return false - } - if header, ok := m.filteredItems[index].(SectionHeader); ok { - return header.IsSectionHeader() - } - return false -} - -// findFirstSelectableItem finds the first item that is not a section header. -func (m *model) findFirstSelectableItem() int { - for i := range m.filteredItems { - if !m.isSectionHeader(i) { - return i - } - } - return NoSelection -} - -// findLastSelectableItem finds the last item that is not a section header. -func (m *model) findLastSelectableItem() int { - for i := len(m.filteredItems) - 1; i >= 0; i-- { - if !m.isSectionHeader(i) { - return i - } - } - return NoSelection -} - -// ensureSelectedItemVisible scrolls the list to make the selected item visible. -// Uses different strategies for forward and reverse rendering modes. -func (m *model) ensureSelectedItemVisible() { - cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex] - if !ok { - m.renderState.needsRerender = true - return - } - - if m.viewState.reverse { - m.ensureVisibleReverse(cachedItem) - } else { - m.ensureVisibleForward(cachedItem) - } - m.renderState.needsRerender = true -} - -// ensureVisibleForward ensures the selected item is visible in forward rendering mode. -// Handles both large items (taller than viewport) and normal items. -func (m *model) ensureVisibleForward(cachedItem renderedItem) { - if cachedItem.height >= m.listHeight() { - if m.selectionState.selectedIndex > 0 { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } else { - changeNeeded := cachedItem.start - m.viewState.offset - m.increaseOffset(changeNeeded) - } - return - } - - if cachedItem.start < m.viewState.offset { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } else { - end := cachedItem.start + cachedItem.height - if end > m.viewState.offset+m.listHeight() { - changeNeeded := end - (m.viewState.offset + m.listHeight()) - m.increaseOffset(changeNeeded) - } - } -} - -// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode. -// Handles both large items (taller than viewport) and normal items. -func (m *model) ensureVisibleReverse(cachedItem renderedItem) { - if cachedItem.height >= m.listHeight() { - if m.selectionState.selectedIndex < len(m.filteredItems)-1 { - changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight()) - m.decreaseOffset(changeNeeded) - } else { - changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset - m.increaseOffset(changeNeeded) - } - return - } - - if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() { - changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset - m.increaseOffset(changeNeeded) - } else if cachedItem.start < m.viewState.offset { - changeNeeded := m.viewState.offset - cachedItem.start - m.decreaseOffset(changeNeeded) - } -} - -// goToBottom switches to reverse mode and selects the last selectable item. -// Commonly used for chat-like interfaces where new content appears at the bottom. -// Skips section headers when selecting the last item. -func (m *model) goToBottom() tea.Cmd { - cmds := []tea.Cmd{m.blurSelected()} - m.viewState.reverse = true - m.selectionState.selectedIndex = m.findLastSelectableItem() - if m.isFocused { - cmds = append(cmds, m.focusSelected()) - } - m.ResetView() - return tea.Batch(cmds...) -} - -// goToTop switches to forward mode and selects the first selectable item. -// Standard behavior for most list interfaces. -// Skips section headers when selecting the first item. -func (m *model) goToTop() tea.Cmd { - cmds := []tea.Cmd{m.blurSelected()} - m.viewState.reverse = false - m.selectionState.selectedIndex = m.findFirstSelectableItem() - if m.isFocused { - cmds = append(cmds, m.focusSelected()) - } - m.ResetView() - return tea.Batch(cmds...) -} - -// ResetView clears all cached rendering data and resets scroll position. -// Forces a complete re-render on the next View() call. -func (m *model) ResetView() { - m.renderState.reset() - m.viewState.offset = 0 -} - -// focusSelected gives focus to the currently selected item if it supports focus. -// Triggers a re-render of the item to show its focused state. -func (m *model) focusSelected() tea.Cmd { - if !m.isFocused { - return nil // No focus change if the list is not focused - } - if !m.selectionState.isValidIndex(len(m.filteredItems)) { - return nil - } - if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { - cmd := i.Focus() - m.rerenderItem(m.selectionState.selectedIndex) - return cmd - } - return nil -} - -// blurSelected removes focus from the currently selected item if it supports focus. -// Triggers a re-render of the item to show its unfocused state. -func (m *model) blurSelected() tea.Cmd { - if !m.selectionState.isValidIndex(len(m.filteredItems)) { - return nil - } - if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok { - cmd := i.Blur() - m.rerenderItem(m.selectionState.selectedIndex) - return cmd - } - return nil -} - -// rerenderItem updates the cached rendering of a specific item. -// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed. -// It efficiently updates only the changed item and adjusts positions of subsequent items if needed. -func (m *model) rerenderItem(inx int) { - if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 { - return - } - - cachedItem, ok := m.renderState.items[inx] - if !ok { - return - } - - rerenderedLines := m.getItemLines(m.filteredItems[inx]) - if slices.Equal(cachedItem.lines, rerenderedLines) { - return - } - - m.updateRenderedLines(cachedItem, rerenderedLines) - m.updateItemPositions(inx, cachedItem, len(rerenderedLines)) - m.updateCachedItem(inx, cachedItem, rerenderedLines) - m.renderState.needsRerender = true -} - -// getItemLines converts an item to its rendered lines, including any gap spacing. -// Handles section headers with special styling. -func (m *model) getItemLines(item util.Model) []string { - var itemLines []string - - itemLines = strings.Split(item.View(), "\n") - - if m.gapSize > 0 { - gap := make([]string, m.gapSize) - itemLines = append(itemLines, gap...) - } - return itemLines -} - -// updateRenderedLines replaces the lines for a specific item in the overall rendered content. -func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) { - start, end := m.getItemBounds(cachedItem) - totalLines := len(m.renderState.lines) - - if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines { - m.renderState.lines = slices.Delete(m.renderState.lines, start, end) - m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...) - } -} - -// getItemBounds calculates the start and end line positions for an item. -// Handles both forward and reverse rendering modes. -func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) { - start = cachedItem.start - end = start + cachedItem.height - - if m.viewState.reverse { - totalLines := len(m.renderState.lines) - end = totalLines - cachedItem.start - start = end - cachedItem.height - } - return start, end -} - -// updateItemPositions recalculates positions for items after the changed item. -// This is necessary when an item's height changes, affecting subsequent items. -func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) { - if cachedItem.height == newHeight { - return - } - - if inx == len(m.filteredItems)-1 { - m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight()) - } - - currentStart := cachedItem.start + newHeight - if m.viewState.reverse { - m.updatePositionsReverse(inx, currentStart) - } else { - m.updatePositionsForward(inx, currentStart) - } -} - -// updatePositionsForward updates positions for items after the changed item in forward mode. -func (m *model) updatePositionsForward(inx int, currentStart int) { - for i := inx + 1; i < len(m.filteredItems); i++ { - if existing, ok := m.renderState.items[i]; ok { - existing.start = currentStart - currentStart += existing.height - m.renderState.items[i] = existing - } else { - break - } - } -} - -// updatePositionsReverse updates positions for items before the changed item in reverse mode. -func (m *model) updatePositionsReverse(inx int, currentStart int) { - for i := inx - 1; i >= 0; i-- { - if existing, ok := m.renderState.items[i]; ok { - existing.start = currentStart - currentStart += existing.height - m.renderState.items[i] = existing - } else { - break - } - } -} - -// updateCachedItem updates the cached rendering information for a specific item. -func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) { - m.renderState.items[inx] = renderedItem{ - lines: newLines, - start: cachedItem.start, - height: len(newLines), - } -} - -// increaseOffset scrolls the list down by increasing the offset. -// Respects the final height limit to prevent scrolling past the end. -func (m *model) increaseOffset(n int) { - if m.renderState.finalHeight > NoFinalHeight { - if m.viewState.offset < m.renderState.finalHeight { - m.viewState.offset += n - if m.viewState.offset > m.renderState.finalHeight { - m.viewState.offset = m.renderState.finalHeight - } - m.renderState.needsRerender = true - } - } else { - m.viewState.offset += n - m.renderState.needsRerender = true - } -} - -// decreaseOffset scrolls the list up by decreasing the offset. -// Prevents scrolling above the beginning of the list. -func (m *model) decreaseOffset(n int) { - if m.viewState.offset > 0 { - m.viewState.offset -= n - if m.viewState.offset < 0 { - m.viewState.offset = 0 - } - m.renderState.needsRerender = true - } -} - -// UpdateItem replaces an item at the specified index with a new item. -// Handles focus management and triggers re-rendering as needed. -func (m *model) UpdateItem(inx int, item util.Model) { - if inx < 0 || inx >= len(m.filteredItems) { - return - } - m.filteredItems[inx] = item - if m.selectionState.selectedIndex == inx { - m.focusSelected() - } - m.setItemSize(inx) - m.rerenderItem(inx) - m.renderState.needsRerender = true -} - -// GetSize returns the current dimensions of the list. -func (m *model) GetSize() (int, int) { - return m.viewState.width, m.viewState.height -} - -// SetSize updates the list dimensions and triggers a complete re-render. -// Also updates the size of all items that support sizing. -func (m *model) SetSize(width int, height int) tea.Cmd { - if m.filterable && !m.hideFilterInput { - height -= 2 // adjust for input field height and border - } - - if m.viewState.width == width && m.viewState.height == height { - return nil - } - if m.viewState.height != height { - m.renderState.finalHeight = NoFinalHeight - m.viewState.height = height - } - m.viewState.width = width - m.ResetView() - if m.filterable && !m.hideFilterInput { - m.input.SetWidth(m.getItemWidth() - 5) - } - return m.setAllItemsSize() -} - -// getItemWidth calculates the available width for items, accounting for padding. -func (m *model) getItemWidth() int { - width := m.viewState.width - switch len(m.padding) { - case 1: - width -= m.padding[0] * 2 - case 2, 3: - width -= m.padding[1] * 2 - case 4: - width -= m.padding[1] + m.padding[3] - } - return max(0, width) -} - -// setItemSize updates the size of a specific item if it supports sizing. -func (m *model) setItemSize(inx int) tea.Cmd { - if inx < 0 || inx >= len(m.filteredItems) { - return nil - } - if i, ok := m.filteredItems[inx].(layout.Sizeable); ok { - return i.SetSize(m.getItemWidth(), 0) - } - return nil -} - -// setAllItemsSize updates the size of all items that support sizing. -func (m *model) setAllItemsSize() tea.Cmd { - var cmds []tea.Cmd - for i := range m.filteredItems { - if cmd := m.setItemSize(i); cmd != nil { - cmds = append(cmds, cmd) - } - } - return tea.Batch(cmds...) -} - -// listHeight calculates the available height for list content, accounting for padding. -func (m *model) listHeight() int { - height := m.viewState.height - switch len(m.padding) { - case 1: - height -= m.padding[0] * 2 - case 2: - height -= m.padding[0] * 2 - case 3, 4: - height -= m.padding[0] + m.padding[2] - } - if m.filterable && !m.hideFilterInput { - height -= lipgloss.Height(m.inputStyle.Render("dummy")) - } - return max(0, height) -} - -// AppendItem adds a new item to the end of the list. -// Automatically switches to reverse mode and scrolls to show the new item. -func (m *model) AppendItem(item util.Model) tea.Cmd { - cmds := []tea.Cmd{ - item.Init(), - } - m.allItems = append(m.allItems, item) - m.filteredItems = m.allItems - cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1)) - cmds = append(cmds, m.goToBottom()) - m.renderState.needsRerender = true - return tea.Batch(cmds...) -} - -// DeleteItem removes an item at the specified index. -// Adjusts selection if necessary and triggers a complete re-render. -func (m *model) DeleteItem(i int) { - if i < 0 || i >= len(m.filteredItems) { - return - } - m.allItems = slices.Delete(m.allItems, i, i+1) - delete(m.renderState.items, i) - m.filteredItems = m.allItems - - if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 { - m.selectionState.selectedIndex-- - } else if m.selectionState.selectedIndex > i { - m.selectionState.selectedIndex-- - } - - m.ResetView() - m.renderState.needsRerender = true -} - -// PrependItem adds a new item to the beginning of the list. -// Adjusts cached positions and selection index, then switches to forward mode. -func (m *model) PrependItem(item util.Model) tea.Cmd { - cmds := []tea.Cmd{item.Init()} - m.allItems = append([]util.Model{item}, m.allItems...) - m.filteredItems = m.allItems - - // Shift all cached item indices by 1 - newItems := make(map[int]renderedItem, len(m.renderState.items)) - for k, v := range m.renderState.items { - newItems[k+1] = v - } - m.renderState.items = newItems - - if m.selectionState.selectedIndex >= 0 { - m.selectionState.selectedIndex++ - } - - cmds = append(cmds, m.goToTop()) - cmds = append(cmds, m.setItemSize(0)) - m.renderState.needsRerender = true - return tea.Batch(cmds...) -} - -// setReverse switches between forward and reverse rendering modes. -func (m *model) setReverse(reverse bool) { - if reverse { - m.goToBottom() - } else { - m.goToTop() - } -} - -// SetItems replaces all items in the list with a new set. -// Initializes all items, sets their sizes, and establishes initial selection. -// Ensures the initial selection skips section headers. -func (m *model) SetItems(items []util.Model) tea.Cmd { - m.allItems = items - m.filteredItems = items - cmds := []tea.Cmd{m.setAllItemsSize()} - - for _, item := range m.filteredItems { - cmds = append(cmds, item.Init()) - } - - if len(m.filteredItems) > 0 { - if m.viewState.reverse { - m.selectionState.selectedIndex = m.findLastSelectableItem() - } else { - m.selectionState.selectedIndex = m.findFirstSelectableItem() - } - if cmd := m.focusSelected(); cmd != nil { - cmds = append(cmds, cmd) - } - } else { - m.selectionState.selectedIndex = NoSelection - } - - m.ResetView() - return tea.Batch(cmds...) -} - -// section represents a group of items under a section header. -type section struct { - header SectionHeader - items []util.Model -} - -// parseSections parses the flat item list into sections. -func (m *model) parseSections() []section { - var sections []section - var currentSection *section - - for _, item := range m.allItems { - if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() { - // Start a new section - if currentSection != nil { - sections = append(sections, *currentSection) - } - currentSection = §ion{ - header: header, - items: []util.Model{}, - } - } else if currentSection != nil { - // Add item to current section - currentSection.items = append(currentSection.items, item) - } else { - // Item without a section header - create an implicit section - if len(sections) == 0 || sections[len(sections)-1].header != nil { - sections = append(sections, section{ - header: nil, - items: []util.Model{item}, - }) - } else { - // Add to the last implicit section - sections[len(sections)-1].items = append(sections[len(sections)-1].items, item) - } - } - } - - // Don't forget the last section - if currentSection != nil { - sections = append(sections, *currentSection) - } - - return sections -} - -// flattenSections converts sections back to a flat list. -func (m *model) flattenSections(sections []section) []util.Model { - var result []util.Model - - for _, sect := range sections { - if sect.header != nil { - result = append(result, sect.header) - } - result = append(result, sect.items...) - } - - return result -} - -func (m *model) Filter(search string) tea.Cmd { - var cmds []tea.Cmd - search = strings.TrimSpace(search) - search = strings.ToLower(search) - - // Clear focus and match indexes from all items - for _, item := range m.allItems { - if i, ok := item.(layout.Focusable); ok { - cmds = append(cmds, i.Blur()) - } - if i, ok := item.(HasMatchIndexes); ok { - i.MatchIndexes(make([]int, 0)) - } - } - - if search == "" { - cmds = append(cmds, m.SetItems(m.allItems)) - return tea.Batch(cmds...) - } - - // Parse items into sections - sections := m.parseSections() - var filteredSections []section - - for _, sect := range sections { - filteredSection := m.filterSection(sect, search) - if filteredSection != nil { - filteredSections = append(filteredSections, *filteredSection) - } - } - - // Rebuild flat list from filtered sections - m.filteredItems = m.flattenSections(filteredSections) - - // Set initial selection - if len(m.filteredItems) > 0 { - if m.viewState.reverse { - slices.Reverse(m.filteredItems) - m.selectionState.selectedIndex = m.findLastSelectableItem() - } else { - m.selectionState.selectedIndex = m.findFirstSelectableItem() - } - if cmd := m.focusSelected(); cmd != nil { - cmds = append(cmds, cmd) - } - } else { - m.selectionState.selectedIndex = NoSelection - } - - m.ResetView() - return tea.Batch(cmds...) -} - -// filterSection filters items within a section and returns the section if it has matches. -func (m *model) filterSection(sect section, search string) *section { - var matchedItems []util.Model - var hasHeaderMatch bool - - // Check if section header itself matches - if sect.header != nil { - headerText := strings.ToLower(sect.header.View()) - if strings.Contains(headerText, search) { - hasHeaderMatch = true - // If header matches, include all items in the section - matchedItems = sect.items - } - } - - // If header didn't match, filter items within the section - if !hasHeaderMatch && len(sect.items) > 0 { - // Create words array for items in this section - words := make([]string, len(sect.items)) - for i, item := range sect.items { - if f, ok := item.(HasFilterValue); ok { - words[i] = strings.ToLower(f.FilterValue()) - } else { - words[i] = "" - } - } - - // Find matches within this section - matches := fuzzy.Find(search, words) - - // Sort matches by score but preserve relative order for equal scores - sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score - }) - - // Build matched items list - for _, match := range matches { - item := sect.items[match.Index] - if i, ok := item.(HasMatchIndexes); ok { - i.MatchIndexes(match.MatchedIndexes) - } - matchedItems = append(matchedItems, item) - } - } - - // Return section only if it has matches - if len(matchedItems) > 0 { - return §ion{ - header: sect.header, - items: matchedItems, - } - } - - return nil -} - -// SelectedIndex returns the index of the currently selected item. -func (m *model) SelectedIndex() int { - if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) { - return NoSelection - } - return m.selectionState.selectedIndex -} - -// SetSelected sets the selected item by index and automatically scrolls to make it visible. -// If the index is invalid or points to a section header, it finds the nearest selectable item. -func (m *model) SetSelected(index int) tea.Cmd { - changeNeeded := m.selectionState.selectedIndex - index - cmds := []tea.Cmd{} - if changeNeeded < 0 { - for range -changeNeeded { - cmds = append(cmds, m.selectNextItem()) - m.renderVisible() - } - } else if changeNeeded > 0 { - for range changeNeeded { - cmds = append(cmds, m.selectPreviousItem()) - m.renderVisible() - } - } - return tea.Batch(cmds...) -} - -// Blur implements ListModel. -func (m *model) Blur() tea.Cmd { - m.isFocused = false - cmd := m.blurSelected() - return cmd -} - -// Focus implements ListModel. -func (m *model) Focus() tea.Cmd { - m.isFocused = true - cmd := m.focusSelected() - return cmd -} - -// IsFocused implements ListModel. -func (m *model) IsFocused() bool { - return m.isFocused -} - -func (m *model) SetFilterPlaceholder(placeholder string) { - m.input.Placeholder = placeholder -} diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go deleted file mode 100644 index 990423958cdc41ab4a04afafed71762ab5e7f122..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/commands/item.go +++ /dev/null @@ -1,69 +0,0 @@ -package commands - -import ( - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/layout" - "github.com/charmbracelet/crush/internal/tui/components/core/list" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" - "github.com/charmbracelet/x/ansi" -) - -type ItemSection interface { - util.Model - layout.Sizeable - list.SectionHeader - SetInfo(info string) -} -type itemSectionModel struct { - width int - title string - info string -} - -func NewItemSection(title string) ItemSection { - return &itemSectionModel{ - title: title, - } -} - -func (m *itemSectionModel) Init() tea.Cmd { - return nil -} - -func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { - return m, nil -} - -func (m *itemSectionModel) View() string { - t := styles.CurrentTheme() - title := ansi.Truncate(m.title, m.width-2, "…") - style := t.S().Base.Padding(1, 1, 0, 1) - title = t.S().Muted.Render(title) - section := "" - if m.info != "" { - section = core.SectionWithInfo(title, m.width-2, m.info) - } else { - section = core.Section(title, m.width-2) - } - - return style.Render(section) -} - -func (m *itemSectionModel) GetSize() (int, int) { - return m.width, 1 -} - -func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd { - m.width = width - return nil -} - -func (m *itemSectionModel) IsSectionHeader() bool { - return true -} - -func (m *itemSectionModel) SetInfo(info string) { - m.info = info -} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 6ef6487e4d04176ed50fe0db16de14f9593e96fb..3fbd1175ea634a21ae52b3e8ccd96e663206347d 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -2,6 +2,7 @@ package list import ( "regexp" + "slices" "sort" "strings" @@ -24,6 +25,7 @@ type FilterableList[T FilterableItem] interface { Cursor() *tea.Cursor SetInputWidth(int) SetInputPlaceholder(string) + Filter(q string) tea.Cmd } type HasMatchIndexes interface { @@ -263,6 +265,10 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd { matchedItems = append(matchedItems, item) } + if f.list.direction == DirectionBackward { + slices.Reverse(matchedItems) + } + cmds = append(cmds, f.list.SetItems(matchedItems)) return tea.Batch(cmds...) } From bb668b1b0fbf9a0c4494119c65fe0df11489e940 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 22:04:40 +0200 Subject: [PATCH 13/18] chore: rebase fix + sync map --- go.mod | 3 +- go.sum | 6 +- .../tui/components/completions/completions.go | 22 ++-- .../tui/components/dialogs/models/list.go | 6 +- internal/tui/exp/list/grouped.go | 5 +- internal/tui/exp/list/list.go | 118 ++++++++++-------- internal/tui/exp/list/list_test.go | 54 ++++---- ...hould_create_simple_filterable_list.golden | 14 ++- ...o_to_selected_item_at_the_beginning.golden | 20 +-- ...ted_item_at_the_beginning_backwards.golden | 20 +-- ...n_list_that_does_not_fits_the_items.golden | 20 +-- ..._the_items_and_has_multi_line_items.golden | 20 +-- ..._and_has_multi_line_items_backwards.golden | 20 +-- ...t_does_not_fits_the_items_backwards.golden | 20 +-- ...sitions_in_list_that_fits_the_items.golden | 25 +++- ..._list_that_fits_the_items_backwards.golden | 25 +++- .../should_move_viewport_down.golden | 20 +-- .../should_move_viewport_down_and_up.golden | 20 +-- .../should_move_viewport_up.golden | 20 +-- .../should_move_viewport_up_and_down.golden | 20 +-- ...are_at_the_bottom_in_backwards_list.golden | 20 +-- ...d_we_are_at_the_top_in_forward_list.golden | 20 +-- ...appended_and_we_are_in_forward_list.golden | 20 +-- ...pended_and_we_are_in_backwards_list.golden | 20 +-- ...d_but_we_moved_down_in_forward_list.golden | 20 +-- ...d_but_we_moved_up_in_backwards_list.golden | 20 +-- ..._above_is_decreases_in_forward_list.golden | 20 +-- ...bove_is_increased_in_backwards_list.golden | 20 +-- ..._above_is_increased_in_forward_list.golden | 20 +-- ...elow_is_decreases_in_backwards_list.golden | 20 +-- ...elow_is_increased_in_backwards_list.golden | 20 +-- ..._below_is_increased_in_forward_list.golden | 20 +-- 32 files changed, 388 insertions(+), 330 deletions(-) diff --git a/go.mod b/go.mod index ddccaeb96d8b91445485a7a431d2d9fa0c8d6740..fdc3d4d180bc492757ead9191bba4b722c08352e 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,8 @@ require ( github.com/bmatcuk/doublestar/v4 v4.9.0 github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac - github.com/charmbracelet/catwalk v0.3.1 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 + github.com/charmbracelet/catwalk v0.3.1 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112 diff --git a/go.sum b/go.sum index 3eabe5e49378bcf9696d33f7b54bcf33f580aa6e..6d0a0a75c672b4748e2c7600295f04a5c03aeb41 100644 --- a/go.sum +++ b/go.sum @@ -70,12 +70,10 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw= -github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U= -github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= +github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U= +github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0= diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 8ff4f7e8aa26df53d23f396a7c900e5dd9752846..0d5b814952dcdb8b6fdabc2f9e6aa8873936babc 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -122,25 +122,23 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.list = d.(listModel) return c, cmd case key.Matches(msg, c.keyMap.UpInsert): - selectedItemInx := c.list.SelectedIndex() - 1 - items := c.list.Items() - if selectedItemInx == list.NoSelection || selectedItemInx < 0 { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - selectedItem := items[selectedItemInx].(CompletionItem).Value() - c.list.SetSelected(selectedItemInx) + selectedItem := *s + c.list.SetSelected(selectedItem.ID()) return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, Insert: true, }) case key.Matches(msg, c.keyMap.DownInsert): - selectedItemInx := c.list.SelectedIndex() + 1 - items := c.list.Items() - if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) { - return c, nil // No item selected, do nothing + s := c.list.SelectedItem() + if s == nil { + return c, nil } - selectedItem := items[selectedItemInx].(CompletionItem).Value() - c.list.SetSelected(selectedItemInx) + selectedItem := *s + c.list.SetSelected(selectedItem.ID()) return c, util.CmdHandler(SelectCompletionMsg{ Value: selectedItem, Insert: true, diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index a8a23874dd2b603999d231675e2f13334948b578..d68e701160e99f36d68a453f0f8095a281d584ed 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -157,7 +157,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { Section: section, } for _, model := range configProvider.Models { - item := list.NewCompletionItem(model.Model, ModelOption{ + item := list.NewCompletionItem(model.Name, ModelOption{ Provider: configProvider, Model: model, }, @@ -195,14 +195,14 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { } section := list.NewItemSection(name) - if _, ok := cfg.Providers[string(provider.ID)]; ok { + if _, ok := cfg.Providers.Get(string(provider.ID)); ok { section.SetInfo(configured) } group := list.Group[list.CompletionItem[ModelOption]]{ Section: section, } for _, model := range provider.Models { - item := list.NewCompletionItem(model.Model, ModelOption{ + item := list.NewCompletionItem(model.Name, ModelOption{ Provider: provider, Model: model, }, diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go index 74f58ca13cddf797dccc6a02baa0fab1b6e0c952..26c3993e717830dfbfb3b7d3b4a3af247bcc7a43 100644 --- a/internal/tui/exp/list/grouped.go +++ b/internal/tui/exp/list/grouped.go @@ -2,6 +2,7 @@ package list import ( tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -37,8 +38,8 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T keyMap: DefaultKeyMap(), focused: true, }, - indexMap: make(map[string]int), - renderedItems: map[string]renderedItem{}, + indexMap: csync.NewMap[string, int](), + renderedItems: csync.NewMap[string, renderedItem](), } for _, opt := range opts { opt(list.confOptions) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index c4ad464b8755394b75cd3e2e5512592bd45a9868..e4c44c4bde1606300329df31b5bb1486d9301831 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/styles" @@ -84,10 +85,10 @@ type list[T Item] struct { offset int - indexMap map[string]int + indexMap *csync.Map[string, int] items []T - renderedItems map[string]renderedItem + renderedItems *csync.Map[string, renderedItem] rendered string @@ -164,8 +165,8 @@ func New[T Item](items []T, opts ...ListOption) List[T] { focused: true, }, items: items, - indexMap: make(map[string]int), - renderedItems: map[string]renderedItem{}, + indexMap: csync.NewMap[string, int](), + renderedItems: csync.NewMap[string, renderedItem](), } for _, opt := range opts { opt(list.confOptions) @@ -175,7 +176,7 @@ func New[T Item](items []T, opts ...ListOption) List[T] { if i, ok := any(item).(Indexable); ok { i.SetIndex(inx) } - list.indexMap[item.ID()] = inx + list.indexMap.Set(item.ID(), inx) } return list } @@ -267,13 +268,13 @@ func (l *list[T]) viewPosition() (int, int) { func (l *list[T]) recalculateItemPositions() { currentContentHeight := 0 for _, item := range l.items { - rItem, ok := l.renderedItems[item.ID()] + rItem, ok := l.renderedItems.Get(item.ID()) if !ok { continue } rItem.start = currentContentHeight rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems[item.ID()] = rItem + l.renderedItems.Set(item.ID(), rItem) currentContentHeight = rItem.end + 1 + l.gap } } @@ -337,7 +338,7 @@ func (l *list[T]) setDefaultSelected() { } func (l *list[T]) scrollToSelection() { - rItem, ok := l.renderedItems[l.selectedItem] + rItem, ok := l.renderedItems.Get(l.selectedItem) if !ok { l.selectedItem = "" l.setDefaultSelected() @@ -395,7 +396,7 @@ func (l *list[T]) scrollToSelection() { } func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { - rItem, ok := l.renderedItems[l.selectedItem] + rItem, ok := l.renderedItems.Get(l.selectedItem) if !ok { return nil } @@ -414,13 +415,16 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { 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] + inx, ok := l.indexMap.Get(rItem.id) + if !ok { + return nil + } for { inx = l.firstSelectableItemBelow(inx) if inx == ItemNotFound { return nil } - item, ok := l.renderedItems[l.items[inx].ID()] + item, ok := l.renderedItems.Get(l.items[inx].ID()) if !ok { continue } @@ -439,13 +443,16 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { } 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] + inx, ok := l.indexMap.Get(rItem.id) + if !ok { + return nil + } for { inx = l.firstSelectableItemAbove(inx) if inx == ItemNotFound { return nil } - item, ok := l.renderedItems[l.items[inx].ID()] + item, ok := l.renderedItems.Get(l.items[inx].ID()) if !ok { continue } @@ -512,10 +519,10 @@ func (l *list[T]) focusSelectedItem() tea.Cmd { if f, ok := any(item).(layout.Focusable); ok { if item.ID() == l.selectedItem && !f.IsFocused() { cmds = append(cmds, f.Focus()) - delete(l.renderedItems, item.ID()) + l.renderedItems.Del(item.ID()) } else if item.ID() != l.selectedItem && f.IsFocused() { cmds = append(cmds, f.Blur()) - delete(l.renderedItems, item.ID()) + l.renderedItems.Del(item.ID()) } } } @@ -531,7 +538,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { if f, ok := any(item).(layout.Focusable); ok { if item.ID() == l.selectedItem && f.IsFocused() { cmds = append(cmds, f.Blur()) - delete(l.renderedItems, item.ID()) + l.renderedItems.Del(item.ID()) } } } @@ -555,13 +562,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { item := l.items[inx] var rItem renderedItem - if cache, ok := l.renderedItems[item.ID()]; ok { + if cache, ok := l.renderedItems.Get(item.ID()); ok { rItem = cache } else { rItem = l.renderItem(item) rItem.start = currentContentHeight rItem.end = currentContentHeight + rItem.height - 1 - l.renderedItems[item.ID()] = rItem + l.renderedItems.Set(item.ID(), rItem) } gap := l.gap + 1 if inx == len(l.items)-1 { @@ -596,9 +603,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd { } l.items = append(l.items, item) - l.indexMap = make(map[string]int) + l.indexMap = csync.NewMap[string, int]() for inx, item := range l.items { - l.indexMap[item.ID()] = inx + l.indexMap.Set(item.ID(), inx) } if l.width > 0 && l.height > 0 { cmd = item.SetSize(l.width, l.height) @@ -617,12 +624,14 @@ func (l *list[T]) AppendItem(item T) tea.Cmd { cmds = append(cmds, cmd) } } else { - newItem := l.renderedItems[item.ID()] - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height + if len(l.items) > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) } - l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) } } return tea.Sequence(cmds...) @@ -636,11 +645,14 @@ func (l *list[T]) Blur() tea.Cmd { // DeleteItem implements List. func (l *list[T]) DeleteItem(id string) tea.Cmd { - inx := l.indexMap[id] + inx, ok := l.indexMap.Get(id) + if !ok { + return nil + } l.items = slices.Delete(l.items, inx, inx+1) - delete(l.renderedItems, id) + l.renderedItems.Del(id) for inx, item := range l.items { - l.indexMap[item.ID()] = inx + l.indexMap.Set(item.ID(), inx) } if l.selectedItem == id { @@ -753,9 +765,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { item.Init(), } l.items = append([]T{item}, l.items...) - l.indexMap = make(map[string]int) + l.indexMap = csync.NewMap[string, int]() for inx, item := range l.items { - l.indexMap[item.ID()] = inx + l.indexMap.Set(item.ID(), inx) } if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) @@ -768,12 +780,14 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { cmds = append(cmds, cmd) } } else { - newItem := l.renderedItems[item.ID()] - newLines := newItem.height - if len(l.items) > 1 { - newLines += l.gap + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height + if len(l.items) > 1 { + newLines += l.gap + } + l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) } - l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) } } return tea.Batch(cmds...) @@ -781,7 +795,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { // SelectItemAbove implements List. func (l *list[T]) SelectItemAbove() tea.Cmd { - inx, ok := l.indexMap[l.selectedItem] + inx, ok := l.indexMap.Get(l.selectedItem) if !ok { return nil } @@ -815,7 +829,7 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { // SelectItemBelow implements List. func (l *list[T]) SelectItemBelow() tea.Cmd { - inx, ok := l.indexMap[l.selectedItem] + inx, ok := l.indexMap.Get(l.selectedItem) if !ok { return nil } @@ -833,7 +847,7 @@ func (l *list[T]) SelectItemBelow() tea.Cmd { // SelectedItem implements List. func (l *list[T]) SelectedItem() *T { - inx, ok := l.indexMap[l.selectedItem] + inx, ok := l.indexMap.Get(l.selectedItem) if !ok { return nil } @@ -869,10 +883,10 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd { l.rendered = "" l.offset = 0 l.selectedItem = selectedItem - l.indexMap = make(map[string]int) - l.renderedItems = make(map[string]renderedItem) + l.indexMap = csync.NewMap[string, int]() + l.renderedItems = csync.NewMap[string, renderedItem]() for inx, item := range l.items { - l.indexMap[item.ID()] = inx + l.indexMap.Set(item.ID(), inx) if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) } @@ -896,22 +910,22 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { // UpdateItem implements List. func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { var cmds []tea.Cmd - if inx, ok := l.indexMap[id]; ok { + if inx, ok := l.indexMap.Get(id); ok { l.items[inx] = item - oldItem := l.renderedItems[id] + oldItem, hasOldItem := l.renderedItems.Get(id) oldPosition := l.offset if l.direction == DirectionBackward { oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset } - delete(l.renderedItems, id) + l.renderedItems.Del(id) cmd := l.render() // need to check for nil because of sequence not handling nil if cmd != nil { cmds = append(cmds, cmd) } - if l.direction == DirectionBackward { + if hasOldItem && l.direction == DirectionBackward { // if we are the last item and there is no offset // make sure to go to the bottom if inx == len(l.items)-1 && l.offset == 0 { @@ -921,14 +935,18 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { } // if the item is at least partially below the viewport } else if oldPosition < oldItem.end { - newItem := l.renderedItems[item.ID()] + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { + newLines := newItem.height - oldItem.height + l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) + } + } + } else if hasOldItem && l.offset > oldItem.start { + newItem, ok := l.renderedItems.Get(item.ID()) + if ok { newLines := newItem.height - oldItem.height l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } - } else if l.offset > oldItem.start { - newItem := l.renderedItems[item.ID()] - newLines := newItem.height - oldItem.height - l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1) } } return tea.Sequence(cmds...) diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 933f061760653f38c8dc49787017740bcc5d58e5..ff824e450fad501dff3c8431f77cb845dd0e17d3 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -29,17 +29,19 @@ func TestList(t *testing.T) { // should select the last item assert.Equal(t, items[0].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) - require.Len(t, l.indexMap, 5) + require.Equal(t, 5, l.indexMap.Len()) require.Len(t, l.items, 5) - require.Len(t, l.renderedItems, 5) + require.Equal(t, 5, l.renderedItems.Len()) 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) + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) } golden.RequireEqual(t, []byte(l.View())) @@ -57,17 +59,19 @@ func TestList(t *testing.T) { // should select the last item assert.Equal(t, items[4].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) - require.Len(t, l.indexMap, 5) + require.Equal(t, 5, l.indexMap.Len()) require.Len(t, l.items, 5) - require.Len(t, l.renderedItems, 5) + require.Equal(t, 5, l.renderedItems.Len()) 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) + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) } golden.RequireEqual(t, []byte(l.View())) @@ -86,17 +90,19 @@ func TestList(t *testing.T) { // 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.Equal(t, 30, l.indexMap.Len()) require.Len(t, l.items, 30) - require.Len(t, l.renderedItems, 30) + require.Equal(t, 30, l.renderedItems.Len()) 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) + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) } golden.RequireEqual(t, []byte(l.View())) @@ -114,17 +120,19 @@ func TestList(t *testing.T) { // 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.Equal(t, 30, l.indexMap.Len()) require.Len(t, l.items, 30) - require.Len(t, l.renderedItems, 30) + require.Equal(t, 30, l.renderedItems.Len()) 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) + item, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) + assert.Equal(t, i, item.start) + assert.Equal(t, i, item.end) } golden.RequireEqual(t, []byte(l.View())) @@ -145,9 +153,9 @@ func TestList(t *testing.T) { // 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.Equal(t, 30, l.indexMap.Len()) require.Len(t, l.items, 30) - require.Len(t, l.renderedItems, 30) + require.Equal(t, 30, l.renderedItems.Len()) expectedLines := 0 for i := range 30 { expectedLines += (i + 1) * 1 @@ -159,7 +167,8 @@ func TestList(t *testing.T) { assert.Equal(t, 9, end) currentPosition := 0 for i := range 30 { - rItem := l.renderedItems[items[i].ID()] + rItem, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) assert.Equal(t, currentPosition, rItem.start) assert.Equal(t, currentPosition+i, rItem.end) currentPosition += i + 1 @@ -182,9 +191,9 @@ func TestList(t *testing.T) { // 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.Equal(t, 30, l.indexMap.Len()) require.Len(t, l.items, 30) - require.Len(t, l.renderedItems, 30) + require.Equal(t, 30, l.renderedItems.Len()) expectedLines := 0 for i := range 30 { expectedLines += (i + 1) * 1 @@ -196,7 +205,8 @@ func TestList(t *testing.T) { assert.Equal(t, expectedLines-1, end) currentPosition := 0 for i := range 30 { - rItem := l.renderedItems[items[i].ID()] + rItem, ok := l.renderedItems.Get(items[i].ID()) + require.True(t, ok) assert.Equal(t, currentPosition, rItem.start) assert.Equal(t, currentPosition+i, rItem.end) currentPosition += i + 1 diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden index 8aac1155586865e3db5a87839b9d430b419d00ec..01668d35b2d07b73b1daf709578d1dccf72a4cea 100644 --- a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -1,6 +1,10 @@ > Type to filter  -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file +│Item 0  +Item 1  +Item 2  +Item 3  +Item 4  + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden index 1331375f5b46cbf692df512e6b0383fb2776b472..7775902a7b151f55d9182fe2af00bd1a0f8e261b 100644 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden @@ -1,10 +1,10 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden index 1331375f5b46cbf692df512e6b0383fb2776b472..7775902a7b151f55d9182fe2af00bd1a0f8e261b 100644 --- a/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden +++ b/internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden @@ -1,10 +1,10 @@ -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 -│Item 10 \ No newline at end of file +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 +│Item 10 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden index 46269dd405b643eef664dafb388d2001ffacc923..4eb402d4d275af1e95c28c538b0059f75fd15a88 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden @@ -1,10 +1,10 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden index 828d986cba48a879f1e3e0c7fd9a35b70bacd52e..f167f64ffd978440b6df4f59911c384ed0538a66 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden @@ -1,10 +1,10 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file +│Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden index 6e558d7a093312cf4911bbe3ffc18a6c02583cc6..d54f38ec7432b9f24930015a7415aa3604b97025 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden @@ -1,10 +1,10 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden index 3531c59b4121a3d85effd1e0779742f98b7b1ac7..aaa3c01a3e5cec4da20bdb25af8bc9c86d8ccfd5 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden @@ -1,10 +1,10 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden index f6b9a64ae1d6aea57fe9c014f5d748801c3b04fd..a11b23ef049201e56929376a6638bd12718b7a3f 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden @@ -1,5 +1,20 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden index f81aca7680744374be81be4e15315468d5c3db8c..55b683ef02e235e03bbe941093d557dd06dfd888 100644 --- a/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden +++ b/internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden @@ -1,5 +1,20 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 \ No newline at end of file +Item 0 +Item 1 +Item 2 +Item 3 +│Item 4 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden index 67a32bfedb0c3941c99e6693fad9612bceb61932..d304f35cc7594d9070555ff914980787b7cfb987 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden @@ -1,10 +1,10 @@ -Item 6 -Item 6 -Item 6 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 -│Item 7 \ No newline at end of file +Item 6 +Item 6 +Item 6 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 +│Item 7 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden index 1662abeb712a883c930bfbe91b33a20a81bc616d..65c98367d817411de97cfae7a34737efe0217d6b 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden @@ -1,10 +1,10 @@ -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -│Item 3 -│Item 3 -│Item 3 -│Item 3 \ No newline at end of file +Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +│Item 3 +│Item 3 +│Item 3 +│Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden index 9469e26363b59f1d98fbb46e2dd4f194028927ab..03582cc911ee2f3d50e428cd320c25a13c99147b 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden @@ -1,10 +1,10 @@ -│Item 28 -│Item 28 -│Item 28 -│Item 28 -│Item 28 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 \ No newline at end of file +│Item 28 +│Item 28 +│Item 28 +│Item 28 +│Item 28 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden index 6e558d7a093312cf4911bbe3ffc18a6c02583cc6..d54f38ec7432b9f24930015a7415aa3604b97025 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden @@ -1,10 +1,10 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden index 03dce1dac791cad0516fd70cfa5bf5d1ec73bee4..8cea66d71fb8e43fc9e0ac8fcb6ee1000cfcb5e4 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing \ No newline at end of file +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +Item 29 +│Testing  \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden index efcdc73a7d9573692365723a1ba65a8773d3a3c2..faed253a104304630e9e33decc445622cde8739a 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden @@ -1,10 +1,10 @@ -│Testing -Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 \ No newline at end of file +│Testing  +Item 0 +Item 1 +Item 1 +Item 2 +Item 2 +Item 2 +Item 3 +Item 3 +Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden index 90740d28955595061e79772cf8011a7571712205..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden @@ -1,10 +1,10 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden index a0ed052f256cca2d93c47364d1e719c112819d86..1a5650ba234a86b20584a146124d7b0c8023679f 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden index 90740d28955595061e79772cf8011a7571712205..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden @@ -1,10 +1,10 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden index a0ed052f256cca2d93c47364d1e719c112819d86..1a5650ba234a86b20584a146124d7b0c8023679f 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden index 46269dd405b643eef664dafb388d2001ffacc923..4eb402d4d275af1e95c28c538b0059f75fd15a88 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden @@ -1,10 +1,10 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden index a0ed052f256cca2d93c47364d1e719c112819d86..1a5650ba234a86b20584a146124d7b0c8023679f 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden index 90740d28955595061e79772cf8011a7571712205..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden @@ -1,10 +1,10 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden index 77d3450cede66562f85e422c7c4199240231f11b..f377a4fd04f868d775c279849fd65723afaac901 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +Item 27 +Item 28 +│Item 29 +Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden index a0ed052f256cca2d93c47364d1e719c112819d86..1a5650ba234a86b20584a146124d7b0c8023679f 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file +Item 18 +Item 19 +Item 20 +Item 21 +Item 22 +Item 23 +Item 24 +Item 25 +Item 26 +│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden index 90740d28955595061e79772cf8011a7571712205..9ac6e51a8a45f645d7e7f10dc4ea0542155e198e 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden @@ -1,10 +1,10 @@ -│Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 -Item 10 -Item 11 \ No newline at end of file +│Item 2 +Item 3 +Item 4 +Item 5 +Item 6 +Item 7 +Item 8 +Item 9 +Item 10 +Item 11 \ No newline at end of file From 4614a6cdcf997c11a6dd76fa55ec47a132a2b096 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 22:19:01 +0200 Subject: [PATCH 14/18] chore: safe slice --- cspell.json | 2 +- internal/csync/slices.go | 127 +++++++++++++ internal/csync/slices_test.go | 209 ++++++++++++++++++++++ internal/tui/exp/list/filterable.go | 2 +- internal/tui/exp/list/filterable_group.go | 2 +- internal/tui/exp/list/grouped.go | 5 +- internal/tui/exp/list/list.go | 140 +++++++++------ internal/tui/exp/list/list_test.go | 12 +- 8 files changed, 438 insertions(+), 61 deletions(-) diff --git a/cspell.json b/cspell.json index 797efddbfc2ba8dbbb8b121f4192f2449b2025ae..713684deb4cf3f066d92b6a71a063df90cddf0fc 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"version":"0.2","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm"],"language":"en"} \ No newline at end of file +{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]} \ No newline at end of file diff --git a/internal/csync/slices.go b/internal/csync/slices.go index be723655079ccc6b07f55c3237b706a17bb14d40..4e67ac1a127f577d1d13673e4311b2ea44e17393 100644 --- a/internal/csync/slices.go +++ b/internal/csync/slices.go @@ -2,6 +2,7 @@ package csync import ( "iter" + "slices" "sync" ) @@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] { } } } + +// Slice is a thread-safe slice implementation that provides concurrent access. +type Slice[T any] struct { + inner []T + mu sync.RWMutex +} + +// NewSlice creates a new thread-safe slice. +func NewSlice[T any]() *Slice[T] { + return &Slice[T]{ + inner: make([]T, 0), + } +} + +// NewSliceFrom creates a new thread-safe slice from an existing slice. +func NewSliceFrom[T any](s []T) *Slice[T] { + inner := make([]T, len(s)) + copy(inner, s) + return &Slice[T]{ + inner: inner, + } +} + +// Append adds an element to the end of the slice. +func (s *Slice[T]) Append(item T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = append(s.inner, item) +} + +// Prepend adds an element to the beginning of the slice. +func (s *Slice[T]) Prepend(item T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = append([]T{item}, s.inner...) +} + +// Delete removes the element at the specified index. +func (s *Slice[T]) Delete(index int) bool { + s.mu.Lock() + defer s.mu.Unlock() + if index < 0 || index >= len(s.inner) { + return false + } + s.inner = slices.Delete(s.inner, index, index+1) + return true +} + +// Get returns the element at the specified index. +func (s *Slice[T]) Get(index int) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + var zero T + if index < 0 || index >= len(s.inner) { + return zero, false + } + return s.inner[index], true +} + +// Set updates the element at the specified index. +func (s *Slice[T]) Set(index int, item T) bool { + s.mu.Lock() + defer s.mu.Unlock() + if index < 0 || index >= len(s.inner) { + return false + } + s.inner[index] = item + return true +} + +// Len returns the number of elements in the slice. +func (s *Slice[T]) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.inner) +} + +// Slice returns a copy of the underlying slice. +func (s *Slice[T]) Slice() []T { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]T, len(s.inner)) + copy(result, s.inner) + return result +} + +// SetSlice replaces the entire slice with a new one. +func (s *Slice[T]) SetSlice(items []T) { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = make([]T, len(items)) + copy(s.inner, items) +} + +// Clear removes all elements from the slice. +func (s *Slice[T]) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.inner = s.inner[:0] +} + +// Seq returns an iterator that yields elements from the slice. +func (s *Slice[T]) Seq() iter.Seq[T] { + // Take a snapshot to avoid holding the lock during iteration + items := s.Slice() + return func(yield func(T) bool) { + for _, v := range items { + if !yield(v) { + return + } + } + } +} + +// SeqWithIndex returns an iterator that yields index-value pairs from the slice. +func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] { + // Take a snapshot to avoid holding the lock during iteration + items := s.Slice() + return func(yield func(int, T) bool) { + for i, v := range items { + if !yield(i, v) { + return + } + } + } +} diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index 731cb96f55dd24cae74f55c0ef8e97ebd28aacaa..d838485e4865210d232c89e1531a191ce7d09418 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -1,11 +1,13 @@ package csync import ( + "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLazySlice_Seq(t *testing.T) { @@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) { assert.Equal(t, []string{"a", "b"}, result) } + +func TestSlice(t *testing.T) { + t.Run("NewSlice", func(t *testing.T) { + s := NewSlice[int]() + assert.Equal(t, 0, s.Len()) + }) + + t.Run("NewSliceFrom", func(t *testing.T) { + original := []int{1, 2, 3} + s := NewSliceFrom(original) + assert.Equal(t, 3, s.Len()) + + // Verify it's a copy, not a reference + original[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 1, val) + }) + + t.Run("Append", func(t *testing.T) { + s := NewSlice[string]() + s.Append("hello") + s.Append("world") + + assert.Equal(t, 2, s.Len()) + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, "hello", val) + + val, ok = s.Get(1) + require.True(t, ok) + assert.Equal(t, "world", val) + }) + + t.Run("Prepend", func(t *testing.T) { + s := NewSlice[string]() + s.Append("world") + s.Prepend("hello") + + assert.Equal(t, 2, s.Len()) + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, "hello", val) + + val, ok = s.Get(1) + require.True(t, ok) + assert.Equal(t, "world", val) + }) + + t.Run("Delete", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3, 4, 5}) + + // Delete middle element + ok := s.Delete(2) + assert.True(t, ok) + assert.Equal(t, 4, s.Len()) + + expected := []int{1, 2, 4, 5} + actual := s.Slice() + assert.Equal(t, expected, actual) + + // Delete out of bounds + ok = s.Delete(10) + assert.False(t, ok) + assert.Equal(t, 4, s.Len()) + + // Delete negative index + ok = s.Delete(-1) + assert.False(t, ok) + assert.Equal(t, 4, s.Len()) + }) + + t.Run("Get", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + val, ok := s.Get(1) + require.True(t, ok) + assert.Equal(t, "b", val) + + // Out of bounds + _, ok = s.Get(10) + assert.False(t, ok) + + // Negative index + _, ok = s.Get(-1) + assert.False(t, ok) + }) + + t.Run("Set", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + ok := s.Set(1, "modified") + assert.True(t, ok) + + val, ok := s.Get(1) + require.True(t, ok) + assert.Equal(t, "modified", val) + + // Out of bounds + ok = s.Set(10, "invalid") + assert.False(t, ok) + + // Negative index + ok = s.Set(-1, "invalid") + assert.False(t, ok) + }) + + t.Run("SetSlice", func(t *testing.T) { + s := NewSlice[int]() + s.Append(1) + s.Append(2) + + newItems := []int{10, 20, 30} + s.SetSlice(newItems) + + assert.Equal(t, 3, s.Len()) + assert.Equal(t, newItems, s.Slice()) + + // Verify it's a copy + newItems[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 10, val) + }) + + t.Run("Clear", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3}) + assert.Equal(t, 3, s.Len()) + + s.Clear() + assert.Equal(t, 0, s.Len()) + }) + + t.Run("Slice", func(t *testing.T) { + original := []int{1, 2, 3} + s := NewSliceFrom(original) + + copy := s.Slice() + assert.Equal(t, original, copy) + + // Verify it's a copy + copy[0] = 999 + val, ok := s.Get(0) + require.True(t, ok) + assert.Equal(t, 1, val) + }) + + t.Run("Seq", func(t *testing.T) { + s := NewSliceFrom([]int{1, 2, 3}) + + var result []int + for v := range s.Seq() { + result = append(result, v) + } + + assert.Equal(t, []int{1, 2, 3}, result) + }) + + t.Run("SeqWithIndex", func(t *testing.T) { + s := NewSliceFrom([]string{"a", "b", "c"}) + + var indices []int + var values []string + for i, v := range s.SeqWithIndex() { + indices = append(indices, i) + values = append(values, v) + } + + assert.Equal(t, []int{0, 1, 2}, indices) + assert.Equal(t, []string{"a", "b", "c"}, values) + }) + + t.Run("ConcurrentAccess", func(t *testing.T) { + s := NewSlice[int]() + const numGoroutines = 100 + const itemsPerGoroutine = 10 + + var wg sync.WaitGroup + + // Concurrent appends + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(start int) { + defer wg.Done() + for j := 0; j < itemsPerGoroutine; j++ { + s.Append(start*itemsPerGoroutine + j) + } + }(i) + } + + // Concurrent reads + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < itemsPerGoroutine; j++ { + s.Len() // Just read the length + } + }() + } + + wg.Wait() + + // Should have all items + assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len()) + }) +} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 3fbd1175ea634a21ae52b3e8ccd96e663206347d..89930a905b162a7b470ab01588013da9577bf81c 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -97,7 +97,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption f.list = New[T](items, f.listOptions...).(*list[T]) f.updateKeyMaps() - f.items = f.list.items + f.items = f.list.items.Slice() if f.inputHidden { return f diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index c1b885da00f02529cb54dcc4505afe1f34807e38..2543723b4b74072510c31453441495d27ec05c9d 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/internal/tui/exp/list/filterable_group.go @@ -179,7 +179,7 @@ func (f *filterableGroupList[T]) inputHeight() int { func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { var cmds []tea.Cmd - for _, item := range f.items { + for _, item := range f.items.Slice() { if i, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, i.Blur()) } diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go index 26c3993e717830dfbfb3b7d3b4a3af247bcc7a43..4b9124fdd0a0e3335d3b22ef122dc5cd86c5785b 100644 --- a/internal/tui/exp/list/grouped.go +++ b/internal/tui/exp/list/grouped.go @@ -38,6 +38,7 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T keyMap: DefaultKeyMap(), focused: true, }, + items: csync.NewSlice[Item](), indexMap: csync.NewMap[string, int](), renderedItems: csync.NewMap[string, renderedItem](), } @@ -82,13 +83,13 @@ func (g *groupedList[T]) convertItems() { items = append(items, g) } } - g.items = items + g.items.SetSlice(items) } func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd { g.groups = groups g.convertItems() - return g.SetItems(g.items) + return g.SetItems(g.items.Slice()) } func (g *groupedList[T]) Groups() []Group[T] { diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index e4c44c4bde1606300329df31b5bb1486d9301831..c3fec3000f86b0edac3debcc55644f1c10e11662 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,7 +1,6 @@ package list import ( - "slices" "strings" "github.com/charmbracelet/bubbles/v2/key" @@ -86,7 +85,7 @@ type list[T Item] struct { offset int indexMap *csync.Map[string, int] - items []T + items *csync.Slice[T] renderedItems *csync.Map[string, renderedItem] @@ -164,7 +163,7 @@ func New[T Item](items []T, opts ...ListOption) List[T] { keyMap: DefaultKeyMap(), focused: true, }, - items: items, + items: csync.NewSliceFrom(items), indexMap: csync.NewMap[string, int](), renderedItems: csync.NewMap[string, renderedItem](), } @@ -191,7 +190,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case anim.StepMsg: var cmds []tea.Cmd - for _, item := range l.items { + for _, item := range l.items.Slice() { if i, ok := any(item).(HasAnim); ok && i.Spinning() { updated, cmd := i.Update(msg) cmds = append(cmds, cmd) @@ -267,7 +266,7 @@ func (l *list[T]) viewPosition() (int, int) { func (l *list[T]) recalculateItemPositions() { currentContentHeight := 0 - for _, item := range l.items { + for _, item := range l.items.Slice() { rItem, ok := l.renderedItems.Get(item.ID()) if !ok { continue @@ -280,7 +279,7 @@ func (l *list[T]) recalculateItemPositions() { } func (l *list[T]) render() tea.Cmd { - if l.width <= 0 || l.height <= 0 || len(l.items) == 0 { + if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 { return nil } l.setDefaultSelected() @@ -424,19 +423,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { if inx == ItemNotFound { return nil } - item, ok := l.renderedItems.Get(l.items[inx].ID()) + item, ok := l.items.Get(inx) + if !ok { + continue + } + renderedItem, ok := l.renderedItems.Get(item.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 + if renderedItem.start <= start && renderedItem.end >= end { + l.selectedItem = renderedItem.id return l.render() } // item is in the view - if item.start >= start && item.start <= end { - l.selectedItem = item.id + if renderedItem.start >= start && renderedItem.start <= end { + l.selectedItem = renderedItem.id return l.render() } } @@ -452,19 +455,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { if inx == ItemNotFound { return nil } - item, ok := l.renderedItems.Get(l.items[inx].ID()) + item, ok := l.items.Get(inx) + if !ok { + continue + } + renderedItem, ok := l.renderedItems.Get(item.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 + if renderedItem.start <= start && renderedItem.end >= end { + l.selectedItem = renderedItem.id return l.render() } // item is in the view - if item.end >= start && item.end <= end { - l.selectedItem = item.id + if renderedItem.end >= start && renderedItem.end <= end { + l.selectedItem = renderedItem.id return l.render() } } @@ -475,36 +482,51 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd { func (l *list[T]) selectFirstItem() { inx := l.firstSelectableItemBelow(-1) if inx != ItemNotFound { - l.selectedItem = l.items[inx].ID() + item, ok := l.items.Get(inx) + if ok { + l.selectedItem = item.ID() + } } } func (l *list[T]) selectLastItem() { - inx := l.firstSelectableItemAbove(len(l.items)) + inx := l.firstSelectableItemAbove(l.items.Len()) if inx != ItemNotFound { - l.selectedItem = l.items[inx].ID() + item, ok := l.items.Get(inx) + if ok { + l.selectedItem = item.ID() + } } } func (l *list[T]) firstSelectableItemAbove(inx int) int { for i := inx - 1; i >= 0; i-- { - if _, ok := any(l.items[i]).(layout.Focusable); ok { + item, ok := l.items.Get(i) + if !ok { + continue + } + if _, ok := any(item).(layout.Focusable); ok { return i } } if inx == 0 && l.wrap { - return l.firstSelectableItemAbove(len(l.items)) + return l.firstSelectableItemAbove(l.items.Len()) } return ItemNotFound } 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 { + itemsLen := l.items.Len() + for i := inx + 1; i < itemsLen; i++ { + item, ok := l.items.Get(i) + if !ok { + continue + } + if _, ok := any(item).(layout.Focusable); ok { return i } } - if inx == len(l.items)-1 && l.wrap { + if inx == itemsLen-1 && l.wrap { return l.firstSelectableItemBelow(-1) } return ItemNotFound @@ -515,7 +537,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd { return nil } var cmds []tea.Cmd - for _, item := range l.items { + for _, item := range l.items.Slice() { if f, ok := any(item).(layout.Focusable); ok { if item.ID() == l.selectedItem && !f.IsFocused() { cmds = append(cmds, f.Focus()) @@ -534,7 +556,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { return nil } var cmds []tea.Cmd - for _, item := range l.items { + for _, item := range l.items.Slice() { if f, ok := any(item).(layout.Focusable); ok { if item.ID() == l.selectedItem && f.IsFocused() { cmds = append(cmds, f.Blur()) @@ -549,7 +571,8 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { // 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++ { + itemsLen := l.items.Len() + for i := startInx; i < itemsLen; i++ { if currentContentHeight >= l.height && limitHeight { return i } @@ -557,10 +580,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { inx := i if l.direction != DirectionForward { - inx = (len(l.items) - 1) - i + inx = (itemsLen - 1) - i } - item := l.items[inx] + item, ok := l.items.Get(inx) + if !ok { + continue + } var rItem renderedItem if cache, ok := l.renderedItems.Get(item.ID()); ok { rItem = cache @@ -571,7 +597,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { l.renderedItems.Set(item.ID(), rItem) } gap := l.gap + 1 - if inx == len(l.items)-1 { + if inx == itemsLen-1 { gap = 0 } @@ -582,7 +608,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { } currentContentHeight = rItem.end + 1 + l.gap } - return len(l.items) + return itemsLen } func (l *list[T]) renderItem(item Item) renderedItem { @@ -602,9 +628,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd { cmds = append(cmds, cmd) } - l.items = append(l.items, item) + l.items.Append(item) l.indexMap = csync.NewMap[string, int]() - for inx, item := range l.items { + for inx, item := range l.items.Slice() { l.indexMap.Set(item.ID(), inx) } if l.width > 0 && l.height > 0 { @@ -627,7 +653,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - if len(l.items) > 1 { + if l.items.Len() > 1 { newLines += l.gap } l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) @@ -649,15 +675,20 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd { if !ok { return nil } - l.items = slices.Delete(l.items, inx, inx+1) + l.items.Delete(inx) l.renderedItems.Del(id) - for inx, item := range l.items { + for inx, item := range l.items.Slice() { l.indexMap.Set(item.ID(), inx) } if l.selectedItem == id { if inx > 0 { - l.selectedItem = l.items[inx-1].ID() + item, ok := l.items.Get(inx - 1) + if ok { + l.selectedItem = item.ID() + } else { + l.selectedItem = "" + } } else { l.selectedItem = "" } @@ -711,7 +742,7 @@ func (l *list[T]) IsFocused() bool { // Items implements List. func (l *list[T]) Items() []T { - return l.items + return l.items.Slice() } func (l *list[T]) incrementOffset(n int) { @@ -764,9 +795,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { cmds := []tea.Cmd{ item.Init(), } - l.items = append([]T{item}, l.items...) + l.items.Prepend(item) l.indexMap = csync.NewMap[string, int]() - for inx, item := range l.items { + for inx, item := range l.items.Slice() { l.indexMap.Set(item.ID(), inx) } if l.width > 0 && l.height > 0 { @@ -783,7 +814,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd { newItem, ok := l.renderedItems.Get(item.ID()) if ok { newLines := newItem.height - if len(l.items) > 1 { + if l.items.Len() > 1 { newLines += l.gap } l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines) @@ -817,7 +848,10 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { } } - item := l.items[newIndex] + item, ok := l.items.Get(newIndex) + if !ok { + return nil + } l.selectedItem = item.ID() l.movingByItem = true renderCmd := l.render() @@ -839,7 +873,10 @@ func (l *list[T]) SelectItemBelow() tea.Cmd { // no item above return nil } - item := l.items[newIndex] + item, ok := l.items.Get(newIndex) + if !ok { + return nil + } l.selectedItem = item.ID() l.movingByItem = true return l.render() @@ -851,18 +888,21 @@ func (l *list[T]) SelectedItem() *T { if !ok { return nil } - if inx > len(l.items)-1 { + if inx > l.items.Len()-1 { + return nil + } + item, ok := l.items.Get(inx) + if !ok { return nil } - item := l.items[inx] return &item } // SetItems implements List. func (l *list[T]) SetItems(items []T) tea.Cmd { - l.items = items + l.items.SetSlice(items) var cmds []tea.Cmd - for inx, item := range l.items { + for inx, item := range l.items.Slice() { if i, ok := any(item).(Indexable); ok { i.SetIndex(inx) } @@ -885,7 +925,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd { l.selectedItem = selectedItem l.indexMap = csync.NewMap[string, int]() l.renderedItems = csync.NewMap[string, renderedItem]() - for inx, item := range l.items { + for inx, item := range l.items.Slice() { l.indexMap.Set(item.ID(), inx) if l.width > 0 && l.height > 0 { cmds = append(cmds, item.SetSize(l.width, l.height)) @@ -911,7 +951,7 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd { func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { var cmds []tea.Cmd if inx, ok := l.indexMap.Get(id); ok { - l.items[inx] = item + l.items.Set(inx, item) oldItem, hasOldItem := l.renderedItems.Get(id) oldPosition := l.offset if l.direction == DirectionBackward { @@ -928,7 +968,7 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { if hasOldItem && l.direction == DirectionBackward { // if we are the last item and there is no offset // make sure to go to the bottom - if inx == len(l.items)-1 && l.offset == 0 { + if inx == l.items.Len()-1 && l.offset == 0 { cmd = l.GoToBottom() if cmd != nil { cmds = append(cmds, cmd) diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index ff824e450fad501dff3c8431f77cb845dd0e17d3..63cfa599e8ce1c96aad1cae67243caa2b097ee0b 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -30,7 +30,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[0].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 5, l.indexMap.Len()) - require.Len(t, l.items, 5) + require.Equal(t, 5, l.items.Len()) require.Equal(t, 5, l.renderedItems.Len()) assert.Equal(t, 5, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") @@ -60,7 +60,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[4].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 5, l.indexMap.Len()) - require.Len(t, l.items, 5) + require.Equal(t, 5, l.items.Len()) require.Equal(t, 5, l.renderedItems.Len()) assert.Equal(t, 5, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") @@ -91,7 +91,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[0].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 30, l.indexMap.Len()) - require.Len(t, l.items, 30) + require.Equal(t, 30, l.items.Len()) require.Equal(t, 30, l.renderedItems.Len()) assert.Equal(t, 30, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") @@ -121,7 +121,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[29].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 30, l.indexMap.Len()) - require.Len(t, l.items, 30) + require.Equal(t, 30, l.items.Len()) require.Equal(t, 30, l.renderedItems.Len()) assert.Equal(t, 30, lipgloss.Height(l.rendered)) assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline") @@ -154,7 +154,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[0].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 30, l.indexMap.Len()) - require.Len(t, l.items, 30) + require.Equal(t, 30, l.items.Len()) require.Equal(t, 30, l.renderedItems.Len()) expectedLines := 0 for i := range 30 { @@ -192,7 +192,7 @@ func TestList(t *testing.T) { assert.Equal(t, items[29].ID(), l.selectedItem) assert.Equal(t, 0, l.offset) require.Equal(t, 30, l.indexMap.Len()) - require.Len(t, l.items, 30) + require.Equal(t, 30, l.items.Len()) require.Equal(t, 30, l.renderedItems.Len()) expectedLines := 0 for i := range 30 { From 5ba54bc7eecd509a39d12270eafac37450e9a64f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 22:21:34 +0200 Subject: [PATCH 15/18] chore: lint --- internal/csync/slices_test.go | 62 ++++++++++++++--------------- internal/tui/exp/list/filterable.go | 2 +- internal/tui/exp/list/list.go | 1 - 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/internal/csync/slices_test.go b/internal/csync/slices_test.go index d838485e4865210d232c89e1531a191ce7d09418..fd1bd69ba6ca4d07cd5383cb52746e41caa8d901 100644 --- a/internal/csync/slices_test.go +++ b/internal/csync/slices_test.go @@ -98,7 +98,7 @@ func TestSlice(t *testing.T) { original := []int{1, 2, 3} s := NewSliceFrom(original) assert.Equal(t, 3, s.Len()) - + // Verify it's a copy, not a reference original[0] = 999 val, ok := s.Get(0) @@ -110,12 +110,12 @@ func TestSlice(t *testing.T) { s := NewSlice[string]() s.Append("hello") s.Append("world") - + assert.Equal(t, 2, s.Len()) val, ok := s.Get(0) require.True(t, ok) assert.Equal(t, "hello", val) - + val, ok = s.Get(1) require.True(t, ok) assert.Equal(t, "world", val) @@ -125,12 +125,12 @@ func TestSlice(t *testing.T) { s := NewSlice[string]() s.Append("world") s.Prepend("hello") - + assert.Equal(t, 2, s.Len()) val, ok := s.Get(0) require.True(t, ok) assert.Equal(t, "hello", val) - + val, ok = s.Get(1) require.True(t, ok) assert.Equal(t, "world", val) @@ -138,21 +138,21 @@ func TestSlice(t *testing.T) { t.Run("Delete", func(t *testing.T) { s := NewSliceFrom([]int{1, 2, 3, 4, 5}) - + // Delete middle element ok := s.Delete(2) assert.True(t, ok) assert.Equal(t, 4, s.Len()) - + expected := []int{1, 2, 4, 5} actual := s.Slice() assert.Equal(t, expected, actual) - + // Delete out of bounds ok = s.Delete(10) assert.False(t, ok) assert.Equal(t, 4, s.Len()) - + // Delete negative index ok = s.Delete(-1) assert.False(t, ok) @@ -161,15 +161,15 @@ func TestSlice(t *testing.T) { t.Run("Get", func(t *testing.T) { s := NewSliceFrom([]string{"a", "b", "c"}) - + val, ok := s.Get(1) require.True(t, ok) assert.Equal(t, "b", val) - + // Out of bounds _, ok = s.Get(10) assert.False(t, ok) - + // Negative index _, ok = s.Get(-1) assert.False(t, ok) @@ -177,18 +177,18 @@ func TestSlice(t *testing.T) { t.Run("Set", func(t *testing.T) { s := NewSliceFrom([]string{"a", "b", "c"}) - + ok := s.Set(1, "modified") assert.True(t, ok) - + val, ok := s.Get(1) require.True(t, ok) assert.Equal(t, "modified", val) - + // Out of bounds ok = s.Set(10, "invalid") assert.False(t, ok) - + // Negative index ok = s.Set(-1, "invalid") assert.False(t, ok) @@ -198,13 +198,13 @@ func TestSlice(t *testing.T) { s := NewSlice[int]() s.Append(1) s.Append(2) - + newItems := []int{10, 20, 30} s.SetSlice(newItems) - + assert.Equal(t, 3, s.Len()) assert.Equal(t, newItems, s.Slice()) - + // Verify it's a copy newItems[0] = 999 val, ok := s.Get(0) @@ -215,7 +215,7 @@ func TestSlice(t *testing.T) { t.Run("Clear", func(t *testing.T) { s := NewSliceFrom([]int{1, 2, 3}) assert.Equal(t, 3, s.Len()) - + s.Clear() assert.Equal(t, 0, s.Len()) }) @@ -223,10 +223,10 @@ func TestSlice(t *testing.T) { t.Run("Slice", func(t *testing.T) { original := []int{1, 2, 3} s := NewSliceFrom(original) - + copy := s.Slice() assert.Equal(t, original, copy) - + // Verify it's a copy copy[0] = 999 val, ok := s.Get(0) @@ -236,25 +236,25 @@ func TestSlice(t *testing.T) { t.Run("Seq", func(t *testing.T) { s := NewSliceFrom([]int{1, 2, 3}) - + var result []int for v := range s.Seq() { result = append(result, v) } - + assert.Equal(t, []int{1, 2, 3}, result) }) t.Run("SeqWithIndex", func(t *testing.T) { s := NewSliceFrom([]string{"a", "b", "c"}) - + var indices []int var values []string for i, v := range s.SeqWithIndex() { indices = append(indices, i) values = append(values, v) } - + assert.Equal(t, []int{0, 1, 2}, indices) assert.Equal(t, []string{"a", "b", "c"}, values) }) @@ -263,9 +263,9 @@ func TestSlice(t *testing.T) { s := NewSlice[int]() const numGoroutines = 100 const itemsPerGoroutine = 10 - + var wg sync.WaitGroup - + // Concurrent appends for i := 0; i < numGoroutines; i++ { wg.Add(1) @@ -276,7 +276,7 @@ func TestSlice(t *testing.T) { } }(i) } - + // Concurrent reads for i := 0; i < numGoroutines; i++ { wg.Add(1) @@ -287,9 +287,9 @@ func TestSlice(t *testing.T) { } }() } - + wg.Wait() - + // Should have all items assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len()) }) diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 89930a905b162a7b470ab01588013da9577bf81c..909bcf8a42c728aea398bdc16794b63d7d6e725d 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -265,7 +265,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd { matchedItems = append(matchedItems, item) } - if f.list.direction == DirectionBackward { + if f.direction == DirectionBackward { slices.Reverse(matchedItems) } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index c3fec3000f86b0edac3debcc55644f1c10e11662..0d9c06cd920fb6c6671a0cba89acbee3912051ca 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -846,7 +846,6 @@ func (l *list[T]) SelectItemAbove() tea.Cmd { cmds = append(cmds, cmd) } } - } item, ok := l.items.Get(newIndex) if !ok { From 4451131bf1d8e11d4176dcce118d9eb0efd207c4 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 23:26:22 +0200 Subject: [PATCH 16/18] chore: add mouse support --- internal/tui/components/chat/chat.go | 6 ++++ internal/tui/exp/list/list.go | 51 +++++++++++++++++++++------- internal/tui/page/chat/chat.go | 35 +++++++++++++++++++ internal/tui/tui.go | 23 +++++-------- 4 files changed, 87 insertions(+), 28 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index fe1df1fb4fc9014407095db465e79409e693462d..90d117e64dec449d09f8ef301de661a1feefd22c 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -67,6 +67,7 @@ func New(app *app.App) MessageListCmp { list.WithDirectionBackward(), list.WithFocus(false), list.WithKeyMap(defaultListKeyMap), + list.WithEnableMouse(), ) return &messageListCmp{ app: app, @@ -97,6 +98,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case pubsub.Event[message.Message]: cmd := m.handleMessageEvent(msg) return m, cmd + + case tea.MouseWheelMsg: + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.List[list.Item]) + return m, cmd default: var cmds []tea.Cmd u, cmd := m.listCmp.Update(msg) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 0d9c06cd920fb6c6671a0cba89acbee3912051ca..68ed2de3a6a14b9d68c7f8d45644f27df86ecdec 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -23,7 +23,6 @@ type HasAnim interface { Item Spinning() bool } -type renderedMsg struct{} type List[T Item] interface { util.Model @@ -77,6 +76,7 @@ type confOptions struct { selectedItem string focused bool resize bool + enableMouse bool } type list[T Item] struct { @@ -156,6 +156,12 @@ func WithResizeByList() ListOption { } } +func WithEnableMouse() ListOption { + return func(l *confOptions) { + l.enableMouse = true + } +} + func New[T Item](items []T, opts ...ListOption) List[T] { list := &list[T]{ confOptions: &confOptions{ @@ -188,6 +194,11 @@ func (l *list[T]) Init() tea.Cmd { // Update implements List. func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.MouseWheelMsg: + if l.enableMouse { + return l.handleMouseWheel(msg) + } + return l, nil case anim.StepMsg: var cmds []tea.Cmd for _, item := range l.items.Slice() { @@ -229,6 +240,17 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return l, nil } +func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg.Button { + case tea.MouseWheelDown: + cmd = l.MoveDown(ViewportDefaultScrollSize) + case tea.MouseWheelUp: + cmd = l.MoveUp(ViewportDefaultScrollSize) + } + return l, cmd +} + // View implements List. func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { @@ -292,9 +314,8 @@ func (l *list[T]) render() tea.Cmd { } // we are not rendering the first time if l.rendered != "" { - l.rendered = "" // rerender everything will mostly hit cache - _ = l.renderIterator(0, false) + l.rendered, _ = l.renderIterator(0, false, "") if l.direction == DirectionBackward { l.recalculateItemPositions() } @@ -304,14 +325,17 @@ func (l *list[T]) render() tea.Cmd { } return focusChangeCmd } - finishIndex := l.renderIterator(0, true) + rendered, finishIndex := l.renderIterator(0, true, "") + l.rendered = rendered + // recalculate for the initial items if l.direction == DirectionBackward { l.recalculateItemPositions() } renderCmd := func() tea.Msg { + l.offset = 0 // render the rest - _ = l.renderIterator(finishIndex, false) + l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered) // needed for backwards if l.direction == DirectionBackward { l.recalculateItemPositions() @@ -321,7 +345,7 @@ func (l *list[T]) render() tea.Cmd { l.scrollToSelection() } - return renderedMsg{} + return nil } return tea.Batch(focusChangeCmd, renderCmd) } @@ -568,13 +592,14 @@ func (l *list[T]) blurSelectedItem() tea.Cmd { } // 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 +// returns the last index and the rendered content so far +// we pass the rendered content around and don't use l.rendered to prevent jumping of the content +func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) { + currentContentHeight := lipgloss.Height(rendered) - 1 itemsLen := l.items.Len() for i := startInx; i < itemsLen; i++ { if currentContentHeight >= l.height && limitHeight { - return i + return rendered, i } // cool way to go through the list in both directions inx := i @@ -602,13 +627,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int { } if l.direction == DirectionForward { - l.rendered += rItem.view + strings.Repeat("\n", gap) + rendered += rItem.view + strings.Repeat("\n", gap) } else { - l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered + rendered = rItem.view + strings.Repeat("\n", gap) + rendered } currentContentHeight = rItem.end + 1 + l.gap } - return itemsLen + return rendered, itemsLen } func (l *list[T]) renderItem(item Item) renderedItem { diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 0ec245be671e78803bb86cfbf28d2d0eb342bd67..073ac869bb5f3916e5eccbb37da135c0b012f251 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -164,6 +164,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: p.keyboardEnhancements = msg return p, nil + case tea.MouseWheelMsg: + if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) { + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd + } + return p, nil case tea.WindowSizeMsg: return p, p.SetSize(msg.Width, msg.Height) case CancelTimerExpiredMsg: @@ -906,3 +913,31 @@ func (p *chatPage) Help() help.KeyMap { func (p *chatPage) IsChatFocused() bool { return p.focusedPane == PanelTypeChat } + +// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds. +// Returns true if the mouse is over the chat area, false otherwise. +func (p *chatPage) isMouseOverChat(x, y int) bool { + // No session means no chat area + if p.session.ID == "" { + return false + } + + var chatX, chatY, chatWidth, chatHeight int + + if p.compact { + // In compact mode: chat area starts after header and spans full width + chatX = 0 + chatY = HeaderHeight + chatWidth = p.width + chatHeight = p.height - EditorHeight - HeaderHeight + } else { + // In non-compact mode: chat area spans from left edge to sidebar + chatX = 0 + chatY = 0 + chatWidth = p.width - SideBarWidth + chatHeight = p.height - EditorHeight + } + + // Check if mouse coordinates are within chat bounds + return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0e2587666f5a8c58be1466149a6b6f7a9dfb2a59..c4c88199de49fd9145dcf21fc78d452b8de14e9a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" @@ -33,26 +34,18 @@ import ( "github.com/charmbracelet/lipgloss/v2" ) -// MouseEventFilter filters mouse events based on the current focus state -// This is used with tea.WithFilter to prevent mouse scroll events from -// interfering with typing performance in the editor +var lastMouseEvent time.Time + func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { - // Only filter mouse events switch msg.(type) { case tea.MouseWheelMsg, tea.MouseMotionMsg: - // Check if we have an appModel and if editor is focused - if appModel, ok := m.(*appModel); ok { - if appModel.currentPage == chat.ChatPageID { - if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok { - // If editor is focused (not chatFocused), filter out mouse wheel/motion events - if !chatPage.IsChatFocused() { - return nil // Filter out the event - } - } - } + now := time.Now() + // trackpad is sending too many requests + if now.Sub(lastMouseEvent) < 5*time.Millisecond { + return nil } + lastMouseEvent = now } - // Allow all other events to pass through return msg } From 742bf1dc37cafec74e59b059b0a941667908e857 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 25 Jul 2025 11:41:30 +0200 Subject: [PATCH 17/18] chore: fix selected item --- internal/tui/exp/list/list.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 68ed2de3a6a14b9d68c7f8d45644f27df86ecdec..1970abb3ea925715fe01d74358d0e89c1a637fb7 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -746,17 +746,21 @@ func (l *list[T]) GetSize() (int, int) { // GoToBottom implements List. func (l *list[T]) GoToBottom() tea.Cmd { + if l.offset != 0 { + l.selectedItem = "" + } l.offset = 0 l.direction = DirectionBackward - l.selectedItem = "" return l.render() } // GoToTop implements List. func (l *list[T]) GoToTop() tea.Cmd { + if l.offset != 0 { + l.selectedItem = "" + } l.offset = 0 l.direction = DirectionForward - l.selectedItem = "" return l.render() } @@ -997,6 +1001,7 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { if cmd != nil { cmds = append(cmds, cmd) } + // if the item is at least partially below the viewport } else if oldPosition < oldItem.end { newItem, ok := l.renderedItems.Get(item.ID()) From 2911847c34322576ef8e591addf24f746461ff10 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 25 Jul 2025 11:46:23 +0200 Subject: [PATCH 18/18] chore: update golden --- ...are_at_the_bottom_in_backwards_list.golden | 20 +++++++++---------- ...d_we_are_at_the_top_in_forward_list.golden | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden index 8cea66d71fb8e43fc9e0ac8fcb6ee1000cfcb5e4..9166c3d388f45243860fde6827b42196305d84ea 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden @@ -1,10 +1,10 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing  \ No newline at end of file +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +│Item 29 +Testing  \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden index faed253a104304630e9e33decc445622cde8739a..5493247c7713c227dc5a46ea5422c2b8c10a492e 100644 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden +++ b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden @@ -1,5 +1,5 @@ -│Testing  -Item 0 +Testing  +│Item 0 Item 1 Item 1 Item 2