v0.1.4 tui uniform transparent background policy, tui example added

This commit is contained in:
2026-07-22 10:05:08 -04:00
parent 056401d20a
commit 228e8ac1ad
6 changed files with 1738 additions and 10 deletions
+992
View File
@@ -0,0 +1,992 @@
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) }
+103
View File
@@ -0,0 +1,103 @@
// fv — file voyager: an lf / midnight-commander / xxd hybrid.
// Demo application for the terminal + tui + color packages.
//
// Layout: [parent dir] [current dir list] [preview: info + text/hex/dir table]
// Enter on a file opens a fullscreen hex viewer with byte cursor and search.
//
// Keys (browse): j/k/arrows move · h/l or ←/→ parent/enter · Enter open
//
// gg/G top/bottom · PgUp/PgDn, Ctrl+D/U page · ~ home · . hidden · s sort
// / live filter (Esc clears) · Space mark · a mark-all · u unmark
// y yank paths · r rename · m mkdir · D delete (confirm) · T theme
// ? help · Ctrl+L redraw · q quit · Q quit + exec $SHELL in last dir
//
// Keys (hex): hjkl/arrows · 0/$ row ends · g/G · PgUp/PgDn · Ctrl+D/U
//
// / search (hex bytes like "de ad" / "0xdead", or literal text) · n/N cycle
// Esc/q back
package main
import (
"fmt"
"os"
"path/filepath"
"syscall"
"time"
"github.com/lixenwraith/terminal"
)
const tickInterval = 100 * time.Millisecond // 10 fps animation clock
func main() {
term := terminal.New()
// Raw-mode safety net: restore the terminal before re-panicking.
defer func() {
if r := recover(); r != nil {
terminal.EmergencyReset(os.Stdout)
panic(r)
}
}()
if err := term.Init(); err != nil {
fmt.Fprintln(os.Stderr, "terminal init:", err)
os.Exit(1)
}
_ = term.SetMouseMode(terminal.MouseModeClick) // wheel + click select
start, err := os.Getwd()
if err != nil {
start = "/"
}
app := newApp(term, start)
if err := app.loadDir(start, ""); err != nil {
term.Fini()
fmt.Fprintln(os.Stderr, "read dir:", err)
os.Exit(1)
}
// Animation clock: the input reader never emits {EventKey, KeyNone}
// (unknown sequences are swallowed), so it is a collision-free
// synthetic tick marker through the MPSC event channel.
tickDone := make(chan struct{})
go func() {
t := time.NewTicker(tickInterval)
defer t.Stop()
for {
select {
case <-tickDone:
return
case <-t.C:
term.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone})
}
}
}()
for !app.quit {
app.render()
app.handleEvent(term.PollEvent())
}
close(tickDone)
term.Fini()
// --- cd-on-exit -----------------------------------------------------
// A child cannot change the parent shell's cwd. Best scriptless
// approximation: chdir + exec a fresh $SHELL (Q). Plain quit (q)
// records the dir for optional shell-function integration.
last := app.br.cwd
_ = os.Chdir(last)
if dir, err := os.UserCacheDir(); err == nil {
_ = os.WriteFile(filepath.Join(dir, "fv_lastdir"), []byte(last+"\n"), 0o600)
}
if app.execShell {
sh := os.Getenv("SHELL")
if sh == "" {
sh = "/bin/sh"
}
if _, err := os.Stat(sh); err == nil {
_ = syscall.Exec(sh, []string{sh}, os.Environ()) // no return on success
}
}
fmt.Fprintln(os.Stderr, last)
}
+600
View File
@@ -0,0 +1,600 @@
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:])
}
+2 -5
View File
@@ -28,9 +28,7 @@ func (r Region) Pane(opts PaneOpts) Region {
r.Box(opts.Border, opts.BorderFg)
// Title on top edge
headerH := 0
if opts.Title != "" {
headerH = 1
title := " " + opts.Title + " "
if RuneLen(title) > r.W-4 {
title = Truncate(title, r.W-4)
@@ -44,8 +42,8 @@ func (r Region) Pane(opts PaneOpts) Region {
}
}
// Return content region (inside border, below title)
return r.Sub(1, 1+headerH, r.W-2, r.H-2-headerH)
// Content region is everything inside the border. The title sits ON the border row.
return r.Sub(1, 1, r.W-2, r.H-2)
}
// TitledPane fills region with background, draws centered title at top, returns content region
@@ -68,4 +66,3 @@ func (r Region) TitledPaneFocused(title string, titleFg, bg, focusBg color.RGB,
}
return r.TitledPane(title, titleFg, bg)
}
+31 -4
View File
@@ -65,7 +65,11 @@ func (r Region) Inset(n int) Region {
return r.Sub(n, n, r.W-2*n, r.H-2*n)
}
// Cell sets a single cell with bounds checking
// Cell sets a single cell with bounds checking.
// A zero-value bg (color.RGB{}) is transparent: the existing cell's
// background is preserved. Establish a base background first (Fill, or
// pre-initialized cell buffer). For a literal black background use
// CellOpaque or Fill(color.RGB{}).
func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
@@ -81,15 +85,39 @@ func (r Region) Cell(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
idx := absY*r.TotalW + absX
// Single bounds check for the backing slice
if uint(idx) < uint(len(r.Cells)) {
if bg == (color.RGB{}) {
bg = r.Cells[idx].Bg // transparent: inherit current background
}
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
}
}
// Fill fills entire region with background color
// CellOpaque sets a single cell writing bg verbatim, including zero (black).
// Used by base-layer operations (Fill) that must be able to write any color.
func (r Region) CellOpaque(x, y int, ch rune, fg, bg color.RGB, attr terminal.Attr) {
if x < 0 || x >= r.W || y < 0 || y >= r.H {
return
}
absX := r.X + x
absY := r.Y + y
if uint(absX) >= uint(r.TotalW) {
return
}
idx := absY*r.TotalW + absX
if uint(idx) < uint(len(r.Cells)) {
r.Cells[idx] = terminal.Cell{Rune: ch, Fg: fg, Bg: bg, Attrs: attr}
}
}
// Fill fills entire region with background color.
// Opaque by definition: Fill establishes the base layer, so bg is written
// verbatim (including zero/black) rather than treated as transparent.
func (r Region) Fill(bg color.RGB) {
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
r.Cell(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
r.CellOpaque(x, y, ' ', color.RGB{}, bg, terminal.AttrNone)
}
}
}
@@ -113,4 +141,3 @@ func (r Region) Height() int {
func (r Region) Bounds() (x, y, w, h int) {
return r.X, r.Y, r.W, r.H
}
+10 -1
View File
@@ -129,7 +129,15 @@ func (r Region) Table(headers []string, rows [][]string, opts TableOpts) {
}
// renderTableRow renders a single table row
// The row is cleared to style.Bg across the full region width first, so
// alternating-row backgrounds render as continuous bands rather than only
// under glyphs. A zero style.Bg inherits the underlying background.
func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Align, sep rune, style Style) {
// Clear row with row background (full width, List-consistent banding)
for cx := 0; cx < r.W; cx++ {
r.Cell(cx, y, ' ', style.Fg, style.Bg, terminal.AttrNone)
}
x := 0
for i, w := range widths {
if x >= r.W {
@@ -177,4 +185,5 @@ func (r Region) renderTableRow(y int, cells []string, widths []int, aligns []Ali
x++
}
}
}
}