Files
terminal/cmd/example-tui/app.go
T

993 lines
20 KiB
Go

package main
import (
"cmp"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"unicode"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
)
const (
hexLoadCap = 8 << 20 // full-view byte cap; larger files load truncated
previewCap = 4096 // preview read size
maxMatches = 5000 // search result cap
toastFrames = 25 // ~2.5 s at 10 fps
)
// --- model -----------------------------------------------------------------
type sortMode uint8
const (
sortName sortMode = iota
sortSize
sortTime
sortModeCount
)
func (s sortMode) String() string { return [...]string{"name", "size", "time"}[s] }
type entry struct {
name string
dir bool
symlink bool
size int64
mode fs.FileMode
mtime time.Time
broken bool // stat failed
}
type browser struct {
cwd string
all []entry // sorted superset
view []int // indices into all, after hidden+filter
cursor int // index into view
scroll int
marks map[string]bool
filter string
sortBy sortMode
showHidden bool
parent []entry // left column
parentSel int
}
type previewKind uint8
const (
pvNone previewKind = iota
pvText
pvHex
pvDir
pvErr
)
type preview struct {
kind previewKind
path string // cache key
lines []string
raw []byte
entries []entry
info fs.FileInfo
errMsg string
}
type hexView struct {
path string
data []byte
truncated bool
cursor int
scrollRow int
bpr int // bytes/row, set by render
visRows int // set by render
starts []int
patLen int
matchIdx int
query string
queryHex bool
}
type viewMode uint8
const (
viewBrowse viewMode = iota
viewHex
)
type overlayMode uint8
const (
ovNone overlayMode = iota
ovPrompt
ovConfirm
ovHelp
)
type promptKind uint8
const (
prFilter promptKind = iota
prRename
prMkdir
prHexSearch
)
type rect struct{ x, y, w, h int }
func (r rect) contains(x, y int) bool { return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h }
type appState struct {
term terminal.Terminal
width int
height int
frame int
quit bool
execShell bool
themeIdx int
view viewMode
overlay overlayMode
br browser
pv preview
hx hexView
prompt promptKind
promptTF *tui.TextFieldState
confirm *tui.ConfirmState
confirmMsg string
onConfirm func()
toast tui.ToastState
pendingG bool
clip []string
geom struct{ list, parent rect } // last-rendered hit-test rects
}
func newApp(t terminal.Terminal, cwd string) *appState {
w, h := t.Size()
return &appState{
term: t, width: w, height: h,
br: browser{cwd: cwd, marks: map[string]bool{}},
}
}
// --- filesystem ------------------------------------------------------------
func readEntries(dir string) ([]entry, error) {
des, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
out := make([]entry, 0, len(des))
for _, de := range des {
e := entry{name: de.Name(), dir: de.IsDir(), symlink: de.Type()&fs.ModeSymlink != 0}
if fi, err := de.Info(); err == nil {
e.size, e.mode, e.mtime = fi.Size(), fi.Mode(), fi.ModTime()
} else {
e.broken = true
}
out = append(out, e)
}
return out, nil
}
func (a *appState) sortEntries(es []entry) {
by := a.br.sortBy
slices.SortFunc(es, func(x, y entry) int {
if x.dir != y.dir { // dirs first, always
if x.dir {
return -1
}
return 1
}
switch by {
case sortSize:
if c := cmp.Compare(y.size, x.size); c != 0 {
return c
}
case sortTime:
if c := y.mtime.Compare(x.mtime); c != 0 {
return c
}
}
return cmp.Compare(strings.ToLower(x.name), strings.ToLower(y.name))
})
}
// loadDir replaces browser state; keep selects the cursor entry by name.
func (a *appState) loadDir(dir, keep string) error {
es, err := readEntries(dir)
if err != nil {
return err
}
a.br.cwd = dir
a.br.all = es
a.sortEntries(a.br.all)
a.br.filter = ""
clear(a.br.marks)
a.rebuildView(keep)
a.loadParent()
a.pv.path = "" // invalidate preview cache
return nil
}
func (a *appState) loadParent() {
p := filepath.Dir(a.br.cwd)
if p == a.br.cwd { // at root
a.br.parent = []entry{{name: "/", dir: true}}
a.br.parentSel = 0
return
}
es, err := readEntries(p)
if err != nil {
a.br.parent, a.br.parentSel = nil, 0
return
}
a.sortEntries(es)
// parent column shows dirs only (lf-style)
dirs := es[:0]
for _, e := range es {
if e.dir {
dirs = append(dirs, e)
}
}
a.br.parent = dirs
base := filepath.Base(a.br.cwd)
a.br.parentSel = 0
for i, e := range dirs {
if e.name == base {
a.br.parentSel = i
break
}
}
}
func (a *appState) rebuildView(keep string) {
b := &a.br
b.view = b.view[:0]
f := strings.ToLower(b.filter)
for i, e := range b.all {
if !b.showHidden && strings.HasPrefix(e.name, ".") {
continue
}
if f != "" && !strings.Contains(strings.ToLower(e.name), f) {
continue
}
b.view = append(b.view, i)
}
b.cursor = 0
if keep != "" {
for vi, i := range b.view {
if b.all[i].name == keep {
b.cursor = vi
break
}
}
}
if b.cursor >= len(b.view) {
b.cursor = max(0, len(b.view)-1)
}
b.scroll = 0
}
func (a *appState) cursorEntry() (entry, bool) {
b := &a.br
if b.cursor < 0 || b.cursor >= len(b.view) {
return entry{}, false
}
return b.all[b.view[b.cursor]], true
}
func (a *appState) cursorPath() (string, bool) {
e, ok := a.cursorEntry()
if !ok {
return "", false
}
return filepath.Join(a.br.cwd, e.name), true
}
// targetSet returns marked entries, or the cursor entry if none marked.
func (a *appState) targetSet() []entry {
var out []entry
for _, i := range a.br.view {
if a.br.marks[a.br.all[i].name] {
out = append(out, a.br.all[i])
}
}
if len(out) == 0 {
if e, ok := a.cursorEntry(); ok {
out = append(out, e)
}
}
return out
}
// --- preview ---------------------------------------------------------------
func (a *appState) refreshPreview() {
path, ok := a.cursorPath()
if !ok {
a.pv = preview{kind: pvNone, path: ""}
return
}
if a.pv.path == path {
return // cached
}
pv := preview{path: path}
fi, err := os.Lstat(path)
if err != nil {
pv.kind, pv.errMsg = pvErr, err.Error()
a.pv = pv
return
}
pv.info = fi
e, _ := a.cursorEntry()
if e.dir {
es, err := readEntries(path)
if err != nil {
pv.kind, pv.errMsg = pvErr, err.Error()
} else {
a.sortEntries(es)
if len(es) > 64 {
es = es[:64]
}
pv.kind, pv.entries = pvDir, es
}
a.pv = pv
return
}
f, err := os.Open(path)
if err != nil {
pv.kind, pv.errMsg = pvErr, err.Error()
a.pv = pv
return
}
buf := make([]byte, previewCap)
n, _ := io.ReadFull(f, buf)
f.Close()
pv.raw = buf[:n]
if isBinary(pv.raw) {
pv.kind = pvHex
} else {
pv.kind = pvText
pv.lines = textLines(pv.raw, 200)
}
a.pv = pv
}
func isBinary(b []byte) bool {
if len(b) == 0 {
return false
}
n := min(len(b), 1024)
bad := 0
for _, c := range b[:n] {
if c == 0 {
return true
}
if c < 0x09 || (c > 0x0d && c < 0x20) {
bad++
}
}
return bad*10 > n*3
}
func textLines(b []byte, maxLines int) []string {
s := strings.ReplaceAll(string(b), "\t", " ")
lines := strings.Split(s, "\n")
if len(lines) > maxLines {
lines = lines[:maxLines]
}
return lines
}
// --- hex viewer ------------------------------------------------------------
// openHex loads the file (capped), rendering a determinate progress overlay
// between chunks — synchronous, so no cross-goroutine state.
func (a *appState) openHex(path string) {
fi, err := os.Stat(path)
if err != nil {
a.say(tui.ToastError, err.Error())
return
}
f, err := os.Open(path)
if err != nil {
a.say(tui.ToastError, err.Error())
return
}
defer f.Close()
total := fi.Size()
loadN := min(total, int64(hexLoadCap))
data := make([]byte, 0, loadN)
buf := make([]byte, 256<<10)
var read int64
for read < loadN {
want := min(int64(len(buf)), loadN-read)
n, err := f.Read(buf[:want])
if n > 0 {
data = append(data, buf[:n]...)
read += int64(n)
}
a.drawLoadFrame(filepath.Base(path), float64(read)/float64(max(loadN, 1)))
if err != nil {
break
}
}
a.hx = hexView{path: path, data: data, truncated: total > loadN, matchIdx: -1}
a.view = viewHex
}
func decodeHexQuery(s string) ([]byte, bool) {
t := strings.ToLower(strings.NewReplacer(" ", "", "\t", "", "0x", "").Replace(s))
if t == "" || len(t)%2 != 0 {
return nil, false
}
out := make([]byte, len(t)/2)
for i := 0; i < len(t); i += 2 {
hi, ok1 := hexNibble(t[i])
lo, ok2 := hexNibble(t[i+1])
if !ok1 || !ok2 {
return nil, false
}
out[i/2] = hi<<4 | lo
}
return out, true
}
func hexNibble(c byte) (byte, bool) {
switch {
case c >= '0' && c <= '9':
return c - '0', true
case c >= 'a' && c <= 'f':
return c - 'a' + 10, true
}
return 0, false
}
func (a *appState) hexSearch(q string) {
h := &a.hx
pat, isHex := decodeHexQuery(q)
if !isHex {
pat = []byte(q)
}
h.query, h.queryHex, h.patLen = q, isHex, len(pat)
h.starts = h.starts[:0]
for off := 0; len(h.starts) < maxMatches; {
i := indexBytes(h.data[off:], pat)
if i < 0 {
break
}
h.starts = append(h.starts, off+i)
off += i + 1
}
if len(h.starts) == 0 {
h.matchIdx = -1
a.say(tui.ToastWarning, "no match: "+q)
return
}
// jump to first match at or after cursor, wrapping
i := sort.SearchInts(h.starts, h.cursor) % len(h.starts)
h.matchIdx = i
h.cursor = h.starts[i]
a.hexEnsureVisible()
a.say(tui.ToastSuccess, fmt.Sprintf("%d match(es)", len(h.starts)))
}
func indexBytes(hay, pat []byte) int {
if len(pat) == 0 || len(pat) > len(hay) {
return -1
}
return strings.Index(string(hay), string(pat)) // zero-copy in map/index contexts is not needed here; sizes are capped
}
func (a *appState) hexEnsureVisible() {
h := &a.hx
if h.bpr <= 0 || h.visRows <= 0 {
return
}
row := h.cursor / h.bpr
if row < h.scrollRow {
h.scrollRow = row
}
if row >= h.scrollRow+h.visRows {
h.scrollRow = row - h.visRows + 1
}
maxRow := (len(h.data) + h.bpr - 1) / h.bpr
h.scrollRow = max(0, min(h.scrollRow, max(0, maxRow-h.visRows)))
}
func (a *appState) hexMove(delta int) {
h := &a.hx
if len(h.data) == 0 {
return
}
h.cursor = max(0, min(len(h.data)-1, h.cursor+delta))
a.hexEnsureVisible()
}
// --- prompts / confirm / toast --------------------------------------------
func (a *appState) openPrompt(k promptKind, initial string) {
a.prompt = k
a.promptTF = tui.NewTextFieldState(initial)
a.overlay = ovPrompt
}
func (a *appState) openConfirm(msg string, fn func()) {
a.confirm = tui.NewConfirmState(false)
a.confirmMsg = msg
a.onConfirm = fn
a.overlay = ovConfirm
}
func (a *appState) say(sev tui.ToastSeverity, msg string) {
o := tui.DefaultToastOpts(msg, sev)
o.Position = tui.ToastBottomRight
o.Style = tui.ToastStyleRounded
a.toast.Show(o, toastFrames)
}
// --- operations ------------------------------------------------------------
func (a *appState) opEnter() {
e, ok := a.cursorEntry()
if !ok {
return
}
path := filepath.Join(a.br.cwd, e.name)
if e.dir {
if err := a.loadDir(path, ""); err != nil {
a.say(tui.ToastError, err.Error())
}
return
}
a.openHex(path)
}
func (a *appState) opParent() {
p := filepath.Dir(a.br.cwd)
if p == a.br.cwd {
return
}
keep := filepath.Base(a.br.cwd)
if err := a.loadDir(p, keep); err != nil {
a.say(tui.ToastError, err.Error())
}
}
func (a *appState) opDelete() {
ts := a.targetSet()
if len(ts) == 0 {
return
}
msg := fmt.Sprintf("Delete %d item(s)? (files and empty dirs only)", len(ts))
if len(ts) == 1 {
msg = "Delete '" + ts[0].name + "'?"
}
names := make([]string, len(ts))
for i, e := range ts {
names[i] = e.name
}
a.openConfirm(msg, func() {
ok, fail := 0, 0
for _, n := range names {
if err := os.Remove(filepath.Join(a.br.cwd, n)); err != nil {
fail++
} else {
ok++
}
}
keep := ""
if e, has := a.cursorEntry(); has {
keep = e.name
}
_ = a.loadDir(a.br.cwd, keep)
if fail > 0 {
a.say(tui.ToastWarning, fmt.Sprintf("deleted %d, failed %d", ok, fail))
} else {
a.say(tui.ToastSuccess, fmt.Sprintf("deleted %d", ok))
}
})
}
func (a *appState) commitPrompt() {
val := strings.TrimSpace(a.promptTF.Value())
k := a.prompt
a.overlay = ovNone
switch k {
case prFilter:
// live filter already applied per keystroke
case prHexSearch:
if val != "" {
a.hexSearch(val)
}
case prMkdir:
if val == "" || strings.ContainsRune(val, os.PathSeparator) {
a.say(tui.ToastWarning, "invalid name")
return
}
if err := os.Mkdir(filepath.Join(a.br.cwd, val), 0o755); err != nil {
a.say(tui.ToastError, err.Error())
return
}
_ = a.loadDir(a.br.cwd, val)
a.say(tui.ToastSuccess, "created "+val)
case prRename:
e, ok := a.cursorEntry()
if !ok || val == "" || val == e.name || strings.ContainsRune(val, os.PathSeparator) {
return
}
if err := os.Rename(filepath.Join(a.br.cwd, e.name), filepath.Join(a.br.cwd, val)); err != nil {
a.say(tui.ToastError, err.Error())
return
}
_ = a.loadDir(a.br.cwd, val)
a.say(tui.ToastSuccess, "renamed → "+val)
}
}
// --- event handling ---------------------------------------------------------
func (a *appState) handleEvent(ev terminal.Event) {
switch ev.Type {
case terminal.EventResize:
a.width, a.height = ev.Width, ev.Height
return
case terminal.EventClosed, terminal.EventError:
a.quit = true
return
case terminal.EventMouse:
a.handleMouse(ev)
return
}
if ev.Type != terminal.EventKey {
return
}
if ev.Key == terminal.KeyNone { // synthetic tick
a.frame++
if a.toast.Visible {
a.toast.Tick()
}
return
}
// Global
switch ev.Key {
case terminal.KeyCtrlC, terminal.KeyCtrlQ:
a.quit = true
return
case terminal.KeyCtrlL:
a.term.Sync()
return
}
switch a.overlay {
case ovHelp:
a.overlay = ovNone
return
case ovConfirm:
if a.confirm.HandleKey(ev.Key, ev.Rune) {
a.overlay = ovNone
if a.confirm.Result == tui.ConfirmYes && a.onConfirm != nil {
a.onConfirm()
}
a.onConfirm = nil
}
return
case ovPrompt:
switch ev.Key {
case terminal.KeyEscape:
if a.prompt == prFilter {
a.br.filter = ""
a.rebuildView(currentName(a))
}
a.overlay = ovNone
case terminal.KeyEnter:
a.commitPrompt()
default:
if a.promptTF.HandleKey(ev.Key, ev.Rune, ev.Modifiers) && a.prompt == prFilter {
a.br.filter = a.promptTF.Value()
a.rebuildView("")
}
}
return
}
if a.view == viewHex {
a.handleHexKey(ev)
} else {
a.handleBrowseKey(ev)
}
}
func currentName(a *appState) string {
if e, ok := a.cursorEntry(); ok {
return e.name
}
return ""
}
func (a *appState) moveCursor(delta int) {
b := &a.br
if len(b.view) == 0 {
return
}
b.cursor = max(0, min(len(b.view)-1, b.cursor+delta))
}
func (a *appState) handleBrowseKey(ev terminal.Event) {
b := &a.br
pendG := a.pendingG
a.pendingG = false
switch ev.Key {
case terminal.KeyUp:
a.moveCursor(-1)
case terminal.KeyDown:
a.moveCursor(1)
case terminal.KeyLeft, terminal.KeyBackspace:
a.opParent()
case terminal.KeyRight, terminal.KeyEnter:
a.opEnter()
case terminal.KeyHome:
b.cursor = 0
case terminal.KeyEnd:
b.cursor = max(0, len(b.view)-1)
case terminal.KeyPageUp:
a.moveCursor(-tui.PageDelta(a.listVisible()) * 2)
case terminal.KeyPageDown:
a.moveCursor(tui.PageDelta(a.listVisible()) * 2)
case terminal.KeyCtrlU:
a.moveCursor(-tui.PageDelta(a.listVisible()))
case terminal.KeyCtrlD:
a.moveCursor(tui.PageDelta(a.listVisible()))
case terminal.KeyEscape:
if b.filter != "" {
b.filter = ""
a.rebuildView(currentName(a))
} else {
clear(b.marks)
}
case terminal.KeyRune:
switch ev.Rune {
case 'q':
a.quit = true
case 'Q':
a.quit, a.execShell = true, true
case 'j':
a.moveCursor(1)
case 'k':
a.moveCursor(-1)
case 'h':
a.opParent()
case 'l':
a.opEnter()
case 'g':
if pendG {
b.cursor = 0
} else {
a.pendingG = true
}
case 'G':
b.cursor = max(0, len(b.view)-1)
case '~':
if home, err := os.UserHomeDir(); err == nil {
if err := a.loadDir(home, ""); err != nil {
a.say(tui.ToastError, err.Error())
}
}
case ' ':
if e, ok := a.cursorEntry(); ok {
if b.marks[e.name] {
delete(b.marks, e.name)
} else {
b.marks[e.name] = true
}
a.moveCursor(1)
}
case 'a':
for _, i := range b.view {
b.marks[b.all[i].name] = true
}
case 'u':
clear(b.marks)
case '.':
b.showHidden = !b.showHidden
a.rebuildView(currentName(a))
case 's':
b.sortBy = (b.sortBy + 1) % sortModeCount
keep := currentName(a)
a.sortEntries(b.all)
a.rebuildView(keep)
case '/':
a.openPrompt(prFilter, b.filter)
case 'r':
if e, ok := a.cursorEntry(); ok {
a.openPrompt(prRename, e.name)
}
case 'm':
a.openPrompt(prMkdir, "")
case 'D':
a.opDelete()
case 'y':
ts := a.targetSet()
a.clip = a.clip[:0]
for _, e := range ts {
a.clip = append(a.clip, filepath.Join(b.cwd, e.name))
}
a.say(tui.ToastInfo, fmt.Sprintf("yanked %d path(s)", len(a.clip)))
case 'T':
a.themeIdx = (a.themeIdx + 1) % len(palettes)
case '?':
a.overlay = ovHelp
}
}
// keep cursor entry sane after any mutation
if b.cursor >= len(b.view) {
b.cursor = max(0, len(b.view)-1)
}
}
func (a *appState) handleHexKey(ev terminal.Event) {
h := &a.hx
pendG := a.pendingG
a.pendingG = false
half := max(1, h.visRows/2) * h.bpr
switch ev.Key {
case terminal.KeyEscape:
a.view = viewBrowse
case terminal.KeyUp:
a.hexMove(-h.bpr)
case terminal.KeyDown:
a.hexMove(h.bpr)
case terminal.KeyLeft:
a.hexMove(-1)
case terminal.KeyRight:
a.hexMove(1)
case terminal.KeyHome:
h.cursor = 0
a.hexEnsureVisible()
case terminal.KeyEnd:
h.cursor = max(0, len(h.data)-1)
a.hexEnsureVisible()
case terminal.KeyPageUp:
a.hexMove(-h.visRows * h.bpr)
case terminal.KeyPageDown:
a.hexMove(h.visRows * h.bpr)
case terminal.KeyCtrlU:
a.hexMove(-half)
case terminal.KeyCtrlD:
a.hexMove(half)
case terminal.KeyRune:
switch ev.Rune {
case 'q', 'h':
if ev.Rune == 'h' {
a.hexMove(-1)
} else {
a.view = viewBrowse
}
case 'j':
a.hexMove(h.bpr)
case 'k':
a.hexMove(-h.bpr)
case 'l':
a.hexMove(1)
case '0':
a.hexMove(-(h.cursor % max(1, h.bpr)))
case '$':
if h.bpr > 0 {
a.hexMove(h.bpr - 1 - h.cursor%h.bpr)
}
case 'g':
if pendG {
h.cursor = 0
a.hexEnsureVisible()
} else {
a.pendingG = true
}
case 'G':
h.cursor = max(0, len(h.data)-1)
a.hexEnsureVisible()
case '/':
a.openPrompt(prHexSearch, h.query)
case 'n', 'N':
if len(h.starts) == 0 {
break
}
d := 1
if ev.Rune == 'N' {
d = -1
}
h.matchIdx = (h.matchIdx + d + len(h.starts)) % len(h.starts)
h.cursor = h.starts[h.matchIdx]
a.hexEnsureVisible()
}
}
}
func (a *appState) handleMouse(ev terminal.Event) {
if ev.MouseAction != terminal.MouseActionPress {
return
}
switch ev.MouseBtn {
case terminal.MouseBtnWheelUp:
if a.view == viewHex {
a.hexMove(-3 * a.hx.bpr)
} else {
a.moveCursor(-3)
}
case terminal.MouseBtnWheelDown:
if a.view == viewHex {
a.hexMove(3 * a.hx.bpr)
} else {
a.moveCursor(3)
}
case terminal.MouseBtnLeft:
if a.view != viewBrowse || a.overlay != ovNone {
return
}
if a.geom.list.contains(ev.MouseX, ev.MouseY) {
idx := a.br.scroll + (ev.MouseY - a.geom.list.y)
if idx >= 0 && idx < len(a.br.view) {
if a.br.cursor == idx {
a.opEnter() // click on cursor row = open
} else {
a.br.cursor = idx
}
}
} else if a.geom.parent.contains(ev.MouseX, ev.MouseY) {
a.opParent()
}
}
}
func (a *appState) listVisible() int { return a.geom.list.h }
// --- misc helpers -----------------------------------------------------------
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
v := float64(n) / float64(div)
suffix := "KMGTPE"[exp]
if v < 10 {
return fmt.Sprintf("%.1f%c", v, suffix)
}
return fmt.Sprintf("%.0f%c", v, suffix)
}
func padRight(s string, w int) string {
n := 0
for range s {
n++
}
if n >= w {
return s
}
return s + strings.Repeat(" ", w-n)
}
func classify(r rune) bool { return unicode.IsPrint(r) }