@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +27 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/tui/app.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
type TrackScoreSnapshot,
|
|
26
26
|
} from "./highway.ts";
|
|
27
27
|
import { asciiHint, fitHint, HINTS } from "./grammar.ts";
|
|
28
|
+
import { GuideBrowser } from "./guide.ts";
|
|
29
|
+
import { listGuides } from "../guides/index.ts";
|
|
28
30
|
import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
|
|
29
31
|
import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
|
|
30
32
|
import {
|
|
@@ -33,7 +35,10 @@ import {
|
|
|
33
35
|
paintPlayStrip,
|
|
34
36
|
type PlayHeaderView,
|
|
35
37
|
} from "./play-strip.ts";
|
|
38
|
+
import { paintArrangeStrip, type ArrangeStripView } from "./arrange-strip.ts";
|
|
36
39
|
import { CellBuffer, ScreenWriter, type CursorPosition } from "./screen.ts";
|
|
40
|
+
import { paintDrawer, type DrawerLayout, type DrawerView } from "./drawer.ts";
|
|
41
|
+
import { HitMap, type HitTarget } from "./hits.ts";
|
|
37
42
|
import { displayWidth, truncate } from "./text.ts";
|
|
38
43
|
import {
|
|
39
44
|
accentStyle,
|
|
@@ -72,6 +77,44 @@ export interface AppView {
|
|
|
72
77
|
agentOffline?: boolean | undefined;
|
|
73
78
|
/** Play mode: replaces the header and adds the keyboard strip row. */
|
|
74
79
|
play?: PlayHeaderView | undefined;
|
|
80
|
+
/** Song master meter: integrated LUFS and true peak of the playing loop. */
|
|
81
|
+
loudness?: LoudnessView | undefined;
|
|
82
|
+
/** Song sections over the timeline (0.5); no row when absent. */
|
|
83
|
+
arrange?: ArrangeStripView | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type LoudnessView = Readonly<{
|
|
87
|
+
/** Integrated loudness, LUFS. */
|
|
88
|
+
integrated: number;
|
|
89
|
+
/** True peak, dBTP. */
|
|
90
|
+
truePeak: number;
|
|
91
|
+
/** The master's loudness target, when one is set. */
|
|
92
|
+
target?: number | undefined;
|
|
93
|
+
/** The limiter's ceiling, dBTP, when the limiter is on. */
|
|
94
|
+
ceiling?: number | undefined;
|
|
95
|
+
/** A monitor-rate reading shown until the export-rate one is in (`~`). */
|
|
96
|
+
estimate?: boolean | undefined;
|
|
97
|
+
}>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `-14.1 LUFS (-14) · TP -1.0`: the compact loudness meter, with the
|
|
101
|
+
* master's target in brackets when one is set and `~` before a monitor-rate
|
|
102
|
+
* estimate. Loudness below -70 LUFS (silence) reads `-∞`.
|
|
103
|
+
*/
|
|
104
|
+
export function loudnessMeter(view: LoudnessView): string {
|
|
105
|
+
const lufs = view.integrated <= -70 ? "-∞" : view.integrated.toFixed(1);
|
|
106
|
+
const peak = view.truePeak <= -119 ? "-∞" : view.truePeak.toFixed(1);
|
|
107
|
+
const target = view.target === undefined ? "" : ` (${view.target})`;
|
|
108
|
+
return `${view.estimate ? "~" : ""}${lufs} LUFS${target} · TP ${peak}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the meter's reading is off target by more than 0.5 LU. */
|
|
112
|
+
export function loudnessOffTarget(view: LoudnessView): boolean {
|
|
113
|
+
return (
|
|
114
|
+
view.target !== undefined &&
|
|
115
|
+
view.integrated > -70 &&
|
|
116
|
+
Math.abs(view.integrated - view.target) > 0.5
|
|
117
|
+
);
|
|
75
118
|
}
|
|
76
119
|
|
|
77
120
|
export type TypesIndicator = Readonly<{ ok: boolean; errors: number }>;
|
|
@@ -89,10 +132,17 @@ export interface UiState {
|
|
|
89
132
|
text?: TextView | undefined;
|
|
90
133
|
/** The `?` panel: keys for the screen underneath, drawn over it. */
|
|
91
134
|
keys?: TextView | undefined;
|
|
135
|
+
/** The `/guide` tree and pages. */
|
|
136
|
+
guide?: GuideBrowser | undefined;
|
|
137
|
+
/** The fader drawer, docked over the bottom of the piano roll. */
|
|
138
|
+
drawer?: DrawerView | undefined;
|
|
92
139
|
}
|
|
93
140
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
141
|
+
/**
|
|
142
|
+
* `log` is the transcript, `picker` an arrow-key list, `text` static lines,
|
|
143
|
+
* `guide` the user guides.
|
|
144
|
+
*/
|
|
145
|
+
export type Overlay = "log" | "picker" | "text" | "guide" | undefined;
|
|
96
146
|
|
|
97
147
|
/** A scrollable read-only panel (`/help`, `/sessions`, `/tracks`). */
|
|
98
148
|
export interface TextView {
|
|
@@ -179,6 +229,10 @@ export interface Frame {
|
|
|
179
229
|
/** Rows used by the prompt editor (excluding borders). */
|
|
180
230
|
promptRows: number;
|
|
181
231
|
layout: FrameLayout;
|
|
232
|
+
/** Click targets, recorded while painting (topmost last). */
|
|
233
|
+
hits: HitMap;
|
|
234
|
+
/** Where the fader drawer landed, when one is open. */
|
|
235
|
+
drawer?: DrawerLayout | undefined;
|
|
182
236
|
}
|
|
183
237
|
|
|
184
238
|
export interface FrameLayout {
|
|
@@ -261,6 +315,8 @@ interface Segment {
|
|
|
261
315
|
style: Style;
|
|
262
316
|
/** Lower numbers survive longer when space is short. */
|
|
263
317
|
priority: number;
|
|
318
|
+
/** What a click on it does. */
|
|
319
|
+
target?: HitTarget | undefined;
|
|
264
320
|
}
|
|
265
321
|
|
|
266
322
|
function paintSegments(
|
|
@@ -272,6 +328,7 @@ function paintSegments(
|
|
|
272
328
|
separatorStyle: Style,
|
|
273
329
|
background: Style,
|
|
274
330
|
rightSegments: Segment[] = [],
|
|
331
|
+
hits?: HitMap,
|
|
275
332
|
): void {
|
|
276
333
|
const sepWidth = displayWidth(separator);
|
|
277
334
|
const total = (list: Segment[]) =>
|
|
@@ -298,12 +355,14 @@ function paintSegments(
|
|
|
298
355
|
separator,
|
|
299
356
|
onBackground(separatorStyle, background),
|
|
300
357
|
);
|
|
301
|
-
|
|
358
|
+
const used = buffer.text(
|
|
302
359
|
x,
|
|
303
360
|
y,
|
|
304
361
|
truncate(segment.text, Math.max(1, width - x - 1)),
|
|
305
362
|
onBackground(segment.style, background),
|
|
306
363
|
);
|
|
364
|
+
if (segment.target) hits?.add(x, y, used, 1, segment.target);
|
|
365
|
+
x += used;
|
|
307
366
|
});
|
|
308
367
|
if (right.length) {
|
|
309
368
|
let rx = width - 1 - total(right);
|
|
@@ -316,12 +375,14 @@ function paintSegments(
|
|
|
316
375
|
separator,
|
|
317
376
|
onBackground(separatorStyle, background),
|
|
318
377
|
);
|
|
319
|
-
|
|
378
|
+
const used = buffer.text(
|
|
320
379
|
rx,
|
|
321
380
|
y,
|
|
322
381
|
segment.text,
|
|
323
382
|
onBackground(segment.style, background),
|
|
324
383
|
);
|
|
384
|
+
if (segment.target) hits?.add(rx, y, used, 1, segment.target);
|
|
385
|
+
rx += used;
|
|
325
386
|
});
|
|
326
387
|
}
|
|
327
388
|
}
|
|
@@ -356,6 +417,7 @@ function paintHeader(
|
|
|
356
417
|
view: AppView,
|
|
357
418
|
ui: UiState,
|
|
358
419
|
width: number,
|
|
420
|
+
hits?: HitMap,
|
|
359
421
|
): void {
|
|
360
422
|
const { theme, capabilities } = ui;
|
|
361
423
|
const roles = theme.roles;
|
|
@@ -370,11 +432,13 @@ function paintHeader(
|
|
|
370
432
|
text: name,
|
|
371
433
|
style: { ...accentStyle(theme, score.trackId ?? name), bold: true },
|
|
372
434
|
priority: 0,
|
|
435
|
+
target: { kind: "tracks" },
|
|
373
436
|
},
|
|
374
437
|
{
|
|
375
438
|
text: transport,
|
|
376
439
|
style: playing ? roles.transport : roles.paused,
|
|
377
440
|
priority: 0,
|
|
441
|
+
target: { kind: "transport" },
|
|
378
442
|
},
|
|
379
443
|
];
|
|
380
444
|
if (score.key)
|
|
@@ -395,7 +459,12 @@ function paintHeader(
|
|
|
395
459
|
});
|
|
396
460
|
const right: Segment[] = [];
|
|
397
461
|
if (view.model)
|
|
398
|
-
right.push({
|
|
462
|
+
right.push({
|
|
463
|
+
text: view.model,
|
|
464
|
+
style: roles.agent,
|
|
465
|
+
priority: 2,
|
|
466
|
+
target: { kind: "model" },
|
|
467
|
+
});
|
|
399
468
|
if (score.revision !== undefined)
|
|
400
469
|
right.push({
|
|
401
470
|
text: `rev ${score.revision}`,
|
|
@@ -410,9 +479,33 @@ function paintHeader(
|
|
|
410
479
|
style: view.types.ok ? roles.success : roles.error,
|
|
411
480
|
priority: 4,
|
|
412
481
|
});
|
|
482
|
+
if (view.loudness)
|
|
483
|
+
right.push({
|
|
484
|
+
text: loudnessMeter(view.loudness),
|
|
485
|
+
// Over full scale is an error; above the limiter's ceiling (-1 dBTP
|
|
486
|
+
// without one) or off the target by more than 0.5 LU a warning.
|
|
487
|
+
style:
|
|
488
|
+
view.loudness.truePeak > 0
|
|
489
|
+
? roles.error
|
|
490
|
+
: view.loudness.truePeak > (view.loudness.ceiling ?? -1) + 0.1 ||
|
|
491
|
+
loudnessOffTarget(view.loudness)
|
|
492
|
+
? roles.warning
|
|
493
|
+
: roles.muted,
|
|
494
|
+
priority: 3,
|
|
495
|
+
});
|
|
413
496
|
const sync = syncSegment(view.sync, theme, unicode);
|
|
414
497
|
if (sync) right.push(sync);
|
|
415
|
-
paintSegments(
|
|
498
|
+
paintSegments(
|
|
499
|
+
buffer,
|
|
500
|
+
0,
|
|
501
|
+
left,
|
|
502
|
+
width,
|
|
503
|
+
" · ",
|
|
504
|
+
roles.faint,
|
|
505
|
+
roles.panel,
|
|
506
|
+
right,
|
|
507
|
+
hits,
|
|
508
|
+
);
|
|
416
509
|
}
|
|
417
510
|
|
|
418
511
|
// ---------------------------------------------------------------------------
|
|
@@ -818,6 +911,61 @@ function paintText(
|
|
|
818
911
|
});
|
|
819
912
|
}
|
|
820
913
|
|
|
914
|
+
function paintGuide(
|
|
915
|
+
buffer: CellBuffer,
|
|
916
|
+
ui: UiState,
|
|
917
|
+
region: { y: number; height: number },
|
|
918
|
+
width: number,
|
|
919
|
+
): void {
|
|
920
|
+
const guide = ui.guide;
|
|
921
|
+
if (!guide) return;
|
|
922
|
+
const roles = ui.theme.roles;
|
|
923
|
+
const unicode = ui.capabilities.unicode;
|
|
924
|
+
const left = width >= 60 ? 2 : 0;
|
|
925
|
+
const boxWidth = width - left * 2;
|
|
926
|
+
const height = region.height;
|
|
927
|
+
if (height < 3 || boxWidth < 10) return;
|
|
928
|
+
const panel = paintBox(buffer, ui, {
|
|
929
|
+
left,
|
|
930
|
+
top: region.y,
|
|
931
|
+
width: boxWidth,
|
|
932
|
+
height,
|
|
933
|
+
});
|
|
934
|
+
const view = guide.view(boxWidth - 4, height - 2, unicode);
|
|
935
|
+
buffer.text(
|
|
936
|
+
left + 2,
|
|
937
|
+
region.y,
|
|
938
|
+
` ${view.title} `,
|
|
939
|
+
onBackground({ ...roles.text, bold: true }, panel),
|
|
940
|
+
boxWidth - 4,
|
|
941
|
+
);
|
|
942
|
+
const hint = footerHint(view.hint, boxWidth - 4, unicode);
|
|
943
|
+
if (hint)
|
|
944
|
+
buffer.text(
|
|
945
|
+
left + boxWidth - 2 - displayWidth(hint),
|
|
946
|
+
region.y + height - 1,
|
|
947
|
+
hint,
|
|
948
|
+
onBackground(roles.muted, panel),
|
|
949
|
+
);
|
|
950
|
+
view.rows.forEach((row, index) => {
|
|
951
|
+
const style = row.selected
|
|
952
|
+
? { ...roles.borderFocus, bold: true }
|
|
953
|
+
: row.heading
|
|
954
|
+
? { ...roles.borderFocus, bold: true }
|
|
955
|
+
: row.muted
|
|
956
|
+
? roles.muted
|
|
957
|
+
: roles.text;
|
|
958
|
+
const marker = row.selected ? (unicode ? "›" : ">") : " ";
|
|
959
|
+
const text = guide.page === undefined ? `${marker}${row.text}` : row.text;
|
|
960
|
+
buffer.text(
|
|
961
|
+
left + 2,
|
|
962
|
+
region.y + 1 + index,
|
|
963
|
+
truncate(text, boxWidth - 4),
|
|
964
|
+
onBackground(style, panel),
|
|
965
|
+
);
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
821
969
|
function paintBox(
|
|
822
970
|
buffer: CellBuffer,
|
|
823
971
|
ui: UiState,
|
|
@@ -849,6 +997,7 @@ function paintPicker(
|
|
|
849
997
|
ui: UiState,
|
|
850
998
|
region: { y: number; height: number },
|
|
851
999
|
width: number,
|
|
1000
|
+
hits?: HitMap,
|
|
852
1001
|
): void {
|
|
853
1002
|
const picker = ui.picker;
|
|
854
1003
|
if (!picker) return;
|
|
@@ -916,6 +1065,7 @@ function paintPicker(
|
|
|
916
1065
|
picker.items.slice(first, first + inner).forEach((item, offset) => {
|
|
917
1066
|
const index = first + offset;
|
|
918
1067
|
const y = region.y + 1 + offset;
|
|
1068
|
+
hits?.add(left + 1, y, boxWidth - 2, 1, { kind: "picker-row", index });
|
|
919
1069
|
const selected = index === picker.index;
|
|
920
1070
|
const marker = selected ? (ui.capabilities.unicode ? "›" : ">") : " ";
|
|
921
1071
|
const style = selected
|
|
@@ -998,9 +1148,16 @@ export function composeFrame(
|
|
|
998
1148
|
const buffer = new CellBuffer(width, height, ui.theme.roles.canvas);
|
|
999
1149
|
ui.prompt.setWidth(promptEditorWidth(width));
|
|
1000
1150
|
const layout = computeLayout({ width, height }, ui.prompt.wrappedRows);
|
|
1151
|
+
const hits = new HitMap();
|
|
1001
1152
|
if (layout.tooSmall) {
|
|
1002
1153
|
paintTooSmall(buffer, ui, { width, height });
|
|
1003
|
-
return {
|
|
1154
|
+
return {
|
|
1155
|
+
buffer,
|
|
1156
|
+
cursor: undefined,
|
|
1157
|
+
promptRows: 0,
|
|
1158
|
+
layout,
|
|
1159
|
+
hits: new HitMap(),
|
|
1160
|
+
};
|
|
1004
1161
|
}
|
|
1005
1162
|
if (view.play) {
|
|
1006
1163
|
paintPlayHeader(
|
|
@@ -1031,14 +1188,36 @@ export function composeFrame(
|
|
|
1031
1188
|
height: layout.highway.height - 1,
|
|
1032
1189
|
};
|
|
1033
1190
|
}
|
|
1034
|
-
} else paintHeader(buffer, view, ui, width);
|
|
1191
|
+
} else paintHeader(buffer, view, ui, width, hits);
|
|
1192
|
+
if (view.arrange && layout.highway.height > 4) {
|
|
1193
|
+
paintArrangeStrip(
|
|
1194
|
+
buffer,
|
|
1195
|
+
layout.highway.y,
|
|
1196
|
+
width,
|
|
1197
|
+
view.arrange,
|
|
1198
|
+
ui.theme,
|
|
1199
|
+
ui.capabilities.unicode,
|
|
1200
|
+
);
|
|
1201
|
+
layout.highway = {
|
|
1202
|
+
y: layout.highway.y + 1,
|
|
1203
|
+
height: layout.highway.height - 1,
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1035
1206
|
const beat = view.beat ?? resolveBeat(view.score, nowMs);
|
|
1207
|
+
let drawer: DrawerLayout | undefined;
|
|
1036
1208
|
if (layout.highway.height > 0) {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1209
|
+
hits.add(0, layout.highway.y, width, layout.highway.height, {
|
|
1210
|
+
kind: ui.overlay && !ui.drawer ? "text" : "highway",
|
|
1211
|
+
});
|
|
1212
|
+
// The drawer replaces the menu's list: the piano roll shows above it.
|
|
1213
|
+
if (ui.overlay === "log" && !ui.drawer)
|
|
1214
|
+
paintOverlay(buffer, ui, layout.highway, width);
|
|
1215
|
+
else if (ui.overlay === "picker" && ui.picker && !ui.drawer)
|
|
1216
|
+
paintPicker(buffer, ui, layout.highway, width, hits);
|
|
1040
1217
|
else if (ui.overlay === "text" && ui.text)
|
|
1041
1218
|
paintText(buffer, ui, layout.highway, width);
|
|
1219
|
+
else if (ui.overlay === "guide" && ui.guide)
|
|
1220
|
+
paintGuide(buffer, ui, layout.highway, width);
|
|
1042
1221
|
else
|
|
1043
1222
|
paintHighway(
|
|
1044
1223
|
buffer,
|
|
@@ -1051,6 +1230,15 @@ export function composeFrame(
|
|
|
1051
1230
|
reducedMotion: ui.reducedMotion,
|
|
1052
1231
|
},
|
|
1053
1232
|
);
|
|
1233
|
+
if (ui.drawer)
|
|
1234
|
+
drawer = paintDrawer(
|
|
1235
|
+
buffer,
|
|
1236
|
+
layout.highway,
|
|
1237
|
+
width,
|
|
1238
|
+
ui.drawer,
|
|
1239
|
+
{ theme: ui.theme, unicode: ui.capabilities.unicode },
|
|
1240
|
+
hits,
|
|
1241
|
+
);
|
|
1054
1242
|
}
|
|
1055
1243
|
if (ui.keys && layout.highway.height > 0)
|
|
1056
1244
|
paintText(buffer, ui, keysRegion(layout.highway, ui.keys), width, {
|
|
@@ -1059,7 +1247,14 @@ export function composeFrame(
|
|
|
1059
1247
|
});
|
|
1060
1248
|
paintActivity(buffer, layout.activity, ui, width, nowMs);
|
|
1061
1249
|
const prompt = paintPrompt(buffer, view, ui, layout, width);
|
|
1062
|
-
return {
|
|
1250
|
+
return {
|
|
1251
|
+
buffer,
|
|
1252
|
+
cursor: prompt.cursor,
|
|
1253
|
+
promptRows: prompt.rows,
|
|
1254
|
+
layout,
|
|
1255
|
+
hits,
|
|
1256
|
+
drawer,
|
|
1257
|
+
};
|
|
1063
1258
|
}
|
|
1064
1259
|
|
|
1065
1260
|
// ---------------------------------------------------------------------------
|
|
@@ -1112,6 +1307,8 @@ export class TuiApp {
|
|
|
1112
1307
|
log: LogView = { scroll: 0, filter: "all" };
|
|
1113
1308
|
picker: PickerState | undefined;
|
|
1114
1309
|
text: TextView | undefined;
|
|
1310
|
+
/** The fader drawer's paint model while one is open. */
|
|
1311
|
+
drawer: DrawerView | undefined;
|
|
1115
1312
|
/** `/view all` overlays every unmuted track; `/view focus` shows one. */
|
|
1116
1313
|
highwayView: "all" | "focus" = "all";
|
|
1117
1314
|
private writer: ScreenWriter;
|
|
@@ -1149,6 +1346,8 @@ export class TuiApp {
|
|
|
1149
1346
|
picker: this.picker,
|
|
1150
1347
|
text: this.text,
|
|
1151
1348
|
keys: this.keys,
|
|
1349
|
+
guide: this.guide,
|
|
1350
|
+
drawer: this.drawer,
|
|
1152
1351
|
};
|
|
1153
1352
|
}
|
|
1154
1353
|
|
|
@@ -1272,6 +1471,18 @@ export class TuiApp {
|
|
|
1272
1471
|
}
|
|
1273
1472
|
return { type: "overlay" };
|
|
1274
1473
|
}
|
|
1474
|
+
if (this.overlay === "guide" && this.guide) {
|
|
1475
|
+
if (
|
|
1476
|
+
key.type === "ui" &&
|
|
1477
|
+
(key.command === "quit" || key.command === "redraw")
|
|
1478
|
+
) {
|
|
1479
|
+
if (key.command === "redraw") this.invalidate();
|
|
1480
|
+
return { type: "ui", command: key.command };
|
|
1481
|
+
}
|
|
1482
|
+
const result = this.guide.key(value, Math.max(1, this.io.rows() - 10));
|
|
1483
|
+
if (result === "close") this.closeGuide();
|
|
1484
|
+
return { type: "overlay" };
|
|
1485
|
+
}
|
|
1275
1486
|
if (this.overlay === "text" && this.text) {
|
|
1276
1487
|
const nav = overlayKey(value);
|
|
1277
1488
|
const page = Math.max(1, this.io.rows() - 10);
|
|
@@ -1434,13 +1645,42 @@ export class TuiApp {
|
|
|
1434
1645
|
return this.overlay === "picker" && this.picker?.filtering === true;
|
|
1435
1646
|
}
|
|
1436
1647
|
|
|
1648
|
+
/** The `/guide` pane; built on first use from `guides/*.md`. */
|
|
1649
|
+
guide: GuideBrowser | undefined;
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Open the guides at the tree, or at one guide (`chords`). Returns false
|
|
1653
|
+
* (and opens nothing) when `topic` names no guide.
|
|
1654
|
+
*/
|
|
1655
|
+
openGuide(topic?: string): boolean {
|
|
1656
|
+
const browser = this.guide ?? new GuideBrowser(listGuides());
|
|
1657
|
+
if (topic && !browser.open(topic)) return false;
|
|
1658
|
+
if (!topic) {
|
|
1659
|
+
browser.page = undefined;
|
|
1660
|
+
browser.query = "";
|
|
1661
|
+
browser.filtering = false;
|
|
1662
|
+
}
|
|
1663
|
+
this.guide = browser;
|
|
1664
|
+
this.overlay = "guide";
|
|
1665
|
+
return true;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
closeGuide(): void {
|
|
1669
|
+
if (this.overlay === "guide") this.overlay = undefined;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** True while the guide filter takes typed text (so `?` is a letter). */
|
|
1673
|
+
get guideTyping(): boolean {
|
|
1674
|
+
return this.overlay === "guide" && this.guide?.typing === true;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1437
1677
|
closeText(): void {
|
|
1438
1678
|
this.text = undefined;
|
|
1439
1679
|
if (this.overlay === "text") this.overlay = undefined;
|
|
1440
1680
|
}
|
|
1441
1681
|
|
|
1442
1682
|
/**
|
|
1443
|
-
* Handle TUI-local slash commands (`/log`, `/theme`, `/motion`).
|
|
1683
|
+
* Handle TUI-local slash commands (`/log`, `/theme`, `/motion`, `/guide`).
|
|
1444
1684
|
* Returns a receipt, or undefined when the command is not a UI command.
|
|
1445
1685
|
*/
|
|
1446
1686
|
command(text: string): string | undefined {
|
|
@@ -1464,6 +1704,15 @@ export class TuiApp {
|
|
|
1464
1704
|
? `theme ${name} · terminal has no color, showing mono`
|
|
1465
1705
|
: `theme ${name}`;
|
|
1466
1706
|
}
|
|
1707
|
+
const guide = command.match(/^\/guides?(?:\s+(.+))?$/i);
|
|
1708
|
+
if (guide) {
|
|
1709
|
+
const topic = guide[1]?.trim();
|
|
1710
|
+
if (!this.openGuide(topic))
|
|
1711
|
+
return `no guide named ${topic} · /guide lists them all`;
|
|
1712
|
+
return topic
|
|
1713
|
+
? `guide · ${this.guide?.guides.find((g) => g.id === this.guide?.page)?.title ?? topic} · esc back`
|
|
1714
|
+
: "guides · → open · esc closes";
|
|
1715
|
+
}
|
|
1467
1716
|
const view = command.match(/^\/view(?:\s+(\S+))?$/i);
|
|
1468
1717
|
if (view) {
|
|
1469
1718
|
const value = view[1]?.toLowerCase();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arrangement strip (0.5): one row under the header that shows the
|
|
3
|
+
* song's sections over the bar timeline, like the arranger track in Logic,
|
|
4
|
+
* Cubase or Studio One. Each section is a block with its name; the looped
|
|
5
|
+
* section is reversed, the one under the playhead is bold, and the playhead
|
|
6
|
+
* is a caret. Bars with no section are a faint rule. The strip is absent
|
|
7
|
+
* when the song has no sections, so a 0.4 screen keeps every row it had.
|
|
8
|
+
*
|
|
9
|
+
* The shape is structural so the TUI does not depend on core.
|
|
10
|
+
*/
|
|
11
|
+
import type { CellBuffer } from "./screen.ts";
|
|
12
|
+
import { displayWidth, truncate } from "./text.ts";
|
|
13
|
+
import { accentStyle, onBackground, type Style, type Theme } from "./theme.ts";
|
|
14
|
+
|
|
15
|
+
export type ArrangeStripSection = Readonly<{
|
|
16
|
+
name: string;
|
|
17
|
+
/** 0-based first bar. */
|
|
18
|
+
startBar: number;
|
|
19
|
+
bars: number;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export type ArrangeStripView = Readonly<{
|
|
23
|
+
/** Bars on the timeline (the score's `bars`). */
|
|
24
|
+
bars: number;
|
|
25
|
+
sections: readonly ArrangeStripSection[];
|
|
26
|
+
/** The looped section's name, if any. */
|
|
27
|
+
loop?: string | undefined;
|
|
28
|
+
/** Playhead in bars (fractional), score time. */
|
|
29
|
+
playheadBar?: number | undefined;
|
|
30
|
+
/** `intro verse chorus×2`, shown at the right edge when it fits. */
|
|
31
|
+
form?: string | undefined;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
/** One painted cell of the strip, for tests and narrow terminals. */
|
|
35
|
+
export type ArrangeStripCell = Readonly<{
|
|
36
|
+
ch: string;
|
|
37
|
+
/** Index into `view.sections`, or -1 for an unmarked bar. */
|
|
38
|
+
section: number;
|
|
39
|
+
playhead: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lay the strip out over `width` cells: each cell covers `bars / width`
|
|
44
|
+
* bars and shows the section that starts latest at or before it (a later
|
|
45
|
+
* marker wins where sections overlap). Names start at a section's first
|
|
46
|
+
* cell and are cut at the next boundary.
|
|
47
|
+
*/
|
|
48
|
+
export function arrangeStripCells(
|
|
49
|
+
view: ArrangeStripView,
|
|
50
|
+
width: number,
|
|
51
|
+
unicode = true,
|
|
52
|
+
): ArrangeStripCell[] {
|
|
53
|
+
const cells: ArrangeStripCell[] = [];
|
|
54
|
+
const bars = Math.max(1, view.bars);
|
|
55
|
+
const span = Math.max(1, Math.floor(width));
|
|
56
|
+
const owner = (bar: number): number => {
|
|
57
|
+
let found = -1;
|
|
58
|
+
let start = -1;
|
|
59
|
+
view.sections.forEach((section, index) => {
|
|
60
|
+
if (
|
|
61
|
+
bar >= section.startBar &&
|
|
62
|
+
bar < section.startBar + section.bars &&
|
|
63
|
+
section.startBar >= start
|
|
64
|
+
) {
|
|
65
|
+
found = index;
|
|
66
|
+
start = section.startBar;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return found;
|
|
70
|
+
};
|
|
71
|
+
const playCell =
|
|
72
|
+
view.playheadBar === undefined
|
|
73
|
+
? -1
|
|
74
|
+
: Math.min(span - 1, Math.floor((view.playheadBar / bars) * span));
|
|
75
|
+
for (let x = 0; x < span; x++) {
|
|
76
|
+
const bar = Math.floor((x / span) * bars);
|
|
77
|
+
const section = owner(bar);
|
|
78
|
+
cells.push({
|
|
79
|
+
ch: section < 0 ? (unicode ? "─" : "-") : " ",
|
|
80
|
+
section,
|
|
81
|
+
playhead: x === playCell,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Names: from each run's first cell; a run is consecutive cells of one
|
|
85
|
+
// section. A run too short for the name gets its first letters.
|
|
86
|
+
let x = 0;
|
|
87
|
+
while (x < span) {
|
|
88
|
+
const section = cells[x]!.section;
|
|
89
|
+
let end = x;
|
|
90
|
+
while (end < span && cells[end]!.section === section) end++;
|
|
91
|
+
if (section >= 0) {
|
|
92
|
+
const name = view.sections[section]!.name;
|
|
93
|
+
const room = end - x - (end - x > 2 ? 1 : 0);
|
|
94
|
+
const label = room > 0 ? truncate(name, room, unicode ? "…" : "~") : "";
|
|
95
|
+
// A boundary bar: one cell gap between neighbours when there is room.
|
|
96
|
+
let at = x + (end - x > 2 ? 1 : 0);
|
|
97
|
+
for (const ch of label) {
|
|
98
|
+
if (at >= end) break;
|
|
99
|
+
cells[at] = { ...cells[at]!, ch };
|
|
100
|
+
at += Math.max(1, displayWidth(ch));
|
|
101
|
+
}
|
|
102
|
+
if (end - x > 2) cells[x] = { ...cells[x]!, ch: unicode ? "▏" : "|" };
|
|
103
|
+
}
|
|
104
|
+
x = end;
|
|
105
|
+
}
|
|
106
|
+
return cells;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Plain text of the strip, for tests. */
|
|
110
|
+
export function arrangeStripText(
|
|
111
|
+
view: ArrangeStripView,
|
|
112
|
+
width: number,
|
|
113
|
+
unicode = true,
|
|
114
|
+
): string {
|
|
115
|
+
return arrangeStripCells(view, width, unicode)
|
|
116
|
+
.map((cell) => (cell.playhead ? (unicode ? "▼" : "v") : cell.ch))
|
|
117
|
+
.join("");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function paintArrangeStrip(
|
|
121
|
+
buffer: CellBuffer,
|
|
122
|
+
y: number,
|
|
123
|
+
width: number,
|
|
124
|
+
view: ArrangeStripView,
|
|
125
|
+
theme: Theme,
|
|
126
|
+
unicode: boolean,
|
|
127
|
+
): void {
|
|
128
|
+
const roles = theme.roles;
|
|
129
|
+
buffer.fill(0, y, width, 1, roles.canvas);
|
|
130
|
+
const form = view.form ? `form ${view.form}` : "";
|
|
131
|
+
const formWidth = displayWidth(form);
|
|
132
|
+
// The form goes right when the timeline keeps at least 24 cells.
|
|
133
|
+
const showForm = form !== "" && width - formWidth - 3 >= 24;
|
|
134
|
+
const stripWidth = Math.max(1, width - 2 - (showForm ? formWidth + 2 : 0));
|
|
135
|
+
const cells = arrangeStripCells(view, stripWidth, unicode);
|
|
136
|
+
const playSection =
|
|
137
|
+
view.playheadBar === undefined
|
|
138
|
+
? -1
|
|
139
|
+
: (cells.find((cell) => cell.playhead)?.section ?? -1);
|
|
140
|
+
cells.forEach((cell, index) => {
|
|
141
|
+
const x = 1 + index;
|
|
142
|
+
if (cell.section < 0) {
|
|
143
|
+
buffer.set(
|
|
144
|
+
x,
|
|
145
|
+
y,
|
|
146
|
+
cell.playhead ? (unicode ? "▼" : "v") : cell.ch,
|
|
147
|
+
cell.playhead ? { ...roles.hit, bold: true } : roles.faint,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const section = view.sections[cell.section]!;
|
|
152
|
+
const accent = accentStyle(theme, section.name);
|
|
153
|
+
const looped = view.loop === section.name;
|
|
154
|
+
const style: Style = {
|
|
155
|
+
...accent,
|
|
156
|
+
reverse: true,
|
|
157
|
+
bold: looped || cell.section === playSection,
|
|
158
|
+
...(looped ? {} : { dim: cell.section !== playSection }),
|
|
159
|
+
};
|
|
160
|
+
buffer.set(
|
|
161
|
+
x,
|
|
162
|
+
y,
|
|
163
|
+
cell.playhead ? (unicode ? "▼" : "v") : cell.ch,
|
|
164
|
+
cell.playhead ? { ...roles.hit, reverse: true, bold: true } : style,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
if (showForm)
|
|
168
|
+
buffer.text(
|
|
169
|
+
width - 1 - formWidth,
|
|
170
|
+
y,
|
|
171
|
+
form,
|
|
172
|
+
onBackground(roles.muted, roles.canvas),
|
|
173
|
+
);
|
|
174
|
+
}
|