|
| 1 | +import { each, ensure, main, until } from "effection"; |
| 2 | +import { |
| 3 | + close, |
| 4 | + createTerm, |
| 5 | + fixed, |
| 6 | + grow, |
| 7 | + type InputEvent, |
| 8 | + type Op, |
| 9 | + open, |
| 10 | + rgba, |
| 11 | + text, |
| 12 | +} from "../mod.ts"; |
| 13 | +import { |
| 14 | + alternateBuffer, |
| 15 | + cursor, |
| 16 | + mouseTracking, |
| 17 | + settings, |
| 18 | +} from "../settings.ts"; |
| 19 | +import { useInput } from "./use-input.ts"; |
| 20 | +import { useStdin } from "./use-stdin.ts"; |
| 21 | +import content from "./lorem.ts"; |
| 22 | + |
| 23 | +let FG = rgba(204, 204, 204); |
| 24 | +let DIM = rgba(100, 100, 110); |
| 25 | +let GUTTER_FG = rgba(100, 120, 160); |
| 26 | +let STATUS_BG = rgba(30, 30, 40); |
| 27 | +let STATUS_FG = rgba(180, 180, 190); |
| 28 | +let THUMB = rgba(120, 120, 140); |
| 29 | +let TRACK = rgba(40, 40, 50); |
| 30 | + |
| 31 | +let lines = content.split("\n"); |
| 32 | + |
| 33 | +function gutter(lineNum: number, width: number): string { |
| 34 | + let num = String(lineNum); |
| 35 | + let pad = width - 2 - num.length; |
| 36 | + return " ".repeat(Math.max(0, pad)) + num + " \u2502"; |
| 37 | +} |
| 38 | + |
| 39 | +function gutterWidth(): number { |
| 40 | + let digits = Math.max(1, Math.floor(Math.log10(lines.length)) + 1); |
| 41 | + return Math.max(4, digits + 2); |
| 42 | +} |
| 43 | + |
| 44 | +function thumbGeometry( |
| 45 | + scrollY: number, |
| 46 | + totalLines: number, |
| 47 | + viewportHeight: number, |
| 48 | +): { pos: number; size: number } { |
| 49 | + let size = totalLines > viewportHeight |
| 50 | + ? Math.max(1, Math.round(viewportHeight * viewportHeight / totalLines)) |
| 51 | + : viewportHeight; |
| 52 | + let maxScroll = Math.max(totalLines - viewportHeight, 1); |
| 53 | + let pos = Math.round((scrollY / maxScroll) * (viewportHeight - size)); |
| 54 | + return { pos, size }; |
| 55 | +} |
| 56 | + |
| 57 | +function build( |
| 58 | + scrollY: number, |
| 59 | + rows: number, |
| 60 | +): Op[] { |
| 61 | + let ops: Op[] = []; |
| 62 | + let gw = gutterWidth(); |
| 63 | + let viewHeight = rows - 1; |
| 64 | + let { pos, size } = thumbGeometry(scrollY, lines.length, viewHeight); |
| 65 | + |
| 66 | + ops.push(open("root", { |
| 67 | + layout: { width: grow(), height: grow(), direction: "ttb" }, |
| 68 | + })); |
| 69 | + |
| 70 | + for (let row = 0; row < viewHeight; row++) { |
| 71 | + let lineIdx = scrollY + row; |
| 72 | + let hasLine = lineIdx < lines.length; |
| 73 | + let lineNum = hasLine ? lineIdx + 1 : 0; |
| 74 | + let lineText = hasLine ? lines[lineIdx] : ""; |
| 75 | + |
| 76 | + let inThumb = row >= pos && row < pos + size; |
| 77 | + let scrollChar = inThumb ? "\u2588" : "\u2502"; |
| 78 | + let scrollColor = inThumb ? THUMB : TRACK; |
| 79 | + |
| 80 | + ops.push( |
| 81 | + open(`r${row}`, { |
| 82 | + layout: { direction: "ltr", height: fixed(1), width: grow() }, |
| 83 | + }), |
| 84 | + open("", { layout: { width: fixed(gw), height: fixed(1) } }), |
| 85 | + text(hasLine ? gutter(lineNum, gw) : " ".repeat(gw - 1) + "\u2502", { |
| 86 | + color: hasLine ? GUTTER_FG : DIM, |
| 87 | + }), |
| 88 | + close(), |
| 89 | + open("", { layout: { width: grow(), height: fixed(1) } }), |
| 90 | + text(lineText || " ", { color: hasLine ? FG : DIM }), |
| 91 | + close(), |
| 92 | + open("", { layout: { width: fixed(1), height: fixed(1) } }), |
| 93 | + text(scrollChar, { color: scrollColor }), |
| 94 | + close(), |
| 95 | + close(), |
| 96 | + ); |
| 97 | + } |
| 98 | + |
| 99 | + let status = ` ${ |
| 100 | + scrollY + 1 |
| 101 | + }/${lines.length} n/p:line j/k:scroll g/G:top/bottom q:quit`; |
| 102 | + ops.push( |
| 103 | + open("status", { |
| 104 | + layout: { |
| 105 | + width: grow(), |
| 106 | + height: fixed(1), |
| 107 | + direction: "ltr", |
| 108 | + padding: { left: 1 }, |
| 109 | + }, |
| 110 | + bg: STATUS_BG, |
| 111 | + }), |
| 112 | + text(status, { color: STATUS_FG }), |
| 113 | + close(), |
| 114 | + ); |
| 115 | + |
| 116 | + ops.push(close()); |
| 117 | + return ops; |
| 118 | +} |
| 119 | + |
| 120 | +function clamp(v: number, min: number, max: number): number { |
| 121 | + if (v < min) { |
| 122 | + return min; |
| 123 | + } else { |
| 124 | + return v > max ? max : v; |
| 125 | + } |
| 126 | +} |
| 127 | + |
| 128 | +await main(function* () { |
| 129 | + let { columns, rows } = Deno.stdout.isTerminal() |
| 130 | + ? Deno.consoleSize() |
| 131 | + : { columns: 80, rows: 24 }; |
| 132 | + |
| 133 | + Deno.stdin.setRaw(true); |
| 134 | + |
| 135 | + let stdin = yield* useStdin(); |
| 136 | + let input = useInput(stdin); |
| 137 | + |
| 138 | + let term = yield* until(createTerm({ width: columns, height: rows })); |
| 139 | + |
| 140 | + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); |
| 141 | + Deno.stdout.writeSync(tty.apply); |
| 142 | + |
| 143 | + yield* ensure(() => { |
| 144 | + Deno.stdout.writeSync(tty.revert); |
| 145 | + }); |
| 146 | + |
| 147 | + let scrollY = 0; |
| 148 | + let viewHeight = rows - 1; |
| 149 | + let maxScroll = Math.max(lines.length - viewHeight, 0); |
| 150 | + let drag = { active: false, offset: 0 }; |
| 151 | + |
| 152 | + Deno.stdout.writeSync(term.render(build(scrollY, rows)).output); |
| 153 | + |
| 154 | + for (let event of yield* each(input)) { |
| 155 | + if (event.type === "keydown" && event.ctrl && event.key === "c") break; |
| 156 | + if (event.type === "keydown" && event.key === "q") break; |
| 157 | + |
| 158 | + if (event.type === "keydown") { |
| 159 | + switch (event.code) { |
| 160 | + case "n": |
| 161 | + case "j": |
| 162 | + case "ArrowDown": |
| 163 | + scrollY = clamp(scrollY + 1, 0, maxScroll); |
| 164 | + break; |
| 165 | + case "p": |
| 166 | + case "k": |
| 167 | + case "ArrowUp": |
| 168 | + scrollY = clamp(scrollY - 1, 0, maxScroll); |
| 169 | + break; |
| 170 | + case "d": |
| 171 | + case "PageDown": |
| 172 | + scrollY = clamp(scrollY + Math.floor(viewHeight / 2), 0, maxScroll); |
| 173 | + break; |
| 174 | + case "u": |
| 175 | + case "PageUp": |
| 176 | + scrollY = clamp(scrollY - Math.floor(viewHeight / 2), 0, maxScroll); |
| 177 | + break; |
| 178 | + case "g": |
| 179 | + case "Home": |
| 180 | + scrollY = 0; |
| 181 | + break; |
| 182 | + case "End": |
| 183 | + scrollY = maxScroll; |
| 184 | + break; |
| 185 | + } |
| 186 | + if ((event as InputEvent & { key: string }).key === "G") { |
| 187 | + scrollY = maxScroll; |
| 188 | + } |
| 189 | + } |
| 190 | + |
| 191 | + if (event.type === "wheel") { |
| 192 | + let delta = event.direction === "down" ? 3 : -3; |
| 193 | + scrollY = clamp(scrollY + delta, 0, maxScroll); |
| 194 | + } |
| 195 | + |
| 196 | + if ( |
| 197 | + event.type === "mousedown" && |
| 198 | + event.button === "left" && |
| 199 | + event.x === columns - 1 && |
| 200 | + event.y < viewHeight |
| 201 | + ) { |
| 202 | + let { pos, size } = thumbGeometry(scrollY, lines.length, viewHeight); |
| 203 | + if (event.y >= pos && event.y < pos + size) { |
| 204 | + drag.active = true; |
| 205 | + drag.offset = event.y - pos; |
| 206 | + } else { |
| 207 | + let fraction = event.y / Math.max(viewHeight - 1, 1); |
| 208 | + scrollY = clamp(Math.round(fraction * maxScroll), 0, maxScroll); |
| 209 | + } |
| 210 | + } |
| 211 | + |
| 212 | + if (event.type === "mousemove" && drag.active) { |
| 213 | + let { size } = thumbGeometry(scrollY, lines.length, viewHeight); |
| 214 | + let fraction = (event.y - drag.offset) / |
| 215 | + Math.max(viewHeight - size, 1); |
| 216 | + scrollY = clamp(Math.round(fraction * maxScroll), 0, maxScroll); |
| 217 | + } |
| 218 | + |
| 219 | + if (event.type === "mouseup") { |
| 220 | + drag.active = false; |
| 221 | + } |
| 222 | + |
| 223 | + if (event.type === "resize") { |
| 224 | + columns = event.width; |
| 225 | + rows = event.height; |
| 226 | + viewHeight = rows - 1; |
| 227 | + maxScroll = Math.max(lines.length - viewHeight, 0); |
| 228 | + scrollY = clamp(scrollY, 0, maxScroll); |
| 229 | + term = yield* until(createTerm({ width: columns, height: rows })); |
| 230 | + } |
| 231 | + |
| 232 | + Deno.stdout.writeSync( |
| 233 | + term.render(build(scrollY, rows)).output, |
| 234 | + ); |
| 235 | + |
| 236 | + yield* each.next(); |
| 237 | + } |
| 238 | +}); |
0 commit comments