Files

601 lines
18 KiB
Go

package main
import (
"fmt"
"math"
"path/filepath"
"sort"
"strings"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
"github.com/lixenwraith/terminal/tui"
)
// --- palettes ---------------------------------------------------------------
type palette struct {
name string
th tui.Theme
accent, accent2 color.RGB
hexNull, hexPrint, hexSpace, hexCtrl color.RGB
hexHigh, matchBg, matchCurBg, markDim color.RGB
}
var palettes = []palette{
{ // Tokyo Night
name: "tokyonight",
th: tui.Theme{
Bg: color.RGB{26, 27, 38}, Fg: color.RGB{192, 202, 245},
FocusBg: color.RGB{35, 36, 48}, CursorBg: color.RGB{45, 50, 80},
Selected: color.RGB{158, 206, 106}, Unselected: color.RGB{86, 95, 137},
Partial: color.RGB{125, 207, 255}, Error: color.RGB{247, 118, 142},
Warning: color.RGB{224, 175, 104}, Border: color.RGB{59, 66, 97},
HeaderBg: color.RGB{22, 22, 30}, HeaderFg: color.RGB{192, 202, 245},
StatusFg: color.RGB{140, 152, 200}, HintFg: color.RGB{86, 95, 137},
InputBg: color.RGB{31, 32, 46}, DirFg: color.RGB{122, 162, 247},
FileFg: color.RGB{192, 202, 245}, SymbolFg: color.RGB{125, 207, 255},
},
accent: color.RGB{122, 162, 247}, accent2: color.RGB{187, 154, 247},
hexNull: color.RGB{65, 72, 104}, hexPrint: color.RGB{192, 202, 245},
hexSpace: color.RGB{125, 207, 255}, hexCtrl: color.RGB{187, 154, 247},
hexHigh: color.RGB{255, 158, 100},
matchBg: color.RGB{40, 70, 45}, matchCurBg: color.RGB{60, 110, 60},
markDim: color.RGB{86, 95, 137},
},
{ // One Dark
name: "onedark",
th: tui.Theme{
Bg: color.RGB{40, 44, 52}, Fg: color.RGB{171, 178, 191},
FocusBg: color.RGB{47, 52, 63}, CursorBg: color.RGB{62, 68, 81},
Selected: color.RGB{152, 195, 121}, Unselected: color.RGB{92, 99, 112},
Partial: color.RGB{86, 182, 194}, Error: color.RGB{224, 108, 117},
Warning: color.RGB{229, 192, 123}, Border: color.RGB{76, 82, 99},
HeaderBg: color.RGB{33, 37, 43}, HeaderFg: color.RGB{171, 178, 191},
StatusFg: color.RGB{130, 140, 155}, HintFg: color.RGB{92, 99, 112},
InputBg: color.RGB{33, 37, 43}, DirFg: color.RGB{97, 175, 239},
FileFg: color.RGB{171, 178, 191}, SymbolFg: color.RGB{86, 182, 194},
},
accent: color.RGB{97, 175, 239}, accent2: color.RGB{198, 120, 221},
hexNull: color.RGB{73, 80, 94}, hexPrint: color.RGB{171, 178, 191},
hexSpace: color.RGB{86, 182, 194}, hexCtrl: color.RGB{198, 120, 221},
hexHigh: color.RGB{209, 154, 102},
matchBg: color.RGB{45, 70, 45}, matchCurBg: color.RGB{70, 110, 60},
markDim: color.RGB{92, 99, 112},
},
}
func (a *appState) pal() palette { return palettes[a.themeIdx] }
// pulse returns border color breathing toward the accent (10 fps clock).
func (a *appState) pulse(base, accent color.RGB) color.RGB {
t := 0.35 + 0.35*math.Sin(float64(a.frame)*0.35)
return base.Lerp(accent, t)
}
// --- top-level frame --------------------------------------------------------
func (a *appState) render() {
w, h := a.width, a.height
if w < 4 || h < 3 {
return
}
p := a.pal()
cells := make([]terminal.Cell, w*h)
for i := range cells {
cells[i] = terminal.Cell{Rune: ' ', Fg: p.th.Fg, Bg: p.th.Bg}
}
root := tui.NewRegion(cells, w, 0, 0, w, h)
if w < 60 || h < 12 {
root.Fill(p.th.Bg)
root.TextCenter(h/2, "terminal too small (need ≥60x12) — q quits", p.th.Warning, p.th.Bg, terminal.AttrBold)
a.term.Flush(cells, w, h)
return
}
header, rest := tui.SplitVFixed(root, 1)
body, tail := tui.SplitVFixed(rest, rest.H-2)
status, footer := tui.SplitVFixed(tail, 1)
if a.view == viewHex {
a.renderHexHeader(header)
a.renderHexBody(body)
a.renderHexStatus(status)
a.renderFooter(footer, hexHints)
} else {
a.refreshPreview()
a.renderHeader(header)
a.renderBrowse(body)
a.renderBrowseStatus(status)
a.renderFooter(footer, browseHints)
}
switch a.overlay {
case ovPrompt:
a.renderPrompt(footer) // command line replaces footer row
case ovConfirm:
root.ConfirmDialog(a.confirm, tui.ConfirmOpts{
Title: "Confirm", Message: a.confirmMsg,
YesLabel: "Delete", NoLabel: "Keep", Destructive: true,
})
case ovHelp:
a.renderHelp(root)
}
if a.toast.Visible {
root.Toast(a.toast.Opts)
}
a.term.Flush(cells, w, h)
}
// --- header / footer / status ----------------------------------------------
func (a *appState) renderHeader(r tui.Region) {
p := a.pal()
r.Fill(p.th.HeaderBg)
r.Text(1, 0, " fv ", p.th.Bg, p.accent, terminal.AttrBold)
r.Spinner(6, 0, a.frame, p.accent2)
path := tailTruncate(a.br.cwd, r.W-40)
r.Text(8, 0, path, p.th.HeaderFg, color.RGB{}, terminal.AttrBold)
marks := len(a.br.marks)
hidden := "off"
if a.br.showHidden {
hidden = "on"
}
pos := "0/0"
if n := len(a.br.view); n > 0 {
pos = fmt.Sprintf("%d/%d", a.br.cursor+1, n)
}
hint := tui.Style{Fg: p.th.HintFg}
r.StatusBar(0, []tui.BarSection{
{Label: "theme ", Value: p.name, LabelStyle: hint, ValueStyle: tui.Style{Fg: p.accent2}, Priority: 0},
{Label: "sort ", Value: a.br.sortBy.String(), LabelStyle: hint, ValueStyle: tui.Style{Fg: p.th.StatusFg}, Priority: 1},
{Label: "dot ", Value: hidden, LabelStyle: hint, ValueStyle: tui.Style{Fg: p.th.StatusFg}, Priority: 1},
{Label: "sel ", Value: fmt.Sprintf("%d", marks), LabelStyle: hint, ValueStyle: tui.Style{Fg: p.th.Selected}, Priority: 2},
{Label: "", Value: pos, ValueStyle: tui.Style{Fg: p.th.HeaderFg, Attr: terminal.AttrBold}, Priority: 3},
}, tui.BarOpts{Bg: p.th.HeaderBg, Align: tui.BarAlignRight})
}
var browseHints = [][2]string{
{"↵", "open"}, {"h/l", "nav"}, {"/", "filter"}, {"␣", "mark"},
{"y", "yank"}, {"r", "ren"}, {"m", "mkdir"}, {"D", "del"},
{"s", "sort"}, {".", "dot"}, {"T", "theme"}, {"?", "help"}, {"q/Q", "quit"},
}
var hexHints = [][2]string{
{"hjkl", "move"}, {"0/$", "row"}, {"g/G", "ends"}, {"^D/^U", "half"},
{"/", "search"}, {"n/N", "match"}, {"esc", "back"},
}
func (a *appState) renderFooter(r tui.Region, hints [][2]string) {
p := a.pal()
r.Fill(p.th.HeaderBg)
x := 1
for _, h := range hints {
key, label := " "+h[0]+" ", h[1]+" "
if x+tui.RuneLen(key)+tui.RuneLen(label) > r.W {
break
}
r.Text(x, 0, key, p.th.Bg, p.accent, terminal.AttrBold)
x += tui.RuneLen(key)
r.Text(x, 0, label, p.th.HintFg, p.th.HeaderBg, terminal.AttrNone)
x += tui.RuneLen(label)
}
}
func (a *appState) renderBrowseStatus(r tui.Region) {
p := a.pal()
r.Fill(p.th.HeaderBg)
// size-distribution sparkline of current view (log scale)
vals := make([]float64, 0, 40)
for _, i := range a.br.view {
if len(vals) == 40 {
break
}
vals = append(vals, math.Log1p(float64(a.br.all[i].size)))
}
if len(vals) > 0 {
r.Text(1, 0, "sizes ", p.th.HintFg, color.RGB{}, terminal.AttrDim)
r.Sparkline(7, 0, min(40, len(vals)), vals, tui.SparklineOpts{Style: tui.Style{Fg: p.accent2}})
}
if a.br.filter != "" {
r.Text(50, 0, "/"+a.br.filter, p.th.Warning, color.RGB{}, terminal.AttrBold)
}
r.ScrollIndicator(0, a.br.scroll, a.geom.list.h, len(a.br.view), p.th.StatusFg)
}
// --- browser ----------------------------------------------------------------
func (a *appState) renderBrowse(r tui.Region) {
cols := tui.SplitH(r, 0.18, 0.42, 0.40)
a.renderParent(cols[0])
a.renderList(cols[1])
a.renderPreview(cols[2])
}
func (a *appState) renderParent(r tui.Region) {
p := a.pal()
r.Fill(p.th.Bg)
r.VLine(r.W-1, tui.LineSingle, p.th.Border)
inner := r.Sub(0, 0, r.W-1, r.H)
a.geom.parent = rect{inner.X, inner.Y, inner.W, inner.H}
total := len(a.br.parent)
scroll := tui.AdjustScroll(a.br.parentSel, 0, inner.H, total)
for y := 0; y < inner.H; y++ {
idx := scroll + y
if idx >= total {
break
}
e := a.br.parent[idx]
bg, fg, attr := p.th.Bg, p.th.HintFg, terminal.AttrNone
if idx == a.br.parentSel {
bg, fg, attr = p.th.FocusBg, p.th.DirFg, terminal.AttrBold
}
for x := 0; x < inner.W; x++ {
inner.Cell(x, y, ' ', fg, bg, terminal.AttrNone)
}
inner.Text(1, y, tui.Truncate(e.name, inner.W-2), fg, bg, attr)
}
}
func (a *appState) renderList(r tui.Region) {
p := a.pal()
content := r.Pane(tui.PaneOpts{
Title: filepath.Base(a.br.cwd),
Border: tui.LineRounded, BorderFg: a.pulse(p.th.Border, p.accent),
TitleFg: p.accent, Bg: p.th.Bg,
})
if content.W < 12 || content.H < 1 {
return
}
listR := content.Sub(0, 0, content.W-1, content.H) // reserve right col for scrollbar
a.geom.list = rect{listR.X, listR.Y, listR.W, listR.H}
b := &a.br
b.scroll = tui.AdjustScroll(b.cursor, b.scroll, listR.H, len(b.view))
if len(b.view) == 0 {
msg := "empty"
if b.filter != "" {
msg = "no match for /" + b.filter
}
listR.TextCenter(listR.H/2, msg, p.th.HintFg, p.th.Bg, terminal.AttrDim)
} else {
items := make([]tui.ListItem, len(b.view))
nameW := max(4, listR.W-15) // "[x] " + name + " " + 7-char size
for vi, ei := range b.view {
e := b.all[ei]
fg, attr := p.th.FileFg, terminal.AttrNone
suffix := ""
switch {
case e.dir:
fg, attr, suffix = p.th.DirFg, terminal.AttrBold, "/"
case e.symlink:
fg, attr, suffix = p.th.SymbolFg, terminal.AttrItalic, "@"
case e.mode&0o111 != 0:
fg, suffix = p.th.Selected, "*"
}
if strings.HasPrefix(e.name, ".") {
attr |= terminal.AttrDim
}
sz := humanSize(e.size)
if e.dir {
sz = ""
}
text := padRight(tui.Truncate(e.name+suffix, nameW), nameW) + fmt.Sprintf(" %7s", sz)
it := tui.ListItem{Text: text, TextStyle: tui.Style{Fg: fg, Attr: attr}, CheckFg: p.markDim}
if b.marks[e.name] {
it.Check, it.CheckFg = tui.CheckFull, p.th.Selected
it.TextStyle.Fg = p.th.Selected
}
items[vi] = it
}
listR.List(items, b.cursor, b.scroll, tui.ListOpts{
CursorBg: p.th.CursorBg, DefaultBg: p.th.Bg, IconWidth: 1,
})
}
// Correct ScrollBar usage: 1-cell column at the right edge,
// visible == track height, drawn at x=0 of that sub-region.
sb := content.Sub(content.W-1, 0, 1, content.H)
sb.ScrollBar(0, b.scroll, sb.H, len(b.view), p.th.Border)
}
// --- preview ----------------------------------------------------------------
func (a *appState) renderPreview(r tui.Region) {
p := a.pal()
content := r.Pane(tui.PaneOpts{
Title: "preview", Border: tui.LineSingle,
BorderFg: p.th.Border, TitleFg: p.th.HintFg, Bg: p.th.Bg,
})
if content.H < 4 {
return
}
pv := &a.pv
// Info band: full-width FocusBg strip; zero-Bg KeyValue styles inherit it
infoRows := 0
if pv.info != nil {
infoRows = 3
}
if infoRows > 0 {
band := content.Sub(0, 0, content.W, infoRows)
band.Fill(p.th.FocusBg)
ks := tui.Style{Fg: p.th.HintFg}
vs := tui.Style{Fg: p.th.Fg}
band.KeyValue(0, "size", humanSize(pv.info.Size()), ks, vs, ':')
band.KeyValue(1, "mode", pv.info.Mode().String(), ks, vs, ':')
band.KeyValue(2, "mtime", pv.info.ModTime().Format("2006-01-02 15:04:05"), ks, vs, ':')
}
y := infoRows
content.Divider(y, "", tui.LineSingle, p.th.Border)
y++
body := content.Sub(0, y, content.W, content.H-y)
switch pv.kind {
case pvErr:
body.TextBlock(1, 0, pv.errMsg, p.th.Error, p.th.Bg, terminal.AttrNone)
case pvDir:
rows := make([][]string, 0, min(len(pv.entries), body.H-1))
for _, e := range pv.entries {
if len(rows) == body.H-1 {
break
}
n := e.name
if e.dir {
n += "/"
}
sz := humanSize(e.size)
if e.dir {
sz = ""
}
rows = append(rows, []string{n, sz, e.mtime.Format("01-02 15:04")})
}
body.Table([]string{"name", "size", "modified"}, rows, tui.TableOpts{
HeaderStyle: tui.Style{Fg: p.accent, Attr: terminal.AttrBold},
RowStyle: tui.Style{Fg: p.th.Fg},
AltRowStyle: tui.Style{Fg: p.th.Fg, Bg: p.th.FocusBg},
ColAligns: []tui.Align{tui.AlignLeft, tui.AlignRight, tui.AlignRight},
})
case pvText:
gut := 5
for i, line := range pv.lines {
if i >= body.H {
break
}
body.Text(0, i, fmt.Sprintf("%4d", i+1), p.th.HintFg, p.th.Bg, terminal.AttrDim)
body.Text(gut, i, tui.Truncate(line, body.W-gut), p.th.Fg, p.th.Bg, terminal.AttrNone)
}
case pvHex:
a.renderHexRows(body, pv.raw, 0, hexBPR(body.W), -1)
}
}
// --- hex viewer -------------------------------------------------------------
func hexBPR(w int) int {
// 8 offset + 2 gap + bpr*3 hex + group gaps + 1 gap + bpr ascii
if w >= 10+16*4+2 {
return 16
}
return 8
}
func (a *appState) renderHexHeader(r tui.Region) {
p := a.pal()
r.Fill(p.th.HeaderBg)
r.Text(1, 0, " hex ", p.th.Bg, p.accent2, terminal.AttrBold)
name := tailTruncate(a.hx.path, r.W-30)
r.Text(7, 0, name, p.th.HeaderFg, p.th.HeaderBg, terminal.AttrBold)
if a.hx.truncated {
r.TextRight(0, fmt.Sprintf("[truncated to %s] ", humanSize(hexLoadCap)), p.th.Warning, p.th.HeaderBg, terminal.AttrBold)
}
}
func (a *appState) renderHexBody(r tui.Region) {
p := a.pal()
r.Fill(p.th.Bg)
h := &a.hx
h.bpr = hexBPR(r.W - 1)
h.visRows = r.H
a.hexEnsureVisible()
body := r.Sub(0, 0, r.W-1, r.H)
a.renderHexRows(body, h.data, h.scrollRow, h.bpr, h.cursor)
totalRows := (len(h.data) + h.bpr - 1) / h.bpr
sb := r.Sub(r.W-1, 0, 1, r.H)
sb.ScrollBar(0, h.scrollRow, sb.H, totalRows, p.th.Border)
if len(h.data) == 0 {
body.TextCenter(r.H/2, "empty file", p.th.HintFg, p.th.Bg, terminal.AttrDim)
}
}
// renderHexRows is shared by preview (cursor=-1, no matches) and full view.
func (a *appState) renderHexRows(r tui.Region, data []byte, scrollRow, bpr, cursor int) {
p := a.pal()
h := &a.hx
asciiX := 10 + bpr*3 + bpr/8
for y := 0; y < r.H; y++ {
base := (scrollRow + y) * bpr
if base >= len(data) {
break
}
r.Text(0, y, fmt.Sprintf("%08x", base), p.th.HintFg, p.th.Bg, terminal.AttrDim)
for i := 0; i < bpr; i++ {
off := base + i
hx := 10 + i*3 + i/8 // extra gap every 8 bytes
if off >= len(data) {
break
}
b := data[off]
fg := a.byteColor(b)
bg := p.th.Bg
attr := terminal.AttrNone
if cursor >= 0 && len(h.starts) > 0 { // match highlight (full view only)
if mi, cur := a.matchAt(off); mi {
bg = p.matchBg
if cur {
bg = p.matchCurBg
}
}
}
if off == cursor {
fg, bg, attr = p.th.Bg, p.accent, terminal.AttrBold
}
hi, lo := hexDigit(b>>4), hexDigit(b&0xf)
r.Cell(hx, y, hi, fg, bg, attr)
r.Cell(hx+1, y, lo, fg, bg, attr)
ac := '·'
if b >= 0x20 && b < 0x7f {
ac = rune(b)
}
afg, abg := fg, bg
if off == cursor {
afg, abg = p.th.Bg, p.accent
}
r.Cell(asciiX+i, y, ac, afg, abg, attr)
}
}
}
func (a *appState) matchAt(off int) (in, current bool) {
h := &a.hx
i := sort.SearchInts(h.starts, off+1) - 1
if i < 0 || off >= h.starts[i]+h.patLen {
return false, false
}
return true, i == h.matchIdx
}
func (a *appState) byteColor(b byte) color.RGB {
p := a.pal()
switch {
case b == 0x00:
return p.hexNull
case b == 0x20 || b == 0x09 || b == 0x0a || b == 0x0d:
return p.hexSpace
case b < 0x20 || b == 0x7f:
return p.hexCtrl
case b >= 0x80:
return p.hexHigh
default:
return p.hexPrint
}
}
func hexDigit(n byte) rune {
if n < 10 {
return rune('0' + n)
}
return rune('a' + n - 10)
}
func (a *appState) renderHexStatus(r tui.Region) {
p := a.pal()
r.Fill(p.th.HeaderBg)
h := &a.hx
n := len(h.data)
pct := 0
if n > 1 {
pct = h.cursor * 100 / (n - 1)
}
r.Gauge(1, 0, 22, pct, 100, p.accent, p.th.HeaderBg)
valStr, off := "--", "--"
if n > 0 {
b := h.data[h.cursor]
ch := "·"
if b >= 0x20 && b < 0x7f {
ch = string(rune(b))
}
valStr = fmt.Sprintf("0x%02x %3d 0b%08b '%s'", b, b, b, ch)
off = fmt.Sprintf("0x%08x/%08x", h.cursor, n)
}
match := "-"
if len(h.starts) > 0 {
match = fmt.Sprintf("%d/%d", h.matchIdx+1, len(h.starts))
}
hint := tui.Style{Fg: p.th.HintFg}
r.StatusBar(0, []tui.BarSection{
{Label: "byte ", Value: valStr, LabelStyle: hint, ValueStyle: tui.Style{Fg: p.accent2, Attr: terminal.AttrBold}, Priority: 3},
{Label: "match ", Value: match, LabelStyle: hint, ValueStyle: tui.Style{Fg: p.th.Selected}, Priority: 2},
{Label: "off ", Value: off, LabelStyle: hint, ValueStyle: tui.Style{Fg: p.th.StatusFg}, Priority: 1},
}, tui.BarOpts{Bg: p.th.HeaderBg, Align: tui.BarAlignRight})
}
// --- prompt / help / load frame ---------------------------------------------
func (a *appState) renderPrompt(r tui.Region) {
p := a.pal()
prefix := map[promptKind]string{
prFilter: "/", prRename: "rename: ", prMkdir: "mkdir: ", prHexSearch: "search: ",
}[a.prompt]
st := tui.DefaultTextFieldStyle()
st.TextBg, st.PrefixFg, st.TextFg = p.th.InputBg, p.accent, p.th.Fg
st.CursorBg, st.CursorFg = p.th.Fg, p.th.Bg
r.TextField(a.promptTF, tui.TextFieldOpts{
Prefix: prefix, Border: tui.LineNone, Focused: true, Style: st,
Placeholder: "…",
})
}
func (a *appState) renderHelp(root tui.Region) {
p := a.pal()
pairs := [][2]string{
{"j/k ↑/↓", "move"}, {"h/l ←/→", "parent / enter"}, {"Enter", "open dir · hex view file"},
{"gg / G", "top / bottom"}, {"^D / ^U", "half page"}, {"/", "filter (live) · hex search"},
{"Space", "mark"}, {"a / u", "mark all / clear"}, {"y", "yank paths"},
{"r / m / D", "rename / mkdir / delete"}, {". / s", "hidden / sort"},
{"n / N", "next / prev match (hex)"}, {"T", "theme"}, {"^L", "redraw"},
{"q", "quit (writes lastdir)"}, {"Q", "quit + exec $SHELL in dir"},
}
h := len(pairs) + 4
box := tui.Center(root, min(64, root.W-4), min(h, root.H-2))
content := box.Modal(tui.ModalOpts{
Title: "fv — keys", Hint: "any key closes",
Border: tui.LineDouble, BorderFg: p.accent,
TitleFg: p.th.HeaderFg, HintFg: p.th.HintFg, Bg: p.th.FocusBg,
})
ks := tui.Style{Fg: p.accent, Bg: p.th.FocusBg, Attr: terminal.AttrBold}
vs := tui.Style{Fg: p.th.Fg, Bg: p.th.FocusBg}
for i, kv := range pairs {
content.KeyValue(i+1, kv[0], kv[1], ks, vs, ' ')
}
}
// drawLoadFrame renders one determinate-progress frame during hex file load.
func (a *appState) drawLoadFrame(name string, frac float64) {
w, h := a.width, a.height
p := a.pal()
cells := make([]terminal.Cell, w*h)
for i := range cells {
cells[i] = terminal.Cell{Rune: ' ', Fg: p.th.Fg, Bg: p.th.Bg}
}
root := tui.NewRegion(cells, w, 0, 0, w, h)
opts := tui.DefaultProgressOpts("Loading", name, tui.ProgressDeterminate)
opts.Width = min(52, w-6)
opts.Progress = frac
opts.Frame = a.frame
opts.BarFg, opts.AccentFg, opts.Bg, opts.Fg = a.pal().accent, a.pal().accent2, p.th.FocusBg, p.th.Fg
root.ProgressOverlay(opts)
a.term.Flush(cells, w, h)
a.frame++
}
func tailTruncate(s string, w int) string {
if w < 4 {
return ""
}
rs := []rune(s)
if len(rs) <= w {
return s
}
return "…" + string(rs[len(rs)-w+1:])
}