mirror of
https://codeberg.org/scip/tablizer.git
synced 2025-12-17 04:30:56 +01:00
Add interactive filter/selection tool (#58)
This commit is contained in:
@@ -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
|
||||
|
||||
120
lib/pager.go
Normal file
120
lib/pager.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package lib
|
||||
|
||||
// pager setup using bubbletea
|
||||
// file shamlelessly copied from:
|
||||
// https://github.com/charmbracelet/bubbletea/tree/main/examples/pager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = func() lipgloss.Style {
|
||||
b := lipgloss.RoundedBorder()
|
||||
b.Right = "├"
|
||||
return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1)
|
||||
}()
|
||||
|
||||
infoStyle = func() lipgloss.Style {
|
||||
b := lipgloss.RoundedBorder()
|
||||
b.Left = "┤"
|
||||
return titleStyle.BorderStyle(b)
|
||||
}()
|
||||
)
|
||||
|
||||
type Doc struct {
|
||||
content string
|
||||
title string
|
||||
ready bool
|
||||
viewport viewport.Model
|
||||
}
|
||||
|
||||
func (m Doc) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
headerHeight := lipgloss.Height(m.headerView())
|
||||
footerHeight := lipgloss.Height(m.footerView())
|
||||
verticalMarginHeight := headerHeight + footerHeight
|
||||
|
||||
if !m.ready {
|
||||
// Since this program is using the full size of the viewport we
|
||||
// need to wait until we've received the window dimensions before
|
||||
// we can initialize the viewport. The initial dimensions come in
|
||||
// quickly, though asynchronously, which is why we wait for them
|
||||
// here.
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
|
||||
m.viewport.YPosition = headerHeight
|
||||
m.viewport.SetContent(m.content)
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - verticalMarginHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard and mouse events in the viewport
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
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 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 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)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Pager(title, message string) {
|
||||
p := tea.NewProgram(
|
||||
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
|
||||
)
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Println("could not run pager:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
271
lib/tableeditor.go
Normal file
271
lib/tableeditor.go
Normal file
@@ -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 <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/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
|
||||
}
|
||||
Reference in New Issue
Block a user