Add interactive filter table (#62)

This commit is contained in:
T.v.Dein
2025-09-09 20:09:08 +02:00
committed by GitHub
parent e2b82515f5
commit 80dd6849ae
3 changed files with 237 additions and 61 deletions

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
github.com/evertras/bubble-table v0.17.2
github.com/evertras/bubble-table v0.19.0
github.com/gookit/color v1.5.4
github.com/hashicorp/hcl/v2 v2.24.0
github.com/lithammer/fuzzysearch v1.1.8

2
go.sum
View File

@@ -32,6 +32,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/evertras/bubble-table v0.17.2 h1:4MtLO888s2xb94OG3KqJCIEav6gE3V4ob56hmOammf0=
github.com/evertras/bubble-table v0.17.2/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ=
github.com/evertras/bubble-table v0.19.0 h1:+JlXRUjNuBN1JI7XU1PapmW1wglbcqZUKkiPnVKPgrc=
github.com/evertras/bubble-table v0.19.0/go.mod h1:ifHujS1YxwnYSOgcR2+m3GnJ84f7CVU/4kUOxUCjEbQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=

View File

@@ -28,8 +28,14 @@ import (
"github.com/tlinden/tablizer/cfg"
)
type FilterTable struct {
Table table.Model
// 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
@@ -38,23 +44,64 @@ type FilterTable struct {
// 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
}
const (
// Add a fixed margin to account for description & instructions
fixedVerticalMargin = 0
// header+footer
ExtraRows = 5
ExtraRows = 8
HelpFooter = "?:help | "
HELP = "/:filter esc:clear-filter q:commit c-c:abort space:select a:select-all | "
// number of lines taken by help below, adjust accordingly!
HelpRows = 7
// shown when the user presses ?
LongHelp = `
Key bindings usable in interactive filter table:
q commit and quit s sort alphanumerically
ctrl-c discard and quit d sort by duration
up|down navigate rows t sort by time
TAB navigage columns f fuzzy filter
space [de]select row ESC finish filter input
a [de]select all rows ? show help buffer
`
)
var (
// we use our own custom border style
customBorder = table.Border{
Top: "─",
Left: "│",
@@ -74,16 +121,27 @@ var (
InnerDivider: "│",
}
// Cells in selected columns will be highlighted
StyleSelected = lipgloss.NewStyle().
Background(lipgloss.Color("#696969")).
Foreground(lipgloss.Color("#ffffff")).
Align(lipgloss.Left)
// the default style
NoStyle = lipgloss.NewStyle().Align(lipgloss.Left)
)
func NewModel(data *Tabdata) FilterTable {
// initializes the table model
func NewModel(data *Tabdata, ctx *Context) FilterTable {
columns := make([]table.Column, len(data.headers))
rows := make([]table.Row, len(data.entries))
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
@@ -95,45 +153,48 @@ func NewModel(data *Tabdata) FilterTable {
}
}
// setup column data
for idx, header := range data.headers {
columns[idx] = table.NewColumn(strings.ToLower(header), header, lengths[idx]+2).
WithFiltered(true)
}
// setup table data
for idx, entry := range data.entries {
rowdata := make(table.RowData, len(entry))
for i, cell := range entry {
rowdata[strings.ToLower(data.headers[i])] = 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
}
rows[idx] = table.NewRow(rowdata)
}
keys := table.DefaultKeyMap()
keys.RowDown.SetKeys("j", "down", "s")
keys.RowUp.SetKeys("k", "up", "w")
// our final interactive table filled with our prepared data
return FilterTable{
Table: table.New(columns).
WithRows(rows).
WithKeyMap(keys).
Filtered(true).
Focused(true).
SelectableRows(true).
WithSelectedText(" ", "✓").
WithFooterVisibility(true).
WithHeaderVisibility(true).
Border(customBorder),
horizontalMargin: 10,
Rows: len(data.entries),
// setup column data with flexColumns
for idx, header := range data.headers {
columns[idx] = table.NewFlexColumn(strings.ToLower(header),
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()
return filtertbl
}
func (m FilterTable) ToggleSelected() {
// 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()
@@ -150,10 +211,55 @@ func (m FilterTable) ToggleSelected() {
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).
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
@@ -163,6 +269,8 @@ func (m FilterTable) Update(msg tea.Msg) (tea.Model, 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:
@@ -178,23 +286,48 @@ func (m FilterTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, tea.Quit)
case "a":
m.ToggleSelected()
}
case tea.WindowSizeMsg:
m.totalWidth = msg.Width
m.totalHeight = msg.Height
m.ToggleAllSelected()
m.recalculateTable()
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))
footer := fmt.Sprintf("selected: %d ", len(selected))
if m.Table.GetIsFilterInputFocused() {
footer = fmt.Sprintf("/%s %s", m.Table.GetCurrentFilter(), footer)
@@ -202,9 +335,10 @@ func (m *FilterTable) updateFooter() {
footer = fmt.Sprintf("Filter: %s %s", m.Table.GetCurrentFilter(), footer)
}
m.Table = m.Table.WithStaticFooter(HELP + 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()).
@@ -212,36 +346,68 @@ func (m *FilterTable) recalculateTable() {
WithPageSize(m.calculateHeight() - ExtraRows)
}
func (m FilterTable) calculateWidth() int {
return m.totalWidth - m.horizontalMargin
func (m *FilterTable) calculateWidth() int {
return m.ctx.totalWidth - m.ctx.horizontalMargin
}
func (m FilterTable) calculateHeight() int {
if m.Rows+ExtraRows < m.totalHeight {
// FIXME: avoid full screen somehow
return m.Rows + ExtraRows
// 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
}
return m.totalHeight - m.verticalMargin - fixedVerticalMargin
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(LongHelp)
}
}
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!
lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(os.Stderr))
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),
NewModel(data, ctx),
tea.WithOutput(os.Stderr),
tea.WithAltScreen())
@@ -255,13 +421,21 @@ func tableEditor(conf *cfg.Config, data *Tabdata) (*Tabdata, error) {
return data, err
}
table := m.(FilterTable).Table
data.entries = make([][]string, len(table.SelectedRows()))
// 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 {
entry[idx] = row.Data[strings.ToLower(field)].(string)
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