chore: grouped list

This commit is contained in:
Kujtim Hoxha
2025-07-24 16:27:30 +02:00
parent 6b3fe6aa20
commit dcf069ea37
24 changed files with 733 additions and 311 deletions

View File

@@ -14,12 +14,11 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/prompt"
"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/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
"github.com/charmbracelet/crush/internal/tui/components/logo"
"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/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
t := styles.CurrentTheme()
inputStyle := t.S().Base.Padding(0, 1, 0, 1)
modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
apiKeyInput := models.NewAPIKeyInput()
return &splashCmp{
@@ -195,17 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
}
if s.isOnboarding && !s.needsAPIKey {
modelInx := s.modelList.SelectedIndex()
items := s.modelList.Items()
selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
selectedItem := s.modelList.SelectedModel()
if selectedItem == nil {
return s, nil
}
if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
cmd := s.setPreferredModel(selectedItem)
cmd := s.setPreferredModel(*selectedItem)
s.isOnboarding = false
return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
} else {
// Provider not configured, show API key input
s.needsAPIKey = true
s.selectedModel = &selectedItem
s.selectedModel = selectedItem
s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
return s, nil
}
@@ -264,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
case key.Matches(msg, s.keyMap.Yes):
if s.isOnboarding {
return s, nil
}
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -274,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, s.initializeProject()
}
case key.Matches(msg, s.keyMap.No):
if s.isOnboarding {
return s, nil
}
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -605,7 +609,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
cursor.Y += offset
cursor.X = cursor.X + 1
} else if s.isOnboarding {
offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
cursor.Y += offset
cursor.X = cursor.X + 1
}

View File

@@ -10,10 +10,9 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/prompt"
"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"
)
@@ -29,6 +28,8 @@ const (
UserCommands
)
type listModel = list.FilterableList[list.CompletionItem[Command]]
// Command represents a command that can be executed
type Command struct {
ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
wWidth int // Width of the terminal window
wHeight int // Height of the terminal window
commandList list.ListModel
commandList listModel
keyMap CommandsDialogKeyMap
help help.Model
commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
)
func NewCommandDialog(sessionID string) CommandsDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultCommandsDialogKeyMap()
listKeyMap := list.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
t := styles.CurrentTheme()
commandList := list.New(
list.WithFilterable(true),
list.WithKeyMap(listKeyMap),
list.WithWrapNavigation(true),
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
commandList := list.NewFilterableList(
[]list.CompletionItem[Command]{},
list.WithFilterInputStyle(inputStyle),
list.WithFilterListOptions(
list.WithKeyMap(listKeyMap),
list.WithWrapNavigation(),
list.WithResizeByList(),
),
)
help := help.New()
help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
if err != nil {
return util.ReportError(err)
}
c.userCommands = commands
c.SetCommandType(c.commandType)
return c.commandList.Init()
return c.SetCommandType(c.commandType)
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
c.wHeight = msg.Height
c.SetCommandType(c.commandType)
return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keyMap.Select):
selectedItemInx := c.commandList.SelectedIndex()
if selectedItemInx == list.NoSelection {
selectedItem := c.commandList.SelectedItem()
if selectedItem == nil {
return c, nil // No item selected, do nothing
}
items := c.commandList.Items()
selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
command := (*selectedItem).Value()
return c, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
selectedItem.Handler(selectedItem),
command.Handler(command),
)
case key.Matches(msg, c.keyMap.Tab):
if len(c.userCommands) == 0 {
return c, nil
}
// Toggle command type between System and User commands
if c.commandType == SystemCommands {
return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
u, cmd := c.commandList.Update(msg)
c.commandList = u.(list.ListModel)
c.commandList = u.(listModel)
return c, cmd
}
}
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
t := styles.CurrentTheme()
listView := c.commandList
radio := c.commandTypeRadio()
header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
if len(c.userCommands) == 0 {
header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
}
content := lipgloss.JoinVertical(
lipgloss.Left,
t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
header,
listView.View(),
"",
t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
@@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
commands = c.userCommands
}
commandItems := []util.Model{}
commandItems := []list.CompletionItem[Command]{}
for _, cmd := range commands {
opts := []completions.CompletionOption{}
if cmd.Shortcut != "" {
opts = append(opts, completions.WithShortcut(cmd.Shortcut))
opts := []list.CompletionItemOption{
list.WithCompletionID(cmd.ID),
}
commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
if cmd.Shortcut != "" {
opts = append(
opts,
list.WithCompletionShortcut(cmd.Shortcut),
)
}
commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
}
return c.commandList.SetItems(commandItems)
}

