mirror of
https://codeberg.org/scip/tablizer.git
synced 2025-12-17 12:31:06 +01:00
272 lines
5.8 KiB
Go
272 lines
5.8 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/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
|
||
|
|
}
|