@flyingrobots/bijou-tui 3.1.0 → 4.1.0

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.
Files changed (158) hide show
  1. package/LICENSE +159 -21
  2. package/README.md +205 -39
  3. package/dist/app-frame-actions.d.ts +5 -5
  4. package/dist/app-frame-actions.d.ts.map +1 -1
  5. package/dist/app-frame-actions.js +74 -9
  6. package/dist/app-frame-actions.js.map +1 -1
  7. package/dist/app-frame-i18n.d.ts +12 -0
  8. package/dist/app-frame-i18n.d.ts.map +1 -0
  9. package/dist/app-frame-i18n.js +92 -0
  10. package/dist/app-frame-i18n.js.map +1 -0
  11. package/dist/app-frame-layers.d.ts +29 -0
  12. package/dist/app-frame-layers.d.ts.map +1 -0
  13. package/dist/app-frame-layers.js +104 -0
  14. package/dist/app-frame-layers.js.map +1 -0
  15. package/dist/app-frame-palette.d.ts +6 -2
  16. package/dist/app-frame-palette.d.ts.map +1 -1
  17. package/dist/app-frame-palette.js +42 -10
  18. package/dist/app-frame-palette.js.map +1 -1
  19. package/dist/app-frame-render.d.ts +28 -14
  20. package/dist/app-frame-render.d.ts.map +1 -1
  21. package/dist/app-frame-render.js +285 -138
  22. package/dist/app-frame-render.js.map +1 -1
  23. package/dist/app-frame-types.d.ts +41 -21
  24. package/dist/app-frame-types.d.ts.map +1 -1
  25. package/dist/app-frame-types.js +8 -6
  26. package/dist/app-frame-types.js.map +1 -1
  27. package/dist/app-frame-utils.d.ts +8 -3
  28. package/dist/app-frame-utils.d.ts.map +1 -1
  29. package/dist/app-frame-utils.js +42 -27
  30. package/dist/app-frame-utils.js.map +1 -1
  31. package/dist/app-frame.d.ts +128 -12
  32. package/dist/app-frame.d.ts.map +1 -1
  33. package/dist/app-frame.js +1212 -91
  34. package/dist/app-frame.js.map +1 -1
  35. package/dist/browsable-list.d.ts +20 -1
  36. package/dist/browsable-list.d.ts.map +1 -1
  37. package/dist/browsable-list.js +48 -10
  38. package/dist/browsable-list.js.map +1 -1
  39. package/dist/collection-surface.d.ts +8 -0
  40. package/dist/collection-surface.d.ts.map +1 -0
  41. package/dist/collection-surface.js +41 -0
  42. package/dist/collection-surface.js.map +1 -0
  43. package/dist/command-palette.d.ts +17 -1
  44. package/dist/command-palette.d.ts.map +1 -1
  45. package/dist/command-palette.js +50 -20
  46. package/dist/command-palette.js.map +1 -1
  47. package/dist/commands.js +1 -1
  48. package/dist/commands.js.map +1 -1
  49. package/dist/css/text-style.d.ts +2 -1
  50. package/dist/css/text-style.d.ts.map +1 -1
  51. package/dist/css/text-style.js +33 -0
  52. package/dist/css/text-style.js.map +1 -1
  53. package/dist/design-language.d.ts +49 -0
  54. package/dist/design-language.d.ts.map +1 -0
  55. package/dist/design-language.js +70 -0
  56. package/dist/design-language.js.map +1 -0
  57. package/dist/driver.d.ts +6 -1
  58. package/dist/driver.d.ts.map +1 -1
  59. package/dist/driver.js +3 -0
  60. package/dist/driver.js.map +1 -1
  61. package/dist/eventbus.d.ts.map +1 -1
  62. package/dist/eventbus.js +21 -1
  63. package/dist/eventbus.js.map +1 -1
  64. package/dist/file-picker.d.ts +19 -1
  65. package/dist/file-picker.d.ts.map +1 -1
  66. package/dist/file-picker.js +47 -20
  67. package/dist/file-picker.js.map +1 -1
  68. package/dist/flex.d.ts +35 -1
  69. package/dist/flex.d.ts.map +1 -1
  70. package/dist/flex.js +127 -1
  71. package/dist/flex.js.map +1 -1
  72. package/dist/focus-area.d.ts +20 -1
  73. package/dist/focus-area.d.ts.map +1 -1
  74. package/dist/focus-area.js +107 -12
  75. package/dist/focus-area.js.map +1 -1
  76. package/dist/grid.d.ts +10 -0
  77. package/dist/grid.d.ts.map +1 -1
  78. package/dist/grid.js +25 -0
  79. package/dist/grid.js.map +1 -1
  80. package/dist/help.d.ts +41 -0
  81. package/dist/help.d.ts.map +1 -1
  82. package/dist/help.js +50 -0
  83. package/dist/help.js.map +1 -1
  84. package/dist/index.d.ts +23 -17
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +22 -16
  87. package/dist/index.js.map +1 -1
  88. package/dist/inspector-drawer.d.ts +36 -0
  89. package/dist/inspector-drawer.d.ts.map +1 -0
  90. package/dist/inspector-drawer.js +68 -0
  91. package/dist/inspector-drawer.js.map +1 -0
  92. package/dist/layout-node-surface.d.ts +17 -0
  93. package/dist/layout-node-surface.d.ts.map +1 -0
  94. package/dist/layout-node-surface.js +61 -0
  95. package/dist/layout-node-surface.js.map +1 -0
  96. package/dist/navigable-table.d.ts +16 -1
  97. package/dist/navigable-table.d.ts.map +1 -1
  98. package/dist/navigable-table.js +32 -1
  99. package/dist/navigable-table.js.map +1 -1
  100. package/dist/notification.d.ts +26 -1
  101. package/dist/notification.d.ts.map +1 -1
  102. package/dist/notification.js +294 -41
  103. package/dist/notification.js.map +1 -1
  104. package/dist/overlay.d.ts +29 -8
  105. package/dist/overlay.d.ts.map +1 -1
  106. package/dist/overlay.js +248 -137
  107. package/dist/overlay.js.map +1 -1
  108. package/dist/pager.d.ts +16 -0
  109. package/dist/pager.d.ts.map +1 -1
  110. package/dist/pager.js +61 -1
  111. package/dist/pager.js.map +1 -1
  112. package/dist/runtime-engine.d.ts +223 -0
  113. package/dist/runtime-engine.d.ts.map +1 -0
  114. package/dist/runtime-engine.js +457 -0
  115. package/dist/runtime-engine.js.map +1 -0
  116. package/dist/runtime.d.ts.map +1 -1
  117. package/dist/runtime.js +33 -19
  118. package/dist/runtime.js.map +1 -1
  119. package/dist/shell-quit.d.ts +12 -0
  120. package/dist/shell-quit.d.ts.map +1 -0
  121. package/dist/shell-quit.js +36 -0
  122. package/dist/shell-quit.js.map +1 -0
  123. package/dist/split-pane.d.ts +12 -1
  124. package/dist/split-pane.d.ts.map +1 -1
  125. package/dist/split-pane.js +31 -1
  126. package/dist/split-pane.js.map +1 -1
  127. package/dist/status-bar.d.ts +12 -0
  128. package/dist/status-bar.d.ts.map +1 -1
  129. package/dist/status-bar.js +45 -16
  130. package/dist/status-bar.js.map +1 -1
  131. package/dist/subapp/mount.d.ts.map +1 -1
  132. package/dist/subapp/mount.js +3 -0
  133. package/dist/subapp/mount.js.map +1 -1
  134. package/dist/surface-layout.d.ts +19 -0
  135. package/dist/surface-layout.d.ts.map +1 -0
  136. package/dist/surface-layout.js +87 -0
  137. package/dist/surface-layout.js.map +1 -0
  138. package/dist/transition-shaders.d.ts +10 -8
  139. package/dist/transition-shaders.d.ts.map +1 -1
  140. package/dist/transition-shaders.js +65 -19
  141. package/dist/transition-shaders.js.map +1 -1
  142. package/dist/types.d.ts +21 -7
  143. package/dist/types.d.ts.map +1 -1
  144. package/dist/types.js +11 -0
  145. package/dist/types.js.map +1 -1
  146. package/dist/view-output.d.ts +5 -4
  147. package/dist/view-output.d.ts.map +1 -1
  148. package/dist/view-output.js +37 -29
  149. package/dist/view-output.js.map +1 -1
  150. package/dist/viewport.d.ts +30 -1
  151. package/dist/viewport.d.ts.map +1 -1
  152. package/dist/viewport.js +77 -1
  153. package/dist/viewport.js.map +1 -1
  154. package/package.json +6 -3
  155. package/dist/layout-v3.d.ts +0 -10
  156. package/dist/layout-v3.d.ts.map +0 -1
  157. package/dist/layout-v3.js +0 -35
  158. package/dist/layout-v3.js.map +0 -1
