pi-ask-popup 0.1.1 → 0.2.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.
@@ -38,12 +38,24 @@ export interface WrappingSelectOptions {
38
38
  totalItemsForNumbering?: number;
39
39
  }
40
40
 
41
+ /** One width's worth of rendered rows, plus where each item sits in them. */
42
+ interface RenderedRows {
43
+ lines: string[];
44
+ rangeByIndex: Map<number, [number, number]>;
45
+ }
46
+
41
47
  export class WrappingSelect implements Component {
42
48
  private static readonly ACTIVE_POINTER = "❯ ";
43
49
  private static readonly INACTIVE_POINTER = " ";
44
50
  private static readonly NUMBER_SEPARATOR = ". ";
45
51
  private static readonly CONFIRMED_MARK = " ✔";
46
52
  private static readonly MIN_CONTENT_WIDTH = 1;
53
+ /**
54
+ * Widths kept in the render memo before it is dropped wholesale. A frame asks
55
+ * for two or three (full pane, side-by-side left column, height probes); the
56
+ * rest of the entries are old terminal sizes from a resize drag.
57
+ */
58
+ private static readonly RENDER_CACHE_MAX_WIDTHS = 8;
47
59
 
48
60
  private readonly items: readonly WrappingSelectItem[];
49
61
  private readonly maxVisible: number;
@@ -68,6 +80,18 @@ export class WrappingSelect implements Component {
68
80
  * prior answer was custom text, we render that text instead (e.g. `4. Hello ✔`).
69
81
  */
70
82
  private confirmedLabelOverride: string | undefined = undefined;
83
+ /**
84
+ * Rendered rows and row boundaries per width, valid while
85
+ * `renderStateSignature()` holds.
86
+ *
87
+ * One frame calls `render` two or three times at different widths — the
88
+ * option list, the height probes in `PreviewPane`, the side-by-side left
89
+ * column — and every call re-wrapped every visible row. The signature covers
90
+ * all the mutable state a row can read, so the props write that happens each
91
+ * tick with unchanged values keeps the memo warm instead of dropping it.
92
+ */
93
+ private readonly renderCache = new Map<number, RenderedRows>();
94
+ private renderCacheSignature: string | undefined = undefined;
71
95
 
72
96
  constructor(
73
97
  items: readonly WrappingSelectItem[],
@@ -127,16 +151,78 @@ export class WrappingSelect implements Component {
127
151
  /** Intentionally empty — input is routed at the container level. */
128
152
  handleInput(_data: string): void {}
129
153
 
130
- invalidate(): void {}
154
+ invalidate(): void {
155
+ this.renderCache.clear();
156
+ }
131
157
 
132
158
  render(width: number): string[] {
159
+ // A copy, because callers own what they get back: `PreviewPane` hands this
160
+ // array straight up to the dialog, which decorates rows in place.
161
+ return [...this.cachedRows(width).lines];
162
+ }
163
+
164
+ /**
165
+ * Row count at `width` without handing out the rows.
166
+ *
167
+ * Reads the same memo `render` fills, so `measureHeight(w) ===
168
+ * render(w).length` by construction — the height probes in `PreviewPane`
169
+ * relied on that parity while paying for an array copy to get it.
170
+ */
171
+ measureHeight(width: number): number {
172
+ return this.cachedRows(width).lines.length;
173
+ }
174
+
175
+ /** The memo. Returns the stored rows themselves — callers must not mutate them. */
176
+ private cachedRows(width: number): RenderedRows {
177
+ const signature = this.renderStateSignature();
178
+ if (signature !== this.renderCacheSignature) {
179
+ this.renderCache.clear();
180
+ this.renderCacheSignature = signature;
181
+ }
182
+ const cached = this.renderCache.get(width);
183
+ if (cached !== undefined) {
184
+ return cached;
185
+ }
186
+ const rendered = this.renderRows(width);
187
+ if (this.renderCache.size >= WrappingSelect.RENDER_CACHE_MAX_WIDTHS) {
188
+ this.renderCache.clear();
189
+ }
190
+ this.renderCache.set(width, rendered);
191
+ return rendered;
192
+ }
193
+
194
+ /**
195
+ * Every mutable input a rendered row reads. `items`, `maxVisible` and `theme`
196
+ * are constructor-fixed, so they stay out. A NUL separator keeps a buffer
197
+ * containing the separator from colliding with the next field.
198
+ */
199
+ private renderStateSignature(): string {
200
+ return [
201
+ this.selectedIndex,
202
+ this.focused,
203
+ this.confirmedIndex ?? -1,
204
+ this.confirmedLabelOverride ?? "",
205
+ this.inputCursorOffset ?? -1,
206
+ this.numberStartOffset,
207
+ this.totalItemsForNumbering,
208
+ this.inputBuffer,
209
+ ].join("\u0000");
210
+ }
211
+
212
+ /**
213
+ * The single pass. Rows and the row range each item occupies come out
214
+ * together, because they are the same walk: counting an item's rows any other
215
+ * way means rendering it a second time and throwing the strings away.
216
+ */
217
+ private renderRows(width: number): RenderedRows {
133
218
  if (this.items.length === 0) {
134
- return [];
219
+ return { lines: [], rangeByIndex: new Map() };
135
220
  }
136
221
 
137
222
  const { startIndex, endIndex } = this.computeVisibleWindow();
138
223
  const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
139
224
  const lines: string[] = [];
225
+ const rangeByIndex = new Map<number, [number, number]>();
140
226
 
141
227
  for (let i = startIndex; i < endIndex; i++) {
142
228
  const item = this.items[i];
@@ -144,55 +230,32 @@ export class WrappingSelect implements Component {
144
230
  continue;
145
231
  }
146
232
  const isActive = i === this.selectedIndex && this.focused;
233
+ const start = lines.length;
147
234
  lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
235
+ rangeByIndex.set(i, [start, lines.length]);
148
236
  }
149
237
 
150
238
  if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
151
239
  lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
152
240
  }
153
- return lines;
241
+ return { lines, rangeByIndex };
154
242
  }
155
243
 
156
244
  /**
157
- * Returns the [startRow, endRow) range of the focused (selected) item within
158
- * the output of `render(width)`. Computed by iterating the visible window and
159
- * summing per-item row counts — O(maxVisible) per call.
245
+ * The [startRow, endRow) range the focused item occupies in `render(width)`.
246
+ *
247
+ * Read out of the same memo the rows come from. It used to re-render every
248
+ * item in the window to count their rows and drop the strings, on every frame
249
+ * the dialog anchored its scroll window — which was every frame.
250
+ *
251
+ * `[0, 1]` when the selection is outside the visible window: there is no
252
+ * range to report, and the first row is the safe thing to anchor on.
160
253
  */
161
254
  focusedItemRowRange(width: number): [number, number] {
162
255
  if (this.items.length === 0) {
163
256
  return [0, 0];
164
257
  }
165
- const { startIndex, endIndex } = this.computeVisibleWindow();
166
- const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
167
- let row = 0;
168
- for (let i = startIndex; i < endIndex; i++) {
169
- const item = this.items[i];
170
- if (!item) {
171
- continue;
172
- }
173
- const isActive = i === this.selectedIndex && this.focused;
174
- const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
175
- if (i === this.selectedIndex) {
176
- return [row, row + itemRowCount];
177
- }
178
- row += itemRowCount;
179
- }
180
- return [0, 1];
181
- }
182
-
183
- /**
184
- * Per-item row count. Delegates to `renderItem().length` so `renderItem` remains
185
- * the single source of truth for per-item row math — eliminates the prior shadow-copy
186
- * that risked silent miscounts when new `kind` values branch in `renderItem` but not here.
187
- */
188
- private computeItemRowCount(
189
- item: WrappingSelectItem,
190
- index: number,
191
- isActive: boolean,
192
- width: number,
193
- numberWidth: number,
194
- ): number {
195
- return this.renderItem(item, index, isActive, width, numberWidth).length;
258
+ return this.cachedRows(width).rangeByIndex.get(this.selectedIndex) ?? [0, 1];
196
259
  }
197
260
 
198
261
  private computeVisibleWindow(): { startIndex: number; endIndex: number } {
@@ -18,6 +18,13 @@ export const HINT_PART_NAV = "↑/↓ to navigate";
18
18
  export const HINT_PART_NEW_LINE = "Shift+Enter for newline";
19
19
  export const HINT_PART_CLEAR = "Ctrl+U to clear";
20
20
  export const HINT_PART_TOGGLE = "Space to toggle";
21
+ /**
22
+ * Replaces ENTER while the typed row of a multi-select question has the
23
+ * keyboard. Enter there commits the question, the way the Next row does, rather
24
+ * than choosing a single answer — and Space belongs to the draft, so the resting
25
+ * "Space to toggle" is dropped alongside this.
26
+ */
27
+ export const HINT_PART_ENTER_CONFIRM = "Enter to confirm";
21
28
  export const HINT_PART_NOTES = "n to add notes";
22
29
  /** Replaces the add form once the tab already has a note. See `buildHintText`. */
23
30
  export const HINT_PART_NOTES_EDIT = "n to edit notes";
@@ -162,7 +169,14 @@ export interface DialogConfig {
162
169
  /** Body height of the tab showing right now. The difference is absorbed outside the border. */
163
170
  getCurrentBodyHeight: (width: number) => number;
164
171
  /** Terminal height, read at render time — the mirror of the width getter. */
165
- getTerminalRows: () => number;
172
+ /**
173
+ * Snapshot the terminal size for the frame about to be painted. Called first
174
+ * thing in `render`, before any child renders or is measured, so every
175
+ * component in the frame decides against the same terminal.
176
+ */
177
+ beginFrame: () => void;
178
+ /** The size `beginFrame` last snapshotted. */
179
+ getFrameTerminalRows: () => number;
166
180
  /**
167
181
  * Resolved collapse key (`"ctrl+]"`, `"alt+o"`, `"off"`). Construction-time
168
182
  * config, deliberately not canonical state: the runtime's copy must never
@@ -227,6 +241,10 @@ export class DialogView implements StatefulView<DialogProps> {
227
241
  invalidate(): void {}
228
242
 
229
243
  render(width: number): string[] {
244
+ // First, before any child renders or is measured: the whole frame decides
245
+ // against one terminal size.
246
+ this.config.beginFrame();
247
+
230
248
  const state = this.liveProps.state;
231
249
  const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
232
250
  const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
@@ -266,7 +284,7 @@ export class DialogView implements StatefulView<DialogProps> {
266
284
  strategy.restingNoteRowCount(state),
267
285
  );
268
286
 
269
- const termRows = this.config.getTerminalRows();
287
+ const termRows = this.config.getFrameTerminalRows();
270
288
 
271
289
  if (natural.length + spacerRows <= termRows) {
272
290
  return renderFitsTerminal(natural, spacerRows);
@@ -102,6 +102,14 @@ export class QuestionnairePropsAdapter {
102
102
  binding.apply(state, ctx);
103
103
  }
104
104
 
105
+ // Collapsed, the overlay is one dim row and no tab is on screen. The
106
+ // per-tab writes would only clear caches for a frame nobody sees; expanding
107
+ // dispatches, which brings the whole loop back with current state.
108
+ if (state.collapsed) {
109
+ this.tui.requestRender();
110
+ return;
111
+ }
112
+
105
113
  for (let i = 0; i < this.tabsByIndex.length; i++) {
106
114
  const tab = this.tabsByIndex[i];
107
115
  if (!tab) {
@@ -23,6 +23,7 @@ import {
23
23
  HINT_PART_NOTES,
24
24
  HINT_PART_NOTES_EDIT,
25
25
  HINT_PART_TAB,
26
+ HINT_PART_ENTER_CONFIRM,
26
27
  HINT_PART_TOGGLE,
27
28
  INCOMPLETE_WARNING_PREFIX,
28
29
  KEY_PLACEHOLDER,
@@ -431,8 +432,15 @@ export function buildHintText(
431
432
  state: DialogState,
432
433
  collapseKey: string,
433
434
  ): string {
434
- const parts: string[] = [HINT_PART_ENTER, HINT_PART_NAV];
435
- if (question?.multiSelect === true) {
435
+ const typingOnMulti = question?.multiSelect === true && state.inputMode;
436
+ // Typing on the multi-select free-text row: Enter ticks that row rather than
437
+ // choosing an answer, and Space belongs to the draft, so neither resting part
438
+ // is true here.
439
+ const parts: string[] = [
440
+ typingOnMulti ? HINT_PART_ENTER_CONFIRM : HINT_PART_ENTER,
441
+ HINT_PART_NAV,
442
+ ];
443
+ if (question?.multiSelect === true && !typingOnMulti) {
436
444
  parts.push(HINT_PART_TOGGLE);
437
445
  }
438
446
  if (question && !state.notesVisible && !state.inputMode) {