From ee202efe09f7896bb78bbed0d55c5aa2cffc0b2a Mon Sep 17 00:00:00 2001 From: minjaesong Date: Thu, 4 Jun 2026 18:36:41 +0900 Subject: [PATCH] taut: sliders on proj tab --- assets/disk0/tvdos/bin/taut.js | 313 +++++++++++++++++++++++++++------ 1 file changed, 259 insertions(+), 54 deletions(-) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 9b74dc3..3d49897 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -2927,7 +2927,66 @@ const PROJ_META_ROW_GVOL = 7 const PROJ_META_ROW_MVOL = 8 const PROJ_META_VALUE_X = 12 +const SLIDER_TW_SMALL = 25 +const SLIDER_TW_WIDE = 36 + +// GlobalVol / MixingVol get the instrument-tab treatment: an editable HEX capsule +// (click or Enter → openInlineHexEdit), a visual-only decimal, and a 0..255 slider. +const PROJ_VOL_CAP_X = PROJ_META_VALUE_X // hex capsule [▌$FF▐] left-cap col +const PROJ_VOL_CAP_W = 5 +const PROJ_VOL_DEC_X = PROJ_VOL_CAP_X + 6 // visual-only decimal +const PROJ_VOL_SLIDER_SX = PROJ_VOL_DEC_X + 8 // slider left-pad col +const PROJ_VOL_SLIDER_TW = SLIDER_TW_SMALL//SCRW - 2 - (PROJ_VOL_SLIDER_SX + 1) // trough ends ~2 cols from the edge + +// Rebuilt by drawProjectContents; hit-tested by registerProjectMouse. +let projSliders = [] + +// Render one volume row (key + hex capsule + decimal + knob) and register its +// slider entry. `commit(v)` applies the new value; `metaCursor` is the keyboard +// cursor value for the row so a mouse click can sync the selection. +function drawProjVolRow(y, selected, key, val0, commit, metaCursor) { + const sx = PROJ_VOL_SLIDER_SX, tw = PROJ_VOL_SLIDER_TW + const render = (v) => { + con.move(y, 2) + con.color_pair(selected ? colWHITE : colStatus, selected ? colHighlight : 255) + print(key) + drawNumCapsule(y, PROJ_VOL_CAP_X, 3, '$' + v.hex02()) // editable hex + con.move(y, PROJ_VOL_DEC_X); con.color_pair(colVoiceHdr, colBackPtn) + const decW = PROJ_VOL_SLIDER_SX - PROJ_VOL_DEC_X + print(('(' + v + ')' + ' '.repeat(decW)).substring(0, decW)) // visual-only decimal + drawSlider(y, sx, tw, v / 255) + } + render(val0) + const entry = { + y, sx, tw, troughLeftPx: sx * CELL_PW, min: 0, max: 255, + numY: y, numX: PROJ_VOL_CAP_X, numW: PROJ_VOL_CAP_W, + val: val0, render, commit, repaint: redrawPanel, metaCursor + } + entry.editHex = () => { + const nv = openInlineHexEdit(y, PROJ_VOL_CAP_X, 2, entry.val) + if (nv !== null) { entry.val = nv & 0xFF; commit(entry.val) } + redrawPanel() + } + projSliders.push(entry) +} + +function projTroughAt(cy, cx) { + for (let i = 0; i < projSliders.length; i++) { + const s = projSliders[i] + if (cy === s.y && cx >= s.sx && cx <= s.sx + s.tw + 1) return s + } + return null +} +function projCapsuleAt(cy, cx) { + for (let i = 0; i < projSliders.length; i++) { + const s = projSliders[i] + if (cy === s.numY && cx >= s.numX && cx < s.numX + s.numW) return s + } + return null +} + function drawProjectContents(wo) { + projSliders.length = 0 fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255) for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255) @@ -2958,9 +3017,22 @@ function drawProjectContents(wo) { } Object.entries(projMeta).forEach(([key, value], index) => { - con.move(PTNVIEW_OFFSET_Y + index, 2) + const rowY = PTNVIEW_OFFSET_Y + index + if (index === PROJ_META_ROW_GVOL) { + drawProjVolRow(rowY, projectCursor === PROJ_META_GVOL, key, initialGlobalVolume, (v) => { + initialGlobalVolume = v & 0xFF; audio.setSongGlobalVolume(PLAYHEAD, initialGlobalVolume); hasUnsavedChanges = true + }, PROJ_META_GVOL) + return + } + if (index === PROJ_META_ROW_MVOL) { + drawProjVolRow(rowY, projectCursor === PROJ_META_MVOL, key, initialMixingVolume, (v) => { + initialMixingVolume = v & 0xFF; audio.setSongMixingVolume(PLAYHEAD, initialMixingVolume); hasUnsavedChanges = true + }, PROJ_META_MVOL) + return + } + con.move(rowY, 2) con.color_pair(colStatus, 255); print(key) - con.move(PTNVIEW_OFFSET_Y + index, PROJ_META_VALUE_X) + con.move(rowY, PROJ_META_VALUE_X) const isEditable = (index in editableMap) const isSelected = isEditable && projectCursor === editableMap[index] if (isSelected) { @@ -4002,7 +4074,7 @@ function drawLabelRow(y, label, value, labelW) { function drawGroupHeader(y, title) { con.move(y, INST_RIGHT_X) con.color_pair(colInstGroupHdr, colBackPtn) - const txt = title + ' ' + const txt = '\u00FB\u00FB ' + title + ' ' const dashes = Math.max(0, INST_RIGHT_W - txt.length) print(txt + `\u00FB`.repeat(dashes)) } @@ -4018,13 +4090,12 @@ function drawGroupHeader(y, title) { // moves, and the instrument byte(s) are written only on mouse release (see // runSliderDrag). instSliders is rebuilt on every Gen.1/Gen.2 body repaint and // hit-tested by the panel's slider mouse region. -const SLIDER_TW_SMALL = 25 -const SLIDER_TW_WIDE = 36 const SLIDER_LABEL_W = 10 const SLIDER_END_COL = SCRW - 1 // common right edge const SLIDER_SMALL_SX = SLIDER_END_COL - (SLIDER_TW_SMALL + 1) // small left-pad col const SLIDER_WIDE_SX = SLIDER_END_COL - (SLIDER_TW_WIDE + 1) // wide left-pad col const SLIDER_VALUE_W = SLIDER_SMALL_SX - (INST_RIGHT_X + SLIDER_LABEL_W) +const SLIDER_NUM_X = INST_RIGHT_X + SLIDER_LABEL_W // editable raw-number capsule (left-cap col) const sliderGlyphs = [sym.slider1, sym.slider2, sym.slider3, sym.slider4, sym.slider5, sym.slider6, sym.slider7] @@ -4072,6 +4143,7 @@ function instWriteBytes(slot, pairs) { for (let i = 0; i < pairs.length; i++) { sys.poke(memBase - (base + pairs[i][0]), pairs[i][1] & 0xFF) } + hasUnsavedChanges = true } // Drag interaction: live label updates while held, commit on release, ESC cancels. @@ -4095,37 +4167,55 @@ function runSliderDrag(s, downEvent) { }) } if (committed) s.commit(val) - drawInstrumentsContents() + if (s.repaint) s.repaint(); else drawInstrumentsContents() } -// fmt helpers — kept short so the value still fits the SLIDER_VALUE_W field. -function fmtByte255(v) { return '$' + _hex(v, 2) + ' (' + v + ')' } -function fmtSigned(v) { return _signed(v) } -function fmtFilter(v) { return (v === 0xFF) ? 'off' : ('$' + _hex(v, 2) + ' (' + v + ')') } -function fmtFadeout(v) { - if (v <= 0) return '0 none' - if (v >= 1024) return '1024 cut' - return v + ' ~' + (1024 / v).toFixed(1) + 't' +// Annotation helpers — short context shown next to the raw-number capsule +// (the capsule itself already shows the decimal value). Kept terse for the +// narrow value field. +function annHex(v) { return '$' + _hex(v, 2) } +function annFilter(v) { return (v === 0xFF) ? 'off' : '$' + _hex(v, 2) } +function annFadeout(v) { + if (v <= 0) return 'none' + if (v >= 1024) return 'cut' + return '~' + Math.round(1024 / v) + 't' } -// Emit a small-slider row: label, numeric value, then the knob. `encode(val)` -// returns the byte pairs to poke on commit. -function sliderRow(y, e, label, val0, min, max, fmt, encode) { +// Draw an editable raw-number field: a black (col 240) capsule with CP437 +// half-block end caps (0xDD left, 0xDE right). The black-bg + cap scheme marks +// the field as "type a number here". `x` is the left-cap column; `digits` number +// cells follow (left-aligned, space-padded), then the right cap. +function drawNumCapsule(y, x, digits, numStr) { + con.color_pair(colBackPtn, colBLACK); con.move(y, x); con.prnch(0xDD) + con.color_pair(colInstValue, colBLACK); con.move(y, x + 1) + print((numStr + ' '.repeat(digits)).substring(0, digits)) + con.color_pair(colBackPtn, colBLACK); con.move(y, x + 1 + digits); con.prnch(0xDE) +} + +// Emit a small-slider row: label, editable raw-number capsule, annotation, knob. +// `ann(val)` returns the short annotation (or null); `encode(val)` returns the +// byte pairs to poke on commit. +function sliderRow(y, e, label, val0, min, max, ann, encode) { const sx = SLIDER_SMALL_SX, tw = SLIDER_TW_SMALL + const digits = Math.max(String(min).length, String(max).length) + const nx = SLIDER_NUM_X, nw = digits + 2 + const annX = nx + nw, annW = sx - annX // fill up to the slider's left pad const render = (val) => { - if (val < min) val = min - if (val > max) val = max + const knob = (val < min) ? min : (val > max) ? max : val // clamp position only con.move(y, INST_RIGHT_X) con.color_pair(colInstLabel, colBackPtn) print((label + ' '.repeat(SLIDER_LABEL_W)).substring(0, SLIDER_LABEL_W)) - con.color_pair(colInstValue, colBackPtn) - print((fmt(val) + ' '.repeat(SLIDER_VALUE_W)).substring(0, SLIDER_VALUE_W)) - drawSlider(y, sx, tw, (max === min) ? 0 : (val - min) / (max - min)) + drawNumCapsule(y, nx, digits, String(val)) + con.move(y, annX); con.color_pair(colInstValue, colBackPtn) + const a = ann ? (' ' + ann(val)) : '' + print((a + ' '.repeat(annW)).substring(0, annW)) + drawSlider(y, sx, tw, (max === min) ? 0 : (knob - min) / (max - min)) } render(val0) instSliders.push({ y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render, - val: (val0 < min ? min : val0 > max ? max : val0), // current value, for wheel ±1 deltas + numY: y, numX: nx, numW: nw, ndig: digits, // raw-number capsule geometry + val: val0, // base for wheel ±1 / edit prefill (clamped on use) commit: (v) => { instWriteBytes(e.slot, encode(v)); e.decoded = decodeInstFull(readInstRecord(e.slot)) } }) } @@ -4138,28 +4228,34 @@ function sliderRow(y, e, label, val0, min, max, fmt, encode) { function detuneRow(y, e, val0) { const sx = SLIDER_WIDE_SX, tw = SLIDER_TW_WIDE const min = -4096, max = 4096 + const digits = 6 // fits a full signed 16-bit display + const nx = INST_RIGHT_X + 4, nw = digits + 2 const render = (val) => { const knob = (val < min) ? min : (val > max) ? max : val // clamp position only con.move(y, INST_RIGHT_X) con.color_pair(colInstLabel, colBackPtn) - print((' Detune:' + ' '.repeat(SLIDER_LABEL_W)).substring(0, SLIDER_LABEL_W)) + print((' Detune:' + ' '.repeat(20)).substring(0, sx - INST_RIGHT_X)) drawSlider(y, sx, tw, (knob - min) / (max - min)) - con.move(y + 1, INST_RIGHT_X) - con.color_pair(colInstValue, colBackPtn) + // Readout row: editable raw-number capsule + cents. + con.move(y + 1, INST_RIGHT_X); con.color_pair(colInstValue, colBackPtn); print(' ') + drawNumCapsule(y + 1, nx, digits, String(val)) const cents = val * 1200 / 4096 // 1 octave = 4096 TET steps = 1200 cents - const s = ' ' + _signed(val) + ' (' + cents.toFixed(1) + ' cents, 4096-TET)' - print((s + ' '.repeat(INST_RIGHT_W)).substring(0, INST_RIGHT_W)) + con.move(y + 1, nx + nw); con.color_pair(colInstValue, colBackPtn) + const s = ' (' + cents.toFixed(1) + ' cents, 4096-TET)' + print((s + ' '.repeat(INST_RIGHT_W)).substring(0, SCRW - (nx + nw) + 1)) } render(val0) instSliders.push({ y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render, - val: (val0 < min ? min : val0 > max ? max : val0), // snapped into range for drag/wheel + numY: y + 1, numX: nx, numW: nw, ndig: digits, // capsule on the readout row + val: val0, // true value; snapped into range on interact commit: (v) => { instWriteBytes(e.slot, [[184, v & 0xFF], [185, (v >> 8) & 0xFF]]); e.decoded = decodeInstFull(readInstRecord(e.slot)) } }) } -// Hit-test the live instSliders list for a cell (cy, cx). Gen.1/Gen.2 only. -function sliderAt(cy, cx) { +// Hit-test the live instSliders list (Gen.1/Gen.2 only). Separate tests for the +// knob trough (drag / wheel) and the raw-number capsule (click-to-edit / wheel). +function sliderTroughAt(cy, cx) { if (instSubTab !== INST_TAB_GEN1 && instSubTab !== INST_TAB_GEN2) return null for (let i = 0; i < instSliders.length; i++) { const s = instSliders[i] @@ -4167,6 +4263,21 @@ function sliderAt(cy, cx) { } return null } +function sliderCapsuleAt(cy, cx) { + if (instSubTab !== INST_TAB_GEN1 && instSubTab !== INST_TAB_GEN2) return null + for (let i = 0; i < instSliders.length; i++) { + const s = instSliders[i] + if (cy === s.numY && cx >= s.numX && cx < s.numX + s.numW) return s + } + return null +} + +// Open the inline number editor over a slider's capsule; commit clamps to range. +function editSliderNumber(s) { + const nv = openInlineNumEdit(s.numY, s.numX + 1, s.ndig, s.val, s.min, s.max) + if (nv !== null) { s.val = nv; s.commit(nv) } + drawInstrumentsContents() // repaint (restores capsule styling; reflects new value) +} // ── Tab body: General (page 1 + page 2) ─────────────────────────────────── // Page 1 (Gen.1): @@ -4214,16 +4325,16 @@ function drawInstTabGeneral1(e) { y++ drawGroupHeader(y++, 'Volume') - sliderRow(y++, e, ' Inst.GV:', d.igv, 0, 255, fmtByte255, (v) => [[171, v]]) - sliderRow(y++, e, ' DefNote:', d.defNoteVol, 0, 255, fmtByte255, (v) => [[196, v]]) - sliderRow(y++, e, ' Fadeout:', d.fadeout, 0, 1024, fmtFadeout, (v) => [[172, v & 0xFF], [173, (v >> 8) & 0x0F]]) - sliderRow(y++, e, ' Swing:', d.volSwing, 0, 255, fmtByte255, (v) => [[174, v]]) + sliderRow(y++, e, ' Inst.GV:', d.igv, 0, 255, annHex, (v) => [[171, v]]) + sliderRow(y++, e, ' DefNote:', d.defNoteVol, 0, 255, annHex, (v) => [[196, v]]) + sliderRow(y++, e, ' Fadeout:', d.fadeout, 0, 1024, annFadeout, (v) => [[172, v & 0xFF], [173, (v >> 8) & 0x0F]]) + sliderRow(y++, e, ' Swing:', d.volSwing, 0, 255, annHex, (v) => [[174, v]]) y++ drawGroupHeader(y++, 'Panning') - sliderRow(y++, e, ' Default:', d.defPan, 0, 255, fmtByte255, (v) => [[177, v]]) - sliderRow(y++, e, ' Sep:', d.pitchPanSep, -128, 127, fmtSigned, (v) => [[180, v & 0xFF]]) - sliderRow(y++, e, ' Swing:', d.panSwing, 0, 255, fmtByte255, (v) => [[181, v]]) + sliderRow(y++, e, ' Default:', d.defPan, 0, 255, annHex, (v) => [[177, v]]) + sliderRow(y++, e, ' Sep:', d.pitchPanSep, -128, 127, null, (v) => [[180, v & 0xFF]]) + sliderRow(y++, e, ' Swing:', d.panSwing, 0, 255, annHex, (v) => [[181, v]]) drawLabelRow(y++, ' PPanCnt:', '$' + _hex(d.pitchPanCenter, 4) + ' Use: ' + (d.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off')) } @@ -4233,16 +4344,16 @@ function drawInstTabGeneral2(e) { let y = INST_BODY_Y drawGroupHeader(y++, 'Filter') - sliderRow(y++, e, ' Cutoff:', d.defCutoff, 0, 255, fmtFilter, (v) => [[182, v]]) - sliderRow(y++, e, ' Reso:', d.defReso, 0, 255, fmtFilter, (v) => [[183, v]]) + sliderRow(y++, e, ' Cutoff:', d.defCutoff, 0, 255, annFilter, (v) => [[182, v]]) + sliderRow(y++, e, ' Reso:', d.defReso, 0, 255, annFilter, (v) => [[183, v]]) y++ drawGroupHeader(y++, 'Vibrato') drawLabelRow(y++, ' Wave:', VIB_WF_NAMES[d.vibWaveform & 7], SLIDER_LABEL_W) - sliderRow(y++, e, ' Speed:', d.vibSpeed, 0, 255, fmtByte255, (v) => [[175, v]]) - sliderRow(y++, e, ' Depth:', d.vibDepth, 0, 255, fmtByte255, (v) => [[187, v]]) - sliderRow(y++, e, ' Sweep:', d.vibSweep, 0, 255, fmtByte255, (v) => [[176, v]]) - sliderRow(y++, e, ' Rate:', d.vibRate, 0, 255, fmtByte255, (v) => [[188, v]]) + sliderRow(y++, e, ' Speed:', d.vibSpeed, 0, 255, annHex, (v) => [[175, v]]) + sliderRow(y++, e, ' Depth:', d.vibDepth, 0, 255, annHex, (v) => [[187, v]]) + sliderRow(y++, e, ' Sweep:', d.vibSweep, 0, 255, annHex, (v) => [[176, v]]) + sliderRow(y++, e, ' Rate:', d.vibRate, 0, 255, annHex, (v) => [[188, v]]) y++ drawGroupHeader(y++, 'Note actions') @@ -4587,16 +4698,18 @@ function registerInstrumentsMouse() { } }) // Slider body (Gen.1 / Gen.2): one region that hit-tests the live instSliders - // list. Click/drag the matched knob until mouse release; wheel nudges by ±1 - // (wheel up = +1) and commits each notch for fine control. + // list. Click the raw-number capsule to type a value; click/drag the knob to + // slide; wheel over either nudges by ±1 (wheel up = +1) and commits each notch. addPanelMouseRegion(INST_RIGHT_X, INST_BODY_Y, INST_RIGHT_W, INST_BODY_H, { onClick: (cy, cx, btn, ev) => { if (btn !== 1) return - const s = sliderAt(cy, cx) + const c = sliderCapsuleAt(cy, cx) + if (c) { editSliderNumber(c); return } + const s = sliderTroughAt(cy, cx) if (s) runSliderDrag(s, ev) }, onWheel: (cy, cx, dy) => { - const s = sliderAt(cy, cx) + const s = sliderTroughAt(cy, cx) || sliderCapsuleAt(cy, cx) if (!s) return const nv = Math.max(s.min, Math.min(s.max, s.val + (dy < 0 ? 1 : -1))) if (nv === s.val) return @@ -5690,6 +5803,77 @@ function openInlineHexEdit(y, x, digits, initialValue) { return cancelled ? null : parseInt(buf, 16) } +// Inline DECIMAL number editor over a raw-number capsule. `x` is the first digit +// cell (the half-block caps painted by drawNumCapsule stay put either side). +// Type digits (and '-' when min < 0); Backspace edits; Enter / click-away commits +// (clamped to [min,max]); Esc / right-click cancels. Returns the value or null. +function openInlineNumEdit(y, x, digits, initialValue, min, max) { + let buf = String(initialValue) + if (buf.length > digits) buf = buf.substring(0, digits) + const allowNeg = (min < 0) + let cancelled = false + let done = false + + const repaint = () => { + const shown = (buf + ' '.repeat(digits)).substring(0, digits) + con.move(y, x) + con.color_pair(colInstValue, colBLACK) // white digits on the black field + print(shown) + const cpos = Math.min(buf.length, digits - 1) // inverse block cursor + con.move(y, x + cpos) + con.color_pair(colBLACK, colInstValue) + print(shown[cpos]) + con.color_pair(colStatus, 255) + } + + repaint() + let eventJustReceived = true + + // Click-away commits; clicks on the digit cells are swallowed (field stays open). + pushMousePopup([ + { x: 1, y: 1, w: SCRW, h: SCRH, onClick: (cy, cx, btn) => { + if (btn === 1) done = true + else if (btn === 2) { cancelled = true; done = true } + }}, + { x, y, w: digits, h: 1, onClick: () => {} }, + ]) + + while (!done) { + input.withEvent(ev => { + if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) { + eventJustReceived = false; return + } + if (dispatchMouseEvent(ev)) return + if (ev[0] !== 'key_down') return + if (1 !== ev[2]) return + const ks = ev[1] + + if (ks === '') { cancelled = true; done = true; return } + if (ks === '\n') { done = true; return } + if (ks === '\x08') { if (buf.length) buf = buf.substring(0, buf.length - 1); repaint(); return } + if (ks === '-' && allowNeg) { + buf = (buf[0] === '-') ? buf.substring(1) : ('-' + buf) + if (buf.length > digits) buf = buf.substring(0, digits) + repaint(); return + } + if (ks.length === 1 && ks >= '0' && ks <= '9') { + if (buf === '0') buf = '' // a fresh digit replaces a lone 0 + if (buf === '-0') buf = '-' + if (buf.length < digits) buf += ks + repaint(); return + } + }) + } + + popMousePopup() + if (cancelled) return null + let v = parseInt(buf, 10) + if (isNaN(v)) return null + if (v < min) v = min + if (v > max) v = max + return v +} + clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid() drawAll() @@ -6075,15 +6259,27 @@ function registerPatternsMouse() { }) } +// Display-row offset (cy - PTNVIEW_OFFSET_Y) of each editable meta field → its +// keyboard cursor value. The editable rows render at offsets 6/7/8. +const PROJ_META_ROW_TO_CURSOR = { + [PROJ_META_ROW_FLAGS]: PROJ_META_FLAGS, + [PROJ_META_ROW_GVOL] : PROJ_META_GVOL, + [PROJ_META_ROW_MVOL] : PROJ_META_MVOL, +} + function registerProjectMouse() { addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, { - onClick: (cy, cx, btn) => { + onClick: (cy, cx, btn, ev) => { if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return - // Meta rows occupy PTNVIEW_OFFSET_Y .. PTNVIEW_OFFSET_Y + PROJ_META_ROWS_COUNT - 1. - // The song list starts at PROJ_SONGLIST_Y + 1. - const metaRow = cy - PTNVIEW_OFFSET_Y - if (metaRow >= 0 && metaRow < PROJ_META_ROWS_COUNT) { - projectCursor = metaRow + // Volume rows: click the hex capsule to type, the knob to slide. + const cap = projCapsuleAt(cy, cx) + if (cap) { projectCursor = cap.metaCursor; cap.editHex(); return } + const tr = projTroughAt(cy, cx) + if (tr) { projectCursor = tr.metaCursor; runSliderDrag(tr, ev); return } + // Otherwise: select an editable meta field, or a song in the list. + const metaCursor = PROJ_META_ROW_TO_CURSOR[cy - PTNVIEW_OFFSET_Y] + if (metaCursor !== undefined) { + projectCursor = metaCursor clampProjectCursor(); redrawPanel() return } @@ -6097,6 +6293,15 @@ function registerProjectMouse() { } }, onWheel: (cy, cx, dy) => { + // Wheel over a volume knob/capsule nudges ±1 (when stopped); else scroll. + if (playbackMode === PLAYMODE_NONE) { + const s = projTroughAt(cy, cx) || projCapsuleAt(cy, cx) + if (s) { + const nv = Math.max(s.min, Math.min(s.max, s.val + (dy < 0 ? 1 : -1))) + if (nv !== s.val) { s.val = nv; s.render(nv); s.commit(nv) } + return + } + } const rowsVis = projectSongListRowsVisible() const maxScroll = Math.max(0, songsMeta.numSongs - rowsVis) projectSongScroll += dy * 3