@@ -2,56 +2,160 @@
2
2
  * Layout rendering for `app-frame.ts`.
3
3
  *
4
4
  * Recursive tree renderer, page content, maximized pane, header/help lines,
5
- * transition shader, and string-to-grid tokenization.
5
+ * transition shader, and surface/string bridge helpers.
6
6
  */
7
- import { resolveSafeCtx, surfaceToString } from '@flyingrobots/bijou';
7
+ import { createSurface, darken, hexToRgb, lighten, mix, parseAnsiToSurface, saturate, resolveSafeCtx, } from '@flyingrobots/bijou';
8
8
  import { TRANSITION_SHADERS } from './transition-shaders.js';
9
9
  import { fitBlock } from './layout-utils.js';
10
- import { splitPane, splitPaneLayout } from './split-pane.js';
11
- import { grid, gridLayout } from './grid.js';
12
- import { createFocusAreaState, focusArea, focusAreaScrollTo, focusAreaScrollToX, } from './focus-area.js';
10
+ import { splitPaneLayout } from './split-pane.js';
11
+ import { gridLayout } from './grid.js';
12
+ import { createFocusAreaStateForSurface, focusAreaSurfaceInto, focusAreaScrollTo, focusAreaScrollToX, } from './focus-area.js';
13
13
  import { isMinimized, createPanelVisibilityState } from './panel-state.js';
