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.
- package/asset/language/en.json +15 -0
- package/asset/language/zh.json +15 -0
- package/dist/core/document/document.d.ts +47 -4
- package/dist/core/document/document.js +184 -25
- package/dist/core/document/text-line.d.ts +13 -0
- package/dist/core/document/text-line.js +31 -0
- package/dist/core/editor-controller.js +14 -3
- package/dist/i18n-resources.d.ts +1 -0
- package/dist/i18n-resources.js +18 -0
- package/dist/index.js +12 -14
- package/dist/view/click-mapping.d.ts +9 -4
- package/dist/view/click-mapping.js +18 -6
- package/dist/view/command-bar.d.ts +7 -0
- package/dist/view/command-bar.js +57 -0
- package/dist/view/editor.d.ts +6 -6
- package/dist/view/editor.js +128 -43
- package/dist/view/information-bar.d.ts +7 -5
- package/dist/view/information-bar.js +10 -3
- package/dist/view/logo.d.ts +6 -0
- package/dist/view/logo.js +87 -0
- package/dist/view/main-menu.d.ts +2 -0
- package/dist/view/main-menu.js +54 -0
- package/dist/view/settings.d.ts +2 -0
- package/dist/view/settings.js +97 -0
- package/package.json +7 -1
|
@@ -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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
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 (
|
|
165
|
-
vs =
|
|
324
|
+
if (vline < vs) {
|
|
325
|
+
vs = vline;
|
|
166
326
|
}
|
|
167
|
-
else if (
|
|
168
|
-
vs =
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
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 {
|
|
2
|
+
import { render } from "ink";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
15
|
-
const
|
|
16
|
-
|
|
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
|
+
}
|
package/dist/view/editor.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
export type EditorContext = {
|
|
3
|
-
onChance
|
|
4
|
-
value
|
|
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
|
|
12
|
-
* to the controller as a named command
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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;
|
package/dist/view/editor.js
CHANGED
|
@@ -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
|
|
8
|
-
* to the controller as a named command
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
}, [
|
|
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.
|
|
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
|
|
55
|
-
const
|
|
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
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
|
132
|
+
const target = clickToPosition(event, rect, lineNumberWidth + lineNumberRightSpacing, doc.scrollTop, doc);
|
|
63
133
|
controller.execute("cursor.setPosition", {
|
|
64
134
|
line: target.line,
|
|
65
|
-
|
|
135
|
+
logical: target.logical,
|
|
66
136
|
});
|
|
67
137
|
},
|
|
68
138
|
onWheel: (event) => {
|
|
69
|
-
|
|
70
|
-
|
|
139
|
+
const up = event.button === "wheel-up";
|
|
140
|
+
const down = event.button === "wheel-down";
|
|
141
|
+
if (!up && !down) {
|
|
142
|
+
return;
|
|
71
143
|
}
|
|
72
|
-
|
|
73
|
-
controller.execute("
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,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,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.
|
|
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",
|