diff --git a/cmd/example-tui/app.go b/cmd/example-tui/app.go new file mode 100644 index 0000000..8458559 --- /dev/null +++ b/cmd/example-tui/app.go @@ -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) } diff --git a/cmd/example-tui/main.go b/cmd/example-tui/main.go new file mode 100644 index 0000000..9735178 --- /dev/null +++ b/cmd/example-tui/main.go @@ -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) +} diff --git a/cmd/example-tui/render.go b/cmd/example-tui/render.go new file mode 100644 index 0000000..3073566 --- /dev/null +++ b/cmd/example-tui/render.go @@ -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:]) +} diff --git a/tui/pane.go b/tui/pane.go index 39bf03d..d211b7d 100644 --- a/tui/pane.go +++ b/tui/pane.go @@ -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) } - diff --git a/tui/region.go b/tui/region.go index e68aea8..11fc932 100644 --- a/tui/region.go +++ b/tui/region.go @@ -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 } - diff --git a/tui/table.go b/tui/table.go index f2d7ad8..bdbec8b 100644 --- a/tui/table.go +++ b/tui/table.go @@ -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++ } } -} \ No newline at end of file +} +