pi-odd-worktree 0.0.0-stage → 1.0.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/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
package/lib/tui-paint.js
ADDED
|
@@ -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
|
+
};
|