bunmicro 0.8.3 → 0.9.1

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.
@@ -29,11 +29,12 @@ export class SyntaxHeader {
29
29
  }
30
30
 
31
31
  export class SyntaxDefinition {
32
- constructor(header, source) {
32
+ constructor(header, source, text = "") {
33
33
  this.header = header;
34
34
  this.filetype = header.filetype;
35
35
  this.source = source;
36
36
  this.rules = parseRules(source.rules ?? []);
37
+ this.autocompleteWords = scanAutocompleteWordsFromText(text);
37
38
  }
38
39
  }
39
40
 
@@ -45,15 +46,27 @@ export async function loadSyntaxDefinitions(runtime) {
45
46
 
46
47
  const definitions = [];
47
48
  for (const file of runtime.list(1)) {
48
- try{
49
- const source = Bun.YAML.parse(await file.text());
50
- const header = headers.get(file.name) ?? parseHeaderYaml(source);
51
- definitions.push(new SyntaxDefinition(header, source));
52
- }catch(e){
53
- console.error("Failed to parse syntax yaml:",file.name)
54
- console.error(" Will not highlight this kind of file")
55
- console.error(" @ loadSyntaxDefinitions ")
49
+ let text = "";
50
+ try {
51
+ text = await file.text();
52
+ } catch (e) {
53
+ console.error("Failed to read syntax yaml:", file.name);
54
+ console.error(" Will not highlight this kind of file");
55
+ console.error(" @ loadSyntaxDefinitions ");
56
+ continue;
56
57
  }
58
+
59
+ let source = null;
60
+ try {
61
+ source = Bun.YAML.parse(text);
62
+ } catch (e) {
63
+ console.error("Failed to parse syntax yaml:", file.name);
64
+ console.error(" Will not highlight this kind of file");
65
+ console.error(" @ loadSyntaxDefinitions ");
66
+ }
67
+
68
+ const header = headers.get(file.name) ?? (source ? parseHeaderYaml(source) : parseHeaderTextFallback(text, file.name));
69
+ definitions.push(new SyntaxDefinition(header, source ?? { rules: [] }, text));
57
70
  }
58
71
  return definitions;
59
72
  }
@@ -86,6 +99,61 @@ export function parseHeaderYaml(source) {
86
99
  });
87
100
  }
88
101
 
102
+ function parseHeaderTextFallback(text, fileName = "") {
103
+ const source = String(text ?? "");
104
+ const fallbackType = String(fileName).replace(/\.ya?ml$/i, "");
105
+ const filetype = rawYamlScalar(source.match(/^filetype:[ \t]*(.*)$/m)?.[1]) || fallbackType;
106
+ return new SyntaxHeader({
107
+ filetype,
108
+ filename: rawYamlDetectScalar(source, "filename"),
109
+ header: rawYamlDetectScalar(source, "header"),
110
+ signature: rawYamlDetectScalar(source, "signature"),
111
+ });
112
+ }
113
+
114
+ function rawYamlDetectScalar(text, key) {
115
+ const detect = text.match(/^detect:[ \t]*(?:#.*)?(?:\r?\n)((?:[ \t]+[^\n]*\r?\n?)*)/m)?.[1] ?? "";
116
+ return rawYamlScalar(detect.match(new RegExp(`^[ \t]+${key}:[ \t]*(.*)$`, "m"))?.[1]);
117
+ }
118
+
119
+ function rawYamlScalar(value) {
120
+ if (value == null) return "";
121
+ let out = String(value).trim();
122
+ if (!out) return "";
123
+ if (out.startsWith("\"") && out.endsWith("\"")) {
124
+ try { return JSON.parse(out); } catch { return out.slice(1, -1); }
125
+ }
126
+ if (out.startsWith("'") && out.endsWith("'")) return out.slice(1, -1).replaceAll("''", "'");
127
+ return out;
128
+ }
129
+
130
+ function scanAutocompleteWordsFromText(text) {
131
+ const source = String(text ?? "");
132
+ const words = [];
133
+ const seen = new Set();
134
+ let i = 0;
135
+ while (i < source.length) {
136
+ if (!isSyntaxWordChar(source[i])) { i++; continue; }
137
+ let j = i;
138
+ while (j < source.length && isSyntaxWordChar(source[j])) j++;
139
+ const word = source.slice(i, j);
140
+ if (!seen.has(word)) {
141
+ seen.add(word);
142
+ words.push(word);
143
+ }
144
+ i = j;
145
+ }
146
+ return words;
147
+ }
148
+
149
+ function isSyntaxWordChar(ch) {
150
+ if (!ch) return false;
151
+ const cp = ch.codePointAt(0);
152
+ if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122) || (cp >= 48 && cp <= 57) || cp === 95) return true;
153
+ if (cp <= 127) return false;
154
+ return /\p{L}|\p{N}/u.test(ch);
155
+ }
156
+
89
157
  function parseRules(rules) {
90
158
  return rules.map((rule) => parseRule(rule)).filter(Boolean);
91
159
  }
