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 }