@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/app.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* injectable `TerminalIO`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { paintTape, type TapeView } from "./tape.ts";
|
|
15
16
|
import {
|
|
16
17
|
ActivityFeed,
|
|
17
18
|
ERROR_FADE_MS,
|
|
@@ -76,8 +77,10 @@ export interface AppView {
|
|
|
76
77
|
sync?: SyncState | undefined;
|
|
77
78
|
/** Human session name; replaces the short id in the header when set. */
|
|
78
79
|
sessionName?: string | undefined;
|
|
79
|
-
/** Live
|
|
80
|
+
/** Live panes on this session (presence); shown when more than one. */
|
|
80
81
|
windows?: number | undefined;
|
|
82
|
+
/** This pane's letter (dawgd), shown beside the count: `B ⧉3` (`B 3 panes` in ASCII). */
|
|
83
|
+
pane?: string | undefined;
|
|
81
84
|
/** Project typecheck result; `types ✓` or `types ✗ N` beside sync. */
|
|
82
85
|
types?: TypesIndicator | undefined;
|
|
83
86
|
/**
|
|
@@ -95,6 +98,8 @@ export interface AppView {
|
|
|
95
98
|
{ ghost?: string | undefined; caption?: string | undefined } | undefined;
|
|
96
99
|
/** Play mode: replaces the header and adds the keyboard strip row. */
|
|
97
100
|
play?: PlayHeaderView | undefined;
|
|
101
|
+
/** TAPE (op1-ux §6): replaces the highway and the arrange strip. */
|
|
102
|
+
tape?: TapeView | undefined;
|
|
98
103
|
/** Song master meter: integrated LUFS and true peak of the playing loop. */
|
|
99
104
|
loudness?: LoudnessView | undefined;
|
|
100
105
|
/** Song sections over the timeline (0.5); no row when absent. */
|
|
@@ -267,8 +272,40 @@ export interface FrameLayout {
|
|
|
267
272
|
tooSmall: boolean;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
/**
|
|
276
|
+
* The smallest terminal the real UI draws in (DAWG.md "Terminal
|
|
277
|
+
* sizes"). Measured with `bun run sizes`: below 60 columns the play-mode
|
|
278
|
+
* key strip and the header's bar position drop out and the prompt's spend
|
|
279
|
+
* line clips; below 16 rows the prompt loses its footer and the drawer its
|
|
280
|
+
* last knob. 80x24 stays the design target.
|
|
281
|
+
*/
|
|
282
|
+
export const MIN_WIDTH = 60;
|
|
283
|
+
export const MIN_HEIGHT = 16;
|
|
284
|
+
|
|
285
|
+
/** True when a terminal of this size shows the too-small screen instead. */
|
|
286
|
+
export function isTooSmall(size: FrameSize): boolean {
|
|
287
|
+
return !(size.width >= MIN_WIDTH && size.height >= MIN_HEIGHT);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The longest line a text panel (help, guides, the transcript, menus) sets:
|
|
291
|
+
* past about 100 columns prose stops being readable and a command table's
|
|
292
|
+
* two columns drift apart, so on a wide terminal the panel holds this
|
|
293
|
+
* measure, left-aligned, and the song keeps drawing beside it (see
|
|
294
|
+
* `SIDE_MIN_WIDTH`).
|
|
295
|
+
*/
|
|
296
|
+
export const TEXT_MEASURE = 100;
|
|
297
|
+
/** The fewest columns beside a capped panel worth drawing the view into. */
|
|
298
|
+
export const SIDE_MIN_WIDTH = 36;
|
|
299
|
+
|
|
300
|
+
/** Where a text panel sits in a terminal `width` columns wide. */
|
|
301
|
+
export function panelRect(
|
|
302
|
+
width: number,
|
|
303
|
+
measure = TEXT_MEASURE,
|
|
304
|
+
): { left: number; boxWidth: number } {
|
|
305
|
+
const left = width >= 60 ? 2 : 0;
|
|
306
|
+
return { left, boxWidth: Math.min(width - left * 2, measure + 4) };
|
|
307
|
+
}
|
|
308
|
+
|
|
272
309
|
export const MAX_PROMPT_ROWS = 8;
|
|
273
310
|
/** New cards glow for this long before settling. */
|
|
274
311
|
export const CARD_GLOW_MS = 700;
|
|
@@ -306,7 +343,7 @@ export function computeLayout(
|
|
|
306
343
|
size: FrameSize,
|
|
307
344
|
promptWrappedRows: number,
|
|
308
345
|
): FrameLayout {
|
|
309
|
-
const tooSmall = size
|
|
346
|
+
const tooSmall = isTooSmall(size);
|
|
310
347
|
const rows = Math.max(
|
|
311
348
|
1,
|
|
312
349
|
Math.min(promptWrappedRows, promptRowCap(size.height)),
|
|
@@ -450,6 +487,54 @@ export function playContext(
|
|
|
450
487
|
].join(" · ");
|
|
451
488
|
}
|
|
452
489
|
|
|
490
|
+
/** `5.3`: the 1-based bar and beat of a song beat. */
|
|
491
|
+
export function barBeatLabel(
|
|
492
|
+
beat: number,
|
|
493
|
+
beatsPerBar: number,
|
|
494
|
+
barBeats?: readonly number[],
|
|
495
|
+
): string {
|
|
496
|
+
const at = Number.isFinite(beat) ? Math.max(0, beat) : 0;
|
|
497
|
+
if (barBeats && barBeats.length > 0) {
|
|
498
|
+
let bar = 0;
|
|
499
|
+
while (bar + 1 < barBeats.length && barBeats[bar + 1]! <= at + 1e-9)
|
|
500
|
+
bar += 1;
|
|
501
|
+
return `${bar + 1}.${Math.floor(at - barBeats[bar]! + 1e-9) + 1}`;
|
|
502
|
+
}
|
|
503
|
+
const per = Math.max(1, beatsPerBar);
|
|
504
|
+
const bar = Math.floor(at / per + 1e-9);
|
|
505
|
+
return `${bar + 1}.${Math.floor(at - bar * per + 1e-9) + 1}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** `↻ 5–6` (`loop 5-6` without Unicode): the loop range, 1-based bars. */
|
|
509
|
+
export function loopRangeLabel(
|
|
510
|
+
range: Readonly<{ startBar: number; bars: number }>,
|
|
511
|
+
unicode: boolean,
|
|
512
|
+
): string {
|
|
513
|
+
const first = range.startBar + 1;
|
|
514
|
+
const last = range.startBar + range.bars;
|
|
515
|
+
const bars =
|
|
516
|
+
first === last ? `${first}` : `${first}${unicode ? "–" : "-"}${last}`;
|
|
517
|
+
return unicode ? `↻ ${bars}` : `loop ${bars}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** `B ⧉3`: this pane's letter and how many panes are live. */
|
|
521
|
+
export function paneLabel(
|
|
522
|
+
windows: number,
|
|
523
|
+
pane: string | undefined,
|
|
524
|
+
unicode: boolean,
|
|
525
|
+
): string {
|
|
526
|
+
const count = unicode ? `⧉${windows}` : `${windows} panes`;
|
|
527
|
+
return pane ? `${pane} ${count}` : count;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The footer's right side: the session name, then the spend line. */
|
|
531
|
+
export function footerStatus(view: AppView, sessionId?: string): string {
|
|
532
|
+
const session =
|
|
533
|
+
view.sessionName ??
|
|
534
|
+
(sessionId ? `session ${sessionId.slice(0, 8)}` : undefined);
|
|
535
|
+
return [session, view.spend].filter(Boolean).join(" · ");
|
|
536
|
+
}
|
|
537
|
+
|
|
453
538
|
function paintHeader(
|
|
454
539
|
buffer: CellBuffer,
|
|
455
540
|
view: AppView,
|
|
@@ -475,14 +560,11 @@ function paintHeader(
|
|
|
475
560
|
});
|
|
476
561
|
const transport = `${playing ? (unicode ? "▶" : ">") : unicode ? "⏸" : "||"} ${score.bpm ?? 120} BPM`;
|
|
477
562
|
const name = score.trackName ?? score.trackId ?? "track";
|
|
563
|
+
// dawg · ▶ BPM · bar.beat · ↻ loop · track ··· key · model · rev · pane
|
|
564
|
+
// (design §8.2): where you are in the song reads first; the session name
|
|
565
|
+
// lives in the footer.
|
|
478
566
|
const left: Segment[] = [
|
|
479
567
|
{ text: "dawg", style: { ...roles.muted, bold: true }, priority: 6 },
|
|
480
|
-
{
|
|
481
|
-
text: name,
|
|
482
|
-
style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
|
|
483
|
-
priority: 0,
|
|
484
|
-
target: { kind: "tracks" },
|
|
485
|
-
},
|
|
486
568
|
{
|
|
487
569
|
text: transport,
|
|
488
570
|
style: playing
|
|
@@ -493,24 +575,29 @@ function paintHeader(
|
|
|
493
575
|
priority: 0,
|
|
494
576
|
target: { kind: "transport" },
|
|
495
577
|
},
|
|
578
|
+
{
|
|
579
|
+
text: barBeatLabel(beat, score.beatsPerBar ?? 4, score.barBeats),
|
|
580
|
+
style: roles.text,
|
|
581
|
+
priority: 2,
|
|
582
|
+
},
|
|
496
583
|
];
|
|
497
|
-
if (score.
|
|
498
|
-
left.push({ text: score.key, style: roles.muted, priority: 5 });
|
|
499
|
-
if (view.sessionName)
|
|
500
|
-
left.push({ text: view.sessionName, style: roles.muted, priority: 3 });
|
|
501
|
-
else if (score.sessionId)
|
|
584
|
+
if (score.loopRange)
|
|
502
585
|
left.push({
|
|
503
|
-
text:
|
|
504
|
-
|
|
586
|
+
text: loopRangeLabel(score.loopRange, unicode),
|
|
587
|
+
// The loop is the green knob's (§8.1): it wears that colour.
|
|
588
|
+
style: roles.knob2,
|
|
505
589
|
priority: 3,
|
|
506
590
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
591
|
+
left.push({
|
|
592
|
+
text: name,
|
|
593
|
+
style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
|
|
594
|
+
priority: 0,
|
|
595
|
+
target: { kind: "tracks" },
|
|
596
|
+
});
|
|
513
597
|
const right: Segment[] = [];
|
|
598
|
+
// The key never drops before the model, rev or sync at 80 columns.
|
|
599
|
+
if (score.key)
|
|
600
|
+
right.push({ text: score.key, style: roles.muted, priority: 1 });
|
|
514
601
|
if (view.model)
|
|
515
602
|
right.push({
|
|
516
603
|
text: view.model,
|
|
@@ -524,6 +611,12 @@ function paintHeader(
|
|
|
524
611
|
style: roles.text,
|
|
525
612
|
priority: 1,
|
|
526
613
|
});
|
|
614
|
+
if (view.windows !== undefined && view.windows > 1)
|
|
615
|
+
right.push({
|
|
616
|
+
text: paneLabel(view.windows, view.pane, unicode),
|
|
617
|
+
style: roles.muted,
|
|
618
|
+
priority: 4,
|
|
619
|
+
});
|
|
527
620
|
if (view.types)
|
|
528
621
|
right.push({
|
|
529
622
|
text: view.types.ok
|
|
@@ -768,6 +861,7 @@ function paintPrompt(
|
|
|
768
861
|
if (view.spend) {
|
|
769
862
|
if (!layout.footer) status.push(view.spend);
|
|
770
863
|
} else if (view.model) status.push(view.model);
|
|
864
|
+
if (!layout.footer && view.sessionName) status.unshift(view.sessionName);
|
|
771
865
|
if (activity.queueDepth > 0) status.push(`queue ${activity.queueDepth}`);
|
|
772
866
|
const layoutInfo = prompt.layout(rows);
|
|
773
867
|
if (layoutInfo.total > rows)
|
|
@@ -845,7 +939,8 @@ function paintPrompt(
|
|
|
845
939
|
: width >= 52
|
|
846
940
|
? " enter · ctrl-q now/next · ctrl-z undo · ctrl-o log "
|
|
847
941
|
: "";
|
|
848
|
-
const
|
|
942
|
+
const right = footerStatus(view, view.score.sessionId);
|
|
943
|
+
const spend = right ? ` ${right} ` : "";
|
|
849
944
|
const spendWidth = displayWidth(spend);
|
|
850
945
|
// Spend wins over hints when both do not fit.
|
|
851
946
|
const shown =
|
|
@@ -896,8 +991,7 @@ function paintOverlay(
|
|
|
896
991
|
const box = capabilities.unicode ? UNICODE_BOX : ASCII_BOX;
|
|
897
992
|
const panel = roles.panel;
|
|
898
993
|
const border = onBackground(roles.border, panel);
|
|
899
|
-
const left = width
|
|
900
|
-
const boxWidth = width - left * 2;
|
|
994
|
+
const { left, boxWidth } = panelRect(width);
|
|
901
995
|
const top = region.y;
|
|
902
996
|
const height = region.height;
|
|
903
997
|
if (height < 3 || boxWidth < 10) return;
|
|
@@ -981,8 +1075,7 @@ function paintText(
|
|
|
981
1075
|
const text = options.text ?? ui.text;
|
|
982
1076
|
if (!text) return;
|
|
983
1077
|
const roles = ui.theme.roles;
|
|
984
|
-
const left = width
|
|
985
|
-
const boxWidth = width - left * 2;
|
|
1078
|
+
const { left, boxWidth } = panelRect(width);
|
|
986
1079
|
const height = region.height;
|
|
987
1080
|
if (height < 3 || boxWidth < 10) return;
|
|
988
1081
|
const panel = paintBox(buffer, ui, {
|
|
@@ -1044,8 +1137,7 @@ function paintGuide(
|
|
|
1044
1137
|
if (!guide) return;
|
|
1045
1138
|
const roles = ui.theme.roles;
|
|
1046
1139
|
const unicode = ui.capabilities.unicode;
|
|
1047
|
-
const left = width
|
|
1048
|
-
const boxWidth = width - left * 2;
|
|
1140
|
+
const { left, boxWidth } = panelRect(width);
|
|
1049
1141
|
const height = region.height;
|
|
1050
1142
|
if (height < 3 || boxWidth < 10) return;
|
|
1051
1143
|
const panel = paintBox(buffer, ui, {
|
|
@@ -1125,8 +1217,7 @@ function paintPicker(
|
|
|
1125
1217
|
const picker = ui.picker;
|
|
1126
1218
|
if (!picker) return;
|
|
1127
1219
|
const roles = ui.theme.roles;
|
|
1128
|
-
const left = width
|
|
1129
|
-
const boxWidth = width - left * 2;
|
|
1220
|
+
const { left, boxWidth } = panelRect(width);
|
|
1130
1221
|
const noteRows = picker.note && region.height >= 6 ? 1 : 0;
|
|
1131
1222
|
const height = Math.min(
|
|
1132
1223
|
region.height,
|
|
@@ -1241,12 +1332,56 @@ export function footerHint(
|
|
|
1241
1332
|
|
|
1242
1333
|
// ---------------------------------------------------------------------------
|
|
1243
1334
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1335
|
+
/**
|
|
1336
|
+
* The too-small screen's lines, widest layout that fits first: one line
|
|
1337
|
+
* (`terminal too small · 58×14 · need ≥ 60×16`), then stacked, then bare
|
|
1338
|
+
* numbers. The last line is the keys that still work.
|
|
1339
|
+
*/
|
|
1340
|
+
export function tooSmallLines(
|
|
1341
|
+
size: FrameSize,
|
|
1342
|
+
unicode: boolean,
|
|
1343
|
+
playing: boolean,
|
|
1344
|
+
): string[] {
|
|
1345
|
+
const x = unicode ? "×" : "x";
|
|
1346
|
+
const dot = unicode ? " · " : " - ";
|
|
1347
|
+
const now = `${size.width}${x}${size.height}`;
|
|
1348
|
+
const need = `need ${unicode ? "≥" : ">="} ${MIN_WIDTH}${x}${MIN_HEIGHT}`;
|
|
1349
|
+
const keys = `space ${playing ? "stop" : "play"}${dot}q quit`;
|
|
1350
|
+
const fits = (lines: string[]) =>
|
|
1351
|
+
lines.length <= size.height &&
|
|
1352
|
+
lines.every((line) => displayWidth(line) <= size.width);
|
|
1353
|
+
const layouts = [
|
|
1354
|
+
[`terminal too small${dot}${now}${dot}${need}`, "", keys],
|
|
1355
|
+
["terminal too small", `${now}${dot}${need}`, "", keys],
|
|
1356
|
+
["terminal too small", now, need, "", keys],
|
|
1357
|
+
["terminal too small", now, need],
|
|
1358
|
+
["too small", now, need],
|
|
1359
|
+
[now, need],
|
|
1360
|
+
[`${now} ${unicode ? "≥" : ">="}${MIN_WIDTH}${x}${MIN_HEIGHT}`],
|
|
1361
|
+
[now],
|
|
1249
1362
|
];
|
|
1363
|
+
return layouts.find(fits) ?? [now];
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* What a key does while the too-small screen is up: ctrl-c and q quit,
|
|
1368
|
+
* space starts or stops playback, everything else (typing, mouse, menu
|
|
1369
|
+
* keys) is dropped so the hidden UI's state is untouched.
|
|
1370
|
+
*/
|
|
1371
|
+
export function tooSmallKey(value: unknown): "quit" | "play" | "ignore" {
|
|
1372
|
+
if (value === "\u0003" || value === "q" || value === "Q") return "quit";
|
|
1373
|
+
if (value === " ") return "play";
|
|
1374
|
+
return "ignore";
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function paintTooSmall(
|
|
1378
|
+
buffer: CellBuffer,
|
|
1379
|
+
ui: UiState,
|
|
1380
|
+
size: FrameSize,
|
|
1381
|
+
playing: boolean,
|
|
1382
|
+
): void {
|
|
1383
|
+
const roles = ui.theme.roles;
|
|
1384
|
+
const lines = tooSmallLines(size, ui.capabilities.unicode, playing);
|
|
1250
1385
|
const top = Math.max(0, Math.floor((size.height - lines.length) / 2));
|
|
1251
1386
|
lines.forEach((line, index) => {
|
|
1252
1387
|
const text = truncate(line, size.width);
|
|
@@ -1255,7 +1390,7 @@ function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
|
|
|
1255
1390
|
x,
|
|
1256
1391
|
top + index,
|
|
1257
1392
|
text,
|
|
1258
|
-
index === 0 ? roles.warning : roles.muted,
|
|
1393
|
+
index === 0 ? { ...roles.warning, bold: true } : roles.muted,
|
|
1259
1394
|
);
|
|
1260
1395
|
});
|
|
1261
1396
|
}
|
|
@@ -1273,7 +1408,7 @@ export function composeFrame(
|
|
|
1273
1408
|
const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
|
|
1274
1409
|
const hits = new HitMap();
|
|
1275
1410
|
if (layout.tooSmall) {
|
|
1276
|
-
paintTooSmall(buffer, ui, { width, height });
|
|
1411
|
+
paintTooSmall(buffer, ui, { width, height }, view.score.playing === true);
|
|
1277
1412
|
return {
|
|
1278
1413
|
buffer,
|
|
1279
1414
|
cursor: undefined,
|
|
@@ -1323,7 +1458,7 @@ export function composeFrame(
|
|
|
1323
1458
|
hits,
|
|
1324
1459
|
view.beat ?? resolveBeat(view.score, nowMs),
|
|
1325
1460
|
);
|
|
1326
|
-
if (view.arrange && layout.highway.height > 4) {
|
|
1461
|
+
if (view.arrange && !view.tape && layout.highway.height > 4) {
|
|
1327
1462
|
paintArrangeStrip(
|
|
1328
1463
|
buffer,
|
|
1329
1464
|
layout.highway.y,
|
|
@@ -1344,6 +1479,7 @@ export function composeFrame(
|
|
|
1344
1479
|
kind: ui.overlay && !ui.drawer ? "text" : "highway",
|
|
1345
1480
|
});
|
|
1346
1481
|
// The drawer replaces the menu's list: the piano roll shows above it.
|
|
1482
|
+
let panel: number | undefined = TEXT_MEASURE;
|
|
1347
1483
|
if (ui.overlay === "log" && !ui.drawer)
|
|
1348
1484
|
paintOverlay(buffer, ui, layout.highway, width);
|
|
1349
1485
|
else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
|
|
@@ -1352,10 +1488,32 @@ export function composeFrame(
|
|
|
1352
1488
|
paintText(buffer, ui, layout.highway, width);
|
|
1353
1489
|
else if (ui.overlay === "guide" && ui.guide)
|
|
1354
1490
|
paintGuide(buffer, ui, layout.highway, width);
|
|
1355
|
-
else
|
|
1491
|
+
else panel = undefined;
|
|
1492
|
+
// A text panel holds its measure; on a wide terminal the song keeps
|
|
1493
|
+
// drawing beside it instead of an empty margin.
|
|
1494
|
+
const { left: panelLeft, boxWidth } = panelRect(width, panel);
|
|
1495
|
+
const sideX = panelLeft + boxWidth + 1;
|
|
1496
|
+
const side =
|
|
1497
|
+
panel !== undefined
|
|
1498
|
+
? width - sideX >= SIDE_MIN_WIDTH
|
|
1499
|
+
? { x: sideX, width: width - sideX }
|
|
1500
|
+
: undefined
|
|
1501
|
+
: { x: 0, width };
|
|
1502
|
+
if (side && view.tape)
|
|
1503
|
+
paintTape(
|
|
1504
|
+
buffer,
|
|
1505
|
+
{ ...side, y: layout.highway.y, height: layout.highway.height },
|
|
1506
|
+
view.tape,
|
|
1507
|
+
{
|
|
1508
|
+
theme: ui.theme,
|
|
1509
|
+
unicode: ui.capabilities.unicode,
|
|
1510
|
+
...(panel !== undefined ? {} : { hits }),
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
else if (side)
|
|
1356
1514
|
paintHighway(
|
|
1357
1515
|
buffer,
|
|
1358
|
-
{
|
|
1516
|
+
{ ...side, y: layout.highway.y, height: layout.highway.height },
|
|
1359
1517
|
view.score,
|
|
1360
1518
|
beat,
|
|
1361
1519
|
{
|
|
@@ -1476,6 +1634,8 @@ export class TuiApp {
|
|
|
1476
1634
|
private writer: ScreenWriter;
|
|
1477
1635
|
private lastFrame: Frame | undefined;
|
|
1478
1636
|
private lastFrameAt = Number.NEGATIVE_INFINITY;
|
|
1637
|
+
/** Compose plus encode time of the last rendered frame, in ms. */
|
|
1638
|
+
lastRenderMs = 0;
|
|
1479
1639
|
/** Minimum interval between frames (~30 fps). */
|
|
1480
1640
|
frameIntervalMs = 33;
|
|
1481
1641
|
/**
|
|
@@ -1544,16 +1704,26 @@ export class TuiApp {
|
|
|
1544
1704
|
now - this.lastFrameAt < this.frameIntervalMs + this.backoffMs
|
|
1545
1705
|
)
|
|
1546
1706
|
return "";
|
|
1707
|
+
// A zero-size (or unknown) terminal has no cell to draw in; any write
|
|
1708
|
+
// would wrap. Draw again in full once it has one.
|
|
1709
|
+
const columns = Math.floor(this.io.columns() || 0);
|
|
1710
|
+
const rows = Math.floor(this.io.rows() || 0);
|
|
1711
|
+
if (columns < 1 || rows < 1) {
|
|
1712
|
+
this.writer.invalidate();
|
|
1713
|
+
return "";
|
|
1714
|
+
}
|
|
1547
1715
|
this.lastFrameAt = now;
|
|
1548
1716
|
this.watchFirstLoop(view, now);
|
|
1717
|
+
const started = performance.now();
|
|
1549
1718
|
const frame = composeFrame(
|
|
1550
1719
|
view,
|
|
1551
1720
|
this.ui,
|
|
1552
|
-
{ width:
|
|
1721
|
+
{ width: columns, height: rows },
|
|
1553
1722
|
now,
|
|
1554
1723
|
);
|
|
1555
1724
|
this.lastFrame = frame;
|
|
1556
1725
|
const out = this.writer.frame(frame.buffer, frame.cursor);
|
|
1726
|
+
this.lastRenderMs = performance.now() - started;
|
|
1557
1727
|
if (out) {
|
|
1558
1728
|
const accepted = this.io.write(out);
|
|
1559
1729
|
this.backoffMs =
|
|
@@ -1710,12 +1880,15 @@ export class TuiApp {
|
|
|
1710
1880
|
// End then ↑ moves the view on the first press.
|
|
1711
1881
|
const max = Math.max(0, this.text.lines.length - page);
|
|
1712
1882
|
const from = Math.min(max, this.text.scroll);
|
|
1713
|
-
|
|
1883
|
+
const next =
|
|
1714
1884
|
nav === "home"
|
|
1715
1885
|
? 0
|
|
1716
1886
|
: nav === "end"
|
|
1717
1887
|
? max
|
|
1718
1888
|
: Math.max(0, Math.min(max, from + step));
|
|
1889
|
+
// At the last page the panel pins to the end: a resize keeps the
|
|
1890
|
+
// last line in view (paintText clamps Infinity to the new bound).
|
|
1891
|
+
this.text.scroll = max > 0 && next === max ? Infinity : next;
|
|
1719
1892
|
return { type: "overlay" };
|
|
1720
1893
|
}
|
|
1721
1894
|
}
|
package/tui/delight.ts
CHANGED
|
@@ -62,6 +62,55 @@ export function sweepProgress(
|
|
|
62
62
|
return age / SWEEP_MS;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The TAPE reels, one step per beat (op1-ux §9.1). */
|
|
66
|
+
export const REELS = "◐◓◑◒";
|
|
67
|
+
/** ASCII reels for terminals without unicode. */
|
|
68
|
+
export const ASCII_REELS = "|/-\\";
|
|
69
|
+
/** How long the loop brackets stay bright after each wrap (§9.3). */
|
|
70
|
+
export const LOOP_FLASH_MS = 150;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The TAPE reel glyph: turns one step per transport beat while playing,
|
|
74
|
+
* stands still when stopped or with `/motion off`. A pure function of the
|
|
75
|
+
* beat, so two panes on one transport turn together.
|
|
76
|
+
*/
|
|
77
|
+
export function reelGlyph(
|
|
78
|
+
input: Readonly<{
|
|
79
|
+
playing: boolean;
|
|
80
|
+
beat: number;
|
|
81
|
+
reducedMotion: boolean;
|
|
82
|
+
unicode: boolean;
|
|
83
|
+
}>,
|
|
84
|
+
): string {
|
|
85
|
+
const reels = input.unicode ? REELS : ASCII_REELS;
|
|
86
|
+
if (!input.playing || input.reducedMotion || !Number.isFinite(input.beat))
|
|
87
|
+
return reels[0]!;
|
|
88
|
+
const step = Math.floor(Math.max(0, input.beat));
|
|
89
|
+
return reels[step % reels.length]!;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the loop brackets flash now: within `LOOP_FLASH_MS` after the
|
|
94
|
+
* transport wraps the loop (each recording pass closes there). Never on
|
|
95
|
+
* the very first pass, never stopped, never with `/motion off`.
|
|
96
|
+
*/
|
|
97
|
+
export function loopFlash(
|
|
98
|
+
input: Readonly<{
|
|
99
|
+
playing: boolean;
|
|
100
|
+
/** Transport beat, unwrapped. */
|
|
101
|
+
beat: number;
|
|
102
|
+
loopBeats?: number | undefined;
|
|
103
|
+
bpm: number;
|
|
104
|
+
reducedMotion: boolean;
|
|
105
|
+
}>,
|
|
106
|
+
): boolean {
|
|
107
|
+
const loop = input.loopBeats;
|
|
108
|
+
if (!input.playing || input.reducedMotion || !loop || loop <= 0) return false;
|
|
109
|
+
if (!Number.isFinite(input.beat) || input.beat < loop) return false;
|
|
110
|
+
const since = input.beat % loop;
|
|
111
|
+
return (since * 60_000) / Math.max(1, input.bpm) < LOOP_FLASH_MS;
|
|
112
|
+
}
|
|
113
|
+
|
|
65
114
|
/** A glowing lane: its pitch and strength (1 at the press, fading to 0). */
|
|
66
115
|
export type LaneGlow = Readonly<{ pitch: number; strength: number }>;
|
|
67
116
|
|