internal/tui/exp/list/filterable.go 🔗
@@ -245,7 +245,6 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
}
}
- f.selectedItem = ""
if query == "" || len(f.items) == 0 {
return f.list.SetItems(f.items)
}
Raphael Amorim created
internal/tui/exp/list/filterable.go | 1
internal/tui/exp/list/filterable_group.go | 1
internal/tui/exp/list/filterable_test.go | 2
internal/tui/exp/list/list.go | 221 +++++++++++---------
internal/tui/exp/list/list_navigation_test.go | 14
internal/tui/exp/list/list_test.go | 16
6 files changed, 142 insertions(+), 113 deletions(-)
@@ -245,7 +245,6 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
}
}
- f.selectedItem = ""
if query == "" || len(f.items) == 0 {
return f.list.SetItems(f.items)
}
@@ -191,7 +191,6 @@ func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
}
}
- f.selectedItem = ""
if query == "" {
return f.groupedList.SetGroups(f.groups)
}
@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
cmd()
}
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, items[0].ID(), l.SelectedItemID())
golden.RequireEqual(t, []byte(l.View()))
})
}
@@ -81,13 +81,13 @@ type confOptions struct {
width, height int
gap int
// if you are at the last item and go down it will wrap to the top
- wrap bool
- keyMap KeyMap
- direction direction
- selectedItem string
- focused bool
- resize bool
- enableMouse bool
+ wrap bool
+ keyMap KeyMap
+ direction direction
+ selectedIndex int // Changed from string to int for index-based selection
+ focused bool
+ resize bool
+ enableMouse bool
}
type list[T Item] struct {
@@ -147,10 +147,19 @@ func WithDirectionBackward() ListOption {
}
}
-// WithSelectedItem sets the initially selected item in the list.
+// WithSelectedItem sets the initially selected item in the list by ID.
+// This will be converted to an index when the list is created.
func WithSelectedItem(id string) ListOption {
return func(l *confOptions) {
- l.selectedItem = id
+ // Store temporarily, will be converted to index in New()
+ l.selectedIndex = -1 // Will be resolved later
+ }
+}
+
+// WithSelectedIndex sets the initially selected item in the list by index.
+func WithSelectedIndex(index int) ListOption {
+ return func(l *confOptions) {
+ l.selectedIndex = index
}
}
@@ -187,9 +196,10 @@ func WithEnableMouse() ListOption {
func New[T Item](items []T, opts ...ListOption) List[T] {
list := &list[T]{
confOptions: &confOptions{
- direction: DirectionForward,
- keyMap: DefaultKeyMap(),
- focused: true,
+ direction: DirectionForward,
+ keyMap: DefaultKeyMap(),
+ focused: true,
+ selectedIndex: -1, // Initialize to -1 to indicate no selection
},
items: csync.NewSliceFrom(items),
indexMap: csync.NewMap[string, int](),
@@ -233,16 +243,21 @@ func (l *list[T]) Init() tea.Cmd {
// Calculate positions for all items
l.calculateItemPositions()
+ // Select initial item based on direction
+ if l.selectedIndex < 0 && l.items.Len() > 0 {
+ if l.direction == DirectionForward {
+ l.selectFirstItem()
+ } else {
+ l.selectLastItem()
+ }
+ }
+
// For backward lists, we need to position at the bottom after initial render
if l.direction == DirectionBackward && l.offset == 0 && l.items.Len() > 0 {
// Set offset to show the bottom of the list
if l.virtualHeight > l.height {
l.offset = 0 // In backward mode, offset 0 means bottom
}
- // Select the last item if no item is selected
- if l.selectedItem == "" {
- l.selectLastItem()
- }
}
// Scroll to the selected item for initial positioning
@@ -592,7 +607,7 @@ func (l *list[T]) renderWithScrollToSelection(scrollToSelection bool) tea.Cmd {
}
func (l *list[T]) setDefaultSelected() {
- if l.selectedItem == "" {
+ if l.selectedIndex < 0 {
if l.direction == DirectionForward {
l.selectFirstItem()
} else {
@@ -602,13 +617,13 @@ func (l *list[T]) setDefaultSelected() {
}
func (l *list[T]) scrollToSelection() {
- if l.selectedItem == "" {
+ if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
return
}
- inx, ok := l.indexMap.Get(l.selectedItem)
- if !ok || inx < 0 || inx >= len(l.itemPositions) {
- l.selectedItem = ""
+ inx := l.selectedIndex
+ if inx < 0 || inx >= len(l.itemPositions) {
+ l.selectedIndex = -1
l.setDefaultSelected()
return
}
@@ -673,12 +688,11 @@ func (l *list[T]) scrollToSelection() {
}
func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
- inx, ok := l.indexMap.Get(l.selectedItem)
- if !ok || inx < 0 || inx >= len(l.itemPositions) {
+ if l.selectedIndex < 0 || l.selectedIndex >= len(l.itemPositions) {
return nil
}
- rItem := l.itemPositions[inx]
+ rItem := l.itemPositions[l.selectedIndex]
start, end := l.viewPosition()
// item bigger than the viewport do nothing
if rItem.start <= start && rItem.end >= end {
@@ -695,14 +709,10 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
// select the first item in the viewport
// the item is most likely an item coming after this item
for {
- inx = l.firstSelectableItemBelow(inx)
+ inx := l.firstSelectableItemBelow(l.selectedIndex)
if inx == ItemNotFound {
return nil
}
- item, ok := l.items.Get(inx)
- if !ok {
- continue
- }
if inx >= len(l.itemPositions) {
continue
}
@@ -710,12 +720,12 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
// If the item is bigger than the viewport, select it
if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItem = item.ID()
+ l.selectedIndex = inx
return l.renderWithScrollToSelection(false)
}
// item is in the view
if renderedItem.start >= start && renderedItem.start <= end {
- l.selectedItem = item.ID()
+ l.selectedIndex = inx
return l.renderWithScrollToSelection(false)
}
}
@@ -723,14 +733,10 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
// select the first item in the viewport
// the item is most likely an item coming after this item
for {
- inx = l.firstSelectableItemAbove(inx)
+ inx := l.firstSelectableItemAbove(l.selectedIndex)
if inx == ItemNotFound {
return nil
}
- item, ok := l.items.Get(inx)
- if !ok {
- continue
- }
if inx >= len(l.itemPositions) {
continue
}
@@ -738,12 +744,12 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
// If the item is bigger than the viewport, select it
if renderedItem.start <= start && renderedItem.end >= end {
- l.selectedItem = item.ID()
+ l.selectedIndex = inx
return l.renderWithScrollToSelection(false)
}
// item is in the view
if renderedItem.end >= start && renderedItem.end <= end {
- l.selectedItem = item.ID()
+ l.selectedIndex = inx
return l.renderWithScrollToSelection(false)
}
}
@@ -754,20 +760,14 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
func (l *list[T]) selectFirstItem() {
inx := l.firstSelectableItemBelow(-1)
if inx != ItemNotFound {
- item, ok := l.items.Get(inx)
- if ok {
- l.selectedItem = item.ID()
- }
+ l.selectedIndex = inx
}
}
func (l *list[T]) selectLastItem() {
inx := l.firstSelectableItemAbove(l.items.Len())
if inx != ItemNotFound {
- item, ok := l.items.Get(inx)
- if ok {
- l.selectedItem = item.ID()
- }
+ l.selectedIndex = inx
}
}
@@ -805,16 +805,16 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
}
func (l *list[T]) focusSelectedItem() tea.Cmd {
- if l.selectedItem == "" || !l.focused {
+ if l.selectedIndex < 0 || !l.focused {
return nil
}
var cmds []tea.Cmd
- for _, item := range slices.Collect(l.items.Seq()) {
+ for inx, item := range slices.Collect(l.items.Seq()) {
if f, ok := any(item).(layout.Focusable); ok {
- if item.ID() == l.selectedItem && !f.IsFocused() {
+ if inx == l.selectedIndex && !f.IsFocused() {
cmds = append(cmds, f.Focus())
l.viewCache.Del(item.ID())
- } else if item.ID() != l.selectedItem && f.IsFocused() {
+ } else if inx != l.selectedIndex && f.IsFocused() {
cmds = append(cmds, f.Blur())
l.viewCache.Del(item.ID())
}
@@ -824,13 +824,13 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
}
func (l *list[T]) blurSelectedItem() tea.Cmd {
- if l.selectedItem == "" || l.focused {
+ if l.selectedIndex < 0 || l.focused {
return nil
}
var cmds []tea.Cmd
- for _, item := range slices.Collect(l.items.Seq()) {
+ for inx, item := range slices.Collect(l.items.Seq()) {
if f, ok := any(item).(layout.Focusable); ok {
- if item.ID() == l.selectedItem && f.IsFocused() {
+ if inx == l.selectedIndex && f.IsFocused() {
cmds = append(cmds, f.Blur())
l.viewCache.Del(item.ID())
}
@@ -1090,6 +1090,22 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
if !ok {
return nil
}
+
+ // Check if we're deleting the selected item
+ if l.selectedIndex == inx {
+ // Adjust selection
+ if inx > 0 {
+ l.selectedIndex = inx - 1
+ } else if l.items.Len() > 1 {
+ l.selectedIndex = 0 // Will be valid after deletion
+ } else {
+ l.selectedIndex = -1 // No items left
+ }
+ } else if l.selectedIndex > inx {
+ // Adjust index if selected item is after deleted item
+ l.selectedIndex--
+ }
+
l.items.Delete(inx)
l.viewCache.Del(id)
// Rebuild index map
@@ -1097,19 +1113,7 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
}
-
- if l.selectedItem == id {
- if inx > 0 {
- item, ok := l.items.Get(inx - 1)
- if ok {
- l.selectedItem = item.ID()
- } else {
- l.selectedItem = ""
- }
- } else {
- l.selectedItem = ""
- }
- }
+
cmd := l.render()
if l.rendered != "" {
renderedHeight := l.virtualHeight
@@ -1139,7 +1143,7 @@ func (l *list[T]) GetSize() (int, int) {
// GoToBottom implements List.
func (l *list[T]) GoToBottom() tea.Cmd {
l.offset = 0
- l.selectedItem = ""
+ l.selectedIndex = -1
l.direction = DirectionBackward
return l.render()
}
@@ -1147,7 +1151,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
// GoToTop implements List.
func (l *list[T]) GoToTop() tea.Cmd {
l.offset = 0
- l.selectedItem = ""
+ l.selectedIndex = -1
l.direction = DirectionForward
return l.render()
}
@@ -1305,17 +1309,22 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
// since offset is from the bottom
cmds = append(cmds, l.render())
}
+
+ // Adjust selected index since we prepended
+ if l.selectedIndex >= 0 {
+ l.selectedIndex++
+ }
+
return tea.Batch(cmds...)
}
// SelectItemAbove implements List.
func (l *list[T]) SelectItemAbove() tea.Cmd {
- inx, ok := l.indexMap.Get(l.selectedItem)
- if !ok {
+ if l.selectedIndex < 0 {
return nil
}
- newIndex := l.firstSelectableItemAbove(inx)
+ newIndex := l.firstSelectableItemAbove(l.selectedIndex)
if newIndex == ItemNotFound {
// no item above
return nil
@@ -1331,11 +1340,7 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
}
}
}
- item, ok := l.items.Get(newIndex)
- if !ok {
- return nil
- }
- l.selectedItem = item.ID()
+ l.selectedIndex = newIndex
l.movingByItem = true
renderCmd := l.render()
if renderCmd != nil {
@@ -1346,41 +1351,44 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
// SelectItemBelow implements List.
func (l *list[T]) SelectItemBelow() tea.Cmd {
- inx, ok := l.indexMap.Get(l.selectedItem)
- if !ok {
+ if l.selectedIndex < 0 {
return nil
}
- newIndex := l.firstSelectableItemBelow(inx)
+ newIndex := l.firstSelectableItemBelow(l.selectedIndex)
if newIndex == ItemNotFound {
- // no item above
- return nil
- }
- item, ok := l.items.Get(newIndex)
- if !ok {
+ // no item below
return nil
}
- l.selectedItem = item.ID()
+ l.selectedIndex = newIndex
l.movingByItem = true
return l.render()
}
// SelectedItem implements List.
func (l *list[T]) SelectedItem() *T {
- inx, ok := l.indexMap.Get(l.selectedItem)
- if !ok {
- return nil
- }
- if inx > l.items.Len()-1 {
+ if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
return nil
}
- item, ok := l.items.Get(inx)
+ item, ok := l.items.Get(l.selectedIndex)
if !ok {
return nil
}
return &item
}
+// SelectedItemID returns the ID of the currently selected item (for testing).
+func (l *list[T]) SelectedItemID() string {
+ if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
+ return ""
+ }
+ item, ok := l.items.Get(l.selectedIndex)
+ if !ok {
+ return ""
+ }
+ return item.ID()
+}
+
// SetItems implements List.
func (l *list[T]) SetItems(items []T) tea.Cmd {
l.items.SetSlice(items)
@@ -1397,15 +1405,31 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
// SetSelected implements List.
func (l *list[T]) SetSelected(id string) tea.Cmd {
- l.selectedItem = id
+ inx, ok := l.indexMap.Get(id)
+ if ok {
+ l.selectedIndex = inx
+ } else {
+ l.selectedIndex = -1
+ }
return l.render()
}
-func (l *list[T]) reset(selectedItem string) tea.Cmd {
+func (l *list[T]) reset(selectedItemID string) tea.Cmd {
var cmds []tea.Cmd
l.rendered = ""
l.offset = 0
- l.selectedItem = selectedItem
+
+ // Convert ID to index if provided
+ if selectedItemID != "" {
+ if inx, ok := l.indexMap.Get(selectedItemID); ok {
+ l.selectedIndex = inx
+ } else {
+ l.selectedIndex = -1
+ }
+ } else {
+ l.selectedIndex = -1
+ }
+
l.indexMap = csync.NewMap[string, int]()
l.viewCache = csync.NewMap[string, string]()
l.itemPositions = nil // Will be recalculated
@@ -1427,7 +1451,14 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
l.width = width
l.height = height
if oldWidth != width {
- cmd := l.reset(l.selectedItem)
+ // Get current selected item ID to preserve selection
+ var selectedID string
+ if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
+ if item, ok := l.items.Get(l.selectedIndex); ok {
+ selectedID = item.ID()
+ }
+ }
+ cmd := l.reset(selectedID)
return cmd
}
return nil
@@ -77,7 +77,7 @@ func TestArrowKeyNavigation(t *testing.T) {
execCmdNav(l, l.Init())
// Initial state - first item should be selected
- assert.Equal(t, "item1", l.selectedItem)
+ assert.Equal(t, "item1", l.SelectedItemID())
assert.Equal(t, 0, l.offset)
// Navigate down to item 2
@@ -86,7 +86,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item2", l.selectedItem)
+ assert.Equal(t, "item2", l.SelectedItemID())
// Item 2 should be fully visible
view := l.View()
assert.Contains(t, view, "Item 2 (3 lines)")
@@ -99,7 +99,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item3", l.selectedItem)
+ assert.Equal(t, "item3", l.SelectedItemID())
view = l.View()
assert.Contains(t, view, "Item 3 (1 line)")
@@ -109,7 +109,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item4", l.selectedItem)
+ assert.Equal(t, "item4", l.SelectedItemID())
view = l.View()
// All lines of item 4 should be visible
assert.Contains(t, view, "Item 4 (4 lines)")
@@ -123,7 +123,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item3", l.selectedItem)
+ assert.Equal(t, "item3", l.SelectedItemID())
view = l.View()
assert.Contains(t, view, "Item 3 (1 line)")
@@ -133,7 +133,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item2", l.selectedItem)
+ assert.Equal(t, "item2", l.SelectedItemID())
view = l.View()
// All lines of item 2 should be visible
assert.Contains(t, view, "Item 2 (3 lines)")
@@ -203,7 +203,7 @@ func TestArrowKeyNavigation(t *testing.T) {
}))
execCmdNav(l, cmd)
- assert.Equal(t, "item2", l.selectedItem)
+ assert.Equal(t, "item2", l.SelectedItemID())
view := l.View()
// Should show the item from the top
@@ -27,7 +27,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, items[0].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Equal(t, 5, l.items.Len())
@@ -56,7 +56,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[4].ID(), l.selectedItem)
+ assert.Equal(t, items[4].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Equal(t, 5, l.items.Len())
@@ -86,7 +86,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, items[0].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
@@ -116,7 +116,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, items[29].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
@@ -149,7 +149,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[0].ID(), l.selectedItem)
+ assert.Equal(t, items[0].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
@@ -189,7 +189,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[29].ID(), l.selectedItem)
+ assert.Equal(t, items[29].ID(), l.SelectedItemID())
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
@@ -230,7 +230,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[10].ID(), l.selectedItem)
+ assert.Equal(t, items[10].ID(), l.SelectedItemID())
golden.RequireEqual(t, []byte(l.View()))
})
@@ -248,7 +248,7 @@ func TestList(t *testing.T) {
execCmd(l, l.Init())
// should select item 10
- assert.Equal(t, items[10].ID(), l.selectedItem)
+ assert.Equal(t, items[10].ID(), l.SelectedItemID())
golden.RequireEqual(t, []byte(l.View()))
})