gt/internal/interactive/interactive.go
2024-08-14 18:53:25 -07:00

760 lines
17 KiB
Go

// Package interactive implements a charm-powered table to display files.
package interactive
import (
"fmt"
"math"
"os"
"path/filepath"
"slices"
"strings"
"git.burning.moe/celediel/gt/internal/dirs"
"git.burning.moe/celediel/gt/internal/files"
"git.burning.moe/celediel/gt/internal/interactive/modes"
"git.burning.moe/celediel/gt/internal/interactive/sorting"
"golang.org/x/term"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/lithammer/fuzzysearch/fuzzy"
)
const (
uncheck string = "☐"
check string = "☑"
space string = " "
woffset int = 13 // why this number, I don't know
hoffset int = 6
poffset int = 2
filenameColumn string = "filename"
pathColumn string = "path"
modifiedColumn string = "modified"
trashedColumn string = "trashed"
sizeColumn string = "size"
bar string = "───"
// TODO: figure these out dynamically based on longest of each
filenameColumnW float64 = 0.46
pathColumnW float64 = 0.25
dateColumnW float64 = 0.15
sizeColumnW float64 = 0.12
checkColumnW float64 = 0.02
// TODO: make these configurable or something
borderbg string = "5"
hoveritembg string = "13"
black string = "0"
darkblack string = "8"
white string = "7"
darkgray string = "15"
)
var (
style = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(borderbg))
regulartext = lipgloss.NewStyle().
Padding(0, poffset)
darktext = lipgloss.NewStyle().
Foreground(lipgloss.Color(darkgray))
darkertext = lipgloss.NewStyle().
Foreground(lipgloss.Color(darkblack))
darkesttext = lipgloss.NewStyle().
Foreground(lipgloss.Color(black))
)
type model struct {
table table.Model
keys keyMap
selected map[string]bool
selectsize int64
totalsize int64
readonly bool
once bool
filtering bool
filter string
termheight int
termwidth int
mode modes.Mode
sorting sorting.Sorting
workdir string
files files.Files
fltrfiles files.Files
}
func newModel(fls files.Files, selectall, readonly, once bool, workdir string, mode modes.Mode) model {
m := model{
keys: defaultKeyMap(),
readonly: readonly,
once: once,
mode: mode,
selected: map[string]bool{},
selectsize: 0,
files: fls,
totalsize: fls.TotalSize(),
}
m.termwidth, m.termheight = termSizes()
if workdir != "" {
m.workdir = filepath.Clean(workdir)
}
rows := m.freshRows()
columns := m.freshColumns()
theight := min(m.termheight-hoffset, len(fls))
m.table = createTable(columns, rows, theight)
m.sorting = sorting.Name
m.sort()
if selectall {
m.selectAll()
}
return m
}
type keyMap struct {
mark key.Binding
doit key.Binding
todo key.Binding
nada key.Binding
invr key.Binding
rstr key.Binding
clen key.Binding
sort key.Binding
rort key.Binding
fltr key.Binding
clfl key.Binding
apfl key.Binding
bksp key.Binding
quit key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
mark: key.NewBinding(
key.WithKeys(space),
key.WithHelp("space", "toggle"),
),
doit: key.NewBinding(
key.WithKeys("enter", "y"),
key.WithHelp("enter/y", "confirm"),
),
todo: key.NewBinding(
key.WithKeys("a", "ctrl+a"),
key.WithHelp("a", "all"),
),
nada: key.NewBinding(
key.WithKeys("n", "ctrl+n"),
key.WithHelp("n", "none"),
),
invr: key.NewBinding(
key.WithKeys("i", "ctrl+i"),
key.WithHelp("i", "invert"),
),
clen: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "clean"),
),
rstr: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "restore"),
),
sort: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s/S", "sort"),
),
rort: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "sort (reverse)"),
),
fltr: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
apfl: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "apply filter"),
),
clfl: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "clear filter"),
),
bksp: key.NewBinding(
key.WithKeys("backspace"),
key.WithHelp("backspace", "backspace"),
),
quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
}
func (m model) Init() tea.Cmd {
/* if m.onePage() {
m.table.SetStyles(makeUnselectedStyle())
m.unselectAll()
return tea.Quit
} */
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.once {
return m.quit(m.readonly)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.updateTableSize()
case tea.KeyMsg:
if m.filtering {
switch {
case key.Matches(msg, m.keys.clfl):
m.filter = ""
m.filtering = false
case key.Matches(msg, m.keys.apfl):
m.filtering = false
case key.Matches(msg, m.keys.bksp):
if len(m.filter) > 0 {
m.filter = m.filter[:len(m.filter)-1]
}
default:
m.filter += msg.String()
}
m.applyFilter()
return m, cmd
}
switch {
case key.Matches(msg, m.keys.mark):
m.toggleItem(m.table.Cursor())
case key.Matches(msg, m.keys.doit):
if !m.readonly && m.mode != modes.Interactive && len(m.fltrfiles) > 1 {
return m.quit(false)
}
case key.Matches(msg, m.keys.nada):
m.unselectAll()
case key.Matches(msg, m.keys.todo):
m.selectAll()
case key.Matches(msg, m.keys.invr):
m.invertSelection()
case key.Matches(msg, m.keys.clen):
return m.execute(modes.Cleaning)
case key.Matches(msg, m.keys.rstr):
return m.execute(modes.Restoring)
case key.Matches(msg, m.keys.sort):
m.sorting = m.sorting.Next()
m.sort()
case key.Matches(msg, m.keys.rort):
m.sorting = m.sorting.Prev()
m.sort()
case key.Matches(msg, m.keys.fltr):
m.filtering = true
case key.Matches(msg, m.keys.clfl):
if m.filter != "" {
m.filter = ""
m.applyFilter()
}
case key.Matches(msg, m.keys.quit):
return m.quit(true)
}
}
// pass events along to the table
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m model) View() string {
var panels []string
if !m.once {
panels = append(panels, m.header())
}
panels = append(panels, style.Render(m.table.View()), m.footer())
return lipgloss.JoinVertical(lipgloss.Top,
panels...,
)
}
func (m model) showHelp() string {
var filterText string
if m.filter != "" {
filterText = fmt.Sprintf(" (%s)", m.filter)
}
keys := []string{
fmt.Sprintf("%s %s%s", darktext.Render(m.keys.fltr.Help().Key), darkertext.Render(m.keys.fltr.Help().Desc), filterText),
fmt.Sprintf("%s %s (%s)", darktext.Render(m.keys.sort.Help().Key), darkertext.Render(m.keys.sort.Help().Desc), m.sorting.String()),
styleKey(m.keys.quit),
}
if !m.readonly {
if m.mode != modes.Interactive {
keys = append([]string{styleKey(m.keys.doit)}, keys...)
}
keys = append([]string{styleKey(m.keys.mark)}, keys...)
}
return strings.Join(keys, darkesttext.Render(" • "))
}
func (m model) header() string {
var (
right, left string
spacerWidth int
keys = []string{
styleKey(m.keys.rstr),
styleKey(m.keys.clen),
}
selectKeys = []string{
styleKey(m.keys.todo),
styleKey(m.keys.nada),
styleKey(m.keys.invr),
}
filterKeys = []string{
styleKey(m.keys.clfl),
styleKey(m.keys.apfl),
}
dot = darkesttext.Render("•")
wideDot = darkesttext.Render(" • ")
keysFmt = strings.Join(keys, wideDot)
selectFmt = strings.Join(selectKeys, wideDot)
filterFmt = strings.Join(filterKeys, wideDot)
totalSize = humanize.Bytes(uint64(m.totalsize))
selectedSize = fmt.Sprintf("%s/%s", humanize.Bytes(uint64(m.selectsize)), totalSize)
)
switch {
case m.filtering:
right = fmt.Sprintf(" Filtering %s %s", dot, filterFmt)
case m.mode == modes.Interactive:
right = fmt.Sprintf(" %s %s %s", keysFmt, dot, selectFmt)
left = fmt.Sprintf("%d/%d %s %s", len(m.selected), len(m.fltrfiles), dot, selectedSize)
case m.mode == modes.Listing:
var filtered string
if m.filter != "" || m.filtering {
filtered = " (filtered)"
}
right = fmt.Sprintf(" Showing%s %d files in trash (%s)", filtered, len(m.fltrfiles), totalSize)
default:
var wd string
if m.workdir != "" {
wd = " in " + dirs.UnExpand(m.workdir, "")
}
right = fmt.Sprintf(" %s%s %s %s", m.mode.String(), wd, dot, selectFmt)
left = fmt.Sprintf("%d/%d %s %s", len(m.selected), len(m.fltrfiles), dot, selectedSize)
}
spacerWidth = (m.termwidth - lipgloss.Width(right) - lipgloss.Width(left)) + 1
if spacerWidth <= 0 {
spacerWidth = 1 // always at least one space
}
return fmt.Sprintf("%s%s%s", right, strings.Repeat(" ", spacerWidth), left)
}
func (m model) footer() string {
return regulartext.Render(m.showHelp())
}
func (m model) quit(unselectAll bool) (model, tea.Cmd) {
if unselectAll {
m.unselectAll()
} else {
m.onlySelected()
}
m.table.SetStyles(makeUnselectedStyle())
return m, tea.Quit
}
func (m model) execute(mode modes.Mode) (model, tea.Cmd) {
if m.mode != modes.Interactive || len(m.selected) <= 0 || len(m.fltrfiles) <= 0 {
var cmd tea.Cmd
return m, cmd
}
m.mode = mode
m.onlySelected()
m.table.SetStyles(makeUnselectedStyle())
return m, tea.Quit
}
func (m model) selectedFiles() (outfile files.Files) {
for _, file := range m.fltrfiles {
if m.selected[file.String()] {
outfile = append(outfile, file)
}
}
return
}
/* func (m model) onePage() bool {
x := m.termheight
y := len(m.table.Rows()) + hoffset
if x > y && m.readonly {
return true
}
return false
} */
func (m *model) freshRows() (rows []table.Row) {
for _, file := range m.files {
row := newRow(file, m.workdir)
if !m.readonly {
row = append(row, getCheck(false))
}
rows = append(rows, row)
}
return
}
func (m *model) onlySelected() {
var rows = make([]table.Row, 0)
for _, row := range m.table.Rows() {
if row[4] == check {
rows = append(rows, row)
} else {
rows = append(rows, table.Row{})
}
}
m.table.SetRows(rows)
}
// updateRow updates row of provided index with provided row.
func (m *model) updateRow(index int, selected bool) {
rows := m.table.Rows()
row := rows[index]
rows[index] = table.Row{
row[0],
row[1],
row[2],
row[3],
getCheck(selected),
}
m.table.SetRows(rows)
}
func (m *model) updateRows(selected bool) {
var newrows = []table.Row{}
for _, row := range m.table.Rows() {
newRow := table.Row{
row[0],
row[1],
row[2],
row[3],
getCheck(selected),
}
newrows = append(newrows, newRow)
}
m.table.SetRows(newrows)
}
func (m *model) toggleItem(index int) (selected bool) {
if m.readonly || len(m.fltrfiles) == 0 {
return false
}
name := m.fltrfiles[index].String()
size := m.fltrfiles[index].Filesize()
// select the thing
if v, ok := m.selected[name]; v && ok {
// already selected
delete(m.selected, name)
selected = false
m.selectsize -= size
} else {
// not selected
m.selected[name] = true
selected = true
m.selectsize += size
}
// update the rows with the state
m.updateRow(index, selected)
return
}
func (m *model) selectAll() {
if m.readonly || len(m.fltrfiles) == 0 {
return
}
m.selected = map[string]bool{}
m.selectsize = 0
for i := range m.table.Rows() {
m.selected[m.fltrfiles[i].String()] = true
m.selectsize += m.fltrfiles[i].Filesize()
}
m.updateRows(true)
}
func (m *model) unselectAll() {
if m.readonly {
return
}
m.selected = map[string]bool{}
m.selectsize = 0
m.updateRows(false)
}
func (m *model) invertSelection() {
if m.readonly || len(m.fltrfiles) == 0 {
return
}
var newrows []table.Row
for index, row := range m.table.Rows() {
name := m.fltrfiles[index].String()
size := m.fltrfiles[index].Filesize()
if v, ok := m.selected[name]; v && ok {
delete(m.selected, name)
m.selectsize -= size
newrows = append(newrows, table.Row{
row[0],
row[1],
row[2],
row[3],
getCheck(false),
})
} else {
m.selected[name] = true
m.selectsize += size
newrows = append(newrows, table.Row{
row[0],
row[1],
row[2],
row[3],
getCheck(true),
})
}
}
m.table.SetRows(newrows)
}
func (m *model) sort() {
slices.SortStableFunc(m.files, m.sorting.Sorter())
m.applyFilter()
}
func (m *model) applyFilter() {
m.fltrfiles = m.filteredFiles()
var rows = []table.Row{}
for _, file := range m.fltrfiles {
row := newRow(file, m.workdir)
if !m.readonly {
row = append(row, getCheck(m.selected[file.String()]))
}
rows = append(rows, row)
}
if len(rows) < 1 {
row := table.Row{"no files matched filter!", bar, bar, bar}
if !m.readonly {
row = append(row, uncheck)
}
rows = append(rows, row)
}
m.table.SetRows(rows)
m.updateTableHeight()
}
func (m *model) filteredFiles() (filteredFiles files.Files) {
for _, file := range m.files {
if isMatch(m.filter, file.Name()) {
filteredFiles = append(filteredFiles, file)
} else {
if _, ok := m.selected[file.String()]; ok {
delete(m.selected, file.String())
m.selectsize -= file.Filesize()
}
}
}
return
}
func (m *model) freshColumns() []table.Column {
var (
fwidth = int(math.Round(float64(m.termwidth-woffset) * filenameColumnW))
owidth = int(math.Round(float64(m.termwidth-woffset) * pathColumnW))
dwidth = int(math.Round(float64(m.termwidth-woffset) * dateColumnW))
swidth = int(math.Round(float64(m.termwidth-woffset) * sizeColumnW))
cwidth = int(math.Round(float64(m.termwidth-woffset) * checkColumnW))
datecolumn string
)
switch m.mode {
case modes.Trashing:
datecolumn = modifiedColumn
default:
datecolumn = trashedColumn
}
columns := []table.Column{
{Title: filenameColumn, Width: fwidth},
{Title: pathColumn, Width: owidth},
{Title: datecolumn, Width: dwidth},
{Title: sizeColumn, Width: swidth},
}
if !m.readonly {
columns = append(columns, table.Column{Title: uncheck, Width: cwidth})
} else {
columns[0].Width += cwidth
}
return columns
}
func (m *model) updateTableSize() {
width, height := termSizes()
m.termheight = height
m.termwidth = width - poffset
m.table.SetWidth(m.termwidth)
m.updateTableHeight()
m.table.SetColumns(m.freshColumns())
}
func (m *model) updateTableHeight() {
h := min(m.termheight-hoffset, len(m.table.Rows()))
m.table.SetHeight(h)
if m.table.Cursor() >= h {
m.table.SetCursor(h - 1)
}
}
func Select(fls files.Files, selectall, once bool, workdir string, mode modes.Mode) (files.Files, modes.Mode, error) {
mdl := newModel(fls, selectall, false, once, workdir, mode)
endmodel, err := tea.NewProgram(mdl).Run()
if err != nil {
return fls, 0, err
}
m, ok := endmodel.(model)
if !ok {
return fls, 0, fmt.Errorf("model isn't the right type?? what has happened")
}
return m.selectedFiles(), m.mode, nil
}
func Show(fls files.Files, once bool, workdir string) error {
mdl := newModel(fls, false, true, once, workdir, modes.Listing)
if _, err := tea.NewProgram(mdl).Run(); err != nil {
return err
}
return nil
}
func newRow(file files.File, workdir string) table.Row {
var time, size string
name := file.Name()
time = humanize.Time(file.Date())
if file.IsDir() {
name += string(os.PathSeparator)
}
size = humanize.Bytes(uint64(file.Filesize()))
return table.Row{
dirs.PercentDecode(name),
dirs.UnExpand(filepath.Dir(file.Path()), workdir),
time,
size,
}
}
func getCheck(selected bool) (ourcheck string) {
if selected {
ourcheck = check
} else {
ourcheck = uncheck
}
return
}
func createTable(columns []table.Column, rows []table.Row, height int) table.Model {
tbl := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(height),
)
tbl.KeyMap = fixTableKeymap()
tbl.SetStyles(makeStyle())
return tbl
}
func fixTableKeymap() table.KeyMap {
keys := table.DefaultKeyMap()
// remove spacebar from default page down keybind, but keep the rest
keys.PageDown.SetKeys(
slices.DeleteFunc(keys.PageDown.Keys(), func(s string) bool {
return s == space
})...,
)
return keys
}
func makeStyle() table.Styles {
style := table.DefaultStyles()
style.Header = style.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(black)).
BorderBottom(true).
Bold(false)
style.Selected = style.Selected.
Foreground(lipgloss.Color(white)).
Background(lipgloss.Color(hoveritembg)).
Bold(false)
return style
}
func makeUnselectedStyle() table.Styles {
style := makeStyle()
style.Selected = style.Selected.
Foreground(lipgloss.NoColor{}).
Background(lipgloss.NoColor{}).
Bold(false)
return style
}
func styleKey(key key.Binding) string {
return fmt.Sprintf("%s %s", darktext.Render(key.Help().Key), darkertext.Render(key.Help().Desc))
}
func isMatch(pattern, filename string) bool {
p := strings.ToLower(pattern)
f := strings.ToLower(filename)
return fuzzy.Match(p, f)
}
func termSizes() (width int, height int) {
// read the term height and width for tables
var err error
width, height, err = term.GetSize(int(os.Stdout.Fd()))
if err != nil {
width = 80
height = 24
}
return
}