14
14
  import { createPanelDockState, resolveChildOrder, getNodeId } from './panel-dock.js';
15
- import { tokenizeAnsi } from './viewport.js';
15
+ import { visibleLength } from './viewport.js';
16
16
  import { helpShort } from './help.js';
17
- import { findPaneNode, isPaneMinimized, mergeMaps, offsetRect, fitLine, mergeBindingSources, } from './app-frame-utils.js';
18
- import { normalizeViewOutput } from './view-output.js';
19
- import { styleTextWithBCSS } from './css/text-style.js';
17
+ import { findPaneNode, isPaneMinimized, mergeMaps, offsetRect, fitLine, } from './app-frame-utils.js';
18
+ import { normalizeViewOutput, normalizeViewOutputInto } from './view-output.js';
19
+ import { createStyledTextSurfaceWithBCSS } from './css/text-style.js';
20
+ import { frameModeLabel } from './app-frame-i18n.js';
21
+ const framePaneScratchBySize = new Map();
22
+ function relativeLuminance(hex) {
23
+ const [red, green, blue] = hexToRgb(hex);
24
+ const r = srgbChannelToLinear(red);
25
+ const g = srgbChannelToLinear(green);
26
+ const b = srgbChannelToLinear(blue);
27
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
28
+ }
29
+ function srgbChannelToLinear(channel) {
30
+ const normalized = channel / 255;
31
+ return normalized <= 0.03928
32
+ ? normalized / 12.92
33
+ : ((normalized + 0.055) / 1.055) ** 2.4;
34
+ }
35
+ function contrastRatio(a, b) {
36
+ const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
37
+ const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
38
+ return (lighter + 0.05) / (darker + 0.05);
39
+ }
40
+ function colorDistance(a, b) {
41
+ const [ar, ag, ab] = hexToRgb(a);
42
+ const [br, bg, bb] = hexToRgb(b);
43
+ return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
44
+ }
45
+ function deriveActiveHeaderTabToken(ctx, backgroundHex, baseHex) {
46
+ const darkBackground = relativeLuminance(backgroundHex) < 0.35;
47
+ const accent = ctx.semantic('accent');
48
+ const info = ctx.semantic('info');
49
+ const primary = ctx.semantic('primary');
50
+ const warning = ctx.semantic('warning');
51
+ const seeds = [
52
+ accent,
53
+ info,
54
+ mix(accent, info, 0.5),
55
+ mix(accent, warning, 0.3),
56
+ mix(primary, accent, 0.3),
57
+ ];
58
+ const candidates = seeds.flatMap((seed) => {
59
+ const emphasized = saturate(seed, 0.35);
60
+ return darkBackground
61
+ ? [
62
+ emphasized,
63
+ lighten(seed, 0.18),
64
+ lighten(emphasized, 0.3),
65
+ lighten(mix(seed, primary, 0.25), 0.2),
66
+ ]
67
+ : [
68
+ darken(seed, 0.18),
69
+ darken(emphasized, 0.28),
70
+ darken(mix(seed, primary, 0.1), 0.22),
71
+ ];
72
+ });
73
+ let best = candidates[0] ?? accent;
74
+ let bestScore = Number.NEGATIVE_INFINITY;
75
+ for (const candidate of candidates) {
76
+ const contrast = contrastRatio(candidate.hex, backgroundHex);
77
+ const distance = colorDistance(candidate.hex, baseHex) / Math.sqrt(3 * 255 * 255);
78
+ const score = contrast * 3 + distance;
79
+ if (score > bestScore) {
80
+ bestScore = score;
81
+ best = candidate;
82
+ }
83
+ }
84
+ return {
85
+ hex: best.hex,
86
+ modifiers: ['bold'],
87
+ };
88
+ }
89
+ function paintActiveHeaderTab(surface, tabTargets, activePageId, ctx, tokenOverride) {
90
+ if (ctx == null)
91
+ return;
92
+ const activeTarget = tabTargets.find((target) => target.pageId === activePageId);
93
+ if (activeTarget == null)
94
+ return;
95
+ const sampleCell = surface.get(activeTarget.startCol, 0);
96
+ const backgroundHex = sampleCell.bg
97
+ ?? ctx.surface('primary').bg
98
+ ?? ctx.surface('secondary').bg
99
+ ?? '#000000';
100
+ const baseHex = sampleCell.fg
101
+ ?? ctx.surface('primary').hex
102
+ ?? ctx.semantic('primary').hex;
103
+ const token = tokenOverride ?? deriveActiveHeaderTabToken(ctx, backgroundHex, baseHex);
104
+ for (let x = activeTarget.startCol; x <= activeTarget.endCol; x++) {
105
+ const cell = surface.get(x, 0);
106
+ surface.set(x, 0, {
107
+ ...cell,
108
+ fg: token.hex,
109
+ bg: token.bg ?? cell.bg,
110
+ modifiers: token.modifiers == null
111
+ ? cell.modifiers
112
+ : Array.from(new Set([...(cell.modifiers ?? []), ...token.modifiers])),
113
+ empty: false,
114
+ });
115
+ }
116
+ }
20
117
  /** Recursively render a layout tree node (pane, split, or grid) into a rect. */
