// 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 }