mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
chore: implement new filterable list
- use this list in the sessions selector
This commit is contained in:
109
cspell.json
109
cspell.json
@@ -1,108 +1 @@
|
||||
{
|
||||
"words": [
|
||||
"afero",
|
||||
"agentic",
|
||||
"alecthomas",
|
||||
"anthropics",
|
||||
"aymanbagabas",
|
||||
"azidentity",
|
||||
"bmatcuk",
|
||||
"bubbletea",
|
||||
"charlievieth",
|
||||
"charmbracelet",
|
||||
"charmtone",
|
||||
"Charple",
|
||||
"chkconfig",
|
||||
"crush",
|
||||
"curlie",
|
||||
"cursorrules",
|
||||
"diffview",
|
||||
"doas",
|
||||
"Dockerfiles",
|
||||
"doublestar",
|
||||
"dpkg",
|
||||
"Emph",
|
||||
"fastwalk",
|
||||
"fdisk",
|
||||
"filepicker",
|
||||
"Focusable",
|
||||
"fseventsd",
|
||||
"fsext",
|
||||
"genai",
|
||||
"goquery",
|
||||
"GROQ",
|
||||
"Guac",
|
||||
"imageorient",
|
||||
"Inex",
|
||||
"jetta",
|
||||
"jsons",
|
||||
"jsonschema",
|
||||
"jspm",
|
||||
"Kaufmann",
|
||||
"killall",
|
||||
"Lanczos",
|
||||
"lipgloss",
|
||||
"LOCALAPPDATA",
|
||||
"lsps",
|
||||
"lucasb",
|
||||
"makepkg",
|
||||
"mcps",
|
||||
"MSYS",
|
||||
"mvdan",
|
||||
"natefinch",
|
||||
"nfnt",
|
||||
"noctx",
|
||||
"nohup",
|
||||
"nolint",
|
||||
"nslookup",
|
||||
"oksvg",
|
||||
"Oneshot",
|
||||
"openrouter",
|
||||
"opkg",
|
||||
"pacman",
|
||||
"paru",
|
||||
"pfctl",
|
||||
"postamble",
|
||||
"postambles",
|
||||
"preconfigured",
|
||||
"Preproc",
|
||||
"Proactiveness",
|
||||
"Puerkito",
|
||||
"pycache",
|
||||
"pytest",
|
||||
"qjebbs",
|
||||
"rasterx",
|
||||
"rivo",
|
||||
"sabhiram",
|
||||
"sess",
|
||||
"shortlog",
|
||||
"sjson",
|
||||
"Sourcegraph",
|
||||
"srwiley",
|
||||
"SSEMCP",
|
||||
"Streamable",
|
||||
"stretchr",
|
||||
"Strikethrough",
|
||||
"substrs",
|
||||
"Suscriber",
|
||||
"systeminfo",
|
||||
"tasklist",
|
||||
"termenv",
|
||||
"textinput",
|
||||
"tidwall",
|
||||
"timedout",
|
||||
"trashhalo",
|
||||
"udiff",
|
||||
"uniseg",
|
||||
"Unticked",
|
||||
"urllib",
|
||||
"USERPROFILE",
|
||||
"VERTEXAI",
|
||||
"webp",
|
||||
"whatis",
|
||||
"whereis"
|
||||
],
|
||||
"flagWords": [],
|
||||
"language": "en",
|
||||
"version": "0.2"
|
||||
}
|
||||
{"flagWords":[],"version":"0.2","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm"],"language":"en"}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
297
internal/tui/exp/list/filterable.go
Normal file
297
internal/tui/exp/list/filterable.go
Normal 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
|
||||
}
|
||||
67
internal/tui/exp/list/filterable_test.go
Normal file
67
internal/tui/exp/list/filterable_test.go
Normal 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
|
||||
}
|
||||
308
internal/tui/exp/list/items.go
Normal file
308
internal/tui/exp/list/items.go
Normal 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
|
||||
}
|
||||
63
internal/tui/exp/list/keys.go
Normal file
63
internal/tui/exp/list/keys.go
Normal 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"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
6
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden
generated
vendored
Normal file
6
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden
generated
vendored
Normal file
@@ -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
|
||||
5
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden
generated
vendored
Normal file
5
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
│Item 3
|
||||
Item 4
|
||||
10
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden
generated
vendored
Normal file
10
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden
generated
vendored
Normal 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
|
||||
Reference in New Issue
Block a user