create-pathfinder 1.6.0 → 1.7.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 +20 -3
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +1 -1
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +46 -18
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +241 -13
package/src/theme.mjs
CHANGED
|
@@ -25,9 +25,20 @@
|
|
|
25
25
|
* Deliberately absent: cursor visibility control. A renderer that never hides
|
|
26
26
|
* the cursor has nothing to restore, so no signal handler and no interrupted
|
|
27
27
|
* run can leave a terminal broken. A cursor parked at the end of a progress bar
|
|
28
|
-
* is the accepted cost of that guarantee.
|
|
28
|
+
* is the accepted cost of that guarantee. Feature 23 adds a second repainting
|
|
29
|
+
* surface and does not weaken this: the selector parks the cursor visibly below
|
|
30
|
+
* its block for exactly the same reason.
|
|
31
|
+
*
|
|
32
|
+
* Newly present, and worth naming beside that absence: `width` and `clip`, from
|
|
33
|
+
* `cells.mjs`. They measure rather than decorate, which is why the algorithm
|
|
34
|
+
* lives in its own module and only the seam is published here — and they are
|
|
35
|
+
* the one part of this file that answers the same way in every tier, because a
|
|
36
|
+
* string's printed width is a fact about the string rather than a capability of
|
|
37
|
+
* the terminal.
|
|
29
38
|
*/
|
|
30
39
|
|
|
40
|
+
import { clip, width } from "./cells.mjs";
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* SGR codes, written out rather than depended on.
|
|
33
44
|
*
|
|
@@ -105,11 +116,37 @@ const BRAND = Object.freeze({
|
|
|
105
116
|
* `rule` is both the stroke the Pathfinder mark is drawn from and the character
|
|
106
117
|
* any other horizontal device would use. `gutter` hangs a block together down
|
|
107
118
|
* its left edge. Both are drawn left to right from a fixed count and neither
|
|
108
|
-
* closes on the right, so no caller ever has to know the printed width of a
|
|
109
|
-
* decorated string
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* own.
|
|
119
|
+
* closes on the right, ~~so no caller ever has to know the printed width of a
|
|
120
|
+
* decorated string~~ — **superseded in Feature 22; see `width` and `clip`
|
|
121
|
+
* below.** That is why there is no corner, no box, and no border character in
|
|
122
|
+
* this table — a closed box cannot be aligned without width maths that emoji
|
|
123
|
+
* defeat, which the prototype demonstrated by failing to close its own.
|
|
124
|
+
*
|
|
125
|
+
* The struck clause was true of every device in this module and is still the
|
|
126
|
+
* reason none of them closes on the right. What falsified it is a surface this
|
|
127
|
+
* module did not have when the sentence was written: a *repainting* one. A
|
|
128
|
+
* renderer that redraws a block in place has to know how many rows that block
|
|
129
|
+
* occupies, and a line wider than the terminal silently becomes two — so the
|
|
130
|
+
* cursor-up count goes wrong and the block walks down the screen. Feature 23's
|
|
131
|
+
* prototype reproduced exactly that at 24 columns.
|
|
132
|
+
*
|
|
133
|
+
* So the conclusion is narrowed rather than reversed. Nothing here draws a box,
|
|
134
|
+
* and nothing should. But "no caller needs printed width" was a claim about
|
|
135
|
+
* *what this module happened to contain*, not a rule about terminals, and the
|
|
136
|
+
* honest correction is to publish the measurement rather than let a caller
|
|
137
|
+
* reach for `.length` and be wrong by the length of an escape sequence.
|
|
138
|
+
*
|
|
139
|
+
* The third group is the selector's, added by Feature 23, and it is five rather
|
|
140
|
+
* than the four a checkbox list looks like it needs. `pointer` marks the
|
|
141
|
+
* highlighted row, `checked` and `unchecked` carry a multi-select row's state,
|
|
142
|
+
* and `arrowUp` and `arrowDown` are the hint line's — printed as text in a
|
|
143
|
+
* sentence, and therefore glyphs like any other rather than the escape
|
|
144
|
+
* sequences the same arrows arrive as.
|
|
145
|
+
*
|
|
146
|
+
* Their ASCII counterparts are the reason the minimum-width floor is what it is.
|
|
147
|
+
* `[x]` is three cells where `◉` is one, and `...` is three where `…` is one, so
|
|
148
|
+
* ASCII is the *binding* alphabet for width — 5 to 8 cells wider per row — and
|
|
149
|
+
* the floor below is derived against it rather than against the pretty one.
|
|
113
150
|
*/
|
|
114
151
|
const GLYPHS = Object.freeze({
|
|
115
152
|
unicode: Object.freeze({
|
|
@@ -127,6 +164,11 @@ const GLYPHS = Object.freeze({
|
|
|
127
164
|
gutter: "│",
|
|
128
165
|
barFull: "█",
|
|
129
166
|
barEmpty: "░",
|
|
167
|
+
pointer: "❯",
|
|
168
|
+
checked: "◉",
|
|
169
|
+
unchecked: "○",
|
|
170
|
+
arrowUp: "↑",
|
|
171
|
+
arrowDown: "↓",
|
|
130
172
|
}),
|
|
131
173
|
ascii: Object.freeze({
|
|
132
174
|
ok: "+",
|
|
@@ -143,6 +185,11 @@ const GLYPHS = Object.freeze({
|
|
|
143
185
|
gutter: "|",
|
|
144
186
|
barFull: "#",
|
|
145
187
|
barEmpty: ".",
|
|
188
|
+
pointer: ">",
|
|
189
|
+
checked: "[x]",
|
|
190
|
+
unchecked: "[ ]",
|
|
191
|
+
arrowUp: "^",
|
|
192
|
+
arrowDown: "v",
|
|
146
193
|
}),
|
|
147
194
|
});
|
|
148
195
|
|
|
@@ -262,6 +309,85 @@ function selectTier({ isTTY, color, unicode }) {
|
|
|
262
309
|
return color && unicode ? "expressive" : "plain";
|
|
263
310
|
}
|
|
264
311
|
|
|
312
|
+
/**
|
|
313
|
+
* How many columns a terminal that told us nothing is assumed to have.
|
|
314
|
+
*
|
|
315
|
+
* 80, because that is the width a terminal has when it has no opinion, and
|
|
316
|
+
* because guessing narrow is the safe direction: an over-wide guess lets a row
|
|
317
|
+
* wrap, which is the one failure the clipping in `cells.mjs` exists to prevent.
|
|
318
|
+
*/
|
|
319
|
+
const DEFAULT_COLUMNS = 80;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The narrowest terminal keyboard selection is offered on: **49 columns**.
|
|
323
|
+
*
|
|
324
|
+
* Measured rather than chosen. The derivation ran the real prompt corpus — every
|
|
325
|
+
* selection question the CLI can ask — through the width model in `cells.mjs`,
|
|
326
|
+
* in both alphabets, and asked what each candidate width still preserves:
|
|
327
|
+
*
|
|
328
|
+
* | Tier preserved | Columns |
|
|
329
|
+
* |---------------------------------|---------|
|
|
330
|
+
* | marker + full label | 24 |
|
|
331
|
+
* | + full hint line | 41 |
|
|
332
|
+
* | **+ `-> path` context** | **49** |
|
|
333
|
+
* | + `(detected)` suffix | 56 |
|
|
334
|
+
*
|
|
335
|
+
* 49 is the smallest width at which nothing load-bearing is lost: the marker,
|
|
336
|
+
* the whole label, the whole interaction hint, and the path each row would
|
|
337
|
+
* write to. An earlier estimate of 32 was falsified outright — at 32 the hint
|
|
338
|
+
* loses the word `confirm` and the path is cut mid-word, and even 40 loses one
|
|
339
|
+
* character.
|
|
340
|
+
*
|
|
341
|
+
* The floor is deliberately **not** 56. `(detected)` is the only thing 49 gives
|
|
342
|
+
* up, and it is already stated in the run's ENVIRONMENT block, so it duplicates
|
|
343
|
+
* information rather than carrying it. A suffix that says nothing new does not
|
|
344
|
+
* get to decide whether the whole interaction is available — it is omitted
|
|
345
|
+
* cleanly at narrow widths instead, never truncated to a fragment.
|
|
346
|
+
*
|
|
347
|
+
* Not configurable, and that is the decision rather than an omission. A floor
|
|
348
|
+
* anyone can lower is a floor that stops meaning what it was measured to mean.
|
|
349
|
+
*/
|
|
350
|
+
const SELECTION_MIN_COLUMNS = 49;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* May this run ask its questions with the arrow keys?
|
|
354
|
+
*
|
|
355
|
+
* A capability in its own right, and specifically **not** derived from
|
|
356
|
+
* `dynamic`. `dynamic` is colour ∧ unicode, and neither is a claim about
|
|
357
|
+
* repainting: someone who set `NO_COLOR` asked for no decoration, not for no
|
|
358
|
+
* cursor movement, and someone in a Latin-1 locale said nothing at all about
|
|
359
|
+
* either. Deriving one from the other would take the keyboard away from users
|
|
360
|
+
* who never asked to lose it.
|
|
361
|
+
*
|
|
362
|
+
* Five conditions, written as refusals, in the order that makes each one's
|
|
363
|
+
* reason legible:
|
|
364
|
+
*
|
|
365
|
+
* 1. **`PATHFINDER_PROMPT=classic` outranks everything.** It is a person saying
|
|
366
|
+
* so, and it is the first-class answer for a screen reader — where a
|
|
367
|
+
* repainting block re-announces itself on every arrow press and the highlight
|
|
368
|
+
* is carried by position and colour, neither of which is conveyed. It also
|
|
369
|
+
* outranks capability so that scripts and anyone who simply prefers typing a
|
|
370
|
+
* number get a supported path rather than a workaround.
|
|
371
|
+
* 2. **Both ends must be terminals.** A selector needs somewhere to repaint
|
|
372
|
+
* *and* someone able to press a key. Either half missing and the question
|
|
373
|
+
* cannot be answered this way at all.
|
|
374
|
+
* 3. **`TERM=dumb` is the terminal telling us what it is.** Taken at its word,
|
|
375
|
+
* exactly as `detectColor` takes it.
|
|
376
|
+
* 4. **`setRawMode` must exist on the input.** Not called here or anywhere in
|
|
377
|
+
* this package — readline owns raw mode and keeps it — but its absence means
|
|
378
|
+
* the stream cannot deliver keypresses, so there is nothing to borrow.
|
|
379
|
+
* 5. **At least `SELECTION_MIN_COLUMNS`.** Below the floor the classic path is
|
|
380
|
+
* used, which is a supported way to answer the question rather than a
|
|
381
|
+
* degradation of this one.
|
|
382
|
+
*/
|
|
383
|
+
function detectSelection({ env, isTTY, inputIsTTY, setRawMode, columns }) {
|
|
384
|
+
if (env.PATHFINDER_PROMPT === "classic") return false;
|
|
385
|
+
if (!isTTY || !inputIsTTY) return false;
|
|
386
|
+
if (env.TERM === "dumb") return false;
|
|
387
|
+
if (!setRawMode) return false;
|
|
388
|
+
return columns >= SELECTION_MIN_COLUMNS;
|
|
389
|
+
}
|
|
390
|
+
|
|
265
391
|
/**
|
|
266
392
|
* Build a theme from what the process was able to observe about the outside
|
|
267
393
|
* world.
|
|
@@ -272,20 +398,69 @@ function selectTier({ isTTY, color, unicode }) {
|
|
|
272
398
|
* @param {string} [options.platform] - `process.platform`, as `run()` received
|
|
273
399
|
* it.
|
|
274
400
|
* @param {boolean} [options.isTTY] - whether stdout is a terminal.
|
|
401
|
+
* @param {boolean} [options.inputIsTTY] - whether stdin is a terminal. Defaults
|
|
402
|
+
* to false for the same reason every other capability here does: an
|
|
403
|
+
* unanswerable environment gets the conservative answer.
|
|
404
|
+
* @param {boolean} [options.setRawMode] - whether the input stream offers
|
|
405
|
+
* `setRawMode`. Passed as a boolean rather than the stream, so this module
|
|
406
|
+
* stays pure and no test has to synthesize a TTY to ask a question about one.
|
|
407
|
+
* @param {number} [options.columns] - the terminal's width. Anything that is
|
|
408
|
+
* not a positive integer — including the `undefined` a non-TTY stream reports
|
|
409
|
+
* — falls back to 80 rather than throwing.
|
|
275
410
|
* @returns {Readonly<object>} the theme
|
|
276
411
|
*/
|
|
277
|
-
export function createTheme({
|
|
412
|
+
export function createTheme({
|
|
413
|
+
env = {},
|
|
414
|
+
platform = "linux",
|
|
415
|
+
isTTY = false,
|
|
416
|
+
inputIsTTY = false,
|
|
417
|
+
setRawMode = false,
|
|
418
|
+
columns,
|
|
419
|
+
} = {}) {
|
|
278
420
|
const unicode = detectUnicode(env, platform);
|
|
279
421
|
const color = detectColor(env, isTTY);
|
|
280
422
|
const colorDepth = detectColorDepth(env, color);
|
|
281
423
|
const tier = selectTier({ isTTY, color, unicode });
|
|
282
424
|
|
|
425
|
+
// A width, always, and never a throw. `process.stdout.columns` is `undefined`
|
|
426
|
+
// whenever stdout is not a terminal, and every arithmetic done with it
|
|
427
|
+
// afterwards would be `NaN` — which compares false against every threshold and
|
|
428
|
+
// would silently answer "no" to questions nobody asked.
|
|
429
|
+
const terminalColumns =
|
|
430
|
+
Number.isInteger(columns) && columns > 0 ? columns : DEFAULT_COLUMNS;
|
|
431
|
+
|
|
283
432
|
// May this run repaint a line it has already written? Only where someone is
|
|
284
433
|
// watching it happen. A pipe keeps every byte ever written to it, so a
|
|
285
434
|
// progress treatment that repaints into a log file produces a transcript of
|
|
286
435
|
// its own animation.
|
|
287
436
|
const dynamic = tier === "expressive";
|
|
288
437
|
|
|
438
|
+
const selection = detectSelection({
|
|
439
|
+
env,
|
|
440
|
+
isTTY,
|
|
441
|
+
inputIsTTY,
|
|
442
|
+
setRawMode,
|
|
443
|
+
columns: terminalColumns,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Who is allowed the cursor escapes, and it is the union of the two surfaces
|
|
447
|
+
// that repaint rather than either one alone.
|
|
448
|
+
//
|
|
449
|
+
// This used to be `dynamic` by itself, and that was correct while the progress
|
|
450
|
+
// bar was the only repainting thing in the package. It stopped being correct
|
|
451
|
+
// the moment a second surface repainted under a *different* capability: a
|
|
452
|
+
// selector on a `NO_COLOR` terminal is offered — `NO_COLOR` says nothing about
|
|
453
|
+
// repainting — and would then have been handed `""` for the very sequences it
|
|
454
|
+
// needs, clearing no line and leaving the tail of every longer row behind.
|
|
455
|
+
//
|
|
456
|
+
// No existing output moves. `progress.mjs` tests `theme.dynamic` itself before
|
|
457
|
+
// it reaches for a primitive, so widening the gate cannot widen what the
|
|
458
|
+
// progress bar draws. What it does narrow is one promise worth stating
|
|
459
|
+
// outright rather than discovering: "a run without colour emits no escape
|
|
460
|
+
// byte" now means no *colour* escape byte. A plain-tier terminal driving a
|
|
461
|
+
// selector emits `ESC[2K` and `ESC[nA`, because that is what a selector is.
|
|
462
|
+
const repaints = dynamic || selection;
|
|
463
|
+
|
|
289
464
|
/**
|
|
290
465
|
* Wrap `text` in an SGR pair, or hand it back untouched.
|
|
291
466
|
*
|
|
@@ -309,6 +484,25 @@ export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}
|
|
|
309
484
|
unicode,
|
|
310
485
|
dynamic,
|
|
311
486
|
|
|
487
|
+
// May this run's questions be answered with the arrow keys?
|
|
488
|
+
//
|
|
489
|
+
// Read by `prompt.mjs` to choose between two whole implementations of the
|
|
490
|
+
// same interface, which is the one legitimate reason to branch on a
|
|
491
|
+
// capability. False is not an error and not a degraded rendering: it selects
|
|
492
|
+
// the numbered/`y n` path, which is a supported way to use this tool and
|
|
493
|
+
// stays byte-identical to the one 1.6.0 shipped.
|
|
494
|
+
selection,
|
|
495
|
+
|
|
496
|
+
// The width the capability above was decided against, resolved and never
|
|
497
|
+
// undefined.
|
|
498
|
+
//
|
|
499
|
+
// A *snapshot*, and the distinction matters: a terminal can be resized while
|
|
500
|
+
// a question is on screen, so a renderer clipping its rows must read the
|
|
501
|
+
// live value from the stream it is writing to. This is here to answer "how
|
|
502
|
+
// wide did we think it was when we decided", which is what a test and a
|
|
503
|
+
// reviewer need, and nothing else.
|
|
504
|
+
columns: terminalColumns,
|
|
505
|
+
|
|
312
506
|
glyph,
|
|
313
507
|
|
|
314
508
|
// Severity, named for what it means and never for the colour it happens to
|
|
@@ -350,13 +544,47 @@ export function createTheme({ env = {}, platform = "linux", isTTY = false } = {}
|
|
|
350
544
|
|
|
351
545
|
// The only escape sequences that are not colour, and the reason they live
|
|
352
546
|
// here: a progress renderer that hand-rolled its own would be a second
|
|
353
|
-
// place capable of writing bytes into a pipe. Exactly
|
|
354
|
-
// column zero,
|
|
355
|
-
//
|
|
356
|
-
//
|
|
547
|
+
// place capable of writing bytes into a pipe. Exactly three — return to
|
|
548
|
+
// column zero, clear the current line, and move up — and every one of them
|
|
549
|
+
// is the empty string wherever repainting is not allowed, so a caller that
|
|
550
|
+
// never checks a capability still emits nothing.
|
|
551
|
+
//
|
|
552
|
+
// `up` is the *only* escape Feature 23 added, and the budget is the design
|
|
553
|
+
// rather than a coincidence. A selector needs to get back to the top of the
|
|
554
|
+
// block it drew and rewrite it; it does not need absolute positioning, a
|
|
555
|
+
// scroll region, an alternate screen, or — above all — cursor hiding. A
|
|
556
|
+
// fourth primitive appearing here means a renderer has started drawing
|
|
557
|
+
// something this package decided not to draw.
|
|
558
|
+
//
|
|
559
|
+
// `n` is a row count, so a non-positive or non-integer one is not an error
|
|
560
|
+
// to raise but a movement to decline: the first paint of a block has nothing
|
|
561
|
+
// above it to return to, and `ESC[0A` moves one row on some terminals rather
|
|
562
|
+
// than none.
|
|
357
563
|
line: Object.freeze({
|
|
358
|
-
start: () => (
|
|
359
|
-
clear: () => (
|
|
564
|
+
start: () => (repaints ? "\r" : ""),
|
|
565
|
+
clear: () => (repaints ? "\u001B[2K" : ""),
|
|
566
|
+
up: (n) => (repaints && Number.isInteger(n) && n > 0 ? `\u001B[${n}A` : ""),
|
|
360
567
|
}),
|
|
568
|
+
|
|
569
|
+
// How wide a rendered string actually is, and how to make it narrower.
|
|
570
|
+
//
|
|
571
|
+
// Published here rather than imported directly by call sites, for the same
|
|
572
|
+
// reason every paint is: one seam, so a second opinion about what a
|
|
573
|
+
// decorated string measures cannot come into existence. A caller reaching
|
|
574
|
+
// past this into `cells.mjs` is doing the thing this module exists to
|
|
575
|
+
// prevent — and a caller reaching for `.length` is simply wrong, by the
|
|
576
|
+
// length of whatever escape sequence the theme just wrapped around it.
|
|
577
|
+
//
|
|
578
|
+
// Note what these are *not* conditioned on. Unlike `line`, they consult
|
|
579
|
+
// neither `dynamic` nor `color` nor the tier: how many cells a string
|
|
580
|
+
// occupies is a fact about the string, and a function that gave a different
|
|
581
|
+
// answer in a pipe would be lying about identical bytes. Capability decides
|
|
582
|
+
// what a caller *does* with the answer, not what the answer is.
|
|
583
|
+
//
|
|
584
|
+
// Deliberately thin. No padding, no alignment, and above all no wrapping —
|
|
585
|
+
// `cells.mjs` explains why wrapping is the one addition that would turn
|
|
586
|
+
// this into the layout engine neither module is allowed to become.
|
|
587
|
+
width,
|
|
588
|
+
clip,
|
|
361
589
|
});
|
|
362
590
|
}
|