chore: implement new filterable list

- use this list in the sessions selector
This commit is contained in:
Kujtim Hoxha
2025-07-21 16:59:54 +02:00
parent 966d24da22
commit aab9b3b8d6
13 changed files with 1160 additions and 337 deletions

View File

@@ -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"}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"),
),
}
}

View File

@@ -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
itemStart := currentPosition
itemEnd := currentPosition + item.height - 1
if start <= itemStart && itemEnd <= end {
return nil
}
// we need to go up
if currentPosition < start {
l.MoveUp(start - currentPosition)
}
// 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.renderedItems[i] = l.renderItem(item)
l.renderView()
}
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.selectedItem = id
cmds = append(cmds, l.moveToSelected(true))
l.renderView()
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
}

View File

@@ -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 select the correct item on startup", func(t *testing.T) {
t.Parallel()
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()
l := New(WithDirection(Forward)).(*list)
l.SetSize(100, 10)
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,

View File

@@ -0,0 +1,6 @@
> Type to filter 
│Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -1,5 +1,5 @@
Item 5
Item 6
Item 7
Item 8
│Item 9
Item 1
Item 2
Item 3
Item 4
│Item 5

View File

@@ -0,0 +1,5 @@
Item 0
Item 1
Item 2
│Item 3
Item 4

View File

@@ -0,0 +1,10 @@
Item 47
Item 48
Item 49
Item 50
Item 51
│Item 52
Item 53
Item 54
Item 55
Item 56