diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 2566ccb947e3826598988f1cc7ae297452e75bb1..6f37dddca91fcfc3f6ad83704b30e8e6cb92fcb1 100644 --- a/internal/tui/exp/list/filterable.go +++ b/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) } diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go index 9f0efc49162004cebfbc57c2c5ce96319895574e..e06fab25125e8da3f96983201b1858fc2fdeef1c 100644 --- a/internal/tui/exp/list/filterable_group.go +++ b/internal/tui/exp/list/filterable_group.go @@ -191,7 +191,6 @@ func (f *filterableGroupList[T]) Filter(query string) tea.Cmd { } } - f.selectedItem = "" if query == "" { return f.groupedList.SetGroups(f.groups) } diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 13208d393ab1086a48b06ab6e8cfd8a72a849ace..38cd4e4c90253f620a4c350ebcf5e665b1f86555 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -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())) }) } diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index d5ac87c0dea9c2a95e528a5631470e1420cfcfb0..0f7d2b64d13f7589207cd303cf7b9f3d61208c5e 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -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 diff --git a/internal/tui/exp/list/list_navigation_test.go b/internal/tui/exp/list/list_navigation_test.go index ce82fac18af2033657be6ba78270afc4df6af218..51228ed269597b867ea418e1a7071d6677abdd0f 100644 --- a/internal/tui/exp/list/list_navigation_test.go +++ b/internal/tui/exp/list/list_navigation_test.go @@ -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 diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index d61bca277618e3ce0993684ee8f36660e9242393..93ebb4b7ab5c1164351acc2117559903074fc8d6 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -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())) })