@mailwoman/map-tui 9.1.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli-args.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Argument parsing for the `map-tui` bin.
9
+ *
10
+ * `parseCLIArgs` is pure: it takes the argv slice and an environment record, and answers with a discriminated result
11
+ * (`help` / `version` / `browse`) or throws {@link CLIArgsError}. Reading `process.argv` / `process.env` is the bin's
12
+ * job (./cli.ts), which keeps every rejection path testable without a subprocess.
13
+ */
14
+
15
+ import { parseArgs } from "node:util"
16
+
17
+ /**
18
+ * Center longitude when `--lon` is omitted — the world view mapscii opens on.
19
+ */
20
+ const DEFAULT_LON = 0
21
+
22
+ /**
23
+ * Center latitude when `--lat` is omitted.
24
+ */
25
+ const DEFAULT_LAT = 0
26
+
27
+ /**
28
+ * Zoom when `--zoom` is omitted. z2 shows a full hemisphere in a typical terminal, so a planet archive opens on
29
+ * something recognizable rather than a single ocean tile.
30
+ */
31
+ const DEFAULT_ZOOM = 2
32
+
33
+ /**
34
+ * Widest zoom any tile archive uses.
35
+ */
36
+ const MIN_ZOOM = 0
37
+
38
+ /**
39
+ * Deepest zoom the Web-Mercator tile pyramid is defined for. The archive's own `maxZoom` clamps further at runtime;
40
+ * this is only the range a flag value must fall inside to be meaningful at all.
41
+ */
42
+ const MAX_ZOOM = 24
43
+
44
+ const MIN_LAT = -90
45
+ const MAX_LAT = 90
46
+ const MIN_LON = -180
47
+ const MAX_LON = 180
48
+
49
+ /**
50
+ * A view request: an archive to read and where to open it.
51
+ */
52
+ export interface BrowseArgs {
53
+ mode: "browse"
54
+ /**
55
+ * Path to the PMTiles archive.
56
+ */
57
+ tiles: string
58
+ lat: number
59
+ lon: number
60
+ /**
61
+ * Integer zoom level. The renderer draws whole tile-pyramid levels, so a fractional flag value is rounded here rather
62
+ * than carried as a lie through the viewport.
63
+ */
64
+ zoom: number
65
+ }
66
+
67
+ export type CLIArgs = { mode: "help" } | { mode: "version" } | BrowseArgs
68
+
69
+ /**
70
+ * A rejected command line. The message is user-facing: it says what was wrong AND what to pass instead, since the bin
71
+ * prints it verbatim to stderr.
72
+ */
73
+ export class CLIArgsError extends Error {
74
+ override name = "CLIArgsError"
75
+ }
76
+
77
+ /**
78
+ * Environment keys the CLI reads. Passed in rather than read here so the parser stays pure.
79
+ */
80
+ export interface CLIEnvironment {
81
+ MAILWOMAN_TILES?: string | undefined
82
+ }
83
+
84
+ /**
85
+ * `--help` output. It doubles as the package's key reference, so the bindings listed here and the ones ./input.ts
86
+ * decodes are the same list said twice — a key added there without a line here is a key nobody finds.
87
+ */
88
+ export const HELP_TEXT = `map-tui — the whole world in your terminal
89
+
90
+ Usage
91
+ npx @mailwoman/map-tui --tiles <archive.pmtiles> [options]
92
+
93
+ Options
94
+ --tiles <path|url> PMTiles archive — local path or https:// URL (default: $MAILWOMAN_TILES)
95
+ --lat <degrees> Initial center latitude, -90..90 (default: ${DEFAULT_LAT})
96
+ --lon <degrees> Initial center longitude, -180..180 (default: ${DEFAULT_LON})
97
+ --zoom <level> Initial zoom, ${MIN_ZOOM}..${MAX_ZOOM} (default: ${DEFAULT_ZOOM})
98
+ -h, --help Print this help and exit
99
+ -v, --version Print the package version and exit
100
+
101
+ Keys
102
+ arrows, hjkl Pan
103
+ +, =, a Zoom in
104
+ -, _, z Zoom out
105
+ q, Esc, Ctrl+C Quit
106
+
107
+ Mouse
108
+ Wheel zooms toward the pointer, drag pans, click centers.
109
+
110
+ Tiles are never bundled with this package. Download a planet or region
111
+ archive from https://protomaps.com/downloads and point --tiles at it.
112
+ `
113
+
114
+ /**
115
+ * Reads one numeric flag, rejecting anything `Number` would quietly accept as garbage (empty string, whitespace,
116
+ * `Infinity`) as well as out-of-range values.
117
+ */
118
+ function numericFlag(name: string, raw: string | undefined, fallback: number, min: number, max: number): number {
119
+ if (raw == null) return fallback
120
+
121
+ const value = Number(raw.trim())
122
+
123
+ if (!Number.isFinite(value) || !raw.trim().length) {
124
+ throw new CLIArgsError(`--${name} expects a number, got ${JSON.stringify(raw)}`)
125
+ }
126
+
127
+ if (value < min || value > max) {
128
+ throw new CLIArgsError(`--${name} must be between ${min} and ${max}, got ${value}`)
129
+ }
130
+
131
+ return value
132
+ }
133
+
134
+ /**
135
+ * Flags whose value is a number, and may therefore start with a dash.
136
+ */
137
+ const NUMERIC_FLAGS = new Set(["--lat", "--lon", "--zoom"])
138
+
139
+ /**
140
+ * Joins `--lon -122.6` into `--lon=-122.6` before `parseArgs` sees it.
141
+ *
142
+ * `node:util`'s parser refuses a separate value that starts with a dash — it cannot tell a negative number from a
143
+ * mistyped flag, and says so ("argument is ambiguous"). Half the planet has a negative longitude, so the space-form has
144
+ * to work. The join is conditional on the next token parsing as a finite number, which leaves a genuinely missing value
145
+ * (`--lon --zoom 3`) to `parseArgs` and its own error.
146
+ */
147
+ function joinNegativeNumbers(argv: readonly string[]): string[] {
148
+ const joined: string[] = []
149
+
150
+ for (let index = 0; index < argv.length; index++) {
151
+ const token = argv[index]!
152
+ const next = argv[index + 1]
153
+
154
+ if (NUMERIC_FLAGS.has(token) && next?.startsWith("-") && Number.isFinite(Number(next))) {
155
+ joined.push(`${token}=${next}`)
156
+ index += 1
157
+
158
+ continue
159
+ }
160
+
161
+ joined.push(token)
162
+ }
163
+
164
+ return joined
165
+ }
166
+
167
+ interface ParsedFlags {
168
+ tiles?: string | undefined
169
+ lat?: string | undefined
170
+ lon?: string | undefined
171
+ zoom?: string | undefined
172
+ help?: boolean | undefined
173
+ version?: boolean | undefined
174
+ }
175
+
176
+ /**
177
+ * `node:util`'s own rejections (unknown flag, missing value) name the flag but not the remedy, so they're re-thrown as
178
+ * a {@link CLIArgsError} pointing at `--help`.
179
+ */
180
+ function readFlags(argv: readonly string[]): ParsedFlags {
181
+ try {
182
+ const { values } = parseArgs({
183
+ args: joinNegativeNumbers(argv),
184
+ options: {
185
+ tiles: { type: "string" },
186
+ lat: { type: "string" },
187
+ lon: { type: "string" },
188
+ zoom: { type: "string" },
189
+ help: { type: "boolean", short: "h" },
190
+ version: { type: "boolean", short: "v" },
191
+ },
192
+ allowPositionals: false,
193
+ strict: true,
194
+ })
195
+
196
+ return values
197
+ } catch (error) {
198
+ const detail = error instanceof Error ? error.message : String(error)
199
+
200
+ throw new CLIArgsError(`${detail}\nRun \`map-tui --help\` for the supported flags.`)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Parses a `map-tui` command line.
206
+ *
207
+ * @throws {CLIArgsError} On an unknown flag, an unparseable or out-of-range number, or a missing archive path.
208
+ */
209
+ export function parseCLIArgs(argv: readonly string[], environment: CLIEnvironment = {}): CLIArgs {
210
+ const values = readFlags(argv)
211
+
212
+ if (values.help) return { mode: "help" }
213
+
214
+ if (values.version) return { mode: "version" }
215
+
216
+ const tiles = (values.tiles ?? environment.MAILWOMAN_TILES ?? "").trim()
217
+
218
+ if (!tiles.length) {
219
+ throw new CLIArgsError(
220
+ "No tile archive: pass --tiles <archive.pmtiles> or set MAILWOMAN_TILES.\n" +
221
+ "Planet and region archives: https://protomaps.com/downloads"
222
+ )
223
+ }
224
+
225
+ return {
226
+ mode: "browse",
227
+ tiles,
228
+ lat: numericFlag("lat", values.lat, DEFAULT_LAT, MIN_LAT, MAX_LAT),
229
+ lon: numericFlag("lon", values.lon, DEFAULT_LON, MIN_LON, MAX_LON),
230
+ zoom: Math.round(numericFlag("zoom", values.zoom, DEFAULT_ZOOM, MIN_ZOOM, MAX_ZOOM)),
231
+ }
232
+ }
package/cli.ts ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @copyright Sister Software.
5
+ * @license AGPL-3.0
6
+ * @author Teffen Ellis, et al.
7
+ */
8
+
9
+ /**
10
+ * The `map-tui` bin — `npx @mailwoman/map-tui` opens a PMTiles archive as a full-screen terminal map.
11
+ *
12
+ * This file owns everything process-shaped so the rest of the package stays testable without one: argv and the
13
+ * environment (parsed by ./cli-args.ts), the archive handle, signal handlers, and the exit code. `MapBrowser` takes
14
+ * streams rather than reaching for `process` itself, which is what lets the PTY smoke test drive the real bin and a
15
+ * unit test drive the parser with neither.
16
+ *
17
+ * Signal handling is not optional here. This is a raw-mode app on the alternate screen with mouse reporting on, and
18
+ * there is no framework underneath to put any of that back — a process killed between `start` and `restore` leaves the
19
+ * user with an unusable shell. So `restore` is wired to SIGINT, SIGTERM and `exit`, and it is idempotent for exactly
20
+ * that reason.
21
+ */
22
+
23
+ import { createRequire } from "node:module"
24
+
25
+ import { MapBrowser } from "./browser.ts"
26
+ import { type CLIArgs, CLIArgsError, HELP_TEXT, parseCLIArgs } from "./cli-args.ts"
27
+ import { TileSource } from "./tile-source.ts"
28
+
29
+ /**
30
+ * Exit code for a command line that could not be parsed.
31
+ */
32
+ const EXIT_USAGE = 1
33
+
34
+ /**
35
+ * Reads the package's own version.
36
+ *
37
+ * Self-reference (`@mailwoman/map-tui/...`, which the package's `exports` publishes) rather than a path relative to
38
+ * this file: the bin runs from `out/` when installed and from the workspace root in development, and only the package
39
+ * graph knows which. `createRequire` parses the JSON itself, so no reader here has to.
40
+ */
41
+ function readVersion(): string {
42
+ const require = createRequire(import.meta.url)
43
+ const manifest = require("@mailwoman/map-tui/package.json") as { version?: string }
44
+
45
+ return manifest.version ?? "0.0.0"
46
+ }
47
+
48
+ /**
49
+ * Opens the archive, translating the filesystem's error into something that names the flag that was wrong.
50
+ */
51
+ async function openTiles(path: string): Promise<TileSource> {
52
+ try {
53
+ return await TileSource.open(path)
54
+ } catch (error) {
55
+ const detail = error instanceof Error ? error.message : String(error)
56
+
57
+ throw new CLIArgsError(
58
+ `Could not open the tile archive at ${path}: ${detail}\n` +
59
+ "Pass --tiles <archive.pmtiles>, or download one from https://protomaps.com/downloads"
60
+ )
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Runs the interactive browser against an already-open archive, resolving with its exit code.
66
+ */
67
+ async function browse(source: TileSource, args: { lat: number; lon: number; zoom: number }): Promise<number> {
68
+ const browser = new MapBrowser({
69
+ source,
70
+ input: process.stdin,
71
+ output: process.stdout,
72
+ lat: args.lat,
73
+ lon: args.lon,
74
+ zoom: args.zoom,
75
+ })
76
+
77
+ const onSignal = (): void => browser.requestExit(130)
78
+ const onExit = (): void => browser.restore()
79
+
80
+ process.on("SIGINT", onSignal)
81
+ process.on("SIGTERM", onSignal)
82
+ process.on("exit", onExit)
83
+
84
+ try {
85
+ return await browser.run()
86
+ } finally {
87
+ browser.restore()
88
+ process.off("SIGINT", onSignal)
89
+ process.off("SIGTERM", onSignal)
90
+ process.off("exit", onExit)
91
+ }
92
+ }
93
+
94
+ async function main(): Promise<number> {
95
+ let args: CLIArgs
96
+
97
+ try {
98
+ /**
99
+ * This package takes no `@mailwoman` dependency by design (see ./mercator.ts), so the blessed readers are out of
100
+ * reach: `@mailwoman/core/env` would pull core's shipped data behind a CLI whose whole premise is `npx`.
101
+ * `parseCLIArgs` is the local equivalent — argv and the environment are read on this one line, and nowhere else in
102
+ * the package.
103
+ */
104
+ // oxlint-disable-next-line sister-software/no-process-globals -- see above.
105
+ args = parseCLIArgs(process.argv.slice(2), process.env)
106
+ } catch (error) {
107
+ if (!(error instanceof CLIArgsError)) throw error
108
+
109
+ process.stderr.write(`${error.message}\n`)
110
+
111
+ return EXIT_USAGE
112
+ }
113
+
114
+ if (args.mode === "help") {
115
+ process.stdout.write(HELP_TEXT)
116
+
117
+ return 0
118
+ }
119
+
120
+ if (args.mode === "version") {
121
+ process.stdout.write(`${readVersion()}\n`)
122
+
123
+ return 0
124
+ }
125
+
126
+ let source: TileSource
127
+
128
+ try {
129
+ source = await openTiles(args.tiles)
130
+ } catch (error) {
131
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
132
+
133
+ return EXIT_USAGE
134
+ }
135
+
136
+ try {
137
+ return await browse(source, args)
138
+ } finally {
139
+ await source.close()
140
+ }
141
+ }
142
+
143
+ process.exitCode = await main()
package/frame.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * per cell. The braille dither/luminance work is asciify's — `FrameRasterizer` subclasses `AsciifyTerminal` with a
12
12
  * no-op sink purely to reach its protected `_computeBrailleCells`, `_cellChars`, `_cellColors`, so this module never
13
13
  * re-implements the dot math. `frameToANSILines` and `overlayText` then work on the plain `MapFrame` value, with no
14
- * further asciify dependency.
14
+ * further asciify dependency; `blitFrame` is the seam back the other way, for callers driving a live terminal.
15
15
  */
16
16
 
17
17
  import { AsciifyTerminal } from "@sister.software/asciify/tui"
@@ -168,3 +168,32 @@ export function overlayText(
168
168
  export function rgbToPacked(color: RGB): number {
169
169
  return (color[0] << 16) | (color[1] << 8) | color[2]
170
170
  }
171
+
172
+ /**
173
+ * Codepoint written for a cell the frame left empty. `MapFrame` stores 0 there; `AsciifyTerminal` expects a real
174
+ * character, and normalizes a space to the inkless color itself.
175
+ */
176
+ const SPACE_CODEPOINT = 0x20
177
+
178
+ /**
179
+ * Writes a frame's cells into an `AsciifyTerminal`'s current frame. Call `flush()` afterwards to emit the damage.
180
+ *
181
+ * The frame's packed color is the exact representation asciify canonicalizes truecolor to, so this is a copy and not a
182
+ * conversion — the channels are unpacked here only because {@linkcode AsciifyTerminal.setCell} takes them apart. Cells
183
+ * beyond the terminal's own grid are dropped by `setCell`, so a frame larger than the pane clips rather than throws.
184
+ */
185
+ export function blitFrame(terminal: AsciifyTerminal, frame: MapFrame): void {
186
+ for (let row = 0; row < frame.rows; row++) {
187
+ for (let column = 0; column < frame.columns; column++) {
188
+ const cellIndex = row * frame.columns + column
189
+ const char = frame.chars[cellIndex]!
190
+ const color = frame.colors[cellIndex]!
191
+
192
+ terminal.setCell(column, row, char === 0 ? SPACE_CODEPOINT : char, [
193
+ (color >> 16) & 0xff,
194
+ (color >> 8) & 0xff,
195
+ color & 0xff,
196
+ ])
197
+ }
198
+ }
199
+ }
package/input.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * @copyright Sister Software.
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ */
6
+
7
+ /**
8
+ * Terminal input decoding for the interactive map browser.
9
+ *
10
+ * `decodeInputChunk` turns a raw-mode stdin chunk into zero or more {@link MapTUIInput} events. It stays pure — the
11
+ * caller owns the only state there is, the unresolved trailing fragment the function hands back — so one chunk in gives
12
+ * the same events out every time.
13
+ *
14
+ * THE FALLBACK IS THE DANGEROUS PART. An ESC this decoder had no rule for used to mean "the Esc key", i.e. QUIT, which
15
+ * made every unrecognized escape sequence a quit: F1 (`ESC O P`), an OSC reply the terminal sends unasked (`ESC ] 11 ;
16
+ * rgb:… BEL`), and — worst, because it needs no exotic key at all — a mouse report split across two stdin reads, whose
17
+ * first half ends inside the sequence. So the fallback now separates three cases:
18
+ *
19
+ * - A sequence this decoder recognizes is consumed and acted on (arrows, SGR mouse).
20
+ * - A sequence it does NOT recognize is consumed WHOLE and ignored: CSI (`ESC [ … final`), SS3 (`ESC O final`), and the
21
+ * string family (OSC/DCS/SOS/PM/APC, terminated by BEL or ST). Re-scanning their bodies as characters is how a `q`
22
+ * inside a cursor-position report quit the app.
23
+ * - A chunk that ENDS mid-sequence — including a lone trailing ESC, which is byte-for-byte the start of one — is not
24
+ * decoded at all: it comes back as {@link DecodedInput.pending} for the caller to prepend to the next chunk. Quit is
25
+ * emitted only for an ESC that is neither, i.e. one whose following byte cannot continue a sequence.
26
+ *
27
+ * Holding costs a lone Esc keypress its effect until the next byte arrives. That is the right side of the trade for a
28
+ * browser whose advertised quit keys are `q` and Ctrl+C: the alternative is a drag ending the session because the
29
+ * kernel split a read.
30
+ *
31
+ * Mouse reports are SGR-encoded (DEC private mode 1006), which is what {@link MOUSE_ENABLE} asks for. Their coordinates
32
+ * are 1-based on the wire and 0-based in every event here — the off-by-one lives at this boundary and nowhere else.
33
+ */
34
+
35
+ /**
36
+ * Enables mouse reporting: button events (1000), drag/button-motion tracking (1002), and SGR extended coordinates
37
+ * (1006) so columns past 223 survive.
38
+ */
39
+ export const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1002h\u001B[?1006h"
40
+
41
+ /**
42
+ * Disables mouse reporting, in the reverse order it was enabled.
43
+ */
44
+ export const MOUSE_DISABLE = "\u001B[?1006l\u001B[?1002l\u001B[?1000l"
45
+
46
+ /**
47
+ * A decoded input event. Pan and zoom carry direction and magnitude only — how far a step moves the map is the
48
+ * browser's decision, not the decoder's.
49
+ */
50
+ export type MapTUIInput =
51
+ | { kind: "quit" }
52
+ /**
53
+ * Ctrl+C. Distinct from `quit` because the process must exit 130, and because raw mode means no SIGINT is raised.
54
+ */
55
+ | { kind: "interrupt" }
56
+ | { kind: "pan"; dx: number; dy: number }
57
+ | { kind: "zoom"; delta: number }
58
+ | { kind: "wheel"; delta: number; column: number; row: number }
59
+ | { kind: "press"; column: number; row: number }
60
+ | { kind: "drag"; column: number; row: number }
61
+ | { kind: "release" }
62
+
63
+ const ESC = "\u001B"
64
+ const CTRL_C = "\u0003"
65
+
66
+ /* oxlint-disable no-control-regex -- ESC (U+001B) is the byte every pattern below exists to match. A decoder of
67
+ terminal escape sequences cannot avoid the control character the sequences are made of. */
68
+
69
+ /**
70
+ * SGR mouse report: `ESC [ < button ; column ; row (M|m)`, where `M` is a press/motion and `m` a release.
71
+ */
72
+ const MOUSE_SGR_PATTERN = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/y
73
+
74
+ /**
75
+ * Cursor keys, in both normal (`ESC [ A`) and application (`ESC O A`) modes — a terminal may be left in either.
76
+ */
77
+ const ARROW_PATTERN = /\u001B(?:\[|O)([ABCD])/y
78
+
79
+ /**
80
+ * Any other CSI sequence, consumed whole and ignored. Without this, an unhandled sequence's body would be re-scanned as
81
+ * individual key presses, and a stray `q` inside one would quit the app.
82
+ */
83
+ const UNKNOWN_CSI_PATTERN = /\u001B\[[\d;<>?]*[\u0020-\u002F]*[\u0040-\u007E]/y
84
+
85
+ /**
86
+ * Any other SS3 sequence (`ESC O <final>`) — F1–F4 on xterm, and the numeric keypad in application mode. Two bytes
87
+ * shorter than a CSI, and until this pattern existed the likeliest key on a keyboard to quit the browser by accident.
88
+ */
89
+ const UNKNOWN_SS3_PATTERN = /\u001BO[\u0040-\u007E]/y
90
+
91
+ /**
92
+ * The string-sequence family: OSC (`ESC ]`), DCS (`ESC P`), SOS (`ESC X`), PM (`ESC ^`), APC (`ESC _`), each running to
93
+ * a BEL or an ST (`ESC \`). A terminal sends these UNASKED — an OSC colour or clipboard reply lands on stdin with no
94
+ * key pressed — so consuming them is not a nicety.
95
+ */
96
+ const STRING_SEQUENCE_PATTERN = /\u001B[P\]X^_][\s\S]*?(?:\u0007|\u001B\\)/y
97
+
98
+ /**
99
+ * Every "unrecognized but complete" sequence, in the order they are tried. Sharing one list is what keeps a new
100
+ * sequence family from being added to the consumer and forgotten in the incomplete test below.
101
+ */
102
+ const UNRECOGNIZED_PATTERNS = [UNKNOWN_CSI_PATTERN, UNKNOWN_SS3_PATTERN, STRING_SEQUENCE_PATTERN] as const
103
+
104
+ /**
105
+ * A chunk that STOPS inside a sequence. The end-anchors are what make these "incomplete" rather than "unrecognized":
106
+ * each requires the WHOLE remainder of the chunk to be a legal prefix and nothing more. The first covers both a lone
107
+ * trailing ESC and an `ESC O` still waiting for its final byte.
108
+ */
109
+ const PARTIAL_PATTERNS = [/\u001BO?$/y, /\u001B\[[\d;<>?]*[\u0020-\u002F]*$/y, /\u001B[P\]X^_][^\u0007]*$/y] as const
110
+
111
+ /* oxlint-enable no-control-regex */
112
+
113
+ /**
114
+ * Wheel reports set bit 6 of the button field; the low bit then separates up (0) from down (1).
115
+ */
116
+ const WHEEL_FLAG = 64
117
+
118
+ /**
119
+ * Motion reports set bit 5. With mode 1002 that means "moved with a button held" — a drag.
120
+ */
121
+ const MOTION_FLAG = 32
122
+
123
+ const BUTTON_MASK = 3
124
+ const LEFT_BUTTON = 0
125
+
126
+ /**
127
+ * The longest fragment worth holding for the next chunk (64 KB). See the drop site: this bounds an unterminated string
128
+ * sequence, not a real key.
129
+ */
130
+ const MAX_PENDING_LENGTH = 65_536
131
+
132
+ const ARROW_INPUTS: Record<string, MapTUIInput> = {
133
+ A: { kind: "pan", dx: 0, dy: -1 },
134
+ B: { kind: "pan", dx: 0, dy: 1 },
135
+ C: { kind: "pan", dx: 1, dy: 0 },
136
+ D: { kind: "pan", dx: -1, dy: 0 },
137
+ }
138
+
139
+ /**
140
+ * Single-character bindings. `a`/`z` are mapscii's zoom keys, `+`/`-` the ones every other map uses, and `hjkl` the vim
141
+ * pan set mapscii also accepts. `y` joins `z` for zoom-out because on a QWERTZ keyboard it sits where `z` does on
142
+ * QWERTY — mapscii binds both for the same reason.
143
+ */
144
+ const CHARACTER_INPUTS: Record<string, MapTUIInput> = {
145
+ q: { kind: "quit" },
146
+ Q: { kind: "quit" },
147
+ "+": { kind: "zoom", delta: 1 },
148
+ "=": { kind: "zoom", delta: 1 },
149
+ a: { kind: "zoom", delta: 1 },
150
+ "-": { kind: "zoom", delta: -1 },
151
+ _: { kind: "zoom", delta: -1 },
152
+ z: { kind: "zoom", delta: -1 },
153
+ y: { kind: "zoom", delta: -1 },
154
+ h: { kind: "pan", dx: -1, dy: 0 },
155
+ j: { kind: "pan", dx: 0, dy: 1 },
156
+ k: { kind: "pan", dx: 0, dy: -1 },
157
+ l: { kind: "pan", dx: 1, dy: 0 },
158
+ }
159
+
160
+ /**
161
+ * One decoded chunk: its events, plus whatever trailing bytes could not be decoded YET.
162
+ */
163
+ export interface DecodedInput {
164
+ events: MapTUIInput[]
165
+ /**
166
+ * An unresolved escape fragment from the end of the chunk — prepend it to the next one. Empty when the chunk ended
167
+ * cleanly, which is the overwhelmingly common case.
168
+ */
169
+ pending: string
170
+ }
171
+
172
+ /**
173
+ * The length of the unrecognized-but-complete sequence at `index`, or null when there isn't one.
174
+ */
175
+ function consumeUnrecognized(buffer: string, index: number): number | null {
176
+ for (const pattern of UNRECOGNIZED_PATTERNS) {
177
+ pattern.lastIndex = index
178
+
179
+ if (pattern.exec(buffer)) return pattern.lastIndex
180
+ }
181
+
182
+ return null
183
+ }
184
+
185
+ /**
186
+ * True when everything from `index` to the end of the chunk is a legal PREFIX of a sequence — i.e. the terminal is
187
+ * mid-sequence and the rest is in the next read.
188
+ */
189
+ function isIncompleteSequence(buffer: string, index: number): boolean {
190
+ for (const pattern of PARTIAL_PATTERNS) {
191
+ pattern.lastIndex = index
192
+
193
+ if (pattern.exec(buffer)) return true
194
+ }
195
+
196
+ return false
197
+ }
198
+
199
+ /**
200
+ * Builds the event for one SGR mouse report.
201
+ */
202
+ function mouseInput(button: number, column: number, row: number, final: string): MapTUIInput | null {
203
+ if (button & WHEEL_FLAG) {
204
+ return { kind: "wheel", delta: button & 1 ? -1 : 1, column, row }
205
+ }
206
+
207
+ if (final === "m") return { kind: "release" }
208
+
209
+ if ((button & BUTTON_MASK) !== LEFT_BUTTON) return null
210
+
211
+ return button & MOTION_FLAG ? { kind: "drag", column, row } : { kind: "press", column, row }
212
+ }
213
+
214
+ /**
215
+ * Decodes one raw-mode stdin chunk into input events. Unrecognized bytes are dropped; an unresolved trailing escape
216
+ * fragment is returned rather than decoded, and the caller passes it back as `pending` with the next chunk.
217
+ */
218
+ export function decodeInputChunk(chunk: string, pending = ""): DecodedInput {
219
+ const events: MapTUIInput[] = []
220
+ const buffer = pending + chunk
221
+ let index = 0
222
+
223
+ while (index < buffer.length) {
224
+ const character = buffer[index]!
225
+
226
+ if (character !== ESC) {
227
+ const input = character === CTRL_C ? { kind: "interrupt" as const } : CHARACTER_INPUTS[character]
228
+
229
+ if (input) {
230
+ events.push(input)
231
+ }
232
+
233
+ index += 1
234
+
235
+ continue
236
+ }
237
+
238
+ MOUSE_SGR_PATTERN.lastIndex = index
239
+ const mouse = MOUSE_SGR_PATTERN.exec(buffer)
240
+
241
+ if (mouse) {
242
+ const input = mouseInput(Number(mouse[1]), Number(mouse[2]) - 1, Number(mouse[3]) - 1, mouse[4]!)
243
+
244
+ if (input) {
245
+ events.push(input)
246
+ }
247
+
248
+ index = MOUSE_SGR_PATTERN.lastIndex
249
+
250
+ continue
251
+ }
252
+
253
+ ARROW_PATTERN.lastIndex = index
254
+ const arrow = ARROW_PATTERN.exec(buffer)
255
+
256
+ if (arrow) {
257
+ events.push(ARROW_INPUTS[arrow[1]!]!)
258
+ index = ARROW_PATTERN.lastIndex
259
+
260
+ continue
261
+ }
262
+
263
+ const consumed = consumeUnrecognized(buffer, index)
264
+
265
+ if (consumed !== null) {
266
+ index = consumed
267
+
268
+ continue
269
+ }
270
+
271
+ // The buffer stops inside a sequence — hand the fragment back instead of guessing at it.
272
+ if (isIncompleteSequence(buffer, index)) {
273
+ const fragment = buffer.slice(index)
274
+
275
+ // …unless it has stopped being plausible. An unterminated string sequence would otherwise grow the held
276
+ // fragment for the life of the process. Dropping is the safe failure: it emits nothing, where flushing
277
+ // the fragment back through the decoder would read its body as keys, which is the bug this all exists
278
+ // for. The cap is generous because an OSC 52 clipboard reply is legitimately large.
279
+ return { events, pending: fragment.length > MAX_PENDING_LENGTH ? "" : fragment }
280
+ }
281
+
282
+ // An ESC whose next byte cannot continue a sequence: the Esc KEY.
283
+ events.push({ kind: "quit" })
284
+ index += 1
285
+ }
286
+
287
+ return { events, pending: "" }
288
+ }