on main help sign in
chore: implement new filterable list
aab9b3bKujtim Hoxha
cspell.json+1−108
@@ -1,108 +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"}
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−}
internal/tui/components/core/list/list.go−1
@@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel {
248 }
248 }
249
249
250 if m.filterable && !m.hideFilterInput {
250 if m.filterable && !m.hideFilterInput {
251− t := styles.CurrentTheme()
252 ti := textinput.New()
251 ti := textinput.New()
253 ti.Placeholder = m.filterPlaceholder
252 ti.Placeholder = m.filterPlaceholder
254 ti.SetVirtualCursor(false)
253 ti.SetVirtualCursor(false)
internal/tui/components/dialogs/sessions/sessions.go+29−34
@@ -6,10 +6,9 @@ import (
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/session"
7 "github.com/charmbracelet/crush/internal/session"
8 "github.com/charmbracelet/crush/internal/tui/components/chat"
8 "github.com/charmbracelet/crush/internal/tui/components/chat"
9− "github.com/charmbracelet/crush/internal/tui/components/completions"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
9 "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
10 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
11+ "github.com/charmbracelet/crush/internal/tui/exp/list"
13 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
22 dialogs.DialogModel
21 dialogs.DialogModel
23}
22}
24
23
24+type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
25+
25type sessionDialogCmp struct {
26type sessionDialogCmp struct {
26 selectedInx int
27 selectedInx int
27 wWidth int
28 wWidth int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
29 width int
30 width int
30 selectedSessionID string
31 selectedSessionID string
31 keyMap KeyMap
32 keyMap KeyMap
32− sessionsList list.ListModel
33+ sessionsList SessionsList
33− renderedSelected bool
34 help help.Model
34 help help.Model
35}
35}
36
36
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
39 t := styles.CurrentTheme()
39 t := styles.CurrentTheme()
40 listKeyMap := list.DefaultKeyMap()
40 listKeyMap := list.DefaultKeyMap()
41 keyMap := DefaultKeyMap()
41 keyMap := DefaultKeyMap()
42−
43 listKeyMap.Down.SetEnabled(false)
42 listKeyMap.Down.SetEnabled(false)
44 listKeyMap.Up.SetEnabled(false)
43 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−
50 listKeyMap.DownOneItem = keyMap.Next
44 listKeyMap.DownOneItem = keyMap.Next
51 listKeyMap.UpOneItem = keyMap.Previous
45 listKeyMap.UpOneItem = keyMap.Previous
52
46
53− selectedInx := 0
54− items := make([]util.Model, len(sessions))
47+ items := make([]list.CompletionItem[session.Session], len(sessions))
55 if len(sessions) > 0 {
48 if len(sessions) > 0 {
56 for i, session := range sessions {
49 for i, session := range sessions {
57− items[i] = completions.NewCompletionItem(session.Title, session)
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
58− if session.ID == selectedID {
59− selectedInx = i
60− }
61 }
51 }
62 }
52 }
63
53
54+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
64− sessionsList := list.New(
55+ sessionsList := list.NewFilterableList(
65− list.WithFilterable(true),
56+ items,
66 list.WithFilterPlaceholder("Enter a session name"),
57 list.WithFilterPlaceholder("Enter a session name"),
58+ list.WithFilterInputStyle(inputStyle),
59+ list.WithFilterListOptions(
67− list.WithKeyMap(listKeyMap),
60+ list.WithKeyMap(listKeyMap),
68− list.WithItems(items),
69− list.WithWrapNavigation(true),
61+ list.WithWrapNavigation(),
62+ ),
70 )
63 )
71 help := help.New()
64 help := help.New()
72 help.Styles = t.S().Help
65 help.Styles = t.S().Help
73 s := &sessionDialogCmp{
66 s := &sessionDialogCmp{
74− selectedInx: selectedInx,
75 selectedSessionID: selectedID,
67 selectedSessionID: selectedID,
76 keyMap: DefaultKeyMap(),
68 keyMap: DefaultKeyMap(),
77 sessionsList: sessionsList,
69 sessionsList: sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
82}
74}
83
75
84func (s *sessionDialogCmp) Init() tea.Cmd {
76func (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...)
86}
81}
87
82
88func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 switch msg := msg.(type) {
84 switch msg := msg.(type) {
90 case tea.WindowSizeMsg:
85 case tea.WindowSizeMsg:
86+ var cmds []tea.Cmd
91 s.wWidth = msg.Width
87 s.wWidth = msg.Width
92 s.wHeight = msg.Height
88 s.wHeight = msg.Height
93− s.width = s.wWidth / 2
89+ s.width = min(120, s.wWidth-8)
94− var cmds []tea.Cmd
90+ s.sessionsList.SetInputWidth(s.listWidth() - 2)
95 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
91 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
96− if !s.renderedSelected {
92+ if s.selectedSessionID != "" {
97− cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
93+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
98− s.renderedSelected = true
99 }
94 }
100− return s, tea.Sequence(cmds...)
95+ return s, tea.Batch(cmds...)
101 case tea.KeyPressMsg:
96 case tea.KeyPressMsg:
102 switch {
97 switch {
103 case key.Matches(msg, s.keyMap.Select):
98 case key.Matches(msg, s.keyMap.Select):
104− if len(s.sessionsList.Items()) > 0 {
99+ selectedItem := s.sessionsList.SelectedItem()
105− items := s.sessionsList.Items()
100+ if selectedItem != nil {
106− selectedItemInx := s.sessionsList.SelectedIndex()
101+ selected := *selectedItem
107 return s, tea.Sequence(
102 return s, tea.Sequence(
108 util.CmdHandler(dialogs.CloseDialogMsg{}),
103 util.CmdHandler(dialogs.CloseDialogMsg{}),
109 util.CmdHandler(
104 util.CmdHandler(
110− chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
105+ chat.SessionSelectedMsg(selected.Value()),
111 ),
106 ),
112 )
107 )
113 }
108 }
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
110 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116 default:
111 default:
117 u, cmd := s.sessionsList.Update(msg)
112 u, cmd := s.sessionsList.Update(msg)
118− s.sessionsList = u.(list.ListModel)
113+ s.sessionsList = u.(SessionsList)
119 return s, cmd
114 return s, cmd
120 }
115 }
121 }
116 }
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 @@
1package list
1package list
2
2
3import (
3import (
4− "fmt"
5 "strings"
4 "strings"
6
5
6+ "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
9 "github.com/charmbracelet/crush/internal/tui/util"
9 "github.com/charmbracelet/crush/internal/tui/util"
@@ -16,11 +16,19 @@ type Item interface {
16 ID() string
16 ID() string
17}
17}
18
18
19−type List interface {
19+type List[T Item] interface {
20 util.Model
20 util.Model
21 layout.Sizeable
21 layout.Sizeable
22 layout.Focusable
22 layout.Focusable
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
23− SetItems(items []Item) tea.Cmd
29+ SetItems([]T) tea.Cmd
30+ SetSelected(string) tea.Cmd
31+ SelectedItem() *T
24}
32}
25
33
26type direction int
34type direction int
@@ -31,41 +39,45 @@ const (
31)
39)
32
40
33const (
41const (
34− NotFound = -1
42+ NotFound = -1
43+ DefaultScrollSize = 2
35)
44)
36
45
46+type setSelectedMsg struct {
47+ selectedItemID string
48+}
49+
37type renderedItem struct {
50type renderedItem struct {
38 id string
51 id string
39 view string
52 view string
40 height int
53 height int
41}
54}
42
55
43−type list struct {
56+type confOptions struct {
44 width, height int
57 width, height int
45− offset int
46 gap int
58 gap int
59+ // if you are at the last item and go down it will wrap to the top
60+ wrap bool
61+ keyMap KeyMap
47− direction direction
62+ direction direction
48− selectedItem string
63+ selectedItem string
49− focused bool
64+}
65+type list[T Item] struct {
66+ confOptions
50
67
68+ focused bool
69+ offset int
51− items []Item
70+ items []T
52 renderedItems []renderedItem
71 renderedItems []renderedItem
53 rendered string
72 rendered string
54 isReady bool
73 isReady bool
55}
74}
56
75
57−type listOption func(*list)
76+type listOption func(*confOptions)
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−}
65
77
66// WithSize sets the size of the list.
78// WithSize sets the size of the list.
67func WithSize(width, height int) listOption {
79func WithSize(width, height int) listOption {
68− return func(l *list) {
80+ return func(l *confOptions) {
69 l.width = width
81 l.width = width
70 l.height = height
82 l.height = height
71 }
83 }
@@ -73,44 +85,53 @@ func WithSize(width, height int) listOption {
73
85
74// WithGap sets the gap between items in the list.
86// WithGap sets the gap between items in the list.
75func WithGap(gap int) listOption {
87func WithGap(gap int) listOption {
76− return func(l *list) {
88+ return func(l *confOptions) {
77 l.gap = gap
89 l.gap = gap
78 }
90 }
79}
91}
80
92
81// WithDirection sets the direction of the list.
93// WithDirection sets the direction of the list.
82func WithDirection(dir direction) listOption {
94func WithDirection(dir direction) listOption {
83− return func(l *list) {
95+ return func(l *confOptions) {
84 l.direction = dir
96 l.direction = dir
85 }
97 }
86}
98}
87
99
88// WithSelectedItem sets the initially selected item in the list.
100// WithSelectedItem sets the initially selected item in the list.
89func WithSelectedItem(id string) listOption {
101func WithSelectedItem(id string) listOption {
90− return func(l *list) {
102+ return func(l *confOptions) {
91 l.selectedItem = id
103 l.selectedItem = id
92 }
104 }
93}
105}
94
106
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+
95−func New(opts ...listOption) List {
119+func New[T Item](items []T, opts ...listOption) List[T] {
96− list := &list{
120+ list := &list[T]{
97− items: make([]Item, 0),
121+ confOptions: confOptions{
98− direction: Forward,
122+ direction: Forward,
123+ keyMap: DefaultKeyMap(),
124+ },
125+ items: items,
99 }
126 }
100 for _, opt := range opts {
127 for _, opt := range opts {
101− opt(list)
128+ opt(&list.confOptions)
102 }
129 }
103 return list
130 return list
104}
131}
105
132
106// Init implements List.
133// Init implements List.
107−func (l *list) Init() tea.Cmd {
134+func (l *list[T]) 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− }
114 var cmds []tea.Cmd
135 var cmds []tea.Cmd
115 for _, item := range l.items {
136 for _, item := range l.items {
116 cmd := item.Init()
137 cmd := item.Init()
@@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd {
121}
142}
122
143
123// Update implements List.
144// 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+ }
125 return l, nil
175 return l, nil
126}
176}
127
177
128// View implements List.
178// View implements List.
129−func (l *list) View() string {
179+func (l *list[T]) View() string {
130 if l.height <= 0 || l.width <= 0 {
180 if l.height <= 0 || l.width <= 0 {
131 return ""
181 return ""
132 }
182 }
@@ -138,7 +188,7 @@ func (l *list) View() string {
138 return strings.Join(lines, "\n")
188 return strings.Join(lines, "\n")
139}
189}
140
190
141−func (l *list) viewPosition() (int, int) {
191+func (l *list[T]) viewPosition() (int, int) {
142 start, end := 0, 0
192 start, end := 0, 0
143 renderedLines := lipgloss.Height(l.rendered) - 1
193 renderedLines := lipgloss.Height(l.rendered) - 1
144 if l.direction == Forward {
194 if l.direction == Forward {
@@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) {
151 return start, end
201 return start, end
152}
202}
153
203
154−func (l *list) renderItem(item Item) renderedItem {
204+func (l *list[T]) renderItem(item Item) renderedItem {
155 view := item.View()
205 view := item.View()
156 return renderedItem{
206 return renderedItem{
157 id: item.ID(),
207 id: item.ID(),
@@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem {
160 }
210 }
161}
211}
162
212
163−func (l *list) renderView() {
213+func (l *list[T]) renderView() {
164 var sb strings.Builder
214 var sb strings.Builder
165 for i, rendered := range l.renderedItems {
215 for i, rendered := range l.renderedItems {
166 sb.WriteString(rendered.view)
216 sb.WriteString(rendered.view)
@@ -171,7 +221,7 @@ func (l *list) renderView() {
171 l.rendered = sb.String()
221 l.rendered = sb.String()
172}
222}
173
223
174−func (l *list) incrementOffset(n int) {
224+func (l *list[T]) incrementOffset(n int) {
175 if !l.isReady {
225 if !l.isReady {
176 return
226 return
177 }
227 }
@@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) {
188 l.offset += n
238 l.offset += n
189}
239}
190
240
191−func (l *list) decrementOffset(n int) {
241+func (l *list[T]) decrementOffset(n int) {
192 if !l.isReady {
242 if !l.isReady {
193 return
243 return
194 }
244 }
@@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) {
203}
253}
204
254
205// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
255// 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 {
207 var cmds []tea.Cmd
257 var cmds []tea.Cmd
208 start, end := l.viewPosition()
258 start, end := l.viewPosition()
209 currentPosition := 0
259 currentPosition := 0
@@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
228 needsMove = true
278 needsMove = true
229 }
279 }
230 if needsMove {
280 if needsMove {
231− if focusable, ok := item.(layout.Focusable); ok {
281+ if focusable, ok := any(item).(layout.Focusable); ok {
232 cmds = append(cmds, focusable.Blur())
282 cmds = append(cmds, focusable.Blur())
233 }
283 }
234 l.renderedItems[i] = l.renderItem(item)
284 l.renderedItems[i] = l.renderItem(item)
@@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
239 if itemWithinView != NotFound && needsMove {
289 if itemWithinView != NotFound && needsMove {
240 newSelection := l.items[itemWithinView]
290 newSelection := l.items[itemWithinView]
241 l.selectedItem = newSelection.ID()
291 l.selectedItem = newSelection.ID()
242− if focusable, ok := newSelection.(layout.Focusable); ok {
292+ if focusable, ok := any(newSelection).(layout.Focusable); ok {
243 cmds = append(cmds, focusable.Focus())
293 cmds = append(cmds, focusable.Focus())
244 }
294 }
245 l.renderedItems[itemWithinView] = l.renderItem(newSelection)
295 l.renderedItems[itemWithinView] = l.renderItem(newSelection)
@@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
251 return tea.Batch(cmds...)
301 return tea.Batch(cmds...)
252}
302}
253
303
254−func (l *list) MoveUp(n int) tea.Cmd {
304+func (l *list[T]) MoveUp(n int) tea.Cmd {
255 if l.direction == Forward {
305 if l.direction == Forward {
256 l.decrementOffset(n)
306 l.decrementOffset(n)
257 } else {
307 } else {
@@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd {
260 return l.changeSelectedWhenNotVisible()
310 return l.changeSelectedWhenNotVisible()
261}
311}
262
312
263−func (l *list) MoveDown(n int) tea.Cmd {
313+func (l *list[T]) MoveDown(n int) tea.Cmd {
264 if l.direction == Forward {
314 if l.direction == Forward {
265 l.incrementOffset(n)
315 l.incrementOffset(n)
266 } else {
316 } else {
@@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd {
269 return l.changeSelectedWhenNotVisible()
319 return l.changeSelectedWhenNotVisible()
270}
320}
271
321
272−func (l *list) firstSelectableItemBefore(inx int) int {
322+func (l *list[T]) firstSelectableItemBefore(inx int) int {
273 for i := inx - 1; i >= 0; i-- {
323 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 {
275 return i
325 return i
276 }
326 }
277 }
327 }
328+ if inx == 0 && l.wrap {
329+ return l.firstSelectableItemBefore(len(l.items))
330+ }
278 return NotFound
331 return NotFound
279}
332}
280
333
281−func (l *list) firstSelectableItemAfter(inx int) int {
334+func (l *list[T]) firstSelectableItemAfter(inx int) int {
282 for i := inx + 1; i < len(l.items); i++ {
335 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 {
284 return i
337 return i
285 }
338 }
286 }
339 }
340+ if inx == len(l.items)-1 && l.wrap {
341+ return l.firstSelectableItemAfter(-1)
342+ }
287 return NotFound
343 return NotFound
288}
344}
289
345
290−func (l *list) moveToSelected() {
346+func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347+ var cmds []tea.Cmd
291 if l.selectedItem == "" || !l.isReady {
348 if l.selectedItem == "" || !l.isReady {
292− return
349+ return nil
293 }
350 }
294 currentPosition := 0
351 currentPosition := 0
295 start, end := l.viewPosition()
352 start, end := l.viewPosition()
296 for _, item := range l.renderedItems {
353 for _, item := range l.renderedItems {
297 if item.id == l.selectedItem {
354 if item.id == l.selectedItem {
355+ itemStart := currentPosition
298− if start <= currentPosition && (currentPosition+item.height) <= end {
356+ itemEnd := currentPosition + item.height - 1
357+
358+ if start <= itemStart && itemEnd <= end {
299− return
359+ return nil
300− }
301− // we need to go up
302− if currentPosition < start {
303− l.MoveUp(start - currentPosition)
304 }
360 }
305− // we need to go down
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 {
306− if currentPosition > end {
382+ if currentPosition < start {
383+ cmds = append(cmds, l.MoveUp(start-currentPosition))
384+ }
385+ if currentPosition > end {
307− l.MoveDown(currentPosition - end)
386+ cmds = append(cmds, l.MoveDown(currentPosition-end))
387+ }
308 }
388 }
309 }
389 }
310 currentPosition += item.height + l.gap
390 currentPosition += item.height + l.gap
311 }
391 }
392+ return tea.Batch(cmds...)
312}
393}
313
394
314−func (l *list) SelectItemAbove() tea.Cmd {
395+func (l *list[T]) SelectItemAbove() tea.Cmd {
315 if !l.isReady {
396 if !l.isReady {
316 return nil
397 return nil
317 }
398 }
@@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd {
324 return nil
405 return nil
325 }
406 }
326 // blur the current item
407 // blur the current item
327− if focusable, ok := item.(layout.Focusable); ok {
408+ if focusable, ok := any(item).(layout.Focusable); ok {
328 cmds = append(cmds, focusable.Blur())
409 cmds = append(cmds, focusable.Blur())
329 }
410 }
330 // rerender the item
411 // rerender the item
331 l.renderedItems[i] = l.renderItem(item)
412 l.renderedItems[i] = l.renderItem(item)
332 // focus the item above
413 // focus the item above
333 above := l.items[inx]
414 above := l.items[inx]
334− if focusable, ok := above.(layout.Focusable); ok {
415+ if focusable, ok := any(above).(layout.Focusable); ok {
335 cmds = append(cmds, focusable.Focus())
416 cmds = append(cmds, focusable.Focus())
336 }
417 }
337 // rerender the item
418 // rerender the item
@@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd {
340 break
421 break
341 }
422 }
342 }
423 }
424+ l.moveToSelected(false)
343 l.renderView()
425 l.renderView()
344− l.moveToSelected()
345 return tea.Batch(cmds...)
426 return tea.Batch(cmds...)
346}
427}
347
428
348−func (l *list) SelectItemBelow() tea.Cmd {
429+func (l *list[T]) SelectItemBelow() tea.Cmd {
349 if !l.isReady {
430 if !l.isReady {
350 return nil
431 return nil
351 }
432 }
@@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
358 return nil
439 return nil
359 }
440 }
360 // blur the current item
441 // blur the current item
361− if focusable, ok := item.(layout.Focusable); ok {
442+ if focusable, ok := any(item).(layout.Focusable); ok {
362 cmds = append(cmds, focusable.Blur())
443 cmds = append(cmds, focusable.Blur())
363 }
444 }
364 // rerender the item
445 // rerender the item
@@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
366
447
367 // focus the item below
448 // focus the item below
368 below := l.items[inx]
449 below := l.items[inx]
369− if focusable, ok := below.(layout.Focusable); ok {
450+ if focusable, ok := any(below).(layout.Focusable); ok {
370 cmds = append(cmds, focusable.Focus())
451 cmds = append(cmds, focusable.Focus())
371 }
452 }
372 // rerender the item
453 // rerender the item
@@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd {
376 }
457 }
377 }
458 }
378
459
460+ l.moveToSelected(false)
379 l.renderView()
461 l.renderView()
380− l.moveToSelected()
381 return tea.Batch(cmds...)
462 return tea.Batch(cmds...)
382}
463}
383
464
384−func (l *list) GoToTop() tea.Cmd {
465+func (l *list[T]) GoToTop() tea.Cmd {
385 if !l.isReady {
466 if !l.isReady {
386 return nil
467 return nil
387 }
468 }
@@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd {
390 return tea.Batch(l.selectFirstItem(), l.renderForward())
471 return tea.Batch(l.selectFirstItem(), l.renderForward())
391}
472}
392
473
393−func (l *list) GoToBottom() tea.Cmd {
474+func (l *list[T]) GoToBottom() tea.Cmd {
394 if !l.isReady {
475 if !l.isReady {
395 return nil
476 return nil
396 }
477 }
@@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd {
400 return tea.Batch(l.selectLastItem(), l.renderBackward())
481 return tea.Batch(l.selectLastItem(), l.renderBackward())
401}
482}
402
483
403−func (l *list) renderForward() tea.Cmd {
484+func (l *list[T]) renderForward() tea.Cmd {
404 // TODO: figure out a way to preserve items that did not change
485 // TODO: figure out a way to preserve items that did not change
405 l.renderedItems = make([]renderedItem, 0)
486 l.renderedItems = make([]renderedItem, 0)
406 currentHeight := 0
487 currentHeight := 0
@@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd {
434 }
515 }
435}
516}
436
517
437−func (l *list) renderBackward() tea.Cmd {
518+func (l *list[T]) renderBackward() tea.Cmd {
438 // TODO: figure out a way to preserve items that did not change
519 // TODO: figure out a way to preserve items that did not change
439 l.renderedItems = make([]renderedItem, 0)
520 l.renderedItems = make([]renderedItem, 0)
440 currentHeight := 0
521 currentHeight := 0
441 currentIndex := 0
522 currentIndex := 0
442 for i := len(l.items) - 1; i >= 0; i-- {
523 for i := len(l.items) - 1; i >= 0; i-- {
443− fmt.Printf("rendering item %d\n", i)
444 currentIndex = i
524 currentIndex = i
445 if currentHeight > l.listHeight() {
525 if currentHeight > l.listHeight() {
446 break
526 break
@@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd {
457 }
537 }
458 return func() tea.Msg {
538 return func() tea.Msg {
459 for i := currentIndex; i >= 0; i-- {
539 for i := currentIndex; i >= 0; i-- {
460− fmt.Printf("rendering item after %d\n", i)
461 rendered := l.renderItem(l.items[i])
540 rendered := l.renderItem(l.items[i])
462 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
541 l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
463 }
542 }
@@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd {
467 }
546 }
468}
547}
469
548
470−func (l *list) selectFirstItem() tea.Cmd {
549+func (l *list[T]) selectFirstItem() tea.Cmd {
471 var cmd tea.Cmd
550 var cmd tea.Cmd
472 inx := l.firstSelectableItemAfter(-1)
551 inx := l.firstSelectableItemAfter(-1)
473 if inx != NotFound {
552 if inx != NotFound {
474 l.selectedItem = l.items[inx].ID()
553 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 {
476 cmd = focusable.Focus()
555 cmd = focusable.Focus()
477 }
556 }
478 }
557 }
479 return cmd
558 return cmd
480}
559}
481
560
482−func (l *list) selectLastItem() tea.Cmd {
561+func (l *list[T]) selectLastItem() tea.Cmd {
483 var cmd tea.Cmd
562 var cmd tea.Cmd
484 inx := l.firstSelectableItemBefore(len(l.items))
563 inx := l.firstSelectableItemBefore(len(l.items))
485 if inx != NotFound {
564 if inx != NotFound {
486 l.selectedItem = l.items[inx].ID()
565 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 {
488 cmd = focusable.Focus()
567 cmd = focusable.Focus()
489 }
568 }
490 }
569 }
491 return cmd
570 return cmd
492}
571}
493
572
494−func (l *list) renderItems() tea.Cmd {
573+func (l *list[T]) renderItems() tea.Cmd {
495 if l.height <= 0 || l.width <= 0 {
574 if l.height <= 0 || l.width <= 0 {
496 return nil
575 return nil
497 }
576 }
@@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd {
512 return l.renderBackward()
591 return l.renderBackward()
513}
592}
514
593
515−func (l *list) listHeight() int {
594+func (l *list[T]) listHeight() int {
516 // for the moment its the same
595 // for the moment its the same
517 return l.height
596 return l.height
518}
597}
519
598
520−func (l *list) SetItems(items []Item) tea.Cmd {
599+func (l *list[T]) SetItems(items []T) tea.Cmd {
521 l.items = items
600 l.items = items
522 var cmds []tea.Cmd
601 var cmds []tea.Cmd
523 for _, item := range l.items {
602 for _, item := range l.items {
@@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd {
525 // Set height to 0 to let the item calculate its own height
604 // Set height to 0 to let the item calculate its own height
526 cmds = append(cmds, item.SetSize(l.width, 0))
605 cmds = append(cmds, item.SetSize(l.width, 0))
527 }
606 }
607+
608+ if l.selectedItem != "" {
609+ cmds = append(cmds, l.moveToSelected(true))
610+ }
528 cmds = append(cmds, l.renderItems())
611 cmds = append(cmds, l.renderItems())
529 return tea.Batch(cmds...)
612 return tea.Batch(cmds...)
530}
613}
531
614
532// GetSize implements List.
615// GetSize implements List.
533−func (l *list) GetSize() (int, int) {
616+func (l *list[T]) GetSize() (int, int) {
534 return l.width, l.height
617 return l.width, l.height
535}
618}
536
619
537// SetSize implements List.
620// 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 {
539 l.width = width
622 l.width = width
540 l.height = height
623 l.height = height
541 var cmds []tea.Cmd
624 var cmds []tea.Cmd
542 for _, item := range l.items {
625 for _, item := range l.items {
543 cmds = append(cmds, item.SetSize(width, height))
626 cmds = append(cmds, item.SetSize(width, height))
544 }
627 }
628+
545 cmds = append(cmds, l.renderItems())
629 cmds = append(cmds, l.renderItems())
546 return tea.Batch(cmds...)
630 return tea.Batch(cmds...)
547}
631}
548
632
549// Blur implements List.
633// Blur implements List.
550−func (l *list) Blur() tea.Cmd {
634+func (l *list[T]) Blur() tea.Cmd {
551 var cmd tea.Cmd
635 var cmd tea.Cmd
552 l.focused = false
636 l.focused = false
553 for i, item := range l.items {
637 for i, item := range l.items {
554 if item.ID() != l.selectedItem {
638 if item.ID() != l.selectedItem {
555 continue
639 continue
556 }
640 }
557− if focusable, ok := item.(layout.Focusable); ok {
641+ if focusable, ok := any(item).(layout.Focusable); ok {
558 cmd = focusable.Blur()
642 cmd = focusable.Blur()
559 }
643 }
560 l.renderedItems[i] = l.renderItem(item)
644 l.renderedItems[i] = l.renderItem(item)
@@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd {
564}
648}
565
649
566// Focus implements List.
650// Focus implements List.
567−func (l *list) Focus() tea.Cmd {
651+func (l *list[T]) Focus() tea.Cmd {
568 var cmd tea.Cmd
652 var cmd tea.Cmd
569 l.focused = true
653 l.focused = true
654+ if l.selectedItem != "" {
570− for i, item := range l.items {
655+ for i, item := range l.items {
571− if item.ID() != l.selectedItem {
656+ if item.ID() != l.selectedItem {
572− continue
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+ }
573 }
665 }
574− if focusable, ok := item.(layout.Focusable); ok {
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 {
575− cmd = focusable.Focus()
686+ cmds = append(cmds, focusable.Focus())
687+ }
688+ if len(l.renderedItems) > i {
689+ l.renderedItems[i] = l.renderItem(item)
690+ }
576 }
691 }
577− l.renderedItems[i] = l.renderItem(item)
578 }
692 }
693+ l.selectedItem = id
694+ cmds = append(cmds, l.moveToSelected(true))
579 l.renderView()
695 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
581}
706}
582
707
583// IsFocused implements List.
708// IsFocused implements List.
584−func (l *list) IsFocused() bool {
709+func (l *list[T]) IsFocused() bool {
585 return l.focused
710 return l.focused
586}
711}
internal/tui/exp/list/list_test.go+148−93
@@ -2,6 +2,7 @@ package list
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "sync"
5 "testing"
6 "testing"
6
7
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
@@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) {
74 }
75 }
75 for _, c := range tests {
76 for _, c := range tests {
76 t.Run(c.test, func(t *testing.T) {
77 t.Run(c.test, func(t *testing.T) {
77− l := New(WithDirection(c.dir)).(*list)
78− l.SetSize(c.width, c.height)
79 items := []Item{}
78 items := []Item{}
80 for i := range c.numItems {
79 for i := range c.numItems {
81− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
80+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
82 items = append(items, item)
81 items = append(items, item)
83 }
82 }
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()
85 if cmd != nil {
86 if cmd != nil {
86 cmd()
87 cmd()
87 }
88 }
@@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) {
102func TestBackwardList(t *testing.T) {
103func TestBackwardList(t *testing.T) {
103 t.Run("within height", func(t *testing.T) {
104 t.Run("within height", func(t *testing.T) {
104 t.Parallel()
105 t.Parallel()
105− l := New(WithDirection(Backward), WithGap(1)).(*list)
106− l.SetSize(10, 20)
107 items := []Item{}
106 items := []Item{}
108 for i := range 5 {
107 for i := range 5 {
109− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
108+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
110 items = append(items, item)
109 items = append(items, item)
111 }
110 }
112− cmd := l.SetItems(items)
111+ l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
112+ l.SetSize(10, 20)
113+ cmd := l.Init()
113 if cmd != nil {
114 if cmd != nil {
114 cmd()
115 cmd()
115 }
116 }
116
117
117 // should select the last item
118 // should select the last item
118 assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
119 assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
119−
120 golden.RequireEqual(t, []byte(l.View()))
120 golden.RequireEqual(t, []byte(l.View()))
121 })
121 })
122 t.Run("should not change selected item", func(t *testing.T) {
122 t.Run("should not change selected item", func(t *testing.T) {
123 t.Parallel()
123 t.Parallel()
124 items := []Item{}
124 items := []Item{}
125 for i := range 5 {
125 for i := range 5 {
126− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
126+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
127 items = append(items, item)
127 items = append(items, item)
128 }
128 }
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])
130 l.SetSize(10, 20)
130 l.SetSize(10, 20)
131− cmd := l.SetItems(items)
131+ cmd := l.Init()
132 if cmd != nil {
132 if cmd != nil {
133 cmd()
133 cmd()
134 }
134 }
@@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) {
137 })
137 })
138 t.Run("more than height", func(t *testing.T) {
138 t.Run("more than height", func(t *testing.T) {
139 t.Parallel()
139 t.Parallel()
140− l := New(WithDirection(Backward))
141− l.SetSize(10, 5)
142 items := []Item{}
140 items := []Item{}
143 for i := range 10 {
141 for i := range 10 {
144− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
142+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
145 items = append(items, item)
143 items = append(items, item)
146 }
144 }
147− cmd := l.SetItems(items)
145+ l := New(items, WithDirection(Backward))
146+ l.SetSize(10, 5)
147+ cmd := l.Init()
148 if cmd != nil {
148 if cmd != nil {
149 cmd()
149 cmd()
150 }
150 }
@@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) {
153 })
153 })
154 t.Run("more than height multi line", func(t *testing.T) {
154 t.Run("more than height multi line", func(t *testing.T) {
155 t.Parallel()
155 t.Parallel()
156− l := New(WithDirection(Backward))
157− l.SetSize(10, 5)
158 items := []Item{}
156 items := []Item{}
159 for i := range 10 {
157 for i := range 10 {
160− item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
158+ item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
161 items = append(items, item)
159 items = append(items, item)
162 }
160 }
163− cmd := l.SetItems(items)
161+ l := New(items, WithDirection(Backward))
162+ l.SetSize(10, 5)
163+ cmd := l.Init()
164 if cmd != nil {
164 if cmd != nil {
165 cmd()
165 cmd()
166 }
166 }
@@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) {
169 })
169 })
170 t.Run("should move up", func(t *testing.T) {
170 t.Run("should move up", func(t *testing.T) {
171 t.Parallel()
171 t.Parallel()
172− l := New(WithDirection(Backward)).(*list)
173− l.SetSize(10, 5)
174 items := []Item{}
172 items := []Item{}
175 for i := range 10 {
173 for i := range 10 {
176− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
174+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
177 items = append(items, item)
175 items = append(items, item)
178 }
176 }
179− cmd := l.SetItems(items)
177+ l := New(items, WithDirection(Backward))
178+ l.SetSize(10, 5)
179+ cmd := l.Init()
180 if cmd != nil {
180 if cmd != nil {
181 cmd()
181 cmd()
182 }
182 }
@@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) {
186 })
186 })
187
187
188 t.Run("should move at max to the top", func(t *testing.T) {
188 t.Run("should move at max to the top", func(t *testing.T) {
189− l := New(WithDirection(Backward)).(*list)
190− l.SetSize(10, 5)
191 items := []Item{}
189 items := []Item{}
192 for i := range 10 {
190 for i := range 10 {
193− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
191+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
194 items = append(items, item)
192 items = append(items, item)
195 }
193 }
196− cmd := l.SetItems(items)
194+ l := New(items, WithDirection(Backward)).(*list[Item])
195+ l.SetSize(10, 5)
196+ cmd := l.Init()
197 if cmd != nil {
197 if cmd != nil {
198 cmd()
198 cmd()
199 }
199 }
@@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) {
204 })
204 })
205 t.Run("should do nothing with wrong move number", func(t *testing.T) {
205 t.Run("should do nothing with wrong move number", func(t *testing.T) {
206 t.Parallel()
206 t.Parallel()
207− l := New(WithDirection(Backward)).(*list)
208− l.SetSize(10, 5)
209 items := []Item{}
207 items := []Item{}
210 for i := range 10 {
208 for i := range 10 {
211− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
209+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
212 items = append(items, item)
210 items = append(items, item)
213 }
211 }
214− cmd := l.SetItems(items)
212+ l := New(items, WithDirection(Backward))
213+ l.SetSize(10, 5)
214+ cmd := l.Init()
215 if cmd != nil {
215 if cmd != nil {
216 cmd()
216 cmd()
217 }
217 }
@@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) {
221 })
221 })
222 t.Run("should move to the top", func(t *testing.T) {
222 t.Run("should move to the top", func(t *testing.T) {
223 t.Parallel()
223 t.Parallel()
224− l := New(WithDirection(Backward)).(*list)
225− l.SetSize(10, 5)
226 items := []Item{}
224 items := []Item{}
227 for i := range 10 {
225 for i := range 10 {
228− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
226+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
229 items = append(items, item)
227 items = append(items, item)
230 }
228 }
231− cmd := l.SetItems(items)
229+ l := New(items, WithDirection(Backward)).(*list[Item])
230+ l.SetSize(10, 5)
231+ cmd := l.Init()
232 if cmd != nil {
232 if cmd != nil {
233 cmd()
233 cmd()
234 }
234 }
@@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) {
239 })
239 })
240 t.Run("should select the item above", func(t *testing.T) {
240 t.Run("should select the item above", func(t *testing.T) {
241 t.Parallel()
241 t.Parallel()
242− l := New(WithDirection(Backward)).(*list)
243− l.SetSize(10, 5)
244 items := []Item{}
242 items := []Item{}
245 for i := range 10 {
243 for i := range 10 {
246− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
244+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
247 items = append(items, item)
245 items = append(items, item)
248 }
246 }
249− cmd := l.SetItems(items)
247+ l := New(items, WithDirection(Backward)).(*list[Item])
248+ l.SetSize(10, 5)
249+ cmd := l.Init()
250 if cmd != nil {
250 if cmd != nil {
251 cmd()
251 cmd()
252 }
252 }
@@ -268,14 +268,14 @@ func TestBackwardList(t *testing.T) {
268 })
268 })
269 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
269 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
270 t.Parallel()
270 t.Parallel()
271− l := New(WithDirection(Backward)).(*list)
272− l.SetSize(10, 5)
273 items := []Item{}
271 items := []Item{}
274 for i := range 10 {
272 for i := range 10 {
275− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
273+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
276 items = append(items, item)
274 items = append(items, item)
277 }
275 }
278− cmd := l.SetItems(items)
276+ l := New(items, WithDirection(Backward)).(*list[Item])
277+ l.SetSize(10, 5)
278+ cmd := l.Init()
279 if cmd != nil {
279 if cmd != nil {
280 cmd()
280 cmd()
281 }
281 }
@@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) {
293func TestForwardList(t *testing.T) {
293func TestForwardList(t *testing.T) {
294 t.Run("within height", func(t *testing.T) {
294 t.Run("within height", func(t *testing.T) {
295 t.Parallel()
295 t.Parallel()
296− l := New(WithDirection(Forward), WithGap(1)).(*list)
297− l.SetSize(10, 20)
298 items := []Item{}
296 items := []Item{}
299 for i := range 5 {
297 for i := range 5 {
300− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
298+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
301 items = append(items, item)
299 items = append(items, item)
302 }
300 }
303− cmd := l.SetItems(items)
301+ l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
302+ l.SetSize(10, 20)
303+ cmd := l.Init()
304 if cmd != nil {
304 if cmd != nil {
305 cmd()
305 cmd()
306 }
306 }
@@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) {
314 t.Parallel()
314 t.Parallel()
315 items := []Item{}
315 items := []Item{}
316 for i := range 5 {
316 for i := range 5 {
317− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
317+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
318 items = append(items, item)
318 items = append(items, item)
319 }
319 }
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])
321 l.SetSize(10, 20)
321 l.SetSize(10, 20)
322− cmd := l.SetItems(items)
322+ cmd := l.Init()
323 if cmd != nil {
323 if cmd != nil {
324 cmd()
324 cmd()
325 }
325 }
@@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) {
328 })
328 })
329 t.Run("more than height", func(t *testing.T) {
329 t.Run("more than height", func(t *testing.T) {
330 t.Parallel()
330 t.Parallel()
331− l := New(WithDirection(Forward))
332− l.SetSize(10, 5)
333 items := []Item{}
331 items := []Item{}
334 for i := range 10 {
332 for i := range 10 {
335− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
333+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
336 items = append(items, item)
334 items = append(items, item)
337 }
335 }
338− cmd := l.SetItems(items)
336+ l := New(items, WithDirection(Forward)).(*list[Item])
337+ l.SetSize(10, 5)
338+ cmd := l.Init()
339 if cmd != nil {
339 if cmd != nil {
340 cmd()
340 cmd()
341 }
341 }
@@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) {
344 })
344 })
345 t.Run("more than height multi line", func(t *testing.T) {
345 t.Run("more than height multi line", func(t *testing.T) {
346 t.Parallel()
346 t.Parallel()
347− l := New(WithDirection(Forward))
348− l.SetSize(10, 5)
349 items := []Item{}
347 items := []Item{}
350 for i := range 10 {
348 for i := range 10 {
351− item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
349+ item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
352 items = append(items, item)
350 items = append(items, item)
353 }
351 }
354− cmd := l.SetItems(items)
352+ l := New(items, WithDirection(Forward)).(*list[Item])
353+ l.SetSize(10, 5)
354+ cmd := l.Init()
355 if cmd != nil {
355 if cmd != nil {
356 cmd()
356 cmd()
357 }
357 }
@@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) {
360 })
360 })
361 t.Run("should move down", func(t *testing.T) {
361 t.Run("should move down", func(t *testing.T) {
362 t.Parallel()
362 t.Parallel()
363− l := New(WithDirection(Forward)).(*list)
364− l.SetSize(10, 5)
365 items := []Item{}
363 items := []Item{}
366 for i := range 10 {
364 for i := range 10 {
367− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
365+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
368 items = append(items, item)
366 items = append(items, item)
369 }
367 }
370− cmd := l.SetItems(items)
368+ l := New(items, WithDirection(Forward)).(*list[Item])
369+ l.SetSize(10, 5)
370+ cmd := l.Init()
371 if cmd != nil {
371 if cmd != nil {
372 cmd()
372 cmd()
373 }
373 }
@@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) {
377 })
377 })
378 t.Run("should move at max to the bottom", func(t *testing.T) {
378 t.Run("should move at max to the bottom", func(t *testing.T) {
379 t.Parallel()
379 t.Parallel()
380− l := New(WithDirection(Forward)).(*list)
381− l.SetSize(10, 5)
382 items := []Item{}
380 items := []Item{}
383 for i := range 10 {
381 for i := range 10 {
384− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
382+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
385 items = append(items, item)
383 items = append(items, item)
386 }
384 }
387− cmd := l.SetItems(items)
385+ l := New(items, WithDirection(Forward)).(*list[Item])
386+ l.SetSize(10, 5)
387+ cmd := l.Init()
388 if cmd != nil {
388 if cmd != nil {
389 cmd()
389 cmd()
390 }
390 }
@@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) {
395 })
395 })
396 t.Run("should do nothing with wrong move number", func(t *testing.T) {
396 t.Run("should do nothing with wrong move number", func(t *testing.T) {
397 t.Parallel()
397 t.Parallel()
398− l := New(WithDirection(Forward)).(*list)
399− l.SetSize(10, 5)
400 items := []Item{}
398 items := []Item{}
401 for i := range 10 {
399 for i := range 10 {
402− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
400+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
403 items = append(items, item)
401 items = append(items, item)
404 }
402 }
405− cmd := l.SetItems(items)
403+ l := New(items, WithDirection(Forward)).(*list[Item])
404+ l.SetSize(10, 5)
405+ cmd := l.Init()
406 if cmd != nil {
406 if cmd != nil {
407 cmd()
407 cmd()
408 }
408 }
@@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) {
412 })
412 })
413 t.Run("should move to the bottom", func(t *testing.T) {
413 t.Run("should move to the bottom", func(t *testing.T) {
414 t.Parallel()
414 t.Parallel()
415− l := New(WithDirection(Forward)).(*list)
416− l.SetSize(10, 5)
417 items := []Item{}
415 items := []Item{}
418 for i := range 10 {
416 for i := range 10 {
419− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
417+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
420 items = append(items, item)
418 items = append(items, item)
421 }
419 }
422− cmd := l.SetItems(items)
420+ l := New(items, WithDirection(Forward)).(*list[Item])
421+ l.SetSize(10, 5)
422+ cmd := l.Init()
423 if cmd != nil {
423 if cmd != nil {
424 cmd()
424 cmd()
425 }
425 }
@@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) {
430 })
430 })
431 t.Run("should select the item below", func(t *testing.T) {
431 t.Run("should select the item below", func(t *testing.T) {
432 t.Parallel()
432 t.Parallel()
433− l := New(WithDirection(Forward)).(*list)
434− l.SetSize(10, 5)
435 items := []Item{}
433 items := []Item{}
436 for i := range 10 {
434 for i := range 10 {
437− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
435+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
438 items = append(items, item)
436 items = append(items, item)
439 }
437 }
440− cmd := l.SetItems(items)
438+ l := New(items, WithDirection(Forward)).(*list[Item])
439+ l.SetSize(10, 5)
440+ cmd := l.Init()
441 if cmd != nil {
441 if cmd != nil {
442 cmd()
442 cmd()
443 }
443 }
@@ -459,14 +459,14 @@ func TestForwardList(t *testing.T) {
459 })
459 })
460 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
460 t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
461 t.Parallel()
461 t.Parallel()
462− l := New(WithDirection(Backward)).(*list)
463− l.SetSize(10, 5)
464 items := []Item{}
462 items := []Item{}
465 for i := range 10 {
463 for i := range 10 {
466− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
464+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
467 items = append(items, item)
465 items = append(items, item)
468 }
466 }
469− cmd := l.SetItems(items)
467+ l := New(items, WithDirection(Forward)).(*list[Item])
468+ l.SetSize(10, 5)
469+ cmd := l.Init()
470 if cmd != nil {
470 if cmd != nil {
471 cmd()
471 cmd()
472 }
472 }
@@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) {
484func TestListSelection(t *testing.T) {
484func TestListSelection(t *testing.T) {
485 t.Run("should skip none selectable items initially", func(t *testing.T) {
485 t.Run("should skip none selectable items initially", func(t *testing.T) {
486 t.Parallel()
486 t.Parallel()
487− l := New(WithDirection(Forward)).(*list)
488− l.SetSize(100, 10)
489 items := []Item{}
487 items := []Item{}
490 items = append(items, NewSimpleItem("None Selectable"))
488 items = append(items, NewSimpleItem("None Selectable"))
491 for i := range 5 {
489 for i := range 5 {
492− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
490+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
493 items = append(items, item)
491 items = append(items, item)
494 }
492 }
495− cmd := l.SetItems(items)
493+ l := New(items, WithDirection(Forward)).(*list[Item])
494+ l.SetSize(100, 10)
495+ cmd := l.Init()
496 if cmd != nil {
496 if cmd != nil {
497 cmd()
497 cmd()
498 }
498 }
@@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) {
500 assert.Equal(t, items[1].ID(), l.selectedItem)
500 assert.Equal(t, items[1].ID(), l.selectedItem)
501 golden.RequireEqual(t, []byte(l.View()))
501 golden.RequireEqual(t, []byte(l.View()))
502 })
502 })
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) {
504 t.Parallel()
504 t.Parallel()
505+ items := []Item{}
506+ for i := range 5 {
507+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
508+ items = append(items, item)
509+ }
505− l := New(WithDirection(Forward)).(*list)
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()
506 l.SetSize(100, 10)
529 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()
507 items := []Item{}
535 items := []Item{}
508− item := NewSelectsableItem("Item initial")
536+ item := NewSelectableItem("Item initial")
509 items = append(items, item)
537 items = append(items, item)
510 items = append(items, NewSimpleItem("None Selectable"))
538 items = append(items, NewSimpleItem("None Selectable"))
511 for i := range 5 {
539 for i := range 5 {
512− item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
540+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
513 items = append(items, item)
541 items = append(items, item)
514 }
542 }
515− cmd := l.SetItems(items)
543+ l := New(items, WithDirection(Forward)).(*list[Item])
544+ l.SetSize(100, 10)
545+ cmd := l.Init()
516 if cmd != nil {
546 if cmd != nil {
517 cmd()
547 cmd()
518 }
548 }
@@ -522,6 +552,31 @@ func TestListSelection(t *testing.T) {
522 })
552 })
523}
553}
524
554
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+
525type SelectableItem interface {
580type SelectableItem interface {
526 Item
581 Item
527 layout.Focusable
582 layout.Focusable
@@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem {
545 }
600 }
546}
601}
547
602
548−func NewSelectsableItem(content string) SelectableItem {
603+func NewSelectableItem(content string) SelectableItem {
549 return &selectableItem{
604 return &selectableItem{
550 simpleItem: NewSimpleItem(content),
605 simpleItem: NewSimpleItem(content),
551 focused: false,
606 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
1+Item 1
2−Item 6
2+Item 2
3−Item 7
3+Item 3
4−Item 8
4+Item 4
5−│Item 9
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