mirror of
https://github.com/charmbracelet/crush.git
synced 2025-08-02 05:20:46 +03:00
chore: grouped list
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
260
internal/tui/exp/list/filterable_group.go
Normal file
260
internal/tui/exp/list/filterable_group.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
99
internal/tui/exp/list/grouped.go
Normal file
99
internal/tui/exp/list/grouped.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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,10 +0,0 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
Item 5
|
||||
Item 6
|
||||
Item 7
|
||||
Item 8
|
||||
Item 9
|
||||
@@ -1,10 +0,0 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 1
|
||||
Item 2
|
||||
Item 2
|
||||
Item 2
|
||||
Item 3
|
||||
Item 3
|
||||
Item 3
|
||||
Item 3
|
||||
@@ -1,10 +0,0 @@
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
│Item 29
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 20
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
Item 27
|
||||
Item 28
|
||||
│Item 29
|
||||
@@ -1,5 +0,0 @@
|
||||
│Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
Item 4
|
||||
@@ -1,5 +0,0 @@
|
||||
Item 0
|
||||
Item 1
|
||||
Item 2
|
||||
Item 3
|
||||
│Item 4
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
Item 29
|
||||
│Testing
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 18
|
||||
Item 19
|
||||
Item 20
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
│Item 27
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 18
|
||||
Item 19
|
||||
Item 20
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
│Item 27
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
Item 27
|
||||
Item 28
|
||||
│Item 29
|
||||
Item 30
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 18
|
||||
Item 19
|
||||
Item 20
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
│Item 27
|
||||
@@ -1,10 +0,0 @@
|
||||
Item 18
|
||||
Item 19
|
||||
Item 20
|
||||
Item 21
|
||||
Item 22
|
||||
Item 23
|
||||
Item 24
|
||||
Item 25
|
||||
Item 26
|
||||
│Item 27
|
||||
Reference in New Issue
Block a user