Skip to content

Commit 7cd8e34

Browse files
authored
[TUI] Switch up table rendering (#11340)
Before we were using bubbles table, but this PR switches to using lipgloss table which support cell styling. In the previous version, we were styling cells using ansi codes - but when these ansi codes got truncated they'd cause weird rendering bugs. Handling styling at the table level works around that.
1 parent 01b2dea commit 7cd8e34

File tree

4 files changed

+227
-82
lines changed

4 files changed

+227
-82
lines changed

cli/ui/BUILD

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ go_library(
2626
"//proto:invocation_status_go_proto",
2727
"//proto:user_go_proto",
2828
"//server/util/grpc_client",
29-
"@com_github_charmbracelet_bubbles//table",
3029
"@com_github_charmbracelet_bubbles//viewport",
3130
"@com_github_charmbracelet_bubbletea//:bubbletea",
3231
"@com_github_charmbracelet_lipgloss//:lipgloss",
32+
"@com_github_charmbracelet_lipgloss//table",
3333
"@org_golang_google_grpc//metadata",
3434
"@org_golang_google_protobuf//types/known/timestamppb",
3535
],

cli/ui/format.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ func formatStatus(inv *inpb.Invocation) string {
1313
switch inv.GetInvocationStatus() {
1414
case inspb.InvocationStatus_COMPLETE_INVOCATION_STATUS:
1515
if inv.GetSuccess() {
16-
return statusPassedStyle.Render("Passed")
16+
return "Passed"
1717
}
18-
return statusFailedStyle.Render("Failed")
18+
return "Failed"
1919
case inspb.InvocationStatus_PARTIAL_INVOCATION_STATUS:
20-
return statusInProgressStyle.Render("In progress")
20+
return "In progress"
2121
case inspb.InvocationStatus_DISCONNECTED_INVOCATION_STATUS:
22-
return statusDisconnectedStyle.Render("Disconnected")
22+
return "Disconnected"
2323
default:
24-
return statusUnknownStyle.Render("Unknown")
24+
return "Unknown"
2525
}
2626
}
2727

cli/ui/list.go

Lines changed: 200 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,42 @@
11
package ui
22

33
import (
4-
"github.com/charmbracelet/bubbles/table"
54
tea "github.com/charmbracelet/bubbletea"
65
"github.com/charmbracelet/lipgloss"
6+
lgtable "github.com/charmbracelet/lipgloss/table"
77

88
inpb "github.com/buildbuddy-io/buildbuddy/proto/invocation"
99
)
1010

1111
type listModel struct {
1212
api *apiClient
1313
filters filters
14-
table table.Model
1514
invocations []*inpb.Invocation
1615
loading bool
1716
err error
1817
width int
1918
height int
19+
cursor int
20+
offset int
2021
selectedID string
2122
switchOrg bool
2223
hasOrgPicker bool
2324
}
2425

25-
func newListModel(api *apiClient, f filters, hasOrgPicker bool) listModel {
26-
columns := []table.Column{
27-
{Title: "Status", Width: 14},
28-
{Title: "User", Width: 12},
29-
{Title: "Command", Width: 8},
30-
{Title: "Pattern", Width: 30},
31-
{Title: "Branch", Width: 16},
32-
{Title: "Duration", Width: 10},
33-
{Title: "When", Width: 10},
34-
}
35-
36-
t := table.New(
37-
table.WithColumns(columns),
38-
table.WithFocused(true),
39-
table.WithHeight(20),
40-
)
41-
42-
s := table.DefaultStyles()
43-
s.Header = s.Header.
44-
BorderStyle(lipgloss.NormalBorder()).
45-
BorderForeground(colorDim).
46-
BorderBottom(true).
47-
Bold(true)
48-
s.Selected = s.Selected.
49-
Foreground(lipgloss.Color("#ffffff")).
50-
Background(lipgloss.Color("#5533cc")).
51-
Bold(false)
52-
t.SetStyles(s)
26+
type listColumnWidths struct {
27+
status int
28+
user int
29+
command int
30+
pattern int
31+
branch int
32+
duration int
33+
when int
34+
}
5335

36+
func newListModel(api *apiClient, f filters, hasOrgPicker bool) listModel {
5437
return listModel{
5538
api: api,
5639
filters: f,
57-
table: t,
5840
loading: true,
5941
hasOrgPicker: hasOrgPicker,
6042
}
@@ -71,12 +53,15 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
7153
switch msg := msg.(type) {
7254
case tea.KeyMsg:
7355
switch msg.String() {
56+
case "up", "k":
57+
m.moveCursor(-1)
58+
return m, nil
59+
case "down", "j":
60+
m.moveCursor(1)
61+
return m, nil
7462
case "enter":
75-
if sel := m.table.SelectedRow(); len(sel) > 0 {
76-
idx := m.table.Cursor()
77-
if idx >= 0 && idx < len(m.invocations) {
78-
m.selectedID = m.invocations[idx].GetInvocationId()
79-
}
63+
if m.cursor >= 0 && m.cursor < len(m.invocations) {
64+
m.selectedID = m.invocations[m.cursor].GetInvocationId()
8065
}
8166
return m, nil
8267
case "r":
@@ -96,12 +81,8 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
9681
return m, nil
9782
}
9883
m.err = nil
99-
cursor := m.table.Cursor()
10084
m.invocations = msg.invocations
101-
m.table.SetRows(m.buildRows())
102-
if cursor < len(m.invocations) {
103-
m.table.SetCursor(cursor)
104-
}
85+
m.clampCursorAndOffset()
10586
return m, nil
10687

10788
case tickMsg:
@@ -113,23 +94,96 @@ func (m listModel) Update(msg tea.Msg) (listModel, tea.Cmd) {
11394
case tea.WindowSizeMsg:
11495
m.width = msg.Width
11596
m.height = msg.Height
116-
m.table.SetHeight(msg.Height - 5)
117-
m.updateColumnWidths()
97+
m.clampCursorAndOffset()
11898
return m, nil
11999
}
120100

121-
var cmd tea.Cmd
122-
m.table, cmd = m.table.Update(msg)
123-
return m, cmd
101+
return m, nil
124102
}
125103

126-
func (m *listModel) updateColumnWidths() {
127-
if m.width < 60 {
104+
func (m *listModel) moveCursor(delta int) {
105+
if len(m.invocations) == 0 {
128106
return
129107
}
130-
// Fixed columns: Status(14) + User(12) + Command(8) + Duration(10) + When(10) = 54
131-
// Plus separators ~6
132-
fixed := 60
108+
m.cursor += delta
109+
m.clampCursorAndOffset()
110+
}
111+
112+
func (m *listModel) clampCursorAndOffset() {
113+
if len(m.invocations) == 0 {
114+
m.cursor = 0
115+
m.offset = 0
116+
return
117+
}
118+
if m.cursor < 0 {
119+
m.cursor = 0
120+
}
121+
if m.cursor >= len(m.invocations) {
122+
m.cursor = len(m.invocations) - 1
123+
}
124+
125+
visibleRows := m.visibleRows()
126+
maxOffset := len(m.invocations) - visibleRows
127+
if maxOffset < 0 {
128+
maxOffset = 0
129+
}
130+
if m.offset < 0 {
131+
m.offset = 0
132+
}
133+
if m.offset > maxOffset {
134+
m.offset = maxOffset
135+
}
136+
if m.cursor < m.offset {
137+
m.offset = m.cursor
138+
}
139+
if m.cursor >= m.offset+visibleRows {
140+
m.offset = m.cursor - visibleRows + 1
141+
}
142+
}
143+
144+
func (m listModel) visibleRows() int {
145+
rows := m.tableAreaHeight() - 2 // account for header row + header separator
146+
if rows < 1 {
147+
return 1
148+
}
149+
return rows
150+
}
151+
152+
func (m listModel) showStatusLine() bool {
153+
return (m.loading && len(m.invocations) == 0) || m.err != nil
154+
}
155+
156+
func (m listModel) tableAreaHeight() int {
157+
// title/filter line + help line are always visible.
158+
h := m.height - 2
159+
// Loading / error status line is conditionally visible.
160+
if m.showStatusLine() {
161+
h--
162+
}
163+
if h < 3 {
164+
return 3
165+
}
166+
return h
167+
}
168+
169+
func (m listModel) columnWidths() listColumnWidths {
170+
widths := listColumnWidths{
171+
status: 14,
172+
user: 12,
173+
command: 14,
174+
pattern: 30,
175+
branch: 16,
176+
duration: 10,
177+
when: 12,
178+
}
179+
180+
if m.width <= 0 {
181+
return widths
182+
}
183+
184+
// Non-resizable content widths: Status(14) + User(12) + Command(14) + Duration(10) + When(12) = 62
185+
// Pattern and Branch consume the remaining width.
186+
fixed := 62
133187
remaining := m.width - fixed
134188
patternW := remaining * 2 / 3
135189
branchW := remaining - patternW
@@ -139,35 +193,112 @@ func (m *listModel) updateColumnWidths() {
139193
if branchW < 8 {
140194
branchW = 8
141195
}
196+
widths.pattern = patternW
197+
widths.branch = branchW
142198

143-
columns := []table.Column{
144-
{Title: "Status", Width: 14},
145-
{Title: "User", Width: 12},
146-
{Title: "Command", Width: 8},
147-
{Title: "Pattern", Width: patternW},
148-
{Title: "Branch", Width: branchW},
149-
{Title: "Duration", Width: 10},
150-
{Title: "When", Width: 10},
151-
}
152-
m.table.SetColumns(columns)
199+
return widths
153200
}
154201

155-
func (m listModel) buildRows() []table.Row {
156-
rows := make([]table.Row, len(m.invocations))
202+
func (m listModel) buildRows(widths listColumnWidths) [][]string {
203+
rows := make([][]string, len(m.invocations))
157204
for i, inv := range m.invocations {
158-
rows[i] = table.Row{
205+
rows[i] = []string{
159206
formatStatus(inv),
160-
truncate(inv.GetUser(), 12),
161-
inv.GetCommand(),
162-
truncate(formatPattern(inv.GetPattern()), 30),
163-
truncate(inv.GetBranchName(), 16),
207+
truncate(inv.GetUser(), widths.user),
208+
truncate(inv.GetCommand(), widths.command),
209+
truncate(formatPattern(inv.GetPattern()), widths.pattern),
210+
truncate(inv.GetBranchName(), widths.branch),
164211
formatDuration(inv.GetDurationUsec()),
165212
formatTimeAgo(inv.GetUpdatedAtUsec()),
166213
}
167214
}
168215
return rows
169216
}
170217

218+
func (m listModel) renderTable() string {
219+
widths := m.columnWidths()
220+
allRows := m.buildRows(widths)
221+
tableHeight := m.tableAreaHeight()
222+
start := m.offset
223+
if start < 0 {
224+
start = 0
225+
}
226+
if start > len(allRows) {
227+
start = len(allRows)
228+
}
229+
end := start + (tableHeight - 2)
230+
if end < start {
231+
end = start
232+
}
233+
if end > len(allRows) {
234+
end = len(allRows)
235+
}
236+
visibleRows := allRows[start:end]
237+
238+
t := lgtable.New().
239+
Headers("Status", "User", "Command", "Pattern", "Branch", "Duration", "When").
240+
Rows(visibleRows...).
241+
Border(lipgloss.NormalBorder()).
242+
BorderTop(false).
243+
BorderBottom(false).
244+
BorderLeft(false).
245+
BorderRight(false).
246+
BorderColumn(false).
247+
BorderRow(false).
248+
BorderHeader(true).
249+
BorderStyle(lipgloss.NewStyle().Foreground(colorDim)).
250+
Wrap(false).
251+
StyleFunc(func(row, col int) lipgloss.Style {
252+
style := lipgloss.NewStyle().
253+
Width(columnWidth(widths, col)).
254+
MaxWidth(columnWidth(widths, col)).
255+
Padding(0, 1)
256+
257+
if row == lgtable.HeaderRow {
258+
return style.Bold(true)
259+
}
260+
261+
absoluteRow := start + row
262+
if absoluteRow == m.cursor {
263+
style = style.Background(lipgloss.Color("#5533cc")).Foreground(colorWhite)
264+
}
265+
266+
if row >= 0 && row < len(visibleRows) && col == 0 {
267+
style = style.Inherit(statusTextStyle(visibleRows[row][0]))
268+
}
269+
270+
return style
271+
})
272+
273+
table := t.String()
274+
frame := lipgloss.NewStyle().Height(tableHeight)
275+
if m.width > 0 {
276+
frame = frame.Width(m.width)
277+
}
278+
return frame.Render(table)
279+
}
280+
281+
func columnWidth(widths listColumnWidths, col int) int {
282+
switch col {
283+
case 0:
284+
return widths.status
285+
case 1:
286+
return widths.user
287+
case 2:
288+
return widths.command
289+
case 3:
290+
return widths.pattern
291+
case 4:
292+
return widths.branch
293+
case 5:
294+
return widths.duration
295+
case 6:
296+
return widths.when
297+
default:
298+
return 0
299+
}
300+
}
301+
171302
func (m listModel) View() string {
172303
title := titleStyle.Render("BuildBuddy Builds")
173304
filterStr := formatActiveFilters(m.filters)
@@ -186,5 +317,5 @@ func (m listModel) View() string {
186317
helpText += " • q: quit"
187318
help := helpStyle.Render(helpText)
188319

189-
return title + filterStr + status + "\n" + m.table.View() + "\n" + help
320+
return title + filterStr + status + "\n" + m.renderTable() + "\n" + help
190321
}

0 commit comments

Comments
 (0)