@xingjisoft/neutrino 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui.js CHANGED
@@ -23,52 +23,78 @@ function chunkByWidth(text, width) {
23
23
  export function wrapText(text, width) {
24
24
  if (width <= 0)
25
25
  width = 1;
26
- const lines = [];
27
- for (const para of text.split("\n")) {
26
+ const out = [];
27
+ const paragraphs = text.replace(/\r\n?/g, "\n").split("\n");
28
+ for (const para of paragraphs) {
28
29
  if (para.length === 0) {
29
- lines.push("");
30
+ out.push("");
30
31
  continue;
31
32
  }
32
- let line = "";
33
- let lineW = 0;
34
- for (const word of para.split(" ")) {
35
- const wordW = displayWidth(word);
36
- if (line.length === 0) {
37
- if (wordW <= width) {
38
- line = word;
39
- lineW = wordW;
40
- }
41
- else {
42
- const chunks = chunkByWidth(word, width);
43
- for (let i = 0; i < chunks.length - 1; i++)
44
- lines.push(chunks[i]);
45
- line = chunks[chunks.length - 1];
46
- lineW = displayWidth(line);
47
- }
48
- continue;
33
+ // Preserve the original indentation. Continuation lines reuse it so
34
+ // wrapped command output / code keeps its nesting instead of being
35
+ // flattened to the left margin.
36
+ const leadMatch = para.match(/^[ \t]*/);
37
+ const leadRaw = leadMatch ? leadMatch[0] : "";
38
+ const indent = leadRaw.replace(/\t/g, " ");
39
+ const indentW = displayWidth(indent);
40
+ const body = para.slice(leadRaw.length);
41
+ // Split into words and the whitespace runs between them so runs of spaces
42
+ // (alignment padding in command output) are kept verbatim rather than
43
+ // collapsed to a single space.
44
+ const parts = body.match(/\s+|\S+/g) ?? [];
45
+ let line = indent;
46
+ let lineW = indentW;
47
+ let hasContent = false;
48
+ // Whitespace seen since the last word. It is only emitted together with
49
+ // the next word, so a line break never leaves trailing blanks behind.
50
+ let pending = "";
51
+ const newLine = () => {
52
+ out.push(line);
53
+ line = " ".repeat(indentW);
54
+ lineW = indentW;
55
+ hasContent = false;
56
+ };
57
+ const placeWord = (word) => {
58
+ const gap = hasContent ? pending : "";
59
+ pending = "";
60
+ const gapW = displayWidth(gap);
61
+ const w = displayWidth(word);
62
+ if (lineW + gapW + w <= width) {
63
+ line += gap + word;
64
+ lineW += gapW + w;
65
+ hasContent = true;
66
+ return;
49
67
  }
50
- if (lineW + 1 + wordW <= width) {
51
- line += " " + word;
52
- lineW += 1 + wordW;
68
+ // Does not fit: break the line and drop the gap that caused the break.
69
+ if (hasContent)
70
+ newLine();
71
+ if (lineW + w <= width) {
72
+ line += word;
73
+ lineW += w;
74
+ hasContent = true;
75
+ return;
53
76
  }
54
- else {
55
- lines.push(line);
56
- if (wordW <= width) {
57
- line = word;
58
- lineW = wordW;
59
- }
60
- else {
61
- const chunks = chunkByWidth(word, width);
62
- for (let i = 0; i < chunks.length - 1; i++)
63
- lines.push(chunks[i]);
64
- line = chunks[chunks.length - 1];
65
- lineW = displayWidth(line);
66
- }
77
+ // Word is wider than an entire line: hard-wrap it by columns.
78
+ const avail = Math.max(1, width - lineW);
79
+ const chunks = chunkByWidth(word, avail);
80
+ for (let i = 0; i < chunks.length - 1; i++) {
81
+ line += chunks[i];
82
+ newLine();
83
+ }
84
+ line += chunks[chunks.length - 1];
85
+ lineW += displayWidth(chunks[chunks.length - 1]);
86
+ hasContent = true;
87
+ };
88
+ for (const part of parts) {
89
+ if (/^\s+$/.test(part)) {
90
+ pending += part.replace(/\t/g, " ");
91
+ continue;
67
92
  }
93
+ placeWord(part);
68
94
  }
69
- lines.push(line);
95
+ out.push(line);
70
96
  }
71
- return lines;
97
+ return out;
72
98
  }
73
99
  /**
74
100
  * Compute which part of the input value is visible inside the box, given the
@@ -137,10 +163,49 @@ export function drawHint(buf, y, text) {
137
163
  const t = displayWidth(text) <= max ? text : truncateByWidth(text, max - 1) + "…";
138
164
  buf.centerText(y, t, { fg: C.dim });
139
165
  }
166
+ /**
167
+ * Geometry of a vertical scrollbar, shared by the drawing code and the app's
168
+ * hit-testing / dragging so the two can never disagree.
169
+ */
170
+ export function scrollbarGeometry(top, bottom, total, viewport, offset) {
171
+ const trackH = Math.max(0, bottom - top + 1);
172
+ const thumbH = Math.max(1, Math.round((trackH * viewport) / total));
173
+ const maxThumbTop = Math.max(0, trackH - thumbH);
174
+ const maxOffset = Math.max(1, total - viewport);
175
+ const clamped = Math.max(0, Math.min(offset, maxOffset));
176
+ const thumbTop = maxThumbTop === 0 ? 0 : Math.round((maxThumbTop * clamped) / maxOffset);
177
+ return { trackH, thumbH, maxThumbTop, maxOffset, thumbTop };
178
+ }
179
+ /**
180
+ * Inverse of the thumb positioning above: the scroll offset that places the
181
+ * thumb's top at 'thumbTop'. Used while dragging the thumb; feeding the result
182
+ * back through scrollbarGeometry reproduces the same 'thumbTop'.
183
+ */
184
+ export function offsetFromThumbTop(g, thumbTop) {
185
+ if (g.maxThumbTop === 0)
186
+ return 0;
187
+ const t = Math.max(0, Math.min(thumbTop, g.maxThumbTop));
188
+ return Math.round((g.maxOffset * t) / g.maxThumbTop);
189
+ }
190
+ export function drawScrollbar(buf, x, top, bottom, total, viewport, offset, opts = {}) {
191
+ const trackH = bottom - top + 1;
192
+ if (trackH <= 0 || x < 0 || x >= buf.width)
193
+ return;
194
+ if (total <= viewport || viewport <= 0)
195
+ return; // nothing to scroll: hide it
196
+ const trackColor = opts.track ?? C.scrollTrack;
197
+ const thumbColor = opts.thumb ?? C.scrollThumb;
198
+ const g = scrollbarGeometry(top, bottom, total, viewport, offset);
199
+ for (let i = 0; i < g.trackH; i++) {
200
+ const isThumb = i >= g.thumbTop && i < g.thumbTop + g.thumbH;
201
+ buf.set(x, top + i, isThumb ? "█" : "│", { fg: isThumb ? thumbColor : trackColor });
202
+ }
203
+ }
140
204
  export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
141
205
  const active = opts.active ?? true;
142
206
  const dimmed = opts.dimmed ?? false;
143
207
  const placeholder = opts.placeholder ?? "";
208
+ const cursorOn = opts.cursorOn ?? true;
144
209
  const h = 3;
145
210
  const innerX = x + 2;
146
211
  const innerW = w - 4;
@@ -158,22 +223,42 @@ export function drawInputBox(buf, x, y, w, value, cursorPos, opts = {}) {
158
223
  }
159
224
  const win = computeInputWindow(value, cursorPos, innerW);
160
225
  const chars = Array.from(value);
226
+ // Codepoint index of the cursor (cursorPos is a UTF-16 offset).
227
+ const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
161
228
  const visible = chars.slice(win.startCp, win.startCp + win.visibleCount).join("");
162
229
  buf.writeText(innerX, innerY, visible, textStyle);
163
- const cursorScreenCol = cursorColAt(value, cursorPos, win);
164
- if (active && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
165
- buf.set(innerX + cursorScreenCol, innerY, " ", { fg: C.black, bg: C.accent });
230
+ // Blinking block cursor. While it is "on" the character underneath is drawn
231
+ // inside the block in the opposite colour so the text stays legible instead
232
+ // of being hidden by the cursor.
233
+ const cursorScreenCol = cursorColAt(chars, cursorCp, win);
234
+ if (active && cursorOn && cursorScreenCol >= 0 && cursorScreenCol < innerW) {
235
+ drawBlockCursor(buf, innerX + cursorScreenCol, innerY, chars, cursorCp, cursorScreenCol, innerW);
166
236
  }
167
237
  }
168
- function cursorColAt(value, cursorPos, win) {
169
- const chars = Array.from(value);
170
- const cursorCp = Math.min(Array.from(value.slice(0, cursorPos)).length, chars.length);
238
+ function cursorColAt(chars, cursorCp, win) {
171
239
  let col = win.prefixW;
172
240
  for (let i = win.startCp; i < cursorCp; i++) {
173
241
  col += wcwidth(chars[i].codePointAt(0) ?? 0);
174
242
  }
175
243
  return col - win.prefixW;
176
244
  }
245
+ /**
246
+ * Draw a single block cursor cell. The glyph under the cursor (the character
247
+ * immediately to its right) is rendered in the opposite colour — a dark glyph
248
+ * on the accent block — so it remains readable. Past the end of the text, or
249
+ * when the glyph would not fit, a solid block is drawn instead.
250
+ */
251
+ function drawBlockCursor(buf, x, y, chars, cursorCp, col, innerW) {
252
+ const cursorStyle = { fg: C.black, bg: C.accent };
253
+ const ch = cursorCp < chars.length ? chars[cursorCp] : "";
254
+ const cw = ch ? wcwidth(ch.codePointAt(0) ?? 0) : 0;
255
+ if (ch && ch !== " " && cw >= 1 && col + cw <= innerW) {
256
+ buf.writeText(x, y, ch, cursorStyle);
257
+ }
258
+ else {
259
+ buf.set(x, y, " ", cursorStyle);
260
+ }
261
+ }
177
262
  function textStylePlaceholder(active) {
178
263
  return active ? { fg: C.dim, bg: C.highlightBg } : { fg: C.dim };
179
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingjisoft/neutrino",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
+ "test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs",
20
21
  "prepare": "npm run build"
21
22
  },
22
23
  "keywords": [