pi-odd-worktree 0.0.0-stage → 0.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 (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+ // Shared palette for the full-screen readers (issues-view S11, odd-viewer S11,
3
+ // customize S11).
4
+ //
5
+ // ONE place decides which theme role paints each kind of token — a shortcut KEY
6
+ // versus its description, a frame title, a count, a static label, a live search
7
+ // value — so every footer and header in these views reads as the same palette
8
+ // and neither view keeps a second copy of the same try/catch wrappers.
9
+ //
10
+ // Roles are theme ROLE NAMES the host theme resolves (`accent`, `muted`, `dim`,
11
+ // `border`, `error`, …). Nothing here names a colour or an escape sequence: a
12
+ // caller injects the theme, and a missing, partial or throwing theme degrades to
13
+ // plain text instead of breaking a render. Shortcut keys and titles are
14
+ // emphasized through the theme's own `bold`, so the pairings below stay
15
+ // distinguishable in a monochrome terminal too, where roles collapse to one
16
+ // colour.
17
+ //
18
+ // S13/S16 («fijate en los titulos, usa un color secundario … para los botones
19
+ // usa el principal del tema»): the reference is the Pi **Agents** view the reader
20
+ // screenshotted, and its source lives in the installed `gentle-pi` package — no
21
+ // colour is inferred here.
22
+ //
23
+ // - TITLE = `customMessageLabel`. That view declares its role table at
24
+ // `gentle-pi/lib/agents-view.ts:42` —
25
+ // const ROLE = { FRAME: "border", TITLE: "customMessageLabel",
26
+ // SELECTED: "accent", NAME: "text", NAME_IDLE: "muted",
27
+ // META: "dim", KEY: "accent", KEY_TEXT: "dim", EMPTY: "dim" }
28
+ // — and paints its frame title with it at the same file's line 437:
29
+ // theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, `❀ Agents · …`) + …
30
+ // The Jobs view uses the identical table (`gentle-pi/lib/jobs-view.ts:23`),
31
+ // so `customMessageLabel` is that family's panel-title role: a secondary,
32
+ // tinted colour (pi's dark theme resolves it to a purple, TokyoNight to
33
+ // `sky`) that is visibly NOT the primary accent and NOT a neutral grey.
34
+ // - BUTTON = the primary accent. The same table maps `KEY: "accent"` and
35
+ // `SELECTED: "accent"`, so every shortcut key and the selection stay on the
36
+ // primary colour (pi's documented accent: "logo, selected items, cursor").
37
+ // - ordinary text = `text` (`NAME: "text"` in that table); a selected row is
38
+ // told apart by its own `selectedBg` band rather than by being repainted.
39
+ // - `dim` stays what that table calls `META`/`KEY_TEXT`/`EMPTY`: small
40
+ // metadata, key descriptions and placeholders — which is also our `label`.
41
+ //
42
+ // Rejected earlier candidates: the Herdr *app*'s Agents sidebar paints its title
43
+ // with `t.dim` (`~/.config/herdr-app/mods/agents/main.js:413`), but that is a
44
+ // different product from the Pi Agents view the reader pointed at, and the
45
+ // reader explicitly called a grey title wrong. `mdHeading` (a Markdown document
46
+ // heading role) is not a UI title role either.
47
+
48
+ // The role per token kind. `key` and `title` are painted AND emphasized;
49
+ // `description`, `count` and `label` are colour-only, which is exactly what
50
+ // keeps a key distinguishable from the words next to it.
51
+ const ROLES = Object.freeze({
52
+ key: 'accent',
53
+ title: 'customMessageLabel',
54
+ description: 'muted',
55
+ count: 'muted',
56
+ label: 'dim',
57
+ value: 'text',
58
+ border: 'border',
59
+ error: 'error',
60
+ });
61
+
62
+ function textOf(value) {
63
+ if (typeof value === 'string') return value;
64
+ return value == null ? '' : String(value);
65
+ }
66
+
67
+ /**
68
+ * Paint `text` with one theme role.
69
+ *
70
+ * A theme that is absent, that has no `fg`, that throws, or that answers
71
+ * something that is not a string leaves the text unpainted — a hostile theme
72
+ * never breaks a view.
73
+ */
74
+ function paint(theme, role, text) {
75
+ const value = textOf(text);
76
+ try {
77
+ if (theme && typeof theme.fg === 'function') {
78
+ const painted = theme.fg(role, value);
79
+ if (typeof painted === 'string') return painted;
80
+ }
81
+ } catch {
82
+ // Fall through to the plain text.
83
+ }
84
+ return value;
85
+ }
86
+
87
+ /** Bold `text` through the theme, or leave it plain for a hostile theme. */
88
+ function emphasize(theme, text) {
89
+ const value = textOf(text);
90
+ try {
91
+ if (theme && typeof theme.bold === 'function') {
92
+ const bold = theme.bold(value);
93
+ if (typeof bold === 'string') return bold;
94
+ }
95
+ } catch {
96
+ // Fall through to the plain text.
97
+ }
98
+ return value;
99
+ }
100
+
101
+ /**
102
+ * The focused-row form: a selected-background band when the theme offers one,
103
+ * otherwise the accent role. This is what tells a SELECTED row apart from the
104
+ * ordinary `text` rows the palette paints (S13): focus never depends on the row
105
+ * already being a different colour. It stays visible on a theme that answers
106
+ * neither because the callers also mark the row with `▸`.
107
+ */
108
+ function highlight(theme, text, focused) {
109
+ const value = textOf(text);
110
+ if (!focused) return value;
111
+ try {
112
+ if (theme && typeof theme.bg === 'function') {
113
+ const painted = theme.bg('selectedBg', value);
114
+ if (typeof painted === 'string') return painted;
115
+ }
116
+ } catch {
117
+ // Fall through to the accent form below.
118
+ }
119
+ return paint(theme, ROLES.key, value);
120
+ }
121
+
122
+ /** A shortcut key (a button): the PRIMARY accent + bold, never accent alone. */
123
+ function key(theme, text) {
124
+ return emphasize(theme, paint(theme, ROLES.key, text));
125
+ }
126
+
127
+ /**
128
+ * A frame title: the role the Pi Agents view paints its own title with
129
+ * (`customMessageLabel`) + bold, named for what it is. A title is never painted
130
+ * with the primary `key` role (S13), never with `dim` (the grey the reader
131
+ * rejected) and never with a Markdown heading role.
132
+ */
133
+ function title(theme, text) {
134
+ return emphasize(theme, paint(theme, ROLES.title, text));
135
+ }
136
+
137
+ /** What a shortcut key does. */
138
+ function description(theme, text) {
139
+ return paint(theme, ROLES.description, text);
140
+ }
141
+
142
+ /** A tab count, a document count, a status word. */
143
+ function count(theme, text) {
144
+ return paint(theme, ROLES.count, text);
145
+ }
146
+
147
+ /** A static label or prompt the reader does not type into. */
148
+ function label(theme, text) {
149
+ return paint(theme, ROLES.label, text);
150
+ }
151
+
152
+ /**
153
+ * A live value the reader typed or derived — the query in the search row, a
154
+ * branch, a path, a page number. It is ordinary text, so it stays out of the
155
+ * accent a button owns and the selected row keeps its own band.
156
+ */
157
+ function value(theme, text) {
158
+ return paint(theme, ROLES.value, text);
159
+ }
160
+
161
+ // The legend joins its kept entries with this separator (the same one the
162
+ // one-row footer always used).
163
+ const LEGEND_SEPARATOR = ' · ';
164
+
165
+ // S14: one scroll GROUP is every hint that pages the same target — the primary
166
+ // pair, the always-available page pair, the half-page pair, the printable
167
+ // fallback. The reader reported the alternates as noise («alguna key generidad
168
+ // como el page o half estan demas»), so the legend spends ONE token on the
169
+ // group and says what it does (`ctrl+j/k scroll`); the list keys that also
170
+ // scroll keep working unadvertised. Callers pass their own target word.
171
+ const DEFAULT_SCROLL_LABEL = 'scroll';
172
+
173
+ /**
174
+ * Collapse one scroll group's hints into the SINGLE legend entry that names
175
+ * the target it scrolls (S14).
176
+ *
177
+ * The group's FIRST entry decides both the key spelling and the priority: it is
178
+ * the binding the caller considers primary (the one the terminal can actually
179
+ * deliver), and the entries after it are alternates for the same target, so
180
+ * their tokens leave the legend. Only the HINT collapses — the caller's key
181
+ * handler is untouched, so a dropped token keeps working.
182
+ *
183
+ * @param {Array<{key?: string, text?: string, short?: string, priority?: number}>} entries
184
+ * the group's entries, primary first.
185
+ * @param {string} [target] the pane the keys scroll, e.g. `doc`.
186
+ * @returns {{key: string, text: string, short: string, priority: number}|null}
187
+ * the labelled entry, or null for an empty group so a caller can spread it
188
+ * unconditionally.
189
+ */
190
+ function scrollHint(entries, target) {
191
+ const list = (Array.isArray(entries) ? entries : []).filter((entry) => entry && typeof entry === 'object');
192
+ if (list.length === 0) return null;
193
+ const primary = list[0];
194
+ const keys = textOf(primary.key) || textOf(primary.short) || textOf(primary.text);
195
+ if (keys === '') return null;
196
+ // The target survives compaction: an unlabelled key pair is exactly the
197
+ // ambiguity S14 reported, so `short` carries the label too.
198
+ const token = `${keys} ${textOf(target) || DEFAULT_SCROLL_LABEL}`;
199
+ return {
200
+ key: keys,
201
+ text: token,
202
+ short: token,
203
+ priority: Number.isFinite(Number(primary.priority)) ? Number(primary.priority) : 0,
204
+ };
205
+ }
206
+
207
+ // Which entries survive, and which spelling of them: the FULL token while it
208
+ // fits, then the compact token, then the same list minus its lowest-priority
209
+ // entries until something fits. Display order never changes and no token
210
+ // carries a newline, so the legend stays on ONE row.
211
+ function legendSelection(entries, width, measure) {
212
+ const list = Array.isArray(entries) ? entries : [];
213
+ const join = (items, field) => items.map((entry) => textOf(entry && entry[field])).join(LEGEND_SEPARATOR);
214
+ const fits = (text) => {
215
+ try {
216
+ return Number(measure(text)) <= width;
217
+ } catch {
218
+ // A hostile measurer never claims a fit.
219
+ return false;
220
+ }
221
+ };
222
+ if (fits(join(list, 'text'))) return { entries: list, field: 'text' };
223
+ if (fits(join(list, 'short'))) return { entries: list, field: 'short' };
224
+ const kept = list.slice();
225
+ while (kept.length > 1) {
226
+ let drop = 0;
227
+ for (let at = 1; at < kept.length; at += 1) {
228
+ if (kept[at].priority <= kept[drop].priority) drop = at;
229
+ }
230
+ kept.splice(drop, 1);
231
+ if (fits(join(kept, 'short'))) break;
232
+ }
233
+ return { entries: kept, field: 'short' };
234
+ }
235
+
236
+ // One legend entry: its `key` prefix carries the key role (+ bold) and the rest
237
+ // of the token carries the description role. An entry without a `key`, or one
238
+ // whose chosen spelling does not start with it, is a description throughout —
239
+ // the legend never guesses where a key ends and the words begin.
240
+ function entryCell(theme, entry, field) {
241
+ const spelled = textOf(entry && entry[field]);
242
+ const keys = textOf(entry && entry.key);
243
+ if (keys === '' || !spelled.startsWith(keys)) return description(theme, spelled);
244
+ const rest = spelled.slice(keys.length);
245
+ if (rest === '') return key(theme, keys);
246
+ return `${key(theme, keys)}${description(theme, rest)}`;
247
+ }
248
+
249
+ /**
250
+ * The painted legend row: keys in the key role, descriptions in the muted one,
251
+ * compacted to ONE row as described above. `measure` reports the visible width
252
+ * of a string (ANSI-aware), so painting never changes what fits.
253
+ */
254
+ function legendRow(theme, entries, width, measure) {
255
+ const selection = legendSelection(entries, width, measure);
256
+ const separator = paint(theme, ROLES.border, LEGEND_SEPARATOR);
257
+ return selection.entries.map((entry) => entryCell(theme, entry, selection.field)).join(separator);
258
+ }
259
+
260
+ module.exports = {
261
+ ROLES,
262
+ count,
263
+ description,
264
+ emphasize,
265
+ highlight,
266
+ key,
267
+ label,
268
+ legendRow,
269
+ paint,
270
+ scrollHint,
271
+ title,
272
+ value,
273
+ };
@@ -0,0 +1,368 @@
1
+ 'use strict';
2
+ // Shared layout for the full-screen readers (issues-view, odd-view). One place
3
+ // decides how wide the left list is and how many of its wrapped rows fit the
4
+ // real body budget, so the two readers cannot drift apart the way they did when
5
+ // each kept its own 34/36-column cap and its own fixed page size (S3, S5, S8,
6
+ // D3).
7
+ //
8
+ // S3 also decides HOW the list moves: the issues and ODD readers scroll one
9
+ // continuous window (`scrollWindow`/`shiftWindow`) with no page navigation. The
10
+ // discrete `paginate` bounds the retired full-screen worktree detail was built
11
+ // on stay exported for the layout tests and any paginated caller.
12
+ //
13
+ // Everything here is pure and injected: width measurement arrives through the
14
+ // caller's TUI helpers, and no module-level state exists.
15
+ //
16
+ // Widths are counted in terminal cells, so a wide character costs what the
17
+ // host says it costs, never a codepoint count.
18
+
19
+ // The frame's own cells: `│ ` on the left and ` │` on the right.
20
+ const CARD_PADDING = 4;
21
+ // The divider between the two panes: ` │ `.
22
+ const DIVIDER = 3;
23
+ // The preview keeps at least this many cells on a terminal that can afford
24
+ // it, and shrinks first on one that cannot.
25
+ const PREVIEW_MIN = 24;
26
+ // The left pane never drops below this while the terminal has room for it.
27
+ const LIST_MIN = 8;
28
+ // The default share is the 40% list / 60% preview split (S5) the retired
29
+ // full-screen worktree detail was built on; the readers pass their own below.
30
+ const LIST_SHARE = 0.4;
31
+ // The issues and ODD readers ask for the smaller 30% share instead (S3): their
32
+ // list is a short index of titles, so the reading pane gets the room.
33
+ const READER_LIST_SHARE = 0.3;
34
+
35
+ function num(value) {
36
+ const parsed = Number(value);
37
+ return Number.isFinite(parsed) ? parsed : 0;
38
+ }
39
+
40
+ function clampInt(value, min, max) {
41
+ const low = Math.ceil(num(min));
42
+ const high = Math.floor(num(max));
43
+ const wanted = Math.round(num(value));
44
+ if (high <= low) return Math.max(1, Math.min(low, high));
45
+ return Math.max(low, Math.min(high, wanted));
46
+ }
47
+
48
+ function measure(helpers, text) {
49
+ try {
50
+ if (helpers && typeof helpers.visibleWidth === 'function') {
51
+ const size = Number(helpers.visibleWidth(text));
52
+ if (Number.isFinite(size)) return size;
53
+ }
54
+ } catch {
55
+ // Fall through to the plain codepoint count.
56
+ }
57
+ return [...String(text).replace(/\x1b\[[0-9;]*m/g, '')].length;
58
+ }
59
+
60
+ /** Inner card width: the outer width minus the frame's own cells. */
61
+ function innerWidth(width) {
62
+ return Math.max(1, Math.floor(num(width)) - CARD_PADDING);
63
+ }
64
+
65
+ /**
66
+ * The two pane widths for one inner width.
67
+ *
68
+ * `options.listShare` selects the list's share of the inner width; it defaults
69
+ * to the 40% the retired full-screen worktree detail was built on, and the
70
+ * issues and ODD readers pass `READER_LIST_SHARE` for their smaller 30% index
71
+ * (S3). The floors below
72
+ * only speak on a terminal too narrow for the split: both panes stay positive
73
+ * and the list keeps its minimum before the divider does.
74
+ *
75
+ * @param {number} inner the inner card width in cells.
76
+ * @param {{listShare?: number}} [options] the list's share of `inner`.
77
+ * @returns {{left: number, right: number, divider: number}}
78
+ */
79
+ function paneWidths(inner, options = {}) {
80
+ const usable = Math.max(2, Math.floor(num(inner)));
81
+ const asked = num(options && options.listShare);
82
+ const share = asked > 0 && asked < 1 ? asked : LIST_SHARE;
83
+ const listFloor = Math.min(LIST_MIN, Math.max(1, usable - DIVIDER - 1));
84
+ let left = Math.round(usable * share);
85
+ if (left < listFloor) left = listFloor;
86
+ const previewFloor = Math.min(PREVIEW_MIN, Math.max(1, Math.floor(usable / 3)));
87
+ const maxLeft = usable - previewFloor - DIVIDER;
88
+ if (left > maxLeft) left = Math.max(listFloor, maxLeft);
89
+ left = clampInt(left, 1, Math.max(1, usable - DIVIDER - 1));
90
+ const right = Math.max(1, usable - left - DIVIDER);
91
+ return { left, right, divider: DIVIDER };
92
+ }
93
+
94
+ /**
95
+ * ONE list label's break opportunities.
96
+ *
97
+ * Whitespace separates words, and a hyphen ends a segment too, so a
98
+ * hyphenated name splits at its own separators instead of mid-word (S17).
99
+ * `glue` says the piece attaches directly to the one before it — a hyphen
100
+ * stays with the piece it closes, which is what makes the join lossless.
101
+ */
102
+ function labelAtoms(text) {
103
+ const atoms = [];
104
+ for (const word of String(text ?? '').split(/\s+/).filter(Boolean)) {
105
+ const pieces = word.split(/(?<=-)/);
106
+ for (let at = 0; at < pieces.length; at += 1) {
107
+ atoms.push({ text: pieces[at], glue: at > 0 });
108
+ }
109
+ }
110
+ return atoms;
111
+ }
112
+
113
+ /**
114
+ * ONE list label wrapped to the pane width.
115
+ *
116
+ * `first` prefixes the first row (the focus marker) and `rest` every
117
+ * continuation row, both measured out of the available text width so the
118
+ * first row never loses a cell to the marker. Rows break at spaces and at
119
+ * hyphens; only a segment wider than the whole pane is hard-broken, because a
120
+ * title is never truncated to fit a row budget (S3, S17).
121
+ */
122
+ function wrapLabel(helpers, text, width, options = {}) {
123
+ const first = String(options.first ?? ' ');
124
+ const rest = String(options.rest ?? ' ');
125
+ const total = Math.max(1, Math.floor(num(width)));
126
+ const firstRoom = Math.max(1, total - measure(helpers, first));
127
+ const restRoom = Math.max(1, total - measure(helpers, rest));
128
+ const rows = [];
129
+ let room = firstRoom;
130
+ let line = '';
131
+ const push = (value, nextRoom) => {
132
+ rows.push(value);
133
+ room = nextRoom;
134
+ line = '';
135
+ };
136
+ for (const atom of labelAtoms(text)) {
137
+ const glued = line === '' ? atom.text : (atom.glue ? `${line}${atom.text}` : `${line} ${atom.text}`);
138
+ if (measure(helpers, glued) <= room) {
139
+ line = glued;
140
+ continue;
141
+ }
142
+ if (line !== '') push(line, restRoom);
143
+ let tail = atom.text;
144
+ while (measure(helpers, tail) > room) {
145
+ const cut = hardCut(helpers, tail, room);
146
+ if (cut === '') break;
147
+ push(cut, restRoom);
148
+ tail = tail.slice(cut.length);
149
+ }
150
+ line = tail;
151
+ }
152
+ if (line !== '' || rows.length === 0) rows.push(line);
153
+ return rows.map((row, at) => `${at === 0 ? first : rest}${row}`);
154
+ }
155
+
156
+ // The longest prefix of `text` that fits `room` cells, at least one character
157
+ // so a pane narrower than a single glyph still advances.
158
+ function hardCut(helpers, text, room) {
159
+ const chars = [...text];
160
+ let cut = '';
161
+ for (const char of chars) {
162
+ if (measure(helpers, cut + char) > room) break;
163
+ cut += char;
164
+ }
165
+ return cut === '' ? chars[0] ?? '' : cut;
166
+ }
167
+
168
+ /** How many screen rows one wrapped label costs at `width`. */
169
+ function rowCost(helpers, text, width, options = {}) {
170
+ return wrapLabel(helpers, text, width, options).length;
171
+ }
172
+
173
+ /**
174
+ * Page bounds for a list whose items cost `rowCosts[i]` screen rows.
175
+ *
176
+ * The discrete page bounds are kept from the retired full-screen worktree
177
+ * detail; the issues and ODD readers use `scrollWindow`/`shiftWindow` below
178
+ * instead (S3). A page holds as many
179
+ * whole items as fit `budget` rows; an item is never split across pages, an
180
+ * item taller than the budget still gets its own page, and every item lands on
181
+ * exactly one page (S8: capacity follows the wrapped titles and the real body
182
+ * height, never a fixed item count).
183
+ *
184
+ * @returns {Array<{start: number, end: number, rows: number}>}
185
+ */
186
+ function paginate(rowCosts, budget) {
187
+ const list = Array.isArray(rowCosts) ? rowCosts : [];
188
+ const size = Math.max(1, Math.floor(num(budget)));
189
+ const pages = [];
190
+ let start = 0;
191
+ while (start < list.length) {
192
+ let end = start;
193
+ let rows = 0;
194
+ while (end < list.length) {
195
+ const cost = Math.max(1, Math.ceil(num(list[end])));
196
+ if (end > start && rows + cost > size) break;
197
+ rows += cost;
198
+ end += 1;
199
+ }
200
+ pages.push({ start, end, rows });
201
+ start = end;
202
+ }
203
+ if (pages.length === 0) pages.push({ start: 0, end: 0, rows: 0 });
204
+ return pages;
205
+ }
206
+
207
+ /** The (clamped) page that holds `index`, 1-based like the footer reads it. */
208
+ function pageForIndex(pages, index) {
209
+ const bounds = Array.isArray(pages) && pages.length > 0 ? pages : [{ start: 0, end: 0, rows: 0 }];
210
+ const wanted = Math.max(0, Math.floor(num(index)));
211
+ for (let at = 0; at < bounds.length; at += 1) {
212
+ if (wanted >= bounds[at].start && wanted < bounds[at].end) {
213
+ return { page: at + 1, bounds: bounds[at] };
214
+ }
215
+ }
216
+ const last = bounds.length - 1;
217
+ return { page: last + 1, bounds: bounds[last] };
218
+ }
219
+
220
+ /** The requested (clamped) page with its 1-based number and total count. */
221
+ function pageOf(rowCosts, options = {}) {
222
+ const pages = paginate(rowCosts, options.budget);
223
+ const wanted = Math.max(1, Math.floor(num(options.page)) || 1);
224
+ const page = Math.min(wanted, pages.length);
225
+ return { page, pageCount: pages.length, start: pages[page - 1].start, end: pages[page - 1].end };
226
+ }
227
+
228
+ // One item's row cost, clamped to at least one row so a zero or hostile cost
229
+ // can never make the window consume unbounded items.
230
+ function costOf(value) {
231
+ return Math.max(1, Math.ceil(num(value)));
232
+ }
233
+
234
+ function toCosts(rowCosts) {
235
+ return Array.isArray(rowCosts) ? rowCosts.map(costOf) : [];
236
+ }
237
+
238
+ // An item index clamped into `0..max`. Unlike `clampInt` this is happy with a
239
+ // zero-width range, which a one-item list has.
240
+ function clampIndex(value, max) {
241
+ const wanted = Math.floor(num(value));
242
+ if (!Number.isFinite(wanted)) return 0;
243
+ return Math.max(0, Math.min(Math.max(0, max), wanted));
244
+ }
245
+
246
+ // The index just past the last item fully inside `budget` rows from `start`.
247
+ // The first item is always included, so an item taller than the budget still
248
+ // gets a window of its own.
249
+ function windowEnd(costs, start, budget) {
250
+ let rows = 0;
251
+ let end = start;
252
+ while (end < costs.length) {
253
+ if (end > start && rows + costs[end] > budget) break;
254
+ rows += costs[end];
255
+ end += 1;
256
+ }
257
+ return end;
258
+ }
259
+
260
+ // The furthest a window may start while still holding `index` inside `budget`
261
+ // rows — `index` sits on the window's last row.
262
+ function windowStartFor(costs, index, budget) {
263
+ let rows = costs[index];
264
+ let at = index;
265
+ while (at > 0 && rows + costs[at - 1] <= budget) {
266
+ rows += costs[at - 1];
267
+ at -= 1;
268
+ }
269
+ return at;
270
+ }
271
+
272
+ /**
273
+ * ONE continuous list window: no pages, only the rows that fit the body.
274
+ *
275
+ * The reader keeps its scroll position as the index of the first painted item
276
+ * (`start`), and this decides the window that paints `index` without leaving
277
+ * blank rows at the bottom while items remain above it (S3). The focused item
278
+ * is always inside the returned window: a selection below the window pushes it
279
+ * down until the focused row sits on its last row, and a selection above it
280
+ * pulls it back up.
281
+ *
282
+ * @param {number[]} rowCosts the screen rows each item needs, in list order.
283
+ * @param {{budget?: number, index?: number, start?: number}} [options] the body
284
+ * row budget, the focused item and the wanted first item.
285
+ * @returns {{start: number, end: number, rows: number, above: boolean, below: boolean}}
286
+ * the painted item range, the rows it spends and whether items remain on
287
+ * either side of it.
288
+ */
289
+ function scrollWindow(rowCosts, options = {}) {
290
+ const costs = toCosts(rowCosts);
291
+ const count = costs.length;
292
+ if (count === 0) return { start: 0, end: 0, rows: 0, above: false, below: false };
293
+ const budget = Math.max(1, Math.floor(num(options && options.budget)));
294
+ const index = clampIndex(options && options.index, count - 1);
295
+ const tailStart = windowStartFor(costs, count - 1, budget);
296
+ let start = clampIndex(options && options.start, count - 1);
297
+ // A window that stops short of the list's end shows its last full screen
298
+ // instead of trailing blank rows.
299
+ if (start > tailStart) start = tailStart;
300
+ if (index < start) {
301
+ start = index;
302
+ } else if (index >= windowEnd(costs, start, budget)) {
303
+ const wanted = windowStartFor(costs, index, budget);
304
+ // Prefer a full window at the bottom, but never at the cost of hiding the
305
+ // focused row: keep the focused window when the full one would drop it.
306
+ start = (wanted > tailStart && index < windowEnd(costs, tailStart, budget)) ? tailStart : wanted;
307
+ }
308
+ const end = windowEnd(costs, start, budget);
309
+ let rows = 0;
310
+ for (let at = start; at < end; at += 1) rows += costs[at];
311
+ return { start, end, rows, above: start > 0, below: end < count };
312
+ }
313
+
314
+ /**
315
+ * The window start `step` screenfuls away from `start`, for ←/→ scrolling.
316
+ *
317
+ * A forward step begins at the item just past the last visible one, a backward
318
+ * step returns to the screen above it, and a step that would run past either
319
+ * end clamps to the first or the last FULL window — so scrolling never parks on
320
+ * a half-empty screen (S3).
321
+ *
322
+ * @param {number[]} rowCosts the screen rows each item needs, in list order.
323
+ * @param {{budget?: number, start?: number, step?: number}} [options]
324
+ * @returns {number} the index of the first item to paint.
325
+ */
326
+ function shiftWindow(rowCosts, options = {}) {
327
+ const costs = toCosts(rowCosts);
328
+ const count = costs.length;
329
+ if (count === 0) return 0;
330
+ const budget = Math.max(1, Math.floor(num(options && options.budget)));
331
+ const at = clampIndex(options && options.start, count - 1);
332
+ const step = Math.trunc(num(options && options.step));
333
+ if (step === 0) return at;
334
+ const tailStart = windowStartFor(costs, count - 1, budget);
335
+ if (step > 0) {
336
+ let cursor = at;
337
+ for (let moved = 0; moved < step; moved += 1) {
338
+ const end = windowEnd(costs, cursor, budget);
339
+ if (end >= count) return Math.min(cursor, tailStart);
340
+ cursor = end;
341
+ }
342
+ return Math.min(cursor, tailStart);
343
+ }
344
+ let cursor = at;
345
+ for (let moved = 0; moved < -step; moved += 1) {
346
+ if (cursor <= 0) return 0;
347
+ cursor = windowStartFor(costs, cursor - 1, budget);
348
+ }
349
+ return Math.max(0, cursor);
350
+ }
351
+
352
+ module.exports = {
353
+ CARD_PADDING,
354
+ DIVIDER,
355
+ PREVIEW_MIN,
356
+ LIST_MIN,
357
+ READER_LIST_SHARE,
358
+ innerWidth,
359
+ measure,
360
+ paneWidths,
361
+ wrapLabel,
362
+ rowCost,
363
+ paginate,
364
+ pageForIndex,
365
+ pageOf,
366
+ scrollWindow,
367
+ shiftWindow,
368
+ };