@profullstack/readm3 0.1.1 → 0.3.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/dist/viewer.js CHANGED
@@ -1,52 +1,36 @@
1
1
  /**
2
- * The reader: a file browser on the left, the rendered document on the right.
2
+ * The reader: a file browser on the left, the document on the right.
3
+ *
4
+ * The right pane has two modes. Reading renders the buffer through
5
+ * `markdown.ts`; editing shows the same buffer raw, with a caret. Both read the
6
+ * same text, so leaving edit mode is all it takes to see the change rendered —
7
+ * there is no separate preview to keep in step.
3
8
  */
4
- import { readFileSync, statSync } from "node:fs";
5
- import { basename, dirname, relative, resolve } from "node:path";
9
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
10
+ import { basename, dirname, resolve } from "node:path";
6
11
  import { createApp, stringWidth } from "@profullstack/hqtui";
12
+ import { backspace, breakRun, createEditor, del, indent, insert, killToLineEnd, killToLineStart, markSaved, moveDocEnd, moveDocStart, moveDown, moveEnd, moveHome, moveLeft, moveRight, moveUp, moveVertical, moveWordLeft, moveWordRight, newline, redo, text as bufferText, undo, } from "./editor.js";
13
+ import { DEFAULT_FLAVOR } from "./flavors.js";
7
14
  import { renderMarkdown } from "./markdown.js";
15
+ import { colorOf } from "./roles.js";
8
16
  import { filterTree, flatten, indexOfPath, label as pathLabel, reveal, scan, } from "./tree.js";
9
17
  /** Files above this size are refused rather than rendered. */
10
18
  const MAX_BYTES = 4 * 1024 * 1024;
19
+ /** A tab is two columns, in the buffer and on screen alike. */
20
+ const TAB = " ";
11
21
  const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
