Detailed changes
@@ -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"
-}
@@ -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)
@@ -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
}
}
@@ -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
+}
@@ -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
+}
@@ -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
+}
@@ -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"),
+ ),
+ }
+}
@@ -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
}
@@ -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,
@@ -0,0 +1,6 @@
+[38;2;223;219;221m[38;2;104;255;214m> [m[38;2;96;95;107mT[m[38;2;96;95;107mype to filter[m[38;2;96;95;107m [m[m
+βItem 0
+Item 1
+Item 2
+Item 3
+Item 4
@@ -1,5 +1,5 @@
-Item 5
-Item 6
-Item 7
-Item 8
-βItem 9
+Item 1
+Item 2
+Item 3
+Item 4
+βItem 5
@@ -0,0 +1,5 @@
+Item 0
+Item 1
+Item 2
+βItem 3
+Item 4
@@ -0,0 +1,10 @@
+Item 47
+Item 48
+Item 49
+Item 50
+Item 51
+βItem 52
+Item 53
+Item 54
+Item 55
+Item 56