bunmicro 0.9.23 → 0.9.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.25] - 2026-06-10
4
+ - Fixed colorscheme: aligned with go
5
+ - Added colorcolumn taberror showchars
6
+ * e.g. showchars = tab=>,space=.
7
+ - linter underline
8
+ - set hltrailingws on : whitespace
9
+ - Added tests/pty-demo.js
10
+ * Demo of the whole bunmicro App
11
+ * bun tests/pty-demo.js
12
+
3
13
  ## [0.9.23] - 2026-06-10
4
14
  - Fixed mouse close prompt cursor move
5
15
  - Fixed set filetype doesn't apply instantly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunmicro",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
4
4
  "description": "Bun JavaScript rewrite of the micro editor originally in Golang",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "scripts": {
12
12
  "start": "bun ./src/index.js",
13
- "check": "node --check ./src/index.js"
13
+ "check": "node --check ./src/index.js",
14
+ "demo:pty": "bun ./tests/pty-demo.js"
14
15
  },
15
16
  "dependencies": {
16
17
  "wasmoon": "^1.16.0"
@@ -7,17 +7,6 @@ export const DEFAULT_STYLE = {
7
7
  underline: false,
8
8
  };
9
9
 
10
- // Built-in fallback styles for color groups not defined in a colorscheme.
11
- // Matches Go micro's default group colours.
12
- const BUILTIN_GROUPS = {
13
- "diff-added": { ...DEFAULT_STYLE, fg: "green" },
14
- "diff-modified": { ...DEFAULT_STYLE, fg: "yellow" },
15
- "diff-deleted": { ...DEFAULT_STYLE, fg: "red" },
16
- "gutter-error": { ...DEFAULT_STYLE, fg: "red" },
17
- "gutter-warning":{ ...DEFAULT_STYLE, fg: "yellow" },
18
- "gutter-info": { ...DEFAULT_STYLE, fg: "brightblue" },
19
- };
20
-
21
10
  const COLOR_LINK = /color-link\s+(\S*)\s+"(.*)"/;
22
11
  const INCLUDE = /include\s+"(.*)"/;
23
12
 
@@ -47,10 +36,11 @@ export class Colorscheme {
47
36
  if (include) {
48
37
  const includeName = include[1];
49
38
  if (!parsed.has(includeName)) {
50
- const child = new Colorscheme(this.runtime);
51
- await child.load(includeName, parsed);
52
- for (const [key, value] of child.styles) styles.set(key, value);
53
- if (child.styles.has("default")) this.defaultStyle = child.styles.get("default");
39
+ const file = this.runtime.find(0, includeName);
40
+ if (!file) throw new Error(`${includeName} is not a valid colorscheme`);
41
+ parsed.add(includeName);
42
+ const includedStyles = await this.parse(includeName, await file.text(), parsed);
43
+ for (const [key, value] of includedStyles) styles.set(key, value);
54
44
  }
55
45
  continue;
56
46
  }
@@ -76,7 +66,7 @@ export class Colorscheme {
76
66
  if (this.styles.has(cur)) style = this.styles.get(cur);
77
67
  }
78
68
  }
79
- return style ?? BUILTIN_GROUPS[group] ?? stringToStyle(group, this.defaultStyle);
69
+ return style ?? stringToStyle(group, this.defaultStyle);
80
70
  }
81
71
  }
82
72
 
@@ -87,10 +77,10 @@ export function stringToStyle(input, base = DEFAULT_STYLE) {
87
77
  return {
88
78
  fg: stringToColor(fgRaw.trim(), base.fg),
89
79
  bg: stringToColor(bgRaw.trim(), base.bg),
90
- bold: text.includes("bold"),
91
- italic: text.includes("italic"),
92
- reverse: text.includes("reverse"),
93
- underline: text.includes("underline"),
80
+ bold: base.bold || text.includes("bold"),
81
+ italic: base.italic || text.includes("italic"),
82
+ reverse: base.reverse || text.includes("reverse"),
83
+ underline: base.underline || text.includes("underline"),
94
84
  };
95
85
  }
96
86
 
package/src/index.js CHANGED
@@ -101,7 +101,11 @@ const DEFAULT_SETTINGS = {
101
101
  fileformat: process.platform === "win32" ? "dos" : "unix",
102
102
  "comment.type": "",
103
103
  commenttype: "",
104
- trailingws: false,
104
+ hltrailingws: false,
105
+ hltaberrors: false,
106
+ colorcolumn: 0,
107
+ showchars: "",
108
+ indentchar: " ",
105
109
  };
106
110
 
107
111
  const LONG_LINE_REHIGHLIGHT_LIMIT = 300;
@@ -647,7 +651,11 @@ class BufferModel {
647
651
  reload: DEFAULT_SETTINGS.reload,
648
652
  eofnewline: DEFAULT_SETTINGS.eofnewline,
649
653
  fileformat: this.fileformat,
650
- trailingws: DEFAULT_SETTINGS.trailingws,
654
+ hltrailingws: DEFAULT_SETTINGS.hltrailingws,
655
+ hltaberrors: DEFAULT_SETTINGS.hltaberrors,
656
+ colorcolumn: DEFAULT_SETTINGS.colorcolumn,
657
+ showchars: DEFAULT_SETTINGS.showchars,
658
+ indentchar: DEFAULT_SETTINGS.indentchar,
651
659
  encoding: this.encoding,
652
660
  readonly,
653
661
  };