12
- /** Map a span's semantic role onto the active theme. */
13
- function colorOf(span, theme) {
14
- switch (span.role) {
15
- case "h1":
16
- return theme.title;
17
- case "h2":
18
- return theme.primary;
19
- case "h3":
20
- return theme.secondary;
21
- case "code":
22
- case "lang":
23
- case "bullet":
24
- return theme.accent;
25
- case "fence":
26
- return theme.foreground;
27
- case "gutter":
28
- case "rule":
29
- return theme.border;
30
- case "link":
31
- case "url":
32
- return theme.info;
33
- case "quote":
34
- return theme.secondary;
35
- case "meta":
36
- return theme.muted;
37
- case "th":
38
- return theme.title;
39
- default:
40
- return theme.foreground;
41
- }
42
- }
22
+ const expand = (line) => line.replace(/\t/g, TAB);
23
+ /** Where a caret at `col` lands once tabs are expanded. */
24
+ const displayCol = (line, col) => expand(line.slice(0, col)).length;
43
25
  export async function run(options) {
44
26
  const root = resolve(options.root);
27
+ const flavor = options.flavor ?? DEFAULT_FLAVOR;
28
+ const readOnly = options.readOnly ?? false;
45
29
  const app = await createApp({
46
30
  theme: options.theme,
47
31
  mouse: options.mouse !== false,
48
- // `q` is handled below so it can be typed into the filter.
49
- quitKeys: ["ctrl+c"],
32
+ // `q` is handled below so it can be typed into the filter, and into the text.
33
+ quitKeys: [],
50
34
  });
51
35
  let entries = scan(root, { all: options.all ?? false });
52
36
  let filter = "";
@@ -58,9 +42,16 @@ export async function run(options) {
58
42
  let help = false;
59
43
  let message = "";
60
44
  let openPath = null;
61
- let source = null;
45
+ /** The open file's text, and the caret into it. Null until a file opens. */
46
+ let buffer = null;
47
+ let mode = "read";
48
+ let spoilers = false;
62
49
  let doc = null;
63
50
  let scroll = 0;
51
+ /** Bumped on every edit, so the rendered document is rebuilt exactly then. */
52
+ let stamp = 0;
53
+ /** A yes/no the next keypress answers. */
54
+ let pending = null;
64
55
  let sidebar = options.sidebar ?? 0;
65
56
  // Last measured pane interiors; the panel header needs them a frame early.
66
57
  let viewport = { w: 80, h: 20 };
@@ -68,9 +59,14 @@ export async function run(options) {
68
59
  // Owned here rather than left to the widget, so a click can be turned back
69
60
  // into an index and the view stays put when the selection moves inside it.
70
61
  let treeOffset = 0;
62
+ // The edit pane scrolls on both axes: long lines are not wrapped, because a
63
+ // caret in a wrapped line is a lie about where the text is.
64
+ let editTop = 0;
65
+ let editLeft = 0;
71
66
  // The panel header quotes numbers only the draw can measure, so a change in
72
67
  // geometry or document length schedules exactly one more frame.
73
68
  let measured = { total: -1, h: -1 };
69
+ const dirty = () => buffer?.dirty ?? false;
74
70
  const rebuild = (keepPath) => {
75
71
  view = filter ? filterTree(entries, filter, root) : entries;
76
72
  flat = flatten(view);
@@ -84,16 +80,50 @@ export async function run(options) {
84
80
  message = `${basename(path)} is ${(info.size / 1024 / 1024).toFixed(1)} MB — too large to render`;
85
81
  return;
86
82
  }
87
- source = readFileSync(path, "utf8");
83
+ buffer = createEditor(readFileSync(path, "utf8"));
88
84
  openPath = path;
89
85
  doc = null;
90
86
  scroll = 0;
87
+ editTop = 0;
88
+ editLeft = 0;
89
+ mode = "read";
91
90
  message = "";
92
91
  }
93
92
  catch (error) {
94
93
  message = `Cannot read ${basename(path)}: ${error instanceof Error ? error.message : String(error)}`;
95
94
  }
96
95
  };
96
+ /** Do something that would discard unsaved work, asking first if it would. */
97
+ const guard = (what, run) => {
98
+ if (!dirty()) {
99
+ run();
100
+ return;
101
+ }
102
+ pending = { message: `${basename(openPath ?? "")} has unsaved changes. ${what} anyway?`, run };
103
+ };
104
+ const save = () => {
105
+ if (!openPath || !buffer)
106
+ return;
107
+ try {
108
+ writeFileSync(openPath, bufferText(buffer), "utf8");
109
+ markSaved(buffer);
110
+ message = "";
111
+ }
112
+ catch (error) {
113
+ message = `Cannot save: ${error instanceof Error ? error.message : String(error)}`;
114
+ }
115
+ };
116
+ const enterEdit = () => {
117
+ if (!buffer)
118
+ return;
119
+ if (readOnly) {
120
+ message = "Read-only: started with --read-only.";
121
+ return;
122
+ }
123
+ mode = "edit";
124
+ pane = "view";
125
+ breakRun(buffer);
126
+ };
97
127
  const maxScroll = () => Math.max(0, (doc?.lines.length ?? 0) - viewport.h);
98
128
  const moveSelection = (delta) => {
99
129
  if (flat.length === 0)
@@ -109,8 +139,11 @@ export async function run(options) {
109
139
  rebuild(current.entry.path);
110
140
  }
111
141
  else {
112
- openFile(current.entry.path);
113
- pane = "view";
142
+ const path = current.entry.path;
143
+ guard("Open another file", () => {
144
+ openFile(path);
145
+ pane = "view";
146
+ });
114
147
  }
115
148
  };
116
149
  // Open whatever was asked for, or fall back to the first file in the tree.
@@ -121,13 +154,139 @@ export async function run(options) {
121
154
  rebuild(openPath);
122
155
  }
123
156
  }
157
+ /** Keys while the caret is in the text. Returns false when unhandled. */
158
+ const editKey = (event) => {
159
+ const ed = buffer;
160
+ const { key, name, char, ctrl, alt } = event;
161
+ const page = Math.max(1, viewport.h - 2);
162
+ switch (key) {
163
+ case "escape":
164
+ case "ctrl+c":
165
+ // Never a dead key: from the text it steps out, and the next one quits.
166
+ mode = "read";
167
+ breakRun(ed);
168
+ return;
169
+ case "ctrl+s":
170
+ save();
171
+ return;
172
+ case "ctrl+g":
173
+ case "f1":
174
+ // "?" is a character in here, so help moves to a key that is not.
175
+ help = true;
176
+ return;
177
+ case "ctrl+z":
178
+ undo(ed);
179
+ stamp++;
180
+ return;
181
+ case "ctrl+y":
182
+ case "ctrl+shift+z":
183
+ redo(ed);
184
+ stamp++;
185
+ return;
186
+ case "ctrl+k":
187
+ killToLineEnd(ed);
188
+ stamp++;
189
+ return;
190
+ case "ctrl+u":
191
+ killToLineStart(ed);
192
+ stamp++;
193
+ return;
194
+ case "ctrl+a":
195
+ moveHome(ed);
196
+ return;
197
+ case "ctrl+e":
198
+ moveEnd(ed);
199
+ return;
200
+ case "ctrl+home":
201
+ moveDocStart(ed);
202
+ return;
203
+ case "ctrl+end":
204
+ moveDocEnd(ed);
205
+ return;
206
+ case "ctrl+left":
207
+ case "alt+left":
208
+ moveWordLeft(ed);
209
+ return;
210
+ case "ctrl+right":
211
+ case "alt+right":
212
+ moveWordRight(ed);
213
+ return;
214
+ default:
215
+ break;
216
+ }
217
+ switch (name) {
218
+ case "left":
219
+ moveLeft(ed);
220
+ return;
221
+ case "right":
222
+ moveRight(ed);
223
+ return;
224
+ case "up":
225
+ moveUp(ed);
226
+ return;
227
+ case "down":
228
+ moveDown(ed);
229
+ return;
230
+ case "home":
231
+ moveHome(ed);
232
+ return;
233
+ case "end":
234
+ moveEnd(ed);
235
+ return;
236
+ case "pageup":
237
+ moveVertical(ed, -page);
238
+ return;
239
+ case "pagedown":
240
+ moveVertical(ed, page);
241
+ return;
242
+ case "enter":
243
+ newline(ed);
244
+ stamp++;
245
+ return;
246
+ case "backspace":
247
+ backspace(ed);
248
+ stamp++;
249
+ return;
250
+ case "delete":
251
+ del(ed);
252
+ stamp++;
253
+ return;
254
+ case "tab":
255
+ indent(ed);
256
+ stamp++;
257
+ return;
258
+ case "space":
259
+ insert(ed, " ");
260
+ stamp++;
261
+ return;
262
+ default:
263
+ break;
264
+ }
265
+ if (char && !ctrl && !alt) {
266
+ insert(ed, char);
267
+ stamp++;
268
+ }
269
+ };
124
270
  app.on("key", (event) => {
125
271
  const { key, name, char } = event;
272
+ if (pending) {
273
+ const answer = pending;
274
+ pending = null;
275
+ if (key === "y" || key === "Y")
276
+ answer.run();
277
+ app.invalidate();
278
+ return;
279
+ }
126
280
  if (help) {
127
281
  help = false;
128
282
  app.invalidate();
129
283
  return;
130
284
  }
285
+ if (mode === "edit" && buffer) {
286
+ editKey(event);
287
+ app.invalidate();
288
+ return;
289
+ }
131
290
  if (filtering) {
132
291
  if (key === "escape") {
133
292
  filtering = false;
@@ -158,8 +317,24 @@ export async function run(options) {
158
317
  return;
159
318
  }
160
319
  switch (key) {
320
+ case "ctrl+c":
161
321
  case "q":
162
- app.stop();
322
+ guard("Quit", () => app.stop());
323
+ app.invalidate();
324
+ return;
325
+ case "ctrl+s":
326
+ save();
327
+ app.invalidate();
328
+ return;
329
+ case "e":
330
+ case "i":
331
+ enterEdit();
332
+ app.invalidate();
333
+ return;
334
+ case "s":
335
+ spoilers = !spoilers;
336
+ doc = null;
337
+ app.invalidate();
163
338
  return;
164
339
  case "?":
165
340
  help = true;
@@ -171,12 +346,14 @@ export async function run(options) {
171
346
  return;
172
347
  case "r": {
173
348
  const keep = openPath;
174
- entries = scan(root, { all: options.all ?? false });
175
- if (keep)
176
- reveal(entries, keep);
177
- rebuild(keep);
178
- if (keep)
179
- openFile(keep);
349
+ guard("Reload from disk", () => {
350
+ entries = scan(root, { all: options.all ?? false });
351
+ if (keep)
352
+ reveal(entries, keep);
353
+ rebuild(keep);
354
+ if (keep)
355
+ openFile(keep);
356
+ });
180
357
  app.invalidate();
181
358
  return;
182
359
  }
@@ -296,7 +473,7 @@ export async function run(options) {
296
473
  }
297
474
  app.invalidate();
298
475
  });
299
- app.render(({ ui, theme, width, height }) => {
476
+ app.render(({ ui, theme, width }) => {
300
477
  if (sidebar === 0)
301
478
  sidebar = clamp(Math.round(width * 0.28), 22, 42);
302
479
  const side = clamp(sidebar, 16, Math.max(16, width - 24));
@@ -327,14 +504,15 @@ export async function run(options) {
327
504
  }
328
505
  p.list({
329
506
  items: flat.map(({ entry, depth }) => {
330
- const indent = " ".repeat(depth);
507
+ const indentation = " ".repeat(depth);
331
508
  const glyph = entry.dir ? (entry.expanded ? "▾ " : "▸ ") : " ";
509
+ const mark = entry.path === openPath && dirty() ? "● " : "";
332
510
  const color = entry.dir
333
511
  ? theme.primary
334
512
  : entry.path === openPath
335
513
  ? theme.accent
336
514
  : theme.foreground;
337
- return { label: `${indent}${glyph}${entry.name}`, color };
515
+ return { label: `${indentation}${glyph}${mark}${entry.name}`, color };
338
516
  }),
339
517
  selected,
340
518
  offset: treeOffset,
@@ -358,11 +536,17 @@ export async function run(options) {
358
536
  });
359
537
  });
360
538
  // -------------------------------------------------------- reader
539
+ const editing = mode === "edit" && buffer !== null;
361
540
  panes.panel({
362
- title: openRel ? ` ${openRel} ` : " readm3 ",
363
- subtitle: openPath ? `${percent}%` : undefined,
364
- footer: openPath && total > 0 ? `${Math.min(scroll + viewport.h, total)}/${total}` : undefined,
365
- borderColor: pane === "view" ? theme.borderFocused : theme.border,
541
+ title: openRel ? ` ${dirty() ? "● " : ""}${openRel} ` : " readm3 ",
542
+ titleColor: editing ? theme.warning : theme.title,
543
+ subtitle: openPath ? (editing ? "EDIT" : `${percent}%`) : undefined,
544
+ footer: openPath && editing && buffer
545
+ ? `${buffer.row + 1}:${buffer.col + 1}`
546
+ : openPath && total > 0
547
+ ? `${Math.min(scroll + viewport.h, total)}/${total}`
548
+ : undefined,
549
+ borderColor: editing ? theme.warning : pane === "view" ? theme.borderFocused : theme.border,
366
550
  padding: [0, 1],
367
551
  }, (p) => {
368
552
  const w = p.width;
@@ -371,7 +555,10 @@ export async function run(options) {
371
555
  p.ctx.hit({
372
556
  rect: p.surface.hitRect(),
373
557
  onScroll: (delta) => {
374
- scroll = clamp(scroll + delta * 3, 0, maxScroll());
558
+ if (editing && buffer)
559
+ moveVertical(buffer, delta * 3);
560
+ else
561
+ scroll = clamp(scroll + delta * 3, 0, maxScroll());
375
562
  app.invalidate();
376
563
  },
377
564
  onClick: () => {
@@ -383,15 +570,25 @@ export async function run(options) {
383
570
  p.text(message, { fg: theme.danger, wrap: true });
384
571
  return;
385
572
  }
386
- if (!openPath || source === null) {
573
+ if (!openPath || !buffer) {
387
574
  p.spacer("fill");
388
575
  p.text("readm3", { fg: theme.title, bold: true, align: "center" });
389
576
  p.text("Pick a file on the left. ? for keys.", { fg: theme.muted, align: "center" });
390
577
  p.spacer("fill");
391
578
  return;
392
579
  }
393
- if (!doc || doc.path !== openPath || doc.width !== w) {
394
- doc = { path: openPath, width: w, lines: renderMarkdown(source, w) };
580
+ if (editing) {
581
+ drawEditor(p, buffer, theme, w, h);
582
+ return;
583
+ }
584
+ if (!doc || doc.path !== openPath || doc.width !== w || doc.stamp !== stamp || doc.spoilers !== spoilers) {
585
+ doc = {
586
+ path: openPath,
587
+ width: w,
588
+ stamp,
589
+ spoilers,
590
+ lines: renderMarkdown(bufferText(buffer), w, { flavor, spoilers }),
591
+ };
395
592
  }
396
593
  scroll = clamp(scroll, 0, Math.max(0, doc.lines.length - h));
397
594
  if (measured.total !== doc.lines.length || measured.h !== h) {
@@ -429,58 +626,145 @@ export async function run(options) {
429
626
  });
430
627
  });
431
628
  screen.statusBar({
432
- items: filtering
629
+ items: mode === "edit"
433
630
  ? [
434
- { key: "type", label: "filter" },
435
- { key: "↵", label: "open" },
436
- { key: "esc", label: "clear" },
631
+ { key: "esc", label: "preview" },
632
+ { key: "^s", label: "save" },
633
+ { key: "^z", label: "undo" },
634
+ { key: "^k", label: "kill" },
635
+ { key: "^g", label: "keys" },
437
636
  ]
438
- : pane === "tree"
637
+ : filtering
439
638
  ? [
440
- { key: "↑↓", label: "move" },
639
+ { key: "type", label: "filter" },
441
640
  { key: "↵", label: "open" },
442
- { key: "←→", label: "fold" },
443
- { key: "/", label: "find" },
444
- { key: "tab", label: "reader" },
445
- { key: "?", label: "keys" },
446
- { key: "q", label: "quit" },
641
+ { key: "esc", label: "clear" },
447
642
  ]
448
- : [
449
- { key: "↑↓", label: "scroll" },
450
- { key: "spc", label: "page" },
451
- { key: "g/G", label: "ends" },
452
- { key: "tab", label: "files" },
453
- { key: "?", label: "keys" },
454
- { key: "q", label: "quit" },
455
- ],
643
+ : pane === "tree"
644
+ ? [
645
+ { key: "↑↓", label: "move" },
646
+ { key: "↵", label: "open" },
647
+ { key: "←→", label: "fold" },
648
+ { key: "/", label: "find" },
649
+ { key: "tab", label: "reader" },
650
+ { key: "?", label: "keys" },
651
+ { key: "q", label: "quit" },
652
+ ]
653
+ : [
654
+ { key: "↑↓", label: "scroll" },
655
+ { key: "spc", label: "page" },
656
+ { key: "e", label: "edit" },
657
+ { key: "g/G", label: "ends" },
658
+ { key: "tab", label: "files" },
659
+ { key: "?", label: "keys" },
660
+ { key: "q", label: "quit" },
661
+ ],
456
662
  right: [
457
663
  { label: openPath ? (dirname(openRel) === "." ? basename(root) : dirname(openRel)) : basename(root) },
664
+ { label: flavor, color: theme.muted },
458
665
  { label: theme.name, color: theme.muted },
459
666
  ],
460
667
  });
461
- if (help) {
462
- screen.modal({ title: " readm3 — keys ", width: 52, height: 18 }, (m) => {
463
- m.keyValues([
464
- { label: "↑ ↓ j k", value: "move / scroll" },
465
- { label: "→ ← l h", value: "expand / collapse" },
466
- { label: "enter", value: "open file" },
467
- { label: "tab", value: "switch pane" },
468
- { label: "space / b", value: "page down / up" },
469
- { label: "g / G", value: "top / bottom" },
470
- { label: "/", value: "filter files" },
471
- { label: "esc", value: "clear filter" },
472
- { label: "r", value: "rescan and reload" },
473
- { label: "[ ]", value: "sidebar width" },
474
- { label: "?", value: "this help" },
475
- { label: "q / ctrl+c", value: "quit" },
476
- ], { labelColor: theme.accent, labelWidth: 14 });
668
+ if (pending) {
669
+ screen.modal({ title: " unsaved changes ", width: 56, height: 9 }, (m) => {
670
+ m.spacer(1);
671
+ m.text(pending?.message ?? "", { fg: theme.foreground, align: "center", wrap: true });
672
+ m.spacer(1);
673
+ m.text("y to continue, any other key to stay", { fg: theme.muted, align: "center" });
674
+ m.spacer("fill");
675
+ });
676
+ }
677
+ else if (help) {
678
+ screen.modal({ title: " readm3 — keys ", width: 56, height: 24 }, (m) => {
679
+ m.keyValues(mode === "edit"
680
+ ? [
681
+ { label: "esc", value: "back to the preview" },
682
+ { label: "ctrl+g", value: "this help" },
683
+ { label: "ctrl+s", value: "save" },
684
+ { label: "ctrl+z / ctrl+y", value: "undo / redo" },
685
+ { label: "ctrl+k / ctrl+u", value: "kill to end / start" },
686
+ { label: "ctrl+a / ctrl+e", value: "line start / end" },
687
+ { label: "ctrl+← →", value: "by word" },
688
+ { label: "ctrl+home/end", value: "top / bottom" },
689
+ { label: "enter", value: "split, continuing a list" },
690
+ { label: "tab", value: "indent two spaces" },
691
+ ]
692
+ : [
693
+ { label: "↑ ↓ j k", value: "move / scroll" },
694
+ { label: "→ ← l h", value: "expand / collapse" },
695
+ { label: "enter", value: "open file" },
696
+ { label: "e / i", value: "edit this file" },
697
+ { label: "ctrl+s", value: "save" },
698
+ { label: "tab", value: "switch pane" },
699
+ { label: "space / b", value: "page down / up" },
700
+ { label: "g / G", value: "top / bottom" },
701
+ { label: "/", value: "filter files" },
702
+ { label: "s", value: "reveal spoilers" },
703
+ { label: "esc", value: "clear filter" },
704
+ { label: "r", value: "rescan and reload" },
705
+ { label: "[ ]", value: "sidebar width" },
706
+ { label: "?", value: "this help" },
707
+ { label: "q / ctrl+c", value: "quit" },
708
+ ], { labelColor: theme.accent, labelWidth: 16 });
477
709
  m.spacer(1);
478
710
  m.text("Any key closes.", { fg: theme.muted, align: "center" });
479
711
  });
480
712
  }
481
713
  });
482
- void height;
483
714
  });
715
+ /**
716
+ * The raw buffer with a caret.
717
+ *
718
+ * Long lines scroll sideways rather than wrap: a caret inside a wrapped line
719
+ * cannot say honestly which column of the file it is in.
720
+ */
721
+ function drawEditor(p, ed, theme, w, h) {
722
+ const gutter = Math.max(3, String(ed.lines.length).length + 1);
723
+ const textWidth = Math.max(8, w - gutter - 1);
724
+ // Keep the caret on screen on both axes.
725
+ if (ed.row < editTop)
726
+ editTop = ed.row;
727
+ else if (ed.row >= editTop + h)
728
+ editTop = ed.row - h + 1;
729
+ editTop = clamp(editTop, 0, Math.max(0, ed.lines.length - 1));
730
+ const caretCol = displayCol(ed.lines[ed.row] ?? "", ed.col);
731
+ if (caretCol < editLeft)
732
+ editLeft = caretCol;
733
+ else if (caretCol >= editLeft + textWidth)
734
+ editLeft = caretCol - textWidth + 1;
735
+ editLeft = Math.max(0, editLeft);
736
+ for (let i = 0; i < h; i++) {
737
+ const row = editTop + i;
738
+ if (row >= ed.lines.length) {
739
+ p.text("", { height: 1 });
740
+ continue;
741
+ }
742
+ const line = expand(ed.lines[row]);
743
+ const shown = line.slice(editLeft, editLeft + textWidth);
744
+ const onCaretRow = row === ed.row;
745
+ const caretAt = caretCol - editLeft;
746
+ p.row({ height: 1 }, (r) => {
747
+ r.text(`${String(row + 1).padStart(gutter - 1)} `, {
748
+ width: gutter,
749
+ fg: onCaretRow ? theme.accent : theme.border,
750
+ });
751
+ if (!onCaretRow) {
752
+ r.text(shown, { width: textWidth, fg: theme.foreground });
753
+ return;
754
+ }
755
+ // Split the line so the caret cell can be painted on its own.
756
+ const before = shown.slice(0, caretAt);
757
+ const under = shown.slice(caretAt, caretAt + 1) || " ";
758
+ const after = shown.slice(caretAt + 1);
759
+ if (before)
760
+ r.text(before, { width: stringWidth(before), fg: theme.foreground });
761
+ r.text(under, { width: Math.max(1, stringWidth(under)), fg: theme.background, bg: theme.cursor });
762
+ if (after)
763
+ r.text(after, { width: stringWidth(after), fg: theme.foreground });
764
+ r.spacer("fill");
765
+ });
766
+ }
767
+ }
484
768
  await app.start();
485
769
  }
486
770
  //# sourceMappingURL=viewer.js.map