on main help sign in
chore: safe slice
4614a6cKujtim Hoxha
cspell.json+1−1
@@ -1 +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"}
1+{"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"]}
internal/csync/slices.go+127
@@ -2,6 +2,7 @@ package csync
2
2
3import (
3import (
4 "iter"
4 "iter"
5+ "slices"
5 "sync"
6 "sync"
6)
7)
7
8
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
34 }
35 }
35 }
36 }
36}
37}
38+
39+// Slice is a thread-safe slice implementation that provides concurrent access.
40+type Slice[T any] struct {
41+ inner []T
42+ mu sync.RWMutex
43+}
44+
45+// NewSlice creates a new thread-safe slice.
46+func NewSlice[T any]() *Slice[T] {
47+ return &Slice[T]{
48+ inner: make([]T, 0),
49+ }
50+}
51+
52+// NewSliceFrom creates a new thread-safe slice from an existing slice.
53+func NewSliceFrom[T any](s []T) *Slice[T] {
54+ inner := make([]T, len(s))
55+ copy(inner, s)
56+ return &Slice[T]{
57+ inner: inner,
58+ }
59+}
60+
61+// Append adds an element to the end of the slice.
62+func (s *Slice[T]) Append(item T) {
63+ s.mu.Lock()
64+ defer s.mu.Unlock()
65+ s.inner = append(s.inner, item)
66+}
67+
68+// Prepend adds an element to the beginning of the slice.
69+func (s *Slice[T]) Prepend(item T) {
70+ s.mu.Lock()
71+ defer s.mu.Unlock()
72+ s.inner = append([]T{item}, s.inner...)
73+}
74+
75+// Delete removes the element at the specified index.
76+func (s *Slice[T]) Delete(index int) bool {
77+ s.mu.Lock()
78+ defer s.mu.Unlock()
79+ if index < 0 || index >= len(s.inner) {
80+ return false
81+ }
82+ s.inner = slices.Delete(s.inner, index, index+1)
83+ return true
84+}
85+
86+// Get returns the element at the specified index.
87+func (s *Slice[T]) Get(index int) (T, bool) {
88+ s.mu.RLock()
89+ defer s.mu.RUnlock()
90+ var zero T
91+ if index < 0 || index >= len(s.inner) {
92+ return zero, false
93+ }
94+ return s.inner[index], true
95+}
96+
97+// Set updates the element at the specified index.
98+func (s *Slice[T]) Set(index int, item T) bool {
99+ s.mu.Lock()
100+ defer s.mu.Unlock()
101+ if index < 0 || index >= len(s.inner) {
102+ return false
103+ }
104+ s.inner[index] = item
105+ return true
106+}
107+
108+// Len returns the number of elements in the slice.
109+func (s *Slice[T]) Len() int {
110+ s.mu.RLock()
111+ defer s.mu.RUnlock()
112+ return len(s.inner)
113+}
114+
115+// Slice returns a copy of the underlying slice.
116+func (s *Slice[T]) Slice() []T {
117+ s.mu.RLock()
118+ defer s.mu.RUnlock()
119+ result := make([]T, len(s.inner))
120+ copy(result, s.inner)
121+ return result
122+}
123+
124+// SetSlice replaces the entire slice with a new one.
125+func (s *Slice[T]) SetSlice(items []T) {
126+ s.mu.Lock()
127+ defer s.mu.Unlock()
128+ s.inner = make([]T, len(items))
129+ copy(s.inner, items)
130+}
131+
132+// Clear removes all elements from the slice.
133+func (s *Slice[T]) Clear() {
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ s.inner = s.inner[:0]
137+}
138+
139+// Seq returns an iterator that yields elements from the slice.
140+func (s *Slice[T]) Seq() iter.Seq[T] {
141+ // Take a snapshot to avoid holding the lock during iteration
142+ items := s.Slice()
143+ return func(yield func(T) bool) {
144+ for _, v := range items {
145+ if !yield(v) {
146+ return
147+ }
148+ }
149+ }
150+}
151+
152+// SeqWithIndex returns an iterator that yields index-value pairs from the slice.
153+func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] {
154+ // Take a snapshot to avoid holding the lock during iteration
155+ items := s.Slice()
156+ return func(yield func(int, T) bool) {
157+ for i, v := range items {
158+ if !yield(i, v) {
159+ return
160+ }
161+ }
162+ }
163+}
internal/csync/slices_test.go+209
@@ -1,11 +1,13 @@
1package csync
1package csync
2
2
3import (
3import (
4+ "sync"
4 "sync/atomic"
5 "sync/atomic"
5 "testing"
6 "testing"
6 "time"
7 "time"
7
8
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
9)
11)
10
12
11func TestLazySlice_Seq(t *testing.T) {
13func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
85
87
86 assert.Equal(t, []string{"a", "b"}, result)
88 assert.Equal(t, []string{"a", "b"}, result)
87}
89}
90+
91+func TestSlice(t *testing.T) {
92+ t.Run("NewSlice", func(t *testing.T) {
93+ s := NewSlice[int]()
94+ assert.Equal(t, 0, s.Len())
95+ })
96+
97+ t.Run("NewSliceFrom", func(t *testing.T) {
98+ original := []int{1, 2, 3}
99+ s := NewSliceFrom(original)
100+ assert.Equal(t, 3, s.Len())
101+
102+ // Verify it's a copy, not a reference
103+ original[0] = 999
104+ val, ok := s.Get(0)
105+ require.True(t, ok)
106+ assert.Equal(t, 1, val)
107+ })
108+
109+ t.Run("Append", func(t *testing.T) {
110+ s := NewSlice[string]()
111+ s.Append("hello")
112+ s.Append("world")
113+
114+ assert.Equal(t, 2, s.Len())
115+ val, ok := s.Get(0)
116+ require.True(t, ok)
117+ assert.Equal(t, "hello", val)
118+
119+ val, ok = s.Get(1)
120+ require.True(t, ok)
121+ assert.Equal(t, "world", val)
122+ })
123+
124+ t.Run("Prepend", func(t *testing.T) {
125+ s := NewSlice[string]()
126+ s.Append("world")
127+ s.Prepend("hello")
128+
129+ assert.Equal(t, 2, s.Len())
130+ val, ok := s.Get(0)
131+ require.True(t, ok)
132+ assert.Equal(t, "hello", val)
133+
134+ val, ok = s.Get(1)
135+ require.True(t, ok)
136+ assert.Equal(t, "world", val)
137+ })
138+
139+ t.Run("Delete", func(t *testing.T) {
140+ s := NewSliceFrom([]int{1, 2, 3, 4, 5})
141+
142+ // Delete middle element
143+ ok := s.Delete(2)
144+ assert.True(t, ok)
145+ assert.Equal(t, 4, s.Len())
146+
147+ expected := []int{1, 2, 4, 5}
148+ actual := s.Slice()
149+ assert.Equal(t, expected, actual)
150+
151+ // Delete out of bounds
152+ ok = s.Delete(10)
153+ assert.False(t, ok)
154+ assert.Equal(t, 4, s.Len())
155+
156+ // Delete negative index
157+ ok = s.Delete(-1)
158+ assert.False(t, ok)
159+ assert.Equal(t, 4, s.Len())
160+ })
161+
162+ t.Run("Get", func(t *testing.T) {
163+ s := NewSliceFrom([]string{"a", "b", "c"})
164+
165+ val, ok := s.Get(1)
166+ require.True(t, ok)
167+ assert.Equal(t, "b", val)
168+
169+ // Out of bounds
170+ _, ok = s.Get(10)
171+ assert.False(t, ok)
172+
173+ // Negative index
174+ _, ok = s.Get(-1)
175+ assert.False(t, ok)
176+ })
177+
178+ t.Run("Set", func(t *testing.T) {
179+ s := NewSliceFrom([]string{"a", "b", "c"})
180+
181+ ok := s.Set(1, "modified")
182+ assert.True(t, ok)
183+
184+ val, ok := s.Get(1)
185+ require.True(t, ok)
186+ assert.Equal(t, "modified", val)
187+
188+ // Out of bounds
189+ ok = s.Set(10, "invalid")
190+ assert.False(t, ok)
191+
192+ // Negative index
193+ ok = s.Set(-1, "invalid")
194+ assert.False(t, ok)
195+ })
196+
197+ t.Run("SetSlice", func(t *testing.T) {
198+ s := NewSlice[int]()
199+ s.Append(1)
200+ s.Append(2)
201+
202+ newItems := []int{10, 20, 30}
203+ s.SetSlice(newItems)
204+
205+ assert.Equal(t, 3, s.Len())
206+ assert.Equal(t, newItems, s.Slice())
207+
208+ // Verify it's a copy
209+ newItems[0] = 999
210+ val, ok := s.Get(0)
211+ require.True(t, ok)
212+ assert.Equal(t, 10, val)
213+ })
214+
215+ t.Run("Clear", func(t *testing.T) {
216+ s := NewSliceFrom([]int{1, 2, 3})
217+ assert.Equal(t, 3, s.Len())
218+
219+ s.Clear()
220+ assert.Equal(t, 0, s.Len())
221+ })
222+
223+ t.Run("Slice", func(t *testing.T) {
224+ original := []int{1, 2, 3}
225+ s := NewSliceFrom(original)
226+
227+ copy := s.Slice()
228+ assert.Equal(t, original, copy)
229+
230+ // Verify it's a copy
231+ copy[0] = 999
232+ val, ok := s.Get(0)
233+ require.True(t, ok)
234+ assert.Equal(t, 1, val)
235+ })
236+
237+ t.Run("Seq", func(t *testing.T) {
238+ s := NewSliceFrom([]int{1, 2, 3})
239+
240+ var result []int
241+ for v := range s.Seq() {
242+ result = append(result, v)
243+ }
244+
245+ assert.Equal(t, []int{1, 2, 3}, result)
246+ })
247+
248+ t.Run("SeqWithIndex", func(t *testing.T) {
249+ s := NewSliceFrom([]string{"a", "b", "c"})
250+
251+ var indices []int
252+ var values []string
253+ for i, v := range s.SeqWithIndex() {
254+ indices = append(indices, i)
255+ values = append(values, v)
256+ }
257+
258+ assert.Equal(t, []int{0, 1, 2}, indices)
259+ assert.Equal(t, []string{"a", "b", "c"}, values)
260+ })
261+
262+ t.Run("ConcurrentAccess", func(t *testing.T) {
263+ s := NewSlice[int]()
264+ const numGoroutines = 100
265+ const itemsPerGoroutine = 10
266+
267+ var wg sync.WaitGroup
268+
269+ // Concurrent appends
270+ for i := 0; i < numGoroutines; i++ {
271+ wg.Add(1)
272+ go func(start int) {
273+ defer wg.Done()
274+ for j := 0; j < itemsPerGoroutine; j++ {
275+ s.Append(start*itemsPerGoroutine + j)
276+ }
277+ }(i)
278+ }
279+
280+ // Concurrent reads
281+ for i := 0; i < numGoroutines; i++ {
282+ wg.Add(1)
283+ go func() {
284+ defer wg.Done()
285+ for j := 0; j < itemsPerGoroutine; j++ {
286+ s.Len() // Just read the length
287+ }
288+ }()
289+ }
290+
291+ wg.Wait()
292+
293+ // Should have all items
294+ assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
295+ })
296+}
internal/tui/exp/list/filterable.go+1−1
@@ -97,7 +97,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
97 f.list = New[T](items, f.listOptions...).(*list[T])
97 f.list = New[T](items, f.listOptions...).(*list[T])
98
98
99 f.updateKeyMaps()
99 f.updateKeyMaps()
100− f.items = f.list.items
100+ f.items = f.list.items.Slice()
101
101
102 if f.inputHidden {
102 if f.inputHidden {
103 return f
103 return f
internal/tui/exp/list/filterable_group.go+1−1
@@ -179,7 +179,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
179
179
180func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
180func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181 var cmds []tea.Cmd
181 var cmds []tea.Cmd
182− for _, item := range f.items {
182+ for _, item := range f.items.Slice() {
183 if i, ok := any(item).(layout.Focusable); ok {
183 if i, ok := any(item).(layout.Focusable); ok {
184 cmds = append(cmds, i.Blur())
184 cmds = append(cmds, i.Blur())
185 }
185 }
internal/tui/exp/list/grouped.go+3−2
@@ -38,6 +38,7 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
38 keyMap: DefaultKeyMap(),
38 keyMap: DefaultKeyMap(),
39 focused: true,
39 focused: true,
40 },
40 },
41+ items: csync.NewSlice[Item](),
41 indexMap: csync.NewMap[string, int](),
42 indexMap: csync.NewMap[string, int](),
42 renderedItems: csync.NewMap[string, renderedItem](),
43 renderedItems: csync.NewMap[string, renderedItem](),
43 }
44 }
@@ -82,13 +83,13 @@ func (g *groupedList[T]) convertItems() {
82 items = append(items, g)
83 items = append(items, g)
83 }
84 }
84 }
85 }
85− g.items = items
86+ g.items.SetSlice(items)
86}
87}
87
88
88func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
89func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
89 g.groups = groups
90 g.groups = groups
90 g.convertItems()
91 g.convertItems()
91− return g.SetItems(g.items)
92+ return g.SetItems(g.items.Slice())
92}
93}
93
94
94func (g *groupedList[T]) Groups() []Group[T] {
95func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+90−50
@@ -1,7 +1,6 @@
1package list
1package list
2
2
3import (
3import (
4− "slices"
5 "strings"
4 "strings"
6
5
7 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
@@ -86,7 +85,7 @@ type list[T Item] struct {
86 offset int
85 offset int
87
86
88 indexMap *csync.Map[string, int]
87 indexMap *csync.Map[string, int]
89− items []T
88+ items *csync.Slice[T]
90
89
91 renderedItems *csync.Map[string, renderedItem]
90 renderedItems *csync.Map[string, renderedItem]
92
91
@@ -164,7 +163,7 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
164 keyMap: DefaultKeyMap(),
163 keyMap: DefaultKeyMap(),
165 focused: true,
164 focused: true,
166 },
165 },
167− items: items,
166+ items: csync.NewSliceFrom(items),
168 indexMap: csync.NewMap[string, int](),
167 indexMap: csync.NewMap[string, int](),
169 renderedItems: csync.NewMap[string, renderedItem](),
168 renderedItems: csync.NewMap[string, renderedItem](),
170 }
169 }
@@ -191,7 +190,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191 switch msg := msg.(type) {
190 switch msg := msg.(type) {
192 case anim.StepMsg:
191 case anim.StepMsg:
193 var cmds []tea.Cmd
192 var cmds []tea.Cmd
194− for _, item := range l.items {
193+ for _, item := range l.items.Slice() {
195 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
194 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
196 updated, cmd := i.Update(msg)
195 updated, cmd := i.Update(msg)
197 cmds = append(cmds, cmd)
196 cmds = append(cmds, cmd)
@@ -267,7 +266,7 @@ func (l *list[T]) viewPosition() (int, int) {
267
266
268func (l *list[T]) recalculateItemPositions() {
267func (l *list[T]) recalculateItemPositions() {
269 currentContentHeight := 0
268 currentContentHeight := 0
270− for _, item := range l.items {
269+ for _, item := range l.items.Slice() {
271 rItem, ok := l.renderedItems.Get(item.ID())
270 rItem, ok := l.renderedItems.Get(item.ID())
272 if !ok {
271 if !ok {
273 continue
272 continue
@@ -280,7 +279,7 @@ func (l *list[T]) recalculateItemPositions() {
280}
279}
281
280
282func (l *list[T]) render() tea.Cmd {
281func (l *list[T]) render() tea.Cmd {
283− if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
282+ if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
284 return nil
283 return nil
285 }
284 }
286 l.setDefaultSelected()
285 l.setDefaultSelected()
@@ -424,19 +423,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
424 if inx == ItemNotFound {
423 if inx == ItemNotFound {
425 return nil
424 return nil
426 }
425 }
427− item, ok := l.renderedItems.Get(l.items[inx].ID())
426+ item, ok := l.items.Get(inx)
427+ if !ok {
428+ continue
429+ }
430+ renderedItem, ok := l.renderedItems.Get(item.ID())
428 if !ok {
431 if !ok {
429 continue
432 continue
430 }
433 }
431
434
432 // If the item is bigger than the viewport, select it
435 // If the item is bigger than the viewport, select it
433− if item.start <= start && item.end >= end {
436+ if renderedItem.start <= start && renderedItem.end >= end {
434− l.selectedItem = item.id
437+ l.selectedItem = renderedItem.id
435 return l.render()
438 return l.render()
436 }
439 }
437 // item is in the view
440 // item is in the view
438− if item.start >= start && item.start <= end {
441+ if renderedItem.start >= start && renderedItem.start <= end {
439− l.selectedItem = item.id
442+ l.selectedItem = renderedItem.id
440 return l.render()
443 return l.render()
441 }
444 }
442 }
445 }
@@ -452,19 +455,23 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
452 if inx == ItemNotFound {
455 if inx == ItemNotFound {
453 return nil
456 return nil
454 }
457 }
455− item, ok := l.renderedItems.Get(l.items[inx].ID())
458+ item, ok := l.items.Get(inx)
459+ if !ok {
460+ continue
461+ }
462+ renderedItem, ok := l.renderedItems.Get(item.ID())
456 if !ok {
463 if !ok {
457 continue
464 continue
458 }
465 }
459
466
460 // If the item is bigger than the viewport, select it
467 // If the item is bigger than the viewport, select it
461− if item.start <= start && item.end >= end {
468+ if renderedItem.start <= start && renderedItem.end >= end {
462− l.selectedItem = item.id
469+ l.selectedItem = renderedItem.id
463 return l.render()
470 return l.render()
464 }
471 }
465 // item is in the view
472 // item is in the view
466− if item.end >= start && item.end <= end {
473+ if renderedItem.end >= start && renderedItem.end <= end {
467− l.selectedItem = item.id
474+ l.selectedItem = renderedItem.id
468 return l.render()
475 return l.render()
469 }
476 }
470 }
477 }
@@ -475,36 +482,51 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
475func (l *list[T]) selectFirstItem() {
482func (l *list[T]) selectFirstItem() {
476 inx := l.firstSelectableItemBelow(-1)
483 inx := l.firstSelectableItemBelow(-1)
477 if inx != ItemNotFound {
484 if inx != ItemNotFound {
485+ item, ok := l.items.Get(inx)
486+ if ok {
478− l.selectedItem = l.items[inx].ID()
487+ l.selectedItem = item.ID()
488+ }
479 }
489 }
480}
490}
481
491
482func (l *list[T]) selectLastItem() {
492func (l *list[T]) selectLastItem() {
483− inx := l.firstSelectableItemAbove(len(l.items))
493+ inx := l.firstSelectableItemAbove(l.items.Len())
484 if inx != ItemNotFound {
494 if inx != ItemNotFound {
495+ item, ok := l.items.Get(inx)
496+ if ok {
485− l.selectedItem = l.items[inx].ID()
497+ l.selectedItem = item.ID()
498+ }
486 }
499 }
487}
500}
488
501
489func (l *list[T]) firstSelectableItemAbove(inx int) int {
502func (l *list[T]) firstSelectableItemAbove(inx int) int {
490 for i := inx - 1; i >= 0; i-- {
503 for i := inx - 1; i >= 0; i-- {
504+ item, ok := l.items.Get(i)
505+ if !ok {
506+ continue
507+ }
491− if _, ok := any(l.items[i]).(layout.Focusable); ok {
508+ if _, ok := any(item).(layout.Focusable); ok {
492 return i
509 return i
493 }
510 }
494 }
511 }
495 if inx == 0 && l.wrap {
512 if inx == 0 && l.wrap {
496− return l.firstSelectableItemAbove(len(l.items))
513+ return l.firstSelectableItemAbove(l.items.Len())
497 }
514 }
498 return ItemNotFound
515 return ItemNotFound
499}
516}
500
517
501func (l *list[T]) firstSelectableItemBelow(inx int) int {
518func (l *list[T]) firstSelectableItemBelow(inx int) int {
519+ itemsLen := l.items.Len()
502− for i := inx + 1; i < len(l.items); i++ {
520+ for i := inx + 1; i < itemsLen; i++ {
521+ item, ok := l.items.Get(i)
522+ if !ok {
523+ continue
524+ }
503− if _, ok := any(l.items[i]).(layout.Focusable); ok {
525+ if _, ok := any(item).(layout.Focusable); ok {
504 return i
526 return i
505 }
527 }
506 }
528 }
507− if inx == len(l.items)-1 && l.wrap {
529+ if inx == itemsLen-1 && l.wrap {
508 return l.firstSelectableItemBelow(-1)
530 return l.firstSelectableItemBelow(-1)
509 }
531 }
510 return ItemNotFound
532 return ItemNotFound
@@ -515,7 +537,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
515 return nil
537 return nil
516 }
538 }
517 var cmds []tea.Cmd
539 var cmds []tea.Cmd
518− for _, item := range l.items {
540+ for _, item := range l.items.Slice() {
519 if f, ok := any(item).(layout.Focusable); ok {
541 if f, ok := any(item).(layout.Focusable); ok {
520 if item.ID() == l.selectedItem && !f.IsFocused() {
542 if item.ID() == l.selectedItem && !f.IsFocused() {
521 cmds = append(cmds, f.Focus())
543 cmds = append(cmds, f.Focus())
@@ -534,7 +556,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
534 return nil
556 return nil
535 }
557 }
536 var cmds []tea.Cmd
558 var cmds []tea.Cmd
537− for _, item := range l.items {
559+ for _, item := range l.items.Slice() {
538 if f, ok := any(item).(layout.Focusable); ok {
560 if f, ok := any(item).(layout.Focusable); ok {
539 if item.ID() == l.selectedItem && f.IsFocused() {
561 if item.ID() == l.selectedItem && f.IsFocused() {
540 cmds = append(cmds, f.Blur())
562 cmds = append(cmds, f.Blur())
@@ -549,7 +571,8 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
549// returns the last index
571// returns the last index
550func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
572func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
551 currentContentHeight := lipgloss.Height(l.rendered) - 1
573 currentContentHeight := lipgloss.Height(l.rendered) - 1
574+ itemsLen := l.items.Len()
552− for i := startInx; i < len(l.items); i++ {
575+ for i := startInx; i < itemsLen; i++ {
553 if currentContentHeight >= l.height && limitHeight {
576 if currentContentHeight >= l.height && limitHeight {
554 return i
577 return i
555 }
578 }
@@ -557,10 +580,13 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
557 inx := i
580 inx := i
558
581
559 if l.direction != DirectionForward {
582 if l.direction != DirectionForward {
560− inx = (len(l.items) - 1) - i
583+ inx = (itemsLen - 1) - i
561 }
584 }
562
585
563− item := l.items[inx]
586+ item, ok := l.items.Get(inx)
587+ if !ok {
588+ continue
589+ }
564 var rItem renderedItem
590 var rItem renderedItem
565 if cache, ok := l.renderedItems.Get(item.ID()); ok {
591 if cache, ok := l.renderedItems.Get(item.ID()); ok {
566 rItem = cache
592 rItem = cache
@@ -571,7 +597,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
571 l.renderedItems.Set(item.ID(), rItem)
597 l.renderedItems.Set(item.ID(), rItem)
572 }
598 }
573 gap := l.gap + 1
599 gap := l.gap + 1
574− if inx == len(l.items)-1 {
600+ if inx == itemsLen-1 {
575 gap = 0
601 gap = 0
576 }
602 }
577
603
@@ -582,7 +608,7 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
582 }
608 }
583 currentContentHeight = rItem.end + 1 + l.gap
609 currentContentHeight = rItem.end + 1 + l.gap
584 }
610 }
585− return len(l.items)
611+ return itemsLen
586}
612}
587
613
588func (l *list[T]) renderItem(item Item) renderedItem {
614func (l *list[T]) renderItem(item Item) renderedItem {
@@ -602,9 +628,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
602 cmds = append(cmds, cmd)
628 cmds = append(cmds, cmd)
603 }
629 }
604
630
605− l.items = append(l.items, item)
631+ l.items.Append(item)
606 l.indexMap = csync.NewMap[string, int]()
632 l.indexMap = csync.NewMap[string, int]()
607− for inx, item := range l.items {
633+ for inx, item := range l.items.Slice() {
608 l.indexMap.Set(item.ID(), inx)
634 l.indexMap.Set(item.ID(), inx)
609 }
635 }
610 if l.width > 0 && l.height > 0 {
636 if l.width > 0 && l.height > 0 {
@@ -627,7 +653,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
627 newItem, ok := l.renderedItems.Get(item.ID())
653 newItem, ok := l.renderedItems.Get(item.ID())
628 if ok {
654 if ok {
629 newLines := newItem.height
655 newLines := newItem.height
630− if len(l.items) > 1 {
656+ if l.items.Len() > 1 {
631 newLines += l.gap
657 newLines += l.gap
632 }
658 }
633 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
659 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -649,15 +675,20 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
649 if !ok {
675 if !ok {
650 return nil
676 return nil
651 }
677 }
652− l.items = slices.Delete(l.items, inx, inx+1)
678+ l.items.Delete(inx)
653 l.renderedItems.Del(id)
679 l.renderedItems.Del(id)
654− for inx, item := range l.items {
680+ for inx, item := range l.items.Slice() {
655 l.indexMap.Set(item.ID(), inx)
681 l.indexMap.Set(item.ID(), inx)
656 }
682 }
657
683
658 if l.selectedItem == id {
684 if l.selectedItem == id {
659 if inx > 0 {
685 if inx > 0 {
660− l.selectedItem = l.items[inx-1].ID()
686+ item, ok := l.items.Get(inx - 1)
687+ if ok {
688+ l.selectedItem = item.ID()
689+ } else {
690+ l.selectedItem = ""
691+ }
661 } else {
692 } else {
662 l.selectedItem = ""
693 l.selectedItem = ""
663 }
694 }
@@ -711,7 +742,7 @@ func (l *list[T]) IsFocused() bool {
711
742
712// Items implements List.
743// Items implements List.
713func (l *list[T]) Items() []T {
744func (l *list[T]) Items() []T {
714− return l.items
745+ return l.items.Slice()
715}
746}
716
747
717func (l *list[T]) incrementOffset(n int) {
748func (l *list[T]) incrementOffset(n int) {
@@ -764,9 +795,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
764 cmds := []tea.Cmd{
795 cmds := []tea.Cmd{
765 item.Init(),
796 item.Init(),
766 }
797 }
767− l.items = append([]T{item}, l.items...)
798+ l.items.Prepend(item)
768 l.indexMap = csync.NewMap[string, int]()
799 l.indexMap = csync.NewMap[string, int]()
769− for inx, item := range l.items {
800+ for inx, item := range l.items.Slice() {
770 l.indexMap.Set(item.ID(), inx)
801 l.indexMap.Set(item.ID(), inx)
771 }
802 }
772 if l.width > 0 && l.height > 0 {
803 if l.width > 0 && l.height > 0 {
@@ -783,7 +814,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
783 newItem, ok := l.renderedItems.Get(item.ID())
814 newItem, ok := l.renderedItems.Get(item.ID())
784 if ok {
815 if ok {
785 newLines := newItem.height
816 newLines := newItem.height
786− if len(l.items) > 1 {
817+ if l.items.Len() > 1 {
787 newLines += l.gap
818 newLines += l.gap
788 }
819 }
789 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
820 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
@@ -817,7 +848,10 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
817 }
848 }
818
849
819 }
850 }
820− item := l.items[newIndex]
851+ item, ok := l.items.Get(newIndex)
852+ if !ok {
853+ return nil
854+ }
821 l.selectedItem = item.ID()
855 l.selectedItem = item.ID()
822 l.movingByItem = true
856 l.movingByItem = true
823 renderCmd := l.render()
857 renderCmd := l.render()
@@ -839,7 +873,10 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
839 // no item above
873 // no item above
840 return nil
874 return nil
841 }
875 }
842− item := l.items[newIndex]
876+ item, ok := l.items.Get(newIndex)
877+ if !ok {
878+ return nil
879+ }
843 l.selectedItem = item.ID()
880 l.selectedItem = item.ID()
844 l.movingByItem = true
881 l.movingByItem = true
845 return l.render()
882 return l.render()
@@ -851,18 +888,21 @@ func (l *list[T]) SelectedItem() *T {
851 if !ok {
888 if !ok {
852 return nil
889 return nil
853 }
890 }
854− if inx > len(l.items)-1 {
891+ if inx > l.items.Len()-1 {
892+ return nil
893+ }
894+ item, ok := l.items.Get(inx)
895+ if !ok {
855 return nil
896 return nil
856 }
897 }
857− item := l.items[inx]
858 return &item
898 return &item
859}
899}
860
900
861// SetItems implements List.
901// SetItems implements List.
862func (l *list[T]) SetItems(items []T) tea.Cmd {
902func (l *list[T]) SetItems(items []T) tea.Cmd {
863− l.items = items
903+ l.items.SetSlice(items)
864 var cmds []tea.Cmd
904 var cmds []tea.Cmd
865− for inx, item := range l.items {
905+ for inx, item := range l.items.Slice() {
866 if i, ok := any(item).(Indexable); ok {
906 if i, ok := any(item).(Indexable); ok {
867 i.SetIndex(inx)
907 i.SetIndex(inx)
868 }
908 }
@@ -885,7 +925,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
885 l.selectedItem = selectedItem
925 l.selectedItem = selectedItem
886 l.indexMap = csync.NewMap[string, int]()
926 l.indexMap = csync.NewMap[string, int]()
887 l.renderedItems = csync.NewMap[string, renderedItem]()
927 l.renderedItems = csync.NewMap[string, renderedItem]()
888− for inx, item := range l.items {
928+ for inx, item := range l.items.Slice() {
889 l.indexMap.Set(item.ID(), inx)
929 l.indexMap.Set(item.ID(), inx)
890 if l.width > 0 && l.height > 0 {
930 if l.width > 0 && l.height > 0 {
891 cmds = append(cmds, item.SetSize(l.width, l.height))
931 cmds = append(cmds, item.SetSize(l.width, l.height))
@@ -911,7 +951,7 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
911func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
951func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
912 var cmds []tea.Cmd
952 var cmds []tea.Cmd
913 if inx, ok := l.indexMap.Get(id); ok {
953 if inx, ok := l.indexMap.Get(id); ok {
914− l.items[inx] = item
954+ l.items.Set(inx, item)
915 oldItem, hasOldItem := l.renderedItems.Get(id)
955 oldItem, hasOldItem := l.renderedItems.Get(id)
916 oldPosition := l.offset
956 oldPosition := l.offset
917 if l.direction == DirectionBackward {
957 if l.direction == DirectionBackward {
@@ -928,7 +968,7 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
928 if hasOldItem && l.direction == DirectionBackward {
968 if hasOldItem && l.direction == DirectionBackward {
929 // if we are the last item and there is no offset
969 // if we are the last item and there is no offset
930 // make sure to go to the bottom
970 // make sure to go to the bottom
931− if inx == len(l.items)-1 && l.offset == 0 {
971+ if inx == l.items.Len()-1 && l.offset == 0 {
932 cmd = l.GoToBottom()
972 cmd = l.GoToBottom()
933 if cmd != nil {
973 if cmd != nil {
934 cmds = append(cmds, cmd)
974 cmds = append(cmds, cmd)
internal/tui/exp/list/list_test.go+6−6
@@ -30,7 +30,7 @@ func TestList(t *testing.T) {
30 assert.Equal(t, items[0].ID(), l.selectedItem)
30 assert.Equal(t, items[0].ID(), l.selectedItem)
31 assert.Equal(t, 0, l.offset)
31 assert.Equal(t, 0, l.offset)
32 require.Equal(t, 5, l.indexMap.Len())
32 require.Equal(t, 5, l.indexMap.Len())
33− require.Len(t, l.items, 5)
33+ require.Equal(t, 5, l.items.Len())
34 require.Equal(t, 5, l.renderedItems.Len())
34 require.Equal(t, 5, l.renderedItems.Len())
35 assert.Equal(t, 5, lipgloss.Height(l.rendered))
35 assert.Equal(t, 5, lipgloss.Height(l.rendered))
36 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
36 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) {
60 assert.Equal(t, items[4].ID(), l.selectedItem)
60 assert.Equal(t, items[4].ID(), l.selectedItem)
61 assert.Equal(t, 0, l.offset)
61 assert.Equal(t, 0, l.offset)
62 require.Equal(t, 5, l.indexMap.Len())
62 require.Equal(t, 5, l.indexMap.Len())
63− require.Len(t, l.items, 5)
63+ require.Equal(t, 5, l.items.Len())
64 require.Equal(t, 5, l.renderedItems.Len())
64 require.Equal(t, 5, l.renderedItems.Len())
65 assert.Equal(t, 5, lipgloss.Height(l.rendered))
65 assert.Equal(t, 5, lipgloss.Height(l.rendered))
66 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
66 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) {
91 assert.Equal(t, items[0].ID(), l.selectedItem)
91 assert.Equal(t, items[0].ID(), l.selectedItem)
92 assert.Equal(t, 0, l.offset)
92 assert.Equal(t, 0, l.offset)
93 require.Equal(t, 30, l.indexMap.Len())
93 require.Equal(t, 30, l.indexMap.Len())
94− require.Len(t, l.items, 30)
94+ require.Equal(t, 30, l.items.Len())
95 require.Equal(t, 30, l.renderedItems.Len())
95 require.Equal(t, 30, l.renderedItems.Len())
96 assert.Equal(t, 30, lipgloss.Height(l.rendered))
96 assert.Equal(t, 30, lipgloss.Height(l.rendered))
97 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
97 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) {
121 assert.Equal(t, items[29].ID(), l.selectedItem)
121 assert.Equal(t, items[29].ID(), l.selectedItem)
122 assert.Equal(t, 0, l.offset)
122 assert.Equal(t, 0, l.offset)
123 require.Equal(t, 30, l.indexMap.Len())
123 require.Equal(t, 30, l.indexMap.Len())
124− require.Len(t, l.items, 30)
124+ require.Equal(t, 30, l.items.Len())
125 require.Equal(t, 30, l.renderedItems.Len())
125 require.Equal(t, 30, l.renderedItems.Len())
126 assert.Equal(t, 30, lipgloss.Height(l.rendered))
126 assert.Equal(t, 30, lipgloss.Height(l.rendered))
127 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
127 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) {
154 assert.Equal(t, items[0].ID(), l.selectedItem)
154 assert.Equal(t, items[0].ID(), l.selectedItem)
155 assert.Equal(t, 0, l.offset)
155 assert.Equal(t, 0, l.offset)
156 require.Equal(t, 30, l.indexMap.Len())
156 require.Equal(t, 30, l.indexMap.Len())
157− require.Len(t, l.items, 30)
157+ require.Equal(t, 30, l.items.Len())
158 require.Equal(t, 30, l.renderedItems.Len())
158 require.Equal(t, 30, l.renderedItems.Len())
159 expectedLines := 0
159 expectedLines := 0
160 for i := range 30 {
160 for i := range 30 {
@@ -192,7 +192,7 @@ func TestList(t *testing.T) {
192 assert.Equal(t, items[29].ID(), l.selectedItem)
192 assert.Equal(t, items[29].ID(), l.selectedItem)
193 assert.Equal(t, 0, l.offset)
193 assert.Equal(t, 0, l.offset)
194 require.Equal(t, 30, l.indexMap.Len())
194 require.Equal(t, 30, l.indexMap.Len())
195− require.Len(t, l.items, 30)
195+ require.Equal(t, 30, l.items.Len())
196 require.Equal(t, 30, l.renderedItems.Len())
196 require.Equal(t, 30, l.renderedItems.Len())
197 expectedLines := 0
197 expectedLines := 0
198 for i := range 30 {
198 for i := range 30 {