package/src/index.js CHANGED
@@ -190,25 +190,79 @@ function isWideCodePoint(cp) {
190
190
  (cp >= 0x1F004 && cp <= 0x1F0CF) ||
191
191
  (cp >= 0x1F18F && cp <= 0x1F19A) ||
192
192
  (cp >= 0x1F200 && cp <= 0x1F2FF) ||
193
- (cp >= 0x1F300 && cp <= 0x1F64F) ||
194
- (cp >= 0x1F900 && cp <= 0x1F9FF) ||
193
+ (cp >= 0x1F300 && cp <= 0x1FAFF) ||
195
194
  (cp >= 0x20000 && cp <= 0x2FFFD) ||
196
195
  (cp >= 0x30000 && cp <= 0x3FFFD)
197
196
  );
198
197
  }
199
198
 
199
+ function isZeroWidthCodePoint(cp) {
200
+ return (
201
+ cp === 0x200D ||
202
+ (cp >= 0x0300 && cp <= 0x036F) ||
203
+ (cp >= 0x1AB0 && cp <= 0x1AFF) ||
204
+ (cp >= 0x1DC0 && cp <= 0x1DFF) ||
205
+ (cp >= 0x20D0 && cp <= 0x20FF) ||
206
+ (cp >= 0xFE00 && cp <= 0xFE0F) ||
207
+ (cp >= 0xFE20 && cp <= 0xFE2F) ||
208
+ (cp >= 0xE0100 && cp <= 0xE01EF)
209
+ );
210
+ }
211
+
200
212
  function charWidth(ch) {
201
213
  if (!ch) return 0;
202
214
  const cp = ch.codePointAt(0);
203
215
  if (cp === 9) return DEFAULT_SETTINGS.tabsize;
204
- if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
216
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0) || isZeroWidthCodePoint(cp)) return 0;
205
217
  if (isWideCodePoint(cp)) return 2;
206
218
  return 1;
207
219
  }
208
220
 
221
+ function isEmojiVariationBase(cp) {
222
+ return (
223
+ cp === 0x00A9 || cp === 0x00AE ||
224
+ cp === 0x203C || cp === 0x2049 ||
225
+ cp === 0x2122 || cp === 0x2139 ||
226
+ (cp >= 0x2194 && cp <= 0x21AA) ||
227
+ (cp >= 0x231A && cp <= 0x231B) ||
228
+ cp === 0x2328 || cp === 0x23CF ||
229
+ (cp >= 0x23E9 && cp <= 0x23F3) ||
230
+ (cp >= 0x23F8 && cp <= 0x23FA) ||
231
+ cp === 0x24C2 ||
232
+ (cp >= 0x25AA && cp <= 0x25AB) ||
233
+ cp === 0x25B6 || cp === 0x25C0 ||
234
+ (cp >= 0x25FB && cp <= 0x25FE) ||
235
+ (cp >= 0x2600 && cp <= 0x27BF) ||
236
+ (cp >= 0x2934 && cp <= 0x2935) ||
237
+ (cp >= 0x2B05 && cp <= 0x2B55) ||
238
+ cp === 0x3030 || cp === 0x303D ||
239
+ cp === 0x3297 || cp === 0x3299
240
+ );
241
+ }
242
+
243
+ function displayUnitAt(text, idx) {
244
+ const cp = text.codePointAt(idx);
245
+ if (cp == null) return { text: "", width: 0, length: 0 };
246
+ let length = cp > 0xFFFF ? 2 : 1;
247
+ let unit = String.fromCodePoint(cp);
248
+ let width = charWidth(unit);
249
+ const nextCp = text.codePointAt(idx + length);
250
+ if (nextCp === 0xFE0F && isEmojiVariationBase(cp)) {
251
+ unit += String.fromCodePoint(nextCp);
252
+ length += 1;
253
+ width = 2;
254
+ }
255
+ return { text: unit, width, length };
256
+ }
257
+
209
258
  function displayWidth(text) {
210
259
  let width = 0;
211
- for (const ch of text) width += charWidth(ch);
260
+ for (let i = 0; i < text.length;) {
261
+ const unit = displayUnitAt(text, i);
262
+ if (unit.length <= 0) break;
263
+ width += unit.width;
264
+ i += unit.length;
265
+ }
212
266
  return width;
213
267
  }