View File

@@ -7,27 +7,36 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"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"
)
type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
type ModelListComponent struct {
list list.ListModel
list listModel
modelType int
providers []catwalk.Provider
}
func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
modelList := list.New(
list.WithFilterable(true),
func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
t := styles.CurrentTheme()
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
options := []list.ListOption{
list.WithKeyMap(keyMap),
list.WithInputStyle(inputStyle),
list.WithWrapNavigation(),
}
if shouldResize {
options = append(options, list.WithResizeByList())
}
modelList := list.NewFilterableGroupedList(
[]list.Group[list.CompletionItem[ModelOption]]{},
list.WithFilterInputStyle(inputStyle),
list.WithFilterPlaceholder(inputPlaceholder),
list.WithWrapNavigation(true),
list.WithFilterListOptions(
options...,
),
)
return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
u, cmd := m.list.Update(msg)
m.list = u.(list.ListModel)
m.list = u.(listModel)
return m, cmd
}
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
return m.list.SetSize(width, height)
}
func (m *ModelListComponent) Items() []util.Model {
return m.list.Items()
}
func (m *ModelListComponent) SelectedIndex() int {
return m.list.SelectedIndex()
func (m *ModelListComponent) SelectedModel() *ModelOption {
s := m.list.SelectedItem()
if s == nil {
return nil
}
sv := *s
model := sv.Value()
return &model
}
func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
t := styles.CurrentTheme()
m.modelType = modelType
modelItems := []util.Model{}
var groups []list.Group[list.CompletionItem[ModelOption]]
// first none section
selectIndex := 1
selectedItemID := ""
cfg := config.Get()
var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
if name == "" {
name = string(configProvider.ID)
}
section := commands.NewItemSection(name)
section := list.NewItemSection(name)
section.SetInfo(configured)
modelItems = append(modelItems, section)
group := list.Group[list.CompletionItem[ModelOption]]{
Section: section,
}
for _, model := range configProvider.Models {
modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
item := list.NewCompletionItem(model.Model, ModelOption{
Provider: configProvider,
Model: model,
}))
},
list.WithCompletionID(
fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
),
)
group.Items = append(group.Items, item)
if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
selectIndex = len(modelItems) - 1 // Set the selected index to the current model
selectedItemID = item.ID()
}
}
groups = append(groups, group)
addedProviders[providerID] = true
}
}
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
name = string(provider.ID)
}
section := commands.NewItemSection(name)
if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
section := list.NewItemSection(name)
if _, ok := cfg.Providers[string(provider.ID)]; ok {
section.SetInfo(configured)
}
modelItems = append(modelItems, section)
group := list.Group[list.CompletionItem[ModelOption]]{
Section: section,
}
for _, model := range provider.Models {
modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
item := list.NewCompletionItem(model.Model, ModelOption{
Provider: provider,
Model: model,
}))
},
list.WithCompletionID(
fmt.Sprintf("%s:%s", provider.ID, model.ID),
),
)
group.Items = append(group.Items, item)
if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
selectIndex = len(modelItems) - 1 // Set the selected index to the current model
selectedItemID = item.ID()
}
}
groups = append(groups, group)
}
return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
var cmds []tea.Cmd
cmd := m.list.SetGroups(groups)
if cmd != nil {
cmds = append(cmds, cmd)
}
cmd = m.list.SetSelected(selectedItemID)
if cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Sequence(cmds...)
}
// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
}
func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
m.list.SetFilterPlaceholder(placeholder)
m.list.SetInputPlaceholder(placeholder)
}
func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {

View File

@@ -10,10 +10,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"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"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
}
func NewModelDialogCmp() ModelDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultKeyMap()
listKeyMap := list.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
t := styles.CurrentTheme()
inputStyle := t.S().Base.Padding(0, 1, 0, 1)
modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
apiKeyInput := NewAPIKeyInput()
apiKeyInput.SetShowTitle(false)
help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
}
// Normal model selection
selectedItemInx := m.modelList.SelectedIndex()
if selectedItemInx == list.NoSelection {
return m, nil
}
items := m.modelList.Items()
selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
selectedItem := m.modelList.SelectedModel()
var modelType config.SelectedModelType
if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
// Provider not configured, show API key input
m.needsAPIKey = true
m.selectedModel = &selectedItem
m.selectedModel = selectedItem
m.selectedModelType = modelType
m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
}
func (m *modelDialogCmp) listWidth() int {
return defaultWidth - 2 // 4 for padding
return m.width - 2
}
func (m *modelDialogCmp) listHeight() int {
items := m.modelList.Items()
listHeigh := len(items) + 2 + 4
return min(listHeigh, m.wHeight/2)
return m.wHeight / 2
}
func (m *modelDialogCmp) Position() (int, int) {

View File

@@ -47,7 +47,7 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
items := make([]list.CompletionItem[session.Session], len(sessions))
if len(sessions) > 0 {
for i, session := range sessions {
items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
}
}

View File

@@ -23,6 +23,7 @@ type FilterableList[T FilterableItem] interface {
List[T]
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
}
type HasMatchIndexes interface {
@@ -30,7 +31,7 @@ type HasMatchIndexes interface {
}
type filterableOptions struct {
listOptions []listOption
listOptions []ListOption
placeholder string
inputHidden bool
inputWidth int
@@ -67,7 +68,7 @@ func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
}
}
func WithFilterListOptions(opts ...listOption) filterableListOption {
func WithFilterListOptions(opts ...ListOption) filterableListOption {
return func(f *filterableOptions) {
f.listOptions = opts
}
@@ -295,3 +296,7 @@ func (f *filterableList[T]) IsFocused() bool {
func (f *filterableList[T]) SetInputWidth(w int) {
f.inputWidth = w
}
func (f *filterableList[T]) SetInputPlaceholder(ph string) {
f.placeholder = ph
}

View File

@@ -0,0 +1,260 @@
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 FilterableGroupList[T FilterableItem] interface {
GroupedList[T]
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
}
type filterableGroupList[T FilterableItem] struct {
*groupedList[T]
*filterableOptions
width, height int
groups []Group[T]
// stores all available items
input textinput.Model
inputWidth int
query string
}
func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
t := styles.CurrentTheme()
f := &filterableGroupList[T]{
filterableOptions: &filterableOptions{
inputStyle: t.S().Base,
placeholder: "Type to filter",
},
}
for _, opt := range opts {
opt(f.filterableOptions)
}
f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
f.updateKeyMaps()
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 *filterableGroupList[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.groupedList.Update(msg)
f.groupedList = u.(*groupedList[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.groupedList.Update(msg)
f.groupedList = u.(*groupedList[T])
return f, cmd
}
func (f *filterableGroupList[T]) View() string {
if f.inputHidden {
return f.groupedList.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
f.inputStyle.Render(f.input.View()),
f.groupedList.View(),
)
}
// removes bindings that are used for search
func (f *filterableGroupList[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 *filterableGroupList[T]) GetSize() (int, int) {
return m.width, m.height
}
func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
f.width = w
f.height = h
if f.inputHidden {
return f.groupedList.SetSize(w, h)
}
if f.inputWidth == 0 {
f.input.SetWidth(w)
} else {
f.input.SetWidth(f.inputWidth)
}
return f.groupedList.SetSize(w, h-(f.inputHeight()))
}
func (f *filterableGroupList[T]) inputHeight() int {
return lipgloss.Height(f.inputStyle.Render(f.input.View()))
}
func (f *filterableGroupList[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.groupedList.SetGroups(f.groups)
}
var newGroups []Group[T]
for _, g := range f.groups {
words := make([]string, len(g.Items))
for i, item := range g.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 := g.Items[match.Index]
if i, ok := any(item).(HasMatchIndexes); ok {
i.MatchIndexes(match.MatchedIndexes)
}
matchedItems = append(matchedItems, item)
}
if len(matchedItems) > 0 {
newGroups = append(newGroups, Group[T]{
Section: g.Section,
Items: matchedItems,
})
}
}
cmds = append(cmds, f.groupedList.SetGroups(newGroups))
return tea.Batch(cmds...)
}
func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
f.groups = groups
return f.groupedList.SetGroups(groups)
}
func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
if f.inputHidden {
return nil
}
return f.input.Cursor()
}
func (f *filterableGroupList[T]) Blur() tea.Cmd {
f.input.Blur()
return f.groupedList.Blur()
}
func (f *filterableGroupList[T]) Focus() tea.Cmd {
f.input.Focus()
return f.groupedList.Focus()
}
func (f *filterableGroupList[T]) IsFocused() bool {
return f.groupedList.IsFocused()
}
func (f *filterableGroupList[T]) SetInputWidth(w int) {
f.inputWidth = w
}
func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
f.placeholder = ph
}

View File

@@ -1,60 +1,68 @@
package list
//
// func TestFilterableList(t *testing.T) {
// t.Parallel()
// 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
// }
import (
"fmt"
"slices"
"testing"
"github.com/charmbracelet/x/exp/golden"
"github.com/stretchr/testify/assert"
)
func TestFilterableList(t *testing.T) {
t.Parallel()
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(WithDirectionForward()),
).(*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(WithDirectionForward()),
).(*filterableList[FilterableItem])
hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
fmt.Println(l.keyMap.Down.Keys())
hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
assert.False(t, hasJ, "should not contain j")
assert.False(t, hasUpperCaseK, "should also remove upper case K")
assert.True(t, hasCtrlJ, "should still have ctrl+j")
}
type filterableItem struct {
*selectableItem
}
func NewFilterableItem(content string) FilterableItem {
return &filterableItem{
selectableItem: NewSelectableItem(content).(*selectableItem),
}
}
func (f *filterableItem) FilterValue() string {
return f.content
}

View File

@@ -0,0 +1,99 @@
package list
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/util"
)
type Group[T Item] struct {
Section ItemSection
Items []T
}
type GroupedList[T Item] interface {
util.Model
layout.Sizeable
Items() []Item
Groups() []Group[T]
SetGroups([]Group[T]) tea.Cmd
MoveUp(int) tea.Cmd
MoveDown(int) tea.Cmd
GoToTop() tea.Cmd
GoToBottom() tea.Cmd
SelectItemAbove() tea.Cmd
SelectItemBelow() tea.Cmd
SetSelected(string) tea.Cmd
SelectedItem() *T
}
type groupedList[T Item] struct {
*list[Item]
groups []Group[T]
}
func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
list := &list[Item]{
confOptions: &confOptions{
direction: DirectionForward,
keyMap: DefaultKeyMap(),
focused: true,
},
indexMap: make(map[string]int),
renderedItems: map[string]renderedItem{},
}
for _, opt := range opts {
opt(list.confOptions)
}
return &groupedList[T]{
list: list,
}
}
func (g *groupedList[T]) Init() tea.Cmd {
g.convertItems()
return g.render()
}
func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := l.list.Update(msg)
l.list = u.(*list[Item])
return l, cmd
}
func (g *groupedList[T]) SelectedItem() *T {
item := g.list.SelectedItem()
if item == nil {
return nil
}
dRef := *item
c, ok := any(dRef).(T)
if !ok {
return nil
}
return &c
}
func (g *groupedList[T]) convertItems() {
var items []Item
for _, g := range g.groups {
items = append(items, g.Section)
for _, g := range g.Items {
items = append(items, g)
}
}
g.items = items
}
func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
g.groups = groups
g.convertItems()
return g.SetItems(g.items)
}
func (g *groupedList[T]) Groups() []Group[T] {
return g.groups
}
func (g *groupedList[T]) Items() []Item {
return g.list.Items()
}

