diff --git a/cspell.json b/cspell.json index 2cffa38ca36558d9273f2781dd7a686be1b3820d..797efddbfc2ba8dbbb8b121f4192f2449b2025ae 100644 --- a/cspell.json +++ b/cspell.json @@ -1,108 +1 @@ -{ - "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" - ], - "flagWords": [], - "language": "en", - "version": "0.2" -} +{"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"} \ No newline at end of file diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 3f99eda5d979e72f0497a120e056df10aca228c3..c6b0a8b1590792b20e05eb7a834b219bd1c00c10 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel { } if m.filterable && !m.hideFilterInput { - t := styles.CurrentTheme() ti := textinput.New() ti.Placeholder = m.filterPlaceholder ti.SetVirtualCursor(false) diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index a95ae0c5ce9b07d499d4f78834a69ccd7ed5635f..7822256d9afb5b8583144142acb55ea3ec287483 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -6,10 +6,9 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/completions" "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs" + "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" @@ -22,6 +21,8 @@ type SessionDialog interface { dialogs.DialogModel } +type SessionsList = list.FilterableList[list.CompletionItem[session.Session]] + type sessionDialogCmp struct { selectedInx int wWidth int @@ -29,8 +30,7 @@ type sessionDialogCmp struct { width int selectedSessionID string keyMap KeyMap - sessionsList list.ListModel - renderedSelected bool + sessionsList SessionsList help help.Model } @@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD t := styles.CurrentTheme() listKeyMap := list.DefaultKeyMap() keyMap := DefaultKeyMap() - listKeyMap.Down.SetEnabled(false) listKeyMap.Up.SetEnabled(false) - listKeyMap.HalfPageDown.SetEnabled(false) - listKeyMap.HalfPageUp.SetEnabled(false) - listKeyMap.Home.SetEnabled(false) - listKeyMap.End.SetEnabled(false) - listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - selectedInx := 0 - items := make([]util.Model, len(sessions)) + items := make([]list.CompletionItem[session.Session], len(sessions)) if len(sessions) > 0 { for i, session := range sessions { - items[i] = completions.NewCompletionItem(session.Title, session) - if session.ID == selectedID { - selectedInx = i - } + items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID)) } } - sessionsList := list.New( - list.WithFilterable(true), + inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1) + sessionsList := list.NewFilterableList( + items, list.WithFilterPlaceholder("Enter a session name"), - list.WithKeyMap(listKeyMap), - list.WithItems(items), - list.WithWrapNavigation(true), + list.WithFilterInputStyle(inputStyle), + list.WithFilterListOptions( + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(), + ), ) help := help.New() help.Styles = t.S().Help s := &sessionDialogCmp{ - selectedInx: selectedInx, selectedSessionID: selectedID, keyMap: DefaultKeyMap(), sessionsList: sessionsList, @@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD } func (s *sessionDialogCmp) Init() tea.Cmd { - return s.sessionsList.Init() + var cmds []tea.Cmd + cmds = append(cmds, s.sessionsList.Init()) + cmds = append(cmds, s.sessionsList.Focus()) + return tea.Sequence(cmds...) } func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + var cmds []tea.Cmd s.wWidth = msg.Width s.wHeight = msg.Height - s.width = s.wWidth / 2 - var cmds []tea.Cmd + s.width = min(120, s.wWidth-8) + s.sessionsList.SetInputWidth(s.listWidth() - 2) cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) - if !s.renderedSelected { - cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx)) - s.renderedSelected = true + if s.selectedSessionID != "" { + cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID)) } - return s, tea.Sequence(cmds...) + return s, tea.Batch(cmds...) case tea.KeyPressMsg: switch { case key.Matches(msg, s.keyMap.Select): - if len(s.sessionsList.Items()) > 0 { - items := s.sessionsList.Items() - selectedItemInx := s.sessionsList.SelectedIndex() + selectedItem := s.sessionsList.SelectedItem() + if selectedItem != nil { + selected := *selectedItem return s, tea.Sequence( util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler( - chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)), + chat.SessionSelectedMsg(selected.Value()), ), ) } @@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := s.sessionsList.Update(msg) - s.sessionsList = u.(list.ListModel) + s.sessionsList = u.(SessionsList) return s, cmd } } diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go new file mode 100644 index 0000000000000000000000000000000000000000..4e2ac9a3e87766efc95a022db3d0adddb15a7544 --- /dev/null +++ b/internal/tui/exp/list/filterable.go @@ -0,0 +1,297 @@ +package list + +import ( + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/textinput" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/sahilm/fuzzy" +) + +type FilterableItem interface { + Item + FilterValue() string +} + +type FilterableList[T FilterableItem] interface { + List[T] + Cursor() *tea.Cursor + SetInputWidth(int) +} + +type HasMatchIndexes interface { + MatchIndexes([]int) +} + +type filterableOptions struct { + listOptions []listOption + placeholder string + inputHidden bool + inputWidth int + inputStyle lipgloss.Style +} +type filterableList[T FilterableItem] struct { + *list[T] + filterableOptions + width, height int + // stores all available items + items []T + input textinput.Model + inputWidth int + query string +} + +type filterableListOption func(*filterableOptions) + +func WithFilterPlaceholder(ph string) filterableListOption { + return func(f *filterableOptions) { + f.placeholder = ph + } +} + +func WithFilterInputHidden() filterableListOption { + return func(f *filterableOptions) { + f.inputHidden = true + } +} + +func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption { + return func(f *filterableOptions) { + f.inputStyle = inputStyle + } +} + +func WithFilterListOptions(opts ...listOption) filterableListOption { + return func(f *filterableOptions) { + f.listOptions = opts + } +} + +func WithFilterInputWidth(inputWidth int) filterableListOption { + return func(f *filterableOptions) { + f.inputWidth = inputWidth + } +} + +func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] { + t := styles.CurrentTheme() + + f := &filterableList[T]{ + filterableOptions: filterableOptions{ + inputStyle: t.S().Base, + placeholder: "Type to filter", + }, + } + for _, opt := range opts { + opt(&f.filterableOptions) + } + f.list = New[T](items, f.listOptions...).(*list[T]) + + f.updateKeyMaps() + f.items = f.list.items + + if f.inputHidden { + return f + } + + ti := textinput.New() + ti.Placeholder = f.placeholder + ti.SetVirtualCursor(false) + ti.Focus() + ti.SetStyles(t.S().TextInput) + f.input = ti + return f +} + +func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + // handle movements + case key.Matches(msg, f.keyMap.Down), + key.Matches(msg, f.keyMap.Up), + key.Matches(msg, f.keyMap.DownOneItem), + key.Matches(msg, f.keyMap.UpOneItem), + key.Matches(msg, f.keyMap.HalfPageDown), + key.Matches(msg, f.keyMap.HalfPageUp), + key.Matches(msg, f.keyMap.PageDown), + key.Matches(msg, f.keyMap.PageUp), + key.Matches(msg, f.keyMap.End), + key.Matches(msg, f.keyMap.Home): + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd + default: + if !f.inputHidden { + var cmds []tea.Cmd + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + cmds = append(cmds, cmd) + + if f.query != f.input.Value() { + cmd = f.Filter(f.input.Value()) + cmds = append(cmds, cmd) + } + f.query = f.input.Value() + return f, tea.Batch(cmds...) + } + } + } + u, cmd := f.list.Update(msg) + f.list = u.(*list[T]) + return f, cmd +} + +func (f *filterableList[T]) View() string { + if f.inputHidden { + return f.list.View() + } + + return lipgloss.JoinVertical( + lipgloss.Left, + f.inputStyle.Render(f.input.View()), + f.list.View(), + ) +} + +// removes bindings that are used for search +func (f *filterableList[T]) updateKeyMaps() { + alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$") + + removeLettersAndNumbers := func(bindings []string) []string { + var keep []string + for _, b := range bindings { + if len(b) != 1 { + keep = append(keep, b) + continue + } + if b == " " { + continue + } + m := alphanumeric.MatchString(b) + if !m { + keep = append(keep, b) + } + } + return keep + } + + updateBinding := func(binding key.Binding) key.Binding { + newKeys := removeLettersAndNumbers(binding.Keys()) + if len(newKeys) == 0 { + binding.SetEnabled(false) + return binding + } + binding.SetKeys(newKeys...) + return binding + } + + f.keyMap.Down = updateBinding(f.keyMap.Down) + f.keyMap.Up = updateBinding(f.keyMap.Up) + f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem) + f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem) + f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown) + f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp) + f.keyMap.PageDown = updateBinding(f.keyMap.PageDown) + f.keyMap.PageUp = updateBinding(f.keyMap.PageUp) + f.keyMap.End = updateBinding(f.keyMap.End) + f.keyMap.Home = updateBinding(f.keyMap.Home) +} + +func (m *filterableList[T]) GetSize() (int, int) { + return m.width, m.height +} + +func (f *filterableList[T]) SetSize(w, h int) tea.Cmd { + f.width = w + f.height = h + if f.inputHidden { + return f.list.SetSize(w, h) + } + if f.inputWidth == 0 { + f.input.SetWidth(w) + } else { + f.input.SetWidth(f.inputWidth) + } + return f.list.SetSize(w, h-(f.inputHeight())) +} + +func (f *filterableList[T]) inputHeight() int { + return lipgloss.Height(f.inputStyle.Render(f.input.View())) +} + +func (f *filterableList[T]) Filter(query string) tea.Cmd { + var cmds []tea.Cmd + for _, item := range f.items { + if i, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, i.Blur()) + } + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(make([]int, 0)) + } + } + + f.selectedItem = "" + if query == "" { + return f.list.SetItems(f.items) + } + + words := make([]string, len(f.items)) + for i, item := range f.items { + words[i] = strings.ToLower(item.FilterValue()) + } + + matches := fuzzy.Find(query, words) + + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].Score > matches[j].Score + }) + + var matchedItems []T + for _, match := range matches { + item := f.items[match.Index] + if i, ok := any(item).(HasMatchIndexes); ok { + i.MatchIndexes(match.MatchedIndexes) + } + matchedItems = append(matchedItems, item) + } + + cmds = append(cmds, f.list.SetItems(matchedItems)) + return tea.Batch(cmds...) +} + +func (f *filterableList[T]) SetItems(items []T) tea.Cmd { + f.items = items + return f.list.SetItems(items) +} + +func (f *filterableList[T]) Cursor() *tea.Cursor { + if f.inputHidden { + return nil + } + return f.input.Cursor() +} + +func (f *filterableList[T]) Blur() tea.Cmd { + f.input.Blur() + return f.list.Blur() +} + +func (f *filterableList[T]) Focus() tea.Cmd { + f.input.Focus() + return f.list.Focus() +} + +func (f *filterableList[T]) IsFocused() bool { + return f.list.IsFocused() +} + +func (f *filterableList[T]) SetInputWidth(w int) { + f.inputWidth = w +} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go new file mode 100644 index 0000000000000000000000000000000000000000..688058cbaa404d378210f815e276cef78254e296 --- /dev/null +++ b/internal/tui/exp/list/filterable_test.go @@ -0,0 +1,67 @@ +package list + +import ( + "fmt" + "slices" + "testing" + + "github.com/charmbracelet/x/exp/golden" + "github.com/stretchr/testify/assert" +) + +func TestFilterableList(t *testing.T) { + t.Run("should create simple filterable list", func(t *testing.T) { + t.Parallel() + items := []FilterableItem{} + for i := range 5 { + item := NewFilterableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := NewFilterableList( + items, + WithFilterListOptions(WithDirection(Forward)), + ).(*filterableList[FilterableItem]) + + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + assert.Equal(t, items[0].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + +func TestUpdateKeyMap(t *testing.T) { + t.Parallel() + l := NewFilterableList( + []FilterableItem{}, + WithFilterListOptions(WithDirection(Forward)), + ).(*filterableList[FilterableItem]) + + hasJ := slices.Contains(l.keyMap.Down.Keys(), "j") + fmt.Println(l.keyMap.Down.Keys()) + hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j") + + hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K") + + assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters") + assert.False(t, hasJ, "should not contain j") + assert.False(t, hasUpperCaseK, "should also remove upper case K") + assert.True(t, hasCtrlJ, "should still have ctrl+j") +} + +type filterableItem struct { + *selectableItem +} + +func NewFilterableItem(content string) FilterableItem { + return &filterableItem{ + selectableItem: NewSelectableItem(content).(*selectableItem), + } +} + +func (f *filterableItem) FilterValue() string { + return f.content +} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go new file mode 100644 index 0000000000000000000000000000000000000000..005b72048a5962559e1bac202a17c8297757c746 --- /dev/null +++ b/internal/tui/exp/list/items.go @@ -0,0 +1,308 @@ +package list + +import ( + "image/color" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/tui/components/core/layout" + "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/google/uuid" + "github.com/rivo/uniseg" +) + +type CompletionItem[T any] interface { + FilterableItem + layout.Focusable + layout.Sizeable + HasMatchIndexes + Value() T +} + +type completionItemCmp[T any] struct { + width int + id string + text string + value T + focus bool + matchIndexes []int + bgColor color.Color + shortcut string +} + +type options struct { + id string + text string + bgColor color.Color + matchIndexes []int + shortcut string +} + +type completionOption func(*options) + +func WithBackgroundColor(c color.Color) completionOption { + return func(cmp *options) { + cmp.bgColor = c + } +} + +func WithMatchIndexes(indexes ...int) completionOption { + return func(cmp *options) { + cmp.matchIndexes = indexes + } +} + +func WithShortcut(shortcut string) completionOption { + return func(cmp *options) { + cmp.shortcut = shortcut + } +} + +func WithID(id string) completionOption { + return func(cmp *options) { + cmp.id = id + } +} + +func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] { + c := &completionItemCmp[T]{ + text: text, + value: value, + } + o := &options{} + + for _, opt := range opts { + opt(o) + } + if o.id == "" { + o.id = uuid.NewString() + } + c.id = o.id + c.bgColor = o.bgColor + c.matchIndexes = o.matchIndexes + c.shortcut = o.shortcut + return c +} + +// Init implements CommandItem. +func (c *completionItemCmp[T]) Init() tea.Cmd { + return nil +} + +// Update implements CommandItem. +func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) { + return c, nil +} + +// View implements CommandItem. +func (c *completionItemCmp[T]) View() string { + t := styles.CurrentTheme() + + itemStyle := t.S().Base.Padding(0, 1).Width(c.width) + innerWidth := c.width - 2 // Account for padding + + if c.shortcut != "" { + innerWidth -= lipgloss.Width(c.shortcut) + } + + titleStyle := t.S().Text.Width(innerWidth) + titleMatchStyle := t.S().Text.Underline(true) + if c.bgColor != nil { + titleStyle = titleStyle.Background(c.bgColor) + titleMatchStyle = titleMatchStyle.Background(c.bgColor) + itemStyle = itemStyle.Background(c.bgColor) + } + + if c.focus { + titleStyle = t.S().TextSelected.Width(innerWidth) + titleMatchStyle = t.S().TextSelected.Underline(true) + itemStyle = itemStyle.Background(t.Primary) + } + + var truncatedTitle string + + if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { + // Smart truncation: ensure the last matching part is visible + truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) + } else { + // No matches, use regular truncation + truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") + } + + text := titleStyle.Render(truncatedTitle) + if len(c.matchIndexes) > 0 { + var ranges []lipgloss.Range + for _, rng := range matchedRanges(c.matchIndexes) { + // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. + // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. + // so we need to adjust it here: + start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) + ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) + } + text = lipgloss.StyleRanges(text, ranges...) + } + parts := []string{text} + if c.shortcut != "" { + // Add the shortcut at the end + shortcutStyle := t.S().Muted + if c.focus { + shortcutStyle = t.S().TextSelected + } + parts = append(parts, shortcutStyle.Render(c.shortcut)) + } + item := itemStyle.Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + parts..., + ), + ) + return item +} + +// Blur implements CommandItem. +func (c *completionItemCmp[T]) Blur() tea.Cmd { + c.focus = false + return nil +} + +// Focus implements CommandItem. +func (c *completionItemCmp[T]) Focus() tea.Cmd { + c.focus = true + return nil +} + +// GetSize implements CommandItem. +func (c *completionItemCmp[T]) GetSize() (int, int) { + return c.width, 1 +} + +// IsFocused implements CommandItem. +func (c *completionItemCmp[T]) IsFocused() bool { + return c.focus +} + +// SetSize implements CommandItem. +func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd { + c.width = width + return nil +} + +func (c *completionItemCmp[T]) MatchIndexes(indexes []int) { + c.matchIndexes = indexes +} + +func (c *completionItemCmp[T]) FilterValue() string { + return c.text +} + +func (c *completionItemCmp[T]) Value() T { + return c.value +} + +// smartTruncate implements fzf-style truncation that ensures the last matching part is visible +func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string { + if width <= 0 { + return "" + } + + textLen := ansi.StringWidth(text) + if textLen <= width { + return text + } + + if len(matchIndexes) == 0 { + return ansi.Truncate(text, width, "…") + } + + // Find the last match position + lastMatchPos := matchIndexes[len(matchIndexes)-1] + + // Convert byte position to visual width position + lastMatchVisualPos := 0 + bytePos := 0 + gr := uniseg.NewGraphemes(text) + for bytePos < lastMatchPos && gr.Next() { + bytePos += len(gr.Str()) + lastMatchVisualPos += max(1, gr.Width()) + } + + // Calculate how much space we need for the ellipsis + ellipsisWidth := 1 // "…" character width + availableWidth := width - ellipsisWidth + + // If the last match is within the available width, truncate from the end + if lastMatchVisualPos < availableWidth { + return ansi.Truncate(text, width, "…") + } + + // Calculate the start position to ensure the last match is visible + // We want to show some context before the last match if possible + startVisualPos := max(0, lastMatchVisualPos-availableWidth+1) + + // Convert visual position back to byte position + startBytePos := 0 + currentVisualPos := 0 + gr = uniseg.NewGraphemes(text) + for currentVisualPos < startVisualPos && gr.Next() { + startBytePos += len(gr.Str()) + currentVisualPos += max(1, gr.Width()) + } + + // Extract the substring starting from startBytePos + truncatedText := text[startBytePos:] + + // Truncate to fit width with ellipsis + truncatedText = ansi.Truncate(truncatedText, availableWidth, "") + truncatedText = "…" + truncatedText + return truncatedText +} + +func matchedRanges(in []int) [][2]int { + if len(in) == 0 { + return [][2]int{} + } + current := [2]int{in[0], in[0]} + if len(in) == 1 { + return [][2]int{current} + } + var out [][2]int + for i := 1; i < len(in); i++ { + if in[i] == current[1]+1 { + current[1] = in[i] + } else { + out = append(out, current) + current = [2]int{in[i], in[i]} + } + } + out = append(out, current) + return out +} + +func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) { + bytePos, byteStart, byteStop := 0, rng[0], rng[1] + pos, start, stop := 0, 0, 0 + gr := uniseg.NewGraphemes(str) + for byteStart > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + start = pos + for byteStop > bytePos { + if !gr.Next() { + break + } + bytePos += len(gr.Str()) + pos += max(1, gr.Width()) + } + stop = pos + return start, stop +} + +// ID implements CompletionItem. +func (c *completionItemCmp[T]) ID() string { + return c.id +} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..271ad1a8e644f2ecd44d5d76e8af6a9b513abab3 --- /dev/null +++ b/internal/tui/exp/list/keys.go @@ -0,0 +1,63 @@ +package list + +import ( + "github.com/charmbracelet/bubbles/v2/key" +) + +type KeyMap struct { + Down, + Up, + DownOneItem, + UpOneItem, + PageDown, + PageUp, + HalfPageDown, + HalfPageUp, + Home, + End key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Down: key.NewBinding( + key.WithKeys("down", "ctrl+j", "ctrl+n", "j"), + key.WithHelp("↓", "down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "ctrl+k", "ctrl+p", "k"), + key.WithHelp("↑", "up"), + ), + UpOneItem: key.NewBinding( + key.WithKeys("shift+up", "K"), + key.WithHelp("shift+↑", "up one item"), + ), + DownOneItem: key.NewBinding( + key.WithKeys("shift+down", "J"), + key.WithHelp("shift+↓", "down one item"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "half page down"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "half page up"), + ), + Home: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "home"), + ), + End: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "end"), + ), + } +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 92c9a38c2a2fbd4c6338a8873e73450bf64c8d70..94a9e13e0904c9df0a1477b5674738b33425fc81 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,9 +1,9 @@ package list import ( - "fmt" "strings" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/util" @@ -16,11 +16,19 @@ type Item interface { ID() string } -type List interface { +type List[T Item] interface { util.Model layout.Sizeable layout.Focusable - SetItems(items []Item) tea.Cmd + MoveUp(int) tea.Cmd + MoveDown(int) tea.Cmd + GoToTop() tea.Cmd + GoToBottom() tea.Cmd + SelectItemAbove() tea.Cmd + SelectItemBelow() tea.Cmd + SetItems([]T) tea.Cmd + SetSelected(string) tea.Cmd + SelectedItem() *T } type direction int @@ -31,41 +39,45 @@ const ( ) const ( - NotFound = -1 + NotFound = -1 + DefaultScrollSize = 2 ) +type setSelectedMsg struct { + selectedItemID string +} + type renderedItem struct { id string view string height int } -type list struct { +type confOptions struct { width, height int - offset int gap int - direction direction - selectedItem string - focused bool + // if you are at the last item and go down it will wrap to the top + wrap bool + keyMap KeyMap + direction direction + selectedItem string +} +type list[T Item] struct { + confOptions - items []Item + focused bool + offset int + items []T renderedItems []renderedItem rendered string isReady bool } -type listOption func(*list) - -// WithItems sets the initial items for the list. -func WithItems(items ...Item) listOption { - return func(l *list) { - l.items = items - } -} +type listOption func(*confOptions) // WithSize sets the size of the list. func WithSize(width, height int) listOption { - return func(l *list) { + return func(l *confOptions) { l.width = width l.height = height } @@ -73,44 +85,53 @@ func WithSize(width, height int) listOption { // WithGap sets the gap between items in the list. func WithGap(gap int) listOption { - return func(l *list) { + return func(l *confOptions) { l.gap = gap } } // WithDirection sets the direction of the list. func WithDirection(dir direction) listOption { - return func(l *list) { + return func(l *confOptions) { l.direction = dir } } // WithSelectedItem sets the initially selected item in the list. func WithSelectedItem(id string) listOption { - return func(l *list) { + return func(l *confOptions) { l.selectedItem = id } } -func New(opts ...listOption) List { - list := &list{ - items: make([]Item, 0), - direction: Forward, +func WithKeyMap(keyMap KeyMap) listOption { + return func(l *confOptions) { + l.keyMap = keyMap + } +} + +func WithWrapNavigation() listOption { + return func(l *confOptions) { + l.wrap = true + } +} + +func New[T Item](items []T, opts ...listOption) List[T] { + list := &list[T]{ + confOptions: confOptions{ + direction: Forward, + keyMap: DefaultKeyMap(), + }, + items: items, } for _, opt := range opts { - opt(list) + opt(&list.confOptions) } return list } // Init implements List. -func (l *list) Init() tea.Cmd { - if l.height <= 0 || l.width <= 0 { - return nil - } - if len(l.items) == 0 { - return nil - } +func (l *list[T]) Init() tea.Cmd { var cmds []tea.Cmd for _, item := range l.items { cmd := item.Init() @@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd { } // Update implements List. -func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case setSelectedMsg: + return l, l.SetSelected(msg.selectedItemID) + case tea.KeyPressMsg: + if l.focused { + switch { + case key.Matches(msg, l.keyMap.Down): + return l, l.MoveDown(DefaultScrollSize) + case key.Matches(msg, l.keyMap.Up): + return l, l.MoveUp(DefaultScrollSize) + case key.Matches(msg, l.keyMap.DownOneItem): + return l, l.SelectItemBelow() + case key.Matches(msg, l.keyMap.UpOneItem): + return l, l.SelectItemAbove() + case key.Matches(msg, l.keyMap.HalfPageDown): + return l, l.MoveDown(l.listHeight() / 2) + case key.Matches(msg, l.keyMap.HalfPageUp): + return l, l.MoveUp(l.listHeight() / 2) + case key.Matches(msg, l.keyMap.PageDown): + return l, l.MoveDown(l.listHeight()) + case key.Matches(msg, l.keyMap.PageUp): + return l, l.MoveUp(l.listHeight()) + case key.Matches(msg, l.keyMap.End): + return l, l.GoToBottom() + case key.Matches(msg, l.keyMap.Home): + return l, l.GoToTop() + } + } + } return l, nil } // View implements List. -func (l *list) View() string { +func (l *list[T]) View() string { if l.height <= 0 || l.width <= 0 { return "" } @@ -138,7 +188,7 @@ func (l *list) View() string { return strings.Join(lines, "\n") } -func (l *list) viewPosition() (int, int) { +func (l *list[T]) viewPosition() (int, int) { start, end := 0, 0 renderedLines := lipgloss.Height(l.rendered) - 1 if l.direction == Forward { @@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) { return start, end } -func (l *list) renderItem(item Item) renderedItem { +func (l *list[T]) renderItem(item Item) renderedItem { view := item.View() return renderedItem{ id: item.ID(), @@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem { } } -func (l *list) renderView() { +func (l *list[T]) renderView() { var sb strings.Builder for i, rendered := range l.renderedItems { sb.WriteString(rendered.view) @@ -171,7 +221,7 @@ func (l *list) renderView() { l.rendered = sb.String() } -func (l *list) incrementOffset(n int) { +func (l *list[T]) incrementOffset(n int) { if !l.isReady { return } @@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) { l.offset += n } -func (l *list) decrementOffset(n int) { +func (l *list[T]) decrementOffset(n int) { if !l.isReady { return } @@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) { } // changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible -func (l *list) changeSelectedWhenNotVisible() tea.Cmd { +func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd { var cmds []tea.Cmd start, end := l.viewPosition() currentPosition := 0 @@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { needsMove = true } if needsMove { - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, focusable.Blur()) } l.renderedItems[i] = l.renderItem(item) @@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { if itemWithinView != NotFound && needsMove { newSelection := l.items[itemWithinView] l.selectedItem = newSelection.ID() - if focusable, ok := newSelection.(layout.Focusable); ok { + if focusable, ok := any(newSelection).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } l.renderedItems[itemWithinView] = l.renderItem(newSelection) @@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd { return tea.Batch(cmds...) } -func (l *list) MoveUp(n int) tea.Cmd { +func (l *list[T]) MoveUp(n int) tea.Cmd { if l.direction == Forward { l.decrementOffset(n) } else { @@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd { return l.changeSelectedWhenNotVisible() } -func (l *list) MoveDown(n int) tea.Cmd { +func (l *list[T]) MoveDown(n int) tea.Cmd { if l.direction == Forward { l.incrementOffset(n) } else { @@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd { return l.changeSelectedWhenNotVisible() } -func (l *list) firstSelectableItemBefore(inx int) int { +func (l *list[T]) firstSelectableItemBefore(inx int) int { for i := inx - 1; i >= 0; i-- { - if _, ok := l.items[i].(layout.Focusable); ok { + if _, ok := any(l.items[i]).(layout.Focusable); ok { return i } } + if inx == 0 && l.wrap { + return l.firstSelectableItemBefore(len(l.items)) + } return NotFound } -func (l *list) firstSelectableItemAfter(inx int) int { +func (l *list[T]) firstSelectableItemAfter(inx int) int { for i := inx + 1; i < len(l.items); i++ { - if _, ok := l.items[i].(layout.Focusable); ok { + if _, ok := any(l.items[i]).(layout.Focusable); ok { return i } } + if inx == len(l.items)-1 && l.wrap { + return l.firstSelectableItemAfter(-1) + } return NotFound } -func (l *list) moveToSelected() { +func (l *list[T]) moveToSelected(center bool) tea.Cmd { + var cmds []tea.Cmd if l.selectedItem == "" || !l.isReady { - return + return nil } currentPosition := 0 start, end := l.viewPosition() for _, item := range l.renderedItems { if item.id == l.selectedItem { - if start <= currentPosition && (currentPosition+item.height) <= end { - return - } - // we need to go up - if currentPosition < start { - l.MoveUp(start - currentPosition) + itemStart := currentPosition + itemEnd := currentPosition + item.height - 1 + + if start <= itemStart && itemEnd <= end { + return nil } - // we need to go down - if currentPosition > end { - l.MoveDown(currentPosition - end) + + if center { + viewportCenter := l.listHeight() / 2 + itemCenter := itemStart + item.height/2 + targetOffset := itemCenter - viewportCenter + if l.direction == Forward { + if targetOffset > l.offset { + cmds = append(cmds, l.MoveDown(targetOffset-l.offset)) + } else if targetOffset < l.offset { + cmds = append(cmds, l.MoveUp(l.offset-targetOffset)) + } + } else { + renderedHeight := lipgloss.Height(l.rendered) + backwardTargetOffset := renderedHeight - targetOffset - l.listHeight() + if backwardTargetOffset > l.offset { + cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset)) + } else if backwardTargetOffset < l.offset { + cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset)) + } + } + } else { + if currentPosition < start { + cmds = append(cmds, l.MoveUp(start-currentPosition)) + } + if currentPosition > end { + cmds = append(cmds, l.MoveDown(currentPosition-end)) + } } } currentPosition += item.height + l.gap } + return tea.Batch(cmds...) } -func (l *list) SelectItemAbove() tea.Cmd { +func (l *list[T]) SelectItemAbove() tea.Cmd { if !l.isReady { return nil } @@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd { return nil } // blur the current item - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, focusable.Blur()) } // rerender the item l.renderedItems[i] = l.renderItem(item) // focus the item above above := l.items[inx] - if focusable, ok := above.(layout.Focusable); ok { + if focusable, ok := any(above).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } // rerender the item @@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd { break } } + l.moveToSelected(false) l.renderView() - l.moveToSelected() return tea.Batch(cmds...) } -func (l *list) SelectItemBelow() tea.Cmd { +func (l *list[T]) SelectItemBelow() tea.Cmd { if !l.isReady { return nil } @@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd { return nil } // blur the current item - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmds = append(cmds, focusable.Blur()) } // rerender the item @@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd { // focus the item below below := l.items[inx] - if focusable, ok := below.(layout.Focusable); ok { + if focusable, ok := any(below).(layout.Focusable); ok { cmds = append(cmds, focusable.Focus()) } // rerender the item @@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd { } } + l.moveToSelected(false) l.renderView() - l.moveToSelected() return tea.Batch(cmds...) } -func (l *list) GoToTop() tea.Cmd { +func (l *list[T]) GoToTop() tea.Cmd { if !l.isReady { return nil } @@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd { return tea.Batch(l.selectFirstItem(), l.renderForward()) } -func (l *list) GoToBottom() tea.Cmd { +func (l *list[T]) GoToBottom() tea.Cmd { if !l.isReady { return nil } @@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd { return tea.Batch(l.selectLastItem(), l.renderBackward()) } -func (l *list) renderForward() tea.Cmd { +func (l *list[T]) renderForward() tea.Cmd { // TODO: figure out a way to preserve items that did not change l.renderedItems = make([]renderedItem, 0) currentHeight := 0 @@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd { } } -func (l *list) renderBackward() tea.Cmd { +func (l *list[T]) renderBackward() tea.Cmd { // TODO: figure out a way to preserve items that did not change l.renderedItems = make([]renderedItem, 0) currentHeight := 0 currentIndex := 0 for i := len(l.items) - 1; i >= 0; i-- { - fmt.Printf("rendering item %d\n", i) currentIndex = i if currentHeight > l.listHeight() { break @@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd { } return func() tea.Msg { for i := currentIndex; i >= 0; i-- { - fmt.Printf("rendering item after %d\n", i) rendered := l.renderItem(l.items[i]) l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...) } @@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd { } } -func (l *list) selectFirstItem() tea.Cmd { +func (l *list[T]) selectFirstItem() tea.Cmd { var cmd tea.Cmd inx := l.firstSelectableItemAfter(-1) if inx != NotFound { l.selectedItem = l.items[inx].ID() - if focusable, ok := l.items[inx].(layout.Focusable); ok { + if focusable, ok := any(l.items[inx]).(layout.Focusable); ok { cmd = focusable.Focus() } } return cmd } -func (l *list) selectLastItem() tea.Cmd { +func (l *list[T]) selectLastItem() tea.Cmd { var cmd tea.Cmd inx := l.firstSelectableItemBefore(len(l.items)) if inx != NotFound { l.selectedItem = l.items[inx].ID() - if focusable, ok := l.items[inx].(layout.Focusable); ok { + if focusable, ok := any(l.items[inx]).(layout.Focusable); ok { cmd = focusable.Focus() } } return cmd } -func (l *list) renderItems() tea.Cmd { +func (l *list[T]) renderItems() tea.Cmd { if l.height <= 0 || l.width <= 0 { return nil } @@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd { return l.renderBackward() } -func (l *list) listHeight() int { +func (l *list[T]) listHeight() int { // for the moment its the same return l.height } -func (l *list) SetItems(items []Item) tea.Cmd { +func (l *list[T]) SetItems(items []T) tea.Cmd { l.items = items var cmds []tea.Cmd for _, item := range l.items { @@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd { // Set height to 0 to let the item calculate its own height cmds = append(cmds, item.SetSize(l.width, 0)) } + + if l.selectedItem != "" { + cmds = append(cmds, l.moveToSelected(true)) + } cmds = append(cmds, l.renderItems()) return tea.Batch(cmds...) } // GetSize implements List. -func (l *list) GetSize() (int, int) { +func (l *list[T]) GetSize() (int, int) { return l.width, l.height } // SetSize implements List. -func (l *list) SetSize(width int, height int) tea.Cmd { +func (l *list[T]) SetSize(width int, height int) tea.Cmd { l.width = width l.height = height var cmds []tea.Cmd for _, item := range l.items { cmds = append(cmds, item.SetSize(width, height)) } + cmds = append(cmds, l.renderItems()) return tea.Batch(cmds...) } // Blur implements List. -func (l *list) Blur() tea.Cmd { +func (l *list[T]) Blur() tea.Cmd { var cmd tea.Cmd l.focused = false for i, item := range l.items { if item.ID() != l.selectedItem { continue } - if focusable, ok := item.(layout.Focusable); ok { + if focusable, ok := any(item).(layout.Focusable); ok { cmd = focusable.Blur() } l.renderedItems[i] = l.renderItem(item) @@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd { } // Focus implements List. -func (l *list) Focus() tea.Cmd { +func (l *list[T]) Focus() tea.Cmd { var cmd tea.Cmd l.focused = true - for i, item := range l.items { - if item.ID() != l.selectedItem { - continue + if l.selectedItem != "" { + for i, item := range l.items { + if item.ID() != l.selectedItem { + continue + } + if focusable, ok := any(item).(layout.Focusable); ok { + cmd = focusable.Focus() + } + if len(l.renderedItems) > i { + l.renderedItems[i] = l.renderItem(item) + } } - if focusable, ok := item.(layout.Focusable); ok { - cmd = focusable.Focus() + l.renderView() + } + return cmd +} + +func (l *list[T]) SetSelected(id string) tea.Cmd { + if l.selectedItem == id { + return nil + } + var cmds []tea.Cmd + for i, item := range l.items { + if item.ID() == l.selectedItem { + if focusable, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, focusable.Blur()) + } + if len(l.renderedItems) > i { + l.renderedItems[i] = l.renderItem(item) + } + } else if item.ID() == id { + if focusable, ok := any(item).(layout.Focusable); ok { + cmds = append(cmds, focusable.Focus()) + } + if len(l.renderedItems) > i { + l.renderedItems[i] = l.renderItem(item) + } } - l.renderedItems[i] = l.renderItem(item) } + l.selectedItem = id + cmds = append(cmds, l.moveToSelected(true)) l.renderView() - return cmd + return tea.Batch(cmds...) +} + +func (l *list[T]) SelectedItem() *T { + for _, item := range l.items { + if item.ID() == l.selectedItem { + return &item + } + } + return nil } // IsFocused implements List. -func (l *list) IsFocused() bool { +func (l *list[T]) IsFocused() bool { return l.focused } diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index a9d1541dee784d2ce1b652773d84138bedcb8241..3dd2e94666df982f186474e5a96da5d721e71c2e 100644 --- a/internal/tui/exp/list/list_test.go +++ b/internal/tui/exp/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "sync" "testing" tea "github.com/charmbracelet/bubbletea/v2" @@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) { } for _, c := range tests { t.Run(c.test, func(t *testing.T) { - l := New(WithDirection(c.dir)).(*list) - l.SetSize(c.width, c.height) items := []Item{} for i := range c.numItems { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(c.dir)).(*list[Item]) + l.SetSize(c.width, c.height) + cmd := l.Init() if cmd != nil { cmd() } @@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) { func TestBackwardList(t *testing.T) { t.Run("within height", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward), WithGap(1)).(*list) - l.SetSize(10, 20) items := []Item{} for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item]) + l.SetSize(10, 20) + cmd := l.Init() if cmd != nil { cmd() } // should select the last item assert.Equal(t, l.selectedItem, items[len(items)-1].ID()) - golden.RequireEqual(t, []byte(l.View())) }) t.Run("should not change selected item", func(t *testing.T) { t.Parallel() items := []Item{} for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) + l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item]) l.SetSize(10, 20) - cmd := l.SetItems(items) + cmd := l.Init() if cmd != nil { cmd() } @@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("more than height", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("more than height multi line", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should move up", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should move at max to the top", func(t *testing.T) { - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should do nothing with wrong move number", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should move to the top", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should select the item above", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -268,14 +268,14 @@ func TestBackwardList(t *testing.T) { }) t.Run("should move the view to be able to see the selected item", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Backward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) { func TestForwardList(t *testing.T) { t.Run("within height", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward), WithGap(1)).(*list) - l.SetSize(10, 20) items := []Item{} for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item]) + l.SetSize(10, 20) + cmd := l.Init() if cmd != nil { cmd() } @@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) { t.Parallel() items := []Item{} for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list) + l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item]) l.SetSize(10, 20) - cmd := l.SetItems(items) + cmd := l.Init() if cmd != nil { cmd() } @@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) { }) t.Run("more than height", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) { }) t.Run("more than height multi line", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should move down", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should move at max to the bottom", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should do nothing with wrong move number", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should move to the bottom", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should select the item below", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -459,14 +459,14 @@ func TestForwardList(t *testing.T) { }) t.Run("should move the view to be able to see the selected item", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Backward)).(*list) - l.SetSize(10, 5) items := []Item{} for i := range 10 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(10, 5) + cmd := l.Init() if cmd != nil { cmd() } @@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) { func TestListSelection(t *testing.T) { t.Run("should skip none selectable items initially", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) - l.SetSize(100, 10) items := []Item{} items = append(items, NewSimpleItem("None Selectable")) for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(100, 10) + cmd := l.Init() if cmd != nil { cmd() } @@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) { assert.Equal(t, items[1].ID(), l.selectedItem) golden.RequireEqual(t, []byte(l.View())) }) - t.Run("should skip none selectable items in the middle", func(t *testing.T) { + t.Run("should select the correct item on startup", func(t *testing.T) { t.Parallel() - l := New(WithDirection(Forward)).(*list) + items := []Item{} + for i := range 5 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirection(Forward)).(*list[Item]) + cmd := l.Init() + otherCmd := l.SetSelected(items[3].ID()) + var wg sync.WaitGroup + if cmd != nil { + wg.Add(1) + go func() { + cmd() + wg.Done() + }() + } + if otherCmd != nil { + wg.Add(1) + go func() { + otherCmd() + wg.Done() + }() + } + wg.Wait() l.SetSize(100, 10) + assert.Equal(t, items[3].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) + t.Run("should skip none selectable items in the middle", func(t *testing.T) { + t.Parallel() items := []Item{} - item := NewSelectsableItem("Item initial") + item := NewSelectableItem("Item initial") items = append(items, item) items = append(items, NewSimpleItem("None Selectable")) for i := range 5 { - item := NewSelectsableItem(fmt.Sprintf("Item %d", i)) + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) items = append(items, item) } - cmd := l.SetItems(items) + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(100, 10) + cmd := l.Init() if cmd != nil { cmd() } @@ -522,6 +552,31 @@ func TestListSelection(t *testing.T) { }) } +func TestListSetSelection(t *testing.T) { + t.Run("should move to the selected item", func(t *testing.T) { + t.Parallel() + items := []Item{} + for i := range 100 { + item := NewSelectableItem(fmt.Sprintf("Item %d", i)) + items = append(items, item) + } + l := New(items, WithDirection(Forward)).(*list[Item]) + l.SetSize(100, 10) + cmd := l.Init() + if cmd != nil { + cmd() + } + + cmd = l.SetSelected(items[52].ID()) + if cmd != nil { + cmd() + } + + assert.Equal(t, items[52].ID(), l.selectedItem) + golden.RequireEqual(t, []byte(l.View())) + }) +} + type SelectableItem interface { Item layout.Focusable @@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem { } } -func NewSelectsableItem(content string) SelectableItem { +func NewSelectableItem(content string) SelectableItem { return &selectableItem{ simpleItem: NewSimpleItem(content), focused: false, diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..8aac1155586865e3db5a87839b9d430b419d00ec --- /dev/null +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -0,0 +1,6 @@ +> Type to filter  +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden index b8fd0efdb00bce286317007e40b2af335d22942f..9b99c5dff003cfe111724b6a8fbb146d81b2f0e3 100644 --- a/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden +++ b/internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden @@ -1,5 +1,5 @@ -Item 5 -Item 6 -Item 7 -Item 8 -│Item 9 \ No newline at end of file +Item 1 +Item 2 +Item 3 +Item 4 +│Item 5 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden b/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden new file mode 100644 index 0000000000000000000000000000000000000000..83638680c8cc7538d2843dabf9dd874782e09669 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden @@ -0,0 +1,5 @@ +Item 0 +Item 1 +Item 2 +│Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden b/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden new file mode 100644 index 0000000000000000000000000000000000000000..bd6e2219113ebea6cbd53d775866d0e2401fbc41 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden @@ -0,0 +1,10 @@ +Item 47 +Item 48 +Item 49 +Item 50 +Item 51 +│Item 52 +Item 53 +Item 54 +Item 55 +Item 56 \ No newline at end of file