Skip to content

Commit

Permalink
feat: scrollbars (#130)
Browse files Browse the repository at this point in the history
  • Loading branch information
leg100 authored Sep 22, 2024
1 parent bbbc733 commit bed7586
Show file tree
Hide file tree
Showing 17 changed files with 72 additions and 59 deletions.
Binary file modified demo/asdf_install_terraform_task_group.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/execute_asdf_install_terraform.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/filter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/infracost_output.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/logs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/modules.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/state.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/task_group.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/task_groups.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/tasks.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/workspaces.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/workspaces_with_cost.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions internal/tui/scrollbar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package tui

import (
"math"
"strings"
)

const (
ScrollbarWidth = 1

scrollbarThumb = "█"
scrollbarTrack = "░"
)

func Scrollbar(height, total, visible, offset int) string {
ratio := float64(height) / float64(total)
thumbHeight := max(1, int(math.Round(float64(visible)*ratio)))
thumbOffset := max(0, min(height-thumbHeight, int(math.Round(float64(offset)*ratio))))

return strings.TrimRight(
strings.Repeat(scrollbarTrack+"\n", thumbOffset)+
strings.Repeat(scrollbarThumb+"\n", thumbHeight)+
strings.Repeat(scrollbarTrack+"\n", max(0, height-thumbOffset-thumbHeight)),
"\n",
)
}
8 changes: 5 additions & 3 deletions internal/tui/table/dimensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@

package table

import "github.com/leg100/pug/internal/tui"

// Update column widths in-place.
//
// TODO: unit test
func (m *Model[V]) setColumnWidths() {
var (
// total available flex width initialized to total viewport width minus
// the padding on each col (2)
totalFlexWidth = m.tableWidth() - 2*len(m.cols)
// total available flex width initialized to total table width minus the
// padding on each col (2) and the scrollbar to the right
totalFlexWidth = m.width - tui.ScrollbarWidth - 2*len(m.cols)
totalFlexFactor int
flexGCD int
)
Expand Down
66 changes: 32 additions & 34 deletions internal/tui/table/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ type Model[V resource.Resource] struct {
// index of first visible row
start int

// dimensions calcs
width int
// width of table without borders
width int
// height of table without borders
height int
}

Expand Down Expand Up @@ -146,18 +147,18 @@ func (m *Model[V]) filterVisible() bool {

// setDimensions sets the dimensions of the table.
func (m *Model[V]) setDimensions(width, height int) {
m.height = height
m.width = width
// Adjust height to accomodate borders
m.height = height - 2
// Adjust width to accomodate borders
m.width = width - 2
m.setColumnWidths()

m.setStart()
}

// maxVisibleRows returns the maximum number of visible rows, i.e. the height of
// the terminal allocated to rows.
func (m *Model[V]) maxVisibleRows() int {
// Subtract two from height to accommodate borders
height := max(0, m.height-headerHeight-2)
// rowAreaHeight returns the height of the terminal allocated to rows.
func (m Model[V]) rowAreaHeight() int {
height := max(0, m.height-headerHeight)

if m.filterVisible() {
// Accommodate height of filter widget
Expand All @@ -169,19 +170,7 @@ func (m *Model[V]) maxVisibleRows() int {
// visibleRows returns the number of renderable visible rows.
func (m Model[V]) visibleRows() int {
// The number of visible rows cannot exceed the row area height.
return min(m.maxVisibleRows(), len(m.rows)-m.start)
}

// tableWidth retrieves the width available to the table, excluding its borders.
func (m *Model[V]) tableWidth() int {
// Subtract two from width to accommodate borders
return m.width - 2
}

// tableHeight retrieves the height available to the table, excluding its borders.
func (m *Model[V]) tableHeight() int {
// Subtract two from width to accommodate borders
return m.height - 2
return min(m.rowAreaHeight(), len(m.rows)-m.start)
}

// Update is the Bubble Tea update loop.
Expand All @@ -198,13 +187,13 @@ func (m Model[V]) Update(msg tea.Msg) (Model[V], tea.Cmd) {
case key.Matches(msg, keys.Navigation.LineDown):
m.MoveDown(1)
case key.Matches(msg, keys.Navigation.PageUp):
m.MoveUp(m.maxVisibleRows())
m.MoveUp(m.rowAreaHeight())
case key.Matches(msg, keys.Navigation.PageDown):
m.MoveDown(m.maxVisibleRows())
m.MoveDown(m.rowAreaHeight())
case key.Matches(msg, keys.Navigation.HalfPageUp):
m.MoveUp(m.maxVisibleRows() / 2)
m.MoveUp(m.rowAreaHeight() / 2)
case key.Matches(msg, keys.Navigation.HalfPageDown):
m.MoveDown(m.maxVisibleRows() / 2)
m.MoveDown(m.rowAreaHeight() / 2)
case key.Matches(msg, keys.Navigation.GotoTop):
m.GotoTop()
case key.Matches(msg, keys.Navigation.GotoBottom):
Expand Down Expand Up @@ -286,20 +275,29 @@ func (m Model[V]) View() string {
// Table is composed of a vertical stack of components:
// (a) optional filter widget
// (b) header
// (c) rows
// (c) rows + scrollbar
components := make([]string, 0, 1+1+m.visibleRows())
if m.filterVisible() {
components = append(components, tui.Regular.Margin(0, 1).Render(m.filter.View()))
// Add horizontal rule between filter widget and table
components = append(components, strings.Repeat("─", m.tableWidth()))
components = append(components, strings.Repeat("─", m.width))
}
components = append(components, m.headersView())
// Generate scrollbar
scrollbar := tui.Scrollbar(m.rowAreaHeight(), len(m.rows), m.visibleRows(), m.start)
// Get all the visible rows
var rows []string
for i := range m.visibleRows() {
components = append(components, m.renderRow(m.start+i))
rows = append(rows, m.renderRow(m.start+i))
}
rowarea := lipgloss.NewStyle().Width(m.width - tui.ScrollbarWidth).Render(
strings.Join(rows, "\n"),
)
// Put rows alongside the scrollbar to the right.
components = append(components, lipgloss.JoinHorizontal(lipgloss.Top, rowarea, scrollbar))
// Render table components, ensuring it is at least a min height
content := lipgloss.NewStyle().
Height(m.tableHeight()).
Height(m.height).
Render(lipgloss.JoinVertical(lipgloss.Top, components...))
// Render table metadata
var metadata string
Expand All @@ -318,7 +316,7 @@ func (m Model[V]) View() string {
var topBorder string
{
// total length of top border runes, not including corners
length := max(0, m.width-lipgloss.Width(metadata)-2)
length := max(0, m.width-lipgloss.Width(metadata))
leftLength := length / 2
rightLength := max(0, length-leftLength)
topBorder = lipgloss.JoinHorizontal(lipgloss.Left,
Expand Down Expand Up @@ -590,12 +588,12 @@ func (m *Model[V]) moveCurrentRow(n int) {
func (m *Model[V]) setStart() {
// Start index must be at least the current row index minus the max number
// of visible rows.
minimum := max(0, m.currentRowIndex-m.maxVisibleRows()+1)
minimum := max(0, m.currentRowIndex-m.rowAreaHeight()+1)
// Start index must be at most the lesser of:
// (a) the current row index, or
// (b) the number of rows minus the maximum number of visible rows (as many
// rows as possible are rendered)
maximum := max(0, min(m.currentRowIndex, len(m.rows)-m.maxVisibleRows()))
maximum := max(0, min(m.currentRowIndex, len(m.rows)-m.rowAreaHeight()))
m.start = clamp(m.start, minimum, maximum)
}

Expand Down Expand Up @@ -673,7 +671,7 @@ func (m *Model[V]) renderRow(rowIdx int) string {
// Join cells together to form a row
renderedRow := lipgloss.JoinHorizontal(lipgloss.Left, styledCells...)

// If current row or seleted rows, strip colors and apply background color
// If current row or selected rows, strip colors and apply background color
if current || selected {
renderedRow = internal.StripAnsi(renderedRow)
renderedRow = lipgloss.NewStyle().
Expand Down
31 changes: 9 additions & 22 deletions internal/tui/viewport.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,11 @@ func (m Viewport) Update(msg tea.Msg) (Viewport, tea.Cmd) {
return m, tea.Batch(cmds...)
}

// percentWidth is the width of the scroll percentage section to the
// right of the viewport
const percentWidth = 6 // 6 = 4 for xxx% + 2 for padding

func (m Viewport) View() string {
// scroll percent container occupies a fixed width section to the right of
// the viewport.
percent := Regular.
Background(ScrollPercentageBackground).
Padding(0, 1).
Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
percentContainer := Regular.
Height(m.viewport.Height).
Width(percentWidth).
AlignVertical(lipgloss.Bottom).
Render(percent)

var output string
if len(m.content) == 0 {
msg := "Awaiting output"
// TODO: make spinner non-optional
if m.spinner != nil {
msg += " " + m.spinner.View()
}
Expand All @@ -101,16 +86,18 @@ func (m Viewport) View() string {
} else {
output = m.viewport.View()
}

return lipgloss.JoinHorizontal(lipgloss.Top,
output,
percentContainer,
scrollbar := Scrollbar(
m.viewport.Height,
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset,
)
return lipgloss.JoinHorizontal(lipgloss.Top, output, scrollbar)
}

func (m *Viewport) SetDimensions(width, height int) {
width = max(0, width-percentWidth)
// If width has changed, re-rewrap existing content.
width = max(0, width-ScrollbarWidth)
// If width has changed, re-wrap existing content.
rewrap := m.viewport.Width != width
m.viewport.Width = width
m.viewport.Height = height
Expand Down

0 comments on commit bed7586

Please sign in to comment.