@profullstack/readm3 0.2.0 → 0.4.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.
Files changed (49) hide show
  1. package/README.md +222 -13
  2. package/dist/cli.d.ts +5 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +52 -6
  5. package/dist/cli.js.map +1 -1
  6. package/dist/cloud-cli.d.ts +4 -0
  7. package/dist/cloud-cli.d.ts.map +1 -0
  8. package/dist/cloud-cli.js +192 -0
  9. package/dist/cloud-cli.js.map +1 -0
  10. package/dist/cloud-client.d.ts +14 -0
  11. package/dist/cloud-client.d.ts.map +1 -0
  12. package/dist/cloud-client.js +62 -0
  13. package/dist/cloud-client.js.map +1 -0
  14. package/dist/editor.d.ts +77 -0
  15. package/dist/editor.d.ts.map +1 -0
  16. package/dist/editor.js +308 -0
  17. package/dist/editor.js.map +1 -0
  18. package/dist/emoji.d.ts +12 -0
  19. package/dist/emoji.d.ts.map +1 -0
  20. package/dist/emoji.js +73 -0
  21. package/dist/emoji.js.map +1 -0
  22. package/dist/flavors.d.ts +68 -0
  23. package/dist/flavors.d.ts.map +1 -0
  24. package/dist/flavors.js +223 -0
  25. package/dist/flavors.js.map +1 -0
  26. package/dist/index.d.ts +10 -5
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +7 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/markdown.d.ts +12 -3
  31. package/dist/markdown.d.ts.map +1 -1
  32. package/dist/markdown.js +424 -379
  33. package/dist/markdown.js.map +1 -1
  34. package/dist/mcp.d.ts +2 -0
  35. package/dist/mcp.d.ts.map +1 -0
  36. package/dist/mcp.js +301 -0
  37. package/dist/mcp.js.map +1 -0
  38. package/dist/print.d.ts +3 -3
  39. package/dist/print.d.ts.map +1 -1
  40. package/dist/print.js +8 -3
  41. package/dist/print.js.map +1 -1
  42. package/dist/roles.d.ts.map +1 -1
  43. package/dist/roles.js +17 -0
  44. package/dist/roles.js.map +1 -1
  45. package/dist/viewer.d.ts +5 -0
  46. package/dist/viewer.d.ts.map +1 -1
  47. package/dist/viewer.js +375 -60
  48. package/dist/viewer.js.map +1 -1
  49. package/package.json +18 -6
package/dist/viewer.js CHANGED
@@ -1,22 +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";
9
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
5
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";
8
15
  import { colorOf } from "./roles.js";
9
16
  import { filterTree, flatten, indexOfPath, label as pathLabel, reveal, scan, } from "./tree.js";
10
17
  /** Files above this size are refused rather than rendered. */
11
18
  const MAX_BYTES = 4 * 1024 * 1024;
