@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/README.md +59 -0
- package/browser.ts +536 -0
- package/cli-args.ts +232 -0
- package/cli.ts +143 -0
- package/frame.ts +30 -1
- package/input.ts +288 -0
- package/out/browser.d.ts +126 -0
- package/out/browser.d.ts.map +1 -0
- package/out/browser.js +386 -0
- package/out/browser.js.map +1 -0
- package/out/cli-args.d.ts +52 -0
- package/out/cli-args.d.ts.map +1 -0
- package/out/cli-args.js +168 -0
- package/out/cli-args.js.map +1 -0
- package/out/cli.d.ts +8 -0
- package/out/cli.d.ts.map +1 -0
- package/out/cli.js +122 -0
- package/out/cli.js.map +1 -0
- package/out/frame.d.ts +18 -0
- package/out/frame.d.ts.map +1 -1
- package/out/frame.js +27 -1
- package/out/frame.js.map +1 -1
- package/out/input.d.ts +93 -0
- package/out/input.d.ts.map +1 -0
- package/out/input.js +214 -0
- package/out/input.js.map +1 -0
- package/out/renderer.d.ts +9 -2
- package/out/renderer.d.ts.map +1 -1
- package/out/renderer.js +42 -15
- package/out/renderer.js.map +1 -1
- package/out/style.d.ts +19 -8
- package/out/style.d.ts.map +1 -1
- package/out/style.js +47 -2
- package/out/style.js.map +1 -1
- package/out/tile-source.d.ts +23 -2
- package/out/tile-source.d.ts.map +1 -1
- package/out/tile-source.js +44 -11
- package/out/tile-source.js.map +1 -1
- package/out/tsconfig.test.tsbuildinfo +1 -1
- package/out/tsconfig.tsbuildinfo +1 -1
- package/package.json +77 -6
- package/renderer.ts +76 -24
- package/style.ts +63 -10
- package/tile-source.ts +71 -16
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
|
+
}
|