From 80dd6849aecc9b4b9512dfaf952249483f1e4c98 Mon Sep 17 00:00:00 2001 From: "T.v.Dein" Date: Tue, 9 Sep 2025 20:09:08 +0200 Subject: [PATCH] Add interactive filter table (#62) --- go.mod | 2 +- go.sum | 2 + lib/tableeditor.go | 294 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 237 insertions(+), 61 deletions(-) diff --git a/go.mod b/go.mod index b4c084f..3ead198 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9324412..a2b3408 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/lib/tableeditor.go b/lib/tableeditor.go index 867ae52..802537e 100644 --- a/lib/tableeditor.go +++ b/lib/tableeditor.go @@ -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