From 1976b4046e9da1756c4dbb1173e4b6bd8c369ba3 Mon Sep 17 00:00:00 2001 From: "T.v.Dein" Date: Thu, 28 Aug 2025 21:08:28 +0200 Subject: [PATCH] Add interactive filter/selection tool (#58) --- README.md | 12 +- cfg/config.go | 1 + cmd/root.go | 4 +- cmd/tablizer.go | 19 +++ go.mod | 8 +- go.sum | 19 ++- lib/io.go | 9 ++ {cmd => lib}/pager.go | 16 +-- lib/tableeditor.go | 271 ++++++++++++++++++++++++++++++++++++++++++ tablizer.1 | 20 +++- tablizer.pod | 19 +++ 11 files changed, 376 insertions(+), 22 deletions(-) rename {cmd => lib}/pager.go (91%) create mode 100644 lib/tableeditor.go diff --git a/README.md b/README.md index 8fcf288..27b5adc 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,29 @@ Tablizer can be used to re-format tabular output of other programs. While you could do this using standard unix tools, in some -cases it's a hard job. +cases it's a hard job. With tablizer you can filter by column[s], +ignore certain column[s] by regex, name or number. It can output the +tabular data in a range of formats (see below). There's even an +interactive filter/selection tool available. Usage: ```default Usage: - tablizer [regex] [file, ...] [flags] + tablizer [regex,...] [file, ...] [flags] Operational Flags: -c, --columns string Only show the speficied columns (separated by ,) -v, --invert-match select non-matching rows - -n, --no-numbering Disable header numbering + -n, --numbering Enable header numbering -N, --no-color Disable pattern highlighting -H, --no-headers Disable headers display -s, --separator string Custom field separator - -k, --sort-by int Sort by column (default: 1) + -k, --sort-by int|name Sort by column (default: 1) -z, --fuzzy Use fuzzy search [experimental] -F, --filter field[!]=reg Filter given field with regex, can be used multiple times -T, --transpose-columns string Transpose the speficied columns (separated by ,) -R, --regex-transposer /from/to/ Apply /search/replace/ regexp to fields given in -T + -I, --interactive Interactively filter and select rows Output Flags (mutually exclusive): -X, --extended Enable extended output diff --git a/cfg/config.go b/cfg/config.go index 06634cc..04ad989 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -78,6 +78,7 @@ type Config struct { Patterns []*Pattern UseFuzzySearch bool UseHighlight bool + Interactive bool SortMode string SortDescending bool diff --git a/cmd/root.go b/cmd/root.go index e21b47d..e3bf795 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,7 +73,7 @@ func Execute() { } if ShowManual { - Pager("tablizer manual page", manpage) + lib.Pager("tablizer manual page", manpage) return } @@ -130,6 +130,8 @@ func Execute() { "Yank the speficied columns (separated by ,) to the clipboard") rootCmd.PersistentFlags().StringVarP(&conf.TransposeColumns, "transpose-columns", "T", "", "Transpose the speficied columns (separated by ,)") + rootCmd.PersistentFlags().BoolVarP(&conf.Interactive, "interactive", "I", false, + "interactive mode (experimental)") // sort options rootCmd.PersistentFlags().StringVarP(&conf.SortByColumn, "sort-by", "k", "", diff --git a/cmd/tablizer.go b/cmd/tablizer.go index d7d95da..9385733 100644 --- a/cmd/tablizer.go +++ b/cmd/tablizer.go @@ -20,6 +20,7 @@ SYNOPSIS -F, --filter field[!]=reg Filter given field with regex, can be used multiple times -T, --transpose-columns string Transpose the speficied columns (separated by ,) -R, --regex-transposer /from/to/ Apply /search/replace/ regexp to fields given in -T + -I, --interactive Interactively filter and select rows Output Flags (mutually exclusive): -X, --extended Enable extended output @@ -189,6 +190,20 @@ DESCRIPTION If the option -v is specified, the filtering is inverted. + INTERACTIVE FILTERING + You can also use the interactive mode, enabled with "-I" to filter and + select rows. This mode is complementary, that is, other filter options + are still being respected. + + To enter e filter, hit "/", enter a filter string and finish with + "ENTER". Use "SPACE" to select/deselect rows, use "a" to select all + (visible) rows. + + Commit your selection with "q". The selected rows are being fed to the + requested output mode as usual. Abort with "CTRL-c", in which case the + results of the interactive mode are being ignored and all rows are being + fed to output. + COLUMNS The parameter -c can be used to specify, which columns to display. By default tablizer numerizes the header names and these numbers can be @@ -416,6 +431,9 @@ LICENSE Released under the MIT License, Copyright (c) 2006-2011 Kirill Simonov + bubble-table (https://github.com/Evertras/bubble-table) + Released under the MIT License, Copyright (c) 2022 Brandon Fulljames + AUTHORS Thomas von Dein tom AT vondein DOT org @@ -437,6 +455,7 @@ Operational Flags: -F, --filter field[!]=reg Filter given field with regex, can be used multiple times -T, --transpose-columns string Transpose the speficied columns (separated by ,) -R, --regex-transposer /from/to/ Apply /search/replace/ regexp to fields given in -T + -I, --interactive Interactively filter and select rows Output Flags (mutually exclusive): -X, --extended Enable extended output diff --git a/go.mod b/go.mod index 4f89944..b4c084f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +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/gookit/color v1.5.4 github.com/hashicorp/hcl/v2 v2.24.0 github.com/lithammer/fuzzysearch v1.1.8 @@ -23,10 +24,11 @@ require ( require ( github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect @@ -40,6 +42,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/olekukonko/errors v1.1.0 // indirect github.com/olekukonko/ll v0.0.9 // indirect @@ -48,6 +51,7 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zclconf/go-cty v1.16.3 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index 7824f73..9324412 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,22 @@ github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -28,6 +30,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 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/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= @@ -51,6 +55,7 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= @@ -59,6 +64,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= @@ -98,8 +105,8 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= diff --git a/lib/io.go b/lib/io.go index 3e39ef8..8c40079 100644 --- a/lib/io.go +++ b/lib/io.go @@ -58,6 +58,15 @@ func ProcessFiles(conf *cfg.Config, args []string) error { return err } + if conf.Interactive { + newdata, err := tableEditor(conf, &data) + if err != nil { + return err + } + + data = *newdata + } + printData(os.Stdout, *conf, &data) return nil diff --git a/cmd/pager.go b/lib/pager.go similarity index 91% rename from cmd/pager.go rename to lib/pager.go index 5185d23..b7e2910 100644 --- a/cmd/pager.go +++ b/lib/pager.go @@ -1,4 +1,4 @@ -package cmd +package lib // pager setup using bubbletea // file shamlelessly copied from: @@ -28,18 +28,18 @@ var ( }() ) -type model struct { +type Doc struct { content string title string ready bool viewport viewport.Model } -func (m model) Init() tea.Cmd { +func (m Doc) Init() tea.Cmd { return nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd @@ -79,21 +79,21 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m model) View() string { +func (m Doc) View() string { if !m.ready { return "\n Initializing..." } return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) } -func (m model) headerView() string { +func (m Doc) headerView() string { // title := titleStyle.Render("RPN Help Overview") title := titleStyle.Render(m.title) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) return lipgloss.JoinHorizontal(lipgloss.Center, title, line) } -func (m model) footerView() string { +func (m Doc) footerView() string { info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) return lipgloss.JoinHorizontal(lipgloss.Center, line, info) @@ -108,7 +108,7 @@ func max(a, b int) int { func Pager(title, message string) { p := tea.NewProgram( - model{content: message, title: title}, + Doc{content: message, title: title}, tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel ) diff --git a/lib/tableeditor.go b/lib/tableeditor.go new file mode 100644 index 0000000..867ae52 --- /dev/null +++ b/lib/tableeditor.go @@ -0,0 +1,271 @@ +/* +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 . +*/ + +package lib + +import ( + "fmt" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/evertras/bubble-table/table" + "github.com/tlinden/tablizer/cfg" +) + +type FilterTable struct { + Table table.Model + + // Window dimensions + totalWidth int + totalHeight int + + // Table dimensions + horizontalMargin int + verticalMargin int + + Rows int + + quitting bool + unchanged bool +} + +const ( + // Add a fixed margin to account for description & instructions + fixedVerticalMargin = 0 + + ExtraRows = 8 + + HELP = "/:filter esc:clear-filter q:commit c-c:abort space:select a:select-all | " +) + +var ( + customBorder = table.Border{ + Top: "─", + Left: "│", + Right: "│", + Bottom: "─", + + TopRight: "╮", + TopLeft: "╭", + BottomRight: "╯", + BottomLeft: "╰", + + TopJunction: "┬", + LeftJunction: "├", + RightJunction: "┤", + BottomJunction: "┴", + InnerJunction: "┼", + + InnerDivider: "│", + } +) + +func NewModel(data *Tabdata) FilterTable { + columns := make([]table.Column, len(data.headers)) + rows := make([]table.Row, len(data.entries)) + lengths := make([]int, len(data.headers)) + + // give columns at least the header width + for idx, header := range data.headers { + lengths[idx] = len(header) + } + + // determine max width per column + for _, entry := range data.entries { + for i, cell := range entry { + if len(cell) > lengths[i] { + lengths[i] = len(cell) + } + } + } + + // 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 + " " + } + + 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), + } +} + +func (m FilterTable) ToggleSelected() { + 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) +} + +func (m FilterTable) Init() tea.Cmd { + return nil +} + +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 !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.ToggleSelected() + } + case tea.WindowSizeMsg: + m.totalWidth = msg.Width + m.totalHeight = msg.Height + + m.recalculateTable() + } + } + m.updateFooter() + + return m, tea.Batch(cmds...) +} + +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(HELP + footer) +} + +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.totalWidth - m.horizontalMargin +} + +func (m FilterTable) calculateHeight() int { + if m.Rows+ExtraRows < m.totalHeight { + // FIXME: avoid full screen somehow + return m.Rows + ExtraRows + } + + return m.totalHeight - m.verticalMargin - fixedVerticalMargin +} + +func (m FilterTable) View() string { + body := strings.Builder{} + + if !m.quitting { + body.WriteString(m.Table.View()) + } + + return body.String() +} + +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 + lipgloss.SetDefaultRenderer(lipgloss.NewRenderer(os.Stderr)) + + program := tea.NewProgram( + NewModel(data), + tea.WithOutput(os.Stderr), + tea.WithAltScreen()) + + m, err := program.Run() + + if err != nil { + return nil, err + } + + if m.(FilterTable).unchanged { + return data, err + } + + table := m.(FilterTable).Table + data.entries = make([][]string, len(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) + } + + data.entries[pos] = entry + } + + return data, err +} diff --git a/tablizer.1 b/tablizer.1 index 0268132..415e68e 100644 --- a/tablizer.1 +++ b/tablizer.1 @@ -133,7 +133,7 @@ .\" ======================================================================== .\" .IX Title "TABLIZER 1" -.TH TABLIZER 1 "2025-03-06" "1" "User Commands" +.TH TABLIZER 1 "2025-08-28" "1" "User Commands" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l @@ -158,6 +158,7 @@ tablizer \- Manipulate tabular output of other programs \& \-F, \-\-filter field[!]=reg Filter given field with regex, can be used multiple times \& \-T, \-\-transpose\-columns string Transpose the speficied columns (separated by ,) \& \-R, \-\-regex\-transposer /from/to/ Apply /search/replace/ regexp to fields given in \-T +\& \-I, \-\-interactive Interactively filter and select rows \& \& Output Flags (mutually exclusive): \& \-X, \-\-extended Enable extended output @@ -349,6 +350,20 @@ These field filters can also be negated: .Ve .PP If the option \fB\-v\fR is specified, the filtering is inverted. +.SS "\s-1INTERACTIVE FILTERING\s0" +.IX Subsection "INTERACTIVE FILTERING" +You can also use the interactive mode, enabled with \f(CW\*(C`\-I\*(C'\fR to filter +and select rows. This mode is complementary, that is, other filter +options are still being respected. +.PP +To enter e filter, hit \f(CW\*(C`/\*(C'\fR, enter a filter string and finish with +\&\f(CW\*(C`ENTER\*(C'\fR. Use \f(CW\*(C`SPACE\*(C'\fR to select/deselect rows, use \f(CW\*(C`a\*(C'\fR to select all +(visible) rows. +.PP +Commit your selection with \f(CW\*(C`q\*(C'\fR. The selected rows are being fed to +the requested output mode as usual. Abort with \f(CW\*(C`CTRL\-c\*(C'\fR, in which +case the results of the interactive mode are being ignored and all +rows are being fed to output. .SS "\s-1COLUMNS\s0" .IX Subsection "COLUMNS" The parameter \fB\-c\fR can be used to specify, which columns to @@ -615,6 +630,9 @@ Released under the \s-1MIT\s0 License, Copyright (c) 201 by Oleku Konko .IP "yaml (gopkg.in/yaml.v3)" 4 .IX Item "yaml (gopkg.in/yaml.v3)" Released under the \s-1MIT\s0 License, Copyright (c) 2006\-2011 Kirill Simonov +.IP "bubble-table (https://github.com/Evertras/bubble\-table)" 4 +.IX Item "bubble-table (https://github.com/Evertras/bubble-table)" +Released under the \s-1MIT\s0 License, Copyright (c) 2022 Brandon Fulljames .SH "AUTHORS" .IX Header "AUTHORS" Thomas von Dein \fBtom \s-1AT\s0 vondein \s-1DOT\s0 org\fR diff --git a/tablizer.pod b/tablizer.pod index ec86063..5dbd166 100644 --- a/tablizer.pod +++ b/tablizer.pod @@ -19,6 +19,7 @@ tablizer - Manipulate tabular output of other programs -F, --filter field[!]=reg Filter given field with regex, can be used multiple times -T, --transpose-columns string Transpose the speficied columns (separated by ,) -R, --regex-transposer /from/to/ Apply /search/replace/ regexp to fields given in -T + -I, --interactive Interactively filter and select rows Output Flags (mutually exclusive): -X, --extended Enable extended output @@ -202,6 +203,20 @@ These field filters can also be negated: If the option B<-v> is specified, the filtering is inverted. +=head2 INTERACTIVE FILTERING + +You can also use the interactive mode, enabled with C<-I> to filter +and select rows. This mode is complementary, that is, other filter +options are still being respected. + +To enter e filter, hit C, enter a filter string and finish with +C. Use C to select/deselect rows, use C to select all +(visible) rows. + +Commit your selection with C. The selected rows are being fed to +the requested output mode as usual. Abort with C, in which +case the results of the interactive mode are being ignored and all +rows are being fed to output. =head2 COLUMNS @@ -465,6 +480,10 @@ Released under the MIT License, Copyright (c) 201 by Oleku Konko Released under the MIT License, Copyright (c) 2006-2011 Kirill Simonov +=item bubble-table (https://github.com/Evertras/bubble-table) + +Released under the MIT License, Copyright (c) 2022 Brandon Fulljames + =back =head1 AUTHORS