Files
2025-07-13 17:50:08 -06:00

602 lines
15 KiB
Go

package table
import (
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
// getTerminalSize returns the terminal width and height
func getTerminalSize() (width, height int) {
// Try to get terminal size from stdout
if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
return w, h
}
// Fallback to reasonable defaults
return 120, 30
}
// TableStyle defines the visual styling for tables
type TableStyle struct {
Header lipgloss.Style
Cell lipgloss.Style
Border lipgloss.Style
Separator string
}
// PlainTableStyle returns a plain, boring table style with no colors
func PlainTableStyle() TableStyle {
return TableStyle{
Header: lipgloss.NewStyle().
Bold(true).
PaddingLeft(1).
PaddingRight(1),
Cell: lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1),
Border: lipgloss.NewStyle(),
Separator: "|",
}
}
// StyledTableStyle returns a colorful, styled table
func StyledTableStyle() TableStyle {
return TableStyle{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
PaddingLeft(1).
PaddingRight(1),
Cell: lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1),
Border: lipgloss.NewStyle().
Border(lipgloss.NormalBorder()),
Separator: "|",
}
}
// DefaultTableStyle returns a plain table styling (for users who hate colors)
func DefaultTableStyle() TableStyle {
return PlainTableStyle()
}
// Table represents a simple table renderer using lipgloss
type Table struct {
headers []string
rows [][]string
style TableStyle
autoWrap bool
alignment []lipgloss.Position
columnWidth []int
}
// NewTable creates a new table with default (plain) styling
func NewTable() *Table {
return &Table{
style: DefaultTableStyle(),
autoWrap: true,
alignment: []lipgloss.Position{},
}
}
// NewPlainTable creates a new table with plain styling (no colors)
func NewPlainTable() *Table {
return &Table{
style: PlainTableStyle(),
autoWrap: true,
alignment: []lipgloss.Position{},
}
}
// NewStyledTable creates a new table with colorful styling
func NewStyledTable() *Table {
return &Table{
style: StyledTableStyle(),
autoWrap: true,
alignment: []lipgloss.Position{},
}
}
// NewTableWithStyle creates a new table with custom styling
func NewTableWithStyle(style TableStyle) *Table {
return &Table{
style: style,
autoWrap: true,
alignment: []lipgloss.Position{},
}
}
// SetHeaders sets the table headers
func (t *Table) SetHeaders(headers []string) {
t.headers = headers
// Initialize alignment slice if not set
if len(t.alignment) == 0 {
t.alignment = make([]lipgloss.Position, len(headers))
for i := range t.alignment {
t.alignment[i] = lipgloss.Left
}
}
}
// SetAlignment sets the alignment for each column
func (t *Table) SetAlignment(align lipgloss.Position) {
for i := range t.alignment {
t.alignment[i] = align
}
}
// AppendRow adds a single row to the table
func (t *Table) AppendRow(row []string) {
t.rows = append(t.rows, row)
}
// AppendBulk adds multiple rows to the table
func (t *Table) AppendBulk(rows [][]string) {
t.rows = append(t.rows, rows...)
}
// SetAutoWrapText sets whether text should auto-wrap
func (t *Table) SetAutoWrapText(wrap bool) {
t.autoWrap = wrap
}
// SetStyle changes the table style
func (t *Table) SetStyle(style TableStyle) {
t.style = style
}
// UsePlainStyle switches to plain (boring) styling
func (t *Table) UsePlainStyle() {
t.style = PlainTableStyle()
}
// UseStyledStyle switches to colorful styling
func (t *Table) UseStyledStyle() {
t.style = StyledTableStyle()
}
// calculateColumnWidths determines the optimal width for each column
func (t *Table) calculateColumnWidths() {
if len(t.headers) == 0 {
return
}
t.columnWidth = make([]int, len(t.headers))
// Start with header widths
for i, header := range t.headers {
t.columnWidth[i] = len(header)
}
// Check all rows for maximum width
for _, row := range t.rows {
for i, cell := range row {
if i < len(t.columnWidth) && len(cell) > t.columnWidth[i] {
t.columnWidth[i] = len(cell)
}
}
}
// Add padding for better appearance
for i := range t.columnWidth {
t.columnWidth[i] += 2 // Account for padding
}
}
// renderRow renders a single row with proper alignment and styling
func (t *Table) renderRow(row []string, isHeader bool) string {
var cells []string
for i, cell := range row {
width := t.columnWidth[i]
align := lipgloss.Left
if i < len(t.alignment) {
align = t.alignment[i]
}
var style lipgloss.Style
if isHeader {
style = t.style.Header
} else {
style = t.style.Cell
}
// Apply width and alignment
renderedCell := style.Width(width).Align(align).Render(cell)
cells = append(cells, renderedCell)
}
return strings.Join(cells, t.style.Separator)
}
// Render generates the complete table as a string
func (t *Table) Render() string {
if len(t.headers) == 0 && len(t.rows) == 0 {
return ""
}
t.calculateColumnWidths()
var output strings.Builder
// Render headers if they exist
if len(t.headers) > 0 {
headerRow := t.renderRow(t.headers, true)
output.WriteString(headerRow)
output.WriteString("\n")
// Add separator line under headers
var separators []string
for _, width := range t.columnWidth {
separators = append(separators, strings.Repeat("-", width))
}
separatorRow := strings.Join(separators, "+")
output.WriteString(separatorRow)
output.WriteString("\n")
}
// Render data rows
for _, row := range t.rows {
dataRow := t.renderRow(row, false)
output.WriteString(dataRow)
output.WriteString("\n")
}
return strings.TrimRight(output.String(), "\n")
}
// RenderToWriter writes the table output to any writer (maintains tablewriter compatibility)
func (t *Table) RenderToWriter(w fmt.State, verb rune) {
fmt.Fprint(w, t.Render())
}
// NewStringBuilderTable creates a table that writes to a strings.Builder
func NewStringBuilderTable(sb *strings.Builder) *Table {
return NewTable()
}
// Custom table that writes directly to strings.Builder
type StringBuilderTable struct {
*Table
builder *strings.Builder
}
// NewStringBuilderTableWriter creates a table that writes to a strings.Builder
func NewStringBuilderTableWriter(sb *strings.Builder) *StringBuilderTable {
return &StringBuilderTable{
Table: NewTable(),
builder: sb,
}
}
// SetHeader sets the headers and maintains compatibility
func (sbt *StringBuilderTable) SetHeader(headers []string) {
sbt.Table.SetHeaders(headers)
}
// SetBorders is a no-op for compatibility
func (sbt *StringBuilderTable) SetBorders(border any) {
// No-op for compatibility
}
// SetCenterSeparator sets the separator
func (sbt *StringBuilderTable) SetCenterSeparator(sep string) {
sbt.style.Separator = sep
}
// SetAlignment sets alignment
func (sbt *StringBuilderTable) SetAlignment(align int) {
var lipglossAlign lipgloss.Position
switch align {
case 1: // ALIGN_LEFT
lipglossAlign = lipgloss.Left
case 2: // ALIGN_CENTER
lipglossAlign = lipgloss.Center
case 3: // ALIGN_RIGHT
lipglossAlign = lipgloss.Right
default:
lipglossAlign = lipgloss.Left
}
sbt.Table.SetAlignment(lipglossAlign)
}
// Render writes the table directly to the strings.Builder
func (sbt *StringBuilderTable) Render() {
output := sbt.Table.Render()
sbt.builder.WriteString(output)
}
// BubbleTable wraps the advanced bubbles/table component
type BubbleTable struct {
table table.Model
data [][]string
styled bool
}
// NewBubbleTable creates a new interactive table using bubbles/table with terminal-aware sizing
func NewBubbleTable(headers []string, styled bool) *BubbleTable {
termWidth, termHeight := getTerminalSize()
// Calculate initial column widths based on terminal width
availableWidth := termWidth - 4 // Account for borders and padding
defaultColWidth := availableWidth / len(headers)
if defaultColWidth < 8 {
defaultColWidth = 8
} else if defaultColWidth > 25 {
defaultColWidth = 25
}
columns := make([]table.Column, len(headers))
for i, header := range headers {
columns[i] = table.Column{
Title: header,
Width: defaultColWidth,
}
}
// Always use styled tables for BubbleTable - it looks much better
tableStyle := table.DefaultStyles()
if styled {
// Enhanced colorful styling
tableStyle.Header = tableStyle.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4"))
tableStyle.Selected = tableStyle.Selected.
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#F25D94")).
Bold(true)
} else {
// Clean but styled appearance
tableStyle.Header = tableStyle.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
tableStyle.Selected = tableStyle.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
}
// Calculate table height to use most of the terminal
// Account for: title (2 lines) + filter area (2 lines) + help (1 line) + margins (2 lines) = 7 lines
tableHeight := max(termHeight-7, 5) // 5 is minimum height for usability
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(tableHeight),
// Don't force width - let it size based on content
)
t.SetStyles(tableStyle)
return &BubbleTable{
table: t,
styled: styled,
}
}
// SetData sets the table data and auto-adjusts column widths based on content
func (bt *BubbleTable) SetData(data [][]string) {
bt.data = data
// Calculate optimal column widths based on content (not terminal width)
columns := bt.table.Columns()
if len(data) > 0 && len(columns) > 0 {
for colIdx := range columns {
maxWidth := len(columns[colIdx].Title) // Start with header width
for _, row := range data {
if colIdx < len(row) {
cellWidth := len(row[colIdx])
if cellWidth > maxWidth {
maxWidth = cellWidth
}
}
}
// Apply only reasonable limits, not terminal width constraints
if maxWidth > 50 {
maxWidth = 50 // Cap very long content
} else if maxWidth < 8 {
maxWidth = 8 // Minimum for readability
}
columns[colIdx].Width = maxWidth
}
bt.table.SetColumns(columns)
}
rows := make([]table.Row, len(data))
for i, row := range data {
rows[i] = table.Row(row)
}
bt.table.SetRows(rows)
}
// AppendData adds rows to the table
func (bt *BubbleTable) AppendData(data [][]string) {
bt.data = append(bt.data, data...)
bt.SetData(bt.data)
}
// GetModel returns the underlying bubbles/table model for use in bubbletea apps
func (bt *BubbleTable) GetModel() table.Model {
return bt.table
}
// RenderStatic renders the table as a static string (non-interactive)
func (bt *BubbleTable) RenderStatic() string {
return bt.table.View()
}
// InteractiveTableModel implements tea.Model for interactive table display
type InteractiveTableModel struct {
table *BubbleTable
originalData [][]string
filteredData [][]string
filterMode bool
filterText string
headers []string
title string
}
// NewInteractiveTable creates a new interactive table model
func NewInteractiveTable(headers []string, data [][]string, styled bool) *InteractiveTableModel {
return NewInteractiveTableWithTitle("📱 iOS Device List", headers, data, styled)
}
// NewInteractiveTableWithTitle creates a new interactive table model with custom title
func NewInteractiveTableWithTitle(title string, headers []string, data [][]string, styled bool) *InteractiveTableModel {
bt := NewBubbleTable(headers, styled)
bt.SetData(data)
return &InteractiveTableModel{
table: bt,
originalData: data,
filteredData: data,
headers: headers,
filterMode: false,
filterText: "",
title: title,
}
}
func (m *InteractiveTableModel) Init() tea.Cmd {
return nil
}
func (m *InteractiveTableModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if m.filterMode {
// Handle filter mode
switch msg.String() {
case "enter", "esc":
// Exit filter mode
m.filterMode = false
if msg.String() == "esc" {
// Clear filter on escape
m.filterText = ""
m.filteredData = m.originalData
m.table.SetData(m.filteredData)
}
case "backspace":
// Remove last character
if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1]
m.applyFilter()
}
case "ctrl+c":
return m, tea.Quit
default:
// Add character to filter
if len(msg.String()) == 1 {
m.filterText += msg.String()
m.applyFilter()
}
}
} else {
// Handle normal navigation mode
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc":
// Clear any active filter
if m.filterText != "" {
m.filterText = ""
m.filteredData = m.originalData
m.table.SetData(m.filteredData)
}
case "/":
// Enter filter mode
m.filterMode = true
default:
// Pass other keys to table for navigation
m.table.table, cmd = m.table.table.Update(msg)
}
}
}
return m, cmd
}
// applyFilter filters the data based on the current filter text
func (m *InteractiveTableModel) applyFilter() {
if m.filterText == "" {
m.filteredData = m.originalData
} else {
m.filteredData = [][]string{}
filterLower := strings.ToLower(m.filterText)
for _, row := range m.originalData {
// Search in all columns
found := false
for _, cell := range row {
if strings.Contains(strings.ToLower(cell), filterLower) {
found = true
break
}
}
if found {
m.filteredData = append(m.filteredData, row)
}
}
}
// Update the table with filtered data
m.table.SetData(m.filteredData)
}
func (m *InteractiveTableModel) View() string {
var b strings.Builder
// Title with filter status
title := m.title
if m.filterText != "" {
title += fmt.Sprintf(" (filtered: %d/%d)", len(m.filteredData), len(m.originalData))
}
b.WriteString(title + "\n\n")
// Table
b.WriteString(m.table.table.View())
b.WriteString("\n")
// Filter input
if m.filterMode {
filterStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("86")).
Bold(true)
b.WriteString("\n" + filterStyle.Render("Filter: /"+m.filterText+"█"))
b.WriteString("\n")
} else if m.filterText != "" {
filterStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Italic(true)
b.WriteString("\n" + filterStyle.Render("Active filter: "+m.filterText+" (press esc to clear)"))
b.WriteString("\n")
}
// Help text
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
if m.filterMode {
b.WriteString(helpStyle.Render("enter: apply filter • esc: cancel • backspace: delete • ctrl+c: quit"))
} else {
b.WriteString(helpStyle.Render("↑/↓: navigate • /: filter • esc: clear filter • q/ctrl+c: quit"))
}
return b.String()
}