chore: rebase fix + sync map

Kujtim Hoxha created

Change summary

go.mod                                                                                                                                                  |   3 
go.sum                                                                                                                                                  |   6 
internal/tui/components/completions/completions.go                                                                                                      |  22 
internal/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 
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden                                                           |  14 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden                                                              |  20 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden                                                    |  20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden                                       |  20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden              |  20 
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    |  20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden                             |  20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden                                                |  25 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden                                      |  25 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden                                                                        |  20 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden                                                                 |  20 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden                                                                          |  20 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden                                                                 |  20 
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  |  20 
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      |  20 
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              |  20 
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           |  20 
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     |  20 
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     |  20 
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   |  20 
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 |  20 
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   |  20 
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 |  20 
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 |  20 
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   |  20 
32 files changed, 388 insertions(+), 330 deletions(-)

Detailed changes

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

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=

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,

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,
 			},

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)

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...)

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

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
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  

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
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  
+│Item 10  

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
+│Item 0   
+Item 1    
+Item 2    
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    

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
+│Item 0   
+Item 1    
+Item 1    
+Item 2    
+Item 2    
+Item 2    
+Item 3    
+Item 3    
+Item 3    
+Item 3    

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
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  

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
+Item 20   
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+Item 27   
+Item 28   
+│Item 29  

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
+Item 6    
+Item 6    
+Item 6    
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   

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
+Item 0    
+Item 1    
+Item 1    
+Item 2    
+Item 2    
+Item 2    
+│Item 3   
+│Item 3   
+│Item 3   
+│Item 3   

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
+│Item 28  
+│Item 28  
+│Item 28  
+│Item 28  
+│Item 28  
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   

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
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  

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  
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+│Testing  

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
+│Testing  
+Item 0    
+Item 1    
+Item 1    
+Item 2    
+Item 2    
+Item 2    
+Item 3    
+Item 3    
+Item 3    

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
+│Item 2   
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    
+Item 10   
+Item 11   

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
+Item 18   
+Item 19   
+Item 20   
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+│Item 27  

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
+│Item 2   
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    
+Item 10   
+Item 11   

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
+Item 18   
+Item 19   
+Item 20   
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+│Item 27  

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
+│Item 0   
+Item 1    
+Item 2    
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    

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
+Item 18   
+Item 19   
+Item 20   
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+│Item 27  

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
+│Item 2   
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    
+Item 10   
+Item 11   

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
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+Item 27   
+Item 28   
+│Item 29  
+Item 30   

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
+Item 18   
+Item 19   
+Item 20   
+Item 21   
+Item 22   
+Item 23   
+Item 24   
+Item 25   
+Item 26   
+│Item 27  

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
+│Item 2   
+Item 3    
+Item 4    
+Item 5    
+Item 6    
+Item 7    
+Item 8    
+Item 9    
+Item 10   
+Item 11