mirror of
https://codeberg.org/scip/tablizer.git
synced 2025-12-16 20:20:57 +01:00
525 lines
12 KiB
Go
525 lines
12 KiB
Go
/*
|
|
Copyright © 2025 Thomas von Dein
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package lib
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/evertras/bubble-table/table"
|
|
"github.com/mattn/go-isatty"
|
|
"codeberg.org/scip/tablizer/cfg"
|
|
)
|
|
|
|
// The context exists outside of the bubble loop, and is being used as
|
|
// pointer reciever. That way we can use it as our primary storage
|
|
// container.
|
|
type Context struct {
|
|
selectedColumn int
|
|
showHelp bool
|
|
descending bool
|
|
data *Tabdata
|
|
|
|
// Window dimensions
|
|
totalWidth int
|
|
totalHeight int
|
|
|
|
// Table dimensions
|
|
horizontalMargin int
|
|
verticalMargin int
|
|
}
|
|
|
|
// Execute tablizer sort function, feed it with fresh config, we do
|
|
// NOT use the existing runtime config, because sorting is
|
|
// configurable in the UI separately.
|
|
func (ctx *Context) Sort(mode string) {
|
|
conf := cfg.Config{
|
|
SortMode: mode,
|
|
SortDescending: ctx.descending,
|
|
UseSortByColumn: []int{ctx.selectedColumn + 1},
|
|
}
|
|
|
|
ctx.descending = !ctx.descending
|
|
|
|
sortTable(conf, ctx.data)
|
|
}
|
|
|
|
// The actual table model, holds the context pointer, a copy of the
|
|
// pre-processed data and some flags
|
|
type FilterTable struct {
|
|
Table table.Model
|
|
|
|
Rows int
|
|
|
|
quitting bool
|
|
unchanged bool
|
|
|
|
maxColumns int
|
|
headerIdx map[string]int
|
|
|
|
ctx *Context
|
|
|
|
columns []table.Column
|
|
}
|
|
|
|
type HelpLine []string
|
|
type HelpColumn []HelpLine
|
|
|
|
const (
|
|
// header+footer
|
|
ExtraRows = 5
|
|
|
|
HelpFooter = "?:help | "
|
|
)
|
|
|
|
var (
|
|
// we use our own custom border style
|
|
customBorder = table.Border{
|
|
Top: "─",
|
|
Left: "│",
|
|
Right: "│",
|
|
Bottom: "─",
|
|
|
|
TopRight: "╮",
|
|
TopLeft: "╭",
|
|
BottomRight: "╯",
|
|
BottomLeft: "╰",
|
|
|
|
TopJunction: "┬",
|
|
LeftJunction: "├",
|
|
RightJunction: "┤",
|
|
BottomJunction: "┴",
|
|
InnerJunction: "┼",
|
|
|
|
InnerDivider: "│",
|
|
}
|
|
|
|
// Cells in selected columns will be highlighted
|
|
StyleSelected = lipgloss.NewStyle().
|
|
Background(lipgloss.Color("#696969")).
|
|
Foreground(lipgloss.Color("#ffffff")).
|
|
Align(lipgloss.Left)
|
|
|
|
StyleHeader = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#ff4500")).
|
|
Align(lipgloss.Left).Bold(true)
|
|
|
|
// help buffer styles
|
|
StyleKey = lipgloss.NewStyle().Bold(true)
|
|
StyleHelp = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4500"))
|
|
|
|
// the default style
|
|
NoStyle = lipgloss.NewStyle().Align(lipgloss.Left)
|
|
|
|
HelpData = []HelpColumn{
|
|
{
|
|
HelpLine{"up", "navigate up"},
|
|
HelpLine{"down", "navigate down"},
|
|
HelpLine{"tab", "navigate columns"},
|
|
},
|
|
{
|
|
HelpLine{"s", "sort alpha-numerically"},
|
|
HelpLine{"n", "sort numerically"},
|
|
HelpLine{"t", "sort by time"},
|
|
HelpLine{"d", "sort by duration"},
|
|
},
|
|
{
|
|
HelpLine{"spc", "[de]select a row"},
|
|
HelpLine{"a", "[de]select all visible rows"},
|
|
HelpLine{"f", "enter fuzzy filter"},
|
|
HelpLine{"esc", "finish filter input"},
|
|
},
|
|
{
|
|
HelpLine{"?", "show help buffer"},
|
|
HelpLine{"q", "commit and quit"},
|
|
HelpLine{"c-c", "discard and quit"},
|
|
},
|
|
}
|
|
|
|
// rendered from Help above
|
|
Help = ""
|
|
|
|
// number of lines taken by help below, adjust accordingly!
|
|
HelpRows = 0
|
|
)
|
|
|
|
// generate a lipgloss styled help buffer consisting of various
|
|
// columns
|
|
func generateHelp() {
|
|
help := strings.Builder{}
|
|
helpcols := []string{}
|
|
maxrows := 0
|
|
|
|
for _, col := range HelpData {
|
|
help.Reset()
|
|
|
|
// determine max key width to avoid excess spaces between keys and help
|
|
keylen := 0
|
|
for _, line := range col {
|
|
if len(line[0]) > keylen {
|
|
keylen = len(line[0])
|
|
}
|
|
}
|
|
|
|
keylenstr := fmt.Sprintf("%d", keylen)
|
|
|
|
for _, line := range col {
|
|
// 0: key, 1: help text
|
|
help.WriteString(StyleKey.Render(fmt.Sprintf("%-"+keylenstr+"s", line[0])))
|
|
help.WriteString(" " + StyleHelp.Render(line[1]) + " \n")
|
|
}
|
|
|
|
helpcols = append(helpcols, help.String())
|
|
|
|
if len(col) > maxrows {
|
|
maxrows = len(col)
|
|
}
|
|
}
|
|
|
|
HelpRows = maxrows + 1
|
|
Help = "\n" + lipgloss.JoinHorizontal(lipgloss.Top, helpcols...)
|
|
}
|
|
|
|
// initializes the table model
|
|
func NewModel(data *Tabdata, ctx *Context) FilterTable {
|
|
columns := make([]table.Column, len(data.headers))
|
|
lengths := make([]int, len(data.headers))
|
|
hidx := make(map[string]int, len(data.headers))
|
|
|
|
// give columns at least the header width
|
|
for idx, header := range data.headers {
|
|
lengths[idx] = len(header)
|
|
hidx[strings.ToLower(header)] = idx
|
|
}
|
|
|
|
// determine max width per column
|
|
for _, entry := range data.entries {
|
|
for i, cell := range entry {
|
|
if len(cell) > lengths[i] {
|
|
lengths[i] = len(cell)
|
|
}
|
|
}
|
|
}
|
|
|
|
// determine flexFactor with base 10, used by flexColumns
|
|
for i, len := range lengths {
|
|
if len <= 10 {
|
|
lengths[i] = 1
|
|
} else {
|
|
lengths[i] = len / 10
|
|
}
|
|
}
|
|
|
|
// setup column data with flexColumns
|
|
for idx, header := range data.headers {
|
|
columns[idx] = table.NewFlexColumn(
|
|
strings.ToLower(header),
|
|
StyleHeader.Render(header),
|
|
lengths[idx]).WithFiltered(true).WithStyle(NoStyle)
|
|
}
|
|
|
|
// separate variable so we can share the row filling code
|
|
filtertbl := FilterTable{
|
|
maxColumns: len(data.headers),
|
|
Rows: len(data.entries),
|
|
headerIdx: hidx,
|
|
ctx: ctx,
|
|
columns: columns,
|
|
}
|
|
|
|
filtertbl.Table = table.New(columns)
|
|
filtertbl.fillRows()
|
|
|
|
// finally construct help buffer
|
|
generateHelp()
|
|
|
|
return filtertbl
|
|
}
|
|
|
|
// Applied to every cell on every change (TAB,up,down key, resize
|
|
// event etc)
|
|
func CellController(input table.StyledCellFuncInput, m FilterTable) lipgloss.Style {
|
|
if m.headerIdx[input.Column.Key()] == m.ctx.selectedColumn {
|
|
return StyleSelected
|
|
}
|
|
|
|
return NoStyle
|
|
}
|
|
|
|
// Selects or deselects ALL rows
|
|
func (m *FilterTable) ToggleAllSelected() {
|
|
rows := m.Table.GetVisibleRows()
|
|
selected := m.Table.SelectedRows()
|
|
|
|
if len(selected) > 0 {
|
|
for i, row := range selected {
|
|
rows[i] = row.Selected(false)
|
|
}
|
|
} else {
|
|
for i, row := range rows {
|
|
rows[i] = row.Selected(true)
|
|
}
|
|
}
|
|
|
|
m.Table.WithRows(rows)
|
|
}
|
|
|
|
// ? pressed, display help message
|
|
func (m FilterTable) ToggleHelp() {
|
|
m.ctx.showHelp = !m.ctx.showHelp
|
|
}
|
|
|
|
func (m FilterTable) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Forward call to context sort
|
|
func (m *FilterTable) Sort(mode string) {
|
|
m.ctx.Sort(mode)
|
|
m.fillRows()
|
|
}
|
|
|
|
// Fills the table rows with our data. Called once on startup and
|
|
// repeatedly if the user changes the sort order in some way
|
|
func (m *FilterTable) fillRows() {
|
|
// required to be able to feed the model to the controller
|
|
controllerWrapper := func(input table.StyledCellFuncInput) lipgloss.Style {
|
|
return CellController(input, *m)
|
|
}
|
|
|
|
// fill the rows with style
|
|
rows := make([]table.Row, len(m.ctx.data.entries))
|
|
for idx, entry := range m.ctx.data.entries {
|
|
rowdata := make(table.RowData, len(entry))
|
|
|
|
for i, cell := range entry {
|
|
rowdata[strings.ToLower(m.ctx.data.headers[i])] =
|
|
table.NewStyledCellWithStyleFunc(cell+" ", controllerWrapper)
|
|
}
|
|
|
|
rows[idx] = table.NewRow(rowdata)
|
|
}
|
|
|
|
m.Table = m.Table.
|
|
WithRows(rows).
|
|
Filtered(true).
|
|
WithFuzzyFilter().
|
|
Focused(true).
|
|
SelectableRows(true).
|
|
WithSelectedText(" ", "✓").
|
|
WithFooterVisibility(true).
|
|
WithHeaderVisibility(true).
|
|
HighlightStyle(StyleSelected).
|
|
Border(customBorder)
|
|
}
|
|
|
|
// Part of the bubbletea event loop, called every tick
|
|
func (m FilterTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
cmd tea.Cmd
|
|
cmds []tea.Cmd
|
|
)
|
|
|
|
m.Table, cmd = m.Table.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// If the user is about to enter filter text, do NOT respond to
|
|
// key bindings, as they might be part of the filter!
|
|
if !m.Table.GetIsFilterInputFocused() {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q":
|
|
m.quitting = true
|
|
m.unchanged = false
|
|
cmds = append(cmds, tea.Quit)
|
|
|
|
case "ctrl+c":
|
|
m.quitting = true
|
|
m.unchanged = true
|
|
cmds = append(cmds, tea.Quit)
|
|
|
|
case "a":
|
|
m.ToggleAllSelected()
|
|
|
|
case "tab":
|
|
m.SelectNextColumn()
|
|
|
|
case "?":
|
|
m.ToggleHelp()
|
|
m.recalculateTable()
|
|
|
|
case "s":
|
|
m.Sort("alphanumeric")
|
|
|
|
case "n":
|
|
m.Sort("numeric")
|
|
|
|
case "d":
|
|
m.Sort("duration")
|
|
|
|
case "t":
|
|
m.Sort("time")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Happens when the terminal window has been resized
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.ctx.totalWidth = msg.Width
|
|
m.ctx.totalHeight = msg.Height
|
|
|
|
m.recalculateTable()
|
|
}
|
|
|
|
m.updateFooter()
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// Add some info to the footer
|
|
func (m *FilterTable) updateFooter() {
|
|
selected := m.Table.SelectedRows()
|
|
footer := fmt.Sprintf("selected: %d ", len(selected))
|
|
|
|
if m.Table.GetIsFilterInputFocused() {
|
|
footer = fmt.Sprintf("/%s %s", m.Table.GetCurrentFilter(), footer)
|
|
} else if m.Table.GetIsFilterActive() {
|
|
footer = fmt.Sprintf("Filter: %s %s", m.Table.GetCurrentFilter(), footer)
|
|
}
|
|
|
|
m.Table = m.Table.WithStaticFooter(HelpFooter + footer)
|
|
}
|
|
|
|
// Called on resize event (or if help has been toggled)
|
|
func (m *FilterTable) recalculateTable() {
|
|
m.Table = m.Table.
|
|
WithTargetWidth(m.calculateWidth()).
|
|
WithMinimumHeight(m.calculateHeight()).
|
|
WithPageSize(m.calculateHeight() - ExtraRows)
|
|
}
|
|
|
|
func (m *FilterTable) calculateWidth() int {
|
|
return m.ctx.totalWidth - m.ctx.horizontalMargin
|
|
}
|
|
|
|
// Take help height into account, if enabled
|
|
func (m *FilterTable) calculateHeight() int {
|
|
height := m.Rows + ExtraRows
|
|
|
|
if height >= m.ctx.totalHeight {
|
|
height = m.ctx.totalHeight - m.ctx.verticalMargin
|
|
} else {
|
|
height = m.ctx.totalHeight
|
|
}
|
|
|
|
if m.ctx.showHelp {
|
|
height = height - HelpRows
|
|
}
|
|
|
|
return height
|
|
}
|
|
|
|
// Part of the bubbletable event view, called every tick
|
|
func (m FilterTable) View() string {
|
|
body := strings.Builder{}
|
|
|
|
if !m.quitting {
|
|
body.WriteString(m.Table.View())
|
|
|
|
if m.ctx.showHelp {
|
|
body.WriteString(Help)
|
|
}
|
|
}
|
|
|
|
return body.String()
|
|
}
|
|
|
|
// User hit the TAB key
|
|
func (m *FilterTable) SelectNextColumn() {
|
|
if m.ctx.selectedColumn == m.maxColumns-1 {
|
|
m.ctx.selectedColumn = 0
|
|
} else {
|
|
m.ctx.selectedColumn++
|
|
}
|
|
}
|
|
|
|
// entry point from outside tablizer into table editor
|
|
func tableEditor(conf *cfg.Config, data *Tabdata) (*Tabdata, error) {
|
|
// we render to STDERR to avoid dead lock when the user redirects STDOUT
|
|
// see https://github.com/charmbracelet/bubbletea/issues/860
|
|
//
|
|
// TODO: doesn't work with libgloss v2 anymore!
|
|
|
|
out := os.Stderr
|
|
|
|
if isatty.IsTerminal(os.Stdout.Fd()) {
|
|
out = os.Stdout
|
|
}
|
|
|
|
lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(out))
|
|
|
|
ctx := &Context{data: data}
|
|
|
|
// Output to STDERR because there's a known bubbletea/lipgloss
|
|
// issue: if a program with a tui is expected to write something
|
|
// to STDOUT when the tui is finished, then the styles do not
|
|
// work. So we write to STDERR (which works) and tablizer can
|
|
// still be used inside pipes.
|
|
program := tea.NewProgram(
|
|
NewModel(data, ctx),
|
|
tea.WithOutput(out),
|
|
tea.WithAltScreen())
|
|
|
|
m, err := program.Run()
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if m.(FilterTable).unchanged {
|
|
return data, err
|
|
}
|
|
|
|
// Data has been modified. Extract it, put it back into our own
|
|
// structure and give control back to cmdline tablizer.
|
|
filteredtable := m.(FilterTable)
|
|
|
|
data.entries = make([][]string, len(filteredtable.Table.SelectedRows()))
|
|
for pos, row := range m.(FilterTable).Table.SelectedRows() {
|
|
entry := make([]string, len(data.headers))
|
|
for idx, field := range data.headers {
|
|
cell := row.Data[strings.ToLower(field)]
|
|
switch value := cell.(type) {
|
|
case string:
|
|
entry[idx] = value
|
|
case table.StyledCell:
|
|
entry[idx] = value.Data.(string)
|
|
}
|
|
}
|
|
|
|
data.entries[pos] = entry
|
|
}
|
|
|
|
return data, err
|
|
}
|