214
268
 
@@ -260,6 +314,16 @@ function charIdxForScrollRight(line, cursorX, visibleCols) {
260
314
  return i;
261
315
  }
262
316
 
317
+ function normalizeCharBoundary(line, idx) {
318
+ idx = clamp(idx, 0, line.length);
319
+ if (idx > 0 && idx < line.length) {
320
+ const prev = line.charCodeAt(idx - 1);
321
+ const cur = line.charCodeAt(idx);
322
+ if (prev >= 0xD800 && prev <= 0xDBFF && cur >= 0xDC00 && cur <= 0xDFFF) return idx - 1;
323
+ }
324
+ return idx;
325
+ }
326
+
263
327
  // --- Softwrap utilities (ported from Go internal/display/softwrap.go) ---
264
328
 
265
329
  // Returns an array of code-unit indices where each visual row starts.
@@ -581,7 +645,7 @@ class BufferModel {
581
645
 
582
646
  ensureCursor() {
583
647
  this.cursor.y = clamp(this.cursor.y, 0, this.lines.length - 1);
584
- this.cursor.x = clamp(this.cursor.x, 0, this.line().length);
648
+ this.cursor.x = normalizeCharBoundary(this.line(), this.cursor.x);
585
649
  }
586
650
 
587
651
  invalidateHighlightFrom(lineNo = 0, options = {}) {
@@ -1038,6 +1102,13 @@ class BufferModel {
1038
1102
  }
1039
1103
  }
1040
1104
  }
1105
+ const syntaxWords = this.syntaxDefinition?.autocompleteWords ?? [];
1106
+ for (const w of syntaxWords) {
1107
+ if (w.length > wordLen && w.startsWith(word) && !seen.has(w)) {
1108
+ seen.add(w);
1109
+ suggestions.push(w);
1110
+ }
1111
+ }
1041
1112
  if (suggestions.length === 0) return false;
