blots-editor 0.0.8 → 0.0.10

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.
@@ -0,0 +1,15 @@
1
+ {
2
+ "menu.edit": "Edit Mode",
3
+ "menu.settings": "Settings",
4
+ "menu.other": "Other",
5
+ "menu.quit": "Quit",
6
+ "settings.title": "Settings",
7
+ "settings.language": "Language",
8
+ "settings.language.en": "English",
9
+ "settings.language.zh": "中文",
10
+ "settings.back": "Back",
11
+ "editor.mode.insert": "INSERT",
12
+ "editor.mode.normal": "NORMAL",
13
+ "command.prompt": "Command",
14
+ "command.unknown": "Unknown command: {cmd}"
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "menu.edit": "编辑模式",
3
+ "menu.settings": "设置",
4
+ "menu.other": "其他",
5
+ "menu.quit": "退出",
6
+ "settings.title": "设置",
7
+ "settings.language": "语言",
8
+ "settings.language.en": "英文",
9
+ "settings.language.zh": "中文",
10
+ "settings.back": "返回",
11
+ "editor.mode.insert": "插入",
12
+ "editor.mode.normal": "普通",
13
+ "command.prompt": "命令",
14
+ "command.unknown": "未知命令:{cmd}"
15
+ }
@@ -17,6 +17,8 @@ export declare class Document {
17
17
  private _lines;
18
18
  private _cursor;
19
19
  private _scrollTop;
20
+ private _wrapWidth;
21
+ private _viewScrollTop;
20
22
  readonly indentWidth: number;
21
23
  constructor(text: string, options?: DocumentOptions);
22
24
  get lineCount(): number;
@@ -25,6 +27,35 @@ export declare class Document {
25
27
  getLine(i: number): string;
26
28
  get cursor(): CursorState;
27
29
  get scrollTop(): number;
30
+ /** Soft-wrap width in terminal cells; `Infinity` disables wrapping. */
31
+ get wrapWidth(): number;
32
+ /** Set the soft-wrap width (clamped to at least 1 cell). */
33
+ setWrapWidth(width: number): void;
34
+ /** The soft-wrap segments of one logical line (an empty line is one empty segment). */
35
+ private lineSegments;
36
+ /** Total number of visual (soft-wrapped) lines across the document. */
37
+ get visualLineCount(): number;
38
+ /**
39
+ * Map a global visual line to its logical line and the segment's logical
40
+ * span. `first` marks the segment that starts at the logical line start
41
+ * (the one that carries the line number).
42
+ */
43
+ visualLineAt(vline: number): {
44
+ line: number;
45
+ start: number;
46
+ end: number;
47
+ first: boolean;
48
+ } | null;
49
+ /** Global visual line the cursor sits on. */
50
+ get cursorVisualLine(): number;
51
+ /** Visual offset of the cursor within its current soft-wrap segment. */
52
+ get cursorSegmentVisual(): number;
53
+ /**
54
+ * The target logical column for a vertical move onto `segment` (in any
55
+ * line): keep the cursor's offset within its current segment, clamping to
56
+ * the segment end only when the target segment is narrower.
57
+ */
58
+ private moveToSegment;
28
59
  /** Gutter width = decimal digit count of the last line number (at least 1). */
29
60
  getLineNumberWidth(): number;
30
61
  setLine(i: number, text: string): void;
@@ -40,6 +71,10 @@ export declare class Document {
40
71
  * (see {@link TextLine.logicalAt}).
41
72
  */
42
73
  setCursorAtVisual(line: number, visual: number): void;
74
+ /** Terminal column on a logical line → nearest logical column. */
75
+ logicalAtVisual(line: number, visual: number): number;
76
+ /** Logical column → terminal column on a logical line. */
77
+ visualAtLogical(line: number, logical: number): number;
43
78
  moveLeft(): void;
44
79
  moveRight(): void;
45
80
  /** Vertical movement aligns by visual column, so wide-character lines do not drift. */
@@ -53,13 +88,21 @@ export declare class Document {
53
88
  moveWordForward(): void;
54
89
  /** A word is a run of non-whitespace; jump back to the current/previous word start. */
55
90
  moveWordBackward(): void;
56
- /** Page up: move the cursor up by `height - 1` lines (one line of overlap), keeping the visual column. */
91
+ /** Page up: move the cursor up by `height - 1` visual lines (one line of overlap), keeping the visual column. */
57
92
  movePageUp(height: number): void;
58
93
  movePageDown(height: number): void;
59
94
  /**
60
- * Scroll the viewport so the cursor stays visible: if the cursor is above
61
- * the viewport, scroll to its line; if below, scroll so it becomes the
62
- * last visible line. Returns the new scrollTop.
95
+ * Scroll the viewport by `delta` visual lines without moving the cursor,
96
+ * clamped to the document range. Locks the view in place (Ctrl+wheel
97
+ * browsing) until the cursor moves.
98
+ */
99
+ scrollView(delta: number, height: number): number;
100
+ /**
101
+ * Scroll the viewport so the cursor stays visible, in visual-line terms:
102
+ * if the cursor's visual line is above the viewport, scroll to it; if
103
+ * below, scroll so it becomes the last visible line. Returns the new
104
+ * scrollTop (in visual lines). While the view is manually locked
105
+ * (`scrollView`), the position is kept instead and only clamped.
63
106
  */
64
107
  updateScroll(height: number): number;
65
108
  }
@@ -14,6 +14,10 @@ export class Document {
14
14
  _lines;
15
15
  _cursor;
16
16
  _scrollTop = 0;
17
+ _wrapWidth = Infinity;
18
+ // Non-null while the user is scrolling the view manually (Ctrl+wheel):
19
+ // updateScroll keeps this position instead of following the cursor.
20
+ _viewScrollTop = null;
17
21
  indentWidth;
18
22
  constructor(text, options = {}) {
19
23
  this._lines = text.split(/\r?\n/).map((t) => new TextLine(t));
@@ -41,6 +45,106 @@ export class Document {
41
45
  get scrollTop() {
42
46
  return this._scrollTop;
43
47
  }
48
+ /** Soft-wrap width in terminal cells; `Infinity` disables wrapping. */
49
+ get wrapWidth() {
50
+ return this._wrapWidth;
51
+ }
52
+ /** Set the soft-wrap width (clamped to at least 1 cell). */
53
+ setWrapWidth(width) {
54
+ this._wrapWidth = Math.max(1, width);
55
+ }
56
+ /** The soft-wrap segments of one logical line (an empty line is one empty segment). */
57
+ lineSegments(line) {
58
+ const tl = this._lines[line];
59
+ if (tl.text.length === 0) {
60
+ return [{ start: 0, end: 0 }];
61
+ }
62
+ const segs = [];
63
+ let segVisual = 0;
64
+ let segStart = 0;
65
+ while (segStart < tl.text.length) {
66
+ const seg = tl.segmentFrom(segVisual, this._wrapWidth);
67
+ segs.push({ start: segStart, end: seg.endLogical });
68
+ if (seg.text === "") {
69
+ break;
70
+ }
71
+ segStart = seg.endLogical;
72
+ segVisual = seg.endVisual;
73
+ }
74
+ return segs;
75
+ }
76
+ /** Total number of visual (soft-wrapped) lines across the document. */
77
+ get visualLineCount() {
78
+ let total = 0;
79
+ for (let i = 0; i < this._lines.length; i++) {
80
+ total += this.lineSegments(i).length;
81
+ }
82
+ return total;
83
+ }
84
+ /**
85
+ * Map a global visual line to its logical line and the segment's logical
86
+ * span. `first` marks the segment that starts at the logical line start
87
+ * (the one that carries the line number).
88
+ */
89
+ visualLineAt(vline) {
90
+ if (vline < 0) {
91
+ return null;
92
+ }
93
+ let remaining = vline;
94
+ for (let line = 0; line < this._lines.length; line++) {
95
+ const segs = this.lineSegments(line);
96
+ if (remaining < segs.length) {
97
+ return {
98
+ line,
99
+ start: segs[remaining].start,
100
+ end: segs[remaining].end,
101
+ first: remaining === 0,
102
+ };
103
+ }
104
+ remaining -= segs.length;
105
+ }
106
+ return null;
107
+ }
108
+ /** Global visual line the cursor sits on. */
109
+ get cursorVisualLine() {
110
+ const { line, logical } = this._cursor;
111
+ let v = 0;
112
+ for (let i = 0; i < line; i++) {
113
+ v += this.lineSegments(i).length;
114
+ }
115
+ const segs = this.lineSegments(line);
116
+ let idx = 0;
117
+ // A cursor exactly on a segment boundary belongs to the segment
118
+ // starting there, so visual lines read like real lines: the boundary
119
+ // column is the first cell of the next visual line.
120
+ while (idx < segs.length - 1 && logical >= segs[idx + 1].start) {
121
+ idx++;
122
+ }
123
+ return v + idx;
124
+ }
125
+ /** Visual offset of the cursor within its current soft-wrap segment. */
126
+ get cursorSegmentVisual() {
127
+ const tl = this._lines[this._cursor.line];
128
+ const segs = this.lineSegments(this._cursor.line);
129
+ const { logical } = this._cursor;
130
+ let idx = segs.length - 1;
131
+ while (idx > 0 && logical < segs[idx].start) {
132
+ idx--;
133
+ }
134
+ return tl.visualAt(logical) - tl.visualAt(segs[idx].start);
135
+ }
136
+ /**
137
+ * The target logical column for a vertical move onto `segment` (in any
138
+ * line): keep the cursor's offset within its current segment, clamping to
139
+ * the segment end only when the target segment is narrower.
140
+ */
141
+ moveToSegment(segment) {
142
+ const offset = this.cursorSegmentVisual;
143
+ const targetVisual = this.visualAtLogical(segment.line, segment.start) + offset;
144
+ let col = this.logicalAtVisual(segment.line, targetVisual);
145
+ col = Math.min(Math.max(col, segment.start), segment.end);
146
+ this.setCursor(segment.line, col);
147
+ }
44
148
  /** Gutter width = decimal digit count of the last line number (at least 1). */
45
149
  getLineNumberWidth() {
46
150
  return Math.max(String(this._lines.length - 1).length, 1);
@@ -61,6 +165,9 @@ export class Document {
61
165
  const l = Math.max(0, Math.min(line, this._lines.length - 1));
62
166
  const len = this._lines[l].text.length;
63
167
  this._cursor = { line: l, logical: Math.max(0, Math.min(logical, len)) };
168
+ // Any cursor movement releases the manual view-scroll lock so the
169
+ // viewport follows the cursor again.
170
+ this._viewScrollTop = null;
64
171
  }
65
172
  /**
66
173
  * Set the cursor by terminal column instead of code unit index — the
@@ -71,6 +178,14 @@ export class Document {
71
178
  const l = Math.max(0, Math.min(line, this._lines.length - 1));
72
179
  this.setCursor(l, this._lines[l].logicalAt(visual));
73
180
  }
181
+ /** Terminal column on a logical line → nearest logical column. */
182
+ logicalAtVisual(line, visual) {
183
+ return this._lines[line].logicalAt(visual);
184
+ }
185
+ /** Logical column → terminal column on a logical line. */
186
+ visualAtLogical(line, logical) {
187
+ return this._lines[line].visualAt(logical);
188
+ }
74
189
  moveLeft() {
75
190
  const { line, logical } = this._cursor;
76
191
  if (logical > 0) {
@@ -86,17 +201,32 @@ export class Document {
86
201
  /** Vertical movement aligns by visual column, so wide-character lines do not drift. */
87
202
  moveUp() {
88
203
  const { line, logical } = this._cursor;
89
- if (line > 0) {
90
- const visual = this._lines[line].visualAt(logical);
91
- this.setCursor(line - 1, this._lines[line - 1].logicalAt(visual));
204
+ if (this.cursorVisualLine === 0) {
205
+ // Already on the first visual line: still move to the start of the
206
+ // current segment (like up-to-line-start in vim).
207
+ const segs = this.lineSegments(line);
208
+ let idx = 0;
209
+ while (idx < segs.length - 1 && logical >= segs[idx + 1].start) {
210
+ idx++;
211
+ }
212
+ this.setCursor(line, segs[idx].start);
213
+ return;
92
214
  }
215
+ const prev = this.visualLineAt(this.cursorVisualLine - 1);
216
+ if (!prev) {
217
+ return;
218
+ }
219
+ this.moveToSegment(prev);
93
220
  }
94
221
  moveDown() {
95
- const { line, logical } = this._cursor;
96
- if (line < this._lines.length - 1) {
97
- const visual = this._lines[line].visualAt(logical);
98
- this.setCursor(line + 1, this._lines[line + 1].logicalAt(visual));
222
+ if (this.cursorVisualLine >= this.visualLineCount - 1) {
223
+ return;
99
224
  }
225
+ const next = this.visualLineAt(this.cursorVisualLine + 1);
226
+ if (!next) {
227
+ return;
228
+ }
229
+ this.moveToSegment(next);
100
230
  }
101
231
  moveToLineStart() {
102
232
  this.setCursor(this._cursor.line, 0);
@@ -140,34 +270,63 @@ export class Document {
140
270
  }
141
271
  this.setCursor(line, i);
142
272
  }
143
- /** Page up: move the cursor up by `height - 1` lines (one line of overlap), keeping the visual column. */
273
+ /** Page up: move the cursor up by `height - 1` visual lines (one line of overlap), keeping the visual column. */
144
274
  movePageUp(height) {
145
- const { line, logical } = this._cursor;
146
- const visual = this._lines[line].visualAt(logical);
147
- const target = Math.max(0, line - Math.max(1, height - 1));
148
- this.setCursor(target, this._lines[target].logicalAt(visual));
275
+ const target = Math.max(0, this.cursorVisualLine - Math.max(1, height - 1));
276
+ const seg = this.visualLineAt(target);
277
+ if (!seg) {
278
+ return;
279
+ }
280
+ this.moveToSegment(seg);
149
281
  }
150
282
  movePageDown(height) {
151
- const { line, logical } = this._cursor;
152
- const visual = this._lines[line].visualAt(logical);
153
- const target = Math.min(this._lines.length - 1, line + Math.max(1, height - 1));
154
- this.setCursor(target, this._lines[target].logicalAt(visual));
283
+ const target = Math.min(this.visualLineCount - 1, this.cursorVisualLine + Math.max(1, height - 1));
284
+ const seg = this.visualLineAt(target);
285
+ if (!seg) {
286
+ return;
287
+ }
288
+ this.moveToSegment(seg);
155
289
  }
156
290
  /**
157
- * Scroll the viewport so the cursor stays visible: if the cursor is above
158
- * the viewport, scroll to its line; if below, scroll so it becomes the
159
- * last visible line. Returns the new scrollTop.
291
+ * Scroll the viewport by `delta` visual lines without moving the cursor,
292
+ * clamped to the document range. Locks the view in place (Ctrl+wheel
293
+ * browsing) until the cursor moves.
294
+ */
295
+ scrollView(delta, height) {
296
+ const total = this.visualLineCount;
297
+ const effectiveH = height > 0 ? height : total;
298
+ const maxScroll = Math.max(0, total - effectiveH);
299
+ const base = this._viewScrollTop ?? this._scrollTop;
300
+ const vs = Math.max(0, Math.min(base + delta, maxScroll));
301
+ this._viewScrollTop = vs;
302
+ this._scrollTop = vs;
303
+ return vs;
304
+ }
305
+ /**
306
+ * Scroll the viewport so the cursor stays visible, in visual-line terms:
307
+ * if the cursor's visual line is above the viewport, scroll to it; if
308
+ * below, scroll so it becomes the last visible line. Returns the new
309
+ * scrollTop (in visual lines). While the view is manually locked
310
+ * (`scrollView`), the position is kept instead and only clamped.
160
311
  */
161
312
  updateScroll(height) {
162
- const effectiveH = height > 0 ? height : this._lines.length;
313
+ const total = this.visualLineCount;
314
+ const effectiveH = height > 0 ? height : total;
315
+ const maxScroll = Math.max(0, total - effectiveH);
316
+ if (this._viewScrollTop !== null) {
317
+ const vs = Math.max(0, Math.min(this._viewScrollTop, maxScroll));
318
+ this._viewScrollTop = vs;
319
+ this._scrollTop = vs;
320
+ return vs;
321
+ }
322
+ const vline = this.cursorVisualLine;
163
323
  let vs = this._scrollTop;
164
- if (this._cursor.line < vs) {
165
- vs = this._cursor.line;
324
+ if (vline < vs) {
325
+ vs = vline;
166
326
  }
167
- else if (this._cursor.line >= vs + effectiveH) {
168
- vs = this._cursor.line - effectiveH + 1;
327
+ else if (vline >= vs + effectiveH) {
328
+ vs = vline - effectiveH + 1;
169
329
  }
170
- const maxScroll = Math.max(0, this._lines.length - effectiveH);
171
330
  vs = Math.max(0, Math.min(vs, maxScroll));
172
331
  this._scrollTop = vs;
173
332
  return vs;
@@ -21,4 +21,17 @@ export declare class TextLine {
21
21
  * (largest prefix not exceeding the target).
22
22
  */
23
23
  logicalAt(visual: number): number;
24
+ /**
25
+ * Cut one soft-wrap segment starting at `startVisual`: the longest
26
+ * slice whose terminal width is at most `wrapWidth`. Returns the segment
27
+ * text plus the visual/logical position where it ends (the next call's
28
+ * `startVisual`). A single character wider than `wrapWidth` (or the
29
+ * trailing empty segment at line end) is returned on its own so the
30
+ * caller always makes progress.
31
+ */
32
+ segmentFrom(startVisual: number, wrapWidth: number): {
33
+ text: string;
34
+ endVisual: number;
35
+ endLogical: number;
36
+ };
24
37
  }
@@ -57,4 +57,35 @@ export class TextLine {
57
57
  }
58
58
  return lo;
59
59
  }
60
+ /**
61
+ * Cut one soft-wrap segment starting at `startVisual`: the longest
62
+ * slice whose terminal width is at most `wrapWidth`. Returns the segment
63
+ * text plus the visual/logical position where it ends (the next call's
64
+ * `startVisual`). A single character wider than `wrapWidth` (or the
65
+ * trailing empty segment at line end) is returned on its own so the
66
+ * caller always makes progress.
67
+ */
68
+ segmentFrom(startVisual, wrapWidth) {
69
+ const start = this.logicalAt(startVisual);
70
+ if (start >= this.text.length) {
71
+ return {
72
+ text: "",
73
+ endVisual: this.visualAt(start),
74
+ endLogical: start,
75
+ };
76
+ }
77
+ const startVisualActual = this.visualAt(start);
78
+ const limit = startVisualActual + Math.max(1, wrapWidth);
79
+ let end = this.logicalAt(limit);
80
+ // `logicalAt` may snap left onto a wide char; guarantee progress by
81
+ // taking at least one code point (a surrogate pair counts as one).
82
+ if (end <= start) {
83
+ end = start + (this.text.codePointAt(start) > 0xffff ? 2 : 1);
84
+ }
85
+ return {
86
+ text: this.text.slice(start, end),
87
+ endVisual: this.visualAt(end),
88
+ endLogical: end,
89
+ };
90
+ }
60
91
  }
@@ -80,11 +80,22 @@ export class EditorController {
80
80
  const height = typeof args?.height === "number" ? args.height : 1;
81
81
  doc.movePageDown(height);
82
82
  });
83
- // Mouse click: position the cursor by terminal column (not code units).
83
+ // Mouse click: position the cursor by logical column (soft-wrap aware).
84
84
  this.defineCommand("cursor.setPosition", (doc, args) => {
85
85
  const line = typeof args?.line === "number" ? args.line : doc.cursor.line;
86
- const visual = typeof args?.visual === "number" ? args.visual : doc.cursor.visual;
87
- doc.setCursorAtVisual(line, visual);
86
+ if (typeof args?.logical === "number") {
87
+ doc.setCursor(line, args.logical);
88
+ }
89
+ else {
90
+ const visual = typeof args?.visual === "number" ? args.visual : doc.cursor.visual;
91
+ doc.setCursorAtVisual(line, visual);
92
+ }
93
+ });
94
+ // Ctrl+wheel: scroll the view without moving the cursor (clamped).
95
+ this.defineCommand("view.scroll", (doc, args) => {
96
+ const delta = typeof args?.delta === "number" ? args.delta : 0;
97
+ const height = typeof args?.height === "number" ? args.height : 1;
98
+ doc.scrollView(delta, height);
88
99
  });
89
100
  }
90
101
  }
@@ -0,0 +1 @@
1
+ export declare const resources: Record<string, Record<string, string>>;
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from "node:fs";
2
+ /**
3
+ * Load language packs from asset/language/*.json.
4
+ *
5
+ * We read the files at module load instead of `import x from "./x.json"`:
6
+ * Node ESM requires import attributes for JSON modules and tsc does not
7
+ * emit them. Paths resolve from this module's own location (dist/), so
8
+ * global installs work regardless of cwd — the JSON files ship inside the
9
+ * package via the `asset` entry in `files`.
10
+ */
11
+ const LANGUAGE_DIR = new URL("../asset/language/", import.meta.url);
12
+ function loadPack(locale) {
13
+ return JSON.parse(readFileSync(new URL(`${locale}.json`, LANGUAGE_DIR), "utf8"));
14
+ }
15
+ export const resources = {
16
+ en: loadPack("en"),
17
+ zh: loadPack("zh"),
18
+ };
package/dist/index.js CHANGED
@@ -1,17 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { Box, render } from "ink";
2
+ import { render } from "ink";
3
3
  import React from "react";
4
- import { InformationBar } from "./view/information-bar.js";
5
- import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
4
+ import { CurrentScreen, KeyboardProvider, LanguageProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
5
+ import { resources } from "./i18n-resources.js";
6
+ import { MainMenu } from "./view/main-menu.js";
6
7
  import { Editor } from "./view/editor.js";
7
- function App() {
8
- return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
9
- React.createElement(Box, { flexGrow: 1 },
10
- React.createElement(InformationBar, { mode: "NONE" })),
11
- React.createElement(Box, { height: "100%", width: "100%" },
12
- React.createElement(Editor, { value: "\u4F60\u597D", onChance: () => { } }))));
13
- }
14
- registerComponent(App, {});
15
- render(React.createElement(ScenarioManagementProvider, { defaultScreen: App, fullScreen: true },
16
- React.createElement(KeyboardProvider, { autoTab: false, mouse: true },
17
- React.createElement(CurrentScreen, null))));
8
+ import { Settings } from "./view/settings.js";
9
+ registerComponent(MainMenu, {});
10
+ registerComponent(Editor, {}, { parent: MainMenu });
11
+ registerComponent(Settings, {}, { parent: MainMenu });
12
+ render(React.createElement(ScenarioManagementProvider, { defaultScreen: MainMenu, fullScreen: true },
13
+ React.createElement(LanguageProvider, { resources: resources, defaultLanguage: "en", fallbackLanguage: "en" },
14
+ React.createElement(KeyboardProvider, { autoTab: false, mouse: true, modes: ["insert", "normal"], defaultMode: "insert" },
15
+ React.createElement(CurrentScreen, null)))));
@@ -1,16 +1,21 @@
1
+ import type { Document } from "../core/document/document.js";
1
2
  export type ClickTarget = {
2
3
  line: number;
3
- visual: number;
4
+ logical: number;
4
5
  };
5
6
  /**
6
7
  * Convert a 1-based terminal click (from `useMouseRegion`) into a document
7
- * position, accounting for the line-number gutter and the vertical scroll
8
- * offset.
8
+ * position, accounting for the line-number gutter, the vertical scroll
9
+ * offset, and soft-wrap segments.
9
10
  *
10
11
  * Assumes the region is the editor box itself with no padding, so
11
12
  * `event.x - rect.x` is a 0-based cell column inside the region. Columns
12
13
  * left of the gutter (clicks on the line numbers) snap to the text area's
13
14
  * left edge; rows past the last document line clamp to it.
15
+ *
16
+ * The clicked visual line is mapped through `doc.visualLineAt`, so a click
17
+ * on a wrapped continuation lands on the same logical line with the segment
18
+ * offset translated back to a logical column.
14
19
  */
15
20
  export declare function clickToPosition(event: {
16
21
  x: number;
@@ -20,4 +25,4 @@ export declare function clickToPosition(event: {
20
25
  y: number;
21
26
  width: number;
22
27
  height: number;
23
- }, gutterWidth: number, visibleStart: number, lineCount: number): ClickTarget;
28
+ }, gutterWidth: number, visibleStart: number, doc: Document): ClickTarget;
@@ -1,17 +1,29 @@
1
1
  /**
2
2
  * Convert a 1-based terminal click (from `useMouseRegion`) into a document
3
- * position, accounting for the line-number gutter and the vertical scroll
4
- * offset.
3
+ * position, accounting for the line-number gutter, the vertical scroll
4
+ * offset, and soft-wrap segments.
5
5
  *
6
6
  * Assumes the region is the editor box itself with no padding, so
7
7
  * `event.x - rect.x` is a 0-based cell column inside the region. Columns
8
8
  * left of the gutter (clicks on the line numbers) snap to the text area's
9
9
  * left edge; rows past the last document line clamp to it.
10
+ *
11
+ * The clicked visual line is mapped through `doc.visualLineAt`, so a click
12
+ * on a wrapped continuation lands on the same logical line with the segment
13
+ * offset translated back to a logical column.
10
14
  */
11
- export function clickToPosition(event, rect, gutterWidth, visibleStart, lineCount) {
15
+ export function clickToPosition(event, rect, gutterWidth, visibleStart, doc) {
12
16
  const localX = event.x - rect.x;
13
17
  const localY = event.y - rect.y;
14
- const visual = Math.max(0, localX - gutterWidth);
15
- const line = Math.max(0, Math.min(visibleStart + localY, lineCount - 1));
16
- return { line, visual };
18
+ const vline = visibleStart + localY;
19
+ const seg = doc.visualLineAt(vline);
20
+ if (!seg) {
21
+ const last = doc.lineCount - 1;
22
+ return { line: last, logical: doc.getLine(last).length };
23
+ }
24
+ const segmentOffset = Math.max(0, localX - gutterWidth);
25
+ const segStartVisual = doc.visualAtLogical(seg.line, seg.start);
26
+ const col = doc.logicalAtVisual(seg.line, segStartVisual + segmentOffset);
27
+ const logical = Math.min(Math.max(col, seg.start), seg.end);
28
+ return { line: seg.line, logical };
17
29
  }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ /**
3
+ * Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
4
+ * element, so it exclusively owns the keyboard while open — the editor's
5
+ * bindings (including the insert-mode `*` wildcard) stay dormant.
6
+ */
7
+ export declare function CommandBar(): React.JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { Box, Text } from "ink";
2
+ import { back, ModalLayerElementContext, useI18n, useKeyboard, useScreenSystem, } from "ink-cartridge";
3
+ import React, { useContext, useEffect, useRef, useState } from "react";
4
+ /**
5
+ * Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
6
+ * element, so it exclusively owns the keyboard while open — the editor's
7
+ * bindings (including the insert-mode `*` wildcard) stay dormant.
8
+ */
9
+ export function CommandBar() {
10
+ const ctx = useContext(ModalLayerElementContext);
11
+ const { t } = useI18n();
12
+ const { boundKeyboard, setMode } = useKeyboard();
13
+ const { closeModalLayer } = useScreenSystem();
14
+ const [input, setInput] = useState("");
15
+ const [error, setError] = useState(null);
16
+ // Handler closures stay stable across renders; the ref holds the truth.
17
+ const inputRef = useRef("");
18
+ useEffect(() => {
19
+ if (!ctx) {
20
+ return;
21
+ }
22
+ const unbinds = [];
23
+ unbinds.push(boundKeyboard(["*"], (ch) => {
24
+ inputRef.current += ch;
25
+ setInput(inputRef.current);
26
+ setError(null);
27
+ }, { elementId: ctx.id }));
28
+ unbinds.push(boundKeyboard(["backspace"], () => {
29
+ inputRef.current = inputRef.current.slice(0, -1);
30
+ setInput(inputRef.current);
31
+ }, { elementId: ctx.id }));
32
+ unbinds.push(boundKeyboard(["escape"], () => {
33
+ closeModalLayer(ctx.modalLayer.layerId);
34
+ setMode("normal");
35
+ }, { elementId: ctx.id }));
36
+ unbinds.push(boundKeyboard(["return"], () => {
37
+ const cmd = inputRef.current.trim();
38
+ if (cmd === "quit") {
39
+ closeModalLayer(ctx.modalLayer.layerId);
40
+ setMode("insert");
41
+ back();
42
+ }
43
+ else if (cmd) {
44
+ setError(t("command.unknown", { params: { cmd } }));
45
+ }
46
+ }, { elementId: ctx.id }));
47
+ return () => {
48
+ unbinds.forEach((fn) => fn());
49
+ };
50
+ }, [boundKeyboard, closeModalLayer, ctx, setMode, t]);
51
+ return (React.createElement(Box, { flexDirection: "row", width: "100%", height: 1, paddingLeft: 1, backgroundColor: "black" },
52
+ React.createElement(Text, { color: "green" }, ":"),
53
+ React.createElement(Text, null, input),
54
+ error ? React.createElement(Text, { color: "red" },
55
+ " ",
56
+ error) : null));
57
+ }
@@ -1,16 +1,16 @@
1
1
  import React from "react";
2
2
  export type EditorContext = {
3
- onChance: () => void;
4
- value: string;
3
+ onChance?: () => void;
4
+ value?: string;
5
5
  lineNumberRightSpacing?: number;
6
6
  cursorOffset?: number;
7
7
  cursorHeightOffset?: number;
8
8
  numberOfIndentationSpaces?: number;
9
9
  };
10
10
  /**
11
- * Main editor view: renders line numbers + text and routes every keystroke
12
- * to the controller as a named command. Rendering diffs via the controller's
13
- * change notifications; the controller lives in a ref so its state survives
14
- * re-renders (same pattern the old TextCalculation used).
11
+ * Main editor view: renders the status bar + line numbers + text, routes
12
+ * every keystroke to the controller as a named command, and splits
13
+ * bindings by mode — insert keeps the classic editing keys, normal gets
14
+ * hjkl + arrow-key movement and the `:` command bar.
15
15
  */
16
16
  export declare function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }: EditorContext): React.JSX.Element;
@@ -1,15 +1,17 @@
1
1
  import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
- import { useKeyboard, useMouseRegion } from "ink-cartridge";
3
- import React, { useEffect, useRef, useState } from "react";
2
+ import { applyElementToModalLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
+ import React, { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { EditorController } from "../core/editor-controller.js";
5
5
  import { clickToPosition } from "./click-mapping.js";
6
+ import { CommandBar } from "./command-bar.js";
7
+ import { InformationBar } from "./information-bar.js";
6
8
  /**
7
- * Main editor view: renders line numbers + text and routes every keystroke
8
- * to the controller as a named command. Rendering diffs via the controller's
9
- * change notifications; the controller lives in a ref so its state survives
10
- * re-renders (same pattern the old TextCalculation used).
9
+ * Main editor view: renders the status bar + line numbers + text, routes
10
+ * every keystroke to the controller as a named command, and splits
11
+ * bindings by mode — insert keeps the classic editing keys, normal gets
12
+ * hjkl + arrow-key movement and the `:` command bar.
11
13
  */
12
- export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
14
+ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
13
15
  const controllerRef = useRef(null);
14
16
  if (!controllerRef.current) {
15
17
  controllerRef.current = new EditorController(initialText, {
@@ -22,64 +24,147 @@ export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorO
22
24
  return controller.onChange(() => forceUpdate((n) => n + 1));
23
25
  }, [controller]);
24
26
  const { setCursorPosition } = useCursor();
25
- const { boundKeyboard, enableWildcardPriority } = useKeyboard();
27
+ const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
28
+ const { t } = useI18n();
29
+ // The engine keeps modes in a ref (no React notification), so we mirror
30
+ // switches locally to keep the status bar live.
31
+ const [mode, setLocalMode] = useState(() => getCurrentMode());
32
+ const switchMode = useCallback((next) => {
33
+ setMode(next);
34
+ setLocalMode(next);
35
+ }, [setMode]);
36
+ const openCommandBar = useCallback(() => {
37
+ openModalLayer("command", 100);
38
+ applyElementToModalLayer("command", {
39
+ elementId: "command-input",
40
+ element: CommandBar,
41
+ });
42
+ }, []);
26
43
  useEffect(() => {
27
44
  const removeWildcard = enableWildcardPriority();
28
45
  const unbinds = [];
29
- unbinds.push(boundKeyboard(["*"], (input) => controller.execute("editor.insertText", { text: input })));
30
- unbinds.push(boundKeyboard(["return"], () => controller.execute("editor.splitLine")));
31
- unbinds.push(boundKeyboard(["right"], () => controller.execute("cursor.moveRight")));
32
- unbinds.push(boundKeyboard(["left"], () => controller.execute("cursor.moveLeft")));
33
- // backspace deletes before the cursor; delete removes after it —
34
- // the old prototype treated both as backspace.
35
- unbinds.push(boundKeyboard(["backspace"], () => controller.execute("editor.deleteBefore")));
36
- unbinds.push(boundKeyboard(["delete"], () => controller.execute("editor.deleteAfter")));
37
- unbinds.push(boundKeyboard(["up"], () => controller.execute("cursor.moveUp")));
38
- unbinds.push(boundKeyboard(["down"], () => controller.execute("cursor.moveDown")));
39
- unbinds.push(boundKeyboard(["tab"], () => controller.execute("editor.indent")));
40
- unbinds.push(boundKeyboard(["shift+tab"], () => controller.execute("editor.outdent")));
46
+ const bind = (keys, handler, options = {}) => {
47
+ unbinds.push(boundKeyboard(keys, handler, options));
48
+ };
49
+ // insert mode: classic editing
50
+ bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), { mode: "insert" });
51
+ bind(["return"], () => controller.execute("editor.splitLine"), { mode: "insert" });
52
+ bind(["right"], () => controller.execute("cursor.moveRight"), { mode: "insert" });
53
+ bind(["left"], () => controller.execute("cursor.moveLeft"), { mode: "insert" });
54
+ // backspace deletes before the cursor; delete removes after it.
55
+ bind(["backspace"], () => controller.execute("editor.deleteBefore"), {
56
+ mode: "insert",
57
+ });
58
+ bind(["delete"], () => controller.execute("editor.deleteAfter"), { mode: "insert" });
59
+ bind(["up"], () => controller.execute("cursor.moveUp"), { mode: "insert" });
60
+ bind(["down"], () => controller.execute("cursor.moveDown"), { mode: "insert" });
61
+ bind(["tab"], () => controller.execute("editor.indent"), { mode: "insert" });
62
+ bind(["shift+tab"], () => controller.execute("editor.outdent"), { mode: "insert" });
63
+ bind(["escape"], () => switchMode("normal"), { mode: "insert" });
64
+ // normal mode: movement + command bar
65
+ bind(["h", "left"], () => controller.execute("cursor.moveLeft"), { mode: "normal" });
66
+ bind(["j"], () => controller.execute("cursor.moveDown"), { mode: "normal" });
67
+ bind(["down"], () => controller.execute("cursor.moveDown"), { mode: "normal" });
68
+ bind(["k", "up"], () => controller.execute("cursor.moveUp"), { mode: "normal" });
69
+ bind(["l", "right"], () => controller.execute("cursor.moveRight"), { mode: "normal" });
70
+ bind(["w"], () => controller.execute("cursor.wordForward"), { mode: "normal" });
71
+ bind(["b"], () => controller.execute("cursor.wordBackward"), { mode: "normal" });
72
+ bind(["0"], () => controller.execute("cursor.lineStart"), { mode: "normal" });
73
+ bind(["$"], () => controller.execute("cursor.lineEnd"), { mode: "normal" });
74
+ // `gg` via the composition engine: the first `g` arms the chain, the
75
+ // second fires documentStart. `mode` keeps typing `g` in insert mode
76
+ // untouched, since composition runs ahead of the `*` wildcard.
77
+ registryCompositionKey({
78
+ key: "g",
79
+ flags: [],
80
+ alternativeFlag: "goto",
81
+ needs: ["goto"],
82
+ optional: true,
83
+ mode: "normal",
84
+ execute: (ctx) => {
85
+ if (ctx.lastFlag === "goto") {
86
+ controller.execute("cursor.documentStart");
87
+ return null;
88
+ }
89
+ // head press: let the engine fill the flag from alternativeFlag
90
+ return { ...ctx, lastFlag: null };
91
+ },
92
+ });
93
+ unbinds.push(() => removeCompositionKey("g"));
94
+ // Ink marks "G" as shift+g, so the normalized key name is "shift+G".
95
+ bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
96
+ bind(["i"], () => switchMode("insert"), { mode: "normal" });
97
+ bind([":"], () => openCommandBar(), { mode: "normal" });
41
98
  return () => {
42
99
  removeWildcard();
43
100
  unbinds.forEach((fn) => fn());
44
101
  };
45
- }, [boundKeyboard, enableWildcardPriority, controller]);
102
+ }, [
103
+ boundKeyboard,
104
+ controller,
105
+ enableWildcardPriority,
106
+ getCurrentMode,
107
+ openCommandBar,
108
+ registryCompositionKey,
109
+ removeCompositionKey,
110
+ switchMode,
111
+ ]);
46
112
  const ref = useRef(null);
47
- const { height } = useBoxMetrics(ref);
113
+ const { height, width } = useBoxMetrics(ref);
48
114
  const doc = controller.document;
49
115
  const lineNumberWidth = doc.getLineNumberWidth();
116
+ // Soft-wrap the text at the editable width minus one cell, leaving a
117
+ // right-hand margin so the last column never hugs the edge.
118
+ doc.setWrapWidth(width - lineNumberWidth - lineNumberRightSpacing - 1);
50
119
  const visibleStart = doc.updateScroll(height);
51
- const effectiveH = height > 0 ? height : doc.lineCount;
52
- const visibleLines = doc.lines.slice(visibleStart, visibleStart + effectiveH);
120
+ const effectiveH = height > 0 ? height : doc.visualLineCount;
53
121
  const cursor = doc.cursor;
54
- const cursorX = cursor.visual + lineNumberWidth + lineNumberRightSpacing + cursorOffset;
55
- const cursorY = cursor.line - visibleStart + cursorHeightOffset;
122
+ const cursorVisualLine = doc.cursorVisualLine;
123
+ const cursorX = doc.cursorSegmentVisual + lineNumberWidth + lineNumberRightSpacing + cursorOffset;
124
+ const cursorY = cursorVisualLine - visibleStart + cursorHeightOffset;
56
125
  setCursorPosition({ x: cursorX, y: cursorY });
57
- // Mouse: clicks position the cursor; the wheel scrolls one line at a time.
58
- // The callbacks close over this render's metrics, and useMouseRegion keeps
59
- // them fresh, so scrollTop/gutter always match what the user sees.
126
+ // Mouse: clicks position the cursor in any mode. A plain wheel moves the
127
+ // cursor one visual line per notch; Ctrl+wheel scrolls the view without
128
+ // moving the cursor (clamped to the document). Callbacks close over this
129
+ // render's metrics.
60
130
  const mouseRef = useMouseRegion({
61
131
  onClick: (event, rect) => {
62
- const target = clickToPosition(event, rect, lineNumberWidth + lineNumberRightSpacing, doc.scrollTop, doc.lineCount);
132
+ const target = clickToPosition(event, rect, lineNumberWidth + lineNumberRightSpacing, doc.scrollTop, doc);
63
133
  controller.execute("cursor.setPosition", {
64
134
  line: target.line,
65
- visual: target.visual,
135
+ logical: target.logical,
66
136
  });
67
137
  },
68
138
  onWheel: (event) => {
69
- if (event.button === "wheel-up") {
70
- controller.execute("cursor.pageUp", { height: 1 });
139
+ const up = event.button === "wheel-up";
140
+ const down = event.button === "wheel-down";
141
+ if (!up && !down) {
142
+ return;
71
143
  }
72
- else if (event.button === "wheel-down") {
73
- controller.execute("cursor.pageDown", { height: 1 });
144
+ if (event.ctrl) {
145
+ controller.execute("view.scroll", {
146
+ delta: up ? -1 : 1,
147
+ height,
148
+ });
149
+ return;
74
150
  }
151
+ controller.execute(up ? "cursor.pageUp" : "cursor.pageDown", {
152
+ height: 1,
153
+ });
75
154
  },
76
155
  });
77
- return (React.createElement(Box, { ref: mouseRef, height: "100%", width: "100%", backgroundColor: "#1e1e1e" },
78
- React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, visibleLines.map((each, i) => {
79
- const lineNumber = visibleStart + i;
80
- return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
81
- React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing },
82
- React.createElement(Text, { bold: cursor.line === lineNumber }, lineNumber)),
83
- React.createElement(Text, null, each)));
84
- }))));
156
+ const modeText = mode === "normal" ? t("editor.mode.normal") : t("editor.mode.insert");
157
+ return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
158
+ React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual } }),
159
+ React.createElement(Box, { ref: mouseRef, flexGrow: 1, width: "100%", backgroundColor: "#1e1e1e" },
160
+ React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, Array.from({ length: effectiveH }, (_, i) => {
161
+ const vline = visibleStart + i;
162
+ const seg = doc.visualLineAt(vline);
163
+ if (!seg) {
164
+ return null;
165
+ }
166
+ return (React.createElement(Box, { key: vline, flexDirection: "row" },
167
+ React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? (React.createElement(Text, { bold: cursor.line === seg.line }, seg.line)) : null),
168
+ React.createElement(Text, null, doc.getLine(seg.line).slice(seg.start, seg.end))));
169
+ })))));
85
170
  }
