on main help sign in
chore: implement new filterable list

- use this list in the sessions selector

commitaab9b3b8d668090b73d62407e7b06fb97d1d8be0 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 21 Jul 2025, 16:59 (1 year ago) parent966d24d
13 files changed+1159−336
cspell.json+1−108 internal/tui/components/core/list/list.go−1 internal/tui/components/dialogs/sessions/sessions.go+29−34 internal/tui/exp/list/filterable.go+297 internal/tui/exp/list/filterable_test.go+67 internal/tui/exp/list/items.go+308 internal/tui/exp/list/keys.go+63 internal/tui/exp/list/list.go+220−95 internal/tui/exp/list/list_test.go+148−93 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden+6 internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden+5−5 internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden+5 internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden+10
cspell.json+1−108
@@ -1,108 +1 @@
1−{
2− "words": [
3− "afero",
4− "agentic",
5− "alecthomas",
6− "anthropics",
7− "aymanbagabas",
8− "azidentity",
9− "bmatcuk",
10− "bubbletea",
11− "charlievieth",
12− "charmbracelet",
13− "charmtone",
14− "Charple",
15− "chkconfig",
16− "crush",
17− "curlie",
18− "cursorrules",
19− "diffview",
20− "doas",
21− "Dockerfiles",
22− "doublestar",
23− "dpkg",
24− "Emph",
25− "fastwalk",
26− "fdisk",
27− "filepicker",
28− "Focusable",
29− "fseventsd",
30− "fsext",
31− "genai",
32− "goquery",
33− "GROQ",
34− "Guac",
35− "imageorient",
36− "Inex",
37− "jetta",
38− "jsons",
39− "jsonschema",
40− "jspm",
41− "Kaufmann",
42− "killall",
43− "Lanczos",
44− "lipgloss",
45− "LOCALAPPDATA",
46− "lsps",
47− "lucasb",
48− "makepkg",
49− "mcps",
50− "MSYS",
51− "mvdan",
52− "natefinch",
53− "nfnt",
54− "noctx",
55− "nohup",
56− "nolint",
57− "nslookup",
58− "oksvg",
59− "Oneshot",
60− "openrouter",
61− "opkg",
62− "pacman",
63− "paru",
64− "pfctl",
65− "postamble",
66− "postambles",
67− "preconfigured",
68− "Preproc",
69− "Proactiveness",
70− "Puerkito",
71− "pycache",
72− "pytest",
73− "qjebbs",
74− "rasterx",
75− "rivo",
76− "sabhiram",
77− "sess",
78− "shortlog",
79− "sjson",
80− "Sourcegraph",
81− "srwiley",
82− "SSEMCP",
83− "Streamable",
84− "stretchr",
85− "Strikethrough",
86− "substrs",
87− "Suscriber",
88− "systeminfo",
89− "tasklist",
90− "termenv",
91− "textinput",
92− "tidwall",
93− "timedout",
94− "trashhalo",
95− "udiff",
96− "uniseg",
97− "Unticked",
98− "urllib",
99− "USERPROFILE",
100− "VERTEXAI",
101− "webp",
102− "whatis",
103− "whereis"
104− ],
105− "flagWords": [],
106− "language": "en",
107− "version": "0.2"
108−}
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"}
internal/tui/components/core/list/list.go−1
@@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel {
248248 }
249249
250250 if m.filterable && !m.hideFilterInput {
251− t := styles.CurrentTheme()
252251 ti := textinput.New()
253252 ti.Placeholder = m.filterPlaceholder
254253 ti.SetVirtualCursor(false)
internal/tui/components/dialogs/sessions/sessions.go+29−34
@@ -6,10 +6,9 @@ import (
66 tea "github.com/charmbracelet/bubbletea/v2"
77 "github.com/charmbracelet/crush/internal/session"
88 "github.com/charmbracelet/crush/internal/tui/components/chat"
9− "github.com/charmbracelet/crush/internal/tui/components/completions"
109 "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1210 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
11+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1312 "github.com/charmbracelet/crush/internal/tui/styles"
1413 "github.com/charmbracelet/crush/internal/tui/util"
1514 "github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
2221 dialogs.DialogModel
2322}
2423
24+type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
25+
2526type sessionDialogCmp struct {
2627 selectedInx int
2728 wWidth int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
2930 width int
3031 selectedSessionID string
3132 keyMap KeyMap
32− sessionsList list.ListModel
33− renderedSelected bool
33+ sessionsList SessionsList
3434 help help.Model
3535}
3636
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
3939 t := styles.CurrentTheme()
4040 listKeyMap := list.DefaultKeyMap()
4141 keyMap := DefaultKeyMap()
42−
4342 listKeyMap.Down.SetEnabled(false)
4443 listKeyMap.Up.SetEnabled(false)
45− listKeyMap.HalfPageDown.SetEnabled(false)
46− listKeyMap.HalfPageUp.SetEnabled(false)
47− listKeyMap.Home.SetEnabled(false)
48− listKeyMap.End.SetEnabled(false)
49−
5044 listKeyMap.DownOneItem = keyMap.Next
5145 listKeyMap.UpOneItem = keyMap.Previous
5246
53− selectedInx := 0
54− items := make([]util.Model, len(sessions))
47+ items := make([]list.CompletionItem[session.Session], len(sessions))
5548 if len(sessions) > 0 {
5649 for i, session := range sessions {
57− items[i] = completions.NewCompletionItem(session.Title, session)
58− if session.ID == selectedID {
59− selectedInx = i
60− }
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
6151 }
6252 }
6353
64− sessionsList := list.New(
65− list.WithFilterable(true),
54+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
55+ sessionsList := list.NewFilterableList(
56+ items,
6657 list.WithFilterPlaceholder("Enter a session name"),
67− list.WithKeyMap(listKeyMap),
68− list.WithItems(items),
69− list.WithWrapNavigation(true),
58+ list.WithFilterInputStyle(inputStyle),
59+ list.WithFilterListOptions(
60+ list.WithKeyMap(listKeyMap),
61+ list.WithWrapNavigation(),
62+ ),
7063 )
7164 help := help.New()
7265 help.Styles = t.S().Help
7366 s := &sessionDialogCmp{
74− selectedInx: selectedInx,
7567 selectedSessionID: selectedID,
7668 keyMap: DefaultKeyMap(),
7769 sessionsList: sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
8274}
8375
8476func (s *sessionDialogCmp) Init() tea.Cmd {
85− return s.sessionsList.Init()
77+ var cmds []tea.Cmd
78+ cmds = append(cmds, s.sessionsList.Init())
79+ cmds = append(cmds, s.sessionsList.Focus())
80+ return tea.Sequence(cmds...)
8681}
8782
8883func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8984 switch msg := msg.(type) {
9085 case tea.WindowSizeMsg:
86+ var cmds []tea.Cmd
9187 s.wWidth = msg.Width
9288 s.wHeight = msg.Height
93− s.width = s.wWidth / 2
94− var cmds []tea.Cmd
89+ s.width = min(120, s.wWidth-8)
90+ s.sessionsList.SetInputWidth(s.listWidth() - 2)
9591 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
96− if !s.renderedSelected {
97− cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
98− s.renderedSelected = true
92+ if s.selectedSessionID != "" {
93+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
9994 }
100− return s, tea.Sequence(cmds...)
95+ return s, tea.Batch(cmds...)
10196 case tea.KeyPressMsg:
10297 switch {
10398 case key.Matches(msg, s.keyMap.Select):
104− if len(s.sessionsList.Items()) > 0 {
105− items := s.sessionsList.Items()
106− selectedItemInx := s.sessionsList.SelectedIndex()
99+ selectedItem := s.sessionsList.SelectedItem()
100+ if selectedItem != nil {
101+ selected := *selectedItem
107102 return s, tea.Sequence(
108103 util.CmdHandler(dialogs.CloseDialogMsg{}),
109104 util.CmdHandler(
110− chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
105+ chat.SessionSelectedMsg(selected.Value()),
111106 ),
112107 )
113108 }
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115110 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116111 default:
117112 u, cmd := s.sessionsList.Update(msg)
118− s.sessionsList = u.(list.ListModel)
113+ s.sessionsList = u.(SessionsList)
119114 return s, cmd
120115 }
121116 }
internal/tui/exp/list/filterable.gonew file+297
@@ -0,0 +1,297 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "sort"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/sahilm/fuzzy"
15+)
16+
17+type FilterableItem interface {
18+ Item
19+ FilterValue() string
20+}
21+
22+type FilterableList[T FilterableItem] interface {
23+ List[T]
24+ Cursor() *tea.Cursor
25+ SetInputWidth(int)
26+}
27+
28+type HasMatchIndexes interface {
29+ MatchIndexes([]int)
30+}
31+
32+type filterableOptions struct {
33+ listOptions []listOption
34+ placeholder string
35+ inputHidden bool
36+ inputWidth int
37+ inputStyle lipgloss.Style
38+}
39+type filterableList[T FilterableItem] struct {
40+ *list[T]
41+ filterableOptions
42+ width, height int
43+ // stores all available items
44+ items []T
45+ input textinput.Model
46+ inputWidth int
47+ query string
48+}
49+
50+type filterableListOption func(*filterableOptions)
51+
52+func WithFilterPlaceholder(ph string) filterableListOption {
53+ return func(f *filterableOptions) {
54+ f.placeholder = ph
55+ }
56+}
57+
58+func WithFilterInputHidden() filterableListOption {
59+ return func(f *filterableOptions) {
60+ f.inputHidden = true
61+ }
62+}
63+
64+func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
65+ return func(f *filterableOptions) {
66+ f.inputStyle = inputStyle
67+ }
68+}
69+
70+func WithFilterListOptions(opts ...listOption) filterableListOption {
71+ return func(f *filterableOptions) {
72+ f.listOptions = opts
73+ }
74+}
75+
76+func WithFilterInputWidth(inputWidth int) filterableListOption {
77+ return func(f *filterableOptions) {
78+ f.inputWidth = inputWidth
79+ }
80+}
81+
82+func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
83+ t := styles.CurrentTheme()
84+
85+ f := &filterableList[T]{
86+ filterableOptions: filterableOptions{
87+ inputStyle: t.S().Base,
88+ placeholder: "Type to filter",
89+ },
90+ }
91+ for _, opt := range opts {
92+ opt(&f.filterableOptions)
93+ }
94+ f.list = New[T](items, f.listOptions...).(*list[T])
95+
96+ f.updateKeyMaps()
97+ f.items = f.list.items
98+
99+ if f.inputHidden {
100+ return f
101+ }
102+
103+ ti := textinput.New()
104+ ti.Placeholder = f.placeholder
105+ ti.SetVirtualCursor(false)
106+ ti.Focus()
107+ ti.SetStyles(t.S().TextInput)
108+ f.input = ti
109+ return f
110+}
111+
112+func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113+ switch msg := msg.(type) {
114+ case tea.KeyPressMsg:
115+ switch {
116+ // handle movements
117+ case key.Matches(msg, f.keyMap.Down),
118+ key.Matches(msg, f.keyMap.Up),
119+ key.Matches(msg, f.keyMap.DownOneItem),
120+ key.Matches(msg, f.keyMap.UpOneItem),
121+ key.Matches(msg, f.keyMap.HalfPageDown),
122+ key.Matches(msg, f.keyMap.HalfPageUp),
123+ key.Matches(msg, f.keyMap.PageDown),
124+ key.Matches(msg, f.keyMap.PageUp),
125+ key.Matches(msg, f.keyMap.End),
126+ key.Matches(msg, f.keyMap.Home):
127+ u, cmd := f.list.Update(msg)
128+ f.list = u.(*list[T])
129+ return f, cmd
130+ default:
131+ if !f.inputHidden {
132+ var cmds []tea.Cmd
133+ var cmd tea.Cmd
134+ f.input, cmd = f.input.Update(msg)
135+ cmds = append(cmds, cmd)
136+
137+ if f.query != f.input.Value() {
138+ cmd = f.Filter(f.input.Value())
139+ cmds = append(cmds, cmd)
140+ }
141+ f.query = f.input.Value()
142+ return f, tea.Batch(cmds...)
143+ }
144+ }
145+ }
146+ u, cmd := f.list.Update(msg)
147+ f.list = u.(*list[T])
148+ return f, cmd
149+}
150+
151+func (f *filterableList[T]) View() string {
152+ if f.inputHidden {
153+ return f.list.View()
154+ }
155+
156+ return lipgloss.JoinVertical(
157+ lipgloss.Left,
158+ f.inputStyle.Render(f.input.View()),
159+ f.list.View(),
160+ )
161+}
162+
163+// removes bindings that are used for search
164+func (f *filterableList[T]) updateKeyMaps() {
165+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
166+
167+ removeLettersAndNumbers := func(bindings []string) []string {
168+ var keep []string
169+ for _, b := range bindings {
170+ if len(b) != 1 {
171+ keep = append(keep, b)
172+ continue
173+ }
174+ if b == " " {
175+ continue
176+ }
177+ m := alphanumeric.MatchString(b)
178+ if !m {
179+ keep = append(keep, b)
180+ }
181+ }
182+ return keep
183+ }
184+
185+ updateBinding := func(binding key.Binding) key.Binding {
186+ newKeys := removeLettersAndNumbers(binding.Keys())
187+ if len(newKeys) == 0 {
188+ binding.SetEnabled(false)
189+ return binding
190+ }
191+ binding.SetKeys(newKeys...)
192+ return binding
193+ }
194+
195+ f.keyMap.Down = updateBinding(f.keyMap.Down)
196+ f.keyMap.Up = updateBinding(f.keyMap.Up)
197+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
198+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
199+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
200+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
201+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
202+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
203+ f.keyMap.End = updateBinding(f.keyMap.End)
204+ f.keyMap.Home = updateBinding(f.keyMap.Home)
205+}
206+
207+func (m *filterableList[T]) GetSize() (int, int) {
208+ return m.width, m.height
209+}
210+
211+func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
212+ f.width = w
213+ f.height = h
214+ if f.inputHidden {
215+ return f.list.SetSize(w, h)
216+ }
217+ if f.inputWidth == 0 {
218+ f.input.SetWidth(w)
219+ } else {
220+ f.input.SetWidth(f.inputWidth)
221+ }
222+ return f.list.SetSize(w, h-(f.inputHeight()))
223+}
224+
225+func (f *filterableList[T]) inputHeight() int {
226+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
227+}
228+
229+func (f *filterableList[T]) Filter(query string) tea.Cmd {
230+ var cmds []tea.Cmd
231+ for _, item := range f.items {
232+ if i, ok := any(item).(layout.Focusable); ok {
233+ cmds = append(cmds, i.Blur())
234+ }
235+ if i, ok := any(item).(HasMatchIndexes); ok {
236+ i.MatchIndexes(make([]int, 0))
237+ }
238+ }
239+
240+ f.selectedItem = ""
241+ if query == "" {
242+ return f.list.SetItems(f.items)
243+ }
244+
245+ words := make([]string, len(f.items))
246+ for i, item := range f.items {
247+ words[i] = strings.ToLower(item.FilterValue())
248+ }
249+
250+ matches := fuzzy.Find(query, words)
251+
252+ sort.SliceStable(matches, func(i, j int) bool {
253+ return matches[i].Score > matches[j].Score
254+ })
255+
256+ var matchedItems []T
257+ for _, match := range matches {
258+ item := f.items[match.Index]
259+ if i, ok := any(item).(HasMatchIndexes); ok {
260+ i.MatchIndexes(match.MatchedIndexes)
261+ }
262+ matchedItems = append(matchedItems, item)
263+ }
264+
265+ cmds = append(cmds, f.list.SetItems(matchedItems))
266+ return tea.Batch(cmds...)
267+}
268+
269+func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
270+ f.items = items
271+ return f.list.SetItems(items)
272+}
273+
274+func (f *filterableList[T]) Cursor() *tea.Cursor {
275+ if f.inputHidden {
276+ return nil
277+ }
278+ return f.input.Cursor()
279+}
280+
281+func (f *filterableList[T]) Blur() tea.Cmd {
282+ f.input.Blur()
283+ return f.list.Blur()
284+}
285+
286+func (f *filterableList[T]) Focus() tea.Cmd {
287+ f.input.Focus()
288+ return f.list.Focus()
289+}
290+
291+func (f *filterableList[T]) IsFocused() bool {
292+ return f.list.IsFocused()
293+}
294+
295+func (f *filterableList[T]) SetInputWidth(w int) {
296+ f.inputWidth = w
297+}
internal/tui/exp/list/filterable_test.gonew file+67
@@ -0,0 +1,67 @@
1+package list
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "testing"
7+
8+ "github.com/charmbracelet/x/exp/golden"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestFilterableList(t *testing.T) {
13+ t.Run("should create simple filterable list", func(t *testing.T) {
14+ t.Parallel()
15+ items := []FilterableItem{}
16+ for i := range 5 {
17+ item := NewFilterableItem(fmt.Sprintf("Item %d", i))
18+ items = append(items, item)
19+ }
20+ l := NewFilterableList(
21+ items,
22+ WithFilterListOptions(WithDirection(Forward)),
23+ ).(*filterableList[FilterableItem])
24+
25+ l.SetSize(100, 10)
26+ cmd := l.Init()
27+ if cmd != nil {
28+ cmd()
29+ }
30+
31+ assert.Equal(t, items[0].ID(), l.selectedItem)
32+ golden.RequireEqual(t, []byte(l.View()))
33+ })
34+}
35+
36+func TestUpdateKeyMap(t *testing.T) {
37+ t.Parallel()
38+ l := NewFilterableList(
39+ []FilterableItem{},
40+ WithFilterListOptions(WithDirection(Forward)),
41+ ).(*filterableList[FilterableItem])
42+
43+ hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
44+ fmt.Println(l.keyMap.Down.Keys())
45+ hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
46+
47+ hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
48+
49+ assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
50+ assert.False(t, hasJ, "should not contain j")
51+ assert.False(t, hasUpperCaseK, "should also remove upper case K")
52+ assert.True(t, hasCtrlJ, "should still have ctrl+j")
53+}
54+
55+type filterableItem struct {
56+ *selectableItem
57+}
58+
59+func NewFilterableItem(content string) FilterableItem {
60+ return &filterableItem{
61+ selectableItem: NewSelectableItem(content).(*selectableItem),
62+ }
63+}
64+
65+func (f *filterableItem) FilterValue() string {
66+ return f.content
67+}
internal/tui/exp/list/items.gonew file+308
@@ -0,0 +1,308 @@
1+package list
2+
3+import (
4+ "image/color"
5+
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8+ "github.com/charmbracelet/crush/internal/tui/styles"
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/charmbracelet/x/ansi"
11+ "github.com/google/uuid"
12+ "github.com/rivo/uniseg"
13+)
14+
15+type CompletionItem[T any] interface {
16+ FilterableItem
17+ layout.Focusable
18+ layout.Sizeable
19+ HasMatchIndexes
20+ Value() T
21+}
22+
23+type completionItemCmp[T any] struct {
24+ width int
25+ id string
26+ text string
27+ value T
28+ focus bool
29+ matchIndexes []int
30+ bgColor color.Color
31+ shortcut string
32+}
33+
34+type options struct {
35+ id string
36+ text string
37+ bgColor color.Color
38+ matchIndexes []int
39+ shortcut string
40+}
41+
42+type completionOption func(*options)
43+
44+func WithBackgroundColor(c color.Color) completionOption {
45+ return func(cmp *options) {
46+ cmp.bgColor = c
47+ }
48+}
49+
50+func WithMatchIndexes(indexes ...int) completionOption {
51+ return func(cmp *options) {
52+ cmp.matchIndexes = indexes
53+ }
54+}
55+
56+func WithShortcut(shortcut string) completionOption {
57+ return func(cmp *options) {
58+ cmp.shortcut = shortcut
59+ }
60+}
61+
62+func WithID(id string) completionOption {
63+ return func(cmp *options) {
64+ cmp.id = id
65+ }
66+}
67+
68+func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
69+ c := &completionItemCmp[T]{
70+ text: text,
71+ value: value,
72+ }
73+ o := &options{}
74+
75+ for _, opt := range opts {
76+ opt(o)
77+ }
78+ if o.id == "" {
79+ o.id = uuid.NewString()
80+ }
81+ c.id = o.id
82+ c.bgColor = o.bgColor
83+ c.matchIndexes = o.matchIndexes
84+ c.shortcut = o.shortcut
85+ return c
86+}
87+
88+// Init implements CommandItem.
89+func (c *completionItemCmp[T]) Init() tea.Cmd {
90+ return nil
91+}
92+
93+// Update implements CommandItem.
94+func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
95+ return c, nil
96+}
97+
98+// View implements CommandItem.
99+func (c *completionItemCmp[T]) View() string {
100+ t := styles.CurrentTheme()
101+
102+ itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
103+ innerWidth := c.width - 2 // Account for padding
104+
105+ if c.shortcut != "" {
106+ innerWidth -= lipgloss.Width(c.shortcut)
107+ }
108+
109+ titleStyle := t.S().Text.Width(innerWidth)
110+ titleMatchStyle := t.S().Text.Underline(true)
111+ if c.bgColor != nil {
112+ titleStyle = titleStyle.Background(c.bgColor)
113+ titleMatchStyle = titleMatchStyle.Background(c.bgColor)
114+ itemStyle = itemStyle.Background(c.bgColor)
115+ }
116+
117+ if c.focus {
118+ titleStyle = t.S().TextSelected.Width(innerWidth)
119+ titleMatchStyle = t.S().TextSelected.Underline(true)
120+ itemStyle = itemStyle.Background(t.Primary)
121+ }
122+
123+ var truncatedTitle string
124+
125+ if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
126+ // Smart truncation: ensure the last matching part is visible
127+ truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
128+ } else {
129+ // No matches, use regular truncation
130+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
131+ }
132+
133+ text := titleStyle.Render(truncatedTitle)
134+ if len(c.matchIndexes) > 0 {
135+ var ranges []lipgloss.Range
136+ for _, rng := range matchedRanges(c.matchIndexes) {
137+ // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
138+ // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
139+ // so we need to adjust it here:
140+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
141+ ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
142+ }
143+ text = lipgloss.StyleRanges(text, ranges...)
144+ }
145+ parts := []string{text}
146+ if c.shortcut != "" {
147+ // Add the shortcut at the end
148+ shortcutStyle := t.S().Muted
149+ if c.focus {
150+ shortcutStyle = t.S().TextSelected
151+ }
152+ parts = append(parts, shortcutStyle.Render(c.shortcut))
153+ }
154+ item := itemStyle.Render(
155+ lipgloss.JoinHorizontal(
156+ lipgloss.Left,
157+ parts...,
158+ ),
159+ )
160+ return item
161+}
162+
163+// Blur implements CommandItem.
164+func (c *completionItemCmp[T]) Blur() tea.Cmd {
165+ c.focus = false
166+ return nil
167+}
168+
169+// Focus implements CommandItem.
170+func (c *completionItemCmp[T]) Focus() tea.Cmd {
171+ c.focus = true
172+ return nil
173+}
174+
175+// GetSize implements CommandItem.
176+func (c *completionItemCmp[T]) GetSize() (int, int) {
177+ return c.width, 1
178+}
179+
180+// IsFocused implements CommandItem.
181+func (c *completionItemCmp[T]) IsFocused() bool {
182+ return c.focus
183+}
184+
185+// SetSize implements CommandItem.
186+func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
187+ c.width = width
188+ return nil
189+}
190+
191+func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
192+ c.matchIndexes = indexes
193+}
194+
195+func (c *completionItemCmp[T]) FilterValue() string {
196+ return c.text
197+}
198+
199+func (c *completionItemCmp[T]) Value() T {
200+ return c.value
201+}
202+
203+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
204+func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
205+ if width <= 0 {
206+ return ""
207+ }
208+
209+ textLen := ansi.StringWidth(text)
210+ if textLen <= width {
211+ return text
212+ }
213+
214+ if len(matchIndexes) == 0 {
215+ return ansi.Truncate(text, width, "…")
216+ }
217+
218+ // Find the last match position
219+ lastMatchPos := matchIndexes[len(matchIndexes)-1]
220+
221+ // Convert byte position to visual width position
222+ lastMatchVisualPos := 0
223+ bytePos := 0
224+ gr := uniseg.NewGraphemes(text)
225+ for bytePos < lastMatchPos && gr.Next() {
226+ bytePos += len(gr.Str())
227+ lastMatchVisualPos += max(1, gr.Width())
228+ }
229+
230+ // Calculate how much space we need for the ellipsis
231+ ellipsisWidth := 1 // "…" character width
232+ availableWidth := width - ellipsisWidth
233+
234+ // If the last match is within the available width, truncate from the end
235+ if lastMatchVisualPos < availableWidth {
236+ return ansi.Truncate(text, width, "…")
237+ }
238+
239+ // Calculate the start position to ensure the last match is visible
240+ // We want to show some context before the last match if possible
241+ startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
242+
243+ // Convert visual position back to byte position
244+ startBytePos := 0
245+ currentVisualPos := 0
246+ gr = uniseg.NewGraphemes(text)
247+ for currentVisualPos < startVisualPos && gr.Next() {
248+ startBytePos += len(gr.Str())
249+ currentVisualPos += max(1, gr.Width())
250+ }
251+
252+ // Extract the substring starting from startBytePos
253+ truncatedText := text[startBytePos:]
254+
255+ // Truncate to fit width with ellipsis
256+ truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
257+ truncatedText = "…" + truncatedText
258+ return truncatedText
259+}
260+
261+func matchedRanges(in []int) [][2]int {
262+ if len(in) == 0 {
263+ return [][2]int{}
264+ }
265+ current := [2]int{in[0], in[0]}
266+ if len(in) == 1 {
267+ return [][2]int{current}
268+ }
269+ var out [][2]int
270+ for i := 1; i < len(in); i++ {
271+ if in[i] == current[1]+1 {
272+ current[1] = in[i]
273+ } else {
274+ out = append(out, current)
275+ current = [2]int{in[i], in[i]}
276+ }
277+ }
278+ out = append(out, current)
279+ return out
280+}
281+
282+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
283+ bytePos, byteStart, byteStop := 0, rng[0], rng[1]
284+ pos, start, stop := 0, 0, 0
285+ gr := uniseg.NewGraphemes(str)
286+ for byteStart > bytePos {
287+ if !gr.Next() {
288+ break
289+ }
290+ bytePos += len(gr.Str())
291+ pos += max(1, gr.Width())
292+ }
293+ start = pos
294+ for byteStop > bytePos {
295+ if !gr.Next() {
296+ break
297+ }
298+ bytePos += len(gr.Str())
299+ pos += max(1, gr.Width())
300+ }
301+ stop = pos
302+ return start, stop
303+}
304+
305+// ID implements CompletionItem.
306+func (c *completionItemCmp[T]) ID() string {
307+ return c.id
308+}
internal/tui/exp/list/keys.gonew file+63
@@ -0,0 +1,63 @@
1+package list
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+)
6+
7+type KeyMap struct {
8+ Down,
9+ Up,
10+ DownOneItem,
11+ UpOneItem,
12+ PageDown,
13+ PageUp,
14+ HalfPageDown,
15+ HalfPageUp,
16+ Home,
17+ End key.Binding
18+}
19+
20+func DefaultKeyMap() KeyMap {
21+ return KeyMap{
22+ Down: key.NewBinding(
23+ key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
24+ key.WithHelp("↓", "down"),
25+ ),
26+ Up: key.NewBinding(
27+ key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
28+ key.WithHelp("↑", "up"),
29+ ),
30+ UpOneItem: key.NewBinding(
31+ key.WithKeys("shift+up", "K"),
32+ key.WithHelp("shift+↑", "up one item"),
33+ ),
34+ DownOneItem: key.NewBinding(
35+ key.WithKeys("shift+down", "J"),
36+ key.WithHelp("shift+↓", "down one item"),
37+ ),
38+ HalfPageDown: key.NewBinding(
39+ key.WithKeys("d"),
40+ key.WithHelp("d", "half page down"),
41+ ),
42+ PageDown: key.NewBinding(
43+ key.WithKeys("pgdown", " ", "f"),
44+ key.WithHelp("f/pgdn", "page down"),
45+ ),
46+ PageUp: key.NewBinding(
47+ key.WithKeys("pgup", "b"),
48+ key.WithHelp("b/pgup", "page up"),
49+ ),
50+ HalfPageUp: key.NewBinding(
51+ key.WithKeys("u"),
52+ key.WithHelp("u", "half page up"),
53+ ),
54+ Home: key.NewBinding(
55+ key.WithKeys("g", "home"),
56+ key.WithHelp("g", "home"),
57+ ),
58+ End: key.NewBinding(
59+ key.WithKeys("G", "end"),
60+ key.WithHelp("G", "end"),
61+ ),
62+ }
63+}
internal/tui/exp/list/list.go+220−95
@@ -1,9 +1,9 @@
11package list
22
33import (
4− "fmt"
54 "strings"
65
6+ "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
99 "github.com/charmbracelet/crush/internal/tui/util"
@@ -16,11 +16,19 @@ type Item interface {
1616 ID() string
1717}
1818
19−type List interface {
19+type List[T Item] interface {
2020 util.Model
2121 layout.Sizeable
2222 layout.Focusable
23− SetItems(items []Item) tea.Cmd
23+ MoveUp(int) tea.Cmd
24+ MoveDown(int) tea.Cmd
25+ GoToTop() tea.Cmd
26+ GoToBottom() tea.Cmd
27+ SelectItemAbove() tea.Cmd
28+ SelectItemBelow() tea.Cmd
29+ SetItems([]T) tea.Cmd
30+ SetSelected(string) tea.Cmd
31+ SelectedItem() *T
2432}
2533
2634type direction int
@@ -31,41 +39,45 @@ const (
3139)
3240
3341const (
34− NotFound = -1
42+ NotFound = -1
43+ DefaultScrollSize = 2
3544)
3645
46+type setSelectedMsg struct {
47+ selectedItemID string
48+}
49+
3750type renderedItem struct {
3851 id string
3952 view string
4053 height int
4154}
4255
43−type list struct {
56+type confOptions struct {
4457 width, height int
45− offset int
4658 gap int
47− direction direction
48− selectedItem string
49− focused bool
59+ // if you are at the last item and go down it will wrap to the top
60+ wrap bool
61+ keyMap KeyMap
62+ direction direction
63+ selectedItem string
64+}
65+type list[T Item] struct {
66+ confOptions
5067
51− items []Item
68+ focused bool
69+ offset int
70+ items []T
5271 renderedItems []renderedItem
5372 rendered string
5473 isReady bool
5574}
5675
57−type listOption func(*list)
58−
59−// WithItems sets the initial items for the list.
60−func WithItems(items ...Item) listOption {
61− return func(l *list) {
62− l.items = items
63− }
64−}
76+type listOption func(*confOptions)
6577
6678// WithSize sets the size of the list.
6779func WithSize(width, height int) listOption {
68− return func(l *list) {
80+ return func(l *confOptions) {
6981 l.width = width
7082 l.height = height
7183 }
@@ -73,44 +85,53 @@ func WithSize(width, height int) listOption {
7385
7486// WithGap sets the gap between items in the list.
7587func WithGap(gap int) listOption {
76− return func(l *list) {
88+ return func(l *confOptions) {
7789 l.gap = gap
7890 }
7991}
8092
8193// WithDirection sets the direction of the list.
8294func WithDirection(dir direction) listOption {
83− return func(l *list) {
95+ return func(l *confOptions) {
8496 l.direction = dir
8597 }
8698}
8799
88100// WithSelectedItem sets the initially selected item in the list.
89101func WithSelectedItem(id string) listOption {
90− return func(l *list) {
102+ return func(l *confOptions) {
91103 l.selectedItem = id
92104 }
93105}
94106
95−func New(opts ...listOption) List {
96− list := &list{
97− items: make([]Item, 0),
98− direction: Forward,
107+func WithKeyMap(keyMap KeyMap) listOption {
108+ return func(l *confOptions) {
109+ l.keyMap = keyMap
110+ }
111+}
112+
113+func WithWrapNavigation() listOption {
114+ return func(l *confOptions) {
115+ l.wrap = true
116+ }
117+}
118+
119+func New[T Item](items []T, opts ...listOption) List[T] {
120+ list := &list[T]{
121+ confOptions: confOptions{
122+ direction: Forward,
123+ keyMap: DefaultKeyMap(),
124+ },
125+ items: items,
99126 }
100127 for _, opt := range opts {
101− opt(list)
128+ opt(&list.confOptions)
102129 }
103130 return list
104131}
105132
106133// Init implements List.
107−func (l *list) Init() tea.Cmd {
108− if l.height <= 0 || l.width <= 0 {
109− return nil
110− }
111− if len(l.items) == 0 {
112− return nil
113− }
134+func (l *list[T]) Init() tea.Cmd {
114135 var cmds []tea.Cmd
115136 for _, item := range l.items {
116137 cmd := item.Init()
@@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd {
121142}
122143
123144// Update implements List.
124−func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
145+func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146+ switch msg := msg.(type) {
147+ case setSelectedMsg:
148+ return l, l.SetSelected(msg.selectedItemID)
149+ case tea.KeyPressMsg:
150+ if l.focused {
151+ switch {
152+ case key.Matches(msg, l.keyMap.Down):
153+ return l, l.MoveDown(DefaultScrollSize)
154+ case key.Matches(msg, l.keyMap.Up):
155+ return l, l.MoveUp(DefaultScrollSize)
156+ case key.Matches(msg, l.keyMap.DownOneItem):
157+ return l, l.SelectItemBelow()
158+ case key.Matches(msg, l.keyMap.UpOneItem):
159+ return l, l.SelectItemAbove()
160+ case key.Matches(msg, l.keyMap.HalfPageDown):
161+ return l, l.MoveDown(l.listHeight() / 2)
162+ case key.Matches(msg, l.keyMap.HalfPageUp):
163+ return l, l.MoveUp(l.listHeight() / 2)
164+ case key.Matches(msg, l.keyMap.PageDown):
165+ return l, l.MoveDown(l.listHeight())
166+ case key.Matches(msg, l.keyMap.PageUp):
167+ return l, l.MoveUp(l.listHeight())
168+ case key.Matches(msg, l.keyMap.End):
169+ return l, l.GoToBottom()
170+ case key.Matches(msg, l.keyMap.Home):
171+ return l, l.GoToTop()
172+ }
173+ }
174+ }
125175 return l, nil
126176}
127177
128178// View implements List.
129−func (l *list) View() string {
179+func (l *list[T]) View() string {
130180 if l.height <= 0 || l.width <= 0 {
131181 return ""
132182 }
@@ -138,7 +188,7 @@ func (l *list) View() string {
138188 return strings.Join(lines, "\n")
139189}
140190
141−func (l *list) viewPosition() (int, int) {
191+func (l *list[T]) viewPosition() (int, int) {
142192 start, end := 0, 0
143193 renderedLines := lipgloss.Height(l.rendered) - 1
144194 if l.direction == Forward {
@@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) {
151201 return start, end
152202}
153203
154−func (l *list) renderItem(item Item) renderedItem {
204+func (l *list[T]) renderItem(item Item) renderedItem {
155205 view := item.View()
156206 return renderedItem{
157207 id: item.ID(),
@@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem {
160210 }
161211}
162212
163−func (l *list) renderView() {
213+func (l *list[T]) renderView() {
164214 var sb strings.Builder
165215 for i, rendered := range l.renderedItems {
166216 sb.WriteString(rendered.view)
@@ -171,7 +221,7 @@ func (l *list) renderView() {
171221 l.rendered = sb.String()
172222}
173223
174−func (l *list) incrementOffset(n int) {
224+func (l *list[T]) incrementOffset(n int) {
175225 if !l.isReady {
176226 return
177227 }
@@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) {
188238 l.offset += n
189239}
190240
191−func (l *list) decrementOffset(n int) {
241+func (l *list[T]) decrementOffset(n int) {
192242 if !l.isReady {
193243 return
194244 }
@@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) {
203253}
204254
205255// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
206−func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
256+func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
207257 var cmds []tea.Cmd
208258 start, end := l.viewPosition()
209259 currentPosition := 0
@@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
228278 needsMove = true
229279 }
230280 if needsMove {
231− if focusable, ok := item.(layout.Focusable); ok {
281+ if focusable, ok := any(item).(layout.Focusable); ok {
232282 cmds = append(cmds, focusable.Blur())
233283 }
234284 l.renderedItems[i] = l.renderItem(item)
@@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
239289 if itemWithinView != NotFound && needsMove {
240290 newSelection := l.items[itemWithinView]
241291 l.selectedItem = newSelection.ID()
242− if focusable, ok := newSelection.(layout.Focusable); ok {
292+ if focusable, ok := any(newSelection).(layout.Focusable); ok {
243293 cmds = append(cmds, focusable.Focus())
244294 }
245295 l.renderedItems[itemWithinView] = l.renderItem(newSelection)
@@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
251301 return tea.Batch(cmds...)
252302}
253303
254−func (l *list) MoveUp(n int) tea.Cmd {
304+func (l *list[T]) MoveUp(n int) tea.Cmd {
255305 if l.direction == Forward {
256306 l.decrementOffset(n)
257307 } else {
@@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd {
260310 return l.changeSelectedWhenNotVisible()
261311}
262312
263−func (l *list) MoveDown(n int) tea.Cmd {
313+func (l *list[T]) MoveDown(n int) tea.Cmd {
264314 if l.direction == Forward {
265315 l.incrementOffset(n)
266316 } else {
@@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd {
269319 return l.changeSelectedWhenNotVisible()
270320}
271321
272−func (l *list) firstSelectableItemBefore(inx int) int {
322+func (l *list[T]) firstSelectableItemBefore(inx int) int {
273323 for i := inx - 1; i >= 0; i-- {
274− if _, ok := l.items[i].(layout.Focusable); ok {
324+ if _, ok := any(l.items[i]).(layout.Focusable); ok {
275325 return i
276326 }
277327 }
328+ if inx == 0 && l.wrap {
329+ return l.firstSelectableItemBefore(len(l.items))
330+ }
278331 return NotFound
279332}
280333
281−func (l *list) firstSelectableItemAfter(inx int) int {
334+func (l *list[T]) firstSelectableItemAfter(inx int) int {
282335 for i := inx + 1; i < len(l.items); i++ {
283− if _, ok := l.items[i].(layout.Focusable); ok {
336+ if _, ok := any(l.items[i]).(layout.Focusable); ok {
284337 return i
285338 }
286339 }
340+ if inx == len(l.items)-1 && l.wrap {
341+ return l.firstSelectableItemAfter(-1)
342+ }
287343 return NotFound
288344}
289345
290−func (l *list) moveToSelected() {
346+func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347+ var cmds []tea.Cmd
291348 if l.selectedItem == "" || !l.isReady {
292− return
349+ return nil
293350 }
294351 currentPosition := 0
295352 start, end := l.viewPosition()
296353 for _, item := range l.renderedItems {
297354 if item.id == l.selectedItem {
298− if start <= currentPosition && (currentPosition+item.height) <= end {
299− return
300− }
301− // we need to go up
302− if currentPosition < start {
303− l.MoveUp(start - currentPosition)
355+ itemStart := currentPosition
356+ itemEnd := currentPosition + item.height - 1
357+
358+ if start <= itemStart && itemEnd <= end {
359+ return nil
304360 }
305− // we need to go down
306− if currentPosition > end {
307− l.MoveDown(currentPosition - end)
361+
362+ if center {
363+ viewportCenter := l.listHeight() / 2
364+ itemCenter := itemStart + item.height/2
365+ targetOffset := itemCenter - viewportCenter
366+ if l.direction == Forward {
367+ if targetOffset > l.offset {
368+ cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
369+ } else if targetOffset < l.offset {
370+ cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
371+ }
372+ } else {
373+ renderedHeight := lipgloss.Height(l.rendered)
374+ backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
375+ if backwardTargetOffset > l.offset {
376+ cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
377+ } else if backwardTargetOffset < l.offset {
378+ cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
379+ }
380+ }
381+ } else {
382+ if currentPosition < start {
383+ cmds = append(cmds, l.MoveUp(start-currentPosition))
384+ }
385+ if currentPosition > end {
386+ cmds = append(cmds, l.MoveDown(currentPosition-end))
387+ }
308388 }
309389 }
310390 currentPosition += item.height + l.gap
311391 }
392+ return tea.Batch(cmds...)
312393}
313394
314−func (l *list) SelectItemAbove() tea.Cmd {
395+func (l *list[T]) SelectItemAbove() tea.Cmd {
315396 if !l.isReady {
316397 return nil
317398 }
@@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd {
324405 return nil
325406 }
326407 // blur the current item
327− if focusable, ok := item.(layout.Focusable); ok {
408+ if focusable, ok := any(item).(layout.Focusable); ok {
328409 cmds = append(cmds, focusable.Blur())
329410 }
330411 // rerender the item
331412 l.renderedItems[i] = l.renderItem(item)
332413 // focus the item above
333414 above := l.items[inx]
334− if focusable, ok := above.(layout.Focusable); ok {
415+ if focusable, ok := any(above).(layout.Focusable); ok {
335416 cmds = append(cmds, focusable.Focus())
336417 }
337418 // rerender the item
@@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd {
340421 break
341422 }
342423 }
424+ l.moveToSelected(false)
343425 l.renderView()
344− l.moveToSelected()
345426 return tea.Batch(cmds...)
346427}
347428
348−func (l *list) SelectItemBelow() tea.Cmd {
429+func (l *list[T]) SelectItemBelow() tea.Cmd {
349430 if !l.isReady {
350431 return nil
351432 }
@@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
358439 return nil
359440 }
360441 // blur the current item
361− if focusable, ok := item.(layout.Focusable); ok {
442+ if focusable, ok := any(item).(layout.Focusable); ok {
362443 cmds = append(cmds, focusable.Blur())
363444 }
364445 // rerender the item
@@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
366447
367448 // focus the item below
368449 below := l.items[inx]
369− if focusable, ok := below.(layout.Focusable); ok {
450+ if focusable, ok := any(below).(layout.Focusable); ok {
370451 cmds = append(cmds, focusable.Focus())
371452 }
372453 // rerender the item
@@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd {
376457 }
377458 }
378459
460+ l.moveToSelected(false)
379461 l.renderView()
380− l.moveToSelected()
381462 return tea.Batch(cmds...)
382463}
383464
384−func (l *list) GoToTop() tea.Cmd {
465+func (l *list[T]) GoToTop() tea.Cmd {
385466 if !l.isReady {
386467 return nil
387468 }
@@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd {
390471 return tea.Batch(l.selectFirstItem(), l.renderForward())
391472}
392473
393−func (l *list) GoToBottom() tea.Cmd {
474+func (l *list[T]) GoToBottom() tea.Cmd {
394475 if !l.isReady {
395476 return nil
396477 }
@@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd {
400481 return tea.Batch(l.selectLastItem(), l.renderBackward())
401482}
402483
403−func (l *list) renderForward() tea.Cmd {
484+func (l *list[T]) renderForward() tea.Cmd {
404485 // TODO: figure out a way to preserve items that did not change
405486 l.renderedItems = make([]renderedItem, 0)
406487 currentHeight := 0
@@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd {
434515 }
435516}
436517
437−func (l *list) renderBackward() tea.Cmd {
518+func (l *list[T]) renderBackward() tea.Cmd {
438519 // TODO: figure out a way to preserve items that did not change
439520 l.renderedItems = make([]renderedItem, 0)
440521 currentHeight := 0
441522 currentIndex := 0
442523 for i := len(l.items) - 1; i >= 0; i-- {
443− fmt.Printf("rendering item %d\n", i)
444524 currentIndex = i
445525 if currentHeight > l.listHeight() {
446526 break
@@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd {
457537 }
458538 return func() tea.Msg {
459539 for i := currentIndex; i >= 0; i-- {
460− fmt.Printf("rendering item after %d\n", i)
461540 rendered := l.renderItem(l.items[i])
462541 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
463542 }
@@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd {
467546 }
468547}
469548
470−func (l *list) selectFirstItem() tea.Cmd {
549+func (l *list[T]) selectFirstItem() tea.Cmd {
471550 var cmd tea.Cmd
472551 inx := l.firstSelectableItemAfter(-1)
473552 if inx != NotFound {
474553 l.selectedItem = l.items[inx].ID()
475− if focusable, ok := l.items[inx].(layout.Focusable); ok {
554+ if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
476555 cmd = focusable.Focus()
477556 }
478557 }
479558 return cmd
480559}
481560
482−func (l *list) selectLastItem() tea.Cmd {
561+func (l *list[T]) selectLastItem() tea.Cmd {
483562 var cmd tea.Cmd
484563 inx := l.firstSelectableItemBefore(len(l.items))
485564 if inx != NotFound {
486565 l.selectedItem = l.items[inx].ID()
487− if focusable, ok := l.items[inx].(layout.Focusable); ok {
566+ if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
488567 cmd = focusable.Focus()
489568 }
490569 }
491570 return cmd
492571}
493572
494−func (l *list) renderItems() tea.Cmd {
573+func (l *list[T]) renderItems() tea.Cmd {
495574 if l.height <= 0 || l.width <= 0 {
496575 return nil
497576 }
@@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd {
512591 return l.renderBackward()
513592}
514593
515−func (l *list) listHeight() int {
594+func (l *list[T]) listHeight() int {
516595 // for the moment its the same
517596 return l.height
518597}
519598
520−func (l *list) SetItems(items []Item) tea.Cmd {
599+func (l *list[T]) SetItems(items []T) tea.Cmd {
521600 l.items = items
522601 var cmds []tea.Cmd
523602 for _, item := range l.items {
@@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd {
525604 // Set height to 0 to let the item calculate its own height
526605 cmds = append(cmds, item.SetSize(l.width, 0))
527606 }
607+
608+ if l.selectedItem != "" {
609+ cmds = append(cmds, l.moveToSelected(true))
610+ }
528611 cmds = append(cmds, l.renderItems())
529612 return tea.Batch(cmds...)
530613}
531614
532615// GetSize implements List.
533−func (l *list) GetSize() (int, int) {
616+func (l *list[T]) GetSize() (int, int) {
534617 return l.width, l.height
535618}
536619
537620// SetSize implements List.
538−func (l *list) SetSize(width int, height int) tea.Cmd {
621+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
539622 l.width = width
540623 l.height = height
541624 var cmds []tea.Cmd
542625 for _, item := range l.items {
543626 cmds = append(cmds, item.SetSize(width, height))
544627 }
628+
545629 cmds = append(cmds, l.renderItems())
546630 return tea.Batch(cmds...)
547631}
548632
549633// Blur implements List.
550−func (l *list) Blur() tea.Cmd {
634+func (l *list[T]) Blur() tea.Cmd {
551635 var cmd tea.Cmd
552636 l.focused = false
553637 for i, item := range l.items {
554638 if item.ID() != l.selectedItem {
555639 continue
556640 }
557− if focusable, ok := item.(layout.Focusable); ok {
641+ if focusable, ok := any(item).(layout.Focusable); ok {
558642 cmd = focusable.Blur()
559643 }
560644 l.renderedItems[i] = l.renderItem(item)
@@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd {
564648}
565649
566650// Focus implements List.
567−func (l *list) Focus() tea.Cmd {
651+func (l *list[T]) Focus() tea.Cmd {
568652 var cmd tea.Cmd
569653 l.focused = true
570− for i, item := range l.items {
571− if item.ID() != l.selectedItem {
572− continue
654+ if l.selectedItem != "" {
655+ for i, item := range l.items {
656+ if item.ID() != l.selectedItem {
657+ continue
658+ }
659+ if focusable, ok := any(item).(layout.Focusable); ok {
660+ cmd = focusable.Focus()
661+ }
662+ if len(l.renderedItems) > i {
663+ l.renderedItems[i] = l.renderItem(item)
664+ }
573665 }
574− if focusable, ok := item.(layout.Focusable); ok {
575− cmd = focusable.Focus()
666+ l.renderView()
667+ }
668+ return cmd
669+}
670+
671+func (l *list[T]) SetSelected(id string) tea.Cmd {
672+ if l.selectedItem == id {
673+ return nil
674+ }
675+ var cmds []tea.Cmd
676+ for i, item := range l.items {
677+ if item.ID() == l.selectedItem {
678+ if focusable, ok := any(item).(layout.Focusable); ok {
679+ cmds = append(cmds, focusable.Blur())
680+ }
681+ if len(l.renderedItems) > i {
682+ l.renderedItems[i] = l.renderItem(item)
683+ }
684+ } else if item.ID() == id {
685+ if focusable, ok := any(item).(layout.Focusable); ok {
686+ cmds = append(cmds, focusable.Focus())
687+ }
688+ if len(l.renderedItems) > i {
689+ l.renderedItems[i] = l.renderItem(item)
690+ }
576691 }
577− l.renderedItems[i] = l.renderItem(item)
578692 }
693+ l.selectedItem = id
694+ cmds = append(cmds, l.moveToSelected(true))
579695 l.renderView()
580− return cmd
696+ return tea.Batch(cmds...)
697+}
698+
699+func (l *list[T]) SelectedItem() *T {
700+ for _, item := range l.items {
701+ if item.ID() == l.selectedItem {
702+ return &item
703+ }
704+ }
705+ return nil
581706}
582707
583708// IsFocused implements List.
584−func (l *list) IsFocused() bool {
709+func (l *list[T]) IsFocused() bool {
585710 return l.focused
586711}
internal/tui/exp/list/list_test.go+148−93
@@ -2,6 +2,7 @@ package list
22
33import (
44 "fmt"
5+ "sync"
56 "testing"
67
78 tea "github.com/charmbracelet/bubbletea/v2"
@@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) {
7475 }
7576 for _, c := range tests {
7677 t.Run(c.test, func(t *testing.T) {
77− l := New(WithDirection(c.dir)).(*list)
78− l.SetSize(c.width, c.height)
7978 items := []Item{}
8079 for i := range c.numItems {
81− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
80+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
8281 items = append(items, item)
8382 }
84− cmd := l.SetItems(items)
83+ l := New(items, WithDirection(c.dir)).(*list[Item])
84+ l.SetSize(c.width, c.height)
85+ cmd := l.Init()
8586 if cmd != nil {
8687 cmd()
8788 }
@@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) {
102103func TestBackwardList(t *testing.T) {
103104 t.Run("within height", func(t *testing.T) {
104105 t.Parallel()
105− l := New(WithDirection(Backward), WithGap(1)).(*list)
106− l.SetSize(10, 20)
107106 items := []Item{}
108107 for i := range 5 {
109− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
108+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
110109 items = append(items, item)
111110 }
112− cmd := l.SetItems(items)
111+ l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
112+ l.SetSize(10, 20)
113+ cmd := l.Init()
113114 if cmd != nil {
114115 cmd()
115116 }
116117
117118 // should select the last item
118119 assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
119−
120120 golden.RequireEqual(t, []byte(l.View()))
121121 })
122122 t.Run("should not change selected item", func(t *testing.T) {
123123 t.Parallel()
124124 items := []Item{}
125125 for i := range 5 {
126− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
126+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
127127 items = append(items, item)
128128 }
129− l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
129+ l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
130130 l.SetSize(10, 20)
131− cmd := l.SetItems(items)
131+ cmd := l.Init()
132132 if cmd != nil {
133133 cmd()
134134 }
@@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) {
137137 })
138138 t.Run("more than height", func(t *testing.T) {
139139 t.Parallel()
140− l := New(WithDirection(Backward))
141− l.SetSize(10, 5)
142140 items := []Item{}
143141 for i := range 10 {
144− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
142+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
145143 items = append(items, item)
146144 }
147− cmd := l.SetItems(items)
145+ l := New(items, WithDirection(Backward))
146+ l.SetSize(10, 5)
147+ cmd := l.Init()
148148 if cmd != nil {
149149 cmd()
150150 }
@@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) {
153153 })
154154 t.Run("more than height multi line", func(t *testing.T) {
155155 t.Parallel()
156− l := New(WithDirection(Backward))
157− l.SetSize(10, 5)
158156 items := []Item{}
159157 for i := range 10 {
160− item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
158+ item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
161159 items = append(items, item)
162160 }
163− cmd := l.SetItems(items)
161+ l := New(items, WithDirection(Backward))
162+ l.SetSize(10, 5)
163+ cmd := l.Init()
164164 if cmd != nil {
165165 cmd()
166166 }
@@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) {
169169 })
170170 t.Run("should move up", func(t *testing.T) {
171171 t.Parallel()
172− l := New(WithDirection(Backward)).(*list)
173− l.SetSize(10, 5)
174172 items := []Item{}
175173 for i := range 10 {
176− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
174+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
177175 items = append(items, item)
178176 }
179− cmd := l.SetItems(items)
177+ l := New(items, WithDirection(Backward))
178+ l.SetSize(10, 5)
179+ cmd := l.Init()
180180 if cmd != nil {
181181 cmd()
182182 }
@@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) {
186186 })
187187
188188 t.Run("should move at max to the top", func(t *testing.T) {
189− l := New(WithDirection(Backward)).(*list)
190− l.SetSize(10, 5)
191189 items := []Item{}
192190 for i := range 10 {
193− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
191+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
194192 items = append(items, item)
195193 }
196− cmd := l.SetItems(items)
194+ l := New(items, WithDirection(Backward)).(*list[Item])
195+ l.SetSize(10, 5)
196+ cmd := l.Init()
197197 if cmd != nil {
198198 cmd()
199199 }
@@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) {
204204 })
205205 t.Run("should do nothing with wrong move number", func(t *testing.T) {
206206 t.Parallel()
207− l := New(WithDirection(Backward)).(*list)
208− l.SetSize(10, 5)
209207 items := []Item{}
210208 for i := range 10 {
211− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
209+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
212210 items = append(items, item)
213211 }
214− cmd := l.SetItems(items)
212+ l := New(items, WithDirection(Backward))
213+ l.SetSize(10, 5)
214+ cmd := l.Init()
215215 if cmd != nil {
216216 cmd()
217217 }
@@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) {
221221 })
222222 t.Run("should move to the top", func(t *testing.T) {
223223 t.Parallel()
224− l := New(WithDirection(Backward)).(*list)
225− l.SetSize(10, 5)
226224 items := []Item{}
227225 for i := range 10 {
228− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
226+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
229227 items = append(items, item)
230228 }
231− cmd := l.SetItems(items)
229+ l := New(items, WithDirection(Backward)).(*list[Item])
230+ l.SetSize(10, 5)
231+ cmd := l.Init()
232232 if cmd != nil {
233233 cmd()
234234 }
@@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) {
239239 })
240240 t.Run("should select the item above", func(t *testing.T) {
241241 t.Parallel()
242− l := New(WithDirection(Backward)).(*list)
243− l.SetSize(10, 5)
244242 items := []Item{}
245243 for i := range 10 {
246− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
244+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
247245 items = append(items, item)
248246 }
249− cmd := l.SetItems(items)
247+ l := New(items, WithDirection(Backward)).(*list[Item])
248+ l.SetSize(10, 5)
249+ cmd := l.Init()
250250 if cmd != nil {
251251 cmd()
252252 }
@@ -268,14 +268,14 @@ func TestBackwardList(t *testing.T) {
268268 })
269269 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
270270 t.Parallel()
271− l := New(WithDirection(Backward)).(*list)
272− l.SetSize(10, 5)
273271 items := []Item{}
274272 for i := range 10 {
275− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
273+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
276274 items = append(items, item)
277275 }
278− cmd := l.SetItems(items)
276+ l := New(items, WithDirection(Backward)).(*list[Item])
277+ l.SetSize(10, 5)
278+ cmd := l.Init()
279279 if cmd != nil {
280280 cmd()
281281 }
@@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) {
293293func TestForwardList(t *testing.T) {
294294 t.Run("within height", func(t *testing.T) {
295295 t.Parallel()
296− l := New(WithDirection(Forward), WithGap(1)).(*list)
297− l.SetSize(10, 20)
298296 items := []Item{}
299297 for i := range 5 {
300− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
298+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
301299 items = append(items, item)
302300 }
303− cmd := l.SetItems(items)
301+ l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
302+ l.SetSize(10, 20)
303+ cmd := l.Init()
304304 if cmd != nil {
305305 cmd()
306306 }
@@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) {
314314 t.Parallel()
315315 items := []Item{}
316316 for i := range 5 {
317− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
317+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
318318 items = append(items, item)
319319 }
320− l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
320+ l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
321321 l.SetSize(10, 20)
322− cmd := l.SetItems(items)
322+ cmd := l.Init()
323323 if cmd != nil {
324324 cmd()
325325 }
@@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) {
328328 })
329329 t.Run("more than height", func(t *testing.T) {
330330 t.Parallel()
331− l := New(WithDirection(Forward))
332− l.SetSize(10, 5)
333331 items := []Item{}
334332 for i := range 10 {
335− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
333+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
336334 items = append(items, item)
337335 }
338− cmd := l.SetItems(items)
336+ l := New(items, WithDirection(Forward)).(*list[Item])
337+ l.SetSize(10, 5)
338+ cmd := l.Init()
339339 if cmd != nil {
340340 cmd()
341341 }
@@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) {
344344 })
345345 t.Run("more than height multi line", func(t *testing.T) {
346346 t.Parallel()
347− l := New(WithDirection(Forward))
348− l.SetSize(10, 5)
349347 items := []Item{}
350348 for i := range 10 {
351− item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
349+ item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
352350 items = append(items, item)
353351 }
354− cmd := l.SetItems(items)
352+ l := New(items, WithDirection(Forward)).(*list[Item])
353+ l.SetSize(10, 5)
354+ cmd := l.Init()
355355 if cmd != nil {
356356 cmd()
357357 }
@@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) {
360360 })
361361 t.Run("should move down", func(t *testing.T) {
362362 t.Parallel()
363− l := New(WithDirection(Forward)).(*list)
364− l.SetSize(10, 5)
365363 items := []Item{}
366364 for i := range 10 {
367− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
365+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
368366 items = append(items, item)
369367 }
370− cmd := l.SetItems(items)
368+ l := New(items, WithDirection(Forward)).(*list[Item])
369+ l.SetSize(10, 5)
370+ cmd := l.Init()
371371 if cmd != nil {
372372 cmd()
373373 }
@@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) {
377377 })
378378 t.Run("should move at max to the bottom", func(t *testing.T) {
379379 t.Parallel()
380− l := New(WithDirection(Forward)).(*list)
381− l.SetSize(10, 5)
382380 items := []Item{}
383381 for i := range 10 {
384− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
382+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
385383 items = append(items, item)
386384 }
387− cmd := l.SetItems(items)
385+ l := New(items, WithDirection(Forward)).(*list[Item])
386+ l.SetSize(10, 5)
387+ cmd := l.Init()
388388 if cmd != nil {
389389 cmd()
390390 }
@@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) {
395395 })
396396 t.Run("should do nothing with wrong move number", func(t *testing.T) {
397397 t.Parallel()
398− l := New(WithDirection(Forward)).(*list)
399− l.SetSize(10, 5)
400398 items := []Item{}
401399 for i := range 10 {
402− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
400+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
403401 items = append(items, item)
404402 }
405− cmd := l.SetItems(items)
403+ l := New(items, WithDirection(Forward)).(*list[Item])
404+ l.SetSize(10, 5)
405+ cmd := l.Init()
406406 if cmd != nil {
407407 cmd()
408408 }
@@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) {
412412 })
413413 t.Run("should move to the bottom", func(t *testing.T) {
414414 t.Parallel()
415− l := New(WithDirection(Forward)).(*list)
416− l.SetSize(10, 5)
417415 items := []Item{}
418416 for i := range 10 {
419− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
417+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
420418 items = append(items, item)
421419 }
422− cmd := l.SetItems(items)
420+ l := New(items, WithDirection(Forward)).(*list[Item])
421+ l.SetSize(10, 5)
422+ cmd := l.Init()
423423 if cmd != nil {
424424 cmd()
425425 }
@@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) {
430430 })
431431 t.Run("should select the item below", func(t *testing.T) {
432432 t.Parallel()
433− l := New(WithDirection(Forward)).(*list)
434− l.SetSize(10, 5)
435433 items := []Item{}
436434 for i := range 10 {
437− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
435+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
438436 items = append(items, item)
439437 }
440− cmd := l.SetItems(items)
438+ l := New(items, WithDirection(Forward)).(*list[Item])
439+ l.SetSize(10, 5)
440+ cmd := l.Init()
441441 if cmd != nil {
442442 cmd()
443443 }
@@ -459,14 +459,14 @@ func TestForwardList(t *testing.T) {
459459 })
460460 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
461461 t.Parallel()
462− l := New(WithDirection(Backward)).(*list)
463− l.SetSize(10, 5)
464462 items := []Item{}
465463 for i := range 10 {
466− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
464+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
467465 items = append(items, item)
468466 }
469− cmd := l.SetItems(items)
467+ l := New(items, WithDirection(Forward)).(*list[Item])
468+ l.SetSize(10, 5)
469+ cmd := l.Init()
470470 if cmd != nil {
471471 cmd()
472472 }
@@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) {
484484func TestListSelection(t *testing.T) {
485485 t.Run("should skip none selectable items initially", func(t *testing.T) {
486486 t.Parallel()
487− l := New(WithDirection(Forward)).(*list)
488− l.SetSize(100, 10)
489487 items := []Item{}
490488 items = append(items, NewSimpleItem("None Selectable"))
491489 for i := range 5 {
492− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
490+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
493491 items = append(items, item)
494492 }
495− cmd := l.SetItems(items)
493+ l := New(items, WithDirection(Forward)).(*list[Item])
494+ l.SetSize(100, 10)
495+ cmd := l.Init()
496496 if cmd != nil {
497497 cmd()
498498 }
@@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) {
500500 assert.Equal(t, items[1].ID(), l.selectedItem)
501501 golden.RequireEqual(t, []byte(l.View()))
502502 })
503− t.Run("should skip none selectable items in the middle", func(t *testing.T) {
503+ t.Run("should select the correct item on startup", func(t *testing.T) {
504504 t.Parallel()
505− l := New(WithDirection(Forward)).(*list)
505+ items := []Item{}
506+ for i := range 5 {
507+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
508+ items = append(items, item)
509+ }
510+ l := New(items, WithDirection(Forward)).(*list[Item])
511+ cmd := l.Init()
512+ otherCmd := l.SetSelected(items[3].ID())
513+ var wg sync.WaitGroup
514+ if cmd != nil {
515+ wg.Add(1)
516+ go func() {
517+ cmd()
518+ wg.Done()
519+ }()
520+ }
521+ if otherCmd != nil {
522+ wg.Add(1)
523+ go func() {
524+ otherCmd()
525+ wg.Done()
526+ }()
527+ }
528+ wg.Wait()
506529 l.SetSize(100, 10)
530+ assert.Equal(t, items[3].ID(), l.selectedItem)
531+ golden.RequireEqual(t, []byte(l.View()))
532+ })
533+ t.Run("should skip none selectable items in the middle", func(t *testing.T) {
534+ t.Parallel()
507535 items := []Item{}
508− item := NewSelectsableItem("Item initial")
536+ item := NewSelectableItem("Item initial")
509537 items = append(items, item)
510538 items = append(items, NewSimpleItem("None Selectable"))
511539 for i := range 5 {
512− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
540+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
513541 items = append(items, item)
514542 }
515− cmd := l.SetItems(items)
543+ l := New(items, WithDirection(Forward)).(*list[Item])
544+ l.SetSize(100, 10)
545+ cmd := l.Init()
516546 if cmd != nil {
517547 cmd()
518548 }
@@ -522,6 +552,31 @@ func TestListSelection(t *testing.T) {
522552 })
523553}
524554
555+func TestListSetSelection(t *testing.T) {
556+ t.Run("should move to the selected item", func(t *testing.T) {
557+ t.Parallel()
558+ items := []Item{}
559+ for i := range 100 {
560+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
561+ items = append(items, item)
562+ }
563+ l := New(items, WithDirection(Forward)).(*list[Item])
564+ l.SetSize(100, 10)
565+ cmd := l.Init()
566+ if cmd != nil {
567+ cmd()
568+ }
569+
570+ cmd = l.SetSelected(items[52].ID())
571+ if cmd != nil {
572+ cmd()
573+ }
574+
575+ assert.Equal(t, items[52].ID(), l.selectedItem)
576+ golden.RequireEqual(t, []byte(l.View()))
577+ })
578+}
579+
525580type SelectableItem interface {
526581 Item
527582 layout.Focusable
@@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem {
545600 }
546601}
547602
548−func NewSelectsableItem(content string) SelectableItem {
603+func NewSelectableItem(content string) SelectableItem {
549604 return &selectableItem{
550605 simpleItem: NewSimpleItem(content),
551606 focused: false,
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldennew file+6
@@ -0,0 +1,6 @@
1+> Type to filter 
2+│Item 0
3+Item 1
4+Item 2
5+Item 3
6+Item 4
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden+5−5
@@ -1,5 +1,5 @@
1−Item 5
2−Item 6
3−Item 7
4−Item 8
5−│Item 9
1+Item 1
2+Item 2
3+Item 3
4+Item 4
5+│Item 5
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.goldennew file+5
@@ -0,0 +1,5 @@
1+Item 0
2+Item 1
3+Item 2
4+│Item 3
5+Item 4
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 47
2+Item 48
3+Item 49
4+Item 50
5+Item 51
6+│Item 52
7+Item 53
8+Item 54
9+Item 55
10+Item 56