Detailed changes
@@ -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{}
+ opts := []list.CompletionItemOption{
+ list.WithCompletionID(cmd.ID),
+ }
if cmd.Shortcut != "" {
- opts = append(opts, completions.WithShortcut(cmd.Shortcut))
+ opts = append(
+ opts,
+ list.WithCompletionShortcut(cmd.Shortcut),
+ )
}
- commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
+ 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)
+ }
+
+ 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(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
+ 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
+}
@@ -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
+}
@@ -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
-
- // 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 renderedMsg struct{}
+
+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
}
@@ -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