@@ -1,8 +1,10 @@
1
1
  import React from "react";
2
- export type Information = {
3
- /**
4
- * current mode
5
- */
2
+ export type InformationBarProps = {
6
3
  mode: string;
4
+ cursor?: {
5
+ line: number;
6
+ column: number;
7
+ };
7
8
  };
8
- export declare function InformationBar({ mode }: Information): React.JSX.Element;
9
+ /** Status bar: current mode (live) plus the cursor's 1-based position. */
10
+ export declare function InformationBar({ mode, cursor }: InformationBarProps): React.JSX.Element;
@@ -1,6 +1,13 @@
1
1
  import { Box, Text } from "ink";
2
2
  import React from "react";
3
- export function InformationBar({ mode }) {
4
- return React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: 'row', paddingLeft: 2 },
5
- React.createElement(Text, { color: 'white', bold: true }, mode));
3
+ /** Status bar: current mode (live) plus the cursor's 1-based position. */
4
+ export function InformationBar({ mode, cursor }) {
5
+ return (React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: "row", paddingLeft: 2 },
6
+ React.createElement(Text, { color: "white", bold: true }, mode),
7
+ cursor ? (React.createElement(Text, { color: "white" },
8
+ " ",
9
+ "Ln ",
10
+ cursor.line + 1,
11
+ ", Col ",
12
+ cursor.column + 1)) : null));
6
13
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Pick the logo variant for the current terminal size. Wider screens get the
3
+ * words side by side; narrower screens stack them, and shrinking height steps
4
+ * the font down (block → simple → chrome → tiny) so buttons stay visible.
5
+ */
6
+ export declare function getLogo(columns: number, rows: number): string;
@@ -0,0 +1,87 @@
1
+ import chalk from "chalk";
2
+ import cfonts from "cfonts";
3
+ import gradient from "gradient-string";
4
+ /**
5
+ * "Blots Editor" rainbow-gradient logo.
6
+ *
7
+ * Generated with the same stack `oh-my-logo --filled` uses (cfonts block
8
+ * glyphs + gradient-string palette) but as a plain string, since
9
+ * oh-my-logo's own `renderFilled` renders through its own ink instance and
10
+ * cannot be embedded. `chalk.level` is forced so the gradient survives
11
+ * non-interactive output (pipes, tests).
12
+ *
13
+ * `getLogo(columns, rows)` picks a size tier from terminal dimensions so the
14
+ * logo shrinks instead of crowding out the menu buttons on small screens.
15
+ */
16
+ chalk.level = 3;
17
+ const RAINBOW = [
18
+ "#FF0000",
19
+ "#FF7F00",
20
+ "#FFFF00",
21
+ "#00FF00",
22
+ "#0000FF",
23
+ "#8B00FF",
24
+ ];
25
+ const FONTS = {
26
+ block: "block",
27
+ simple: "simple",
28
+ chrome: "chrome",
29
+ tiny: "tiny",
30
+ };
31
+ const renderBlock = (text, font) => {
32
+ const result = cfonts.render(text, {
33
+ font: FONTS[font],
34
+ align: "left",
35
+ colors: ["system"],
36
+ backgroundColor: "transparent",
37
+ letterSpacing: 1,
38
+ lineHeight: 1,
39
+ space: true,
40
+ maxLength: 0,
41
+ });
42
+ // cfonts returns `false` when it cannot render; fall back to plain text.
43
+ const art = (result === false ? text : result.string).replace(/^\n+|\n+$/g, "");
44
+ return art.split("\n");
45
+ };
46
+ /** Side-by-side layout: "Blots Editor" on shared lines. */
47
+ const wide = (font) => {
48
+ const blots = renderBlock("Blots", font);
49
+ const editor = renderBlock("Editor", font);
50
+ return blots.map((line, i) => `${line} ${editor[i] ?? ""}`).join("\n");
51
+ };
52
+ /** Stacked layout: "Blots" above "Editor". */
53
+ const narrow = (font) => `${renderBlock("Blots", font).join("\n")}\n\n${renderBlock("Editor", font).join("\n")}`;
54
+ /** Same gradient pass across the whole logo, whichever layout/font. */
55
+ const toLogo = (plain) => gradient(RAINBOW).multiline(plain);
56
+ /** Size tiers: (min rows, min cols for side-by-side) → (layout, font). */
57
+ const TIERS = [
58
+ // Wide screens: words side by side; shrink the font as height drops.
59
+ { minCols: 104, minRows: 26, build: () => wide("block") },
60
+ { minCols: 104, minRows: 20, build: () => wide("simple") },
61
+ { minCols: 104, minRows: 0, build: () => wide("tiny") },
62
+ // Narrow screens: words stacked; shrink the font as height drops.
63
+ { minCols: 0, minRows: 30, build: () => narrow("block") },
64
+ { minCols: 0, minRows: 24, build: () => narrow("simple") },
65
+ { minCols: 0, minRows: 20, build: () => narrow("chrome") },
66
+ { minCols: 0, minRows: 0, build: () => narrow("tiny") },
67
+ ];
68
+ const cache = new Map();
69
+ /**
70
+ * Pick the logo variant for the current terminal size. Wider screens get the
71
+ * words side by side; narrower screens stack them, and shrinking height steps
72
+ * the font down (block → simple → chrome → tiny) so buttons stay visible.
73
+ */
74
+ export function getLogo(columns, rows) {
75
+ for (const tier of TIERS) {
76
+ if (columns >= tier.minCols && rows >= tier.minRows) {
77
+ const key = tier.build.toString();
78
+ let logo = cache.get(key);
79
+ if (!logo) {
80
+ logo = toLogo(tier.build());
81
+ cache.set(key, logo);
82
+ }
83
+ return logo;
84
+ }
85
+ }
86
+ return toLogo(wide("block"));
87
+ }
@@ -0,0 +1,2 @@
1
+ import React from "react";
2
+ export declare function MainMenu(): React.JSX.Element;
@@ -0,0 +1,54 @@
1
+ import { Box, Text, useWindowSize } from "ink";
2
+ import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
3
+ import React, { useEffect, useState } from "react";
4
+ import { Editor } from "./editor.js";
5
+ import { Settings } from "./settings.js";
6
+ import { gotoScreen } from "ink-cartridge";
7
+ import { getLogo } from "./logo.js";
8
+ /**
9
+ * A borderless text button: the plain Box provides the mouse hit area,
10
+ * hovering inverts the text as visual feedback, disabled buttons dim.
11
+ */
12
+ function MenuButton({ label, shortcut, disabled, onClick }) {
13
+ const [hovered, setHovered] = useState(false);
14
+ const ref = useMouseRegion({
15
+ onEnter: () => setHovered(true),
16
+ onLeave: () => setHovered(false),
17
+ onClick: () => {
18
+ if (!disabled) {
19
+ onClick?.();
20
+ }
21
+ },
22
+ });
23
+ return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
24
+ React.createElement(Text, { inverse: hovered, dimColor: disabled }, label),
25
+ !disabled && shortcut ? React.createElement(Text, { dimColor: true },
26
+ " [",
27
+ shortcut,
28
+ "]") : null));
29
+ }
30
+ export function MainMenu() {
31
+ const { t } = useI18n();
32
+ const { boundKeyboard } = useKeyboard();
33
+ // The wide logo needs ≈100 columns; below that the words stack vertically.
34
+ // On short screens `getLogo` also shrinks the font so buttons stay visible.
35
+ const { columns, rows } = useWindowSize();
36
+ const narrow = columns < 104;
37
+ const logo = getLogo(columns, rows);
38
+ useEffect(() => {
39
+ const unbinds = [
40
+ boundKeyboard(["e"], () => gotoScreen(Editor, {})),
41
+ boundKeyboard(["s"], () => gotoScreen(Settings, {})),
42
+ boundKeyboard(["q"], () => process.exit(0)),
43
+ ];
44
+ return () => unbinds.forEach((fn) => fn());
45
+ }, [boundKeyboard]);
46
+ return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%" },
47
+ React.createElement(Box, { flexDirection: "column", marginBottom: narrow ? 2 : 6 },
48
+ React.createElement(Text, null, logo)),
49
+ React.createElement(Box, { flexDirection: narrow ? "column" : "row", gap: narrow ? 1 : 6, alignItems: "center" },
50
+ React.createElement(MenuButton, { label: t("menu.edit"), shortcut: "e", onClick: () => gotoScreen(Editor, {}) }),
51
+ React.createElement(MenuButton, { label: t("menu.settings"), shortcut: "s", onClick: () => gotoScreen(Settings, {}) }),
52
+ React.createElement(MenuButton, { label: t("menu.other"), disabled: true }),
53
+ React.createElement(MenuButton, { label: t("menu.quit"), shortcut: "q", onClick: () => process.exit(0) }))));
54
+ }
@@ -0,0 +1,2 @@
1
+ import React from "react";
2
+ export declare function Settings(): React.JSX.Element;
@@ -0,0 +1,97 @@
1
+ import { Box, Text } from "ink";
2
+ import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
3
+ import React, { useCallback, useContext, useEffect, useState } from "react";
4
+ const LANGUAGES = [
5
+ { code: "en", labelKey: "settings.language.en" },
6
+ { code: "zh", labelKey: "settings.language.zh" },
7
+ ];
8
+ /** Selectable language row with the same hover-highlight style as the menu. */
9
+ function LanguageRow({ label, selected, active, onEnter, onLeave, onClick, }) {
10
+ const ref = useMouseRegion({ onEnter, onLeave, onClick });
11
+ return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
12
+ React.createElement(Text, { inverse: active },
13
+ active ? "> " : " ",
14
+ label,
15
+ selected ? " *" : "")));
16
+ }
17
+ /**
18
+ * Modal language picker, opened from the settings screen. While open it owns
19
+ * the keyboard (modal layer takeover); picking a language applies it and
20
+ * closes, Esc cancels without changing anything.
21
+ */
22
+ function LanguagePicker() {
23
+ const ctx = useContext(ModalLayerElementContext);
24
+ const { t, setLanguage, currentLanguage } = useI18n();
25
+ const { boundKeyboard } = useKeyboard();
26
+ const { closeModalLayer } = useScreenSystem();
27
+ const [index, setIndex] = useState(0);
28
+ const [hovered, setHovered] = useState(null);
29
+ useEffect(() => {
30
+ if (!ctx) {
31
+ return;
32
+ }
33
+ const unbinds = [
34
+ boundKeyboard(["up"], () => setIndex((i) => Math.max(0, i - 1)), { elementId: ctx.id }),
35
+ boundKeyboard(["down"], () => setIndex((i) => Math.min(LANGUAGES.length - 1, i + 1)), { elementId: ctx.id }),
36
+ boundKeyboard(["return"], () => {
37
+ setLanguage(LANGUAGES[index].code);
38
+ closeModalLayer(ctx.modalLayer.layerId);
39
+ }, { elementId: ctx.id }),
40
+ boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
41
+ ];
42
+ return () => unbinds.forEach((fn) => fn());
43
+ }, [boundKeyboard, closeModalLayer, ctx, index, setLanguage]);
44
+ const apply = (code) => {
45
+ if (!ctx) {
46
+ return;
47
+ }
48
+ setLanguage(code);
49
+ closeModalLayer(ctx.modalLayer.layerId);
50
+ };
51
+ return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center" },
52
+ React.createElement(Box, { flexDirection: "column", borderStyle: "bold", borderColor: "white", backgroundColor: "black", width: 36, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
53
+ React.createElement(Text, { bold: true }, t("settings.language")),
54
+ LANGUAGES.map((lang, i) => (React.createElement(LanguageRow, { key: lang.code, label: t(lang.labelKey), selected: currentLanguage === lang.code, active: i === index || hovered === i, onEnter: () => setHovered(i), onLeave: () => setHovered((h) => (h === i ? null : h)), onClick: () => apply(lang.code) }))),
55
+ React.createElement(Text, { dimColor: true },
56
+ t("settings.back"),
57
+ " (Esc)"))));
58
+ }
59
+ /** A single settings entry: label plus its current value; opens a picker on activation. */
60
+ function SettingRow({ label, value, active, onEnter, onLeave, onClick, }) {
61
+ const ref = useMouseRegion({ onEnter, onLeave, onClick });
62
+ return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1, flexDirection: "row", gap: 2 },
63
+ React.createElement(Text, { inverse: active },
64
+ active ? "> " : " ",
65
+ label),
66
+ React.createElement(Text, { dimColor: true }, value)));
67
+ }
68
+ export function Settings() {
69
+ const { t, currentLanguage } = useI18n();
70
+ const { boundKeyboard } = useKeyboard();
71
+ const { openModalLayer, applyElementToModalLayer } = useScreenSystem();
72
+ const [hovered, setHovered] = useState(false);
73
+ const openLanguagePicker = useCallback(() => {
74
+ openModalLayer("language", 50);
75
+ applyElementToModalLayer("language", {
76
+ elementId: "language-picker",
77
+ element: LanguagePicker,
78
+ });
79
+ }, [applyElementToModalLayer, openModalLayer]);
80
+ useEffect(() => {
81
+ const unbinds = [
82
+ boundKeyboard(["return"], () => openLanguagePicker()),
83
+ boundKeyboard(["escape", "backspace"], () => back()),
84
+ ];
85
+ return () => unbinds.forEach((fn) => fn());
86
+ }, [boundKeyboard, openLanguagePicker]);
87
+ const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
88
+ "settings.language.en";
89
+ return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", gap: 2 },
90
+ React.createElement(Text, { bold: true }, t("settings.title")),
91
+ React.createElement(Box, { flexDirection: "column", gap: 2, marginTop: 2 },
92
+ React.createElement(SettingRow, { label: t("settings.language"), value: t(currentLabel), active: hovered, onEnter: () => setHovered(true), onLeave: () => setHovered(false), onClick: openLanguagePicker })),
93
+ React.createElement(Box, { marginTop: 4 },
94
+ React.createElement(Text, { dimColor: true },
95
+ t("settings.back"),
96
+ " (Esc)"))));
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "dist",
19
+ "asset",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],
@@ -50,10 +51,15 @@
50
51
  "react": ">=18"
51
52
  },
52
53
  "dependencies": {
54
+ "cfonts": "^3.3.1",
55
+ "chalk": "^4.1.2",
56
+ "gradient-string": "^2.0.2",
53
57
  "ink-cartridge": "*",
58
+ "oh-my-logo": "^0.5.0",
54
59
  "string-width": "^8.2.2"
55
60
  },
56
61
  "devDependencies": {
62
+ "@types/gradient-string": "^1.1.6",
57
63
  "@types/node": "^20.19.39",
58
64
  "@types/react": "^19.2.14",
59
65
  "ink": "^7.1.1",