@hraness/dawg 0.7.0 → 0.8.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/CHANGELOG.md +26 -0
- package/DAWG.md +117 -30
- package/README.md +7 -5
- package/core/diff.ts +2 -0
- package/core/ids.ts +52 -0
- package/core/loop.ts +3 -0
- package/core/range.ts +581 -0
- package/core/score.ts +123 -1
- package/core/sdk/print.ts +11 -2
- package/core/sdk/v1.ts +281 -2
- package/core/sections.ts +198 -19
- package/guides/arrange.md +9 -8
- package/guides/audio.md +31 -0
- package/guides/mix.md +3 -1
- package/guides/panes.md +32 -0
- package/guides/project.md +3 -2
- package/guides/rhythm.md +2 -1
- package/guides/tape.md +32 -0
- package/package.json +2 -1
- package/src/agent/agent.ts +4 -4
- package/src/agent/command-agent.ts +19 -10
- package/src/agent/ops.ts +15 -0
- package/src/agent/planner.ts +12 -0
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/show-me.ts +1 -1
- package/src/agent/tools.ts +2 -0
- package/src/agent/xcb-agent.ts +2 -3
- package/src/audio/arrange.ts +12 -2
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +84 -13
- package/src/audio/engine.ts +216 -28
- package/src/commands/arrange.ts +60 -25
- package/src/commands/grammar.ts +3 -1
- package/src/commands/help.ts +187 -9
- package/src/commands/history.ts +122 -11
- package/src/commands/keys.ts +17 -0
- package/src/commands/parses.ts +6 -1
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +2 -6
- package/src/commands/vocal-pitch.ts +2 -1
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +18 -0
- package/src/launch-args.ts +105 -1
- package/src/main.ts +1142 -86
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +196 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/origin.ts +20 -0
- package/src/session/port.ts +87 -10
- package/src/session/protocol.ts +360 -6
- package/src/session/rebase.ts +46 -8
- package/src/session/receipt.ts +7 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +61 -2
- package/src/tui/arrange-menu.ts +169 -1
- package/src/tui/euclid.ts +95 -14
- package/src/tui/fader.ts +55 -2
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu.ts +228 -2
- package/src/tui/play-session.ts +196 -21
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/tui/app.ts +217 -44
- package/tui/delight.ts +49 -0
- package/tui/drawer.ts +167 -9
- package/tui/grammar.ts +34 -5
- package/tui/guide.ts +8 -1
- package/tui/highway.ts +40 -5
- package/tui/hints.ts +5 -0
- package/tui/hits.ts +7 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +12 -0
- package/tui/screen.ts +7 -4
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +19 -1
package/tui/tape.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TAPE (op1-ux §6, §8.3): every track across the bars, as a pure painter.
|
|
3
|
+
*
|
|
4
|
+
* The caller (src/tui/tape-view.ts) turns a score into a `TapeView`: one
|
|
5
|
+
* density level per cell and track, the sections, the loop range, the
|
|
6
|
+
* playhead and the knob slots. This module only draws it, so the layout is
|
|
7
|
+
* testable without a session. Colour is never the only cue: the playhead is
|
|
8
|
+
* `▼`/`│`, the loop `[══]`, the focused row `›`, a muted row dim `·`, a
|
|
9
|
+
* form repeat `░` (ASCII `~`), and every knob carries its shape glyph
|
|
10
|
+
* (NO_COLOR and the mono theme).
|
|
11
|
+
*/
|
|
12
|
+
import type { HitMap } from "./hits.ts";
|
|
13
|
+
import { paintKnobStrip, type KnobIndex, type KnobSlots } from "./knobs.ts";
|
|
14
|
+
import type { CellBuffer } from "./screen.ts";
|
|
15
|
+
import { displayWidth, truncate } from "./text.ts";
|
|
16
|
+
import { accentStyle, type Style, type Theme } from "./theme.ts";
|
|
17
|
+
|
|
18
|
+
/** One track row: its density levels (0 empty … 8 dense), one per cell. */
|
|
19
|
+
export type TapeRow = Readonly<{
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
muted: boolean;
|
|
23
|
+
levels: readonly number[];
|
|
24
|
+
/** Other panes on this track (§12.7): `B`, `C●`; empty when none. */
|
|
25
|
+
marks: string;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
export type TapeSection = Readonly<{
|
|
29
|
+
name: string;
|
|
30
|
+
startBar: number;
|
|
31
|
+
bars: number;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
/** Cells per bar: half beats, beats or whole bars (`-` / `=` zoom). */
|
|
35
|
+
export type TapeZoom = "half" | "beat" | "bar";
|
|
36
|
+
export const TAPE_ZOOMS: readonly TapeZoom[] = ["bar", "beat", "half"];
|
|
37
|
+
|
|
38
|
+
export type TapeView = Readonly<{
|
|
39
|
+
bars: number;
|
|
40
|
+
/** The bar (0-based) each cell belongs to, in order. */
|
|
41
|
+
cellBars: readonly number[];
|
|
42
|
+
/** Cell under the playhead. */
|
|
43
|
+
playheadCell: number;
|
|
44
|
+
/** The loop range (`score.loop`, or the looped section's bars). */
|
|
45
|
+
loop?: Readonly<{ startBar: number; bars: number }> | undefined;
|
|
46
|
+
sections: readonly TapeSection[];
|
|
47
|
+
/**
|
|
48
|
+
* An unrolled form (op1-ux §4): per cell, whether it is a later pass of a
|
|
49
|
+
* section (ghosted `░`, ASCII `~`), and which form pass it belongs to
|
|
50
|
+
* (-1 for bars the form never plays). Absent without a form.
|
|
51
|
+
*/
|
|
52
|
+
ghosts?: readonly boolean[] | undefined;
|
|
53
|
+
passes?: readonly number[] | undefined;
|
|
54
|
+
/** Each form pass's label (`chorus ×2`), indexed by `passes`. */
|
|
55
|
+
passNames?: readonly string[] | undefined;
|
|
56
|
+
/** The looped section's name, drawn reversed. */
|
|
57
|
+
loopSection?: string | undefined;
|
|
58
|
+
/** Tempo changes (`♩96` marks); empty when the tempo is constant. */
|
|
59
|
+
tempo: readonly Readonly<{ cell: number; bpm: number }>[];
|
|
60
|
+
rows: readonly TapeRow[];
|
|
61
|
+
focused: number;
|
|
62
|
+
/** `range: bass · bars 5–6 (loop)`. */
|
|
63
|
+
range: string;
|
|
64
|
+
/** `clipboard: drums · 2 bars`, when the pane holds one. */
|
|
65
|
+
clipboard?: string | undefined;
|
|
66
|
+
knobs: KnobSlots;
|
|
67
|
+
selected: KnobIndex;
|
|
68
|
+
/** The gesture row, from the screen's key table. */
|
|
69
|
+
hint: string;
|
|
70
|
+
/** The reels (`◐◓◑◒`, §9.1), turning on beats while playing. */
|
|
71
|
+
reel?: string | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* The loop just wrapped (a recording pass closed, §9.3): the brackets
|
|
74
|
+
* draw reversed and bold and the fill turns `━`, so it reads in mono.
|
|
75
|
+
*/
|
|
76
|
+
loopFlash?: boolean | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
|
|
79
|
+
/** Glyph for a density level, 1 … 8. */
|
|
80
|
+
const LEVELS = " ▁▂▃▄▅▆▇█";
|
|
81
|
+
const ASCII_LEVELS = " .:-=+*#@";
|
|
82
|
+
/** A later pass of a section on an unrolled form. */
|
|
83
|
+
const GHOST = "░";
|
|
84
|
+
const ASCII_GHOST = "~";
|
|
85
|
+
|
|
86
|
+
/** Columns of the row gutter: ` 2›bass `. */
|
|
87
|
+
export const TAPE_GUTTER = 9;
|
|
88
|
+
|
|
89
|
+
/** The rows the tape keeps below the tracks: range, knobs, hints. */
|
|
90
|
+
const FOOTER_ROWS = 3;
|
|
91
|
+
|
|
92
|
+
/** First visible cell: pages so the playhead stays on screen. */
|
|
93
|
+
export function tapeFirstCell(view: TapeView, cells: number): number {
|
|
94
|
+
if (view.cellBars.length <= cells) return 0;
|
|
95
|
+
const page = Math.floor(view.playheadCell / cells);
|
|
96
|
+
return Math.min(page * cells, view.cellBars.length - cells);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** First visible track row: keeps the focused row on screen. */
|
|
100
|
+
export function tapeFirstRow(view: TapeView, rows: number): number {
|
|
101
|
+
if (view.rows.length <= rows) return 0;
|
|
102
|
+
return Math.max(
|
|
103
|
+
0,
|
|
104
|
+
Math.min(view.focused - rows + 1, view.rows.length - rows),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TapePaintOptions = Readonly<{
|
|
109
|
+
theme: Theme;
|
|
110
|
+
unicode: boolean;
|
|
111
|
+
hits?: HitMap | undefined;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
/** What the painter laid out, for mouse hits and tests. */
|
|
115
|
+
export type TapeLayout = Readonly<{
|
|
116
|
+
rulerY: number;
|
|
117
|
+
firstCell: number;
|
|
118
|
+
cells: number;
|
|
119
|
+
firstRow: number;
|
|
120
|
+
rowsY: number;
|
|
121
|
+
rowCount: number;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
export function paintTape(
|
|
125
|
+
buffer: CellBuffer,
|
|
126
|
+
rect: Readonly<{ x: number; y: number; width: number; height: number }>,
|
|
127
|
+
view: TapeView,
|
|
128
|
+
options: TapePaintOptions,
|
|
129
|
+
): TapeLayout {
|
|
130
|
+
const { theme, unicode } = options;
|
|
131
|
+
const roles = theme.roles;
|
|
132
|
+
// ` B C●`: room for up to three pane letters right of the cells.
|
|
133
|
+
const marksWidth = view.rows.some((row) => row.marks) ? 7 : 0;
|
|
134
|
+
const cells = Math.max(
|
|
135
|
+
0,
|
|
136
|
+
Math.min(view.cellBars.length, rect.width - TAPE_GUTTER - marksWidth),
|
|
137
|
+
);
|
|
138
|
+
const firstCell = tapeFirstCell(view, cells);
|
|
139
|
+
const left = rect.x + TAPE_GUTTER;
|
|
140
|
+
const bottom = rect.y + rect.height;
|
|
141
|
+
const cellBar = (cell: number): number => view.cellBars[cell] ?? view.bars;
|
|
142
|
+
const ghost = (cell: number): boolean => view.ghosts?.[cell] ?? false;
|
|
143
|
+
const passOf = (cell: number): number | undefined => view.passes?.[cell];
|
|
144
|
+
const barStart = (cell: number): boolean =>
|
|
145
|
+
cell === 0 ||
|
|
146
|
+
cellBar(cell) !== cellBar(cell - 1) ||
|
|
147
|
+
passOf(cell) !== passOf(cell - 1);
|
|
148
|
+
const loopStart = view.loop?.startBar;
|
|
149
|
+
const loopEnd = view.loop ? view.loop.startBar + view.loop.bars : undefined;
|
|
150
|
+
// The loop brackets its source bars, never a ghost pass of them.
|
|
151
|
+
const inLoop = (cell: number): boolean =>
|
|
152
|
+
loopStart !== undefined &&
|
|
153
|
+
!ghost(cell) &&
|
|
154
|
+
cellBar(cell) >= loopStart &&
|
|
155
|
+
cellBar(cell) < loopEnd!;
|
|
156
|
+
|
|
157
|
+
// Ruler: bar numbers every 4 bars and at the loop edges; `[══]` in green.
|
|
158
|
+
let y = rect.y;
|
|
159
|
+
const ruler = Array.from({ length: cells }, () => " ");
|
|
160
|
+
const rulerStyle: (Style | undefined)[] = Array.from(
|
|
161
|
+
{ length: cells },
|
|
162
|
+
() => roles.muted,
|
|
163
|
+
);
|
|
164
|
+
const flash = view.loopFlash === true;
|
|
165
|
+
const bracketStyle: Style = flash
|
|
166
|
+
? { ...roles.knob2, bold: true, reverse: true }
|
|
167
|
+
: roles.knob2;
|
|
168
|
+
for (let index = 0; index < cells; index += 1) {
|
|
169
|
+
const cell = firstCell + index;
|
|
170
|
+
if (inLoop(cell)) {
|
|
171
|
+
ruler[index] = flash ? (unicode ? "━" : "#") : unicode ? "═" : "=";
|
|
172
|
+
rulerStyle[index] = flash ? { ...roles.knob2, bold: true } : roles.knob2;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (let index = 0; index < cells; index += 1) {
|
|
176
|
+
const cell = firstCell + index;
|
|
177
|
+
if (!barStart(cell)) continue;
|
|
178
|
+
const bar = cellBar(cell);
|
|
179
|
+
const edge = !ghost(cell) && (bar === loopStart || bar + 1 === loopEnd);
|
|
180
|
+
if (bar % 4 !== 0 && !edge && bar !== view.bars - 1) continue;
|
|
181
|
+
const label = String(bar + 1);
|
|
182
|
+
const at = index + (edge && bar === loopStart ? 1 : 0);
|
|
183
|
+
// A number never overwrites a neighbour's digits.
|
|
184
|
+
if (ruler.slice(at, at + label.length).some((ch) => /\d/.test(ch)))
|
|
185
|
+
continue;
|
|
186
|
+
for (let k = 0; k < label.length && at + k < cells; k += 1) {
|
|
187
|
+
ruler[at + k] = label[k]!;
|
|
188
|
+
if (!inLoop(cell)) rulerStyle[at + k] = roles.muted;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (view.loop) {
|
|
192
|
+
for (let index = 0; index < cells; index += 1) {
|
|
193
|
+
const cell = firstCell + index;
|
|
194
|
+
if (barStart(cell) && cellBar(cell) === loopStart && !ghost(cell)) {
|
|
195
|
+
ruler[index] = "[";
|
|
196
|
+
rulerStyle[index] = bracketStyle;
|
|
197
|
+
}
|
|
198
|
+
if (inLoop(cell) && !inLoop(cell + 1)) {
|
|
199
|
+
ruler[index] = "]";
|
|
200
|
+
rulerStyle[index] = bracketStyle;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const head = view.playheadCell - firstCell;
|
|
205
|
+
if (head >= 0 && head < cells) {
|
|
206
|
+
// On a loop bracket the bracket stays and the playhead reverses it, so
|
|
207
|
+
// both read without color (the rows still carry the `│` column).
|
|
208
|
+
if (ruler[head] === "[" || ruler[head] === "]")
|
|
209
|
+
rulerStyle[head] = { ...roles.knob1, reverse: true };
|
|
210
|
+
else {
|
|
211
|
+
ruler[head] = unicode ? "▼" : "v";
|
|
212
|
+
rulerStyle[head] = roles.knob1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
buffer.text(rect.x, y, " bar", roles.faint);
|
|
216
|
+
// The reels sit in the gutter: a glyph that turns, so never color-only.
|
|
217
|
+
if (view.reel) buffer.text(rect.x + 6, y, view.reel, roles.warning);
|
|
218
|
+
ruler.forEach((ch, index) =>
|
|
219
|
+
buffer.set(left + index, y, ch, rulerStyle[index]),
|
|
220
|
+
);
|
|
221
|
+
const rulerY = y;
|
|
222
|
+
options.hits?.add(left, y, cells, 1, { kind: "tape-ruler", left, firstCell });
|
|
223
|
+
y += 1;
|
|
224
|
+
|
|
225
|
+
// Sections: `▀name▀▀▀`; the looped one reversed; a form repeat `░name░`.
|
|
226
|
+
if (y < bottom - FOOTER_ROWS) {
|
|
227
|
+
buffer.text(rect.x, y, " sect", roles.faint);
|
|
228
|
+
const sectionAt = (cell: number): TapeSection | undefined => {
|
|
229
|
+
const bar = cellBar(cell);
|
|
230
|
+
return view.sections.find(
|
|
231
|
+
(item) => bar >= item.startBar && bar < item.startBar + item.bars,
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
const sectionStyle = (section: TapeSection, cell: number): Style => ({
|
|
235
|
+
...accentStyle(theme, `section:${section.name}`),
|
|
236
|
+
...(ghost(cell) ? { dim: true } : {}),
|
|
237
|
+
...(section.name === view.loopSection && !ghost(cell)
|
|
238
|
+
? { reverse: true }
|
|
239
|
+
: {}),
|
|
240
|
+
});
|
|
241
|
+
for (let index = 0; index < cells; index += 1) {
|
|
242
|
+
const cell = firstCell + index;
|
|
243
|
+
const section = sectionAt(cell);
|
|
244
|
+
if (!section) {
|
|
245
|
+
buffer.set(left + index, y, " ", undefined);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
buffer.set(
|
|
249
|
+
left + index,
|
|
250
|
+
y,
|
|
251
|
+
ghost(cell) ? (unicode ? GHOST : ASCII_GHOST) : unicode ? "▀" : "-",
|
|
252
|
+
sectionStyle(section, cell),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
// A label at each section's first cell (each form pass when unrolled).
|
|
256
|
+
for (let index = 0; index < cells; index += 1) {
|
|
257
|
+
const cell = firstCell + index;
|
|
258
|
+
const section = sectionAt(cell);
|
|
259
|
+
if (!section) continue;
|
|
260
|
+
const pass = passOf(cell);
|
|
261
|
+
const starts =
|
|
262
|
+
cell === firstCell
|
|
263
|
+
? true
|
|
264
|
+
: sectionAt(cell - 1) !== section || pass !== passOf(cell - 1);
|
|
265
|
+
if (!starts) continue;
|
|
266
|
+
let room = 1;
|
|
267
|
+
while (
|
|
268
|
+
index + room < cells &&
|
|
269
|
+
sectionAt(cell + room) === section &&
|
|
270
|
+
passOf(cell + room) === pass
|
|
271
|
+
)
|
|
272
|
+
room += 1;
|
|
273
|
+
const name =
|
|
274
|
+
pass !== undefined && pass >= 0
|
|
275
|
+
? (view.passNames?.[pass] ?? section.name)
|
|
276
|
+
: section.name;
|
|
277
|
+
// A ghost keeps a leading `░` so the repeat reads in mono too.
|
|
278
|
+
if (ghost(cell) && room <= 2) continue;
|
|
279
|
+
const offset = ghost(cell) ? 1 : 0;
|
|
280
|
+
const label = truncate(name, room - offset, "");
|
|
281
|
+
buffer.text(left + index + offset, y, label, {
|
|
282
|
+
...sectionStyle(section, cell),
|
|
283
|
+
bold: true,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
y += 1;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Tempo marks, only when the tempo changes.
|
|
290
|
+
if (view.tempo.length > 0 && y < bottom - FOOTER_ROWS) {
|
|
291
|
+
buffer.text(rect.x, y, " tempo", roles.faint);
|
|
292
|
+
for (const mark of view.tempo) {
|
|
293
|
+
const at = mark.cell - firstCell;
|
|
294
|
+
if (at < 0 || at >= cells) continue;
|
|
295
|
+
buffer.text(
|
|
296
|
+
left + at,
|
|
297
|
+
y,
|
|
298
|
+
truncate(`${unicode ? "♩" : "q"}${mark.bpm}`, cells - at, ""),
|
|
299
|
+
roles.knob3,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
y += 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Track rows.
|
|
306
|
+
const rowsY = y;
|
|
307
|
+
const rowRoom = Math.max(0, bottom - FOOTER_ROWS - y);
|
|
308
|
+
const firstRow = tapeFirstRow(view, rowRoom);
|
|
309
|
+
const levels = unicode ? LEVELS : ASCII_LEVELS;
|
|
310
|
+
const visible = view.rows.slice(firstRow, firstRow + rowRoom);
|
|
311
|
+
visible.forEach((row, offset) => {
|
|
312
|
+
const index = firstRow + offset;
|
|
313
|
+
const focused = index === view.focused;
|
|
314
|
+
const number = index < 9 ? String(index + 1) : " ";
|
|
315
|
+
const accent = accentStyle(theme, row.id);
|
|
316
|
+
buffer.text(rect.x, y, ` ${number}`, roles.faint);
|
|
317
|
+
buffer.text(rect.x + 2, y, focused ? (unicode ? "›" : ">") : " ", {
|
|
318
|
+
...roles.text,
|
|
319
|
+
bold: true,
|
|
320
|
+
});
|
|
321
|
+
const name = truncate(row.name, 6, "").padEnd(6);
|
|
322
|
+
buffer.text(
|
|
323
|
+
rect.x + 3,
|
|
324
|
+
y,
|
|
325
|
+
name,
|
|
326
|
+
focused
|
|
327
|
+
? { ...accent, bold: true, reverse: true }
|
|
328
|
+
: row.muted
|
|
329
|
+
? roles.mutedNote
|
|
330
|
+
: accent,
|
|
331
|
+
);
|
|
332
|
+
for (let column = 0; column < cells; column += 1) {
|
|
333
|
+
const cell = firstCell + column;
|
|
334
|
+
const level = row.levels[cell] ?? 0;
|
|
335
|
+
const repeat = ghost(cell);
|
|
336
|
+
const style: Style | undefined =
|
|
337
|
+
column === head
|
|
338
|
+
? // Over a note the playhead reverses it: visible in mono too.
|
|
339
|
+
{ ...roles.knob1, ...(level > 0 ? { reverse: true } : {}) }
|
|
340
|
+
: row.muted
|
|
341
|
+
? roles.mutedNote
|
|
342
|
+
: level > 0
|
|
343
|
+
? repeat
|
|
344
|
+
? { ...accent, dim: true }
|
|
345
|
+
: accent
|
|
346
|
+
: barStart(cell)
|
|
347
|
+
? roles.barRule
|
|
348
|
+
: roles.beatRule;
|
|
349
|
+
const ch =
|
|
350
|
+
level > 0
|
|
351
|
+
? repeat
|
|
352
|
+
? unicode
|
|
353
|
+
? GHOST
|
|
354
|
+
: ASCII_GHOST
|
|
355
|
+
: levels[Math.min(8, level)]!
|
|
356
|
+
: column === head
|
|
357
|
+
? unicode
|
|
358
|
+
? "│"
|
|
359
|
+
: "|"
|
|
360
|
+
: barStart(cell)
|
|
361
|
+
? unicode
|
|
362
|
+
? "┊"
|
|
363
|
+
: ":"
|
|
364
|
+
: "·";
|
|
365
|
+
buffer.set(left + column, y, ch, style);
|
|
366
|
+
}
|
|
367
|
+
// Other panes, dim; a recording pane's `●` in the record red.
|
|
368
|
+
[
|
|
369
|
+
...truncate(
|
|
370
|
+
unicode ? row.marks : row.marks.replace(/●/g, "*"),
|
|
371
|
+
marksWidth - 1,
|
|
372
|
+
"",
|
|
373
|
+
),
|
|
374
|
+
].forEach((ch, k) =>
|
|
375
|
+
buffer.set(
|
|
376
|
+
left + cells + 1 + k,
|
|
377
|
+
y,
|
|
378
|
+
ch,
|
|
379
|
+
ch === "●" || ch === "*" ? roles.error : { ...roles.muted, dim: true },
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
options.hits?.add(rect.x, y, rect.width, 1, {
|
|
383
|
+
kind: "tape-row",
|
|
384
|
+
row: index,
|
|
385
|
+
left,
|
|
386
|
+
firstCell,
|
|
387
|
+
});
|
|
388
|
+
y += 1;
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Footer: range line with the clipboard chip, knob strip, gesture hints.
|
|
392
|
+
const footerY = bottom - FOOTER_ROWS;
|
|
393
|
+
if (footerY >= rowsY) {
|
|
394
|
+
buffer.text(
|
|
395
|
+
rect.x + 1,
|
|
396
|
+
footerY,
|
|
397
|
+
truncate(view.range, rect.width - 2),
|
|
398
|
+
roles.text,
|
|
399
|
+
);
|
|
400
|
+
if (view.clipboard) {
|
|
401
|
+
const chip = `${unicode ? "◆" : "*"} ${view.clipboard}`;
|
|
402
|
+
const room = rect.width - 2 - displayWidth(view.range) - 3;
|
|
403
|
+
if (room >= 12) {
|
|
404
|
+
const text = truncate(chip, room);
|
|
405
|
+
const x = rect.x + rect.width - 1 - displayWidth(text);
|
|
406
|
+
buffer.text(x, footerY, text, roles.warning);
|
|
407
|
+
options.hits?.add(x, footerY, displayWidth(text), 1, {
|
|
408
|
+
kind: "tape-clipboard",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
paintKnobStrip(
|
|
413
|
+
buffer,
|
|
414
|
+
rect.x + 1,
|
|
415
|
+
footerY + 1,
|
|
416
|
+
rect.width - 2,
|
|
417
|
+
view.knobs,
|
|
418
|
+
view.selected,
|
|
419
|
+
{
|
|
420
|
+
theme,
|
|
421
|
+
unicode,
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
buffer.text(
|
|
425
|
+
rect.x + 1,
|
|
426
|
+
footerY + 2,
|
|
427
|
+
truncate(view.hint, rect.width - 2),
|
|
428
|
+
roles.muted,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
rulerY,
|
|
433
|
+
firstCell,
|
|
434
|
+
cells,
|
|
435
|
+
firstRow,
|
|
436
|
+
rowsY,
|
|
437
|
+
rowCount: visible.length,
|
|
438
|
+
};
|
|
439
|
+
}
|
package/tui/text.ts
CHANGED
|
@@ -30,9 +30,37 @@ export function graphemeWidth(cluster: string): number {
|
|
|
30
30
|
return Math.max(0, Math.min(2, width));
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/** Printable ASCII only: one cell per character, no segmentation needed. */
|
|
34
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Recent segmentations. A frame paints the same strings every tick (help
|
|
38
|
+
* rows, labels, hints), and segmenting them is most of a large frame's
|
|
39
|
+
* cost; the cache holds a frame's worth and is dropped whole when full.
|
|
40
|
+
*/
|
|
41
|
+
const segmented = new Map<string, readonly Grapheme[]>();
|
|
42
|
+
const SEGMENTED_MAX = 4096;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Segment text into grapheme clusters with code-point offsets and widths.
|
|
46
|
+
* The result is shared between calls: read it, never mutate it.
|
|
47
|
+
*/
|
|
48
|
+
export function graphemes(text: string): readonly Grapheme[] {
|
|
49
|
+
const cached = segmented.get(text);
|
|
50
|
+
if (cached) return cached;
|
|
51
|
+
const result = segment(text);
|
|
52
|
+
if (segmented.size >= SEGMENTED_MAX) segmented.clear();
|
|
53
|
+
segmented.set(text, result);
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function segment(text: string): Grapheme[] {
|
|
35
58
|
const result: Grapheme[] = [];
|
|
59
|
+
if (PRINTABLE_ASCII.test(text)) {
|
|
60
|
+
for (let index = 0; index < text.length; index += 1)
|
|
61
|
+
result.push({ text: text[index]!, start: index, length: 1, width: 1 });
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
36
64
|
let start = 0;
|
|
37
65
|
const clusters = segmenter
|
|
38
66
|
? Array.from(segmenter.segment(text), (part) => part.segment)
|
|
@@ -51,6 +79,7 @@ export function graphemes(text: string): Grapheme[] {
|
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
export function displayWidth(text: string): number {
|
|
82
|
+
if (PRINTABLE_ASCII.test(text)) return text.length;
|
|
54
83
|
let width = 0;
|
|
55
84
|
for (const cluster of graphemes(text)) width += cluster.width;
|
|
56
85
|
return width;
|
|
@@ -61,6 +90,10 @@ export function truncate(text: string, width: number, ellipsis = "…"): string
|
|
|
61
90
|
if (width <= 0) return "";
|
|
62
91
|
if (displayWidth(text) <= width) return text;
|
|
63
92
|
const room = Math.max(0, width - displayWidth(ellipsis));
|
|
93
|
+
if (PRINTABLE_ASCII.test(text))
|
|
94
|
+
return room === 0
|
|
95
|
+
? ellipsis.slice(0, width)
|
|
96
|
+
: `${text.slice(0, room)}${ellipsis}`;
|
|
64
97
|
let used = 0;
|
|
65
98
|
let out = "";
|
|
66
99
|
for (const cluster of graphemes(text)) {
|
package/tui/theme.ts
CHANGED
|
@@ -56,7 +56,12 @@ export type SemanticRole =
|
|
|
56
56
|
| "promptBg"
|
|
57
57
|
| "promptText"
|
|
58
58
|
| "pillSteer"
|
|
59
|
-
| "pillQueue"
|
|
59
|
+
| "pillQueue"
|
|
60
|
+
/** The four knobs (design §7.1): reserved, never reused for other things. */
|
|
61
|
+
| "knob1"
|
|
62
|
+
| "knob2"
|
|
63
|
+
| "knob3"
|
|
64
|
+
| "knob4";
|
|
60
65
|
|
|
61
66
|
export interface Rgb {
|
|
62
67
|
readonly r: number;
|
|
@@ -115,6 +120,10 @@ const defaultTheme: Theme = {
|
|
|
115
120
|
promptText: { fg: rgb(232, 237, 246) },
|
|
116
121
|
pillSteer: { fg: rgb(17, 20, 26), bg: rgb(91, 211, 145), bold: true },
|
|
117
122
|
pillQueue: { fg: rgb(17, 20, 26), bg: rgb(255, 193, 87), bold: true },
|
|
123
|
+
knob1: { fg: rgb(80, 160, 255), bold: true },
|
|
124
|
+
knob2: { fg: rgb(90, 210, 140), bold: true },
|
|
125
|
+
knob3: { fg: rgb(225, 228, 236), bold: true },
|
|
126
|
+
knob4: { fg: rgb(255, 140, 60), bold: true },
|
|
118
127
|
},
|
|
119
128
|
accents: [
|
|
120
129
|
rgb(95, 181, 255),
|
|
@@ -160,6 +169,10 @@ const highContrastTheme: Theme = {
|
|
|
160
169
|
promptText: { fg: rgb(255, 255, 255), bold: true },
|
|
161
170
|
pillSteer: { fg: rgb(0, 0, 0), bg: rgb(0, 255, 0), bold: true },
|
|
162
171
|
pillQueue: { fg: rgb(0, 0, 0), bg: rgb(255, 255, 0), bold: true },
|
|
172
|
+
knob1: { fg: rgb(60, 150, 255), bold: true },
|
|
173
|
+
knob2: { fg: rgb(60, 255, 140), bold: true },
|
|
174
|
+
knob3: { fg: rgb(236, 240, 255), bold: true },
|
|
175
|
+
knob4: { fg: rgb(255, 130, 30), bold: true },
|
|
163
176
|
},
|
|
164
177
|
accents: [
|
|
165
178
|
rgb(0, 200, 255),
|
|
@@ -206,6 +219,11 @@ const monoTheme: Theme = {
|
|
|
206
219
|
promptText: {},
|
|
207
220
|
pillSteer: { reverse: true, bold: true },
|
|
208
221
|
pillQueue: { reverse: true, bold: true, underline: true },
|
|
222
|
+
// No hue: the knob glyphs (● ▲ ■ ◆) carry which knob is which.
|
|
223
|
+
knob1: { bold: true },
|
|
224
|
+
knob2: { bold: true },
|
|
225
|
+
knob3: { bold: true },
|
|
226
|
+
knob4: { bold: true },
|
|
209
227
|
},
|
|
210
228
|
accents: [],
|
|
211
229
|
};
|