Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
manual p.151) and its resizable Clip View Selector (p.210); Logic Pro
reaches the same place with a separate scroll bar plus a Horizontal Zoom
slider (Logic Pro user guide, p.297).
- **The drum grid scrolls vertically — the kick and snare are reachable
again.** `DRUM_LANE_H` is a fixed 22px and the grid's lane geometry had no
scroll term, so a full 18-piece kit (396px) simply ran off the bottom of a
short canvas with no way to reach what fell off. Because the lane order
ends `snare, snare_xstick, kick`, the two most-used pieces on the kit were
the first to disappear. Measured in a browser at a 1600x660 window: a 449px
canvas with 356px of lane band, losing the bottom two rows outright.

A vertical wheel now scrolls the grid when (and only when) it overflows,
with a scrollbar on the right edge that appears only when there is
something to scroll — the Tracks-area behaviour Logic describes at p.297 of
its user guide. Drag the thumb, or click the rail to jump. When the grid
already fits, nothing changes: no bar, and the wheel keeps panning the
timeline exactly as before.

### Changed

Expand Down
43 changes: 34 additions & 9 deletions src/drum.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
import { CP } from './canvas-appearance.js';
import { ctx } from './canvas.js';
import { drawSelectionRect } from './draw.js';
import { LABEL_W, TIMELINE_TOP, WAVEFORM_H, timeToX, xToTime } from './geometry.js';
import { drawLaneScrollbar } from './lane-scroll.js';
import { LABEL_W, TIMELINE_TOP, WAVEFORM_H, timeToX, xToTime, laneScrollY} from './geometry.js';
import { host } from './host.js';
import { S, editGen } from './state.js';
import { setStatus } from './ui.js';
Expand Down Expand Up @@ -259,8 +260,11 @@ export function _editorSetDrumDensity(mode) {
_drumDensityCache = next;
try { localStorage.setItem('editorDrumDensity', next); } catch (_) {}
// Row count/order changed — drop selection (indices keep meaning, but
// the user's visual anchor doesn't) and repaint.
// the user's visual anchor doesn't) and repaint. The row count also
// changes the grid's height, so the vertical scroll has to be re-clamped:
// Full (18 rows) → Compact (7) can otherwise leave it scrolled past the end.
S.drumSel = new Set();
S.laneScrollY = 0;
host.draw();
setStatus(next === 'compact'
? 'Compact rows — families share a row (colors keep each piece distinct); adding writes the family’s main piece'
Expand Down Expand Up @@ -305,9 +309,13 @@ export const DRUM_HIT_RADIUS = 8;
// Y" answers with the row's CANONICAL piece — the add-target; hit lookup
// matches any member of the row (see _drumHitAtPoint).
export function _drumPieceCount() { return _drumLanes().length; }
export function _drumLaneIdxToY(idx) { return (TIMELINE_TOP + WAVEFORM_H) + idx * DRUM_LANE_H; }
// The vertical-scroll funnel for the drum grid. Every painter and hit-test
// reaches lane geometry through this pair, so the offset belongs here and
// nowhere else — see src/lane-scroll.js for why the grid needed it at all
// (the kick lane was unreachable on a short canvas).
export function _drumLaneIdxToY(idx) { return (TIMELINE_TOP + WAVEFORM_H) - laneScrollY() + idx * DRUM_LANE_H; }
export function _drumYToLaneIdx(y) {
const idx = Math.floor((y - (TIMELINE_TOP + WAVEFORM_H)) / DRUM_LANE_H);
const idx = Math.floor((y - (TIMELINE_TOP + WAVEFORM_H) + laneScrollY()) / DRUM_LANE_H);
if (idx < 0 || idx >= _drumPieceCount()) return -1;
return idx;
}
Expand Down Expand Up @@ -352,6 +360,18 @@ export function _drumEditorDraw(w, h) {
host.drawTimelineHeader(w);
host.drawWaveform(w);

// Everything below the waveform scrolls vertically, so it is clipped to
// the lane viewport — without this a scrolled grid paints straight up
// over the waveform and ruler. Restored before the HUD, which is chrome
// and must stay put. The band bottom is the CONTENT's end or the canvas
// edge, whichever comes first.
const bandTop = TIMELINE_TOP + WAVEFORM_H;
const bandBottom = Math.min(h, _drumLaneIdxToY(_drumPieceCount()));
ctx.save();
ctx.beginPath();
ctx.rect(0, bandTop, w, Math.max(0, h - bandTop));
ctx.clip();

// ── Lane grid ─────────────────────────────────────────────────────
const laneTable = _drumLanes();
for (let i = 0; i < laneTable.length; i++) {
Expand Down Expand Up @@ -392,8 +412,8 @@ export function _drumEditorDraw(w, h) {
ctx.strokeStyle = meas ? CP('gridMeasure') : CP('gridBeat');
ctx.lineWidth = meas ? 1.5 : 1;
ctx.beginPath();
ctx.moveTo(x, (TIMELINE_TOP + WAVEFORM_H));
ctx.lineTo(x, (TIMELINE_TOP + WAVEFORM_H) + _drumPieceCount() * DRUM_LANE_H);
ctx.moveTo(x, bandTop);
ctx.lineTo(x, bandBottom);
ctx.stroke();
}

Expand Down Expand Up @@ -535,21 +555,26 @@ export function _drumEditorDraw(w, h) {
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(timeToX(S.cursorTime), (TIMELINE_TOP + WAVEFORM_H));
ctx.lineTo(timeToX(S.cursorTime), (TIMELINE_TOP + WAVEFORM_H) + _drumPieceCount() * DRUM_LANE_H);
ctx.moveTo(timeToX(S.cursorTime), bandTop);
ctx.lineTo(timeToX(S.cursorTime), bandBottom);
ctx.stroke();
}

// ── Marquee rubber-band selection rect ────────────────────────────
drawSelectionRect();

ctx.restore(); // end lane-viewport clip
drawLaneScrollbar(w, h);

// ── HUD ───────────────────────────────────────────────────────────
ctx.fillStyle = '#94a3b8';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const hud = `Drum editor — ${hits.length} hits, ${S.drumSel.size} selected. Click empty: add. Drag empty: select box. Click hit: select. Del: remove. G/F/K: ghost/flam/choke. A/N: accent/normal. Alt+drag or Shift+↑/↓: velocity.`;
const hudY = (TIMELINE_TOP + WAVEFORM_H) + _drumPieceCount() * DRUM_LANE_H + 6;
// Sits under the grid normally; pinned above the canvas edge once the
// grid is taller than the view, so the hints never scroll out of reach.
const hudY = Math.min(h - 32, bandBottom + 6);
ctx.fillText(hud, LABEL_W + 6, hudY);
// Advisory playability count on its own line — amber, non-blocking. Wording
// stays gentle (these are hints for the human, not errors).
Expand Down
14 changes: 14 additions & 0 deletions src/geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ export function clampZoom(z) {
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, v));
}

// ── Vertical lane scroll offset ─────────────────────────────────────
// Lives HERE rather than in lane-scroll.js purely to break a cycle: the
// drum-grid and piano-roll geometry funnels have to read it, and
// lane-scroll.js imports both of those modules to answer "which view is
// showing". geometry.js is already a leaf both of them depend on.
//
// Pixels, unlike S.scrollX's seconds — lanes have no natural time unit.
// Reads 0 for views that don't scroll vertically, so the funnels can read
// it unconditionally. src/lane-scroll.js owns every WRITE.
export function laneScrollY() {
const y = Number(S.laneScrollY);
return Number.isFinite(y) && y > 0 ? y : 0;
}

// ── time ⇄ x ────────────────────────────────────────────────────────
export function timeToX(t) { return LABEL_W + (t - S.scrollX) * S.zoom; }
export function xToTime(x) { return (x - LABEL_W) / S.zoom + S.scrollX; }
Expand Down
185 changes: 185 additions & 0 deletions src/lane-scroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/* Slopsmith Arrangement Editor — vertical lane scrolling.
*
* The horizontal story has always been complete: `S.scrollX` pans the
* timeline and every writer funnels through `_editorClampScrollX`. The
* VERTICAL story had a hole. Two canvas views stack fixed-height lanes and
* simply ran off the bottom of a short canvas with no way to reach what
* fell off:
*
* Drum grid `DRUM_LANE_H` is a fixed 22px and `_drumLaneIdxToY` had no
* scroll term. 18 full-kit lanes need 396px, so on a canvas
* shorter than ~496px the bottom lanes were unreachable — and
* `DRUM_PIECE_ORDER` ends `snare, snare_xstick, kick`, so the
* two most-used pieces on the kit were the first to vanish.
* That is the bug this module exists for.
* Piano roll dodged the overflow by SQUASHING instead: `PIANO_LANE_H`
* packs the whole range into ~350px and floors at 4px per
* semitone. Reachable, but unusable at any real range.
*
* The model mirrors the horizontal one exactly: one scroll offset in
* `S.laneScrollY` (PIXELS, unlike `S.scrollX`'s seconds — lanes have no
* natural unit), one clamp every writer passes through, and one funnel per
* view so no call site can forget the term. `midiToY`/`yToMidi` and
* `_drumLaneIdxToY`/`_drumYToLaneIdx` are those funnels: 29 call sites
* across 7 modules go through them, so adding the offset there reaches
* every painter and hit-test at once.
*
* Reference: Logic exposes a vertical scroll bar on the right edge of the
* Tracks area, and only when the content exceeds the view (Logic Pro user
* guide, p.297) — the auto-hide is deliberate, not an accident. Live puts
* the same job on the wheel. Both are followed here.
*
* The bar is PAINTED on the canvas rather than added as a DOM rail. A
* layout-participating rail would change `#editor-canvas-wrap`'s client
* size, which feeds `setLaneMetrics()` — so the lane heights would change
* the moment the bar appeared, moving the very content it measures. On the
* canvas it costs nothing and reuses the drag-state pattern the minimap
* scrollbar already established.
*/

import { S } from './state.js';
import { DRUM_LANE_H, _drumPieceCount } from './drum.js';
import { TIMELINE_TOP, WAVEFORM_H, laneScrollY } from './geometry.js';
import { ctx } from './canvas.js';

// Rail geometry. 10px reads as chrome rather than furniture at the edge of
// a dense grid; the thumb floors at 24px so a long piano range still leaves
// something to grab.
export const LANE_BAR_W = 10;
export const LANE_THUMB_MIN_H = 24;

/* @pure:lane-scroll:start */
// How far the lanes may scroll. Zero when everything already fits — the
// content is then pinned to the top, never floating.
export function _laneScrollMaxPure(contentH, viewH) {
const c = Number(contentH), v = Number(viewH);
if (!Number.isFinite(c) || !Number.isFinite(v) || c <= 0 || v <= 0) return 0;
return Math.max(0, c - v);
}

export function _clampLaneScrollPure(y, contentH, viewH) {
const raw = Number(y);
const safe = Number.isFinite(raw) ? raw : 0;
return Math.max(0, Math.min(safe, _laneScrollMaxPure(contentH, viewH)));
}

// The thumb's y-extent inside the rail, or null when there is nothing to
// scroll (which is also the signal not to paint the bar at all — Logic
// p.297 shows the vertical bar only when the content exceeds the view).
export function _laneThumbPure(scrollY, contentH, viewH, trackTop, trackH) {
const max = _laneScrollMaxPure(contentH, viewH);
if (max <= 0) return null;
const th = Number(trackH);
if (!Number.isFinite(th) || th <= 0) return null;
const top = Number(trackTop) || 0;
const minH = Math.min(LANE_THUMB_MIN_H, th);
const h = Math.max(minH, th * (Number(viewH) / Number(contentH)));
// Position by scroll PROGRESS over the travel the thumb actually has,
// so the thumb reaches the bottom exactly when the content does. Using
// the raw ratio would strand it short whenever the min-height kicked in.
const progress = _clampLaneScrollPure(scrollY, contentH, viewH) / max;
return { y0: top + progress * (th - h), y1: top + progress * (th - h) + h };
}

// Inverse: dragging the thumb to `y` means what scroll offset?
export function _laneScrollForThumbPure(y, grabDY, contentH, viewH, trackTop, trackH) {
const max = _laneScrollMaxPure(contentH, viewH);
if (max <= 0) return 0;
const th = Number(trackH);
const minH = Math.min(LANE_THUMB_MIN_H, th);
const h = Math.max(minH, th * (Number(viewH) / Number(contentH)));
const travel = th - h;
if (!(travel > 0)) return 0;
const top = (Number(trackTop) || 0) + (Number(grabDY) || 0);
const progress = (Number(y) - top) / travel;
return _clampLaneScrollPure(progress * max, contentH, viewH);
}
/* @pure:lane-scroll:end */

// Where the lane band starts. Both scrolling views stack from the same
// place: below the timeline header and the waveform strip.
export function laneBandTop() { return TIMELINE_TOP + WAVEFORM_H; }

// Metrics for whichever view is showing, or null when the active view does
// not scroll vertically. Today that means the drum grid only.
//
// The string/tab view is deliberately absent: its lanes AUTO-FIT via
// setLaneMetrics(), and at a 30px floor even a 9-string instrument fits
// every realistic canvas, so a bar there would be chrome that never moves.
// The tempo map has no lane stack at all.
//
// The PIANO ROLL is absent for a different reason — it is a follow-up, not
// a non-issue. Its content is at least always reachable (it squashes to a
// 4px lane rather than overflowing), and unlike the drum grid it shares the
// main draw chain with the string view and has the anchor + handshape lanes
// pinned below it via _beatBarTopY(). Scrolling it means making that stack
// scroll-aware too, which is a bigger change than this reachability fix and
// does not belong bundled with it.
export function laneScrollMetrics(canvasH) {
const h = Number(canvasH);
if (!Number.isFinite(h) || h <= 0) return null;
const top = laneBandTop();
const viewH = Math.max(0, h - top);
if (viewH <= 0) return null;
if (S.drumEditMode && S.drumTab) {
return { top, viewH, contentH: _drumPieceCount() * DRUM_LANE_H };
}
return null;
}

// Re-clamp against the CURRENT metrics. Called after anything that changes
// the content or viewport height — a resize, a density switch, a range
// recompute — so a scrolled view can never be left staring past the end.
export function applyLaneScrollBounds(canvasH) {
const m = laneScrollMetrics(canvasH);
if (!m) { S.laneScrollY = 0; return 0; }
S.laneScrollY = _clampLaneScrollPure(S.laneScrollY, m.contentH, m.viewH);
return S.laneScrollY;
}

// Scroll by a wheel delta. Returns true when it consumed the gesture —
// false means "nothing to scroll here", and the caller should fall back to
// its existing behaviour rather than swallowing the event.
export function laneScrollBy(dy, canvasH) {
const m = laneScrollMetrics(canvasH);
if (!m || _laneScrollMaxPure(m.contentH, m.viewH) <= 0) return false;
const next = _clampLaneScrollPure(laneScrollY() + dy, m.contentH, m.viewH);
if (next === S.laneScrollY) return true; // consumed, but already at the stop
S.laneScrollY = next;
return true;
}

export function setLaneScrollY(y, canvasH) {
const m = laneScrollMetrics(canvasH);
if (!m) return false;
S.laneScrollY = _clampLaneScrollPure(y, m.contentH, m.viewH);
return true;
}

// The rail's x-extent, hugging the right edge.
export function laneBarRect(canvasW, canvasH) {
const m = laneScrollMetrics(canvasH);
if (!m) return null;
const thumb = _laneThumbPure(laneScrollY(), m.contentH, m.viewH, m.top, m.viewH);
if (!thumb) return null;
return { x: Number(canvasW) - LANE_BAR_W, w: LANE_BAR_W, ...m, thumb };
}

export function drawLaneScrollbar(canvasW, canvasH) {
const r = laneBarRect(canvasW, canvasH);
if (!r) return; // fits ⇒ no bar (Logic p.297)
const held = !!S.drag && S.drag.type === 'lane-scroll';
ctx.fillStyle = 'rgba(5,5,15,0.55)';
ctx.fillRect(r.x, r.top, r.w, r.viewH);
ctx.fillStyle = held ? 'rgba(120,220,232,0.85)' : 'rgba(220,230,255,0.30)';
ctx.fillRect(r.x + 2, r.thumb.y0, r.w - 4, r.thumb.y1 - r.thumb.y0);
}

// Which part of the bar an (x, y) lands on: 'thumb' | 'track' | null.
export function laneBarHit(x, y, canvasW, canvasH) {
const r = laneBarRect(canvasW, canvasH);
if (!r) return null;
if (x < r.x || x > r.x + r.w) return null;
if (y < r.top || y > r.top + r.viewH) return null;
return (y >= r.thumb.y0 && y <= r.thumb.y1) ? 'thumb' : 'track';
}
4 changes: 4 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@
} from './lanes.js';
import {
LABEL_W, TIMELINE_TOP, clampZoom, setLaneMetrics } from './geometry.js';
import { applyLaneScrollBounds } from './lane-scroll.js';
import {
KEYS_PATTERN, _rollLockNotice,
_rollMidiForNote, _rollPitchCtx, _rollReadOnly, editorKeyNoteNames, isKeysMode, midiToNote, updatePianoRange } from './keys.js';
Expand Down Expand Up @@ -1411,6 +1412,9 @@
// The max scroll depends on the (now-changed) canvas width — re-clamp so a
// widen doesn't leave the timeline scrolled past the new max with blank tail.
_editorApplyScrollBounds();
// Same story vertically: growing the window shrinks (or removes) the lane
// overflow, and a stale offset would leave the grid scrolled past its end.
applyLaneScrollBounds(h);
draw();
}

Expand Down Expand Up @@ -1825,7 +1829,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1832 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down
Loading
Loading