From dcf069ea376f2fefa3bd8d4ed7cba171bdfd5211 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 24 Jul 2025 16:27:30 +0200 Subject: [PATCH] chore: grouped list --- internal/tui/components/chat/splash/splash.go | 26 +- .../components/dialogs/commands/commands.go | 67 +++-- .../tui/components/dialogs/models/list.go | 105 ++++--- .../tui/components/dialogs/models/models.go | 28 +- .../components/dialogs/sessions/sessions.go | 2 +- internal/tui/exp/list/filterable.go | 9 +- internal/tui/exp/list/filterable_group.go | 260 ++++++++++++++++++ internal/tui/exp/list/filterable_test.go | 124 +++++---- internal/tui/exp/list/grouped.go | 99 +++++++ internal/tui/exp/list/items.go | 89 +++++- internal/tui/exp/list/list.go | 119 +++++--- ...hould_create_simple_filterable_list.golden | 6 + ...n_list_that_does_not_fits_the_items.golden | 10 - ..._the_items_and_has_multi_line_items.golden | 10 - ..._and_has_multi_line_items_backwards.golden | 10 - ...t_does_not_fits_the_items_backwards.golden | 10 - ...sitions_in_list_that_fits_the_items.golden | 5 - ..._list_that_fits_the_items_backwards.golden | 5 - ...e_appended_and_we_are_at_the_botton.golden | 10 - ...new_items_are_added_but_we_moved_up.golden | 10 - ...hight_of_an_item_above_is_increased.golden | 10 - ...hight_of_an_item_below_is_decreases.golden | 10 - ...ht_of_an_item_below_is_increased#01.golden | 10 - ...hight_of_an_item_below_is_increased.golden | 10 - 24 files changed, 733 insertions(+), 311 deletions(-) create mode 100644 internal/tui/exp/list/filterable_group.go create mode 100644 internal/tui/exp/list/grouped.go create mode 100644 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden delete mode 100644 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden delete mode 100644 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index b7291b3b59ae2bec879739e384e495776bb84f23..0e754e083ff0ef88e6f39689a577e72f894e05b5 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -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 } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index c1b96f0bac7d0b665aad77794392b7417d60457a..50a67b77be373f987849953d0d60d9773caeb752 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -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) } diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index 5a36ab736351f2c92154da997f01ba7360470d8a..a8a23874dd2b603999d231675e2f13334948b578 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -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) { diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index 795e2585760391bcd711491533a156f9b2c810ba..e09b040a52ebf911ceefc455b0892c7c9ceba754 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -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) { diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index 7822256d9afb5b8583144142acb55ea3ec287483..4e5cbdef7fdb42f4c667de7ac5bdd5066e7be4df 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -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)) } } diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index cc2d0e1264621b10efd6df03916f5ccd3e70987e..6ef6487e4d04176ed50fe0db16de14f9593e96fb 100644 --- a/internal/tui/exp/list/filterable.go +++ b/internal/tui/exp/list/filterable.go @@ -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 +} diff --git a/internal/tui/exp/list/filterable_group.go b/internal/tui/exp/list/filterable_group.go new file mode 100644 index 0000000000000000000000000000000000000000..c1b885da00f02529cb54dcc4505afe1f34807e38 --- /dev/null +++ b/internal/tui/exp/list/filterable_group.go @@ -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 +} diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 09020b5b2af7d4255b8e5954a9bcab6220d2848b..13208d393ab1086a48b06ab6e8cfd8a72a849ace 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/internal/tui/exp/list/filterable_test.go @@ -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 +} diff --git a/internal/tui/exp/list/grouped.go b/internal/tui/exp/list/grouped.go new file mode 100644 index 0000000000000000000000000000000000000000..74f58ca13cddf797dccc6a02baa0fab1b6e0c952 --- /dev/null +++ b/internal/tui/exp/list/grouped.go @@ -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() +} diff --git a/internal/tui/exp/list/items.go b/internal/tui/exp/list/items.go index 005b72048a5962559e1bac202a17c8297757c746..1c09e402352b0d354f01f551279c198c387042a0 100644 --- a/internal/tui/exp/list/items.go +++ b/internal/tui/exp/list/items.go @@ -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 +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index afcf0a2bc9c1148b559d878445d8be169ca6ea9f..c4ad464b8755394b75cd3e2e5512592bd45a9868 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -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 } diff --git a/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden new file mode 100644 index 0000000000000000000000000000000000000000..8aac1155586865e3db5a87839b9d430b419d00ec --- /dev/null +++ b/internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden @@ -0,0 +1,6 @@ +> Type to filter  +│Item 0 +Item 1 +Item 2 +Item 3 +Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden deleted file mode 100644 index 46269dd405b643eef664dafb388d2001ffacc923..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 -Item 5 -Item 6 -Item 7 -Item 8 -Item 9 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden deleted file mode 100644 index 828d986cba48a879f1e3e0c7fd9a35b70bacd52e..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 0 -Item 1 -Item 1 -Item 2 -Item 2 -Item 2 -Item 3 -Item 3 -Item 3 -Item 3 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden deleted file mode 100644 index 6e558d7a093312cf4911bbe3ffc18a6c02583cc6..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden deleted file mode 100644 index 3531c59b4121a3d85effd1e0779742f98b7b1ac7..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden deleted file mode 100644 index f6b9a64ae1d6aea57fe9c014f5d748801c3b04fd..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden +++ /dev/null @@ -1,5 +0,0 @@ -│Item 0 -Item 1 -Item 2 -Item 3 -Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden b/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden deleted file mode 100644 index f81aca7680744374be81be4e15315468d5c3db8c..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden +++ /dev/null @@ -1,5 +0,0 @@ -Item 0 -Item 1 -Item 2 -Item 3 -│Item 4 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden b/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden deleted file mode 100644 index 03dce1dac791cad0516fd70cfa5bf5d1ec73bee4..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -Item 29 -│Testing \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden deleted file mode 100644 index 77d3450cede66562f85e422c7c4199240231f11b..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -Item 27 -Item 28 -│Item 29 -Item 30 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden b/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden deleted file mode 100644 index a0ed052f256cca2d93c47364d1e719c112819d86..0000000000000000000000000000000000000000 --- a/internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden +++ /dev/null @@ -1,10 +0,0 @@ -Item 18 -Item 19 -Item 20 -Item 21 -Item 22 -Item 23 -Item 24 -Item 25 -Item 26 -│Item 27 \ No newline at end of file