Skip to content

Commit d06b472

Browse files
committed
feat(horizontal layout): add horizontal grid view
1 parent ff8b5a8 commit d06b472

File tree

3 files changed

+355
-25
lines changed

3 files changed

+355
-25
lines changed

list/keys.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ type KeyMap struct {
88
// Keybindings used when browsing the list.
99
CursorUp key.Binding
1010
CursorDown key.Binding
11+
CursorLeft key.Binding
12+
CursorRight key.Binding
1113
NextPage key.Binding
1214
PrevPage key.Binding
1315
GoToStart key.Binding
@@ -42,6 +44,14 @@ func DefaultKeyMap() KeyMap {
4244
key.WithKeys("down", "j"),
4345
key.WithHelp("↓/j", "down"),
4446
),
47+
CursorLeft: key.NewBinding(
48+
key.WithKeys("left", "h"),
49+
key.WithHelp("←/h", "left"),
50+
),
51+
CursorRight: key.NewBinding(
52+
key.WithKeys("right", "l"),
53+
key.WithHelp("→/l", "right"),
54+
),
4555
PrevPage: key.NewBinding(
4656
key.WithKeys("left", "h", "pgup", "b", "u"),
4757
key.WithHelp("←/h/pgup", "prev page"),

list/list.go

Lines changed: 208 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ type ItemDelegate interface {
5050
// Height is the height of the list item.
5151
Height() int
5252

53-
// Spacing is the size of the horizontal gap between list items in cells.
53+
// Width is the width of the list item.
54+
Width() int
55+
56+
// Spacing is the size of the vertical (1:1 ratio) and horizontal (1:2 ratio) gap between list items in cells.
5457
Spacing() int
5558

5659
// Update is the update loop for items. All messages in the list's update
@@ -145,12 +148,13 @@ func (f FilterState) String() string {
145148

146149
// Model contains the state of this component.
147150
type Model struct {
148-
showTitle bool
149-
showFilter bool
150-
showStatusBar bool
151-
showPagination bool
152-
showHelp bool
153-
filteringEnabled bool
151+
showTitle bool
152+
showFilter bool
153+
showStatusBar bool
154+
showPagination bool
155+
showHelp bool
156+
horizontalEnabled bool
157+
filteringEnabled bool
154158

155159
itemNameSingular string
156160
itemNamePlural string
@@ -232,6 +236,7 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
232236
itemNameSingular: "item",
233237
itemNamePlural: "items",
234238
filteringEnabled: true,
239+
horizontalEnabled: false,
235240
KeyMap: DefaultKeyMap(),
236241
Filter: DefaultFilter,
237242
Styles: styles,
@@ -273,6 +278,19 @@ func (m Model) FilteringEnabled() bool {
273278
return m.filteringEnabled
274279
}
275280

281+
// SetHorizontalEnabled enables or disables horizontal.
282+
func (m *Model) SetHorizontalEnabled(v bool) {
283+
m.horizontalEnabled = v
284+
285+
m.updatePagination()
286+
m.updateKeybindings()
287+
}
288+
289+
// HorizontalEnabled returns whether or not horizontal is enabled.
290+
func (m Model) HorizontalEnabled() bool {
291+
return m.horizontalEnabled
292+
}
293+
276294
// SetShowTitle shows or hides the title bar.
277295
func (m *Model) SetShowTitle(v bool) {
278296
m.showTitle = v
@@ -519,7 +537,11 @@ func (m Model) Cursor() int {
519537
// CursorUp moves the cursor up. This can also move the state to the previous
520538
// page.
521539
func (m *Model) CursorUp() {
522-
m.cursor--
540+
if m.horizontalEnabled {
541+
m.cursor -= m.columnsPerPage()
542+
} else {
543+
m.cursor--
544+
}
523545

524546
// If we're at the start, stop
525547
if m.cursor < 0 && m.Paginator.OnFirstPage() {
@@ -547,7 +569,11 @@ func (m *Model) CursorUp() {
547569
func (m *Model) CursorDown() {
548570
maxCursorIndex := m.maxCursorIndex()
549571

550-
m.cursor++
572+
if m.horizontalEnabled {
573+
m.cursor += m.columnsPerPage()
574+
} else {
575+
m.cursor++
576+
}
551577

552578
// We're still within bounds of the current page, so no need to do anything.
553579
if m.cursor <= maxCursorIndex {
@@ -569,6 +595,78 @@ func (m *Model) CursorDown() {
569595
}
570596
}
571597

598+
// CursorLeft moves the cursor to the left. This can also move the state to the previous
599+
// page.
600+
func (m *Model) CursorLeft() {
601+
if !m.horizontalEnabled {
602+
return
603+
}
604+
605+
m.cursor--
606+
607+
// If we're at the start, stop
608+
if m.cursor < 0 && m.Paginator.Page == 0 {
609+
// if infinite scrolling is enabled, go to the last item
610+
if m.InfiniteScrolling {
611+
m.Paginator.Page = m.Paginator.TotalPages - 1
612+
m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1
613+
return
614+
}
615+
616+
m.cursor = 0
617+
return
618+
}
619+
620+
// Move the cursor as normal
621+
if m.cursor >= 0 {
622+
return
623+
}
624+
625+
// Go to the previous page
626+
m.Paginator.PrevPage()
627+
m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1
628+
}
629+
630+
// CursorRight moves the cursor to the right. This can also advance the state to the
631+
// next page.
632+
func (m *Model) CursorRight() {
633+
if !m.horizontalEnabled {
634+
return
635+
}
636+
637+
itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems()))
638+
639+
m.cursor++
640+
641+
// If we're at the end, stop
642+
if m.cursor < itemsOnPage {
643+
return
644+
}
645+
646+
// Go to the next page
647+
if !m.Paginator.OnLastPage() {
648+
m.Paginator.NextPage()
649+
m.cursor = 0
650+
return
651+
}
652+
653+
// During filtering the cursor position can exceed the number of
654+
// itemsOnPage. It's more intuitive to start the cursor at the
655+
// topmost position when moving it down in this scenario.
656+
if m.cursor > itemsOnPage {
657+
m.cursor = 0
658+
return
659+
}
660+
661+
m.cursor = itemsOnPage - 1
662+
663+
// if infinite scrolling is enabled, go to the first item
664+
if m.InfiniteScrolling {
665+
m.Paginator.Page = 0
666+
m.cursor = 0
667+
}
668+
}
669+
572670
// GoToStart moves to the first page, and first item on the first page.
573671
func (m *Model) GoToStart() {
574672
m.Paginator.Page = 0
@@ -736,6 +834,12 @@ func (m *Model) updateKeybindings() {
736834
case Filtering:
737835
m.KeyMap.CursorUp.SetEnabled(false)
738836
m.KeyMap.CursorDown.SetEnabled(false)
837+
838+
if m.horizontalEnabled {
839+
m.KeyMap.CursorLeft.SetEnabled(false)
840+
m.KeyMap.CursorRight.SetEnabled(false)
841+
}
842+
739843
m.KeyMap.NextPage.SetEnabled(false)
740844
m.KeyMap.PrevPage.SetEnabled(false)
741845
m.KeyMap.GoToStart.SetEnabled(false)
@@ -753,6 +857,11 @@ func (m *Model) updateKeybindings() {
753857
m.KeyMap.CursorUp.SetEnabled(hasItems)
754858
m.KeyMap.CursorDown.SetEnabled(hasItems)
755859

860+
if m.horizontalEnabled {
861+
m.KeyMap.CursorLeft.SetEnabled(hasItems)
862+
m.KeyMap.CursorRight.SetEnabled(hasItems)
863+
}
864+
756865
hasPages := m.Paginator.TotalPages > 1
757866
m.KeyMap.NextPage.SetEnabled(hasPages)
758867
m.KeyMap.PrevPage.SetEnabled(hasPages)
@@ -781,6 +890,7 @@ func (m *Model) updateKeybindings() {
781890
func (m *Model) updatePagination() {
782891
index := m.Index()
783892
availHeight := m.height
893+
availWidth := m.width
784894

785895
if m.showTitle || (m.showFilter && m.filteringEnabled) {
786896
availHeight -= lipgloss.Height(m.titleView())
@@ -795,7 +905,14 @@ func (m *Model) updatePagination() {
795905
availHeight -= lipgloss.Height(m.helpView())
796906
}
797907

798-
m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing()))
908+
if m.horizontalEnabled {
909+
availRows := availHeight / (m.delegate.Height() + m.delegate.Spacing())
910+
availColumns := availWidth / (m.delegate.Width() + (m.delegate.Spacing() * 2))
911+
912+
m.Paginator.PerPage = max(1, availRows*availColumns)
913+
} else {
914+
m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing()))
915+
}
799916

800917
if pages := len(m.VisibleItems()); pages < 1 {
801918
m.Paginator.SetTotalPages(1)
@@ -875,6 +992,12 @@ func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd {
875992
case key.Matches(msg, m.KeyMap.CursorDown):
876993
m.CursorDown()
877994

995+
case key.Matches(msg, m.KeyMap.CursorLeft):
996+
m.CursorLeft()
997+
998+
case key.Matches(msg, m.KeyMap.CursorRight):
999+
m.CursorRight()
1000+
8781001
case key.Matches(msg, m.KeyMap.PrevPage):
8791002
m.Paginator.PrevPage()
8801003

@@ -979,6 +1102,10 @@ func (m Model) ShortHelp() []key.Binding {
9791102
m.KeyMap.CursorDown,
9801103
}
9811104

1105+
if m.horizontalEnabled {
1106+
kb = append(kb, m.KeyMap.CursorLeft, m.KeyMap.CursorRight)
1107+
}
1108+
9821109
filtering := m.filterState == Filtering
9831110

9841111
// If the delegate implements the help.KeyMap interface add the short help
@@ -1018,6 +1145,10 @@ func (m Model) FullHelp() [][]key.Binding {
10181145
m.KeyMap.GoToEnd,
10191146
}}
10201147

1148+
if m.horizontalEnabled {
1149+
kb = append(kb, [][]key.Binding{{m.KeyMap.CursorLeft, m.KeyMap.CursorRight}}...)
1150+
}
1151+
10211152
filtering := m.filterState == Filtering
10221153

10231154
// If the delegate implements the help.KeyMap interface add full help
@@ -1052,6 +1183,7 @@ func (m Model) View() string {
10521183
var (
10531184
sections []string
10541185
availHeight = m.height
1186+
availWidth = m.width
10551187
)
10561188

10571189
if m.showTitle || (m.showFilter && m.filteringEnabled) {
@@ -1078,7 +1210,7 @@ func (m Model) View() string {
10781210
availHeight -= lipgloss.Height(help)
10791211
}
10801212

1081-
content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView())
1213+
content := lipgloss.NewStyle().Height(availHeight).Width(availWidth).Render(m.populatedView())
10821214
sections = append(sections, content)
10831215

10841216
if m.showPagination {
@@ -1209,6 +1341,16 @@ func (m Model) paginationView() string {
12091341
return style.Render(s)
12101342
}
12111343

1344+
// rowsPerPage returns the amount of rows that fits into current height.
1345+
func (m Model) rowsPerPage() int {
1346+
return m.height / (m.delegate.Height() + m.delegate.Spacing())
1347+
}
1348+
1349+
// columnsPerPage returns the amount of columns that fits into current width.
1350+
func (m Model) columnsPerPage() int {
1351+
return m.width / (m.delegate.Width() + (m.delegate.Spacing() * 2))
1352+
}
1353+
12121354
func (m Model) populatedView() string {
12131355
items := m.VisibleItems()
12141356

@@ -1224,26 +1366,67 @@ func (m Model) populatedView() string {
12241366

12251367
if len(items) > 0 {
12261368
start, end := m.Paginator.GetSliceBounds(len(items))
1227-
docs := items[start:end]
1369+
itms := items[start:end]
12281370

1229-
for i, item := range docs {
1230-
m.delegate.Render(&b, m, i+start, item)
1231-
if i != len(docs)-1 {
1232-
fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1))
1371+
if m.horizontalEnabled {
1372+
rowsPerPage := m.rowsPerPage()
1373+
columnsPerPage := m.columnsPerPage()
1374+
1375+
var br strings.Builder
1376+
1377+
i := 0
1378+
1379+
for range rowsPerPage {
1380+
var r string
1381+
1382+
for range columnsPerPage {
1383+
br.Reset()
1384+
1385+
// handle last page
1386+
if len(itms) < rowsPerPage*columnsPerPage {
1387+
if i < len(itms) {
1388+
m.delegate.Render(&br, m, i+start, itms[i])
1389+
} else {
1390+
fmt.Fprint(&br, " ")
1391+
}
1392+
} else {
1393+
// render items
1394+
m.delegate.Render(&br, m, i+start, itms[i])
1395+
}
1396+
1397+
if i%columnsPerPage == 0 {
1398+
r = lipgloss.JoinHorizontal(lipgloss.Left, r, br.String())
1399+
} else {
1400+
r = lipgloss.JoinHorizontal(lipgloss.Left, r, strings.Repeat(" ", m.delegate.Spacing()*2), br.String())
1401+
}
1402+
1403+
i++
1404+
}
1405+
1406+
fmt.Fprint(&b, r, "\n")
1407+
}
1408+
} else {
1409+
for i, item := range itms {
1410+
m.delegate.Render(&b, m, i+start, item)
1411+
if i != len(itms)-1 {
1412+
fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1))
1413+
}
12331414
}
12341415
}
12351416
}
12361417

1237-
// If there aren't enough items to fill up this page (always the last page)
1238-
// then we need to add some newlines to fill up the space where items would
1239-
// have been.
1240-
itemsOnPage := m.Paginator.ItemsOnPage(len(items))
1241-
if itemsOnPage < m.Paginator.PerPage {
1242-
n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing())
1243-
if len(items) == 0 {
1244-
n -= m.delegate.Height() - 1
1418+
if !m.horizontalEnabled {
1419+
// If there aren't enough items to fill up this page (always the last page)
1420+
// then we need to add some newlines to fill up the space where items would
1421+
// have been.
1422+
itemsOnPage := m.Paginator.ItemsOnPage(len(items))
1423+
if itemsOnPage < m.Paginator.PerPage {
1424+
n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing())
1425+
if len(items) == 0 {
1426+
n -= m.delegate.Height() - 1
1427+
}
1428+
fmt.Fprint(&b, strings.Repeat("\n", n))
12451429
}
1246-
fmt.Fprint(&b, strings.Repeat("\n", n))
12471430
}
12481431

12491432
return b.String()

0 commit comments

Comments
 (0)