@earendil-works/pi-tui 0.78.0 → 0.79.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.
package/dist/tui.js CHANGED
@@ -118,6 +118,7 @@ export class TUI extends Container {
118
118
  // Overlay stack for modal components rendered on top of base content
119
119
  focusOrderCounter = 0;
120
120
  overlayStack = [];
121
+ overlayFocusRestore = { status: "inactive" };
121
122
  constructor(terminal, showHardwareCursor) {
122
123
  super();
123
124
  this.terminal = terminal;
@@ -152,16 +153,115 @@ export class TUI extends Container {
152
153
  this.clearOnShrink = enabled;
153
154
  }
154
155
  setFocus(component) {
155
- // Clear focused flag on old component
156
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
157
+ }
158
+ setFocusInternal({ component, overlayFocusRestore, }) {
159
+ const previousFocus = this.focusedComponent;
160
+ let nextFocus = component;
161
+ const previousFocusedOverlay = previousFocus
162
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
163
+ : undefined;
164
+ const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
165
+ const restoreState = this.getVisibleOverlayFocusRestore();
166
+ if (nextFocus && !nextFocusIsOverlay) {
167
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
168
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
169
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
170
+ }
171
+ else {
172
+ this.overlayFocusRestore = {
173
+ status: "blocked",
174
+ overlay: restoreState.overlay,
175
+ blockedBy: nextFocus,
176
+ resume: restoreState.resume,
177
+ };
178
+ }
179
+ }
180
+ else if (previousFocusedOverlay &&
181
+ restoreState.status !== "inactive" &&
182
+ restoreState.overlay === previousFocusedOverlay &&
183
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
184
+ this.overlayFocusRestore = {
185
+ status: "blocked",
186
+ overlay: previousFocusedOverlay,
187
+ blockedBy: nextFocus,
188
+ resume: { status: "restore-overlay" },
189
+ };
190
+ }
191
+ }
192
+ else if (nextFocus === null) {
193
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
194
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
195
+ }
196
+ else if (overlayFocusRestore === "clear") {
197
+ this.clearOverlayFocusRestore();
198
+ }
199
+ }
156
200
  if (isFocusable(this.focusedComponent)) {
157
201
  this.focusedComponent.focused = false;
158
202
  }
159
- this.focusedComponent = component;
160
- // Set focused flag on new component
161
- if (isFocusable(component)) {
162
- component.focused = true;
203
+ this.focusedComponent = nextFocus;
204
+ if (isFocusable(nextFocus)) {
205
+ nextFocus.focused = true;
206
+ }
207
+ const focusedOverlay = nextFocus
208
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
209
+ : undefined;
210
+ if (focusedOverlay) {
211
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
212
+ }
213
+ }
214
+ clearOverlayFocusRestore() {
215
+ this.overlayFocusRestore = { status: "inactive" };
216
+ }
217
+ clearOverlayFocusRestoreFor(overlay) {
218
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
219
+ this.clearOverlayFocusRestore();
220
+ }
221
+ }
222
+ resolveBlockedOverlayFocusResume(restoreState) {
223
+ if (restoreState.resume.status === "restore-overlay")
224
+ return restoreState.overlay.component;
225
+ this.clearOverlayFocusRestore();
226
+ return restoreState.resume.target;
227
+ }
228
+ getVisibleOverlayFocusRestore() {
229
+ const restoreState = this.overlayFocusRestore;
230
+ if (restoreState.status === "inactive")
231
+ return restoreState;
232
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
233
+ return { status: "inactive" };
234
+ }
235
+ return restoreState;
236
+ }
237
+ isOverlayFocusAncestor(entry, component) {
238
+ const visited = new Set();
239
+ let current = entry.preFocus;
240
+ while (current && !visited.has(current)) {
241
+ visited.add(current);
242
+ if (current === component)
243
+ return true;
244
+ current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
245
+ }
246
+ return false;
247
+ }
248
+ retargetOverlayPreFocus(removed) {
249
+ for (const overlay of this.overlayStack) {
250
+ if (overlay !== removed && overlay.preFocus === removed.component) {
251
+ overlay.preFocus = removed.preFocus;
252
+ }
163
253
  }
164
254
  }