@@ -1879,7 +1887,9 @@ class App {
1879
1887
  const ft = (buf?.filetype && buf.filetype !== "unknown") ? buf.filetype : "?";
1880
1888
  const fmt = buf?.fileformat ?? "unix";
1881
1889
  const enc = buf?.encoding ?? "utf-8";
1882
- const baseStatus = { ...defaultStyle, reverse: true };
1890
+ const baseStatus = this.context.colorscheme?.styles?.has("statusline")
1891
+ ? this.context.colorscheme.get("statusline")
1892
+ : { ...defaultStyle, reverse: true };
1883
1893
  const redStatus = { ...baseStatus, fg: "red" };
1884
1894
  // Fill entire row with base style first
1885
1895
  putText(this.screen, 0, statusRow, " ".repeat(this.cols), baseStatus, this.cols);
@@ -2010,8 +2020,9 @@ class App {
2010
2020
  }
2011
2021
 
2012
2022
  renderMessageRow(defaultStyle, row, message) {
2013
- const statusStyle = this.context.colorscheme?.get("statusline") ?? { ...defaultStyle, reverse: true };
2014
- const style = { ...statusStyle, reverse: false };
2023
+ const style = this.context.colorscheme?.styles?.has("message")
2024
+ ? this.context.colorscheme.get("message")
2025
+ : defaultStyle;
2015
2026
  putText(this.screen, 0, row, " ".repeat(this.cols), style, this.cols);
2016
2027
  this._messageRowY = row;
2017
2028
  this._messageRowClickZone = null;
@@ -2041,15 +2052,15 @@ class App {
2041
2052
  }
2042
2053
 
2043
2054
  renderSuggestions(defaultStyle, row, suggestions, curIdx) {
2044
- const suggestionStyle = this.context.colorscheme?.get("statusline.suggestions")
2045
- ?? this.context.colorscheme?.get("statusline")
2046
- ?? defaultStyle;
2047
- const baseStyle = { ...suggestionStyle, reverse: false };
2055
+ const cs = this.context.colorscheme;
2056
+ const baseStyle = cs?.styles?.has("statusline.suggestions")
2057
+ ? cs.get("statusline.suggestions")
2058
+ : cs?.styles?.has("statusline")
2059
+ ? cs.get("statusline")
2060
+ : { ...defaultStyle, reverse: true };
2048
2061
  const selStyle = {
2049
2062
  ...baseStyle,
2050
- fg: baseStyle.bg === "default" ? "black" : baseStyle.bg,
2051
- bg: baseStyle.fg === "default" ? "brightwhite" : baseStyle.fg,
2052
- reverse: false,
2063
+ reverse: true,
2053
2064
  };
2054
2065
 
2055
2066
  // Compute each item's position in the virtual (pre-scroll) space.
@@ -2172,9 +2183,16 @@ class App {
2172
2183
  const softwrap = buf.Settings?.softwrap ?? false;
2173
2184
  const wordwrap = softwrap && (buf.Settings?.wordwrap ?? false);
2174
2185
  const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
2175
- const gutterStyle = { ...defaultStyle, fg: "brightblack" };
2176
- const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2177
2186
  const isActivePane = pane === this.tab.activePane;
2187
+ const gutterStyle = this.context.colorscheme?.styles?.has("line-number")
2188
+ ? this.context.colorscheme.get("line-number")
2189
+ : defaultStyle;
2190
+ const cursorlineOn = buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline;
2191
+ const csHasCurNum = this.context.colorscheme?.styles?.has("current-line-number");
2192
+ const curNumStyle = !csHasCurNum
2193
+ ? defaultStyle
2194
+ : (cursorlineOn ? this.context.colorscheme.get("current-line-number") : gutterStyle);
2195
+ const dirtyGutterStyle = { ...gutterStyle, fg: "red" };
2178
2196
  const useCursorline = (buf.Settings?.cursorline ?? DEFAULT_SETTINGS.cursorline) && isActivePane;
2179
2197
  const clBg = (useCursorline && this.context.colorscheme?.styles?.has("cursor-line"))
2180
2198
  ? (this.context.colorscheme.get("cursor-line")?.fg ?? null)
@@ -2187,12 +2205,12 @@ class App {
2187
2205
  const diffCol = (buf.Settings?.diffgutter ?? false) ? 1 : 0;
2188
2206
  const lineNumW = gutterW - msgW - diffCol;
2189
2207
  const cs = this.context.colorscheme;
2190
- const diffAddStyle = cs?.get("diff-added") ?? { ...defaultStyle, fg: "green" };
2191
- const diffModStyle = cs?.get("diff-modified") ?? { ...defaultStyle, fg: "yellow" };
2192
- const diffDelStyle = cs?.get("diff-deleted") ?? { ...defaultStyle, fg: "red" };
2193
- const msgInfoStyle = cs?.get("gutter-info") ?? { ...defaultStyle, fg: "cyan" };
2194
- const msgWarnStyle = cs?.get("gutter-warning") ?? { ...defaultStyle, fg: "yellow" };
2195
- const msgErrStyle = cs?.get("gutter-error") ?? { ...defaultStyle, fg: "red" };
2208
+ const diffAddStyle = cs?.styles?.has("diff-added") ? cs.get("diff-added") : null;
2209
+ const diffModStyle = cs?.styles?.has("diff-modified") ? cs.get("diff-modified") : null;
2210
+ const diffDelStyle = cs?.styles?.has("diff-deleted") ? cs.get("diff-deleted") : null;
2211
+ const msgInfoStyle = cs?.styles?.has("gutter-info") ? cs.get("gutter-info") : defaultStyle;
2212
+ const msgWarnStyle = cs?.styles?.has("gutter-warning") ? cs.get("gutter-warning") : defaultStyle;
2213
+ const msgErrStyle = cs?.styles?.has("gutter-error") ? cs.get("gutter-error") : defaultStyle;
2196
2214
 
2197
2215
  const renderGutter = (lineNo, row, screenRow, subRow = 0) => {
2198
2216
  // Message indicator: 2 cols, '> ' with kind-based style (Go: drawGutter)
@@ -2209,19 +2227,22 @@ class App {
2209
2227
  }
2210
2228
  putText(this.screen, pane.x, screenRow, msgCh + " ", msgSt, 2);
2211
2229
  }
2230
+ const isCurrentLine = isActivePane && !pane.selection && lineNo === buf.cursor.y;
2231
+ const lineStyle = isCurrentLine ? curNumStyle : gutterStyle;
2212
2232
  if (diffCol > 0) {
2213
2233
  const m = diffMarks?.[lineNo] ?? 0;
2214
- const [ch, st] = m === 1 ? ["▌", diffAddStyle]
2215
- : m === 2 ? ["▌", diffModStyle]
2216
- : m === 3 ? ["▔", diffDelStyle]
2217
- : [" ", gutterStyle];
2234
+ const [ch, colorStyle] = m === 1 ? ["▌", diffAddStyle]
2235
+ : m === 2 ? ["▌", diffModStyle]
2236
+ : m === 3 ? ["▔", diffDelStyle]
2237
+ : [" ", null];
2238
+ const st = colorStyle ? { ...lineStyle, fg: colorStyle.fg } : lineStyle;
2218
2239
  putText(this.screen, pane.x + msgW, screenRow, subRow === 0 ? ch : " ", st, 1);
2219
2240
  }
2220
2241
  if (lineNumW > 0) {
2221
2242
  const prefix = subRow === 0
2222
2243
  ? lineNumberText(buf, lineNo, row, lineNumW)
2223
2244
  : visualLineNumberText(subRow, lineNumW);
2224
- putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : gutterStyle, lineNumW);
2245
+ putText(this.screen, pane.x + msgW + diffCol, screenRow, prefix, isDirtyLongLine(buf, lineNo) ? dirtyGutterStyle : lineStyle, lineNumW);
2225
2246
  }
2226
2247
  };
2227
2248
 
@@ -2305,7 +2326,11 @@ class App {
2305
2326
 
2306
2327
  renderDividers(node, defaultStyle) {
2307
2328
  if (node instanceof Pane) return;
2308
- const divStyle = { ...defaultStyle, fg: "brightblack" };
2329
+ const divReverse = this.context.config?.globalSettings?.divreverse ?? true;
2330
+ const baseDiv = this.context.colorscheme?.styles?.has("divider")
2331
+ ? this.context.colorscheme.get("divider")
2332
+ : defaultStyle;
2333
+ const divStyle = divReverse ? { ...baseDiv, reverse: true } : baseDiv;
2309
2334
  for (let i = 0; i < node.children.length - 1; i++) {
2310
2335
  const child = node.children[i];
2311
2336
  if (node.dir === "h") {
@@ -2322,21 +2347,37 @@ class App {
2322
2347
  }
2323
2348
 
2324
2349
  renderTabbar(defaultStyle) {
2325
- const tabBarStyle = this.context.colorscheme?.get("tabbar") ?? { ...defaultStyle, reverse: true };
2326
- const activeStyle = this.context.colorscheme?.get("tabbar.active") ?? { ...tabBarStyle, bold: true };
2350
+ const cs = this.context.colorscheme;
2351
+ const gs = this.context.config?.globalSettings;
2352
+ const tabReverse = gs?.tabreverse ?? true;
2353
+ const tabHighlight = gs?.tabhighlight ?? false;
2354
+ const tabCharReverse = (tabReverse || tabHighlight) && !(tabReverse && tabHighlight);
2355
+
2356
+ const stylesFor = (reverse) => {
2357
+ const base = cs?.styles?.has("tabbar")
2358
+ ? cs.get("tabbar")
2359
+ : { ...defaultStyle, reverse };
2360
+ const active = cs?.styles?.has("tabbar.active")
2361
+ ? cs.get("tabbar.active")
2362
+ : base;
2363
+ return [base, active];
2364
+ };
2365
+ const [sepStyle] = stylesFor(tabReverse);
2366
+ const [charBase, charActive] = stylesFor(tabCharReverse);
2367
+
2327
2368
  let x = 0;
2328
2369
  for (let i = 0; i < this.tabs.length && x < this.cols; i++) {
2329
2370
  const name = this.tabs[i].name || "No name";
2330
2371
  const isActive = i === this.activeTabIdx;
2331
2372
  const label = isActive ? `[${name}]` : ` ${name} `;
2332
- const style = isActive ? activeStyle : tabBarStyle;
2373
+ const style = isActive ? charActive : charBase;
2333
2374
  const start = x;
2334
2375
  x = putText(this.screen, x, 0, label, style, this.cols - x);
2335
2376
  this.tabRects.push({ index: i, start, end: x });
2336
2377
  if (i < this.tabs.length - 1 && x < this.cols)
2337
- x = putText(this.screen, x, 0, " ", tabBarStyle, this.cols - x);
2378
+ x = putText(this.screen, x, 0, " ", sepStyle, this.cols - x);
2338
2379
  }
2339
- if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), tabBarStyle, this.cols - x);
2380
+ if (x < this.cols) putText(this.screen, x, 0, " ".repeat(this.cols - x), sepStyle, this.cols - x);
2340
2381
  }
2341
2382
 
2342
2383
  updateScrollForPane(pane) {
@@ -6047,22 +6088,75 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6047
6088
  // Go: cursor-line bg is skipped when a syntax style already has a non-default background (preservebg)
6048
6089
  const defBg = colorscheme?.defaultStyle?.bg ?? "default";
6049
6090
 
6050
- const showTrailingWs = buf.Settings?.trailingws ?? false;
6091
+ const showTrailingWs = buf.Settings?.hltrailingws ?? false;
6051
6092
  let trailingWsIdx = raw.length;
6052
6093
  if (showTrailingWs) {
6053
6094
  let k = raw.length - 1;
6054
6095
  while (k >= 0 && (raw[k] === " " || raw[k] === "\t")) k--;
6055
6096
  trailingWsIdx = k + 1;
6056
6097
  }
6057
- const trailingWsStyle = showTrailingWs
6058
- ? (colorscheme?.get("trailingws") ?? { fg: "red", underline: true })
6098
+ const trailingWsColor = showTrailingWs && colorscheme?.styles?.has("trailingws")
6099
+ ? colorscheme.get("trailingws")?.fg
6100
+ : null;
6101
+
6102
+ // showchars parsing (Go bufwindow.go:455-476)
6103
+ const indentchar = buf.Settings?.indentchar ?? " ";
6104
+ let spacechars = " ";
6105
+ let tabchars = indentchar;
6106
+ let indentspacechars = "";
6107
+ let indenttabchars = "";
6108
+ for (const entry of String(buf.Settings?.showchars ?? "").split(",")) {
6109
+ const eq = entry.indexOf("=");
6110
+ if (eq < 0) continue;
6111
+ const key = entry.slice(0, eq);
6112
+ const val = entry.slice(eq + 1);
6113
+ if (key === "space") spacechars = val;
6114
+ else if (key === "tab") tabchars = val;
6115
+ else if (key === "ispace") indentspacechars = val;
6116
+ else if (key === "itab") indenttabchars = val;
6117
+ }
6118
+ // leadingwsEnd: index of first non-whitespace char (raw code-unit index)
6119
+ let leadingwsEnd = 0;
6120
+ while (leadingwsEnd < raw.length && (raw[leadingwsEnd] === " " || raw[leadingwsEnd] === "\t")) leadingwsEnd++;
6121
+
6122
+ const hltaberrors = buf.Settings?.hltaberrors ?? false;
6123
+ const tabstospaces = buf.Settings?.tabstospaces ?? false;
6124
+ const tabErrorFg = hltaberrors && colorscheme?.styles?.has("tab-error")
6125
+ ? colorscheme.get("tab-error")?.fg
6126
+ : null;
6127
+ const indentCharFg = colorscheme?.styles?.has("indent-char")
6128
+ ? colorscheme.get("indent-char")?.fg
6059
6129
  : null;
6130
+ const colorcolumn = Number(buf.Settings?.colorcolumn ?? 0) | 0;
6131
+ const colorColumnBg = colorcolumn > 0 && colorscheme?.styles?.has("color-column")
6132
+ ? colorscheme.get("color-column")?.fg
6133
+ : null;
6134
+ const tabsize = buf.Settings?.tabsize ?? DEFAULT_SETTINGS.tabsize;
6135
+
6136
+ // scrollVisualCol: visual column of raw[0..scrollX). Uses displayWidth (tab = full
6137
+ // tabsize, not aligned-to-boundary) to stay consistent with cursor/scroll math.
6138
+ const scrollVisualCol = displayWidth(raw.slice(0, scrollX));
6139
+
6140
+ // Linter messages overlapping this line (Go bufwindow.go:662-668)
6141
+ const lineMessages = (buf.Messages ?? []).filter((m) => {
6142
+ const sy = m.Start?.Y ?? 0, ey = m.End?.Y ?? 0;
6143
+ return sy <= lineNo && ey >= lineNo;
6144
+ });
6145
+ const inMessageAt = (charIdx) => {
6146
+ for (const m of lineMessages) {
6147
+ const sY = m.Start?.Y ?? 0, sX = m.Start?.X ?? 0;
6148
+ const eY = m.End?.Y ?? 0, eX = m.End?.X ?? 0;
6149
+ const ge = lineNo > sY || (lineNo === sY && charIdx >= sX);
6150
+ const lt = lineNo < eY || (lineNo === eY && charIdx < eX);
6151
+ if (ge && lt) return true;
6152
+ }
6153
+ return false;
6154
+ };
6060
6155
 
6061
6156
  let changeIndex = 0;
6062
6157
  let searchIdx = 0;
6063
6158
  let i = scrollX;
6064
6159
  while (i < raw.length && width < maxWidth) {
6065
- const cp = raw.codePointAt(i);
6066
6160
  const unit = displayUnitAt(raw, i);
6067
6161
  const ch = unit.text;
6068
6162
  const charLen = unit.length;
@@ -6071,16 +6165,30 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6071
6165
  while (changeIndex + 1 < changes.length && i >= changes[changeIndex + 1][0]) changeIndex++;
6072
6166
  const group = changes[changeIndex]?.[1] ?? "default";
6073
6167
  const syntaxStyle = colorscheme?.get(group) ?? colorscheme?.defaultStyle ?? {};
6074
- const preservebg = cursorLineBg != null && syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6075
- const baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6168
+ let preservebg = syntaxStyle.bg !== undefined && syntaxStyle.bg !== defBg;
6169
+ let baseStyle = (cursorLineBg && !preservebg) ? { ...syntaxStyle, bg: cursorLineBg } : syntaxStyle;
6076
6170
  while (searchIdx < searchRanges.length && searchRanges[searchIdx][1] <= i) searchIdx++;
6077
6171
  const inSearch = searchIdx < searchRanges.length && i >= searchRanges[searchIdx][0] && i < searchRanges[searchIdx][1];
6078
6172
  const selected = isSelected(selection, lineNo, i, i + charLen);
6079
6173
  const braceMatched = braceMatches?.has(String(lineNo) + ":" + String(i));
6080
- let style = (showTrailingWs && i >= trailingWsIdx) ? trailingWsStyle : baseStyle;
6174
+
6175
+ // tab-error in leading whitespace
6176
+ let style = baseStyle;
6177
+ const inLeading = i < leadingwsEnd;
6178
+ if (tabErrorFg != null && inLeading) {
6179
+ if ((tabstospaces && ch === "\t") || (!tabstospaces && ch === " ")) {
6180
+ style = { ...style, bg: tabErrorFg };
6181
+ preservebg = true;
6182
+ }
6183
+ }
6184
+ if (trailingWsColor != null && i >= trailingWsIdx) {
6185
+ style = { ...style, bg: trailingWsColor };
6186
+ preservebg = true;
6187
+ }
6081
6188
  if (inSearch) {
6082
6189
  const searchStyle = colorscheme?.styles?.has("hlsearch") ? colorscheme.get("hlsearch") : null;
6083
- style = searchStyle ?? { ...baseStyle, reverse: !baseStyle.reverse };
6190
+ style = searchStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6191
+ if ((style.bg ?? "default") !== defBg) preservebg = true;
6084
6192
  }
6085
6193
  if (braceMatched) {
6086
6194
  if ((buf.Settings?.matchbracestyle ?? DEFAULT_SETTINGS.matchbracestyle) === "highlight") {
@@ -6091,22 +6199,73 @@ function renderHighlightedCells(buf, lineNo, scrollX, maxWidth, colorscheme, sel
6091
6199
  }
6092
6200
  }
6093
6201
  if (selected) {
6094
- style = { ...style, reverse: !style.reverse };
6202
+ const selectionStyle = colorscheme?.styles?.has("selection") ? colorscheme.get("selection") : null;
6203
+ style = selectionStyle ?? { ...(colorscheme?.defaultStyle ?? {}), reverse: true };
6204
+ }
6205
+ if (lineMessages.length > 0 && inMessageAt(i)) {
6206
+ style = { ...style, underline: true };
6207
+ }
6208
+
6209
+ // Visualize whitespace
6210
+ let displayCh = ch;
6211
+ let useIndentCharFg = false;
6212
+ if (ch === " ") {
6213
+ // Go bufwindow.go:554-559: ispace only kicks in at tabsize boundaries (indent guide).
6214
+ const visualCol = scrollVisualCol + width;
6215
+ const useIspace = inLeading && indentspacechars && (visualCol % tabsize === 0);
6216
+ const candidate = useIspace ? indentspacechars : spacechars;
6217
+ if (candidate && candidate !== " ") {
6218
+ displayCh = candidate[0] ?? " ";
6219
+ useIndentCharFg = true;
6220
+ }
6221
+ } else if (ch === "\t") {
6222
+ const candidate = (inLeading && indenttabchars) ? indenttabchars : tabchars;
6223
+ if (candidate && candidate !== " ") {
6224
+ displayCh = candidate[0] ?? " ";
6225
+ useIndentCharFg = true;
6226
+ } else {
6227
+ displayCh = " ";
6228
+ }
6229
+ }
6230
+ if (useIndentCharFg && indentCharFg != null) {
6231
+ style = { ...style, fg: indentCharFg };
6095
6232
  }
6233
+
6234
+ const ccAt = (visualCol) => {
6235
+ if (colorColumnBg == null || preservebg) return null;
6236
+ return visualCol === colorcolumn ? colorColumnBg : null;
6237
+ };
6238
+
6096
6239
  if (ch === "\t") {
6097
- const spaces = Math.min(DEFAULT_SETTINGS.tabsize, maxWidth - width);
6098
- for (let j = 0; j < spaces; j++) cells.push({ ch: " ", style });
6099
- width += spaces;
6240
+ const spaces = Math.min(tabsize, maxWidth - width);
6241
+ for (let j = 0; j < spaces; j++) {
6242
+ const visualCol = scrollVisualCol + width;
6243
+ const cellCh = j === 0 ? displayCh : " ";
6244
+ const ccBg = ccAt(visualCol);
6245
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6246
+ cells.push({ ch: cellCh, style: cellStyle });
6247
+ width++;
6248
+ }
6100
6249
  } else if (w > 0 && width + w <= maxWidth) {
6101
- cells.push({ ch, style, width: w });
6250
+ const visualCol = scrollVisualCol + width;
6251
+ const ccBg = ccAt(visualCol);
6252
+ const cellStyle = ccBg != null ? { ...style, bg: ccBg } : style;
6253
+ cells.push({ ch: displayCh, style: cellStyle, width: w });
6102
6254
  width += w;
6103
6255
  }
6104
6256
  i += charLen;
6105
6257
  }
6106
- // Fill rest of line with cursorline background
6107
- if (cursorLineBg) {
6108
- const padStyle = { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg };
6109
- while (width < maxWidth) { cells.push({ ch: " ", style: padStyle }); width++; }
6258
+ // Trailing fill: cursor-line bg and color-column always apply (Go bufwindow.go:807-826)
6259
+ while (width < maxWidth) {
6260
+ const visualCol = scrollVisualCol + width;
6261
+ let padStyle = cursorLineBg
6262
+ ? { ...(colorscheme?.defaultStyle ?? {}), bg: cursorLineBg }
6263
+ : (colorscheme?.defaultStyle ?? {});
6264
+ if (colorColumnBg != null && visualCol === colorcolumn) {
6265
+ padStyle = { ...padStyle, bg: colorColumnBg };
6266
+ }
6267
+ cells.push({ ch: " ", style: padStyle });
6268
+ width++;
6110
6269
  }
6111
6270
  return cells;
6112
6271
  }
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Visual PTY demo for bunmicro.
4
+ *
5
+ * Child terminal output is forwarded directly to the current TTY.
6
+ * stdin is not forwarded; press q, Ctrl-Q, or Esc to stop the demo.
7
+ *
8
+ * Usage:
9
+ * bun tests/pty-demo.js
10
+ * bun tests/pty-demo.js --delay 700 --type-delay 45 --term-delay 1800
11
+ */
12
+
13
+ import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
14
+ import { join, resolve,dirname,basename } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ const isAnci = Bun.env.TMPK_HOME && Bun.which('ldcustom')
18
+
19
+ const ROOT = resolve(import.meta.dir, "..");
20
+ const BUNMICRO = join(ROOT, "src", "index.js");
21
+
22
+ const DEMO_ONE_TEXT = `
23
+
24
+ # BUNMICRO PTY DEMO
25
+ "Use q, Ctrl-Q, or Esc to stop this demo."
26
+ `;
27
+
28
+ const DEMO_TWO_LONG_LINE = "This deliberately long line demonstrates soft wrapping across the editor viewport without real newline characters. 中文段落用來測試寬字元、游標移動與自動換行是否正確,並確認每一個漢字都能完整顯示。日本語の文章では、ひらがな、カタカナ、漢字を混ぜて、表示幅と折り返し位置を確認します。さらに長い一行を維持したまま、画面端で自然に折り返される動作を丁寧に実演します。中文日本語🚀✨🧪確認。";
29
+ const DEMO_TWO_TEXT = [
30
+ "SECOND TAB",
31
+ DEMO_TWO_LONG_LINE,
32
+ "",
33
+ ].join("\n");
34
+ if ([...DEMO_TWO_LONG_LINE].length !== 250) throw new Error("demo two long line must be exactly 250 characters");
35
+ const options = {
36
+ delay: numberArg("--delay", 500),
37
+ typeDelay: numberArg("--type-delay", 100),
38
+ startupDelay: numberArg("--startup-delay", 5000),
39
+ termDelay: numberArg("--term-delay", 1400),
40
+ cols: numberArg("--cols", process.stdout.columns || 100),
41
+ rows: numberArg("--rows", process.stdout.rows || 30),
42
+ };
43
+
44
+ if (process.argv.includes("--help")) {
45
+ console.log(`Usage: bun tests/pty-demo.js [options]
46
+
47
+ Options:
48
+ --delay MS Delay after each action (default: ${options.delay})
49
+ --type-delay MS Delay between typed characters (default: ${options.typeDelay})
50
+ --startup-delay MS Initial editor startup delay (default: ${options.startupDelay})
51
+ --term-delay MS Extra delay for terminal pane startup (default: ${options.termDelay})
52
+ --cols N PTY columns (default: current TTY width)
53
+ --rows N PTY rows (default: current TTY height)
54
+
55
+ stdin is not forwarded to bunmicro. Press q, Ctrl-Q, or Esc to stop.`);
56
+ process.exit(0);
57
+ }
58
+
59
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
60
+ console.error("pty-demo requires stdin and stdout to be TTYs.");
61
+ process.exit(1);
62
+ }
63
+
64
+ let stopped = false;
65
+ let resolveStopped;
66
+ const stoppedPromise = new Promise((resolve) => { resolveStopped = resolve; });
67
+ let terminal = null;
68
+ let proc = null;
69
+ let temp = "";
70
+ let originalRaw = false;
71
+
72
+ function requestStop() {
73
+ if (stopped) return;
74
+ stopped = true;
75
+ resolveStopped();
76
+ }
77
+
78
+ function onInput(data) {
79
+ const bytes = Buffer.from(data);
80
+ if (bytes.includes(0x71) || bytes.includes(0x11) || bytes.includes(0x1b)) requestStop();
81
+ }
82
+
83
+ function onResize() {
84
+ if (!terminal || terminal.closed) return;
85
+ terminal.resize(process.stdout.columns || options.cols, process.stdout.rows || options.rows);
86
+ }
87
+
88
+ async function pause(ms = options.delay) {
89
+ if (stopped) throw new Error("demo stopped");
90
+ await Promise.race([Bun.sleep(ms), stoppedPromise]);
91
+ if (stopped) throw new Error("demo stopped");
92
+ }
93
+
94
+ function setTitle(title) {
95
+ process.stdout.write(`\x1b]0;bunmicro demo: ${title}\x07`);
96
+ }
97
+
98
+ async function send(data, wait = options.delay) {
99
+ if (stopped) throw new Error("demo stopped");
100
+ terminal.write(data);
101
+ await pause(wait);
102
+ }
103
+
104
+ async function type(text, wait = options.delay) {
105
+ for (const ch of text) {
106
+ if (stopped) throw new Error("demo stopped");
107
+ terminal.write(ch);
108
+ await pause(options.typeDelay);
109
+ }
110
+ await pause(wait);
111
+ }
112
+
113
+ async function paste(text, wait = options.delay) {
114
+ await send(`\x1b[200~${text}\x1b[201~`, wait);
115
+ }
116
+
117
+ async function action(title, fn, waitBefore = Math.min(250, options.delay)) {
118
+ setTitle(title);
119
+ await pause(waitBefore);
120
+ await fn();
121
+ }
122
+
123
+ async function command(value, wait = options.delay) {
124
+ await send("\x05", Math.min(350, options.delay)); // Ctrl-E
125
+ await type(`${value}\r`, wait);
126
+ }
127
+
128
+ function click(x, y, button = 0) {
129
+ return `\x1b[<${button};${x + 1};${y + 1}M\x1b[<${button};${x + 1};${y + 1}m`;
130
+ }
131
+
132
+ async function runDemo({ file2, unknownFile, redetectedFile, crlfShell, themeCount }) {
133
+ await action("bracketed paste JavaScript sample", async () => {
134
+ await send("\x1b[F", 150);
135
+ await paste([
136
+ "",
137
+ "alpha one",
138
+ "alpha two",
139
+ "foo bar foo",
140
+ "",
141
+ "async function loadProfile(userId) {",
142
+ " const enabled = true;",
143
+ " const retries = 3;",
144
+ ' const greeting = "hello from bunmicro";',
145
+ " const tags = [\"editor\", \"javascript\", \"demo\"];",
146
+ " const profile = { userId, enabled, retries, tags };",
147
+ " await new Promise((resolve) => setTimeout(resolve, 250));",
148
+ " if (!profile.enabled) throw new Error(\"disabled profile\");",
149
+ " return { ...profile, greeting };",
150
+ "}",
151
+ "",
152
+ "loadProfile(42).then(console.log).catch(console.error);",
153
+ "mouse target",
154
+ ].join("\n"), 1000);
155
+ });
156
+
157
+ await action("cursor movement and edit", async () => {
158
+ await send("\x1b[A\x1b[A\x1b[H", 200);
159
+ await type("[edited] ");
160
+ await send("\x1b[F", 150);
161
+ await type(" !");
162
+ });
163
+
164
+ await action("undo and redo", async () => {
165
+ await send("\x1a", 350);
166
+ await send("\x19", 500);
167
+ });
168
+
169
+ await action("set filetype applies syntax instantly", async () => {
170
+ await command("set filetype javascript", 1100);
171
+ });
172
+
173
+ await action("theme picker: preview every theme with Down", async () => {
174
+ await send("\x05", 250); // Ctrl-E
175
+ await type("theme");
176
+ setTitle("theme picker: Space opens theme completions");
177
+ await send(" ", 350);
178
+ setTitle("theme picker: Tab previews first theme");
179
+ await send("\t", 850);
180
+ for (let i = 1; i < themeCount; i++) {
181
+ setTitle(`theme picker: Down ${i}/${themeCount - 1}`);
182
+ await send("\x1b[B", 650);
183
+ }
184
+ setTitle("theme picker: reached final theme");
185
+ await pause(1000);
186
+ await send("\x1b", 500); // Cancel preview and restore original theme
187
+ });
188
+
189
+ await action("select fixed theme: dracula-tc", async () => {
190
+ await command("theme darcula", 1000);
191
+ });
192
+
193
+ await action("find alpha", async () => {
194
+ await send("\x06", 300);
195
+ await type("alpha");
196
+ await send("\r", 500);
197
+ await send("\x0e", 500);
198
+ });
199
+
200
+ await action("interactive replace foo", async () => {
201
+ await send("\x1bh", 300);
202
+ await type("foo FIRST");
203
+ await send("\r", 700);
204
+ setTitle("interactive replace: accept first match");
205
+ await send("y", 700);
206
+ setTitle("interactive replace: skip second match");
207
+ await send("n", 700);
208
+ });
209
+
210
+ await action("replace all alpha", async () => {
211
+ await command("replaceall alpha 阿爾法", 650);
212
+ });
213
+
214
+ await action("show whitespace and trailing spaces", async () => {
215
+ await command("set showchars tab=>,space=.", 450);
216
+ await command("set hltrailingws true", 450);
217
+ await command("set colorcolumn 30", 600);
218
+ await command("set hltaberrors true", 450);
219
+ await command("set tabstospaces true", 850);
220
+ await send("\x1b[F", 120);
221
+ await type(" ");
222
+ });
223
+
224
+ await action("save", async () => {
225
+ await send("\x13", 650);
226
+ });
227
+
228
+ await action("unknown filetype saves as .js and redetects", async () => {
229
+ await command(`open ${unknownFile}`, 850);
230
+ await command(`save ${redetectedFile}`, 1200);
231
+ });
232
+
233
+ await action("select all and eval js", async () => {
234
+ await send("\x01", 900); // Ctrl-A
235
+
236
+ if(isAnci)
237
+ await command("js Object.getOwnPropertyNames(''.__proto__)", 2500);
238
+ else
239
+ await command("eval js", 2500);
240
+ setTitle("eval js result: press Enter to continue");
241
+ await send("\r", 1000);
242
+ });
243
+
244
+ await action("DOS CRLF shell script warning", async () => {
245
+ await command(`open ${crlfShell}`, 1200);
246
+ });
247
+
248
+ await action("open second file in another tab", async () => {
249
+ await command(`open ${file2}`, 800);
250
+ });
251
+
252
+ await action("enable softwrap for long line", async () => {
253
+ await command("set softwrap on", 1200);
254
+ });
255
+
256
+ await action("previous and next tab", async () => {
257
+ await send("\x1bp", 650);
258
+ await send("\x1bt", 650);
259
+ });
260
+
261
+ await action("new scratch tab", async () => {
262
+ await send("\x14", 600);
263
+ await type("scratch tab\ncreated by PTY demo");
264
+ });
265
+
266
+ await action("mouse click unsaved star opens save command", async () => {
267
+ await send(click(9, options.rows - 1), 750);
268
+ await send("\x1b", 500);
269
+ });
270
+
271
+ await action("mouse command and shell icons toggle prompts", async () => {
272
+ await send(click(25, options.rows - 1), 650); // € opens command prompt
273
+ await send(click(25, options.rows - 2), 650); // € closes command prompt
274
+ await send(click(32, options.rows - 1), 650); // $ opens shell prompt
275
+ await send(click(32, options.rows - 2), 650); // $ closes shell prompt
276
+ });
277
+
278
+ await action("mouse click first tab", async () => {
279
+ await send(click(2, 0), 800);
280
+ });
281
+
282
+ await action("vertical split and pane switch", async () => {
283
+ await command("vsplit", 800);
284
+ await send("\x17", 650);
285
+ });
286
+
287
+ await action("mouse click editor pane", async () => {
288
+ await send(click(Math.floor(options.cols * 0.75), 5), 700);
289
+ });
290
+
291
+ await action("horizontal split and pane switch", async () => {
292
+ await command("hsplit", 800);
293
+ await send("\x17", 650);
294
+ });
295
+
296
+ await action("Ctrl-B interactive shell", async () => {
297
+ await send("\x02", 350); // Ctrl-B
298
+ await type("echo BUNMICRO_CTRL_B_SHELL");
299
+ await send("\r", options.termDelay);
300
+ setTitle("Ctrl-B shell: press Enter to return");
301
+ await send("\r", 900);
302
+ });
303
+
304
+ await action("open terminal pane", async () => {
305
+ await command("term", options.termDelay);
306
+ await type("printf 'BUNMICRO_TERM_DEMO\\n'\r", options.termDelay);
307
+ await send("\x1b", 800);
308
+ });
309
+
310
+ await action("mouse wheel and editor click", async () => {
311
+ await send(click(10, 8, 65), 500);
312
+ await send(click(10, 8, 64), 500);
313
+ await send(click(12, 6), 700);
314
+ });
315
+
316
+ await action("final tab switching", async () => {
317
+ await send("\x1bp", 600);
318
+ await send("\x1bt", 900);
319
+ });
320
+ }
321
+
322
+ async function cleanup() {
323
+ process.stdin.off("data", onInput);
324
+ process.stdout.off("resize", onResize);
325
+
326
+ if (proc?.exitCode === null) {
327
+ // Let bunmicro restore the terminal itself first. Escape closes a possible
328
+ // terminal pane; repeated Ctrl-Q/n handles panes, tabs, and save prompts.
329
+ terminal?.write("\x1b");
330
+ await Bun.sleep(250);
331
+ for (let i = 0; i < 12 && proc.exitCode === null; i++) {
332
+ terminal?.write("\x11"); // Ctrl-Q
333
+ await Bun.sleep(180);
334
+ terminal?.write("n");
335
+ await Bun.sleep(120);
336
+ }
337
+ await Promise.race([proc.exited, Bun.sleep(700)]);
338
+ }
339
+ if (proc?.exitCode === null) {
340
+ proc.kill();
341
+ await Promise.race([proc.exited, Bun.sleep(500)]);
342
+ }
343
+ if (terminal && !terminal.closed) terminal.close();
344
+
345
+
346
+ let udroot="";
347
+
348
+ if(isAnci)
349
+ {
350
+ udroot=join(Bun.env.HOME,'.udocker/containers/alpine-toolbox/ROOT')
351
+ }
352
+
353
+ if (temp) await rm(udroot+temp, { recursive: true, force: true });
354
+
355
+
356
+ // Defensive reset in case the child was killed before emitting its teardown.
357
+ process.stdout.write([
358
+ "\x1b[?1000l", // normal mouse tracking
359
+ "\x1b[?1001l", // highlight mouse tracking
360
+ "\x1b[?1002l", // button-event mouse tracking
361
+ "\x1b[?1003l", // any-event mouse tracking
362
+ "\x1b[?1004l", // focus events
363
+ "\x1b[?1005l", // UTF-8 mouse encoding
364
+ "\x1b[?1006l", // SGR mouse encoding
365
+ "\x1b[?1007l", // alternate scroll mode
366
+ "\x1b[?1015l", // urxvt mouse encoding
367
+ "\x1b[?1016l", // SGR pixel mouse encoding
368
+ "\x1b[?2004l", // bracketed paste
369
+ "\x1b[?2026l", // synchronized output
370
+ "\x1b[>4;0m", // xterm modifyOtherKeys
371
+ "\x1b[<u", // pop kitty keyboard protocol
372
+ "\x1b[0m",
373
+ "\x1b[?25h",
374
+ "\x1b[?1049l", // leave alternate screen last
375
+ ].join(""));
376
+ if (process.stdin.isTTY) process.stdin.setRawMode(originalRaw);
377
+ process.stdin.pause();
378
+ setTitle(stopped ? "stopped" : "complete");
379
+ process.stdout.write("\n");
380
+ }
381
+
382
+ async function main() {
383
+
384
+ let udroot="";
385
+ if(isAnci)
386
+ {
387
+ await Bun.spawn(['fish','-c echo Using fish shell!'],{env:Bun.env}).exited
388
+
389
+ temp = '/tmp/bunmicro-pty-demo-'+Bun.randomUUIDv7().slice(0,8);
390
+ udroot=join(Bun.env.HOME,'.udocker/containers/alpine-toolbox/ROOT')
391
+ }
392
+ else
393
+ temp = await mkdtemp(join(tmpdir(), "bunmicro-pty-demo-"));
394
+
395
+ const configDir = join(temp, "config");
396
+ const file1 = join(temp, "pty-demo-one.txt");
397
+ const file2 = join(temp, "pty-demo-two.txt");
398
+ const unknownFile = join(temp, "pty-demo-redetect");
399
+ const redetectedFile = join(temp, "pty-demo-redetected.js");
400
+ const crlfShell = join(temp, "pty-demo-crlf.sh");
401
+ const syntaxDir = join(configDir, "syntax");
402
+ const themeCount = (await readdir(join(ROOT, "runtime", "colorschemes")))
403
+ .filter((name) => name.endsWith(".micro")).length;
404
+ await mkdir(udroot+configDir, { recursive: true });
405
+ await mkdir(udroot+syntaxDir, { recursive: true });
406
+ await Bun.write(join(udroot,configDir, "settings.json"), JSON.stringify({
407
+ colorscheme: "default",
408
+ mouse: true,
409
+ savecursor: false,
410
+ savehistory: false,
411
+ }, null, 2));
412
+
413
+ await Bun.write(udroot+file1, DEMO_ONE_TEXT);
414
+ await Bun.write(udroot+file2, DEMO_TWO_TEXT);
415
+ await Bun.write(udroot+unknownFile, `
416
+ async function hello(){
417
+ const redetected = 'yes';
418
+ console.log(
419
+ Object.getOwnPropertyNames(
420
+ redetected.__proto__
421
+ )
422
+ );
423
+ }
424
+
425
+ await hello();
426
+ `);
427
+ await Bun.write(udroot+crlfShell, "#!/bin/sh\r\necho CRLF_SHELL_WARNING\r\n");
428
+ await Bun.write(join(udroot,syntaxDir, "javascript.yaml"), "filetype: javascript\nrules: [invalid yaml");
429
+
430
+ console.log([
431
+ "NOTE: The upcoming JavaScript YAML parse failure is intentional.",
432
+ "It demonstrates fallback to bunmicro's built-in JavaScript syntax.",
433
+ ].join("\n"));
434
+
435
+ originalRaw = process.stdin.isRaw ?? false;
436
+ process.stdin.setRawMode(true);
437
+ process.stdin.resume();
438
+ process.stdin.on("data", onInput);
439
+ process.stdout.on("resize", onResize);
440
+
441
+ terminal = new Bun.Terminal({
442
+ cols: options.cols,
443
+ rows: options.rows,
444
+ data(_terminal, data) {
445
+ process.stdout.write(data);
446
+ },
447
+ });
448
+
449
+ let bunArr
450
+ if(isAnci)
451
+ {
452
+ bunArr=['fish','-c',`/android/bin/ldcustom --library-path /android/glibc:/android/bun /android/bun/bun-linux-aarch64 $argv`,'--']
453
+ }
454
+ else
455
+ bunArr=['bun'];
456
+
457
+ proc = Bun.spawn({
458
+ cmd: [...bunArr, BUNMICRO, "-config-dir", configDir, file1],
459
+ cwd: ROOT,
460
+ terminal,
461
+ env: {
462
+ ...process.env,
463
+ TERM: process.env.TERM || "xterm-256color",
464
+ COLORTERM: process.env.COLORTERM || "truecolor",
465
+ COLUMNS: String(options.cols),
466
+ LINES: String(options.rows),
467
+ },
468
+ });
469
+
470
+ setTitle("startup - press q to stop");
471
+ await pause(options.startupDelay);
472
+ await runDemo({ file2, unknownFile, redetectedFile, crlfShell, themeCount });
473
+ await pause(1000);
474
+ }
475
+
476
+ try {
477
+ await main();
478
+ } catch (error) {
479
+ if (!stopped) throw error;
480
+ } finally {
481
+ await cleanup();
482
+ }
483
+
484
+ function numberArg(name, fallback) {
485
+ const eqArg = process.argv.find((arg) => arg.startsWith(`${name}=`));
486
+ const index = process.argv.indexOf(name);
487
+ const raw = eqArg?.slice(name.length + 1) ?? (index >= 0 ? process.argv[index + 1] : undefined);
488
+ if (raw === undefined) return fallback;
489
+ const value = Number(raw);
490
+ if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
491
+ return Math.floor(value);
492
+ }