aboutsummaryrefslogtreecommitdiff
path: root/pick.go
diff options
context:
space:
mode:
Diffstat (limited to 'pick.go')
-rw-r--r--pick.go806
1 files changed, 806 insertions, 0 deletions
diff --git a/pick.go b/pick.go
new file mode 100644
index 0000000..e33b088
--- /dev/null
+++ b/pick.go
@@ -0,0 +1,806 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "math"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+
+ "golang.org/x/term"
+)
+
+// ramp maps brightness (dark..light) to characters for the no-truecolor
+// fallback: dense ink for dark pixels, blank for the white page.
+const ramp = "@%#*+=-:. "
+
+type overlayCell struct {
+ set bool
+ r rune
+ color [3]uint8
+}
+
+// picker holds the interactive editing state.
+type picker struct {
+ doc *doc
+ plan *plan
+ name string // input file name shown in header
+ out *bufio.Writer
+ in *bufio.Reader
+ fd int
+ rows int
+ tc bool // truecolor terminal
+
+ page int // 0-based
+ npages int
+ cx, cy float64 // cursor in points (bottom-left origin)
+ zoom float64
+ sel int // index into plan.Stamps, -1 for cursor mode
+
+ anchor bool
+ ax, ay float64
+ idSeq int
+ msg string
+ showHelp bool
+ view *view
+
+ savePath string // pick mode: where enter saves
+ applyPath string // one-shot: where enter applies+quits
+ wrote bool // one-shot: applyPath was written (vs quitting via q)
+}
+
+func newPicker(d *doc, input string) *picker {
+ return &picker{
+ doc: d,
+ plan: newPlan(input),
+ name: input,
+ npages: d.numPage(),
+ zoom: 1,
+ sel: -1,
+ tc: truecolor(),
+ }
+}
+
+func truecolor() bool {
+ ct := strings.ToLower(os.Getenv("COLORTERM"))
+ return strings.Contains(ct, "truecolor") || strings.Contains(ct, "24bit")
+}
+
+// run drives the interactive loop until the user quits. The UI is drawn to and
+// read from /dev/tty, leaving stdin/stdout free so the plan can be piped.
+func (p *picker) run() error {
+ tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
+ if err != nil {
+ return fmt.Errorf("open controlling terminal: %w", err)
+ }
+ defer tty.Close()
+ p.fd = int(tty.Fd())
+
+ old, err := term.MakeRaw(p.fd)
+ if err != nil {
+ return fmt.Errorf("raw mode (need a real terminal): %w", err)
+ }
+ p.out = bufio.NewWriter(tty)
+ p.in = bufio.NewReader(tty)
+
+ // Restore the terminal exactly once, on both the normal and the signal
+ // path. The signal path writes straight to the tty to avoid racing the
+ // main goroutine on the buffered writer.
+ var once sync.Once
+ restore := func() {
+ once.Do(func() {
+ tty.WriteString("\x1b[?1049l\x1b[?25h") // leave alt screen, show cursor
+ term.Restore(p.fd, old)
+ })
+ }
+ defer restore()
+
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+ defer signal.Stop(sig)
+ go func() {
+ s := <-sig
+ restore()
+ if s == syscall.SIGTERM {
+ os.Exit(143) // 128 + SIGTERM
+ }
+ os.Exit(130) // 128 + SIGINT
+ }()
+
+ p.out.WriteString("\x1b[?1049h\x1b[?25l") // alt screen, hide cursor
+ p.centerCursor()
+ for {
+ if err := p.draw(); err != nil {
+ return err
+ }
+ quit, err := p.handle()
+ if err != nil {
+ return err
+ }
+ if quit {
+ return nil
+ }
+ }
+}
+
+func (p *picker) centerCursor() {
+ w, h, err := p.doc.pageSize(p.page)
+ if err == nil {
+ p.cx, p.cy = w/2, h/2
+ }
+}
+
+// --- drawing ---
+
+func (p *picker) draw() error {
+ w, h, err := term.GetSize(p.fd)
+ if err != nil || w < 20 || h < 8 {
+ w, h = 80, 24
+ }
+ p.rows = h
+ paneRows := h - 5 // header, 2 separators, status, hints
+ if paneRows < 1 {
+ paneRows = 1
+ }
+
+ v, err := p.doc.render(p.page, w, paneRows, p.zoom, p.cx, p.cy)
+ if err != nil {
+ return err
+ }
+ p.view = v
+
+ ov := make([][]overlayCell, paneRows)
+ for i := range ov {
+ ov[i] = make([]overlayCell, w)
+ }
+ p.drawStamps(ov)
+ if p.anchor {
+ p.drawRectPt(ov, p.ax, p.ay, p.cx, p.cy, [3]uint8{80, 220, 80})
+ }
+ p.drawCursor(ov)
+
+ p.out.WriteString("\x1b[H")
+ p.writeHeader(w)
+ p.writeSep(w)
+ p.writePane(v, ov)
+ p.writeSep(w)
+ p.writeStatus(w)
+ p.writeHints(w)
+ if p.showHelp {
+ p.writeHelp(h)
+ }
+ return p.out.Flush()
+}
+
+func (p *picker) writeHeader(w int) {
+ left := terminalText(p.name)
+ right := fmt.Sprintf("page %d / %d zoom %.1fx", p.page+1, p.npages, p.zoom)
+ p.out.WriteString("\x1b[7m" + pad(left, right, w) + "\x1b[0m\r\n")
+}
+
+func (p *picker) writeSep(w int) {
+ p.out.WriteString(strings.Repeat("─", w) + "\r\n")
+}
+
+func (p *picker) writePane(v *view, ov [][]overlayCell) {
+ for r := 0; r < v.rows; r++ {
+ var b strings.Builder
+ for c := 0; c < v.cols; c++ {
+ if o := ov[r][c]; o.set {
+ if p.tc {
+ fmt.Fprintf(&b, "\x1b[48;2;0;0;0m\x1b[38;2;%d;%d;%dm%c", o.color[0], o.color[1], o.color[2], o.r)
+ } else {
+ b.WriteRune(o.r)
+ }
+ continue
+ }
+ cell := v.cell(r, c)
+ if p.tc {
+ fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀",
+ cell.top[0], cell.top[1], cell.top[2], cell.bot[0], cell.bot[1], cell.bot[2])
+ } else {
+ b.WriteByte(rampChar(cell.top, cell.bot))
+ }
+ }
+ if p.tc {
+ b.WriteString("\x1b[0m")
+ }
+ p.out.WriteString(b.String() + "\r\n")
+ }
+}
+
+func (p *picker) writeStatus(w int) {
+ s := "stamp: (none) cursor x:" + fmt.Sprintf("%.0f y:%.0f", p.cx, p.cy)
+ if p.sel >= 0 && p.sel < len(p.plan.Stamps) {
+ st := p.plan.Stamps[p.sel]
+ label := st.Content
+ if st.Type == typeImage {
+ label = st.Src
+ }
+ s = fmt.Sprintf("stamp: %s x:%.0f y:%.0f w:%.0f h:%.0f op:%.2f pgs:%s",
+ fit(terminalText(label), 10), st.Rect.X, st.Rect.Y, st.Rect.W, st.Rect.H, st.Opacity,
+ formatPages(st.Pages))
+ }
+ if p.msg != "" {
+ s += " " + terminalText(p.msg)
+ }
+ p.out.WriteString("\x1b[7m" + fit(s, w) + "\x1b[0m\r\n")
+}
+
+func (p *picker) writeHints(w int) {
+ h := "h/j/k/l move H/J/K/L resize space rect a add/edit d del tab sel P pages o opacity +/- zoom n/p page g goto enter save ? help q quit"
+ p.out.WriteString(fit(h, w))
+}
+
+func (p *picker) writeHelp(h int) {
+ lines := []string{
+ " pdfstamp controls ",
+ "",
+ " n / p next / previous page",
+ " g go to page",
+ " h j k l move cursor (or selected stamp)",
+ " arrows move (same as h j k l)",
+ " H J K L resize selected stamp",
+ " space start / finish a rectangle",
+ " a add stamp (or edit selected)",
+ " d delete selected stamp",
+ " P set stamp pages (1,3-5 or all)",
+ " o set stamp opacity (0-1)",
+ " tab cycle stamp selection",
+ " + / - zoom in / out",
+ " enter save plan (or apply, one-shot)",
+ " ? toggle this help",
+ " q quit",
+ "",
+ " press any key ",
+ }
+ top := 3
+ for i, ln := range lines {
+ if top+i > h {
+ break
+ }
+ fmt.Fprintf(p.out, "\x1b[%d;4H\x1b[7m %s \x1b[0m", top+i, fit(ln, 40))
+ }
+}
+
+func (p *picker) drawStamps(ov [][]overlayCell) {
+ for i, s := range p.plan.Stamps {
+ if !slices.Contains(s.Pages, p.page+1) {
+ continue
+ }
+ color := [3]uint8{90, 200, 230}
+ if i == p.sel {
+ color = [3]uint8{240, 220, 60}
+ }
+ p.drawRectPt(ov, s.Rect.X, s.Rect.Y, s.Rect.X+s.Rect.W, s.Rect.Y+s.Rect.H, color)
+ }
+}
+
+func (p *picker) drawCursor(ov [][]overlayCell) {
+ col, row := p.view.ptToCell(p.cx, p.cy)
+ setOverlay(ov, row, col, '+', [3]uint8{240, 80, 80})
+}
+
+// drawRectPt draws a box (in point coordinates) onto the overlay grid.
+func (p *picker) drawRectPt(ov [][]overlayCell, x0, y0, x1, y1 float64, color [3]uint8) {
+ c0, r0 := p.view.ptToCell(math.Min(x0, x1), math.Max(y0, y1)) // top-left
+ c1, r1 := p.view.ptToCell(math.Max(x0, x1), math.Min(y0, y1)) // bottom-right
+ if c1 < c0 {
+ c0, c1 = c1, c0
+ }
+ if r1 < r0 {
+ r0, r1 = r1, r0
+ }
+ for c := c0; c <= c1; c++ {
+ setOverlay(ov, r0, c, '─', color)
+ setOverlay(ov, r1, c, '─', color)
+ }
+ for r := r0; r <= r1; r++ {
+ setOverlay(ov, r, c0, '│', color)
+ setOverlay(ov, r, c1, '│', color)
+ }
+ setOverlay(ov, r0, c0, '┌', color)
+ setOverlay(ov, r0, c1, '┐', color)
+ setOverlay(ov, r1, c0, '└', color)
+ setOverlay(ov, r1, c1, '┘', color)
+}
+
+func setOverlay(ov [][]overlayCell, row, col int, r rune, color [3]uint8) {
+ if row < 0 || row >= len(ov) || col < 0 || col >= len(ov[0]) {
+ return
+ }
+ ov[row][col] = overlayCell{set: true, r: r, color: color}
+}
+
+// --- input handling ---
+
+func (p *picker) handle() (quit bool, err error) {
+ b, err := p.in.ReadByte()
+ if err != nil {
+ return true, err
+ }
+ if p.showHelp {
+ p.showHelp = false
+ return false, nil
+ }
+ p.msg = ""
+
+ sx, sy := p.view.stepPt()
+ switch b {
+ case 'q':
+ return true, nil
+ case '?':
+ p.showHelp = true
+ case 'n':
+ p.gotoPage(p.page + 1)
+ case 'p':
+ p.gotoPage(p.page - 1)
+ case 'g':
+ if s, ok := p.prompt("go to page: "); ok {
+ if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
+ p.gotoPage(v - 1)
+ } else {
+ p.msg = "bad page number"
+ }
+ }
+ case '+', '=':
+ p.zoom = clampf(p.zoom*1.5, 0.2, 20)
+ case '-', '_':
+ p.zoom = clampf(p.zoom/1.5, 0.2, 20)
+ case '\t':
+ p.cycleSel()
+ case 'h':
+ p.move(-sx, 0)
+ case 'l':
+ p.move(sx, 0)
+ case 'j':
+ p.move(0, -sy)
+ case 'k':
+ p.move(0, sy)
+ case 'H':
+ p.resize(-sx, 0)
+ case 'L':
+ p.resize(sx, 0)
+ case 'J':
+ p.resize(0, -sy)
+ case 'K':
+ p.resize(0, sy)
+ case ' ':
+ p.toggleRect()
+ case 'a':
+ p.addOrEdit()
+ case 'd':
+ p.deleteSel()
+ case 'P':
+ p.setPages()
+ case 'o':
+ p.setOpacity()
+ case '\r', '\n':
+ return p.commit()
+ case 0x1b: // ESC: arrow-key sequence, or a lone ESC that cancels.
+ if p.in.Buffered() == 0 {
+ p.sel, p.anchor = -1, false
+ break
+ }
+ b2, _ := p.in.ReadByte()
+ if b2 != '[' && b2 != 'O' {
+ drain(p.in)
+ break
+ }
+ b3, _ := p.in.ReadByte()
+ switch b3 {
+ case 'A': // up
+ p.move(0, sy)
+ case 'B': // down
+ p.move(0, -sy)
+ case 'C': // right
+ p.move(sx, 0)
+ case 'D': // left
+ p.move(-sx, 0)
+ default:
+ drain(p.in)
+ }
+ }
+ return false, nil
+}
+
+func (p *picker) move(dx, dy float64) {
+ if p.sel >= 0 {
+ s := &p.plan.Stamps[p.sel]
+ s.Rect.X += dx
+ s.Rect.Y += dy
+ return
+ }
+ w, h, _ := p.doc.pageSize(p.page)
+ p.cx = clampf(p.cx+dx, 0, w)
+ p.cy = clampf(p.cy+dy, 0, h)
+}
+
+func (p *picker) resize(dw, dh float64) {
+ if p.sel < 0 {
+ return
+ }
+ s := &p.plan.Stamps[p.sel]
+ s.Rect.W = math.Max(4, s.Rect.W+dw)
+ s.Rect.H = math.Max(4, s.Rect.H+dh)
+}
+
+func (p *picker) gotoPage(pg int) {
+ if pg < 0 {
+ pg = 0
+ }
+ if pg > p.npages-1 {
+ pg = p.npages - 1
+ }
+ if pg != p.page {
+ p.page = pg
+ p.sel, p.anchor = -1, false
+ p.centerCursor()
+ }
+}
+
+func (p *picker) cycleSel() {
+ idx := p.stampsOnPage()
+ if len(idx) == 0 {
+ p.sel = -1
+ return
+ }
+ // none -> first -> ... -> none
+ cur := -1
+ for i, g := range idx {
+ if g == p.sel {
+ cur = i
+ }
+ }
+ if cur == len(idx)-1 {
+ p.sel = -1
+ } else {
+ p.sel = idx[cur+1]
+ }
+}
+
+func (p *picker) stampsOnPage() []int {
+ var idx []int
+ for i, s := range p.plan.Stamps {
+ if slices.Contains(s.Pages, p.page+1) {
+ idx = append(idx, i)
+ }
+ }
+ return idx
+}
+
+func (p *picker) toggleRect() {
+ if !p.anchor {
+ p.anchor = true
+ p.sel = -1 // move the cursor (not a selected stamp) while drawing
+ p.ax, p.ay = p.cx, p.cy
+ p.msg = "rect: move and press space to finish"
+ return
+ }
+ p.anchor = false
+ r := rect{
+ X: math.Min(p.ax, p.cx),
+ Y: math.Min(p.ay, p.cy),
+ W: math.Abs(p.cx - p.ax),
+ H: math.Abs(p.cy - p.ay),
+ }
+ if r.W < 4 || r.H < 4 {
+ p.msg = "rect too small"
+ return
+ }
+ p.createStamp(r)
+}
+
+func (p *picker) addOrEdit() {
+ if p.sel >= 0 {
+ p.editStamp(&p.plan.Stamps[p.sel])
+ return
+ }
+ // default rect anchored at cursor
+ r := rect{X: p.cx, Y: p.cy, W: 120, H: 42}
+ p.createStamp(r)
+}
+
+func (p *picker) createStamp(r rect) {
+ p.idSeq++
+ s := stamp{
+ ID: fmt.Sprintf("stamp-%d", p.idSeq),
+ Type: typeText,
+ Pages: []int{p.page + 1},
+ Rect: r,
+ Opacity: 0.8,
+ }
+ if !p.editStamp(&s) {
+ p.idSeq--
+ return
+ }
+ p.plan.Stamps = append(p.plan.Stamps, s)
+ p.sel = len(p.plan.Stamps) - 1
+}
+
+// editStamp prompts for type and content; returns false if cancelled. The
+// unused field is cleared so retyping a stamp cannot leave a stale src or
+// content behind in the plan.
+func (p *picker) editStamp(s *stamp) bool {
+ t, ok := p.promptKey("type [t]ext / [i]mage: ")
+ if !ok {
+ return false
+ }
+ switch t {
+ case 't':
+ v, ok := p.prompt("text: ")
+ if !ok || strings.TrimSpace(v) == "" {
+ return false
+ }
+ s.Type = typeText
+ s.Content, s.Src = v, ""
+ if s.Opacity == 0 {
+ s.Opacity = 0.8
+ }
+ case 'i':
+ v, ok := p.prompt("image path: ")
+ if !ok || strings.TrimSpace(v) == "" {
+ return false
+ }
+ src := strings.TrimSpace(v)
+ if err := checkImage(src); err != nil {
+ p.msg = err.Error()
+ return false
+ }
+ s.Type = typeImage
+ s.Src, s.Content = src, ""
+ s.Opacity = 1.0
+ default:
+ return false
+ }
+ return true
+}
+
+// checkImage gives immediate feedback at pick time rather than waiting for
+// apply to fail: the path must exist and its header must decode as a PNG or
+// JPEG within the pixel cap.
+func checkImage(path string) error {
+ switch strings.ToLower(filepath.Ext(path)) {
+ case ".png", ".jpg", ".jpeg":
+ default:
+ return fmt.Errorf("image must be .png, .jpg, or .jpeg")
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("image not found: %s", path)
+ }
+ defer f.Close()
+ if _, _, err := decodeImageConfig(f); err != nil {
+ return fmt.Errorf("bad image %s: %v", path, err)
+ }
+ return nil
+}
+
+// setPages reassigns the selected stamp to a page list, e.g. for initials on
+// every page. Deselects when the stamp leaves the current page.
+func (p *picker) setPages() {
+ if p.sel < 0 {
+ p.msg = "no stamp selected"
+ return
+ }
+ v, ok := p.prompt(fmt.Sprintf("pages 1-%d (e.g. 1,3-5 or all): ", p.npages))
+ if !ok {
+ return
+ }
+ pages, err := parsePages(v, p.npages)
+ if err != nil {
+ p.msg = err.Error()
+ return
+ }
+ p.plan.Stamps[p.sel].Pages = pages
+ if !slices.Contains(pages, p.page+1) {
+ p.sel = -1
+ }
+}
+
+// setOpacity prompts for the selected stamp's opacity.
+func (p *picker) setOpacity() {
+ if p.sel < 0 {
+ p.msg = "no stamp selected"
+ return
+ }
+ v, ok := p.prompt("opacity 0-1: ")
+ if !ok {
+ return
+ }
+ f, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
+ if err != nil || math.IsNaN(f) || f < 0 || f > 1 {
+ p.msg = "opacity must be a number in [0,1]"
+ return
+ }
+ p.plan.Stamps[p.sel].Opacity = f
+}
+
+// parsePages parses a 1-based page list like "2", "1,3-5", or "all" (meaning
+// 1..n), returning a sorted, deduplicated list.
+func parsePages(s string, n int) ([]int, error) {
+ s = strings.TrimSpace(s)
+ if s == "all" {
+ pages := make([]int, n)
+ for i := range pages {
+ pages[i] = i + 1
+ }
+ return pages, nil
+ }
+ seen := map[int]bool{}
+ for _, part := range strings.Split(s, ",") {
+ part = strings.TrimSpace(part)
+ lo, hi, isRange := strings.Cut(part, "-")
+ if !isRange {
+ hi = lo
+ }
+ a, err1 := strconv.Atoi(strings.TrimSpace(lo))
+ b, err2 := strconv.Atoi(strings.TrimSpace(hi))
+ if err1 != nil || err2 != nil || a < 1 || b > n || a > b {
+ return nil, fmt.Errorf("bad page spec %q (have %d pages)", part, n)
+ }
+ for pg := a; pg <= b; pg++ {
+ seen[pg] = true
+ }
+ }
+ pages := make([]int, 0, len(seen))
+ for pg := range seen {
+ pages = append(pages, pg)
+ }
+ slices.Sort(pages)
+ return pages, nil
+}
+
+// formatPages renders a page list compactly, e.g. "1,3-5".
+func formatPages(pgs []int) string {
+ s := slices.Clone(pgs)
+ slices.Sort(s)
+ var parts []string
+ for i := 0; i < len(s); {
+ j := i
+ for j+1 < len(s) && s[j+1] == s[j]+1 {
+ j++
+ }
+ if i == j {
+ parts = append(parts, strconv.Itoa(s[i]))
+ } else {
+ parts = append(parts, fmt.Sprintf("%d-%d", s[i], s[j]))
+ }
+ i = j + 1
+ }
+ return strings.Join(parts, ",")
+}
+
+func (p *picker) deleteSel() {
+ if p.sel < 0 || p.sel >= len(p.plan.Stamps) {
+ return
+ }
+ p.plan.Stamps = append(p.plan.Stamps[:p.sel], p.plan.Stamps[p.sel+1:]...)
+ p.sel = -1
+ p.msg = "deleted"
+}
+
+func (p *picker) commit() (quit bool, err error) {
+ switch {
+ case p.applyPath != "": // one-step: burn into the output PDF and quit
+ if err := apply(p.plan, p.applyPath); err != nil {
+ p.msg = "apply failed: " + err.Error()
+ return false, nil
+ }
+ p.wrote = true
+ return true, nil
+ case p.savePath != "": // pick -o FILE: write the plan, keep editing
+ p.plan.toAbsolute()
+ if err := p.plan.save(p.savePath); err != nil {
+ p.msg = "save failed: " + err.Error()
+ return false, nil
+ }
+ p.msg = "saved " + p.savePath
+ return false, nil
+ default: // pick with no -o: emit the plan to stdout and quit (pipe-friendly)
+ p.plan.toAbsolute()
+ b, err := p.plan.marshal()
+ if err != nil {
+ p.msg = "encode failed: " + err.Error()
+ return false, nil
+ }
+ if _, err := os.Stdout.Write(b); err != nil {
+ return true, fmt.Errorf("write plan: %w", err)
+ }
+ return true, nil
+ }
+}
+
+// --- minibuffer prompts ---
+
+func (p *picker) prompt(label string) (string, bool) {
+ var s []rune
+ for {
+ fmt.Fprintf(p.out, "\x1b[%d;1H\x1b[K\x1b[7m%s%s\x1b[0m", p.rows, label, string(s))
+ p.out.Flush()
+ b, err := p.in.ReadByte()
+ if err != nil {
+ return "", false
+ }
+ switch b {
+ case '\r', '\n':
+ return string(s), true
+ case 0x1b:
+ drain(p.in)
+ return "", false
+ case 127, 8:
+ if len(s) > 0 {
+ s = s[:len(s)-1]
+ }
+ default:
+ if b >= 0x20 && b < 0x7f {
+ s = append(s, rune(b))
+ }
+ }
+ }
+}
+
+func (p *picker) promptKey(label string) (byte, bool) {
+ fmt.Fprintf(p.out, "\x1b[%d;1H\x1b[K\x1b[7m%s\x1b[0m", p.rows, label)
+ p.out.Flush()
+ b, err := p.in.ReadByte()
+ if err != nil || b == 0x1b {
+ return 0, false
+ }
+ return b, true
+}
+
+// stepPt is the point-distance of one cell, used for cursor/stamp nudges.
+func (v *view) stepPt() (sx, sy float64) {
+ return 1 / v.scaleX, 2 / v.scaleY
+}
+
+// --- helpers ---
+
+func drain(in *bufio.Reader) {
+ for in.Buffered() > 0 {
+ in.ReadByte()
+ }
+}
+
+func clampf(v, lo, hi float64) float64 {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func rampChar(top, bot [3]uint8) byte {
+ l := (lum(top) + lum(bot)) / 2
+ idx := int(l * float64(len(ramp)-1))
+ return ramp[idx]
+}
+
+func lum(c [3]uint8) float64 {
+ return (0.299*float64(c[0]) + 0.587*float64(c[1]) + 0.114*float64(c[2])) / 255
+}
+
+func pad(left, right string, w int) string {
+ gap := w - len([]rune(left)) - len([]rune(right))
+ if gap < 1 {
+ return fit(left+" "+right, w)
+ }
+ return left + strings.Repeat(" ", gap) + right
+}
+
+func fit(s string, w int) string {
+ r := []rune(s)
+ if len(r) > w {
+ return string(r[:w])
+ }
+ return s + strings.Repeat(" ", w-len(r))
+}