charmbracelet / lipgloss

Style definitions for nice terminal layouts 👄
MIT License
8.04k stars 229 forks source link

Improper rendering of lipgloss.Table #381

Open siennathesane opened 3 weeks ago

siennathesane commented 3 weeks ago

Describe the bug

The lipgloss.Table rendering provides improper rendering in a few ways:

Setup Please complete the following information along with version numbers, if applicable.

To Reproduce Steps to reproduce the behavior:

  1. Render the table with more rows than the height of the window.

Unfortunately, I can't share the code that's rendering the table, only the table component itself. You can render the table as the only thing rendered and still observe this behavior

Source Code

Here is the table component I built. It's pulled from github.com/charmbracelet/bubbles.Table, but modified to use lipgloss.Table instead.

package table

import (
    "github.com/charmbracelet/bubbles/help"
    "github.com/charmbracelet/bubbles/key"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
    "github.com/charmbracelet/lipgloss/table"
    "github.com/jamf/k8s-hermes-cli/internal/styles"
    "github.com/jamf/k8s-hermes-cli/internal/utils"
    jamfcolor "github.com/jamf/pkg/styles"
)

// table constants
const (
    headerRow int = 0
    firstRow  int = 1
)

type Data interface {
    table.Data
    Headers() []string
}

var _ tea.Model = (*Model)(nil)

// KeyMap defines keybindings for the Model.
type KeyMap struct {
    LineUp       key.Binding
    LineDown     key.Binding
    PageUp       key.Binding
    PageDown     key.Binding
    HalfPageUp   key.Binding
    HalfPageDown key.Binding
    GotoTop      key.Binding
    GotoBottom   key.Binding
}

// ShortHelp implements the KeyMap interface.
func (km KeyMap) ShortHelp() []key.Binding {
    return []key.Binding{km.LineUp, km.LineDown}
}

// FullHelp implements the KeyMap interface.
func (km KeyMap) FullHelp() [][]key.Binding {
    return [][]key.Binding{
        {km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
        {km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
    }
}

// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
    const spacebar = " "
    return KeyMap{
        LineUp: key.NewBinding(
            key.WithKeys("up", "k"),
            key.WithHelp("↑/k", "up"),
        ),
        LineDown: key.NewBinding(
            key.WithKeys("down", "j"),
            key.WithHelp("↓/j", "down"),
        ),
        PageUp: key.NewBinding(
            key.WithKeys("b", "pgup"),
            key.WithHelp("b/pgup", "page up"),
        ),
        PageDown: key.NewBinding(
            key.WithKeys("f", "pgdown", spacebar),
            key.WithHelp("f/pgdn", "page down"),
        ),
        HalfPageUp: key.NewBinding(
            key.WithKeys("u", "ctrl+u"),
            key.WithHelp("u", "½ page up"),
        ),
        HalfPageDown: key.NewBinding(
            key.WithKeys("d", "ctrl+d"),
            key.WithHelp("d", "½ page down"),
        ),
        GotoTop: key.NewBinding(
            key.WithKeys("home", "g"),
            key.WithHelp("g/home", "go to start"),
        ),
        GotoBottom: key.NewBinding(
            key.WithKeys("end", "G"),
            key.WithHelp("G/end", "go to end"),
        ),
    }
}

type Styles struct {
    Border       lipgloss.Border
    BorderStyle  lipgloss.Style
    BorderHeader bool
    Header       lipgloss.Style
    Cell         lipgloss.Style
    Selected     lipgloss.Style
}

func DefaultStyles() Styles {
    return Styles{
        Border:       lipgloss.RoundedBorder(),
        BorderHeader: true,
        BorderStyle:  styles.BaseStyle().BorderForeground(lipgloss.Color(jamfcolor.AthensGrey)),
        Selected:     styles.BaseStyle().Bold(true).Background(lipgloss.Color(jamfcolor.Havelock)),
        Header:       styles.BaseStyle().Bold(true).Padding(1).AlignHorizontal(lipgloss.Center),
        Cell:         styles.BaseStyle().Padding(1),
    }
}

type Option func(*Model)

type Model struct {
    KeyMap KeyMap
    Help   help.Model

    yoffset int
    height  int
    data    Data
    cursor  int
    focus   bool
    styles  Styles

    table *table.Table
    start int
    end   int
}

func New(opts ...Option) *Model {
    m := &Model{
        cursor: firstRow,
        table:  table.New(),
        KeyMap: DefaultKeyMap(),
        Help:   help.New(),
        styles: DefaultStyles(),
    }
    m.Help.ShowAll = true
    for _, o := range opts {
        o(m)
    }
    return m
}

// WithHeaders sets the headers for the table.
func WithHeaders(headers ...string) Option {
    return func(m *Model) {
        m.table.Headers(headers...)
    }
}

// WithRows sets the rows for the table.
func WithRows(rows ...[]string) Option {
    return func(m *Model) {
        m.table.Rows(rows...)
    }
}

// WithData sets the table data.
func WithData(data table.Data) Option {
    return func(m *Model) {
        m.table = m.table.Data(data)
    }
}

func WithFocus() Option {
    return func(m *Model) {
        m.focus = true
    }
}

func WithStyles(styles Styles) Option {
    return func(m *Model) {
        m.styles = styles
    }
}

func WithKeyMap(km KeyMap) Option {
    return func(m *Model) {
        m.KeyMap = km
    }
}

func WithHeight(h int) Option {
    return func(m *Model) {
        m.height = h
        m.table.Height(h)
    }
}

func WithWidth(w int) Option {
    return func(m *Model) {
        m.table.Width(w)
    }
}

// SetData sets the table data.
func (m *Model) SetData(data Data) {
    m.data = data
    m.table = m.table.Data(data)
}

// SetHeaders sets the table headers.
func (m *Model) SetHeaders(headers ...string) {
    m.table = m.table.Headers(headers...)
}

func (m *Model) Cursor() int {
    return m.cursor
}

func (m *Model) SetCursor(n int) {
    m.cursor = utils.Clamp(n, 0, m.data.Rows())
}

// MoveUp moves the cursor up n rows, up to the first row.
func (m *Model) MoveUp(n int) {
    m.SetCursor(m.cursor - n)

    m.setYOffset(m.yoffset - n)
    m.table.Offset(m.yoffset)
}

// MoveDown moves the cursor down n rows, up to the last row.
func (m *Model) MoveDown(n int) {
    m.SetCursor(m.cursor + n)

    m.setYOffset(m.yoffset + n)
    m.table.Offset(m.yoffset)
}

func (m *Model) GoToBottom() {
    m.MoveDown(m.data.Rows())
}

func (m *Model) GoToTop() {
    // todo (sienna): this feels buggy
    m.MoveUp(firstRow)
}

func (m *Model) Height() int {
    return m.height
}

func (m *Model) SetHeight(h int) {
    m.height = h
    m.table = m.table.Height(h)
}

// SetWidth sets the width of the table.
func (m *Model) SetWidth(w int) {
    m.table = m.table.Width(w)
}

// Focused returns true if the table is focused.
func (m *Model) Focused() bool {
    return m.focus
}

// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
    m.focus = true
}

// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
    m.focus = false
}

func (m *Model) HelpView() string {
    return m.Help.View(m.KeyMap)
}

func (m *Model) setYOffset(n int) {
    m.yoffset = utils.Clamp(n, 0, m.data.Rows())
}

func (m *Model) Init() tea.Cmd {
    return nil
}

func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    if !m.focus {
        return m, nil
    }

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch {
        case key.Matches(msg, m.KeyMap.LineUp):
            m.MoveUp(1)
        case key.Matches(msg, m.KeyMap.LineDown):
            m.MoveDown(1)
        case key.Matches(msg, m.KeyMap.PageUp):
            m.MoveUp(m.height)
        case key.Matches(msg, m.KeyMap.PageDown):
            m.MoveDown(m.height)
        case key.Matches(msg, m.KeyMap.HalfPageUp):
            m.MoveUp(m.height / 2)
        case key.Matches(msg, m.KeyMap.HalfPageDown):
            m.MoveDown(m.height / 2)
        case key.Matches(msg, m.KeyMap.LineDown):
            m.MoveDown(1)
        case key.Matches(msg, m.KeyMap.GotoTop):
            m.GoToTop()
        case key.Matches(msg, m.KeyMap.GotoBottom):
            m.GoToBottom()
        }
    case tea.MouseMsg:
        if msg.Button == tea.MouseButtonWheelUp {
            m.MoveUp(1)
        }
        if msg.Button == tea.MouseButtonWheelDown {
            m.MoveDown(1)
        }
    }

    return m, nil
}

func (m *Model) View() string {
    m.table.StyleFunc(func(row int, col int) lipgloss.Style {
        if row == headerRow {
            return m.styles.Header
        }
        if row == m.cursor {
            return m.styles.Selected
        }
        return m.styles.Cell
    })

    return m.table.String()
}

Expected behavior

Screenshots

https://github.com/user-attachments/assets/19d90fc2-0047-4935-87e1-ae44b7897944

Additional context

I've tested several different iterations and still end up with the same generalized results.

bashbunni commented 5 days ago

Hey, I think this should be fixed by https://github.com/charmbracelet/lipgloss/pull/373 We have an open PR in bubbles as well to make it render using Lip Gloss table as well if that's of interest! https://github.com/charmbracelet/bubbles/pull/617

If you have any feedback on that one, please don't hesitate to let us know