564 lines
15 KiB
Go
564 lines
15 KiB
Go
// Package ui implements the terminal interface of the bulk file renamer.
|
|
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"aehenamer/internal/files"
|
|
"aehenamer/internal/ops"
|
|
)
|
|
|
|
// pageMain is the name of the page holding the main layout. Dialogs are added
|
|
// as further pages on top of it.
|
|
const pageMain = "main"
|
|
|
|
// App is the interactive bulk file renamer.
|
|
type App struct {
|
|
app *tview.Application
|
|
pages *tview.Pages
|
|
|
|
dirInput *tview.InputField
|
|
filePane *tview.Table
|
|
opPane *tview.Table
|
|
addOp *tview.DropDown
|
|
applyBtn *tview.Button
|
|
status *tview.TextView
|
|
|
|
// focusRing is the order in which Tab moves between the main widgets.
|
|
focusRing []tview.Primitive
|
|
|
|
dir string
|
|
loadErr string
|
|
names []string
|
|
entries []files.Entry
|
|
conflicts map[int]string
|
|
|
|
operations []ops.Operation
|
|
|
|
// opRows is how many operations the right pane showed when it was last
|
|
// drawn. An empty pane has no meaningful selection to preserve.
|
|
opRows int
|
|
|
|
// screen, when set, replaces the terminal the interface draws on, and
|
|
// ready is closed once the widgets exist. Only tests use them.
|
|
screen tcell.Screen
|
|
ready chan struct{}
|
|
|
|
// dialogFocus remembers which widget had the focus before a floating
|
|
// window was opened, keyed by page name.
|
|
dialogFocus map[string]tview.Primitive
|
|
|
|
// pending is the operation currently being edited in a dialog. It is shown
|
|
// in the preview before the user confirms it; pendingIndex is the position
|
|
// it will take in the list, or -1 when it is a new operation.
|
|
pending ops.Operation
|
|
pendingIndex int
|
|
}
|
|
|
|
// New returns an application that starts out browsing dir.
|
|
func New(dir string) *App {
|
|
return &App{
|
|
dir: dir,
|
|
pendingIndex: -1,
|
|
dialogFocus: make(map[string]tview.Primitive),
|
|
ready: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Run builds the interface and runs the event loop until the user quits or ctx
|
|
// is cancelled.
|
|
func (a *App) Run(ctx context.Context) error {
|
|
a.build(ctx)
|
|
if a.screen != nil {
|
|
a.app.SetScreen(a.screen)
|
|
}
|
|
|
|
stopped := make(chan struct{})
|
|
defer close(stopped)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
a.app.Stop()
|
|
case <-stopped:
|
|
}
|
|
}()
|
|
|
|
a.reload(ctx)
|
|
a.refresh(ctx)
|
|
close(a.ready)
|
|
|
|
if err := a.app.Run(); err != nil {
|
|
return fmt.Errorf("running the interface: %w", err)
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// build creates every widget and wires the handlers together.
|
|
func (a *App) build(ctx context.Context) {
|
|
a.app = tview.NewApplication().EnableMouse(true).EnablePaste(true)
|
|
a.pages = tview.NewPages()
|
|
|
|
a.buildDirInput(ctx)
|
|
a.buildFilePane(ctx)
|
|
a.buildOpPane(ctx)
|
|
a.buildControls(ctx)
|
|
a.status = tview.NewTextView().SetDynamicColors(true)
|
|
|
|
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(a.opPane, 0, 1, false).
|
|
AddItem(a.controls(), 3, 0, false)
|
|
|
|
main := tview.NewFlex().
|
|
AddItem(a.filePane, 0, 2, false).
|
|
AddItem(right, 0, 1, false)
|
|
|
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(a.dirInput, 3, 0, true).
|
|
AddItem(main, 0, 1, false).
|
|
AddItem(a.status, 1, 0, false)
|
|
|
|
a.pages.AddPage(pageMain, layout, true, true)
|
|
a.focusRing = []tview.Primitive{a.dirInput, a.filePane, a.opPane, a.addOp, a.applyBtn}
|
|
|
|
a.app.SetInputCapture(a.globalKeys(ctx))
|
|
a.app.SetRoot(a.pages, true).SetFocus(a.dirInput)
|
|
}
|
|
|
|
// controls returns the row holding the "add operation" drop-down and the
|
|
// apply button, below the operation list.
|
|
func (a *App) controls() tview.Primitive {
|
|
row := tview.NewFlex().
|
|
AddItem(a.addOp, 0, 1, false).
|
|
AddItem(a.applyBtn, 14, 0, false)
|
|
row.SetBorder(true)
|
|
return row
|
|
}
|
|
|
|
// buildDirInput creates the folder picker at the top of the screen. The file
|
|
// list follows along as the path is typed, as soon as it names a directory.
|
|
func (a *App) buildDirInput(ctx context.Context) {
|
|
a.dirInput = tview.NewInputField().
|
|
SetLabel(" Folder: ").
|
|
SetText(a.dir).
|
|
SetFieldWidth(0)
|
|
a.dirInput.SetBorder(true).SetTitle(" Working folder ")
|
|
|
|
a.dirInput.SetAutocompleteFunc(func(current string) []string {
|
|
return subdirectories(current)
|
|
})
|
|
a.dirInput.SetChangedFunc(func(text string) {
|
|
if info, err := os.Stat(text); err == nil && info.IsDir() {
|
|
a.dir = text
|
|
a.reload(ctx)
|
|
a.refresh(ctx)
|
|
}
|
|
})
|
|
a.dirInput.SetDoneFunc(func(key tcell.Key) {
|
|
if key != tcell.KeyEnter {
|
|
return
|
|
}
|
|
a.dir = a.dirInput.GetText()
|
|
a.reload(ctx)
|
|
a.refresh(ctx)
|
|
if a.loadErr == "" {
|
|
a.app.SetFocus(a.filePane)
|
|
}
|
|
})
|
|
a.dirInput.SetFocusFunc(func() {
|
|
a.setStatus("[white]Type a path (Tab completes) · [yellow]Enter[white] loads it · [yellow]Tab[white] next pane · [yellow]F1[white] help")
|
|
})
|
|
}
|
|
|
|
// buildFilePane creates the left pane listing current and proposed names.
|
|
func (a *App) buildFilePane(ctx context.Context) {
|
|
a.filePane = tview.NewTable().
|
|
SetFixed(1, 0).
|
|
SetSelectable(true, false)
|
|
a.filePane.SetBorder(true).SetTitle(" Files ")
|
|
|
|
a.filePane.SetFocusFunc(func() {
|
|
a.setStatus("[white]↑↓ select · [yellow]x[white]/[yellow]Del[white] drop file from the list · [yellow]Ctrl-R[white] reload folder · [yellow]Tab[white] next pane")
|
|
a.showRowProblem()
|
|
})
|
|
a.filePane.SetSelectionChangedFunc(func(row, column int) {
|
|
if a.filePane.HasFocus() {
|
|
a.showRowProblem()
|
|
}
|
|
})
|
|
a.filePane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
switch {
|
|
case event.Key() == tcell.KeyDelete, event.Rune() == 'x':
|
|
a.removeSelectedFile(ctx)
|
|
return nil
|
|
case event.Key() == tcell.KeyCtrlR:
|
|
a.reload(ctx)
|
|
a.refresh(ctx)
|
|
return nil
|
|
}
|
|
return event
|
|
})
|
|
}
|
|
|
|
// buildOpPane creates the right pane listing the operations to apply.
|
|
func (a *App) buildOpPane(ctx context.Context) {
|
|
a.opPane = tview.NewTable().SetSelectable(true, false)
|
|
a.opPane.SetBorder(true).SetTitle(" Operations ")
|
|
|
|
a.opPane.SetFocusFunc(func() {
|
|
a.setStatus("[white]↑↓ select · [yellow]Enter[white]/[yellow]e[white] edit · [yellow]x[white]/[yellow]Del[white] remove · [yellow]u[white]/[yellow]d[white] move up/down · [yellow]a[white] add")
|
|
})
|
|
a.opPane.SetSelectedFunc(func(row, column int) {
|
|
a.editOperation(ctx, row)
|
|
})
|
|
a.opPane.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
row, _ := a.opPane.GetSelection()
|
|
switch {
|
|
case event.Key() == tcell.KeyDelete, event.Rune() == 'x':
|
|
a.removeOperation(ctx, row)
|
|
case event.Rune() == 'e':
|
|
a.editOperation(ctx, row)
|
|
case event.Rune() == 'u':
|
|
a.moveOperation(ctx, row, -1)
|
|
case event.Rune() == 'd':
|
|
a.moveOperation(ctx, row, 1)
|
|
case event.Rune() == 'a':
|
|
a.app.SetFocus(a.addOp)
|
|
default:
|
|
return event
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// buildControls creates the "add operation" drop-down and the apply button.
|
|
func (a *App) buildControls(ctx context.Context) {
|
|
labels := make([]string, len(ops.Kinds))
|
|
for i, kind := range ops.Kinds {
|
|
labels[i] = string(kind)
|
|
}
|
|
|
|
a.addOp = tview.NewDropDown().
|
|
SetLabel(" Add operation ").
|
|
SetOptions(labels, nil)
|
|
a.addOp.SetSelectedFunc(func(text string, index int) {
|
|
if index < 0 || index >= len(ops.Kinds) {
|
|
return
|
|
}
|
|
kind := ops.Kinds[index]
|
|
// Clear the choice so that the same operation can be picked again.
|
|
a.addOp.SetCurrentOption(-1)
|
|
a.openOperationDialog(ctx, kind, ops.DefaultConfig(kind), -1)
|
|
})
|
|
a.addOp.SetFocusFunc(func() {
|
|
a.setStatus("[white]Press [yellow]Enter[white] or click to open the list of operations · [yellow]↑↓[white] choose · [yellow]Esc[white] cancel")
|
|
})
|
|
|
|
a.applyBtn = tview.NewButton("Apply rename").SetSelectedFunc(func() {
|
|
a.confirmApply(ctx)
|
|
})
|
|
a.applyBtn.SetFocusFunc(func() {
|
|
a.setStatus("[white]Press [yellow]Enter[white] to rename the listed files on disk · [yellow]Tab[white] next pane")
|
|
})
|
|
}
|
|
|
|
// globalKeys returns the application wide key handler.
|
|
func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey {
|
|
return func(event *tcell.EventKey) *tcell.EventKey {
|
|
if name, _ := a.pages.GetFrontPage(); name != pageMain {
|
|
// A dialog is open; it handles its own keys.
|
|
return event
|
|
}
|
|
switch event.Key() {
|
|
case tcell.KeyCtrlQ:
|
|
a.app.Stop()
|
|
return nil
|
|
case tcell.KeyF1:
|
|
a.showHelp()
|
|
return nil
|
|
case tcell.KeyCtrlR:
|
|
a.reload(ctx)
|
|
a.refresh(ctx)
|
|
return nil
|
|
case tcell.KeyTab:
|
|
a.closeOperationMenu()
|
|
a.cycleFocus(1)
|
|
return nil
|
|
case tcell.KeyBacktab:
|
|
a.closeOperationMenu()
|
|
a.cycleFocus(-1)
|
|
return nil
|
|
}
|
|
return event
|
|
}
|
|
}
|
|
|
|
// closeOperationMenu closes the drop-down's embedded list before focus moves
|
|
// elsewhere. Otherwise the list remains internally open after a global Tab
|
|
// shortcut pulls focus away from it.
|
|
func (a *App) closeOperationMenu() {
|
|
if !a.addOp.IsOpen() {
|
|
return
|
|
}
|
|
handler := a.addOp.InputHandler()
|
|
if handler == nil {
|
|
return
|
|
}
|
|
handler(tcell.NewEventKey(tcell.KeyEscape, 0, tcell.ModNone), func(p tview.Primitive) {
|
|
a.app.SetFocus(p)
|
|
})
|
|
}
|
|
|
|
// cycleFocus moves the focus by delta positions around the focus ring.
|
|
func (a *App) cycleFocus(delta int) {
|
|
current := a.app.GetFocus()
|
|
for i, p := range a.focusRing {
|
|
if p != current {
|
|
continue
|
|
}
|
|
next := (i + delta + len(a.focusRing)) % len(a.focusRing)
|
|
a.app.SetFocus(a.focusRing[next])
|
|
return
|
|
}
|
|
a.app.SetFocus(a.focusRing[0])
|
|
}
|
|
|
|
// reload reads the working folder again, dropping any files the user had
|
|
// removed from the list.
|
|
func (a *App) reload(ctx context.Context) {
|
|
names, err := files.List(ctx, a.dir)
|
|
if err != nil {
|
|
a.loadErr = err.Error()
|
|
a.names = nil
|
|
return
|
|
}
|
|
a.loadErr = ""
|
|
a.names = names
|
|
}
|
|
|
|
// effectiveOps returns the operation list including the operation currently
|
|
// being edited, so that a dialog can be previewed before it is confirmed.
|
|
func (a *App) effectiveOps() []ops.Operation {
|
|
if a.pending == nil {
|
|
return a.operations
|
|
}
|
|
list := make([]ops.Operation, len(a.operations))
|
|
copy(list, a.operations)
|
|
if a.pendingIndex >= 0 && a.pendingIndex < len(list) {
|
|
list[a.pendingIndex] = a.pending
|
|
return list
|
|
}
|
|
return append(list, a.pending)
|
|
}
|
|
|
|
// refresh recomputes the proposed names and redraws both panes.
|
|
func (a *App) refresh(ctx context.Context) {
|
|
proposed := ops.Preview(a.names, a.effectiveOps())
|
|
a.entries = make([]files.Entry, len(a.names))
|
|
for i, name := range a.names {
|
|
a.entries[i] = files.Entry{Original: name, Proposed: proposed[i]}
|
|
}
|
|
a.conflicts = files.Conflicts(ctx, a.dir, a.entries)
|
|
|
|
a.drawFiles(ctx)
|
|
a.drawOperations(ctx)
|
|
}
|
|
|
|
// drawFiles fills the left pane with the current and proposed names.
|
|
func (a *App) drawFiles(ctx context.Context) {
|
|
selected, _ := a.filePane.GetSelection()
|
|
a.filePane.Clear()
|
|
a.filePane.SetSelectable(len(a.entries) > 0, false)
|
|
|
|
header := func(column int, text string, expansion int) {
|
|
a.filePane.SetCell(0, column, tview.NewTableCell(text).
|
|
SetTextColor(tcell.ColorYellow).
|
|
SetSelectable(false).
|
|
SetExpansion(expansion))
|
|
}
|
|
header(0, " Current name", 1)
|
|
header(1, " New name", 1)
|
|
header(2, "", 0)
|
|
|
|
for i, entry := range a.entries {
|
|
row := i + 1
|
|
index := i
|
|
|
|
a.filePane.SetCell(row, 0, tview.NewTableCell(" "+tview.Escape(entry.Original)).SetExpansion(1))
|
|
|
|
proposed := tview.NewTableCell(" " + tview.Escape(entry.Proposed)).SetExpansion(1)
|
|
switch {
|
|
case a.conflicts[index] != "":
|
|
proposed.SetTextColor(tcell.ColorRed)
|
|
case entry.Changed():
|
|
proposed.SetTextColor(tcell.ColorGreen)
|
|
default:
|
|
proposed.SetTextColor(tcell.ColorGray)
|
|
}
|
|
a.filePane.SetCell(row, 1, proposed)
|
|
|
|
remove := tview.NewTableCell(" ✕ ").
|
|
SetTextColor(tcell.ColorRed).
|
|
SetAlign(tview.AlignCenter)
|
|
remove.Clicked = func() bool {
|
|
a.removeFile(ctx, index)
|
|
return true
|
|
}
|
|
a.filePane.SetCell(row, 2, remove)
|
|
}
|
|
|
|
title := fmt.Sprintf(" Files (%d) — %s ", len(a.entries), tview.Escape(a.dir))
|
|
if a.loadErr != "" {
|
|
title = " Files — folder cannot be read "
|
|
}
|
|
a.filePane.SetTitle(title)
|
|
|
|
last := len(a.entries)
|
|
if last == 0 {
|
|
a.filePane.Select(0, 0)
|
|
return
|
|
}
|
|
a.filePane.Select(clamp(selected, 1, last), 0)
|
|
}
|
|
|
|
// drawOperations fills the right pane with the operation list.
|
|
func (a *App) drawOperations(ctx context.Context) {
|
|
selected := 0
|
|
if a.opRows > 0 {
|
|
selected, _ = a.opPane.GetSelection()
|
|
}
|
|
a.opRows = len(a.operations)
|
|
a.opPane.Clear()
|
|
a.opPane.SetSelectable(len(a.operations) > 0, false)
|
|
|
|
if len(a.operations) == 0 {
|
|
a.opPane.SetCell(0, 0, tview.NewTableCell(" No operations yet — use “Add operation” below ").
|
|
SetTextColor(tcell.ColorGray).
|
|
SetSelectable(false).
|
|
SetExpansion(1))
|
|
a.opPane.Select(0, 0)
|
|
return
|
|
}
|
|
|
|
for i, op := range a.operations {
|
|
index := i
|
|
a.opPane.SetCell(i, 0, tview.NewTableCell(fmt.Sprintf(" %d.", i+1)).
|
|
SetTextColor(tcell.ColorYellow))
|
|
a.opPane.SetCell(i, 1, tview.NewTableCell(tview.Escape(op.Summary())).SetExpansion(1))
|
|
|
|
remove := tview.NewTableCell(" [Remove] ").SetTextColor(tcell.ColorRed)
|
|
remove.Clicked = func() bool {
|
|
a.removeOperation(ctx, index)
|
|
return true
|
|
}
|
|
a.opPane.SetCell(i, 2, remove)
|
|
}
|
|
|
|
a.opPane.Select(clamp(selected, 0, len(a.operations)-1), 0)
|
|
}
|
|
|
|
// clamp confines n to the inclusive range [low, high].
|
|
func clamp(n, low, high int) int {
|
|
if n < low {
|
|
return low
|
|
}
|
|
if n > high {
|
|
return high
|
|
}
|
|
return n
|
|
}
|
|
|
|
// removeSelectedFile drops the file under the cursor from the list.
|
|
func (a *App) removeSelectedFile(ctx context.Context) {
|
|
row, _ := a.filePane.GetSelection()
|
|
a.removeFile(ctx, row-1)
|
|
}
|
|
|
|
// removeFile drops the file at index from the list. The file itself is left
|
|
// untouched on disk.
|
|
func (a *App) removeFile(ctx context.Context, index int) {
|
|
if index < 0 || index >= len(a.names) {
|
|
return
|
|
}
|
|
name := a.names[index]
|
|
a.names = append(a.names[:index:index], a.names[index+1:]...)
|
|
a.refresh(ctx)
|
|
a.setStatus("[white]Removed [yellow]%s[white] from the list · [yellow]Ctrl-R[white] brings it back", tview.Escape(name))
|
|
}
|
|
|
|
// removeOperation deletes the operation at index.
|
|
func (a *App) removeOperation(ctx context.Context, index int) {
|
|
if index < 0 || index >= len(a.operations) {
|
|
return
|
|
}
|
|
summary := a.operations[index].Summary()
|
|
a.operations = append(a.operations[:index:index], a.operations[index+1:]...)
|
|
a.refresh(ctx)
|
|
a.setStatus("[white]Removed operation: %s", tview.Escape(summary))
|
|
}
|
|
|
|
// moveOperation moves the operation at index by delta places, changing the
|
|
// order in which operations are applied.
|
|
func (a *App) moveOperation(ctx context.Context, index, delta int) {
|
|
target := index + delta
|
|
if index < 0 || index >= len(a.operations) || target < 0 || target >= len(a.operations) {
|
|
return
|
|
}
|
|
a.operations[index], a.operations[target] = a.operations[target], a.operations[index]
|
|
a.refresh(ctx)
|
|
a.opPane.Select(target, 0)
|
|
}
|
|
|
|
// showRowProblem explains in the status bar why the selected file cannot be
|
|
// renamed, if it cannot.
|
|
func (a *App) showRowProblem() {
|
|
row, _ := a.filePane.GetSelection()
|
|
if reason, bad := a.conflicts[row-1]; bad {
|
|
a.setStatus("[red]%s[white] — fix the operations or drop the file with [yellow]x", reason)
|
|
}
|
|
}
|
|
|
|
// setStatus writes a message to the status bar. The text may contain tview
|
|
// colour tags.
|
|
func (a *App) setStatus(format string, args ...any) {
|
|
a.status.SetText(" " + fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// subdirectories returns directory paths that continue the partially typed
|
|
// path, for the folder input's autocompletion.
|
|
func subdirectories(current string) []string {
|
|
if current == "" {
|
|
return nil
|
|
}
|
|
dir, prefix := filepath.Split(current)
|
|
search := dir
|
|
if search == "" {
|
|
search = "."
|
|
}
|
|
entries, err := os.ReadDir(search)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
const maxEntries = 25
|
|
var out []string
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
|
continue
|
|
}
|
|
out = append(out, dir+entry.Name())
|
|
if len(out) == maxEntries {
|
|
break
|
|
}
|
|
}
|
|
if len(out) == 1 && out[0] == current {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|