21
118
  export function renderFrameNode(node, rect, ctx) {
22
119
  if (rect.width <= 0 || rect.height <= 0) {
23
- return { output: '', paneRects: new Map(), paneOrder: [] };
120
+ return { surface: createSurface(rect.width, rect.height), paneRects: new Map(), paneOrder: [] };
121
+ }
122
+ const surface = createSurface(rect.width, rect.height);
123
+ const painted = paintFrameNodeInto(node, { row: 0, col: 0, width: rect.width, height: rect.height }, rect, ctx, surface);
124
+ return { surface, paneRects: painted.paneRects, paneOrder: painted.paneOrder };
125
+ }
126
+ function paintFrameNodeInto(node, localRect, absoluteRect, ctx, target) {
127
+ if (localRect.width <= 0 || localRect.height <= 0) {
128
+ return { paneRects: new Map(), paneOrder: [] };
24
129
  }
25
130
  if (node.kind === 'pane') {
26
131
  // Minimized pane: render as collapsed title bar
27
132
  if (isMinimized(ctx.visibility, node.paneId)) {
28
133
  const titleBar = `[${node.paneId}] \u25b8`; // ▸
29
- const output = fitBlock(titleBar, rect.width, rect.height).join('\n');
134
+ target.blit(blockSurface(titleBar, localRect.width, localRect.height), localRect.col, localRect.row);
30
135
  return {
31
- output,
32
- paneRects: new Map([[node.paneId, rect]]),
136
+ paneRects: new Map([[node.paneId, absoluteRect]]),
33
137
  paneOrder: [node.paneId],
34
138
  };
35
139
  }
36
140
  const prior = ctx.scrollByPane[node.paneId] ?? { x: 0, y: 0 };
37
- const content = framePaneOutputToString(node.render(rect.width, rect.height), rect.width, rect.height);
38
- let state = createFocusAreaState({
39
- content,
40
- width: rect.width,
41
- height: rect.height,
141
+ const contentSurface = framePaneOutputToSurface(node.render(localRect.width, localRect.height), localRect.width, localRect.height, getFramePaneScratch(localRect.width, localRect.height));
142
+ let state = createFocusAreaStateForSurface(contentSurface, {
143
+ width: localRect.width,
144
+ height: localRect.height,
42
145
  overflowX: node.overflowX ?? 'hidden',
43
146
  });
44
147
  state = focusAreaScrollTo(state, prior.y);
45
148
  state = focusAreaScrollToX(state, prior.x);
46
- const output = focusArea(state, {
149
+ focusAreaSurfaceInto(contentSurface, state, target, {
47
150
  focused: node.paneId === ctx.focusedPaneId,
48
151
  ctx: resolveSafeCtx(),
49
152
  id: node.paneId,
50
153
  classes: [node.paneId === ctx.focusedPaneId ? 'focused' : 'unfocused'],
51
- });
154
+ focusedGutterToken: node.focusedGutterToken,
155
+ unfocusedGutterToken: node.unfocusedGutterToken,
156
+ }, localRect.col, localRect.row);
52
157
  return {
53
- output,
54
- paneRects: new Map([[node.paneId, rect]]),
158
+ paneRects: new Map([[node.paneId, absoluteRect]]),
55
159
  paneOrder: [node.paneId],
56
160
  };
57
161
  }
@@ -74,75 +178,56 @@ export function renderFrameNode(node, rect, ctx) {
74
178
  }
75
179
  if (aMinimized && !bMinimized) {
76
180
  // A is minimized: give it minimal space
77
- const mainAxisTotal = direction === 'row' ? rect.width : rect.height;
181
+ const mainAxisTotal = direction === 'row' ? localRect.width : localRect.height;
78
182
  const minimizedSize = Math.min(1, mainAxisTotal);
79
183
  splitState = { ...splitState, ratio: minimizedSize / Math.max(1, mainAxisTotal) };
80
184
  }
81
185
  else if (bMinimized && !aMinimized) {
82
186
  // B is minimized: give A most of the space
83
- const mainAxisTotal = direction === 'row' ? rect.width : rect.height;
187
+ const mainAxisTotal = direction === 'row' ? localRect.width : localRect.height;
84
188
  const minimizedSize = Math.min(1, mainAxisTotal);
85
189
  splitState = { ...splitState, ratio: 1 - minimizedSize / Math.max(1, mainAxisTotal) };
86
190
  }
87
191
  const layout = splitPaneLayout(splitState, {
88
192
  direction,
89
- width: rect.width,
90
- height: rect.height,
91
- minA: node.minA,
92
- minB: node.minB,
93
- });
94
- const aRect = offsetRect(layout.paneA, rect.row, rect.col);
95
- const bRect = offsetRect(layout.paneB, rect.row, rect.col);
96
- const a = renderFrameNode(effectiveA, aRect, ctx);
97
- const b = renderFrameNode(effectiveB, bRect, ctx);
98
- const output = splitPane(splitState, {
99
- direction,
100
- width: rect.width,
101
- height: rect.height,
193
+ width: localRect.width,
194
+ height: localRect.height,
102
195
  minA: node.minA,
103
196
  minB: node.minB,
104
- dividerChar: node.dividerChar,
105
- paneA: () => a.output,
106
- paneB: () => b.output,
107
197
  });
198
+ const localARect = offsetRect(layout.paneA, localRect.row, localRect.col);
199
+ const localBRect = offsetRect(layout.paneB, localRect.row, localRect.col);
200
+ const absoluteARect = offsetRect(layout.paneA, absoluteRect.row, absoluteRect.col);
201
+ const absoluteBRect = offsetRect(layout.paneB, absoluteRect.row, absoluteRect.col);
202
+ const a = paintFrameNodeInto(effectiveA, localARect, absoluteARect, ctx, target);
203
+ const b = paintFrameNodeInto(effectiveB, localBRect, absoluteBRect, ctx, target);
204
+ paintDivider(target, offsetRect(layout.divider, localRect.row, localRect.col), node.dividerChar, direction);
108
205
  return {
109
- output,
110
206
  paneRects: mergeMaps(a.paneRects, b.paneRects),
111
207
  paneOrder: [...a.paneOrder, ...b.paneOrder],
112
208
  };
113
209
  }
114
210
  const relRects = gridLayout({
115
- width: rect.width,
116
- height: rect.height,
211
+ width: localRect.width,
212
+ height: localRect.height,
117
213
  columns: node.columns,
118
214
  rows: node.rows,
119
215
  areas: node.areas,
120
216
  gap: node.gap,
121
217
  });
122
- const renderedByArea = new Map();
218
+ let paneRects = new Map();
219
+ const seenPaneIds = new Set();
220
+ const paneOrder = [];
123
221
  for (const [areaName, areaRect] of relRects) {
124
- const absoluteAreaRect = offsetRect(areaRect, rect.row, rect.col);
222
+ const localAreaRect = offsetRect(areaRect, localRect.row, localRect.col);
223
+ const absoluteAreaRect = offsetRect(areaRect, absoluteRect.row, absoluteRect.col);
125
224
  const child = node.cells[areaName];
126
225
  if (child == null) {
127
226
  resolveSafeCtx()?.io.writeError(`createFramedApp: grid cell "${areaName}" missing in page "${ctx.pageId}" — rendering placeholder\n`);
128
- renderedByArea.set(areaName, renderMissingGridCell(areaName, absoluteAreaRect));
227
+ target.blit(blockSurface(`[missing grid cell: ${areaName}]`, localAreaRect.width, localAreaRect.height), localAreaRect.col, localAreaRect.row);
129
228
  continue;
130
229
  }
131
- renderedByArea.set(areaName, renderFrameNode(child, absoluteAreaRect, ctx));
132
- }
133
- const output = grid({
134
- width: rect.width,
135
- height: rect.height,
136
- columns: node.columns,
137
- rows: node.rows,
138
- areas: node.areas,
139
- gap: node.gap,
140
- cells: Object.fromEntries([...renderedByArea.entries()].map(([name, rendered]) => [name, () => rendered.output])),
141
- });
142
- let paneRects = new Map();
143
- const seenPaneIds = new Set();
144
- const paneOrder = [];
145
- for (const rendered of renderedByArea.values()) {
230
+ const rendered = paintFrameNodeInto(child, localAreaRect, absoluteAreaRect, ctx, target);
146
231
  for (const [paneId, paneRect] of rendered.paneRects.entries()) {
147
232
  if (paneRects.has(paneId)) {
148
233
  throw new Error(`createFramedApp: duplicate paneId "${paneId}" in rendered layout`);
@@ -157,18 +242,24 @@ export function renderFrameNode(node, rect, ctx) {
157
242
  paneOrder.push(paneId);
158
243
  }
159
244
  }
160
- return { output, paneRects, paneOrder };
245
+ return { paneRects, paneOrder };
161
246
  }
162
247
  /** Render a placeholder for a grid area that has no matching cell definition. */
163
248
  export function renderMissingGridCell(areaName, rect) {
164
249
  return {
165
- output: fitBlock(`[missing grid cell: ${areaName}]`, rect.width, rect.height).join('\n'),
250
+ surface: blockSurface(`[missing grid cell: ${areaName}]`, rect.width, rect.height),
166
251
  paneRects: new Map(),
167
252
  paneOrder: [],
168
253
  };
169
254
  }
170
255
  /** Render a page's layout tree within the frame body rect. */
171
256
  export function renderPageContent(pageId, model, bodyRect, pagesById) {
257
+ const surface = createSurface(bodyRect.width, bodyRect.height);
258
+ const geometry = renderPageContentInto(pageId, model, bodyRect, pagesById, surface, 0, 0);
259
+ return { surface, paneRects: geometry.paneRects, paneOrder: geometry.paneOrder };
260
+ }
261
+ /** Paint a page's layout tree directly into an existing target surface. */
262
+ export function renderPageContentInto(pageId, model, bodyRect, pagesById, target, offsetRow = bodyRect.row, offsetCol = bodyRect.col) {
172
263
  const page = pagesById.get(pageId);
173
264
  const pageModel = model.pageModels[pageId];
174
265
  const renderCtx = {
@@ -179,92 +270,131 @@ export function renderPageContent(pageId, model, bodyRect, pagesById) {
179
270
  visibility: model.minimizedByPage[pageId] ?? createPanelVisibilityState(),
180
271
  dockState: model.dockStateByPage[pageId] ?? createPanelDockState(),
181
272
  };
182
- return renderFrameNode(page.layout(pageModel), bodyRect, renderCtx);
273
+ return paintFrameNodeInto(page.layout(pageModel), { row: offsetRow, col: offsetCol, width: bodyRect.width, height: bodyRect.height }, bodyRect, renderCtx, target);
183
274
  }
184
275
  /** Render only the maximized pane at the full body rect. */
185
276
  export function renderMaximizedPane(pageId, model, bodyRect, pagesById, maximizedPaneId) {
277
+ const surface = createSurface(bodyRect.width, bodyRect.height);
278
+ const geometry = renderMaximizedPaneInto(pageId, model, bodyRect, pagesById, maximizedPaneId, surface, 0, 0);
279
+ return { surface, paneRects: geometry.paneRects, paneOrder: geometry.paneOrder };
280
+ }
281
+ /** Paint only the maximized pane directly into an existing target surface. */
282
+ export function renderMaximizedPaneInto(pageId, model, bodyRect, pagesById, maximizedPaneId, target, offsetRow = bodyRect.row, offsetCol = bodyRect.col) {
186
283
  const page = pagesById.get(pageId);
187
284
  const pageModel = model.pageModels[pageId];
188
285
  const layoutTree = page.layout(pageModel);
189
286
  const paneNode = findPaneNode(layoutTree, maximizedPaneId);
190
287
  if (paneNode == null) {
191
288
  // Pane not found, fall back to normal rendering
192
- return renderPageContent(pageId, model, bodyRect, pagesById);
289
+ return renderPageContentInto(pageId, model, bodyRect, pagesById, target, offsetRow, offsetCol);
193
290
  }
194
291
  const prior = model.scrollByPage[pageId]?.[maximizedPaneId] ?? { x: 0, y: 0 };
195
- const content = framePaneOutputToString(paneNode.render(bodyRect.width, bodyRect.height), bodyRect.width, bodyRect.height);
196
- let state = createFocusAreaState({
197
- content,
292
+ const contentSurface = framePaneOutputToSurface(paneNode.render(bodyRect.width, bodyRect.height), bodyRect.width, bodyRect.height, getFramePaneScratch(bodyRect.width, bodyRect.height));
293
+ let state = createFocusAreaStateForSurface(contentSurface, {
198
294
  width: bodyRect.width,
199
295
  height: bodyRect.height,
200
296
  overflowX: paneNode.overflowX ?? 'hidden',
201
297
  });
202
298
  state = focusAreaScrollTo(state, prior.y);
203
299
  state = focusAreaScrollToX(state, prior.x);
204
- const output = focusArea(state, {
300
+ focusAreaSurfaceInto(contentSurface, state, target, {
205
301
  focused: true,
206
302
  ctx: resolveSafeCtx(),
207
303
  id: maximizedPaneId,
208
304
  classes: ['focused', 'maximized'],
209
- });
305
+ focusedGutterToken: paneNode.focusedGutterToken,
306
+ unfocusedGutterToken: paneNode.unfocusedGutterToken,
307
+ }, offsetCol, offsetRow);
210
308
  return {
211
- output,
212
309
  paneRects: new Map([[maximizedPaneId, bodyRect]]),
213
310
  paneOrder: [maximizedPaneId],
214
311
  };
215
312
  }
216
- /** Render the top header line showing the app title and tab bar. */
217
- export function renderHeaderLine(model, options, pagesById) {
313
+ /** Resolve the top header line plus clickable tab target geometry. */
314
+ export function resolveHeaderLine(model, options, pagesById) {
315
+ const ctx = resolveSafeCtx();
316
+ const activePage = pagesById.get(model.activePageId);
317
+ const activePageModel = model.pageModels[model.activePageId];
318
+ const headerStyle = options.headerStyle?.({
319
+ model,
320
+ activePage,
321
+ pageModel: activePageModel,
322
+ });
218
323
  const title = options.title ?? 'App';
219
- const tabs = model.pageOrder.map((id) => {
324
+ let cursor = visibleLength(title) + 2;
325
+ const tabTargets = [];
326
+ const tabs = model.pageOrder.map((id, index) => {
220
327
  const page = pagesById.get(id);
221
- return id === model.activePageId ? `[${page.title}]` : ` ${page.title} `;
328
+ const label = id === model.activePageId ? `[${page.title}]` : ` ${page.title} `;
329
+ const width = visibleLength(label);
330
+ const startCol = cursor;
331
+ const endCol = cursor + width - 1;
332
+ if (endCol >= 0 && startCol < model.columns) {
333
+ tabTargets.push({
334
+ pageId: id,
335
+ startCol: Math.max(0, startCol),
336
+ endCol: Math.min(Math.max(0, model.columns - 1), endCol),
337
+ });
338
+ }
339
+ cursor += width;
340
+ if (index < model.pageOrder.length - 1) {
341
+ cursor += 1;
342
+ }
343
+ return label;
222
344
  }).join(' ');
223
345
  const line = fitLine(`${title} ${tabs}`, model.columns);
224
- return styleTextWithBCSS(line, resolveSafeCtx(), {
346
+ const surface = createStyledTextSurfaceWithBCSS(line, model.columns, ctx, {
225
347
  type: 'FrameHeader',
226
348
  id: 'frame-header',
227
349
  classes: [`page-${model.activePageId}`],
228
350
  });
351
+ paintActiveHeaderTab(surface, tabTargets, model.activePageId, ctx, headerStyle?.activeTabToken);
352
+ return {
353
+ surface,
354
+ tabTargets,
355
+ };
229
356
  }
230
- /** Render the bottom status line showing mode, focused pane, and key hints. */
231
- export function renderHelpLine(model, frameKeys, options, activePage) {
232
- const mode = model.commandPalette != null
357
+ /** Render the footer status line showing mode, focused pane, and key hints. */
358
+ export function renderHelpLine(model, activeLayer, i18n, notificationCue) {
359
+ const mode = activeLayer.kind === 'search' || activeLayer.kind === 'command-palette'
233
360
  ? 'PALETTE'
234
- : model.helpOpen
361
+ : activeLayer.kind === 'help'
235
362
  ? 'HELP'
236
- : 'NORMAL';
363
+ : activeLayer.kind === 'quit-confirm'
364
+ ? 'QUIT'
365
+ : activeLayer.kind === 'settings'
366
+ ? 'SETTINGS'
367
+ : activeLayer.kind === 'notification-center'
368
+ ? 'NOTICES'
369
+ : activeLayer.kind === 'page-modal'
370
+ ? 'MODAL'
371
+ : 'NORMAL';
237
372
  const focusedPane = model.focusedPaneByPage[model.activePageId] ?? '-';
238
- const status = `[${mode}] page:${model.activePageId} pane:${focusedPane}`;
239
- const source = options.helpLineSource?.({
240
- model,
241
- activePage,
242
- frameKeys,
243
- globalKeys: options.globalKeys,
244
- }) ?? mergeBindingSources(frameKeys, options.globalKeys, activePage.helpSource ?? activePage.keyMap);
245
- const hint = helpShort(source);
373
+ const modeLabel = frameModeLabel(i18n, mode);
374
+ const status = notificationCue == null || notificationCue.length === 0
375
+ ? `[${modeLabel}] page:${model.activePageId} pane:${focusedPane}`
376
+ : `[${modeLabel}] page:${model.activePageId} pane:${focusedPane} ${notificationCue}`;
377
+ const hint = typeof activeLayer.hintSource === 'string'
378
+ ? activeLayer.hintSource
379
+ : activeLayer.hintSource == null
380
+ ? ''
381
+ : helpShort(activeLayer.hintSource);
246
382
  const line = hint.length > 0
247
- ? ` ${status} ${hint}`
383
+ ? (() => {
384
+ const statusWithPadding = ` ${status}`;
385
+ const gap = model.columns - visibleLength(statusWithPadding) - visibleLength(hint);
386
+ return gap >= 2
387
+ ? `${statusWithPadding}${' '.repeat(gap)}${hint}`
388
+ : `${statusWithPadding} ${hint}`;
389
+ })()
248
390
  : ` ${status}`;
249
- return styleTextWithBCSS(fitLine(line, model.columns), resolveSafeCtx(), {
391
+ return createStyledTextSurfaceWithBCSS(fitLine(line, model.columns), model.columns, resolveSafeCtx(), {
250
392
  type: 'FrameHelp',
251
393
  id: 'frame-help',
252
394
  classes: [`mode-${mode.toLowerCase()}`, `page-${model.activePageId}`],
253
395
  });
254
396
  }
255
397
  /**
256
- * Split a styled multiline string into a 2D grid of single-column characters.
257
- * Each cell is a fully-styled string (including resets).
258
- */
259
- export function stringToGrid(str, width, height) {
260
- const lines = str.split('\n');
261
- const grid = [];
262
- for (let y = 0; y < height; y++) {
263
- const line = lines[y] ?? '';
264
- grid.push(tokenizeAnsi(line, width));
265
- }
266
- return grid;
267
- }
268
398
  /**
269
399
  * Apply a transition shader to blend between the previous and next page views.
270
400
  *
@@ -273,52 +403,69 @@ export function stringToGrid(str, width, height) {
273
403
  */
274
404
  export function renderTransition(prev, next, style, progress, width, height, ctx, frame = 0) {
275
405
  const shader = typeof style === 'function' ? style : TRANSITION_SHADERS[style];
276
- if (!shader)
406
+ if (!shader || width <= 0 || height <= 0)
277
407
  return next;
278
- if (width <= 0 || height <= 0)
279
- return next;
280
- const prevGrid = stringToGrid(prev, width, height);
281
- const nextGrid = stringToGrid(next, width, height);
282
- const lines = [];
408
+ const surface = createSurface(width, height);
283
409
  for (let y = 0; y < height; y++) {
284
- let line = '';
285
410
  for (let x = 0; x < width; x++) {
286
- // Shared stable-ish pseudo-random seed based on coordinates
287
411
  const seed = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
288
412
  const rand = seed - Math.floor(seed);
289
413
  const result = shader({ x, y, width, height, progress, rand, frame, ctx });
290
- const showNext = result.showNext;
291
- const charOverride = result.char;
292
- if (charOverride !== undefined) {
293
- line += charOverride;
294
- }
295
- else {
296
- line += (showNext ? nextGrid[y]?.[x] : prevGrid[y]?.[x]) ?? ' ';
297
- }
414
+ const baseCell = result.showNext ? next.get(x, y) : prev.get(x, y);
415
+ surface.set(x, y, applyTransitionCell(baseCell, result));
298
416
  }
299
- lines.push(line);
300
417
  }
301
- return lines.join('\n');
418
+ return surface;
302
419
  }
303
- export function framePaneOutputToString(output, width, height) {
304
- if (typeof output === 'string')
305
- return output;
306
- const surface = normalizeViewOutput(output, { width, height }).surface;
307
- const ctx = resolveSafeCtx();
308
- if (ctx?.style) {
309
- return surfaceToString(surface, ctx.style);
310
- }
311
- return surfaceToPlainText(surface);
420
+ export function framePaneOutputToSurface(output, width, height, scratch) {
421
+ return scratch == null
422
+ ? normalizeViewOutput(output, { width, height }).surface
423
+ : normalizeViewOutputInto(output, { width, height }, scratch).surface;
424
+ }
425
+ export function blockSurface(content, width, height) {
426
+ return parseAnsiToSurface(fitBlock(content, width, height).join('\n'), width, height);
312
427
  }
313
- function surfaceToPlainText(surface) {
314
- const lines = [];
315
- for (let y = 0; y < surface.height; y++) {
316
- let line = '';
317
- for (let x = 0; x < surface.width; x++) {
318
- line += surface.get(x, y).char;
428
+ function paintDivider(target, rect, dividerChar, direction) {
429
+ const unit = resolveDividerUnit(dividerChar, direction === 'row' ? '│' : '─');
430
+ for (let y = 0; y < rect.height; y++) {
431
+ for (let x = 0; x < rect.width; x++) {
432
+ target.set(rect.col + x, rect.row + y, { char: unit, empty: false });
319
433
  }
320
- lines.push(line);
321
434
  }
322
- return lines.join('\n');
435
+ }
436
+ function resolveDividerUnit(dividerChar, fallback) {
437
+ if (dividerChar == null || dividerChar.length === 0)
438
+ return fallback;
439
+ return dividerChar[0] ?? fallback;
440
+ }
441
+ function getFramePaneScratch(width, height) {
442
+ const key = `${width}x${height}`;
443
+ let scratch = framePaneScratchBySize.get(key);
444
+ if (scratch == null) {
445
+ scratch = createSurface(width, height);
446
+ framePaneScratchBySize.set(key, scratch);
447
+ }
448
+ return scratch;
449
+ }
450
+ function applyTransitionCell(baseCell, result) {
451
+ if (result.overrideCell != null) {
452
+ return {
453
+ ...baseCell,
454
+ ...result.overrideCell,
455
+ char: result.overrideCell.char,
456
+ empty: false,
457
+ };
458
+ }
459
+ if (result.overrideChar !== undefined) {
460
+ return {
461
+ ...baseCell,
462
+ char: result.overrideChar,
463
+ empty: false,
464
+ };
465
+ }
466
+ return {
467
+ ...baseCell,
468
+ empty: false,
469
+ };
323
470
  }
324
471
  //# sourceMappingURL=app-frame-render.js.map