tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

tui.go (7407B)


      1 package main
      2 
      3 import (
      4 	"cmp"
      5 	"fmt"
      6 	"os"
      7 	"os/exec"
      8 	"os/signal"
      9 	"runtime"
     10 	"sort"
     11 	"strings"
     12 	"syscall"
     13 	"unicode/utf8"
     14 	"unsafe"
     15 )
     16 
     17 // Terminal state is saved once, in enter(); later transitions only flip raw mode
     18 const (
     19 	altOn  = "\x1b[?1049h\x1b[?25l" // alt screen, hide cursor
     20 	altOff = "\x1b[?25h\x1b[?1049l" // show cursor, primary screen
     21 )
     22 
     23 func (a *app) enter() {
     24 	cmd := exec.Command("stty", "-g")
     25 	cmd.Stdin = os.Stdin
     26 	if out, err := cmd.Output(); err == nil {
     27 		a.savedStty = strings.TrimSpace(string(out))
     28 	}
     29 	a.resume()
     30 }
     31 
     32 func (a *app) resume() { a.raw(); fmt.Print(altOn) }
     33 func (a *app) leave()  { fmt.Print(altOff); a.cooked() }
     34 
     35 // raw disables isig/ixon too, so ctrl-c/s/q arrive as bytes instead of signals
     36 func (a *app) raw() { a.stty("-echo", "-icanon", "-isig", "-ixon", "min", "1", "time", "0") }
     37 
     38 func (a *app) cooked() { a.stty(cmp.Or(a.savedStty, "sane")) }
     39 
     40 func (a *app) stty(args ...string) {
     41 	cmd := exec.Command("stty", args...)
     42 	cmd.Stdin = os.Stdin
     43 	_ = cmd.Run()
     44 }
     45 
     46 // size queries the window via ioctl
     47 func (a *app) size() {
     48 	var ws struct{ row, col, x, y uint16 }
     49 	_, _, _ = syscall.Syscall(syscall.SYS_IOCTL, os.Stdin.Fd(),
     50 		uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws)))
     51 	a.rows = max(int(ws.row), 5)
     52 	a.cols = max(int(ws.col), 20)
     53 }
     54 
     55 func (a *app) run() {
     56 	sort.SliceStable(a.docs, func(i, j int) bool {
     57 		return a.docs[i].mtime.After(a.docs[j].mtime)
     58 	})
     59 	a.enter()
     60 	defer a.leave()
     61 
     62 	// defer does not run on signal death; restore the terminal by hand
     63 	sig := make(chan os.Signal, 1)
     64 	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
     65 	go func() { <-sig; a.leave(); os.Exit(1) }()
     66 
     67 	a.size()
     68 	a.search()
     69 	a.draw()
     70 
     71 	buf := make([]byte, 64)
     72 	for {
     73 		n, err := os.Stdin.Read(buf)
     74 		if err != nil || n == 0 {
     75 			return
     76 		}
     77 		if a.handle(buf[:n]) {
     78 			return
     79 		}
     80 		a.draw()
     81 	}
     82 }
     83 
     84 func (a *app) handle(b []byte) bool {
     85 	changed := false
     86 	for i := 0; i < len(b); {
     87 		c := b[i]
     88 		switch {
     89 		case c == 0x1b: // CSI sequence; a lone esc quits; ignore alt/partial
     90 			if i+1 < len(b) && b[i+1] == '[' {
     91 				j := i + 2
     92 				for j < len(b) && (b[j] < 0x40 || b[j] > 0x7e) {
     93 					j++
     94 				}
     95 				if j < len(b) {
     96 					switch param, final := string(b[i+2:j]), b[j]; {
     97 					case final == 'A':
     98 						a.move(-1)
     99 					case final == 'B':
    100 						a.move(1)
    101 					case final == '~' && param == "5":
    102 						a.move(-(a.rows - 2))
    103 					case final == '~' && param == "6":
    104 						a.move(a.rows - 2)
    105 					}
    106 					i = j + 1
    107 					continue
    108 				}
    109 			}
    110 			if len(b) == 1 {
    111 				return true
    112 			}
    113 			// alt-prefixed key or a split escape: drop the esc, keep going
    114 		case c == 3: // ctrl-c
    115 			return true
    116 		case c == 9: // tab
    117 			a.content = !a.content
    118 			changed = true
    119 		case c == 13 || c == 10: // enter
    120 			a.open()
    121 		case c == 15: // ctrl-o
    122 			a.openURL()
    123 		case c == 25: // ctrl-y
    124 			a.yank()
    125 		case c == 14: // ctrl-n
    126 			a.move(1)
    127 		case c == 16: // ctrl-p
    128 			a.move(-1)
    129 		case c == 21: // ctrl-u
    130 			a.query = a.query[:0]
    131 			changed = true
    132 		case c == 127 || c == 8: // backspace
    133 			if len(a.query) > 0 {
    134 				a.query = a.query[:len(a.query)-1]
    135 				changed = true
    136 			}
    137 		case c < 32:
    138 		default:
    139 			r, n := utf8.DecodeRune(b[i:])
    140 			if r == utf8.RuneError && n <= 1 {
    141 				i++ // invalid or a multibyte rune split across reads
    142 				continue
    143 			}
    144 			if r >= 32 && r != 0x7f {
    145 				a.query = append(a.query, r)
    146 				changed = true
    147 			}
    148 			i += n
    149 			continue
    150 		}
    151 		i++
    152 	}
    153 	if changed {
    154 		a.search()
    155 	}
    156 	return false
    157 }
    158 
    159 func (a *app) move(d int) {
    160 	if len(a.results) > 0 {
    161 		a.sel = max(0, min(a.sel+d, len(a.results)-1))
    162 	}
    163 }
    164 
    165 // cur returns the selected match, or false if there is none
    166 func (a *app) cur() (match, bool) {
    167 	if a.sel < len(a.results) {
    168 		return a.results[a.sel], true
    169 	}
    170 	return match{}, false
    171 }
    172 
    173 func (a *app) draw() {
    174 	a.size()
    175 	listH := a.rows - 1
    176 	leftW := min(max(a.cols*2/5, 24), a.cols-10)
    177 	prevW := a.cols - leftW - 1
    178 
    179 	if a.sel < a.top {
    180 		a.top = a.sel
    181 	}
    182 	if a.sel >= a.top+listH {
    183 		a.top = a.sel - listH + 1
    184 	}
    185 
    186 	var sb strings.Builder
    187 	sb.WriteString("\x1b[H\x1b[2J")
    188 
    189 	mode := "content"
    190 	if !a.content {
    191 		mode = "browse"
    192 	}
    193 	qTrunc := truncate(string(a.query), a.cols/2)
    194 	prompt := fmt.Sprintf("\x1b[1m%s\x1b[0m ▸ %s", mode, qTrunc)
    195 	count := fmt.Sprintf("\x1b[2m%d/%d\x1b[0m", min(len(a.results), a.sel+1), len(a.results))
    196 	sb.WriteString(pad(prompt, a.cols-visLen(count)))
    197 	sb.WriteString(count)
    198 	sb.WriteString("\r\n")
    199 
    200 	prev := a.previewLines(prevW, listH)
    201 	for i := range listH {
    202 		left := strings.Repeat(" ", leftW)
    203 		if ri := a.top + i; ri < len(a.results) {
    204 			left = padRaw(truncate(a.results[ri].text(), leftW), leftW)
    205 			if ri == a.sel {
    206 				left = "\x1b[7m" + left + "\x1b[0m"
    207 			}
    208 		}
    209 		right := ""
    210 		if i < len(prev) {
    211 			right = prev[i]
    212 		}
    213 		sb.WriteString(left)
    214 		sb.WriteString(" \x1b[2m│\x1b[0m ")
    215 		sb.WriteString(right)
    216 		sb.WriteString("\r\n")
    217 	}
    218 	fmt.Print(strings.TrimSuffix(sb.String(), "\r\n"))
    219 }
    220 
    221 // previewLines renders the selection's text centred on the matched line
    222 func (a *app) previewLines(w, h int) []string {
    223 	m, ok := a.cur()
    224 	if !ok || w < 4 {
    225 		return nil
    226 	}
    227 	if len(m.d.lines) == 0 {
    228 		return []string{"\x1b[2m(no cached text)\x1b[0m"}
    229 	}
    230 	start := 0
    231 	if m.line > 0 {
    232 		start = max(0, m.line-1-h/2)
    233 	}
    234 	var out []string
    235 	for i := start; i < len(m.d.lines) && len(out) < h; i++ {
    236 		row := truncate(m.d.lines[i], w-2)
    237 		if i+1 == m.line {
    238 			out = append(out, "\x1b[1m"+row+"\x1b[0m")
    239 		} else {
    240 			out = append(out, "\x1b[2m"+row+"\x1b[0m")
    241 		}
    242 	}
    243 	return out
    244 }
    245 
    246 func (a *app) withExternal(fn func()) {
    247 	a.leave()
    248 	fn()
    249 	a.resume()
    250 }
    251 
    252 func (a *app) open() {
    253 	m, ok := a.cur()
    254 	if !ok {
    255 		return
    256 	}
    257 	src := m.d.src
    258 	a.withExternal(func() {
    259 		if strings.HasSuffix(strings.ToLower(src), ".md") {
    260 			tmp, err := os.CreateTemp("", "wrd-*.html")
    261 			if err != nil {
    262 				return
    263 			}
    264 			defer func() { _ = os.Remove(tmp.Name()) }()
    265 			out, _ := exec.Command("lowdown", src).Output()
    266 			_, _ = tmp.Write(out)
    267 			_ = tmp.Close()
    268 			src = tmp.Name()
    269 		}
    270 		runTTY(exec.Command("cha", src))
    271 	})
    272 }
    273 
    274 func (a *app) openURL() {
    275 	if m, ok := a.cur(); ok {
    276 		u := url(m.d.rel)
    277 		a.withExternal(func() { runTTY(exec.Command("cha", u)) })
    278 	}
    279 }
    280 
    281 func (a *app) yank() {
    282 	m, ok := a.cur()
    283 	if !ok {
    284 		return
    285 	}
    286 	clip := url(m.d.rel)
    287 	var cmds []*exec.Cmd
    288 	if runtime.GOOS == "darwin" {
    289 		cmds = []*exec.Cmd{exec.Command("pbcopy")}
    290 	} else if os.Getenv("WAYLAND_DISPLAY") != "" {
    291 		cmds = []*exec.Cmd{exec.Command("wl-copy")}
    292 	} else {
    293 		cmds = []*exec.Cmd{exec.Command("xclip", "-selection", "clipboard")}
    294 	}
    295 	for _, cmd := range cmds {
    296 		cmd.Stdin = strings.NewReader(clip)
    297 		if cmd.Run() == nil {
    298 			return
    299 		}
    300 	}
    301 }
    302 
    303 func runTTY(cmd *exec.Cmd) {
    304 	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    305 	_ = cmd.Run()
    306 }
    307 
    308 // visLen counts visible runes, ignoring SGR escapes
    309 func visLen(s string) int {
    310 	n, esc := 0, false
    311 	for _, r := range s {
    312 		switch {
    313 		case esc:
    314 			esc = r != 'm'
    315 		case r == 0x1b:
    316 			esc = true
    317 		default:
    318 			n++
    319 		}
    320 	}
    321 	return n
    322 }
    323 
    324 func truncate(s string, w int) string {
    325 	rs := []rune(s)
    326 	if w <= 0 {
    327 		return ""
    328 	}
    329 	if len(rs) <= w {
    330 		return s
    331 	}
    332 	if w == 1 {
    333 		return string(rs[:1])
    334 	}
    335 	return string(rs[:w-1]) + "…"
    336 }
    337 
    338 func padRaw(s string, w int) string {
    339 	rs := []rune(s)
    340 	if len(rs) >= w {
    341 		return string(rs[:w])
    342 	}
    343 	return s + strings.Repeat(" ", w-len(rs))
    344 }
    345 
    346 func pad(s string, w int) string {
    347 	if v := visLen(s); v < w {
    348 		return s + strings.Repeat(" ", w-v)
    349 	}
    350 	return s
    351 }