@hraness/dawg 0.3.0 → 0.4.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 +122 -0
- package/DAWG.md +605 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +27 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +280 -278
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1119 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +1286 -244
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/tui/app.ts
CHANGED
|
@@ -24,9 +24,11 @@ import {
|
|
|
24
24
|
resolveBeat,
|
|
25
25
|
type TrackScoreSnapshot,
|
|
26
26
|
} from "./highway.ts";
|
|
27
|
+
import { asciiHint, fitHint, HINTS } from "./grammar.ts";
|
|
27
28
|
import { classifyKey, overlayKey, type UiCommand } from "./keys.ts";
|
|
28
29
|
import { PromptModel, type PromptAction, type PromptMode } from "./prompt.ts";
|
|
29
30
|
import {
|
|
31
|
+
paintChordLegend,
|
|
30
32
|
paintPlayHeader,
|
|
31
33
|
paintPlayStrip,
|
|
32
34
|
type PlayHeaderView,
|
|
@@ -85,6 +87,8 @@ export interface UiState {
|
|
|
85
87
|
log?: LogView | undefined;
|
|
86
88
|
picker?: PickerState | undefined;
|
|
87
89
|
text?: TextView | undefined;
|
|
90
|
+
/** The `?` panel: keys for the screen underneath, drawn over it. */
|
|
91
|
+
keys?: TextView | undefined;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** `log` is the transcript, `picker` an arrow-key list, `text` static lines. */
|
|
@@ -122,9 +126,18 @@ export interface PickerState {
|
|
|
122
126
|
filterable?: boolean | undefined;
|
|
123
127
|
/** Current filter text; `items` is then the matching subset of `all`. */
|
|
124
128
|
query?: string | undefined;
|
|
129
|
+
/** `/` was pressed: printable keys type into the filter. */
|
|
130
|
+
filtering?: boolean | undefined;
|
|
125
131
|
all?: readonly PickerItem[] | undefined;
|
|
126
132
|
/** Footer keys, replacing the default move/choose/cancel hint. */
|
|
127
133
|
hint?: string | undefined;
|
|
134
|
+
/** A dim line under the rows: what the focused row does. */
|
|
135
|
+
note?: string | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Hosts the audition loop (src/tui/audition.ts): Space, `a` and `c` are
|
|
138
|
+
* returned as `pick-audition` instead of being swallowed.
|
|
139
|
+
*/
|
|
140
|
+
audition?: boolean | undefined;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
export interface PickerItem {
|
|
@@ -701,11 +714,10 @@ function paintOverlay(
|
|
|
701
714
|
onBackground({ ...roles.text, bold: true }, panel),
|
|
702
715
|
boxWidth - 4,
|
|
703
716
|
);
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
if (boxWidth > hintText.length + 4)
|
|
717
|
+
const hintText = footerHint(HINTS.log, boxWidth - 4, capabilities.unicode);
|
|
718
|
+
if (hintText)
|
|
707
719
|
buffer.text(
|
|
708
|
-
left + boxWidth - 2 - hintText
|
|
720
|
+
left + boxWidth - 2 - displayWidth(hintText),
|
|
709
721
|
top + height - 1,
|
|
710
722
|
hintText,
|
|
711
723
|
onBackground(roles.muted, panel),
|
|
@@ -748,8 +760,9 @@ function paintText(
|
|
|
748
760
|
ui: UiState,
|
|
749
761
|
region: { y: number; height: number },
|
|
750
762
|
width: number,
|
|
763
|
+
options: { text?: TextView | undefined; hint?: string } = {},
|
|
751
764
|
): void {
|
|
752
|
-
const text = ui.text;
|
|
765
|
+
const text = options.text ?? ui.text;
|
|
753
766
|
if (!text) return;
|
|
754
767
|
const roles = ui.theme.roles;
|
|
755
768
|
const left = width >= 60 ? 2 : 0;
|
|
@@ -776,12 +789,14 @@ function paintText(
|
|
|
776
789
|
onBackground({ ...roles.text, bold: true }, panel),
|
|
777
790
|
boxWidth - 4,
|
|
778
791
|
);
|
|
779
|
-
const hint =
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
792
|
+
const hint = footerHint(
|
|
793
|
+
options.hint ?? HINTS.text,
|
|
794
|
+
boxWidth - 4,
|
|
795
|
+
ui.capabilities.unicode,
|
|
796
|
+
);
|
|
797
|
+
if (hint)
|
|
783
798
|
buffer.text(
|
|
784
|
-
left + boxWidth - 2 - hint
|
|
799
|
+
left + boxWidth - 2 - displayWidth(hint),
|
|
785
800
|
region.y + height - 1,
|
|
786
801
|
hint,
|
|
787
802
|
onBackground(roles.muted, panel),
|
|
@@ -840,7 +855,11 @@ function paintPicker(
|
|
|
840
855
|
const roles = ui.theme.roles;
|
|
841
856
|
const left = width >= 60 ? 2 : 0;
|
|
842
857
|
const boxWidth = width - left * 2;
|
|
843
|
-
const
|
|
858
|
+
const noteRows = picker.note && region.height >= 6 ? 1 : 0;
|
|
859
|
+
const height = Math.min(
|
|
860
|
+
region.height,
|
|
861
|
+
Math.max(1, picker.items.length) + 2 + noteRows,
|
|
862
|
+
);
|
|
844
863
|
if (height < 3 || boxWidth < 10) return;
|
|
845
864
|
const panel = paintBox(buffer, ui, {
|
|
846
865
|
left,
|
|
@@ -851,23 +870,35 @@ function paintPicker(
|
|
|
851
870
|
buffer.text(
|
|
852
871
|
left + 2,
|
|
853
872
|
region.y,
|
|
854
|
-
` ${picker.title}${picker.query ? ` ·
|
|
873
|
+
` ${picker.title}${picker.filtering || picker.query ? ` · /${picker.query ?? ""}${picker.filtering ? "▏" : ""}` : ""} `,
|
|
855
874
|
onBackground({ ...roles.text, bold: true }, panel),
|
|
856
875
|
boxWidth - 4,
|
|
857
876
|
);
|
|
858
|
-
const hint =
|
|
859
|
-
picker.
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
877
|
+
const hint = footerHint(
|
|
878
|
+
picker.filtering
|
|
879
|
+
? HINTS.filtering
|
|
880
|
+
: (picker.hint ??
|
|
881
|
+
(picker.filterable
|
|
882
|
+
? HINTS.list
|
|
883
|
+
: HINTS.list.replace(" · / filter", ""))),
|
|
884
|
+
boxWidth - 4,
|
|
885
|
+
ui.capabilities.unicode,
|
|
886
|
+
);
|
|
887
|
+
if (hint)
|
|
864
888
|
buffer.text(
|
|
865
|
-
left + boxWidth - 2 - hint
|
|
889
|
+
left + boxWidth - 2 - displayWidth(hint),
|
|
866
890
|
region.y + height - 1,
|
|
867
891
|
hint,
|
|
868
892
|
onBackground(roles.muted, panel),
|
|
869
893
|
);
|
|
870
|
-
const inner = height - 2;
|
|
894
|
+
const inner = height - 2 - noteRows;
|
|
895
|
+
if (noteRows && picker.note)
|
|
896
|
+
buffer.text(
|
|
897
|
+
left + 2,
|
|
898
|
+
region.y + height - 2,
|
|
899
|
+
truncate(picker.note, boxWidth - 4),
|
|
900
|
+
onBackground(roles.muted, panel),
|
|
901
|
+
);
|
|
871
902
|
const first = Math.max(
|
|
872
903
|
0,
|
|
873
904
|
Math.min(picker.items.length - inner, picker.index - inner + 1),
|
|
@@ -917,6 +948,24 @@ function paintPicker(
|
|
|
917
948
|
});
|
|
918
949
|
}
|
|
919
950
|
|
|
951
|
+
/** The `?` panel sits at the bottom of the highway, as tall as it needs. */
|
|
952
|
+
function keysRegion(
|
|
953
|
+
region: { y: number; height: number },
|
|
954
|
+
keys: TextView,
|
|
955
|
+
): { y: number; height: number } {
|
|
956
|
+
const height = Math.min(region.height, keys.lines.length + 2);
|
|
957
|
+
return { y: region.y + region.height - height, height };
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A footer hint fitted to `width`, spelled in ASCII when needed. */
|
|
961
|
+
export function footerHint(
|
|
962
|
+
hint: string,
|
|
963
|
+
width: number,
|
|
964
|
+
unicode: boolean,
|
|
965
|
+
): string {
|
|
966
|
+
return fitHint(unicode ? hint : asciiHint(hint), width);
|
|
967
|
+
}
|
|
968
|
+
|
|
920
969
|
// ---------------------------------------------------------------------------
|
|
921
970
|
|
|
922
971
|
function paintTooSmall(buffer: CellBuffer, ui: UiState, size: FrameSize): void {
|
|
@@ -969,6 +1018,19 @@ export function composeFrame(
|
|
|
969
1018
|
height: layout.highway.height - 1,
|
|
970
1019
|
};
|
|
971
1020
|
}
|
|
1021
|
+
if (view.play.legend && layout.highway.height > 4) {
|
|
1022
|
+
paintChordLegend(
|
|
1023
|
+
buffer,
|
|
1024
|
+
layout.highway.y,
|
|
1025
|
+
width,
|
|
1026
|
+
view.play.legend,
|
|
1027
|
+
ui.theme,
|
|
1028
|
+
);
|
|
1029
|
+
layout.highway = {
|
|
1030
|
+
y: layout.highway.y + 1,
|
|
1031
|
+
height: layout.highway.height - 1,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
972
1034
|
} else paintHeader(buffer, view, ui, width);
|
|
973
1035
|
const beat = view.beat ?? resolveBeat(view.score, nowMs);
|
|
974
1036
|
if (layout.highway.height > 0) {
|
|
@@ -990,6 +1052,11 @@ export function composeFrame(
|
|
|
990
1052
|
},
|
|
991
1053
|
);
|
|
992
1054
|
}
|
|
1055
|
+
if (ui.keys && layout.highway.height > 0)
|
|
1056
|
+
paintText(buffer, ui, keysRegion(layout.highway, ui.keys), width, {
|
|
1057
|
+
text: ui.keys,
|
|
1058
|
+
hint: HINTS.keys,
|
|
1059
|
+
});
|
|
993
1060
|
paintActivity(buffer, layout.activity, ui, width, nowMs);
|
|
994
1061
|
const prompt = paintPrompt(buffer, view, ui, layout, width);
|
|
995
1062
|
return { buffer, cursor: prompt.cursor, promptRows: prompt.rows, layout };
|
|
@@ -1021,6 +1088,14 @@ export type AppInput =
|
|
|
1021
1088
|
| { type: "pick"; picker: string; value: string }
|
|
1022
1089
|
/** Esc on a picker. */
|
|
1023
1090
|
| { type: "pick-cancel"; picker: string }
|
|
1091
|
+
/**
|
|
1092
|
+
* The highlighted row changed (move, page, filter): the hook for live
|
|
1093
|
+
* previews (/pattern today; an audition controller can listen here too).
|
|
1094
|
+
* Pickers raise `pick-move` → `pick` (commit) or `pick-cancel` (cancel).
|
|
1095
|
+
*/
|
|
1096
|
+
| { type: "pick-move"; picker: string; value: string }
|
|
1097
|
+
/** Space, `a` or `c` on a picker that hosts the audition loop. */
|
|
1098
|
+
| { type: "pick-audition"; picker: string; key: "loop" | "ab" | "context" }
|
|
1024
1099
|
/** Consumed by an overlay (scroll, filter, move). */
|
|
1025
1100
|
| { type: "overlay" }
|
|
1026
1101
|
| { type: "none" };
|
|
@@ -1073,6 +1148,7 @@ export class TuiApp {
|
|
|
1073
1148
|
log: this.log,
|
|
1074
1149
|
picker: this.picker,
|
|
1075
1150
|
text: this.text,
|
|
1151
|
+
keys: this.keys,
|
|
1076
1152
|
};
|
|
1077
1153
|
}
|
|
1078
1154
|
|
|
@@ -1121,15 +1197,17 @@ export class TuiApp {
|
|
|
1121
1197
|
if (nav === "up" || nav === "down" || nav === "pgup" || nav === "pgdn") {
|
|
1122
1198
|
const step =
|
|
1123
1199
|
nav === "up" ? -1 : nav === "down" ? 1 : nav === "pgup" ? -8 : 8;
|
|
1200
|
+
// Moving ends typing into the filter; the filter itself stays.
|
|
1201
|
+
picker.filtering = false;
|
|
1124
1202
|
picker.index = Math.max(
|
|
1125
1203
|
0,
|
|
1126
1204
|
Math.min(picker.items.length - 1, picker.index + step),
|
|
1127
1205
|
);
|
|
1128
|
-
return
|
|
1206
|
+
return this.pickerMoved(picker);
|
|
1129
1207
|
}
|
|
1130
1208
|
if (nav === "home" || nav === "end") {
|
|
1131
1209
|
picker.index = nav === "home" ? 0 : picker.items.length - 1;
|
|
1132
|
-
return
|
|
1210
|
+
return this.pickerMoved(picker);
|
|
1133
1211
|
}
|
|
1134
1212
|
if (nav === "enter") {
|
|
1135
1213
|
const item = picker.items[picker.index];
|
|
@@ -1139,30 +1217,51 @@ export class TuiApp {
|
|
|
1139
1217
|
: { type: "pick-cancel", picker: picker.id };
|
|
1140
1218
|
}
|
|
1141
1219
|
if (key.type === "ui" && key.command === "close-overlay") {
|
|
1220
|
+
// Esc clears the filter first, then closes.
|
|
1221
|
+
if (picker.query || picker.filtering) {
|
|
1222
|
+
this.filterPicker("");
|
|
1223
|
+
if (this.picker) this.picker.filtering = false;
|
|
1224
|
+
return { type: "overlay" };
|
|
1225
|
+
}
|
|
1142
1226
|
this.closePicker();
|
|
1143
1227
|
return { type: "pick-cancel", picker: picker.id };
|
|
1144
1228
|
}
|
|
1145
|
-
if (picker.filterable &&
|
|
1146
|
-
|
|
1147
|
-
|
|
1229
|
+
if (picker.filterable && picker.filtering) {
|
|
1230
|
+
if (value === "\u007f" || value === "\b") {
|
|
1231
|
+
const query = (picker.query ?? "").slice(0, -1);
|
|
1232
|
+
this.filterPicker(query);
|
|
1233
|
+
if (!query && this.picker) this.picker.filtering = false;
|
|
1234
|
+
return { type: "overlay" };
|
|
1235
|
+
}
|
|
1236
|
+
if (key.type === "text") {
|
|
1237
|
+
this.filterPicker(((picker.query ?? "") + key.text).slice(0, 40));
|
|
1238
|
+
return { type: "overlay" };
|
|
1239
|
+
}
|
|
1148
1240
|
}
|
|
1149
|
-
if (
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1241
|
+
if (picker.audition && !picker.filtering) {
|
|
1242
|
+
const audition =
|
|
1243
|
+
value === " "
|
|
1244
|
+
? "loop"
|
|
1245
|
+
: value === "a"
|
|
1246
|
+
? "ab"
|
|
1247
|
+
: value === "c"
|
|
1248
|
+
? "context"
|
|
1249
|
+
: undefined;
|
|
1250
|
+
if (audition)
|
|
1251
|
+
return { type: "pick-audition", picker: picker.id, key: audition };
|
|
1156
1252
|
}
|
|
1157
|
-
if (key.type === "text" &&
|
|
1158
|
-
|
|
1159
|
-
if (index < picker.items.length) {
|
|
1160
|
-
const item = picker.items[index]!;
|
|
1161
|
-
this.closePicker();
|
|
1162
|
-
return { type: "pick", picker: picker.id, value: item.value };
|
|
1163
|
-
}
|
|
1253
|
+
if (picker.filterable && key.type === "text" && key.text === "/") {
|
|
1254
|
+
picker.filtering = true;
|
|
1164
1255
|
return { type: "overlay" };
|
|
1165
1256
|
}
|
|
1257
|
+
if (key.type === "text" && (key.text === "j" || key.text === "k")) {
|
|
1258
|
+
const step = key.text === "k" ? -1 : 1;
|
|
1259
|
+
picker.index = Math.max(
|
|
1260
|
+
0,
|
|
1261
|
+
Math.min(picker.items.length - 1, picker.index + step),
|
|
1262
|
+
);
|
|
1263
|
+
return this.pickerMoved(picker);
|
|
1264
|
+
}
|
|
1166
1265
|
// Quit and redraw still work; everything else is swallowed.
|
|
1167
1266
|
if (
|
|
1168
1267
|
key.type === "ui" &&
|
|
@@ -1270,6 +1369,14 @@ export class TuiApp {
|
|
|
1270
1369
|
}
|
|
1271
1370
|
|
|
1272
1371
|
/** Show an arrow-key picker over the highway; replaces any overlay. */
|
|
1372
|
+
/** `pick-move` for the row now highlighted, or a plain overlay input. */
|
|
1373
|
+
private pickerMoved(picker: PickerState): AppInput {
|
|
1374
|
+
const item = picker.items[picker.index];
|
|
1375
|
+
return item
|
|
1376
|
+
? { type: "pick-move", picker: picker.id, value: item.value }
|
|
1377
|
+
: { type: "overlay" };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1273
1380
|
openPicker(picker: Omit<PickerState, "index"> & { index?: number }): void {
|
|
1274
1381
|
const items = picker.items.slice(0, MAX_PICKER_ITEMS);
|
|
1275
1382
|
if (items.length === 0) return;
|
|
@@ -1312,6 +1419,21 @@ export class TuiApp {
|
|
|
1312
1419
|
this.overlay = "text";
|
|
1313
1420
|
}
|
|
1314
1421
|
|
|
1422
|
+
/** The `?` panel over the current screen; any key closes it. */
|
|
1423
|
+
keys: TextView | undefined;
|
|
1424
|
+
showKeys(title: string, lines: readonly string[]): void {
|
|
1425
|
+
this.keys = { title, lines, scroll: 0 };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
closeKeys(): void {
|
|
1429
|
+
this.keys = undefined;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** True while a picker's `/` filter is taking typed text. */
|
|
1433
|
+
get pickerTyping(): boolean {
|
|
1434
|
+
return this.overlay === "picker" && this.picker?.filtering === true;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1315
1437
|
closeText(): void {
|
|
1316
1438
|
this.text = undefined;
|
|
1317
1439
|
if (this.overlay === "text") this.overlay = undefined;
|
package/tui/grammar.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared key grammar: one vocabulary for every picker, editor and menu,
|
|
3
|
+
* the one-line footer hint each screen shows, and the `?` panel listing the
|
|
4
|
+
* keys of the current screen.
|
|
5
|
+
*
|
|
6
|
+
* ↑↓ (j k) move Enter open or confirm
|
|
7
|
+
* ←→ (h l, - +) adjust a value Space audition or toggle
|
|
8
|
+
* / filter Esc back one level (filter first)
|
|
9
|
+
* ? keys for this screen
|
|
10
|
+
* digits type a value, only where a value is focused
|
|
11
|
+
*
|
|
12
|
+
* Play mode is the one exception (its letters are piano keys, by the
|
|
13
|
+
* GarageBand "Musical Typing" convention); its panel says so.
|
|
14
|
+
*/
|
|
15
|
+
import { displayWidth } from "./text.ts";
|
|
16
|
+
|
|
17
|
+
/** A `key action` pair for the `?` panel. */
|
|
18
|
+
export type KeyRow = readonly [keys: string, action: string];
|
|
19
|
+
export type KeySection = Readonly<{ title: string; rows: readonly KeyRow[] }>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fit a ` a · b · esc back · ? keys ` hint into `width` columns by dropping
|
|
23
|
+
* parts from the middle: the way out (`esc …`) and `? keys` stay longest,
|
|
24
|
+
* since they are how a user leaves or learns the rest. Returns "" when not
|
|
25
|
+
* even `? keys` fits.
|
|
26
|
+
*/
|
|
27
|
+
export function fitHint(hint: string, width: number): string {
|
|
28
|
+
const key = `${width}\u0000${hint}`;
|
|
29
|
+
const cached = fitted.get(key);
|
|
30
|
+
if (cached !== undefined) return cached;
|
|
31
|
+
const text = fitHintUncached(hint, width);
|
|
32
|
+
if (fitted.size > 256) fitted.clear();
|
|
33
|
+
fitted.set(key, text);
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Hints repeat every frame; fit each (hint, width) once. */
|
|
38
|
+
const fitted = new Map<string, string>();
|
|
39
|
+
|
|
40
|
+
function fitHintUncached(hint: string, width: number): string {
|
|
41
|
+
const parts = hint
|
|
42
|
+
.trim()
|
|
43
|
+
.split(" · ")
|
|
44
|
+
.filter((part) => part.length > 0);
|
|
45
|
+
if (parts.length === 0) return "";
|
|
46
|
+
const tailAt = parts.findIndex(
|
|
47
|
+
(part) => part.startsWith("esc ") || part.startsWith("? "),
|
|
48
|
+
);
|
|
49
|
+
const head = tailAt < 0 ? parts.slice(0, -1) : parts.slice(0, tailAt);
|
|
50
|
+
const tail = tailAt < 0 ? parts.slice(-1) : parts.slice(tailAt);
|
|
51
|
+
for (;;) {
|
|
52
|
+
const text = ` ${[...head, ...tail].join(" · ")} `;
|
|
53
|
+
if (displayWidth(text) <= width) return text;
|
|
54
|
+
if (head.length > 0) head.pop();
|
|
55
|
+
else if (tail.length > 1) tail.shift();
|
|
56
|
+
else return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The ASCII spelling of a hint, for terminals without Unicode arrows. */
|
|
61
|
+
export function asciiHint(hint: string): string {
|
|
62
|
+
return hint
|
|
63
|
+
.replace(/↑↓/g, "up/dn")
|
|
64
|
+
.replace(/←→/g, "lt/rt")
|
|
65
|
+
.replace(/–/g, "-");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── footer hints, one per screen ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export const HINTS = {
|
|
71
|
+
list: " ↑↓ move · enter choose · / filter · esc back · ? keys ",
|
|
72
|
+
preview: " ↑↓ preview · enter apply · / filter · esc back · ? keys ",
|
|
73
|
+
hover: " ↑↓ hear · enter choose · a A/B · c context · esc back · ? keys ",
|
|
74
|
+
audition:
|
|
75
|
+
" ↑↓ hear · enter keep · space loop · a A/B · c mix · / filter · esc back · ? keys ",
|
|
76
|
+
filtering: " type to filter · enter choose · esc clear · ? keys ",
|
|
77
|
+
menu: " ↑↓ move · enter open · / filter · esc back · ? keys ",
|
|
78
|
+
value: " ←→ adjust · enter type · x reset · esc back · ? keys ",
|
|
79
|
+
point: " ←→ adjust · enter type · x delete · esc back · ? keys ",
|
|
80
|
+
action: " ↑↓ move · enter apply · / filter · esc back · ? keys ",
|
|
81
|
+
typing: " type a value · enter apply · esc cancel ",
|
|
82
|
+
euclid:
|
|
83
|
+
" ↑↓ voice · ←→ adjust · tab field · space hear · x off · esc back · ? keys ",
|
|
84
|
+
text: " ↑↓ scroll · pgup pgdn page · esc back · ? keys ",
|
|
85
|
+
log: " ↑↓ scroll · / filter · esc back · ? keys ",
|
|
86
|
+
keys: " any key closes ",
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
// ── the `?` panel ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const LIST: readonly KeyRow[] = [
|
|
92
|
+
["↑ ↓ j k", "move"],
|
|
93
|
+
["pgup pgdn", "page"],
|
|
94
|
+
["enter", "choose"],
|
|
95
|
+
["/", "filter (type, then enter or esc)"],
|
|
96
|
+
["esc", "clear the filter, then back"],
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export const KEYS = {
|
|
100
|
+
prompt: [
|
|
101
|
+
{
|
|
102
|
+
title: "start here",
|
|
103
|
+
rows: [
|
|
104
|
+
["type + enter", "ask for a change in plain words, or a command"],
|
|
105
|
+
["ctrl-p", "play notes on the keyboard (chords: q)"],
|
|
106
|
+
["ctrl-k", "menu: sound, effects, rhythm, chords, mix"],
|
|
107
|
+
["space", "play / pause (empty prompt)"],
|
|
108
|
+
["/help", "what dawg can do · /help <topic> for more"],
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
title: "prompt",
|
|
113
|
+
rows: [
|
|
114
|
+
["enter", "send"],
|
|
115
|
+
["shift-enter ctrl-j", "new line"],
|
|
116
|
+
["alt-enter ctrl-q", "queue after the current request"],
|
|
117
|
+
["ctrl-z ctrl-y", "undo / redo"],
|
|
118
|
+
["ctrl-o", "transcript"],
|
|
119
|
+
["esc", "cancel the agent · close a panel"],
|
|
120
|
+
["ctrl-c", "quit"],
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
list: [{ title: "list", rows: LIST }],
|
|
125
|
+
audition: [
|
|
126
|
+
{
|
|
127
|
+
title: "auditioning list",
|
|
128
|
+
rows: [
|
|
129
|
+
["space", "loop the focused track · again stops"],
|
|
130
|
+
["↑ ↓ j k", "move; while looping, hear the row on the loop"],
|
|
131
|
+
["a", "A/B: before ↔ the highlighted row"],
|
|
132
|
+
["c", "solo ↔ in context (the whole mix)"],
|
|
133
|
+
["enter", "keep it (one undo step)"],
|
|
134
|
+
["esc", "back; nothing changes"],
|
|
135
|
+
["/", "filter (type, then enter or esc)"],
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
preview: [
|
|
140
|
+
{
|
|
141
|
+
title: "patterns",
|
|
142
|
+
rows: [
|
|
143
|
+
["↑ ↓ j k", "move and hear the pattern"],
|
|
144
|
+
["enter", "apply it to the focused track"],
|
|
145
|
+
...LIST.slice(3),
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
menu: [
|
|
150
|
+
{
|
|
151
|
+
title: "menu",
|
|
152
|
+
rows: [
|
|
153
|
+
["↑ ↓ j k", "move"],
|
|
154
|
+
["enter → l", "open a section"],
|
|
155
|
+
["esc ← h", "back one level"],
|
|
156
|
+
["/", "filter this level"],
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
title: "on a value",
|
|
161
|
+
rows: [
|
|
162
|
+
["← → h l - +", "adjust"],
|
|
163
|
+
["0-9", "type a value, enter applies"],
|
|
164
|
+
["enter", "type a value · pick from a list"],
|
|
165
|
+
["space", "toggle on/off (elsewhere: hear the track)"],
|
|
166
|
+
["x delete", "reset to default (deletes an automation point)"],
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
title: "auditioning",
|
|
171
|
+
rows: [
|
|
172
|
+
["space", "loop the focused track · again stops"],
|
|
173
|
+
["c", "solo ↔ in context (the whole mix)"],
|
|
174
|
+
["a", "A/B: committed ↔ staged"],
|
|
175
|
+
["enter", "keep staged changes (one undo step)"],
|
|
176
|
+
["esc", "revert staged changes"],
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
title: "every change",
|
|
181
|
+
rows: [
|
|
182
|
+
["", "while looping, stages; otherwise runs the command shown"],
|
|
183
|
+
["ctrl-z", "undoes it"],
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
euclid: [
|
|
188
|
+
{
|
|
189
|
+
title: "rhythm editor",
|
|
190
|
+
rows: [
|
|
191
|
+
["↑ ↓ j k", "voice"],
|
|
192
|
+
["← → h l - +", "adjust the field"],
|
|
193
|
+
["tab shift-tab ] [", "next / previous field"],
|
|
194
|
+
["0-9", "type a value, enter applies"],
|
|
195
|
+
["enter", "add a row (or type a value)"],
|
|
196
|
+
["space", "hear the voice"],
|
|
197
|
+
["x", "turn the row off"],
|
|
198
|
+
["f", "freeze into plain hits"],
|
|
199
|
+
["esc", "back"],
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
text: [
|
|
204
|
+
{
|
|
205
|
+
title: "panel",
|
|
206
|
+
rows: [
|
|
207
|
+
["↑ ↓ j k", "scroll"],
|
|
208
|
+
["pgup pgdn home end", "page"],
|
|
209
|
+
["esc", "close"],
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
log: [
|
|
214
|
+
{
|
|
215
|
+
title: "transcript",
|
|
216
|
+
rows: [
|
|
217
|
+
["↑ ↓", "scroll"],
|
|
218
|
+
["pgup pgdn home end", "page"],
|
|
219
|
+
["/", "next filter: all, requests, ops, errors"],
|
|
220
|
+
["esc ctrl-o", "close"],
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
play: [
|
|
225
|
+
{
|
|
226
|
+
title: "play mode · letters are piano keys",
|
|
227
|
+
rows: [
|
|
228
|
+
["a s d f g h j k l ; '", "white keys"],
|
|
229
|
+
["w e t y u o p", "black keys"],
|
|
230
|
+
["z x", "octave down / up"],
|
|
231
|
+
["c v", "velocity down / up"],
|
|
232
|
+
["shift · tab", "sustain while held · sustain latch"],
|
|
233
|
+
["space", "play / pause (with count-in)"],
|
|
234
|
+
["r · R", "record · record replacing"],
|
|
235
|
+
["m", "metronome click"],
|
|
236
|
+
["q", "chord mode: auto ⇄ manual"],
|
|
237
|
+
["/", "type a command, still in play mode"],
|
|
238
|
+
["ctrl-k", "menu"],
|
|
239
|
+
["esc", "leave play mode"],
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
chords: [
|
|
244
|
+
{
|
|
245
|
+
title: "chord mode · number row latches",
|
|
246
|
+
rows: [
|
|
247
|
+
["1 2 3 4", "dim · min · maj · sus (two combine)"],
|
|
248
|
+
["5 6 7 8", "add 6 · m7 · M7 · 9"],
|
|
249
|
+
["0", "clear the latches"],
|
|
250
|
+
["- =", "voicing down / up"],
|
|
251
|
+
["9", "next perform mode (block, strum, arp…)"],
|
|
252
|
+
["b", "next bass mode"],
|
|
253
|
+
["n", "play the suggested next chord"],
|
|
254
|
+
["q", "auto (chords fit the key) ⇄ manual"],
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
} satisfies Record<string, readonly KeySection[]>;
|
|
259
|
+
|
|
260
|
+
export type KeyScreen = keyof typeof KEYS;
|
|
261
|
+
|
|
262
|
+
/** `?` panel lines: a heading per section, then aligned `keys action`. */
|
|
263
|
+
export function keyLines(sections: readonly KeySection[]): string[] {
|
|
264
|
+
const column =
|
|
265
|
+
Math.max(
|
|
266
|
+
0,
|
|
267
|
+
...sections.flatMap((section) =>
|
|
268
|
+
section.rows.map(([keys]) => displayWidth(keys)),
|
|
269
|
+
),
|
|
270
|
+
) + 2;
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
for (const section of sections) {
|
|
273
|
+
if (lines.length) lines.push("");
|
|
274
|
+
lines.push(`── ${section.title}`);
|
|
275
|
+
for (const [keys, action] of section.rows)
|
|
276
|
+
lines.push(
|
|
277
|
+
`${keys}${" ".repeat(Math.max(1, column - displayWidth(keys)))}${action}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
package/tui/highway.ts
CHANGED
|
@@ -562,7 +562,13 @@ export function paintHighway(
|
|
|
562
562
|
if (empty) {
|
|
563
563
|
const name = score.trackName ?? score.trackId ?? "track";
|
|
564
564
|
const start = projection.kind === "pitch" ? "add C4 at 0" : "hit kick at 0";
|
|
565
|
-
|
|
565
|
+
// First run: name the three ways in (ask, play, menu); narrow
|
|
566
|
+
// windows fall back to one prompt command.
|
|
567
|
+
const full = `${name} · empty · type a request · ctrl-p play · ctrl-k menu`;
|
|
568
|
+
const text = truncate(
|
|
569
|
+
full.length <= areaWidth ? full : `${name} · empty · ${start} to start`,
|
|
570
|
+
areaWidth,
|
|
571
|
+
);
|
|
566
572
|
const x = gutter + Math.max(0, Math.floor((areaWidth - text.length) / 2));
|
|
567
573
|
const y = Math.max(0, Math.floor(hitRow / 2));
|
|
568
574
|
for (let index = 0; index < text.length; index += 1)
|