View File

@@ -4,6 +4,7 @@ import (
"image/color"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/lipgloss/v2"
@@ -12,6 +13,10 @@ import (
"github.com/rivo/uniseg"
)
type Indexable interface {
SetIndex(int)
}
type CompletionItem[T any] interface {
FilterableItem
layout.Focusable
@@ -39,33 +44,33 @@ type options struct {
shortcut string
}
type completionOption func(*options)
type CompletionItemOption func(*options)
func WithBackgroundColor(c color.Color) completionOption {
func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
return func(cmp *options) {
cmp.bgColor = c
}
}
func WithMatchIndexes(indexes ...int) completionOption {
func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
return func(cmp *options) {
cmp.matchIndexes = indexes
}
}
func WithShortcut(shortcut string) completionOption {
func WithCompletionShortcut(shortcut string) CompletionItemOption {
return func(cmp *options) {
cmp.shortcut = shortcut
}
}
func WithID(id string) completionOption {
func WithCompletionID(id string) CompletionItemOption {
return func(cmp *options) {
cmp.id = id
}
}
func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
c := &completionItemCmp[T]{
text: text,
value: value,
@@ -306,3 +311,75 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
func (c *completionItemCmp[T]) ID() string {
return c.id
}
type ItemSection interface {
Item
layout.Sizeable
Indexable
SetInfo(info string)
}
type itemSectionModel struct {
width int
title string
inx int
info string
}
// ID implements ItemSection.
func (m *itemSectionModel) ID() string {
return uuid.NewString()
}
func NewItemSection(title string) ItemSection {
return &itemSectionModel{
title: title,
inx: -1,
}
}
func (m *itemSectionModel) Init() tea.Cmd {
return nil
}
func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *itemSectionModel) View() string {
t := styles.CurrentTheme()
title := ansi.Truncate(m.title, m.width-2, "…")
style := t.S().Base.Padding(1, 1, 0, 1)
if m.inx == 0 {
style = style.Padding(0, 1, 0, 1)
}
title = t.S().Muted.Render(title)
section := ""
if m.info != "" {
section = core.SectionWithInfo(title, m.width-2, m.info)
} else {
section = core.Section(title, m.width-2)
}
return style.Render(section)
}
func (m *itemSectionModel) GetSize() (int, int) {
return m.width, 1
}
func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
m.width = width
return nil
}
func (m *itemSectionModel) IsSectionHeader() bool {
return true
}
func (m *itemSectionModel) SetInfo(info string) {
m.info = info
}
func (m *itemSectionModel) SetIndex(inx int) {
m.inx = inx
}

View File

@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
)
@@ -22,30 +23,29 @@ type HasAnim interface {
Item
Spinning() bool
}
type (
renderedMsg struct{}
List[T Item] interface {
util.Model
layout.Sizeable
layout.Focusable
type renderedMsg struct{}
// Just change state
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
Items() []T
UpdateItem(string, T) tea.Cmd
DeleteItem(string) tea.Cmd
PrependItem(T) tea.Cmd
AppendItem(T) tea.Cmd
}
)
type List[T Item] interface {
util.Model
layout.Sizeable
layout.Focusable
// Just change state
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
Items() []T
UpdateItem(string, T) tea.Cmd
DeleteItem(string) tea.Cmd
PrependItem(T) tea.Cmd
AppendItem(T) tea.Cmd
}
type direction int
@@ -76,6 +76,7 @@ type confOptions struct {
direction direction
selectedItem string
focused bool
resize bool
}
type list[T Item] struct {
@@ -93,10 +94,10 @@ type list[T Item] struct {
movingByItem bool
}
type listOption func(*confOptions)
type ListOption func(*confOptions)
// WithSize sets the size of the list.
func WithSize(width, height int) listOption {
func WithSize(width, height int) ListOption {
return func(l *confOptions) {
l.width = width
l.height = height
@@ -104,52 +105,58 @@ func WithSize(width, height int) listOption {
}
// WithGap sets the gap between items in the list.
func WithGap(gap int) listOption {
func WithGap(gap int) ListOption {
return func(l *confOptions) {
l.gap = gap
}
}
// WithDirectionForward sets the direction to forward
func WithDirectionForward() listOption {
func WithDirectionForward() ListOption {
return func(l *confOptions) {
l.direction = DirectionForward
}
}
// WithDirectionBackward sets the direction to forward
func WithDirectionBackward() listOption {
func WithDirectionBackward() ListOption {
return func(l *confOptions) {
l.direction = DirectionBackward
}
}
// WithSelectedItem sets the initially selected item in the list.
func WithSelectedItem(id string) listOption {
func WithSelectedItem(id string) ListOption {
return func(l *confOptions) {
l.selectedItem = id
}
}
func WithKeyMap(keyMap KeyMap) listOption {
func WithKeyMap(keyMap KeyMap) ListOption {
return func(l *confOptions) {
l.keyMap = keyMap
}
}
func WithWrapNavigation() listOption {
func WithWrapNavigation() ListOption {
return func(l *confOptions) {
l.wrap = true
}
}
func WithFocus(focus bool) listOption {
func WithFocus(focus bool) ListOption {
return func(l *confOptions) {
l.focused = focus
}
}
func New[T Item](items []T, opts ...listOption) List[T] {
func WithResizeByList() ListOption {
return func(l *confOptions) {
l.resize = true
}
}
func New[T Item](items []T, opts ...ListOption) List[T] {
list := &list[T]{
confOptions: &confOptions{
direction: DirectionForward,
@@ -165,6 +172,9 @@ func New[T Item](items []T, opts ...listOption) List[T] {
}
for inx, item := range items {
if i, ok := any(item).(Indexable); ok {
i.SetIndex(inx)
}
list.indexMap[item.ID()] = inx
}
return list
@@ -224,6 +234,7 @@ func (l *list[T]) View() string {
if l.height <= 0 || l.width <= 0 {
return ""
}
t := styles.CurrentTheme()
view := l.rendered
lines := strings.Split(view, "\n")
@@ -231,7 +242,13 @@ func (l *list[T]) View() string {
viewStart := max(0, start)
viewEnd := min(len(lines), end+1)
lines = lines[viewStart:viewEnd]
return strings.Join(lines, "\n")
if l.resize {
return strings.Join(lines, "\n")
}
return t.S().Base.
Height(l.height).
Width(l.width).
Render(strings.Join(lines, "\n"))
}
func (l *list[T]) viewPosition() (int, int) {
@@ -774,10 +791,26 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
// no item above
return nil
}
var cmds []tea.Cmd
if newIndex == 1 {
peakAboveIndex := l.firstSelectableItemAbove(newIndex)
if peakAboveIndex == ItemNotFound {
// this means there is a section above move to the top
cmd := l.GoToTop()
if cmd != nil {
cmds = append(cmds, cmd)
}
}
}
item := l.items[newIndex]
l.selectedItem = item.ID()
l.movingByItem = true
return l.render()
renderCmd := l.render()
if renderCmd != nil {
cmds = append(cmds, renderCmd)
}
return tea.Sequence(cmds...)
}
// SelectItemBelow implements List.
@@ -815,10 +848,13 @@ func (l *list[T]) SelectedItem() *T {
func (l *list[T]) SetItems(items []T) tea.Cmd {
l.items = items
var cmds []tea.Cmd
for _, item := range l.items {
for inx, item := range l.items {
if i, ok := any(item).(Indexable); ok {
i.SetIndex(inx)
}
cmds = append(cmds, item.Init())
}
cmds = append(cmds, l.reset())
cmds = append(cmds, l.reset(""))
return tea.Batch(cmds...)
}
@@ -828,11 +864,11 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
return l.render()
}
func (l *list[T]) reset() tea.Cmd {
func (l *list[T]) reset(selectedItem string) tea.Cmd {
var cmds []tea.Cmd
l.rendered = ""
l.offset = 0
l.selectedItem = ""
l.selectedItem = selectedItem
l.indexMap = make(map[string]int)
l.renderedItems = make(map[string]renderedItem)
for inx, item := range l.items {
@@ -851,7 +887,8 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
l.width = width
l.height = height
if oldWidth != width {
return l.reset()
cmd := l.reset(l.selectedItem)
return cmd
}
return nil
}

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
│Item 0
Item 1
Item 1
Item 2
Item 2
Item 2
Item 3
Item 3
Item 3
Item 3

View File

@@ -1,10 +0,0 @@
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29

View File

@@ -1,10 +0,0 @@
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
Item 27
Item 28
│Item 29

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
Item 29
Item 29
Item 29
Item 29
Item 29
Item 29
Item 29
Item 29
Item 29
│Testing

View File

@@ -1,10 +0,0 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -1,10 +0,0 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -1,10 +0,0 @@
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
Item 27
Item 28
│Item 29
Item 30

View File

@@ -1,10 +0,0 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -1,10 +0,0 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27