1042
1113
  if (suggestions.length === 1) {
1043
1114
  // Single match: insert suffix directly without entering cycling mode
@@ -1252,6 +1323,7 @@ class TerminalPane {
1252
1323
  this.app = app;
1253
1324
  this.proc = null;
1254
1325
  this.vt = null;
1326
+ this.decoder = new TextDecoder();
1255
1327
  }
1256
1328
 
1257
1329
  open(cols, rows) {
@@ -1269,7 +1341,8 @@ class TerminalPane {
1269
1341
  cols,
1270
1342
  rows,
1271
1343
  data: (_terminal, data) => {
1272
- const text = decoder.decode(data);
1344
+ const text = this.decoder.decode(data, { stream: true });
1345
+ if (!text) return;
1273
1346
  const responses = this.vt.feed(text);
1274
1347
  for (const resp of responses) this.proc?.terminal?.write(resp);
1275
1348
  this.app.render();
@@ -1464,9 +1537,10 @@ class App {
1464
1537
  const resize = this.screen.updateSize();
1465
1538
  this.rows = resize.rows;
1466
1539
  this.cols = resize.cols;
1540
+ this.layoutEditorArea();
1467
1541
  for (const tab of this.tabs)
1468
1542
  for (const p of tab.panes())
1469
- if (p.type === "term") p.terminal?.resize(p.w, Math.max(4, p.h));
1543
+ if (p.type === "term") p.terminal?.resize(p.w, Math.max(4, p.h - 1));
1470
1544
  if (!this.shellRunning && !this._alertRunning) this.render();
1471
1545
  });
1472
1546
  process.on("SIGINT", () => {}); // Ctrl+C is handled as copy in handleEvent
@@ -1500,9 +1574,7 @@ class App {
1500
1574
  process.exit(code);
1501
1575
  }
1502
1576
 
1503
- render() {
1504
- if (!this.running) return;
1505
- const tab = this.tab;
1577
+ layoutEditorArea() {
1506
1578
  const promptHeight = this.prompt ? 1 : 0;
1507
1579
  const tabBarHeight = this.tabs.length > 1 ? 1 : 0;
1508
1580
  const keymenuHeight = this.keymenu ? KEYDISPLAY.length : 0;
@@ -1517,15 +1589,40 @@ class App {
1517
1589
  const editorAreaH = Math.max(1, this.rows - 1 - promptHeight - tabBarHeight - keymenuHeight - infoHeight);
1518
1590
  const statusRow = this.rows - promptHeight - 1;
1519
1591
 
1592
+ for (const tab of this.tabs) computeLayout(tab.root, 0, editorAreaTop, this.cols, editorAreaH);
1593
+
1594
+ return {
1595
+ tabBarHeight,
1596
+ keymenuHeight,
1597
+ activeSuggestions,
1598
+ activeSuggestionIdx,
1599
+ activeMessage,
1600
+ suggestionsHeight,
1601
+ messageHeight,
1602
+ statusRow,
1603
+ };
1604
+ }
1605
+
1606
+ render() {
1607
+ if (!this.running) return;
1608
+ const tab = this.tab;
1609
+ const {
1610
+ tabBarHeight,
1611
+ keymenuHeight,
1612
+ activeSuggestions,
1613
+ activeSuggestionIdx,
1614
+ activeMessage,
1615
+ suggestionsHeight,
1616
+ messageHeight,
1617
+ statusRow,
1618
+ } = this.layoutEditorArea();
1619
+
1520
1620
  const defaultStyle = this.context.colorscheme?.defaultStyle ?? {};
1521
1621
  this.screen.fill(" ", defaultStyle);
1522
1622
 
1523
1623
  this.tabRects = [];
1524
1624
  if (tabBarHeight) this.renderTabbar(defaultStyle);
1525
1625
 
1526
- // Compute pane rects for this tab
1527
- computeLayout(tab.root, 0, editorAreaTop, this.cols, editorAreaH);
1528
-
1529
1626
  // Center scroll for any buffer restored from savecursor (deferred until layout is known)
1530
1627
  for (const p of tab.panes()) {
1531
1628
  if (p.buffer?._pendingCenterScroll) {
@@ -1649,8 +1746,9 @@ class App {
1649
1746
  if (softwrap) {
1650
1747
  const scrollSloc = { line: buf.scroll.y, row: buf.scroll.row ?? 0 };
1651
1748
  const cursorLine = buf.lines[buf.cursor.y] ?? "";
1749
+ const cursorX = normalizeCharBoundary(cursorLine, buf.cursor.x);
1652
1750
  const cursorBreaks = softwrapBreaks(cursorLine, bufW, wordwrap, tabsize);
1653
- const cursorSubRow = softwrapRowOfCharIdx(cursorBreaks, buf.cursor.x);
1751
+ const cursorSubRow = softwrapRowOfCharIdx(cursorBreaks, cursorX);
1654
1752
  const cursorSloc = { line: buf.cursor.y, row: cursorSubRow };
1655
1753
  const cursorAbove = cursorSloc.line < scrollSloc.line ||
1656
1754
  (cursorSloc.line === scrollSloc.line && cursorSloc.row < scrollSloc.row);
@@ -1659,10 +1757,12 @@ class App {
1659
1757
  : slocDiff(buf.lines, scrollSloc, cursorSloc, bufW, wordwrap, tabsize);
1660
1758
  cursorRow = p.y + visualRowOffset;
1661
1759
  const segStart = cursorBreaks[cursorSubRow] ?? 0;
1662
- cursorCol = p.x + gutterW + displayWidth(cursorLine.slice(segStart, buf.cursor.x));
1760
+ cursorCol = p.x + gutterW + displayWidth(cursorLine.slice(segStart, cursorX));
1663
1761
  } else {
1762
+ const line = buf.line();
1763
+ const cursorX = normalizeCharBoundary(line, buf.cursor.x);
1664
1764
  cursorRow = p.y + buf.cursor.y - buf.scroll.y;
1665
- cursorCol = p.x + gutterW + displayWidth(buf.line().slice(buf.scroll.x, buf.cursor.x));
1765
+ cursorCol = p.x + gutterW + displayWidth(line.slice(buf.scroll.x, cursorX));
1666
1766
  }
1667
1767
 
1668
1768
  const cursorVisible = cursorRow >= p.y && cursorRow < p.y + p.h && cursorCol >= p.x && cursorCol < p.x + p.w;
@@ -1891,11 +1991,16 @@ class App {
1891
1991
  for (let col = 0; col < Math.min(pane.w, vt.cols); col++) {
1892
1992
  const cell = vtRow[col];
1893
1993
  if (!cell) continue;
1894
- this.screen.setContent(pane.x + col, pane.y + 1 + row, cell.ch || " ", {
1994
+ const style = {
1895
1995
  fg: cell.fg, bg: cell.bg,
1896
1996
  bold: cell.bold, italic: cell.italic,
1897
1997
  underline: cell.underline, reverse: cell.reverse,
1898
- });
1998
+ };
1999
+ if (cell.filler) {
2000
+ this.screen.setFillerContent(pane.x + col, pane.y + 1 + row, style);
2001
+ continue;
2002
+ }
2003
+ this.screen.setContent(pane.x + col, pane.y + 1 + row, cell.ch || " ", style, cell.combining ?? []);
1899
2004
  }
1900
2005
  }
1901
2006
  // Show VT cursor only when live (not scrolled back) and active
@@ -2316,6 +2421,7 @@ class App {
2316
2421
  switch (seq) {
2317
2422
  case "escape": {
2318
2423
  this.pane.selection = null;
2424
+ this._markSelStart = null;
2319
2425
  if (buf) buf.searchPattern = "";
2320
2426
  const count = forceRehighlightDirtyLongLines(buf, this);
2321
2427
  if (count > 0) this.message = `Rehighlighted ${count} long line${count === 1 ? "" : "s"}`;
@@ -2620,6 +2726,20 @@ class App {
2620
2726
  case "alt-down":
2621
2727
  await runAction("MoveLinesDown", this);
2622
2728
  break;
2729
+ case "alt-d": //DedentSelection
2730
+ await runAction("OutdentSelection", this);
2731
+ break;
2732
+ case "alt-s": { //Mark selection start / extend selection to mark
2733
+ if (!this._markSelStart) {
2734
+ this._markSelStart = { ...buf.cursor };
2735
+ this.message = "selectionStart, ESC:cancel";
2736
+ } else {
2737
+ this.pane.selection = { start: { ...this._markSelStart }, end: { ...buf.cursor } };
2738
+ buf.cursor = { ...buf.cursor };
2739
+ this.message = "selectionEnd, ESC:cancel";
2740
+ }
2741
+ break;
2742
+ }
2623
2743
  case "alt-p": //PreviousTab
2624
2744
  await runAction("PreviousTab", this);
2625
2745
  break;
@@ -5285,8 +5405,11 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
5285
5405
  let i = scrollX;
5286
5406
  while (i < raw.length && width < maxWidth) {
5287
5407
  const cp = raw.codePointAt(i);
5288
- const ch = String.fromCodePoint(cp);
5289
- const charLen = cp > 0xFFFF ? 2 : 1;
5408
+ const unit = displayUnitAt(raw, i);
5409
+ const ch = unit.text;
5410
+ const charLen = unit.length;
5411
+ const w = unit.width;
5412
+ if (charLen <= 0) break;
5290
5413
  while (changeIndex + 1 < changes.length && i >= changes[changeIndex + 1][0]) changeIndex++;
5291
5414
  const group = changes[changeIndex]?.[1] ?? "default";
5292
5415
  const syntaxStyle = colorscheme?.get(group) ?? colorscheme?.defaultStyle ?? {};
@@ -5311,13 +5434,12 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
5311
5434
  if (selected) {
5312
5435
  style = { ...style, reverse: !style.reverse };
5313
5436
  }
5314
- const w = charWidth(ch);
5315
5437
  if (ch === "\t") {
5316
5438
  const spaces = Math.min(DEFAULT_SETTINGS.tabsize, maxWidth - width);
5317
5439
  for (let j = 0; j < spaces; j++) cells.push({ ch: " ", style });
5318
5440
  width += spaces;
5319
5441
  } else if (w > 0 && width + w <= maxWidth) {
5320
- cells.push({ ch, style, wide: w === 2 });
5442
+ cells.push({ ch, style, width: w });
5321
5443
  width += w;
5322
5444
  }
5323
5445
  i += charLen;
@@ -5333,20 +5455,29 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
5333
5455
  function putText(screen, x, y, text, style = null, maxWidth = Infinity) {
5334
5456
  let col = x;
5335
5457
  let width = 0;
5336
- for (const ch of String(text)) {
5458
+ const str = String(text);
5459
+ for (let i = 0; i < str.length;) {
5337
5460
  if (width >= maxWidth) break;
5461
+ const unit = displayUnitAt(str, i);
5462
+ const ch = unit.text;
5463
+ const w = unit.width;
5464
+ if (unit.length <= 0) break;
5338
5465
  if (ch === "\t") {
5339
5466
  const spaces = Math.min(DEFAULT_SETTINGS.tabsize, maxWidth - width);
5340
5467
  for (let i = 0; i < spaces; i++) screen.setContent(col++, y, " ", style);
5341
5468
  width += spaces;
5469
+ i += unit.length;
5470
+ continue;
5471
+ }
5472
+ if (w <= 0 || width + w > maxWidth) {
5473
+ i += unit.length;
5342
5474
  continue;
5343
5475
  }
5344
- const w = charWidth(ch);
5345
- if (w <= 0 || width + w > maxWidth) continue;
5346
5476
  screen.setContent(col, y, ch, style);
5347
5477
  if (w === 2) screen.setFillerContent(col + 1, y, style);
5348
5478
  col += w;
5349
5479
  width += w;
5480
+ i += unit.length;
5350
5481
  }
5351
5482
  return col;
5352
5483
  }
@@ -5356,7 +5487,7 @@ function putCells(screen, x, y, cells, maxWidth = Infinity) {
5356
5487
  let width = 0;
5357
5488
  for (const cell of cells) {
5358
5489
  if (width >= maxWidth) break;
5359
- const w = charWidth(cell.ch);
5490
+ const w = cell.width ?? charWidth(cell.ch);
5360
5491
  if (w <= 0 || width + w > maxWidth) continue;
5361
5492
  screen.setContent(col, y, cell.ch, cell.style);
5362
5493
  if (w === 2) screen.setFillerContent(col + 1, y, cell.style);
@@ -180,6 +180,11 @@ function registerBuiltinActions() {
180
180
  buf.modified = true;
181
181
  }
182
182
  });
183
+ // Aliases for OutdentSelection / OutdentLine
184
+ reg("DedentSelection", (app) => ACTIONS.get("OutdentSelection")(app));
185
+ reg("UnindentSelection", (app) => ACTIONS.get("OutdentSelection")(app));
186
+ reg("DedentLine", (app) => ACTIONS.get("OutdentLine")(app));
187
+ reg("UnindentLine", (app) => ACTIONS.get("OutdentLine")(app));
183
188
 
184
189
  // Editing
185
190
  reg("Backspace", (app) => app.buffer?.backspace());
@@ -59,7 +59,19 @@ export class Screen {
59
59
  let out = "\x1b[?25l";
60
60
  let activeStyleKey = null;
61
61
  for (const { x, y, cell } of changes) {
62
- if (cell.filler) continue; // right-half of a wide char; the char itself already covers this column
62
+ if (cell.filler) continue; // right-half of a wide char; the base cell covers this column
63
+ // If this is a wide char (next cell is its filler), clear the filler column with
64
+ // default style first. On narrow-emoji terminals this leaves a default-bg blank
65
+ // at the right-half column instead of stale cursor-line / syntax background, so
66
+ // the area next to the glyph doesn't look like a colored block "covering" it.
67
+ // On wide-emoji terminals the glyph's right half overwrites the blank harmlessly.
68
+ const nextCell = this.cells.getContent(x + 1, y);
69
+ if (nextCell?.filler) {
70
+ out += this.move(y + 1, x + 2);
71
+ out += styleToAnsi({});
72
+ activeStyleKey = "";
73
+ out += " ";
74
+ }
63
75
  out += this.move(y + 1, x + 1);
64
76
  if (cell.styleKey !== activeStyleKey) {
65
77
  out += styleToAnsi(cell.style ?? {});
@@ -8,7 +8,7 @@ const ANSI_COLORS = [
8
8
  ];
9
9
 
10
10
  function blankCell() {
11
- return { ch: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, reverse: false };
11
+ return { ch: " ", combining: [], filler: false, fg: "default", bg: "default", bold: false, italic: false, underline: false, reverse: false };
12
12
  }
13
13
 
14
14
  function findCSIEnd(str, start) {
@@ -24,6 +24,52 @@ function toHex2(n) {
24
24
  return ((n ?? 0) & 0xFF).toString(16).padStart(2, "0");
25
25
  }
26
26
 
27
+ function isZeroWidthCodePoint(cp) {
28
+ return (
29
+ cp === 0x200D ||
30
+ (cp >= 0x0300 && cp <= 0x036F) ||
31
+ (cp >= 0x1AB0 && cp <= 0x1AFF) ||
32
+ (cp >= 0x1DC0 && cp <= 0x1DFF) ||
33
+ (cp >= 0x20D0 && cp <= 0x20FF) ||
34
+ (cp >= 0xFE00 && cp <= 0xFE0F) ||
35
+ (cp >= 0xFE20 && cp <= 0xFE2F) ||
36
+ (cp >= 0xE0100 && cp <= 0xE01EF)
37
+ );
38
+ }
39
+
40
+ function isWideCodePoint(cp) {
41
+ if (cp < 0x1100) return false;
42
+ return (
43
+ cp <= 0x115F ||
44
+ cp === 0x2329 || cp === 0x232A ||
45
+ (cp >= 0x2E80 && cp <= 0x303E) ||
46
+ (cp >= 0x3040 && cp <= 0x33FF) ||
47
+ (cp >= 0x3400 && cp <= 0x4DBF) ||
48
+ (cp >= 0x4E00 && cp <= 0xA4C6) ||
49
+ (cp >= 0xA960 && cp <= 0xA97C) ||
50
+ (cp >= 0xAC00 && cp <= 0xD7A3) ||
51
+ (cp >= 0xF900 && cp <= 0xFAFF) ||
52
+ (cp >= 0xFE10 && cp <= 0xFE19) ||
53
+ (cp >= 0xFE30 && cp <= 0xFE4F) ||
54
+ (cp >= 0xFF01 && cp <= 0xFF60) ||
55
+ (cp >= 0xFFE0 && cp <= 0xFFE6) ||
56
+ (cp >= 0x1B000 && cp <= 0x1B0FF) ||
57
+ (cp >= 0x1F004 && cp <= 0x1F0CF) ||
58
+ (cp >= 0x1F18F && cp <= 0x1F19A) ||
59
+ (cp >= 0x1F200 && cp <= 0x1F2FF) ||
60
+ (cp >= 0x1F300 && cp <= 0x1FAFF) ||
61
+ (cp >= 0x20000 && cp <= 0x2FFFD) ||
62
+ (cp >= 0x30000 && cp <= 0x3FFFD)
63
+ );
64
+ }
65
+
66
+ function charWidth(ch) {
67
+ if (!ch) return 0;
68
+ const cp = ch.codePointAt(0);
69
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0) || isZeroWidthCodePoint(cp)) return 0;
70
+ return isWideCodePoint(cp) ? 2 : 1;
71
+ }
72
+
27
73
  export class VT100 {
28
74
  constructor(cols, rows) {
29
75
  this.cols = Math.max(1, cols);
@@ -56,10 +102,7 @@ export class VT100 {
56
102
  return this.cells[this._idx(x, y)];
57
103
  }
58
104
 
59
- _setCell(x, y, ch) {
60
- const cell = this._cell(x, y);
61
- if (!cell) return;
62
- cell.ch = ch;
105
+ _copyStyleTo(cell) {
63
106
  cell.fg = this.sgr.fg;
64
107
  cell.bg = this.sgr.bg;
65
108
  cell.bold = this.sgr.bold;
@@ -68,11 +111,54 @@ export class VT100 {
68
111
  cell.reverse = this.sgr.reverse;
69
112
  }
70
113
 
71
- _clearCell(x, y) {
114
+ _clearCellRaw(x, y) {
72
115
  const cell = this._cell(x, y);
73
116
  if (cell) Object.assign(cell, blankCell());
74
117
  }
75
118
 
119
+ _breakWideAt(x, y) {
120
+ const cell = this._cell(x, y);
121
+ if (!cell) return;
122
+ if (cell.filler) this._clearCellRaw(x - 1, y);
123
+ if (x + 1 < this.cols && this._cell(x + 1, y)?.filler) this._clearCellRaw(x + 1, y);
124
+ }
125
+
126
+ _setCell(x, y, ch, width = 1) {
127
+ if (width === 0) {
128
+ const targetX = this.cx > 0 ? this.cx - 1 : 0;
129
+ const cell = this._cell(targetX, this.cy);
130
+ if (cell && !cell.filler) cell.combining.push(ch);
131
+ return;
132
+ }
133
+ if (width > 1 && x >= this.cols - 1) {
134
+ this.cx = 0;
135
+ this._lineFeed();
136
+ x = this.cx;
137
+ y = this.cy;
138
+ }
139
+ this._breakWideAt(x, y);
140
+ if (width > 1) this._breakWideAt(x + 1, y);
141
+ const cell = this._cell(x, y);
142
+ if (!cell) return;
143
+ cell.ch = ch;
144
+ cell.combining = [];
145
+ cell.filler = false;
146
+ this._copyStyleTo(cell);
147
+ if (width > 1) {
148
+ const filler = this._cell(x + 1, y);
149
+ if (filler) {
150
+ Object.assign(filler, blankCell());
151
+ filler.filler = true;
152
+ this._copyStyleTo(filler);
153
+ }
154
+ }
155
+ }
156
+
157
+ _clearCell(x, y) {
158
+ this._breakWideAt(x, y);
159
+ this._clearCellRaw(x, y);
160
+ }
161
+
76
162
  _clearLineFrom(x, y) {
77
163
  for (let i = x; i < this.cols; i++) this._clearCell(i, y);
78
164
  }
@@ -198,12 +284,15 @@ export class VT100 {
198
284
  i++; // SO/SI charset switch, ignore
199
285
  } else if (code >= 0x20) {
200
286
  // Printable
201
- this._setCell(this.cx, this.cy, ch);
202
- this.cx++;
287
+ const cp = data.codePointAt(i);
288
+ const rune = String.fromCodePoint(cp);
289
+ const width = charWidth(rune);
290
+ this._setCell(this.cx, this.cy, rune, width);
291
+ this.cx += width;
203
292
  if (this.cx >= this.cols) {
204
293
  this.cx = 0; this._lineFeed();
205
294
  }
206
- i++;
295
+ i += cp > 0xFFFF ? 2 : 1;
207
296
  } else {
208
297
  i++; // other control: skip
209
298
  }
package/todo.txt CHANGED
@@ -30,6 +30,10 @@ Current handoff notes
30
30
  - Matchbrace highlights (), {}, [] and statusline column click cycles between matched braces when cursor is on a matched brace.
31
31
  - Ctrl-C/Ctrl-X/Ctrl-V/Ctrl-Y work for line and selection clipboard flows; external clipboard failures fall back to the internal register.
32
32
  - Copy/cut/paste and similar status messages render on a standalone info row above the statusline.
33
+ [x] term pane Unicode input/output support:
34
+ - PTY output now uses a streaming UTF-8 decoder so multi-byte Unicode split across chunks is preserved.
35
+ - VT100 printable handling iterates by code point, stores combining marks on the base cell, and uses filler cells for wide CJK/emoji output.
36
+ - Terminal pane rendering skips filler cells and emits combining marks through Screen.SetContent.
33
37
  [x] replace/replaceall commands aligned with Go behavior:
34
38
  - Flags: -a (replace all at once), -l (literal/no-regex, uses RegExp.escape).
35
39
  - replace without -a shows interactive Y/N prompt per match (Perform replacement (y,n,esc)).