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 @@
|
|||||||
{
|
{"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"}
|
||||||
"words": [
|
|
||||||
"afero",
|
|
||||||
"agentic",
|
|
||||||
"alecthomas",
|
|
||||||
"anthropics",
|
|
||||||
"aymanbagabas",
|
|
||||||
"azidentity",
|
|
||||||
"bmatcuk",
|
|
||||||
"bubbletea",
|
|
||||||
"charlievieth",
|
|
||||||
"charmbracelet",
|
|
||||||
"charmtone",
|
|
||||||
"Charple",
|
|
||||||
"chkconfig",
|
|
||||||
"crush",
|
|
||||||
"curlie",
|
|
||||||
"cursorrules",
|
|
||||||
"diffview",
|
|
||||||
"doas",
|
|
||||||
"Dockerfiles",
|
|
||||||
"doublestar",
|
|
||||||
"dpkg",
|
|
||||||
"Emph",
|
|
||||||
"fastwalk",
|
|
||||||
"fdisk",
|
|
||||||
"filepicker",
|
|
||||||
"Focusable",
|
|
||||||
"fseventsd",
|
|
||||||
"fsext",
|
|
||||||
"genai",
|
|
||||||
"goquery",
|
|
||||||
"GROQ",
|
|
||||||
"Guac",
|
|
||||||
"imageorient",
|
|
||||||
"Inex",
|
|
||||||
"jetta",
|
|
||||||
"jsons",
|
|
||||||
"jsonschema",
|
|
||||||
"jspm",
|
|
||||||
"Kaufmann",
|
|
||||||
"killall",
|
|
||||||
"Lanczos",
|
|
||||||
"lipgloss",
|
|
||||||
"LOCALAPPDATA",
|
|
||||||
"lsps",
|
|
||||||
"lucasb",
|
|
||||||
"makepkg",
|
|
||||||
"mcps",
|
|
||||||
"MSYS",
|
|
||||||
"mvdan",
|
|
||||||
"natefinch",
|
|
||||||
"nfnt",
|
|
||||||
"noctx",
|
|
||||||
"nohup",
|
|
||||||
"nolint",
|
|
||||||
"nslookup",
|
|
||||||
"oksvg",
|
|
||||||
"Oneshot",
|
|
||||||
"openrouter",
|
|
||||||
"opkg",
|
|
||||||
"pacman",
|
|
||||||
"paru",
|
|
||||||
"pfctl",
|
|
||||||
"postamble",
|
|
||||||
"postambles",
|
|
||||||
"preconfigured",
|
|
||||||
"Preproc",
|
|
||||||
"Proactiveness",
|
|
||||||
"Puerkito",
|
|
||||||
"pycache",
|
|
||||||
"pytest",
|
|
||||||
"qjebbs",
|
|
||||||
"rasterx",
|
|
||||||
"rivo",
|
|
||||||
"sabhiram",
|
|
||||||
"sess",
|
|
||||||
"shortlog",
|
|
||||||
"sjson",
|
|
||||||
"Sourcegraph",
|
|
||||||
"srwiley",
|
|
||||||
"SSEMCP",
|
|
||||||
"Streamable",
|
|
||||||
"stretchr",
|
|
||||||
"Strikethrough",
|
|
||||||
"substrs",
|
|
||||||
"Suscriber",
|
|
||||||
"systeminfo",
|
|
||||||
"tasklist",
|
|
||||||
"termenv",
|
|
||||||
"textinput",
|
|
||||||
"tidwall",
|
|
||||||
"timedout",
|
|
||||||
"trashhalo",
|
|
||||||
"udiff",
|
|
||||||
"uniseg",
|
|
||||||
"Unticked",
|
|
||||||
"urllib",
|
|
||||||
"USERPROFILE",
|
|
||||||
"VERTEXAI",
|
|
||||||
"webp",
|
|
||||||
"whatis",
|
|
||||||
"whereis"
|
|
||||||
],
|
|
||||||
"flagWords": [],
|
|
||||||
"language": "en",
|
|
||||||
"version": "0.2"
|
|
||||||
}
|
|
||||||
@@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.filterable && !m.hideFilterInput {
|
if m.filterable && !m.hideFilterInput {
|
||||||
t := styles.CurrentTheme()
|
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
ti.Placeholder = m.filterPlaceholder
|
ti.Placeholder = m.filterPlaceholder
|
||||||
ti.SetVirtualCursor(false)
|
ti.SetVirtualCursor(false)
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/crush/internal/session"
|
"github.com/charmbracelet/crush/internal/session"
|
||||||
"github.com/charmbracelet/crush/internal/tui/components/chat"
|
"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"
|
||||||
"github.com/charmbracelet/crush/internal/tui/components/core/list"
|
|
||||||
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
|
"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/styles"
|
||||||
"github.com/charmbracelet/crush/internal/tui/util"
|
"github.com/charmbracelet/crush/internal/tui/util"
|
||||||
"github.com/charmbracelet/lipgloss/v2"
|
"github.com/charmbracelet/lipgloss/v2"
|
||||||
@@ -22,6 +21,8 @@ type SessionDialog interface {
|
|||||||
dialogs.DialogModel
|
dialogs.DialogModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
|
||||||
|
|
||||||
type sessionDialogCmp struct {
|
type sessionDialogCmp struct {
|
||||||
selectedInx int
|
selectedInx int
|
||||||
wWidth int
|
wWidth int
|
||||||
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
|
|||||||
width int
|
width int
|
||||||
selectedSessionID string
|
selectedSessionID string
|
||||||
keyMap KeyMap
|
keyMap KeyMap
|
||||||
sessionsList list.ListModel
|
sessionsList SessionsList
|
||||||
renderedSelected bool
|
|
||||||
help help.Model
|
help help.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
|
|||||||
t := styles.CurrentTheme()
|
t := styles.CurrentTheme()
|
||||||
listKeyMap := list.DefaultKeyMap()
|
listKeyMap := list.DefaultKeyMap()
|
||||||
keyMap := DefaultKeyMap()
|
keyMap := DefaultKeyMap()
|
||||||
|
|
||||||
listKeyMap.Down.SetEnabled(false)
|
listKeyMap.Down.SetEnabled(false)
|
||||||
listKeyMap.Up.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.DownOneItem = keyMap.Next
|
||||||
listKeyMap.UpOneItem = keyMap.Previous
|
listKeyMap.UpOneItem = keyMap.Previous
|
||||||
|
|
||||||
selectedInx := 0
|
items := make([]list.CompletionItem[session.Session], len(sessions))
|
||||||
items := make([]util.Model, len(sessions))
|
|
||||||
if len(sessions) > 0 {
|
if len(sessions) > 0 {
|
||||||
for i, session := range sessions {
|
for i, session := range sessions {
|
||||||
items[i] = completions.NewCompletionItem(session.Title, session)
|
items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
|
||||||
if session.ID == selectedID {
|
|
||||||
selectedInx = i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionsList := list.New(
|
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
|
||||||
list.WithFilterable(true),
|
sessionsList := list.NewFilterableList(
|
||||||
|
items,
|
||||||
list.WithFilterPlaceholder("Enter a session name"),
|
list.WithFilterPlaceholder("Enter a session name"),
|
||||||
list.WithKeyMap(listKeyMap),
|
list.WithFilterInputStyle(inputStyle),
|
||||||
list.WithItems(items),
|
list.WithFilterListOptions(
|
||||||
list.WithWrapNavigation(true),
|
list.WithKeyMap(listKeyMap),
|
||||||
|
list.WithWrapNavigation(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
help := help.New()
|
help := help.New()
|
||||||
help.Styles = t.S().Help
|
help.Styles = t.S().Help
|
||||||
s := &sessionDialogCmp{
|
s := &sessionDialogCmp{
|
||||||
selectedInx: selectedInx,
|
|
||||||
selectedSessionID: selectedID,
|
selectedSessionID: selectedID,
|
||||||
keyMap: DefaultKeyMap(),
|
keyMap: DefaultKeyMap(),
|
||||||
sessionsList: sessionsList,
|
sessionsList: sessionsList,
|
||||||
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *sessionDialogCmp) Init() tea.Cmd {
|
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) {
|
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
|
var cmds []tea.Cmd
|
||||||
s.wWidth = msg.Width
|
s.wWidth = msg.Width
|
||||||
s.wHeight = msg.Height
|
s.wHeight = msg.Height
|
||||||
s.width = s.wWidth / 2
|
s.width = min(120, s.wWidth-8)
|
||||||
var cmds []tea.Cmd
|
s.sessionsList.SetInputWidth(s.listWidth() - 2)
|
||||||
cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
|
cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
|
||||||
if !s.renderedSelected {
|
if s.selectedSessionID != "" {
|
||||||
cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
|
cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
|
||||||
s.renderedSelected = true
|
|
||||||
}
|
}
|
||||||
return s, tea.Sequence(cmds...)
|
return s, tea.Batch(cmds...)
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, s.keyMap.Select):
|
case key.Matches(msg, s.keyMap.Select):
|
||||||
if len(s.sessionsList.Items()) > 0 {
|
selectedItem := s.sessionsList.SelectedItem()
|
||||||
items := s.sessionsList.Items()
|
if selectedItem != nil {
|
||||||
selectedItemInx := s.sessionsList.SelectedIndex()
|
selected := *selectedItem
|
||||||
return s, tea.Sequence(
|
return s, tea.Sequence(
|
||||||
util.CmdHandler(dialogs.CloseDialogMsg{}),
|
util.CmdHandler(dialogs.CloseDialogMsg{}),
|
||||||
util.CmdHandler(
|
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{})
|
return s, util.CmdHandler(dialogs.CloseDialogMsg{})
|
||||||
default:
|
default:
|
||||||
u, cmd := s.sessionsList.Update(msg)
|
u, cmd := s.sessionsList.Update(msg)
|
||||||
s.sessionsList = u.(list.ListModel)
|
s.sessionsList = u.(SessionsList)
|
||||||
return s, cmd
|
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
|
package list
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/v2/key"
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
|
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
|
||||||
"github.com/charmbracelet/crush/internal/tui/util"
|
"github.com/charmbracelet/crush/internal/tui/util"
|
||||||
@@ -16,11 +16,19 @@ type Item interface {
|
|||||||
ID() string
|
ID() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type List interface {
|
type List[T Item] interface {
|
||||||
util.Model
|
util.Model
|
||||||
layout.Sizeable
|
layout.Sizeable
|
||||||
layout.Focusable
|
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
|
type direction int
|
||||||
@@ -31,41 +39,45 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NotFound = -1
|
NotFound = -1
|
||||||
|
DefaultScrollSize = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type setSelectedMsg struct {
|
||||||
|
selectedItemID string
|
||||||
|
}
|
||||||
|
|
||||||
type renderedItem struct {
|
type renderedItem struct {
|
||||||
id string
|
id string
|
||||||
view string
|
view string
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
||||||
type list struct {
|
type confOptions struct {
|
||||||
width, height int
|
width, height int
|
||||||
offset int
|
|
||||||
gap int
|
gap int
|
||||||
direction direction
|
// if you are at the last item and go down it will wrap to the top
|
||||||
selectedItem string
|
wrap bool
|
||||||
focused bool
|
keyMap KeyMap
|
||||||
|
direction direction
|
||||||
|
selectedItem string
|
||||||
|
}
|
||||||
|
type list[T Item] struct {
|
||||||
|
confOptions
|
||||||
|
|
||||||
items []Item
|
focused bool
|
||||||
|
offset int
|
||||||
|
items []T
|
||||||
renderedItems []renderedItem
|
renderedItems []renderedItem
|
||||||
rendered string
|
rendered string
|
||||||
isReady bool
|
isReady bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type listOption func(*list)
|
type listOption func(*confOptions)
|
||||||
|
|
||||||
// WithItems sets the initial items for the list.
|
|
||||||
func WithItems(items ...Item) listOption {
|
|
||||||
return func(l *list) {
|
|
||||||
l.items = items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSize sets the size of the list.
|
// WithSize sets the size of the list.
|
||||||
func WithSize(width, height int) listOption {
|
func WithSize(width, height int) listOption {
|
||||||
return func(l *list) {
|
return func(l *confOptions) {
|
||||||
l.width = width
|
l.width = width
|
||||||
l.height = height
|
l.height = height
|
||||||
}
|
}
|
||||||
@@ -73,44 +85,53 @@ func WithSize(width, height int) listOption {
|
|||||||
|
|
||||||
// WithGap sets the gap between items in the list.
|
// WithGap sets the gap between items in the list.
|
||||||
func WithGap(gap int) listOption {
|
func WithGap(gap int) listOption {
|
||||||
return func(l *list) {
|
return func(l *confOptions) {
|
||||||
l.gap = gap
|
l.gap = gap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithDirection sets the direction of the list.
|
// WithDirection sets the direction of the list.
|
||||||
func WithDirection(dir direction) listOption {
|
func WithDirection(dir direction) listOption {
|
||||||
return func(l *list) {
|
return func(l *confOptions) {
|
||||||
l.direction = dir
|
l.direction = dir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSelectedItem sets the initially selected item in the list.
|
// WithSelectedItem sets the initially selected item in the list.
|
||||||
func WithSelectedItem(id string) listOption {
|
func WithSelectedItem(id string) listOption {
|
||||||
return func(l *list) {
|
return func(l *confOptions) {
|
||||||
l.selectedItem = id
|
l.selectedItem = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(opts ...listOption) List {
|
func WithKeyMap(keyMap KeyMap) listOption {
|
||||||
list := &list{
|
return func(l *confOptions) {
|
||||||
items: make([]Item, 0),
|
l.keyMap = keyMap
|
||||||
direction: Forward,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
for _, opt := range opts {
|
||||||
opt(list)
|
opt(&list.confOptions)
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init implements List.
|
// Init implements List.
|
||||||
func (l *list) Init() tea.Cmd {
|
func (l *list[T]) Init() tea.Cmd {
|
||||||
if l.height <= 0 || l.width <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(l.items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
for _, item := range l.items {
|
for _, item := range l.items {
|
||||||
cmd := item.Init()
|
cmd := item.Init()
|
||||||
@@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update implements List.
|
// 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
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// View implements List.
|
// View implements List.
|
||||||
func (l *list) View() string {
|
func (l *list[T]) View() string {
|
||||||
if l.height <= 0 || l.width <= 0 {
|
if l.height <= 0 || l.width <= 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -138,7 +188,7 @@ func (l *list) View() string {
|
|||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) viewPosition() (int, int) {
|
func (l *list[T]) viewPosition() (int, int) {
|
||||||
start, end := 0, 0
|
start, end := 0, 0
|
||||||
renderedLines := lipgloss.Height(l.rendered) - 1
|
renderedLines := lipgloss.Height(l.rendered) - 1
|
||||||
if l.direction == Forward {
|
if l.direction == Forward {
|
||||||
@@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) {
|
|||||||
return start, end
|
return start, end
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) renderItem(item Item) renderedItem {
|
func (l *list[T]) renderItem(item Item) renderedItem {
|
||||||
view := item.View()
|
view := item.View()
|
||||||
return renderedItem{
|
return renderedItem{
|
||||||
id: item.ID(),
|
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
|
var sb strings.Builder
|
||||||
for i, rendered := range l.renderedItems {
|
for i, rendered := range l.renderedItems {
|
||||||
sb.WriteString(rendered.view)
|
sb.WriteString(rendered.view)
|
||||||
@@ -171,7 +221,7 @@ func (l *list) renderView() {
|
|||||||
l.rendered = sb.String()
|
l.rendered = sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) incrementOffset(n int) {
|
func (l *list[T]) incrementOffset(n int) {
|
||||||
if !l.isReady {
|
if !l.isReady {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) {
|
|||||||
l.offset += n
|
l.offset += n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) decrementOffset(n int) {
|
func (l *list[T]) decrementOffset(n int) {
|
||||||
if !l.isReady {
|
if !l.isReady {
|
||||||
return
|
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
|
// 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
|
var cmds []tea.Cmd
|
||||||
start, end := l.viewPosition()
|
start, end := l.viewPosition()
|
||||||
currentPosition := 0
|
currentPosition := 0
|
||||||
@@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
|
|||||||
needsMove = true
|
needsMove = true
|
||||||
}
|
}
|
||||||
if needsMove {
|
if needsMove {
|
||||||
if focusable, ok := item.(layout.Focusable); ok {
|
if focusable, ok := any(item).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Blur())
|
cmds = append(cmds, focusable.Blur())
|
||||||
}
|
}
|
||||||
l.renderedItems[i] = l.renderItem(item)
|
l.renderedItems[i] = l.renderItem(item)
|
||||||
@@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
|
|||||||
if itemWithinView != NotFound && needsMove {
|
if itemWithinView != NotFound && needsMove {
|
||||||
newSelection := l.items[itemWithinView]
|
newSelection := l.items[itemWithinView]
|
||||||
l.selectedItem = newSelection.ID()
|
l.selectedItem = newSelection.ID()
|
||||||
if focusable, ok := newSelection.(layout.Focusable); ok {
|
if focusable, ok := any(newSelection).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Focus())
|
cmds = append(cmds, focusable.Focus())
|
||||||
}
|
}
|
||||||
l.renderedItems[itemWithinView] = l.renderItem(newSelection)
|
l.renderedItems[itemWithinView] = l.renderItem(newSelection)
|
||||||
@@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
|
|||||||
return tea.Batch(cmds...)
|
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 {
|
if l.direction == Forward {
|
||||||
l.decrementOffset(n)
|
l.decrementOffset(n)
|
||||||
} else {
|
} else {
|
||||||
@@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd {
|
|||||||
return l.changeSelectedWhenNotVisible()
|
return l.changeSelectedWhenNotVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) MoveDown(n int) tea.Cmd {
|
func (l *list[T]) MoveDown(n int) tea.Cmd {
|
||||||
if l.direction == Forward {
|
if l.direction == Forward {
|
||||||
l.incrementOffset(n)
|
l.incrementOffset(n)
|
||||||
} else {
|
} else {
|
||||||
@@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd {
|
|||||||
return l.changeSelectedWhenNotVisible()
|
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-- {
|
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
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if inx == 0 && l.wrap {
|
||||||
|
return l.firstSelectableItemBefore(len(l.items))
|
||||||
|
}
|
||||||
return NotFound
|
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++ {
|
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
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if inx == len(l.items)-1 && l.wrap {
|
||||||
|
return l.firstSelectableItemAfter(-1)
|
||||||
|
}
|
||||||
return NotFound
|
return NotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) moveToSelected() {
|
func (l *list[T]) moveToSelected(center bool) tea.Cmd {
|
||||||
|
var cmds []tea.Cmd
|
||||||
if l.selectedItem == "" || !l.isReady {
|
if l.selectedItem == "" || !l.isReady {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
currentPosition := 0
|
currentPosition := 0
|
||||||
start, end := l.viewPosition()
|
start, end := l.viewPosition()
|
||||||
for _, item := range l.renderedItems {
|
for _, item := range l.renderedItems {
|
||||||
if item.id == l.selectedItem {
|
if item.id == l.selectedItem {
|
||||||
if start <= currentPosition && (currentPosition+item.height) <= end {
|
itemStart := currentPosition
|
||||||
return
|
itemEnd := currentPosition + item.height - 1
|
||||||
|
|
||||||
|
if start <= itemStart && itemEnd <= end {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
// we need to go up
|
|
||||||
if currentPosition < start {
|
if center {
|
||||||
l.MoveUp(start - currentPosition)
|
viewportCenter := l.listHeight() / 2
|
||||||
}
|
itemCenter := itemStart + item.height/2
|
||||||
// we need to go down
|
targetOffset := itemCenter - viewportCenter
|
||||||
if currentPosition > end {
|
if l.direction == Forward {
|
||||||
l.MoveDown(currentPosition - end)
|
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
|
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 {
|
if !l.isReady {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// blur the current item
|
// blur the current item
|
||||||
if focusable, ok := item.(layout.Focusable); ok {
|
if focusable, ok := any(item).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Blur())
|
cmds = append(cmds, focusable.Blur())
|
||||||
}
|
}
|
||||||
// rerender the item
|
// rerender the item
|
||||||
l.renderedItems[i] = l.renderItem(item)
|
l.renderedItems[i] = l.renderItem(item)
|
||||||
// focus the item above
|
// focus the item above
|
||||||
above := l.items[inx]
|
above := l.items[inx]
|
||||||
if focusable, ok := above.(layout.Focusable); ok {
|
if focusable, ok := any(above).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Focus())
|
cmds = append(cmds, focusable.Focus())
|
||||||
}
|
}
|
||||||
// rerender the item
|
// rerender the item
|
||||||
@@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
l.moveToSelected(false)
|
||||||
l.renderView()
|
l.renderView()
|
||||||
l.moveToSelected()
|
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) SelectItemBelow() tea.Cmd {
|
func (l *list[T]) SelectItemBelow() tea.Cmd {
|
||||||
if !l.isReady {
|
if !l.isReady {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// blur the current item
|
// blur the current item
|
||||||
if focusable, ok := item.(layout.Focusable); ok {
|
if focusable, ok := any(item).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Blur())
|
cmds = append(cmds, focusable.Blur())
|
||||||
}
|
}
|
||||||
// rerender the item
|
// rerender the item
|
||||||
@@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
|
|||||||
|
|
||||||
// focus the item below
|
// focus the item below
|
||||||
below := l.items[inx]
|
below := l.items[inx]
|
||||||
if focusable, ok := below.(layout.Focusable); ok {
|
if focusable, ok := any(below).(layout.Focusable); ok {
|
||||||
cmds = append(cmds, focusable.Focus())
|
cmds = append(cmds, focusable.Focus())
|
||||||
}
|
}
|
||||||
// rerender the item
|
// rerender the item
|
||||||
@@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
l.moveToSelected(false)
|
||||||
l.renderView()
|
l.renderView()
|
||||||
l.moveToSelected()
|
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) GoToTop() tea.Cmd {
|
func (l *list[T]) GoToTop() tea.Cmd {
|
||||||
if !l.isReady {
|
if !l.isReady {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd {
|
|||||||
return tea.Batch(l.selectFirstItem(), l.renderForward())
|
return tea.Batch(l.selectFirstItem(), l.renderForward())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) GoToBottom() tea.Cmd {
|
func (l *list[T]) GoToBottom() tea.Cmd {
|
||||||
if !l.isReady {
|
if !l.isReady {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd {
|
|||||||
return tea.Batch(l.selectLastItem(), l.renderBackward())
|
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
|
// TODO: figure out a way to preserve items that did not change
|
||||||
l.renderedItems = make([]renderedItem, 0)
|
l.renderedItems = make([]renderedItem, 0)
|
||||||
currentHeight := 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
|
// TODO: figure out a way to preserve items that did not change
|
||||||
l.renderedItems = make([]renderedItem, 0)
|
l.renderedItems = make([]renderedItem, 0)
|
||||||
currentHeight := 0
|
currentHeight := 0
|
||||||
currentIndex := 0
|
currentIndex := 0
|
||||||
for i := len(l.items) - 1; i >= 0; i-- {
|
for i := len(l.items) - 1; i >= 0; i-- {
|
||||||
fmt.Printf("rendering item %d\n", i)
|
|
||||||
currentIndex = i
|
currentIndex = i
|
||||||
if currentHeight > l.listHeight() {
|
if currentHeight > l.listHeight() {
|
||||||
break
|
break
|
||||||
@@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd {
|
|||||||
}
|
}
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
for i := currentIndex; i >= 0; i-- {
|
for i := currentIndex; i >= 0; i-- {
|
||||||
fmt.Printf("rendering item after %d\n", i)
|
|
||||||
rendered := l.renderItem(l.items[i])
|
rendered := l.renderItem(l.items[i])
|
||||||
l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
|
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
|
var cmd tea.Cmd
|
||||||
inx := l.firstSelectableItemAfter(-1)
|
inx := l.firstSelectableItemAfter(-1)
|
||||||
if inx != NotFound {
|
if inx != NotFound {
|
||||||
l.selectedItem = l.items[inx].ID()
|
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()
|
cmd = focusable.Focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) selectLastItem() tea.Cmd {
|
func (l *list[T]) selectLastItem() tea.Cmd {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
inx := l.firstSelectableItemBefore(len(l.items))
|
inx := l.firstSelectableItemBefore(len(l.items))
|
||||||
if inx != NotFound {
|
if inx != NotFound {
|
||||||
l.selectedItem = l.items[inx].ID()
|
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()
|
cmd = focusable.Focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) renderItems() tea.Cmd {
|
func (l *list[T]) renderItems() tea.Cmd {
|
||||||
if l.height <= 0 || l.width <= 0 {
|
if l.height <= 0 || l.width <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd {
|
|||||||
return l.renderBackward()
|
return l.renderBackward()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) listHeight() int {
|
func (l *list[T]) listHeight() int {
|
||||||
// for the moment its the same
|
// for the moment its the same
|
||||||
return l.height
|
return l.height
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *list) SetItems(items []Item) tea.Cmd {
|
func (l *list[T]) SetItems(items []T) tea.Cmd {
|
||||||
l.items = items
|
l.items = items
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
for _, item := range l.items {
|
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
|
// Set height to 0 to let the item calculate its own height
|
||||||
cmds = append(cmds, item.SetSize(l.width, 0))
|
cmds = append(cmds, item.SetSize(l.width, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if l.selectedItem != "" {
|
||||||
|
cmds = append(cmds, l.moveToSelected(true))
|
||||||
|
}
|
||||||
cmds = append(cmds, l.renderItems())
|
cmds = append(cmds, l.renderItems())
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSize implements List.
|
// GetSize implements List.
|
||||||
func (l *list) GetSize() (int, int) {
|
func (l *list[T]) GetSize() (int, int) {
|
||||||
return l.width, l.height
|
return l.width, l.height
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSize implements List.
|
// 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.width = width
|
||||||
l.height = height
|
l.height = height
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
for _, item := range l.items {
|
for _, item := range l.items {
|
||||||
cmds = append(cmds, item.SetSize(width, height))
|
cmds = append(cmds, item.SetSize(width, height))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmds = append(cmds, l.renderItems())
|
cmds = append(cmds, l.renderItems())
|
||||||
return tea.Batch(cmds...)
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blur implements List.
|
// Blur implements List.
|
||||||
func (l *list) Blur() tea.Cmd {
|
func (l *list[T]) Blur() tea.Cmd {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
l.focused = false
|
l.focused = false
|
||||||
for i, item := range l.items {
|
for i, item := range l.items {
|
||||||
if item.ID() != l.selectedItem {
|
if item.ID() != l.selectedItem {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if focusable, ok := item.(layout.Focusable); ok {
|
if focusable, ok := any(item).(layout.Focusable); ok {
|
||||||
cmd = focusable.Blur()
|
cmd = focusable.Blur()
|
||||||
}
|
}
|
||||||
l.renderedItems[i] = l.renderItem(item)
|
l.renderedItems[i] = l.renderItem(item)
|
||||||
@@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Focus implements List.
|
// Focus implements List.
|
||||||
func (l *list) Focus() tea.Cmd {
|
func (l *list[T]) Focus() tea.Cmd {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
l.focused = true
|
l.focused = true
|
||||||
for i, item := range l.items {
|
if l.selectedItem != "" {
|
||||||
if item.ID() != l.selectedItem {
|
for i, item := range l.items {
|
||||||
continue
|
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 {
|
l.renderView()
|
||||||
cmd = focusable.Focus()
|
|
||||||
}
|
|
||||||
l.renderedItems[i] = l.renderItem(item)
|
|
||||||
}
|
}
|
||||||
l.renderView()
|
|
||||||
return cmd
|
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.
|
// IsFocused implements List.
|
||||||
func (l *list) IsFocused() bool {
|
func (l *list[T]) IsFocused() bool {
|
||||||
return l.focused
|
return l.focused
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package list
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea/v2"
|
tea "github.com/charmbracelet/bubbletea/v2"
|
||||||
@@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range tests {
|
for _, c := range tests {
|
||||||
t.Run(c.test, func(t *testing.T) {
|
t.Run(c.test, func(t *testing.T) {
|
||||||
l := New(WithDirection(c.dir)).(*list)
|
|
||||||
l.SetSize(c.width, c.height)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range c.numItems {
|
for i := range c.numItems {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) {
|
|||||||
func TestBackwardList(t *testing.T) {
|
func TestBackwardList(t *testing.T) {
|
||||||
t.Run("within height", func(t *testing.T) {
|
t.Run("within height", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward), WithGap(1)).(*list)
|
|
||||||
l.SetSize(10, 20)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// should select the last item
|
// should select the last item
|
||||||
assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
|
assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
|
||||||
|
|
||||||
golden.RequireEqual(t, []byte(l.View()))
|
golden.RequireEqual(t, []byte(l.View()))
|
||||||
})
|
})
|
||||||
t.Run("should not change selected item", func(t *testing.T) {
|
t.Run("should not change selected item", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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)
|
l.SetSize(10, 20)
|
||||||
cmd := l.SetItems(items)
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("more than height", func(t *testing.T) {
|
t.Run("more than height", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward))
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
cmd := l.SetItems(items)
|
l := New(items, WithDirection(Backward))
|
||||||
|
l.SetSize(10, 5)
|
||||||
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("more than height multi line", func(t *testing.T) {
|
t.Run("more than height multi line", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward))
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
cmd := l.SetItems(items)
|
l := New(items, WithDirection(Backward))
|
||||||
|
l.SetSize(10, 5)
|
||||||
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should move up", func(t *testing.T) {
|
t.Run("should move up", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
cmd := l.SetItems(items)
|
l := New(items, WithDirection(Backward))
|
||||||
|
l.SetSize(10, 5)
|
||||||
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should move at max to the top", func(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{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should do nothing with wrong move number", func(t *testing.T) {
|
t.Run("should do nothing with wrong move number", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
cmd := l.SetItems(items)
|
l := New(items, WithDirection(Backward))
|
||||||
|
l.SetSize(10, 5)
|
||||||
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should move to the top", func(t *testing.T) {
|
t.Run("should move to the top", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should select the item above", func(t *testing.T) {
|
t.Run("should select the item above", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
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.Run("should move the view to be able to see the selected item", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) {
|
|||||||
func TestForwardList(t *testing.T) {
|
func TestForwardList(t *testing.T) {
|
||||||
t.Run("within height", func(t *testing.T) {
|
t.Run("within height", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward), WithGap(1)).(*list)
|
|
||||||
l.SetSize(10, 20)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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)
|
l.SetSize(10, 20)
|
||||||
cmd := l.SetItems(items)
|
cmd := l.Init()
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("more than height", func(t *testing.T) {
|
t.Run("more than height", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward))
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("more than height multi line", func(t *testing.T) {
|
t.Run("more than height multi line", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward))
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should move down", func(t *testing.T) {
|
t.Run("should move down", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should move at max to the bottom", func(t *testing.T) {
|
t.Run("should move at max to the bottom", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should do nothing with wrong move number", func(t *testing.T) {
|
t.Run("should do nothing with wrong move number", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should move to the bottom", func(t *testing.T) {
|
t.Run("should move to the bottom", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) {
|
|||||||
})
|
})
|
||||||
t.Run("should select the item below", func(t *testing.T) {
|
t.Run("should select the item below", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
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.Run("should move the view to be able to see the selected item", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Backward)).(*list)
|
|
||||||
l.SetSize(10, 5)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
for i := range 10 {
|
for i := range 10 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) {
|
|||||||
func TestListSelection(t *testing.T) {
|
func TestListSelection(t *testing.T) {
|
||||||
t.Run("should skip none selectable items initially", func(t *testing.T) {
|
t.Run("should skip none selectable items initially", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(100, 10)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
items = append(items, NewSimpleItem("None Selectable"))
|
items = append(items, NewSimpleItem("None Selectable"))
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
cmd()
|
||||||
}
|
}
|
||||||
@@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) {
|
|||||||
assert.Equal(t, items[1].ID(), l.selectedItem)
|
assert.Equal(t, items[1].ID(), l.selectedItem)
|
||||||
golden.RequireEqual(t, []byte(l.View()))
|
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.Run("should skip none selectable items in the middle", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
l := New(WithDirection(Forward)).(*list)
|
|
||||||
l.SetSize(100, 10)
|
|
||||||
items := []Item{}
|
items := []Item{}
|
||||||
item := NewSelectsableItem("Item initial")
|
item := NewSelectableItem("Item initial")
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
items = append(items, NewSimpleItem("None Selectable"))
|
items = append(items, NewSimpleItem("None Selectable"))
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
|
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
|
||||||
items = append(items, item)
|
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 {
|
if cmd != nil {
|
||||||
cmd()
|
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 {
|
type SelectableItem interface {
|
||||||
Item
|
Item
|
||||||
layout.Focusable
|
layout.Focusable
|
||||||
@@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSelectsableItem(content string) SelectableItem {
|
func NewSelectableItem(content string) SelectableItem {
|
||||||
return &selectableItem{
|
return &selectableItem{
|
||||||
simpleItem: NewSimpleItem(content),
|
simpleItem: NewSimpleItem(content),
|
||||||
focused: false,
|
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 1
|
||||||
Item 6
|
Item 2
|
||||||
Item 7
|
Item 3
|
||||||
Item 8
|
Item 4
|
||||||
│Item 9
|
│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