19
+ /** A tab is two columns, in the buffer and on screen alike. */
20
+ const TAB = " ";
12
21
  const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
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;
13
25
  export async function run(options) {
14
26
  const root = resolve(options.root);
27
+ const flavor = options.flavor ?? DEFAULT_FLAVOR;
28
+ const readOnly = options.readOnly ?? false;
15
29
  const app = await createApp({
16
30
  theme: options.theme,
17
31
  mouse: options.mouse !== false,
18
- // `q` is handled below so it can be typed into the filter.
19
- quitKeys: ["ctrl+c"],
32
+ // `q` is handled below so it can be typed into the filter, and into the text.
33
+ quitKeys: [],
20
34
  });
21
35
  let entries = scan(root, { all: options.all ?? false });
22
36
  let filter = "";
@@ -28,9 +42,16 @@ export async function run(options) {
28
42
  let help = false;
29
43
  let message = "";
30
44
  let openPath = null;
31
- 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;
32
49
  let doc = null;
33
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;
34
55
  let sidebar = options.sidebar ?? 0;
35
56
  // Last measured pane interiors; the panel header needs them a frame early.
36
57
  let viewport = { w: 80, h: 20 };
@@ -38,9 +59,14 @@ export async function run(options) {
38
59
  // Owned here rather than left to the widget, so a click can be turned back
39
60
  // into an index and the view stays put when the selection moves inside it.
40
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;
41
66
  // The panel header quotes numbers only the draw can measure, so a change in
42
67
  // geometry or document length schedules exactly one more frame.
43
68
  let measured = { total: -1, h: -1 };
69
+ const dirty = () => buffer?.dirty ?? false;
44
70
  const rebuild = (keepPath) => {
45
71
  view = filter ? filterTree(entries, filter, root) : entries;
46
72
  flat = flatten(view);
@@ -54,16 +80,50 @@ export async function run(options) {
54
80
  message = `${basename(path)} is ${(info.size / 1024 / 1024).toFixed(1)} MB — too large to render`;
55
81
  return;
56
82
  }
57
- source = readFileSync(path, "utf8");
83
+ buffer = createEditor(readFileSync(path, "utf8"));
58
84
  openPath = path;
59
85
  doc = null;
60
86
  scroll = 0;
87
+ editTop = 0;
88
+ editLeft = 0;
89
+ mode = "read";
61
90
  message = "";
62
91
  }
63
92
  catch (error) {
64
93
  message = `Cannot read ${basename(path)}: ${error instanceof Error ? error.message : String(error)}`;
65
94
  }
66
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
+ };
67
127
  const maxScroll = () => Math.max(0, (doc?.lines.length ?? 0) - viewport.h);
68
128
  const moveSelection = (delta) => {
69
129
  if (flat.length === 0)
@@ -79,8 +139,11 @@ export async function run(options) {
79
139
  rebuild(current.entry.path);
80
140
  }
81
141
  else {
82
- openFile(current.entry.path);
83
- pane = "view";
142
+ const path = current.entry.path;
143
+ guard("Open another file", () => {
144
+ openFile(path);
145
+ pane = "view";
146
+ });
84
147
  }
85
148
  };
86
149
  // Open whatever was asked for, or fall back to the first file in the tree.
@@ -91,13 +154,139 @@ export async function run(options) {
91
154
  rebuild(openPath);
92
155
  }
93
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
+ };
94
270
  app.on("key", (event) => {
95
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
+ }
96
280
  if (help) {
97
281
  help = false;
98
282
  app.invalidate();
99
283
  return;
100
284
  }
285
+ if (mode === "edit" && buffer) {
286
+ editKey(event);
287
+ app.invalidate();
288
+ return;
289
+ }
101
290
  if (filtering) {
102
291
  if (key === "escape") {
103
292
  filtering = false;
@@ -128,8 +317,24 @@ export async function run(options) {
128
317
  return;
129
318
  }
130
319
  switch (key) {
320
+ case "ctrl+c":
131
321
  case "q":
132
- 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();
133
338
  return;
134
339
  case "?":
135
340
  help = true;
@@ -141,12 +346,14 @@ export async function run(options) {
141
346
  return;
142
347
  case "r": {
143
348
  const keep = openPath;
144
- entries = scan(root, { all: options.all ?? false });
145
- if (keep)
146
- reveal(entries, keep);
147
- rebuild(keep);
148
- if (keep)
149
- 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
+ });
150
357
  app.invalidate();
151
358
  return;
152
359
  }
@@ -297,14 +504,15 @@ export async function run(options) {
297
504
  }
298
505
  p.list({
299
506
  items: flat.map(({ entry, depth }) => {
300
- const indent = " ".repeat(depth);
507
+ const indentation = " ".repeat(depth);
301
508
  const glyph = entry.dir ? (entry.expanded ? "▾ " : "▸ ") : " ";
509
+ const mark = entry.path === openPath && dirty() ? "● " : "";
302
510
  const color = entry.dir
303
511
  ? theme.primary
304
512
  : entry.path === openPath
305
513
  ? theme.accent
306
514
  : theme.foreground;
307
- return { label: `${indent}${glyph}${entry.name}`, color };
515
+ return { label: `${indentation}${glyph}${mark}${entry.name}`, color };
308
516
  }),
309
517
  selected,
310
518
  offset: treeOffset,
@@ -328,11 +536,17 @@ export async function run(options) {
328
536
  });
329
537
  });
330
538
  // -------------------------------------------------------- reader
539
+ const editing = mode === "edit" && buffer !== null;
331
540
  panes.panel({
332
- title: openRel ? ` ${openRel} ` : " readm3 ",
333
- subtitle: openPath ? `${percent}%` : undefined,
334
- footer: openPath && total > 0 ? `${Math.min(scroll + viewport.h, total)}/${total}` : undefined,
335
- 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,
336
550
  padding: [0, 1],
337
551
  }, (p) => {
338
552
  const w = p.width;
@@ -341,7 +555,10 @@ export async function run(options) {
341
555
  p.ctx.hit({
342
556
  rect: p.surface.hitRect(),
343
557
  onScroll: (delta) => {
344
- 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());
345
562
  app.invalidate();
346
563
  },
347
564
  onClick: () => {
@@ -353,15 +570,25 @@ export async function run(options) {
353
570
  p.text(message, { fg: theme.danger, wrap: true });
354
571
  return;
355
572
  }
356
- if (!openPath || source === null) {
573
+ if (!openPath || !buffer) {
357
574
  p.spacer("fill");
358
575
  p.text("readm3", { fg: theme.title, bold: true, align: "center" });
359
576
  p.text("Pick a file on the left. ? for keys.", { fg: theme.muted, align: "center" });
360
577
  p.spacer("fill");
361
578
  return;
362
579
  }
363
- if (!doc || doc.path !== openPath || doc.width !== w) {
364
- 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
+ };
365
592
  }
366
593
  scroll = clamp(scroll, 0, Math.max(0, doc.lines.length - h));
367
594
  if (measured.total !== doc.lines.length || measured.h !== h) {
@@ -399,57 +626,145 @@ export async function run(options) {
399
626
  });
400
627
  });
401
628
  screen.statusBar({
402
- items: filtering
629
+ items: mode === "edit"
403
630
  ? [
404
- { key: "type", label: "filter" },
405
- { key: "↵", label: "open" },
406
- { 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" },
407
636
  ]
408
- : pane === "tree"
637
+ : filtering
409
638
  ? [
410
- { key: "↑↓", label: "move" },
639
+ { key: "type", label: "filter" },
411
640
  { key: "↵", label: "open" },
412
- { key: "←→", label: "fold" },
413
- { key: "/", label: "find" },
414
- { key: "tab", label: "reader" },
415
- { key: "?", label: "keys" },
416
- { key: "q", label: "quit" },
641
+ { key: "esc", label: "clear" },
417
642
  ]
418
- : [
419
- { key: "↑↓", label: "scroll" },
420
- { key: "spc", label: "page" },
421
- { key: "g/G", label: "ends" },
422
- { key: "tab", label: "files" },
423
- { key: "?", label: "keys" },
424
- { key: "q", label: "quit" },
425
- ],
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
+ ],
426
662
  right: [
427
663
  { label: openPath ? (dirname(openRel) === "." ? basename(root) : dirname(openRel)) : basename(root) },
664
+ { label: flavor, color: theme.muted },
428
665
  { label: theme.name, color: theme.muted },
429
666
  ],
430
667
  });
431
- if (help) {
432
- screen.modal({ title: " readm3 — keys ", width: 52, height: 18 }, (m) => {
433
- m.keyValues([
434
- { label: "↑ ↓ j k", value: "move / scroll" },
435
- { label: "→ ← l h", value: "expand / collapse" },
436
- { label: "enter", value: "open file" },
437
- { label: "tab", value: "switch pane" },
438
- { label: "space / b", value: "page down / up" },
439
- { label: "g / G", value: "top / bottom" },
440
- { label: "/", value: "filter files" },
441
- { label: "esc", value: "clear filter" },
442
- { label: "r", value: "rescan and reload" },
443
- { label: "[ ]", value: "sidebar width" },
444
- { label: "?", value: "this help" },
445
- { label: "q / ctrl+c", value: "quit" },
446
- ], { 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 });
447
709
  m.spacer(1);
448
710
  m.text("Any key closes.", { fg: theme.muted, align: "center" });
449
711
  });
450
712
  }
451
713
  });
452
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
+ }
453
768
  await app.start();
454
769
  }
455
770
  //# sourceMappingURL=viewer.js.map