diff --git a/list/keys.go b/list/keys.go index 33220313..641197e6 100644 --- a/list/keys.go +++ b/list/keys.go @@ -8,6 +8,8 @@ type KeyMap struct { // Keybindings used when browsing the list. CursorUp key.Binding CursorDown key.Binding + CursorLeft key.Binding + CursorRight key.Binding NextPage key.Binding PrevPage key.Binding GoToStart key.Binding @@ -42,6 +44,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + CursorLeft: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left"), + ), + CursorRight: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "right"), + ), PrevPage: key.NewBinding( key.WithKeys("left", "h", "pgup", "b", "u"), key.WithHelp("←/h/pgup", "prev page"), diff --git a/list/list.go b/list/list.go index d82a12c5..e04496b3 100644 --- a/list/list.go +++ b/list/list.go @@ -50,7 +50,10 @@ type ItemDelegate interface { // Height is the height of the list item. Height() int - // Spacing is the size of the horizontal gap between list items in cells. + // Width is the width of the list item. + Width() int + + // Spacing is the size of the vertical (1:1 ratio) and horizontal (1:2 ratio) gap between list items in cells. Spacing() int // Update is the update loop for items. All messages in the list's update @@ -145,12 +148,13 @@ func (f FilterState) String() string { // Model contains the state of this component. type Model struct { - showTitle bool - showFilter bool - showStatusBar bool - showPagination bool - showHelp bool - filteringEnabled bool + showTitle bool + showFilter bool + showStatusBar bool + showPagination bool + showHelp bool + horizontalEnabled bool + filteringEnabled bool itemNameSingular string itemNamePlural string @@ -232,6 +236,7 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model { itemNameSingular: "item", itemNamePlural: "items", filteringEnabled: true, + horizontalEnabled: false, KeyMap: DefaultKeyMap(), Filter: DefaultFilter, Styles: styles, @@ -273,6 +278,19 @@ func (m Model) FilteringEnabled() bool { return m.filteringEnabled } +// SetHorizontalEnabled enables or disables horizontal. +func (m *Model) SetHorizontalEnabled(v bool) { + m.horizontalEnabled = v + + m.updatePagination() + m.updateKeybindings() +} + +// HorizontalEnabled returns whether or not horizontal is enabled. +func (m Model) HorizontalEnabled() bool { + return m.horizontalEnabled +} + // SetShowTitle shows or hides the title bar. func (m *Model) SetShowTitle(v bool) { m.showTitle = v @@ -519,7 +537,11 @@ func (m Model) Cursor() int { // CursorUp moves the cursor up. This can also move the state to the previous // page. func (m *Model) CursorUp() { - m.cursor-- + if m.horizontalEnabled { + m.cursor -= m.columnsPerPage() + } else { + m.cursor-- + } // If we're at the start, stop if m.cursor < 0 && m.Paginator.OnFirstPage() { @@ -547,7 +569,11 @@ func (m *Model) CursorUp() { func (m *Model) CursorDown() { maxCursorIndex := m.maxCursorIndex() - m.cursor++ + if m.horizontalEnabled { + m.cursor += m.columnsPerPage() + } else { + m.cursor++ + } // We're still within bounds of the current page, so no need to do anything. if m.cursor <= maxCursorIndex { @@ -569,6 +595,78 @@ func (m *Model) CursorDown() { } } +// CursorLeft moves the cursor to the left. This can also move the state to the previous +// page. +func (m *Model) CursorLeft() { + if !m.horizontalEnabled { + return + } + + m.cursor-- + + // If we're at the start, stop + if m.cursor < 0 && m.Paginator.Page == 0 { + // if infinite scrolling is enabled, go to the last item + if m.InfiniteScrolling { + m.Paginator.Page = m.Paginator.TotalPages - 1 + m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 + return + } + + m.cursor = 0 + return + } + + // Move the cursor as normal + if m.cursor >= 0 { + return + } + + // Go to the previous page + m.Paginator.PrevPage() + m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 +} + +// CursorRight moves the cursor to the right. This can also advance the state to the +// next page. +func (m *Model) CursorRight() { + if !m.horizontalEnabled { + return + } + + itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) + + m.cursor++ + + // If we're at the end, stop + if m.cursor < itemsOnPage { + return + } + + // Go to the next page + if !m.Paginator.OnLastPage() { + m.Paginator.NextPage() + m.cursor = 0 + return + } + + // During filtering the cursor position can exceed the number of + // itemsOnPage. It's more intuitive to start the cursor at the + // topmost position when moving it down in this scenario. + if m.cursor > itemsOnPage { + m.cursor = 0 + return + } + + m.cursor = itemsOnPage - 1 + + // if infinite scrolling is enabled, go to the first item + if m.InfiniteScrolling { + m.Paginator.Page = 0 + m.cursor = 0 + } +} + // GoToStart moves to the first page, and first item on the first page. func (m *Model) GoToStart() { m.Paginator.Page = 0 @@ -736,6 +834,12 @@ func (m *Model) updateKeybindings() { case Filtering: m.KeyMap.CursorUp.SetEnabled(false) m.KeyMap.CursorDown.SetEnabled(false) + + if m.horizontalEnabled { + m.KeyMap.CursorLeft.SetEnabled(false) + m.KeyMap.CursorRight.SetEnabled(false) + } + m.KeyMap.NextPage.SetEnabled(false) m.KeyMap.PrevPage.SetEnabled(false) m.KeyMap.GoToStart.SetEnabled(false) @@ -753,6 +857,11 @@ func (m *Model) updateKeybindings() { m.KeyMap.CursorUp.SetEnabled(hasItems) m.KeyMap.CursorDown.SetEnabled(hasItems) + if m.horizontalEnabled { + m.KeyMap.CursorLeft.SetEnabled(hasItems) + m.KeyMap.CursorRight.SetEnabled(hasItems) + } + hasPages := m.Paginator.TotalPages > 1 m.KeyMap.NextPage.SetEnabled(hasPages) m.KeyMap.PrevPage.SetEnabled(hasPages) @@ -781,6 +890,7 @@ func (m *Model) updateKeybindings() { func (m *Model) updatePagination() { index := m.Index() availHeight := m.height + availWidth := m.width if m.showTitle || (m.showFilter && m.filteringEnabled) { availHeight -= lipgloss.Height(m.titleView()) @@ -795,7 +905,14 @@ func (m *Model) updatePagination() { availHeight -= lipgloss.Height(m.helpView()) } - m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) + if m.horizontalEnabled { + availRows := availHeight / (m.delegate.Height() + m.delegate.Spacing()) + availColumns := availWidth / (m.delegate.Width() + (m.delegate.Spacing() * 2)) + + m.Paginator.PerPage = max(1, availRows*availColumns) + } else { + m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) + } if pages := len(m.VisibleItems()); pages < 1 { m.Paginator.SetTotalPages(1) @@ -875,6 +992,12 @@ func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { case key.Matches(msg, m.KeyMap.CursorDown): m.CursorDown() + case key.Matches(msg, m.KeyMap.CursorLeft): + m.CursorLeft() + + case key.Matches(msg, m.KeyMap.CursorRight): + m.CursorRight() + case key.Matches(msg, m.KeyMap.PrevPage): m.Paginator.PrevPage() @@ -979,6 +1102,10 @@ func (m Model) ShortHelp() []key.Binding { m.KeyMap.CursorDown, } + if m.horizontalEnabled { + kb = append(kb, m.KeyMap.CursorLeft, m.KeyMap.CursorRight) + } + filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add the short help @@ -1018,6 +1145,10 @@ func (m Model) FullHelp() [][]key.Binding { m.KeyMap.GoToEnd, }} + if m.horizontalEnabled { + kb = append(kb, [][]key.Binding{{m.KeyMap.CursorLeft, m.KeyMap.CursorRight}}...) + } + filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add full help @@ -1052,6 +1183,7 @@ func (m Model) View() string { var ( sections []string availHeight = m.height + availWidth = m.width ) if m.showTitle || (m.showFilter && m.filteringEnabled) { @@ -1078,7 +1210,7 @@ func (m Model) View() string { availHeight -= lipgloss.Height(help) } - content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) + content := lipgloss.NewStyle().Height(availHeight).Width(availWidth).Render(m.populatedView()) sections = append(sections, content) if m.showPagination { @@ -1209,6 +1341,16 @@ func (m Model) paginationView() string { return style.Render(s) } +// rowsPerPage returns the amount of rows that fits into current height. +func (m Model) rowsPerPage() int { + return m.height / (m.delegate.Height() + m.delegate.Spacing()) +} + +// columnsPerPage returns the amount of columns that fits into current width. +func (m Model) columnsPerPage() int { + return m.width / (m.delegate.Width() + (m.delegate.Spacing() * 2)) +} + func (m Model) populatedView() string { items := m.VisibleItems() @@ -1224,26 +1366,67 @@ func (m Model) populatedView() string { if len(items) > 0 { start, end := m.Paginator.GetSliceBounds(len(items)) - docs := items[start:end] + itms := items[start:end] - for i, item := range docs { - m.delegate.Render(&b, m, i+start, item) - if i != len(docs)-1 { - fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) + if m.horizontalEnabled { + rowsPerPage := m.rowsPerPage() + columnsPerPage := m.columnsPerPage() + + var br strings.Builder + + i := 0 + + for range rowsPerPage { + var r string + + for range columnsPerPage { + br.Reset() + + // handle last page + if len(itms) < rowsPerPage*columnsPerPage { + if i < len(itms) { + m.delegate.Render(&br, m, i+start, itms[i]) + } else { + fmt.Fprint(&br, " ") + } + } else { + // render items + m.delegate.Render(&br, m, i+start, itms[i]) + } + + if i%columnsPerPage == 0 { + r = lipgloss.JoinHorizontal(lipgloss.Left, r, br.String()) + } else { + r = lipgloss.JoinHorizontal(lipgloss.Left, r, strings.Repeat(" ", m.delegate.Spacing()*2), br.String()) + } + + i++ + } + + fmt.Fprint(&b, r, "\n") + } + } else { + for i, item := range itms { + m.delegate.Render(&b, m, i+start, item) + if i != len(itms)-1 { + fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) + } } } } - // If there aren't enough items to fill up this page (always the last page) - // then we need to add some newlines to fill up the space where items would - // have been. - itemsOnPage := m.Paginator.ItemsOnPage(len(items)) - if itemsOnPage < m.Paginator.PerPage { - n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) - if len(items) == 0 { - n -= m.delegate.Height() - 1 + if !m.horizontalEnabled { + // If there aren't enough items to fill up this page (always the last page) + // then we need to add some newlines to fill up the space where items would + // have been. + itemsOnPage := m.Paginator.ItemsOnPage(len(items)) + if itemsOnPage < m.Paginator.PerPage { + n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) + if len(items) == 0 { + n -= m.delegate.Height() - 1 + } + fmt.Fprint(&b, strings.Repeat("\n", n)) } - fmt.Fprint(&b, strings.Repeat("\n", n)) } return b.String() diff --git a/list/list_test.go b/list/list_test.go index 2627e5b1..8ca17abb 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -17,6 +17,7 @@ func (i item) FilterValue() string { return string(i) } type itemDelegate struct{} func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Width() int { return 8 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m Model, index int, listItem Item) { @@ -135,3 +136,139 @@ func TestSetFilterState(t *testing.T) { t.Fatalf("Error: expected view to contain '%s'", expected) } } + +func TestHorizontalEnabled(t *testing.T) { + items := make([]Item, 20) + for i := range items { + items[i] = item(fmt.Sprintf("item %d", i+1)) + } + + // Create a list with enough height for one row, but enough width for multiple columns + // Delegate height is 1, spacing 0. So 1 item height + 0 spacing = 1 row height per item. + // A height of 10 means 10 rows fit vertically. + // A width of 20, with itemDelegate width 8, spacing 0. + // 8 width + (0*2) spacing = 8 width per item. + // 20 width / 8 per item = 2.5 columns, so 2 columns fit. + list := New(items, itemDelegate{}, 20, 10) + + // Simplify testing + list.SetShowPagination(false) + list.SetShowHelp(false) + list.SetShowStatusBar(false) + list.SetShowFilter(false) + list.SetShowTitle(false) + + t.Run("Vertical Layout", func(t *testing.T) { + list.SetHorizontalEnabled(false) + + // Expect 10 items per page (height 10 / itemHeight 1) + if list.Paginator.PerPage != 10 { + t.Errorf("Expected 10 items per page in vertical layout, got %d", list.Paginator.PerPage) + } + if list.Paginator.TotalPages != 2 { // 20 items / 10 per page = 2 pages + t.Errorf("Expected 2 total pages in vertical layout, got %d", list.Paginator.TotalPages) + } + + // CursorDown should move to the next row (next item) + list.cursor = 0 + list.CursorDown() + if list.cursor != 1 { + t.Errorf("Expected cursor to be 1 after CursorDown in vertical, got %d", list.cursor) + } + + // CursorUp should move to the previous row (previous item) + list.CursorUp() + if list.cursor != 0 { + t.Errorf("Expected cursor to be 0 after CursorUp in vertical, got %d", list.cursor) + } + + // CursorLeft/Right should not move the cursor + list.CursorLeft() + if list.cursor != 0 { + t.Errorf("Expected cursor to be 0 after CursorLeft in vertical, got %d", list.cursor) + } + list.CursorRight() + if list.cursor != 0 { + t.Errorf("Expected cursor to be 0 after CursorRight in vertical, got %d", list.cursor) + } + }) + + t.Run("Horizontal Layout", func(t *testing.T) { + list.SetHorizontalEnabled(true) + + // Expected 2 columns per page (width 20 / itemWidth 8) + // Expected 10 rows per page (height 10 / itemHeight 1) + // PerPage = 2 columns * 10 rows = 20 items per page + if list.Paginator.PerPage != 20 { + t.Errorf("Expected 20 items per page in horizontal layout, got %d", list.Paginator.PerPage) + } + if list.Paginator.TotalPages != 1 { // 20 items / 20 per page = 1 page + t.Errorf("Expected 1 total page in horizontal layout, got %d", list.Paginator.TotalPages) + } + + // CursorDown should move down by a full column width (2 items) + list.cursor = 0 + list.CursorDown() + if list.cursor != 2 { // Moved to the item in the next "row" within the horizontal flow + t.Errorf("Expected cursor to be 2 after CursorDown in horizontal, got %d", list.cursor) + } + + // CursorUp should move up by a full column width (2 items) + list.CursorUp() + if list.cursor != 0 { + t.Errorf("Expected cursor to be 0 after CursorUp in horizontal, got %d", list.cursor) + } + + // CursorRight should move to the next item + list.CursorRight() + if list.cursor != 1 { + t.Errorf("Expected cursor to be 1 after CursorRight in horizontal, got %d", list.cursor) + } + + // CursorLeft should move to the previous item + list.CursorLeft() + if list.cursor != 0 { + t.Errorf("Expected cursor to be 0 after CursorLeft in horizontal, got %d", list.cursor) + } + + // Test moving to next page with CursorRight if infinite scrolling is enabled + list.InfiniteScrolling = true + list.SetItems(make([]Item, 30)) // More items to create multiple pages horizontally + list.SetSize(20, 10) // Recalculate pagination + + // Should still be 2 columns * 10 rows = 20 items per page + if list.Paginator.PerPage != 20 { + t.Errorf("Expected 20 items per page for 30 items, got %d", list.Paginator.PerPage) + } + if list.Paginator.TotalPages != 2 { // 30 items / 20 per page = 2 pages + t.Errorf("Expected 2 total pages for 30 items, got %d", list.Paginator.TotalPages) + } + + list.Paginator.Page = 0 + list.cursor = 19 // Last item on the first page + + list.CursorRight() // Move past the end of the page + if list.Paginator.Page != 1 || list.cursor != 0 { + t.Errorf("Expected to move to page 1, cursor 0, but got page %d, cursor %d", list.Paginator.Page, list.cursor) + } + + list.cursor = 9 // On the second row of the first page + list.Paginator.Page = 0 + + list.CursorDown() + if list.cursor != 11 { // 9 + 2 (columns) + t.Errorf("Expected cursor to be 11, got %d", list.cursor) + } + + // Test moving to previous page with CursorLeft if infinite scrolling is enabled + list.Paginator.Page = 1 + list.cursor = 0 // First item on the second page + + list.CursorLeft() // Move before the start of the page + if list.Paginator.Page != 0 || list.cursor != 19 { + t.Errorf("Expected to move to page 0, cursor 19, but got page %d, cursor %d", list.Paginator.Page, list.cursor) + } + + list.InfiniteScrolling = false + }) +}