Reset a track slider with a double-click in the Song tab
game-maker / gate (push) Successful in 3m30s
game-maker / suite (push) Successful in 16m37s

The heads of the Song tab had four rough spots. Panned down, the
first track had no line above it. The M and S letters sat high in
their buttons. A volume or pan slider could only be dragged back to
the middle by eye. Muting a track paled its volume slider.

- The first track draws a border along its top, in the lanes and in
  its head, like the one below every track.
- M and S sit in the middle of their buttons. A label of the heads
  can now carry a height and is centred in its box.
- A double-click on a head's volume or pan sets it back to 0: the
  volume to 0 dB, the pan to the middle. It is one undo step of its
  own after the step the first click set.
- Mute only lights its own button. The volume slider keeps its
  colour, so a muted head no longer looks out of step.

Tests:
- e2e 65 on melektaus-atx: ALL PASSED, 27 checks (+1)
- typecheck, ESLint, Prettier: clean (also after the mute change)
- screenshot on stein2: border above track 1, M and S with 4 px free
  above and below the letter in a 16 px button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 19:36:07 +02:00
1 parent 5b96d0b329
commit 5fde3e975e
2 files changed
+40 -5

No files matched your search

+24 -4
View File
@@ -120,6 +120,8 @@ interface Placed {
x: number;
y: number;
w: number;
/** Given, the text sits in the middle of the box `w` by `h`. */
h?: number;
dark?: boolean;
}
@@ -657,6 +659,17 @@ export class SongTimeline extends Widget {
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: !t[part] } : t)) },
label,
);
} else if (
(part === 'volume' || part === 'pan') &&
pressCount(e, `song-slider:${track.id}:${part}`) === 2
) {
// A double-click sets it back to the middle: 0 dB, or straight ahead.
this.commit(
asset,
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, [part]: 0 } : t)) },
part === 'volume' ? 'Reset track volume' : 'Reset track pan',
);
this.onTracksPreview(this.asset()?.tracks ?? asset.tracks);
} else if (part === 'volume' || part === 'pan') {
const value = this.sliderAt(part, x);
this.gesture = { kind: 'slider', track: track.id, part, value };
@@ -1207,6 +1220,7 @@ export class SongTimeline extends Widget {
this.lanesG
.rect(0, y, lw, th)
.fill(lit ? { color: c.accent, alpha: 0.08 } : { color: 0xffffff, alpha: i % 2 ? 0.015 : 0.035 });
if (i === 0) this.lanesG.rect(0, y, lw, 1).fill({ color: c.border, alpha: 0.8 });
this.lanesG.rect(0, y + th - 1, lw, 1).fill({ color: c.border, alpha: 0.8 });
});
// Above the first track and below the last, panned to, is empty.
@@ -1358,14 +1372,15 @@ export class SongTimeline extends Widget {
const lit = track.id === this.pickedTrack;
const th = this.view.trackHeight;
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
const { name, mute, solo, volume, pan } = HEAD_BOXES;
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
this.toggle(mute, y, track.mute, MUTE_COLOR);
this.toggle(solo, y, track.solo, SOLO_COLOR);
labels.push(
{ text: 'M', x: mute.x + 5, y: y + mute.y, w: mute.w, dark: track.mute },
{ text: 'S', x: solo.x + 5, y: y + solo.y, w: solo.w, dark: track.solo },
{ text: 'M', x: mute.x, y: y + mute.y, w: mute.w, h: mute.h, dark: track.mute },
{ text: 'S', x: solo.x, y: y + solo.y, w: solo.w, h: solo.h, dark: track.solo },
);
if (th < SLIDERS_MIN_HEIGHT) return;
const share = sliderShare(track.volume, TRACK_MIN_DB, TRACK_MAX_DB);
@@ -1375,7 +1390,7 @@ export class SongTimeline extends Widget {
.stroke({ width: 1, color: c.border, alignment: 1 });
this.headsG
.rect(volume.x + 1, y + volume.y + 1, Math.max(0, (volume.w - 2) * share), volume.h - 2)
.fill({ color: c.accent, alpha: track.mute ? 0.35 : 0.8 });
.fill({ color: c.accent, alpha: 0.8 });
const unity = volume.x + volume.w * sliderShare(0, TRACK_MIN_DB, TRACK_MAX_DB);
this.headsG.rect(unity, y + volume.y - 2, 1, volume.h + 4).fill({ color: c.text, alpha: 0.5 });
this.headsG
@@ -1414,7 +1429,12 @@ export class SongTimeline extends Widget {
for (let cut = label.text.length; text.width > label.w && cut > 1; cut--)
text.text = `${label.text.slice(0, cut - 1)}…`;
text.visible = true;
text.position.set(label.x, label.y);
if (label.h === undefined) text.position.set(label.x, label.y);
else
text.position.set(
Math.round(label.x + (label.w - text.width) / 2),
Math.round(label.y + (label.h - text.height) / 2),
);
});
for (let i = labels.length; i < pool.length; i++) pool[i]!.visible = false;
}
+16 -1
View File
@@ -10,7 +10,7 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs';
* copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click
* on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the
* wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets
* the volume of its track, its menu adds one. Space plays the song through the scheduler:
* the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler:
* the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus.
* A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab.
* Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and clips
@@ -407,6 +407,21 @@ export async function song(t) {
(await json('__gm.song.info()')) === '5 tracks · 2 items',
JSON.stringify(heads.tracks),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks[2].pan = 0.5;`);
await sleep(100);
p = await json("__gm.song.headAt(2, 'volume', 0.2)");
await doubleClick(p.x, p.y);
p = await json("__gm.song.headAt(2, 'pan', 0.9)");
await doubleClick(p.x, p.y);
await sleep(150);
const reset = (await model()).tracks[2];
check(
'a double-click on a slider sets it back to 0: volume to 0 dB, pan to the middle',
reset.volume === 0 && reset.pan === 0,
JSON.stringify(reset),
);
await mutate(`
const song = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
song.tracks[2].volume = -6;