255
+ isComponentMounted(component) {
256
+ return this.children.some((child) => this.containsComponent(child, component));
257
+ }
258
+ containsComponent(root, target) {
259
+ if (root === target)
260
+ return true;
261
+ if (!(root instanceof Container))
262
+ return false;
263
+ return root.children.some((child) => this.containsComponent(child, target));
264
+ }
165
265
  /**
166
266
  * Show an overlay component with configurable positioning and sizing.
167
267
  * Returns a handle to control the overlay's visibility.
@@ -169,7 +269,7 @@ export class TUI extends Container {
169
269
  showOverlay(component, options) {
170
270
  const entry = {
171
271
  component,
172
- options,
272
+ ...(options === undefined ? {} : { options }),
173
273
  preFocus: this.focusedComponent,
174
274
  hidden: false,
175
275
  focusOrder: ++this.focusOrderCounter,
@@ -186,6 +286,8 @@ export class TUI extends Container {
186
286
  hide: () => {
187
287
  const index = this.overlayStack.indexOf(entry);
188
288
  if (index !== -1) {
289
+ this.clearOverlayFocusRestoreFor(entry);
290
+ this.retargetOverlayPreFocus(entry);
189
291
  this.overlayStack.splice(index, 1);
190
292
  // Restore focus if this overlay had focus
191
293
  if (this.focusedComponent === component) {
@@ -203,6 +305,7 @@ export class TUI extends Container {
203
305
  entry.hidden = hidden;
204
306
  // Update focus when hiding/showing
205
307
  if (hidden) {
308
+ this.clearOverlayFocusRestoreFor(entry);
206
309
  // If this overlay had focus, move focus to next visible or preFocus
207
310
  if (this.focusedComponent === component) {
208
311
  const topVisible = this.getTopmostVisibleOverlay();
@@ -222,17 +325,39 @@ export class TUI extends Container {
222
325
  focus: () => {
223
326
  if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
224
327
  return;
225
- if (this.focusedComponent !== component) {
226
- this.setFocus(component);
227
- }
228
328
  entry.focusOrder = ++this.focusOrderCounter;
329
+ this.setFocus(component);
229
330
  this.requestRender();
230
331
  },
231
- unfocus: () => {
232
- if (this.focusedComponent !== component)
332
+ unfocus: (unfocusOptions) => {
333
+ const isFocused = this.focusedComponent === component;
334
+ const restoreState = this.overlayFocusRestore;
335
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
336
+ if (!isFocused && !hasPendingRestore)
337
+ return;
338
+ if (restoreState.status === "blocked" &&
339
+ restoreState.overlay === entry &&
340
+ this.focusedComponent === restoreState.blockedBy) {
341
+ if (unfocusOptions) {
342
+ this.overlayFocusRestore = {
343
+ status: "blocked",
344
+ overlay: entry,
345
+ blockedBy: restoreState.blockedBy,
346
+ resume: { status: "focus-target", target: unfocusOptions.target },
347
+ };
348
+ }
349
+ else {
350
+ this.clearOverlayFocusRestore();
351
+ }
352
+ this.requestRender();
233
353
  return;
234
- const topVisible = this.getTopmostVisibleOverlay();
235
- this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
354
+ }
355
+ this.clearOverlayFocusRestoreFor(entry);
356
+ if (isFocused || unfocusOptions) {
357
+ const topVisible = this.getTopmostVisibleOverlay();
358
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
359
+ this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
360
+ }
236
361
  this.requestRender();
237
362
  },
238
363
  isFocused: () => this.focusedComponent === component,
@@ -240,9 +365,12 @@ export class TUI extends Container {
240
365
  }
241
366
  /** Hide the topmost overlay and restore previous focus. */
242
367
  hideOverlay() {
243
- const overlay = this.overlayStack.pop();
368
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
244
369
  if (!overlay)
245
370
  return;
371
+ this.clearOverlayFocusRestoreFor(overlay);
372
+ this.retargetOverlayPreFocus(overlay);
373
+ this.overlayStack.pop();
246
374
  if (this.focusedComponent === overlay.component) {
247
375
  // Find topmost visible overlay, or fall back to preFocus
248
376
  const topVisible = this.getTopmostVisibleOverlay();
@@ -265,16 +393,17 @@ export class TUI extends Container {
265
393
  }
266
394
  return true;
267
395
  }
268
- /** Find the topmost visible capturing overlay, if any */
396
+ /** Find the visual-frontmost visible capturing overlay, if any */
269
397
  getTopmostVisibleOverlay() {
270
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
271
- if (this.overlayStack[i].options?.nonCapturing)
398
+ let topmost;
399
+ for (const overlay of this.overlayStack) {
400
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
272
401
  continue;
273
- if (this.isOverlayVisible(this.overlayStack[i])) {
274
- return this.overlayStack[i];
402
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
403
+ topmost = overlay;
275
404
  }
276
405
  }
277
- return undefined;
406
+ return topmost;
278
407
  }
279
408
  invalidate() {
280
409
  super.invalidate();
@@ -411,8 +540,23 @@ export class TUI extends Container {
411
540
  this.setFocus(topVisible.component);
412
541
  }
413
542
  else {
414
- // No visible overlays, restore to preFocus
415
- this.setFocus(focusedOverlay.preFocus);
543
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
544
+ }
545
+ }
546
+ const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
547
+ if (!focusIsOverlay) {
548
+ const restoreState = this.getVisibleOverlayFocusRestore();
549
+ if (restoreState.status === "eligible") {
550
+ this.setFocus(restoreState.overlay.component);
551
+ }
552
+ else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
553
+ if (restoreState.resume.status === "restore-overlay") {
554
+ this.setFocus(restoreState.overlay.component);
555
+ }
556
+ else {
557
+ this.clearOverlayFocusRestore();
558
+ this.setFocus(restoreState.resume.target);
559
+ }
416
560
  }
417
561
  }
418
562
  // Pass input to focused component (including Ctrl+C)
@@ -880,16 +1024,18 @@ export class TUI extends Container {
880
1024
  fullRender(true);
881
1025
  return;
882
1026
  }
883
- if (extraLines > 0) {
884
- buffer += "\x1b[1B";
1027
+ const clearStartOffset = newLines.length === 0 ? 0 : 1;
1028
+ if (extraLines > 0 && clearStartOffset > 0) {
1029
+ buffer += `\x1b[${clearStartOffset}B`;
885
1030
  }
886
1031
  for (let i = 0; i < extraLines; i++) {
887
1032
  buffer += "\r\x1b[2K";
888
1033
  if (i < extraLines - 1)
889
1034
  buffer += "\x1b[1B";
890
1035
  }
891
- if (extraLines > 0) {
892
- buffer += `\x1b[${extraLines}A`;
1036
+ const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
1037
+ if (moveBack > 0) {
1038
+ buffer += `\x1b[${moveBack}A`;
893
1039
  }
894
1040
  buffer += "\x1b[?2026l";
895
1041
  this.terminal.write(buffer);