{ "schemaVersion": 2, "id": "note-list-16", "name": "16-Step Note List", "description": "Waldorf Q-style 16-step note list — single-tile widget with 2 dedicated encoders, common RANGE shared between instances", "targetDevice": "mk2", "lua": "-- === electraone-widgets bundled preset ===\n-- Auto-generated by scripts/bundle-preset.js — do not edit the\n-- 'lua' field by hand; re-run the bundler after editing the\n-- source widget.lua / theme.lua / primitives.\n\n-- ----- lib/theme.lua -----\n-- electraone-widgets · Theme v0.3\n-- Modern visual language for Electra One MK2 widgets.\n-- Classic pro-audio combination: cool slate neutrals (echoes the MK2's\n-- brushed aluminum anodised case) + warm amber-terracotta signature\n-- (echoes the Electra logo). Like SSL blue on gunmetal, or Teenage\n-- Engineering orange on grey.\n-- Copy this module to the top of your widget.lua to reuse it; the emulator\n-- pre-loads it automatically.\n\nTheme = Theme or {}\n\n-- ========== Version ==========\n-- Widgets declare which Theme version they were written against via\n-- Theme.require(\"0.3\")\n-- at the top of their widget.lua. Bumps to this number signal a breaking\n-- change in the palette, primitive APIs, or both — widgets pinned to an\n-- older version should be audited before they render on the new Theme.\nTheme.VERSION = \"0.3\"\n\nfunction Theme.require(expected)\n if Theme.VERSION ~= expected then\n error(string.format(\n \"This widget was written for Theme v%s but the loaded Theme is v%s — review widget.lua for API changes.\",\n tostring(expected), tostring(Theme.VERSION)))\n end\nend\n\n-- ========== Palette — RGB888 (24-bit) ==========\n-- The firmware (4.1.4+) accepts 0xRRGGBB values directly and converts to\n-- the panel's native RGB565 internally. The release notes for v4.1.4\n-- specifically fixed the RGB888→RGB565 translation for preset bank\n-- colours, confirming RGB888 is the expected input format.\n--\n-- Neutrals: cool slate hierarchy, blue-undertoned. Matches the MK2's\n-- brushed aluminum anodised enclosure so the screen feels continuous\n-- with the device edge.\nTheme.CANVAS = 0x0A0D11 -- page base, deep slate-black\nTheme.SURFACE = 0x14181E -- card / tile, charcoal slate\nTheme.ELEVATED = 0x232830 -- raised / active, brushed steel\nTheme.BORDER = 0x3A4048 -- hairline, steel edge\nTheme.TEXT_DIM = 0x9098A3 -- secondary text, cool silver\nTheme.TEXT = 0xE8EBF0 -- primary text, cool off-white\n\n-- Accents: warm-on-cold signature. Amber-terracotta hero accent reads\n-- like a VU-meter needle or an OP-1 encoder knob against cool slate.\nTheme.ACCENT = 0xE5823E -- primary: active, modulation\nTheme.ACCENT_DIM = 0x8F5129 -- deep copper: inactive variant\nTheme.WARNING = 0xF5C64A -- vintage VU yellow: peak-hold\nTheme.ALERT = 0xEB5757 -- red: over-threshold, critical\nTheme.POSITIVE = 0x7EC699 -- cool sage: in-range, confirmed\nTheme.INFO = 0x5B8FD4 -- steel blue: informational\nTheme.NEUTRAL_ACCENT = 0x6B7384 -- cool grey-blue: disabled\n\n-- ========== Drawing helpers ==========\n-- Thin wrappers around graphics.* so widget code stays declarative.\n-- IMPORTANT: do NOT cache `graphics` to a local upvalue — the firmware\n-- (and our IIFE-free bundle layout) doesn't reliably keep upvalues alive\n-- across paint dispatch. Reference `graphics` directly inside each helper.\n\n-- The firmware's graphics primitives require integer coordinates; passing\n-- a float (e.g. from a division) raises \"number has no integer\n-- representation\". math.floor on every coord keeps things safe.\n\nfunction Theme.rect(x, y, w, h, color)\n graphics.setColor(color)\n graphics.fillRect(math.floor(x), math.floor(y), math.floor(w), math.floor(h))\nend\n\nfunction Theme.outline(x, y, w, h, color)\n graphics.setColor(color)\n graphics.drawRect(math.floor(x), math.floor(y), math.floor(w), math.floor(h))\nend\n\nfunction Theme.roundRect(x, y, w, h, r, color)\n graphics.setColor(color)\n graphics.fillRoundRect(math.floor(x), math.floor(y), math.floor(w), math.floor(h), math.floor(r))\nend\n\nfunction Theme.line(x1, y1, x2, y2, color)\n graphics.setColor(color)\n graphics.drawLine(math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2))\nend\n\nfunction Theme.text(x, y, str, color)\n graphics.setColor(color)\n graphics.print(math.floor(x), math.floor(y), tostring(str), 9999, LEFT)\nend\n\n-- ========== Composite ==========\n-- Base container for most widgets: filled surface + hairline border.\nfunction Theme.card(x, y, w, h)\n Theme.rect(x, y, w, h, Theme.SURFACE)\n Theme.outline(x, y, w, h, Theme.BORDER)\nend\n\n-- Clear the tile with canvas base — call first in paint callbacks.\nfunction Theme.clear(w, h)\n Theme.rect(0, 0, w, h, Theme.CANVAS)\nend\n\n\n-- ----- lib/primitives/knob.lua -----\n-- electraone-widgets · primitive: knob\n-- Rotary knob with ring gauge. 270° sweep from 7 o'clock to 5 o'clock.\n-- Requires Theme (from lib/theme.lua) to be loaded first.\n--\n-- Usage:\n-- Theme.knob(x, y, size, value, {\n-- color = Theme.ACCENT, -- value ring colour (default ACCENT)\n-- label = \"CUTOFF\", -- caption below (optional)\n-- valueText = \"72\", -- override centred readout (optional)\n-- })\n-- `value` is normalised 0..1. `size` is the square bounding box width/height.\n\nfunction Theme.knob(x, y, size, value, opts)\n opts = opts or {}\n local cx = x + size / 2\n local cy = y + size / 2\n local r = size / 2 - 4 -- ring outer radius\n local bodyR = r - 12 -- inner disc radius\n local color = opts.color or Theme.ACCENT\n local label = opts.label\n local v = math.max(0, math.min(1, value or 0))\n\n local startA = math.pi * 0.75 -- 135° (7 o'clock)\n local sweep = math.pi * 1.5 -- 270°\n local segs = 56\n\n -- Helper: draw an arc segment set at radius `rr` between two normalised t\n -- values (0..1 along the sweep). Two passes at slightly different radii\n -- thicken the line without needing a native stroke-weight.\n local function arc(rr, t0, t1, col)\n graphics.setColor(col)\n local lx, ly\n local i0 = math.floor(segs * t0)\n local i1 = math.ceil(segs * t1)\n for i = i0, i1 do\n local a = startA + sweep * (i / segs)\n local px = math.floor(cx + rr * math.cos(a))\n local py = math.floor(cy + rr * math.sin(a))\n if lx then graphics.drawLine(lx, ly, px, py) end\n lx, ly = px, py\n end\n end\n\n -- Track (dim background ring) — 3-pass for ≈3px thickness\n arc(r, 0, 1, Theme.ELEVATED)\n arc(r - 1, 0, 1, Theme.ELEVATED)\n arc(r - 2, 0, 1, Theme.ELEVATED)\n\n -- Value arc (coloured) — 4-pass for ≈4px thickness, more dominant\n if v > 0 then\n arc(r, 0, v, color)\n arc(r - 1, 0, v, color)\n arc(r - 2, 0, v, color)\n arc(r - 3, 0, v, color)\n end\n\n -- Inner body (disc)\n graphics.setColor(Theme.SURFACE)\n graphics.fillCircle(cx, cy, bodyR)\n graphics.setColor(Theme.BORDER)\n graphics.drawCircle(cx, cy, bodyR)\n\n -- Indicator: thick radial line from centre to the ring, in accent colour.\n -- Drawn as 3 parallel lines offset perpendicularly for pseudo-stroke-weight.\n -- Firmware requires integer coordinates for drawLine, so floor the final pixel\n -- positions (the float math for direction is fine — only the args to drawLine\n -- need to be integers).\n local indA = startA + sweep * v\n local nx, ny = -math.sin(indA), math.cos(indA) -- perpendicular unit\n local ox = math.floor(cx + (bodyR - 2) * math.cos(indA))\n local oy = math.floor(cy + (bodyR - 2) * math.sin(indA))\n local ix = math.floor(cx + r * math.cos(indA))\n local iy = math.floor(cy + r * math.sin(indA))\n graphics.setColor(color)\n graphics.drawLine(ox, oy, ix, iy)\n graphics.drawLine(math.floor(ox + nx), math.floor(oy + ny), math.floor(ix + nx), math.floor(iy + ny))\n graphics.drawLine(math.floor(ox - nx), math.floor(oy - ny), math.floor(ix - nx), math.floor(iy - ny))\n\n -- Centred value readout\n local text = opts.valueText or tostring(math.floor(v * 100 + 0.5))\n graphics.setColor(Theme.TEXT)\n graphics.print(cx - #text * 4, cy - 6, text, 9999, LEFT)\n\n -- Label below\n if label then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(x + (size - #label * 6) / 2, y + size + 4, label, 9999, LEFT)\n end\nend\n\n\n-- ----- lib/primitives/bar.lua -----\n-- electraone-widgets · primitive: bar\n-- Horizontal value bar with optional label. Minimal — for linear values\n-- where a rotary knob would be too heavy.\n--\n-- Usage:\n-- Theme.bar(x, y, w, h, value, {\n-- color = Theme.ACCENT, -- fill colour (default ACCENT)\n-- label = \"LEVEL\", -- caption above (optional)\n-- valueText = \"-6 dB\", -- right-aligned readout (optional)\n-- })\n-- `value` is 0..1.\n\nfunction Theme.bar(x, y, w, h, value, opts)\n opts = opts or {}\n local color = opts.color or Theme.ACCENT\n local v = math.max(0, math.min(1, value or 0))\n local label = opts.label\n local vtext = opts.valueText\n\n local barY = label and (y + 14) or y\n local barH = label and (h - 14) or h\n\n -- Track\n Theme.rect(x, barY, w, barH, Theme.ELEVATED)\n Theme.outline(x, barY, w, barH, Theme.BORDER)\n\n -- Fill\n local fillW = math.floor(w * v)\n if fillW > 0 then\n Theme.rect(x, barY, fillW, barH, color)\n end\n\n -- Label on top-left\n if label then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(x, y, label, 9999, LEFT)\n end\n\n -- Value on top-right\n if vtext then\n graphics.setColor(Theme.TEXT)\n graphics.print(x + w - #vtext * 6, y, vtext, 9999, LEFT)\n end\nend\n\n\n-- ----- lib/primitives/led.lua -----\n-- electraone-widgets · primitive: led\n-- Status indicator dot with optional glow halo when on.\n--\n-- Usage:\n-- Theme.led(cx, cy, on, {\n-- color = Theme.POSITIVE, -- on-state colour (default POSITIVE)\n-- size = 6, -- radius (default 6)\n-- label = \"SYNC\", -- caption to the right (optional)\n-- })\n-- (cx, cy) is the LED centre.\n\nfunction Theme.led(cx, cy, on, opts)\n opts = opts or {}\n local color = opts.color or Theme.POSITIVE\n local r = opts.size or 6\n local label = opts.label\n\n if on then\n -- Glow halo — slightly larger, same colour but we rely on the MK2's\n -- lack of anti-aliasing to give it that blown-pixel feel.\n graphics.setColor(color)\n graphics.fillCircle(cx, cy, r + 2)\n graphics.setColor(Theme.TEXT)\n graphics.fillCircle(cx, cy, r - 2)\n else\n graphics.setColor(Theme.ELEVATED)\n graphics.fillCircle(cx, cy, r)\n graphics.setColor(Theme.BORDER)\n graphics.drawCircle(cx, cy, r)\n end\n\n if label then\n graphics.setColor(on and Theme.TEXT or Theme.TEXT_DIM)\n graphics.print(cx + r + 6, cy - 5, label, 9999, LEFT)\n end\nend\n\n\n-- ----- lib/primitives/meter.lua -----\n-- electraone-widgets · primitive: meter\n-- VU-style meter with graduated tick marks. Horizontal or vertical.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.meter(x, y, w, h, value, {\n-- orientation = \"h\" | \"v\", -- default \"h\"\n-- color = Theme.POSITIVE, -- fill colour in safe zone (default POSITIVE)\n-- warn = 0.70, -- value threshold where color becomes WARNING\n-- alert = 0.90, -- value threshold where color becomes ALERT\n-- peak = 0.82, -- optional peak-hold marker (0..1)\n-- label = \"IN\", -- caption\n-- valueText = \"-6 dB\", -- right / top readout\n-- ticks = 6, -- number of graduations (default 6, 0 to disable)\n-- inverted = false, -- fill from the opposite end (top for \"v\",\n-- -- right for \"h\"). Use for compressor GR.\n-- })\n\nfunction Theme.meter(x, y, w, h, value, opts)\n opts = opts or {}\n local v = math.max(0, math.min(1, value or 0))\n local orient = opts.orientation or \"h\"\n local warn = opts.warn or 0.70\n local alert = opts.alert or 0.90\n local peak = opts.peak\n local ticks = opts.ticks or 6\n\n -- Colour selection by zone\n local color = opts.color or Theme.POSITIVE\n if v >= alert then color = Theme.ALERT\n elseif v >= warn then color = Theme.WARNING end\n\n -- Header row: label + value (reserve 14px top)\n local headerH = (opts.label or opts.valueText) and 14 or 0\n local bx, by, bw, bh = x, y + headerH, w, h - headerH\n\n if opts.label then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(x, y, opts.label, 9999, LEFT)\n end\n if opts.valueText then\n graphics.setColor(Theme.TEXT)\n graphics.print(x + w - #opts.valueText * 6, y, opts.valueText, 9999, LEFT)\n end\n\n -- Track\n Theme.rect(bx, by, bw, bh, Theme.ELEVATED)\n Theme.outline(bx, by, bw, bh, Theme.BORDER)\n\n -- Fill + ticks depending on orientation\n local inverted = opts.inverted\n if orient == \"v\" then\n local fillH = math.floor(bh * v)\n if fillH > 0 then\n if inverted then\n Theme.rect(bx, by, bw, fillH, color)\n else\n Theme.rect(bx, by + bh - fillH, bw, fillH, color)\n end\n end\n -- Ticks on the right edge\n if ticks > 0 then\n graphics.setColor(Theme.TEXT_DIM)\n for i = 1, ticks - 1 do\n local ty = by + (bh * i) // ticks\n local major = (i % 5 == 0)\n local len = major and 10 or 7\n graphics.drawLine(bx + bw - len, ty, bx + bw, ty)\n if major then graphics.drawLine(bx + bw - len, ty + 1, bx + bw, ty + 1) end\n end\n end\n -- Peak marker\n if peak and peak > 0 then\n local py = inverted and (by + math.floor(bh * peak)) or (by + bh - math.floor(bh * peak))\n graphics.setColor(Theme.TEXT_DIM)\n graphics.drawLine(bx, py, bx + bw, py)\n end\n else\n local fillW = math.floor(bw * v)\n if fillW > 0 then\n if inverted then\n Theme.rect(bx + bw - fillW, by, fillW, bh, color)\n else\n Theme.rect(bx, by, fillW, bh, color)\n end\n end\n -- Ticks on the bottom edge\n if ticks > 0 then\n graphics.setColor(Theme.TEXT_DIM)\n for i = 1, ticks - 1 do\n local tx = bx + (bw * i) // ticks\n local major = (i % 5 == 0)\n local len = major and 10 or 7\n graphics.drawLine(tx, by + bh - len, tx, by + bh)\n if major then graphics.drawLine(tx + 1, by + bh - len, tx + 1, by + bh) end\n end\n end\n -- Peak marker\n if peak and peak > 0 then\n local px = inverted and (bx + bw - math.floor(bw * peak)) or (bx + math.floor(bw * peak))\n graphics.setColor(Theme.TEXT_DIM)\n graphics.drawLine(px, by, px, by + bh)\n end\n end\nend\n\n\n-- ----- lib/primitives/slider.lua -----\n-- electraone-widgets · primitive: slider\n-- Linear fader: thin track with a rectangular handle marking the current\n-- position. Style = synth-panel linear pot, optional millimetre-ruler\n-- style ticks on both sides of the track.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.slider(x, y, w, h, value, {\n-- orientation = \"h\" | \"v\", -- default \"h\"\n-- color = Theme.ACCENT, -- track fill colour (default ACCENT)\n-- label = \"ATTACK\", -- caption\n-- valueText = \"12 ms\", -- readout\n-- bipolar = false, -- if true, fill from centre outward\n-- ticks = 20, -- graduation count, both sides, every 5th longer\n-- -- (omit or 0 to disable)\n-- })\n\nfunction Theme.slider(x, y, w, h, value, opts)\n opts = opts or {}\n local v = math.max(0, math.min(1, value or 0))\n local orient = opts.orientation or \"h\"\n local color = opts.color or Theme.ACCENT\n local label = opts.label\n local vtext = opts.valueText\n local bipolar = opts.bipolar\n local ticks = opts.ticks\n\n local headerH = (label or vtext) and 14 or 0\n\n if label then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(x, y, label, 9999, LEFT)\n end\n if vtext then\n graphics.setColor(Theme.TEXT)\n graphics.print(x + w - #vtext * 6, y, vtext, 9999, LEFT)\n end\n\n local bx, by, bw, bh = x, y + headerH, w, h - headerH\n\n -- Helper: draw a 2-pixel-wide line (pseudo-stroke-weight)\n local function fat(x0, y0, x1, y1, col)\n graphics.setColor(col)\n graphics.drawLine(x0, y0, x1, y1)\n if x0 == x1 then\n graphics.drawLine(x0 + 1, y0, x1 + 1, y1)\n else\n graphics.drawLine(x0, y0 + 1, x1, y1 + 1)\n end\n end\n\n if orient == \"v\" then\n local trackX = bx + bw // 2\n\n -- Track (3px thick)\n graphics.setColor(Theme.ELEVATED)\n for dx = -1, 1 do graphics.drawLine(trackX + dx, by, trackX + dx, by + bh) end\n\n -- Colour fill\n graphics.setColor(color)\n local y0, y1\n if bipolar then\n local mid = by + bh / 2\n local posY = by + bh - bh * v\n y0, y1 = math.min(mid, posY), math.max(mid, posY)\n graphics.setColor(Theme.BORDER)\n graphics.drawLine(trackX - 6, mid, trackX + 6, mid)\n graphics.setColor(color)\n else\n y0, y1 = by + bh - bh * v, by + bh\n end\n for dx = -1, 1 do graphics.drawLine(trackX + dx, y0, trackX + dx, y1) end\n\n -- Ruler ticks — both sides, close to track, every 5th longer + thicker\n if ticks and ticks > 0 then\n graphics.setColor(Theme.TEXT_DIM)\n for i = 0, ticks do\n local ty = by + bh - (bh * i / ticks)\n local major = (i % 5 == 0)\n local len = major and 5 or 2\n graphics.drawLine(trackX - 4 - len, ty, trackX - 4, ty)\n graphics.drawLine(trackX + 4, ty, trackX + 4 + len, ty)\n if major then\n graphics.drawLine(trackX - 4 - len, ty + 1, trackX - 4, ty + 1)\n graphics.drawLine(trackX + 4, ty + 1, trackX + 4 + len, ty + 1)\n end\n end\n end\n\n -- Handle: narrow rectangle, tall, dark body with horizontal white mark\n local hw = 18\n local hh = 12\n local hx = trackX - hw / 2\n local hy = by + bh - bh * v - hh / 2\n Theme.rect(hx, hy, hw, hh, Theme.CANVAS)\n Theme.outline(hx, hy, hw, hh, Theme.TEXT_DIM)\n -- white horizontal bar through the middle (2px)\n fat(hx + 2, hy + hh // 2, hx + hw - 2, hy + hh // 2, Theme.TEXT)\n else\n local trackY = by + bh // 2\n\n graphics.setColor(Theme.ELEVATED)\n for dy = -1, 1 do graphics.drawLine(bx, trackY + dy, bx + bw, trackY + dy) end\n\n graphics.setColor(color)\n local x0, x1\n if bipolar then\n local mid = bx + bw / 2\n local posX = bx + bw * v\n x0, x1 = math.min(mid, posX), math.max(mid, posX)\n graphics.setColor(Theme.BORDER)\n graphics.drawLine(mid, trackY - 6, mid, trackY + 6)\n graphics.setColor(color)\n else\n x0, x1 = bx, bx + bw * v\n end\n for dy = -1, 1 do graphics.drawLine(x0, trackY + dy, x1, trackY + dy) end\n\n -- Ruler ticks — above and below track\n if ticks and ticks > 0 then\n graphics.setColor(Theme.TEXT_DIM)\n for i = 0, ticks do\n local tx = bx + (bw * i / ticks)\n local major = (i % 5 == 0)\n local len = major and 5 or 2\n graphics.drawLine(tx, trackY - 4 - len, tx, trackY - 4)\n graphics.drawLine(tx, trackY + 4, tx, trackY + 4 + len)\n if major then\n graphics.drawLine(tx + 1, trackY - 4 - len, tx + 1, trackY - 4)\n graphics.drawLine(tx + 1, trackY + 4, tx + 1, trackY + 4 + len)\n end\n end\n end\n\n -- Handle: narrow, tall\n local hw = 12\n local hh = 18\n local hx = bx + bw * v - hw / 2\n local hy = by + bh // 2 - hh / 2\n Theme.rect(hx, hy, hw, hh, Theme.CANVAS)\n Theme.outline(hx, hy, hw, hh, Theme.TEXT_DIM)\n fat(hx + hw // 2, hy + 2, hx + hw // 2, hy + hh - 2, Theme.TEXT)\n end\nend\n\n\n-- ----- lib/primitives/readout.lua -----\n-- electraone-widgets · primitive: readout\n-- Typography-driven value display. Dominant value in primary text, label\n-- above in dim grey, unit suffix in dim grey on the right of the value.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.readout(x, y, {\n-- label = \"CUTOFF\", -- tiny caption above\n-- value = \"5,280\", -- large primary value (any string)\n-- unit = \"Hz\", -- optional dim suffix\n-- color = Theme.ACCENT, -- value colour (default TEXT)\n-- align = \"l\" | \"r\", -- text anchor at (x,y) — default \"l\"\n-- })\n\nfunction Theme.readout(x, y, opts)\n opts = opts or {}\n local label = opts.label\n local value = tostring(opts.value or \"\")\n local unit = opts.unit\n local color = opts.color or Theme.TEXT\n local align = opts.align or \"l\"\n\n if label then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(x, y, label, 9999, LEFT)\n end\n\n -- We don't have access to font metrics on the MK2 so character width is\n -- approximated at 8px for the big readout (assumes default system font).\n local valueW = #value * 8\n local unitW = unit and (#unit * 6 + 4) or 0\n local totalW = valueW + unitW\n local startX = (align == \"r\") and (x - totalW) or x\n local valueY = label and (y + 12) or y\n\n graphics.setColor(color)\n graphics.print(startX, valueY, value, 9999, LEFT)\n\n if unit then\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(startX + valueW + 4, valueY + 4, unit, 9999, LEFT)\n end\nend\n\n\n-- ----- lib/primitives/graph.lua -----\n-- electraone-widgets · primitive: graph\n-- Polyline plot of normalised points inside a rectangle. Useful for\n-- envelope shapes, EQ curves, LFO traces, waveform thumbnails.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.graph(x, y, w, h, points, {\n-- color = Theme.ACCENT, -- trace colour (default ACCENT)\n-- fill = false, -- if true, solid-fill area under the curve\n-- grid = 0, -- N horizontal divisions (default 0 = none)\n-- baseline = 0, -- y-value of the baseline for fill (0..1)\n-- markers = {0.25, 0.5}, -- section edges: vertical 2px lines from the\n-- -- baseline up to the curve at each x, NEVER\n-- -- above the curve. Drawn in the trace colour.\n-- })\n-- `points` is an array of {x, y} pairs with each coord normalised 0..1.\n-- y=0 is the bottom of the rect, y=1 the top.\n\nfunction Theme.graph(x, y, w, h, points, opts)\n opts = opts or {}\n local color = opts.color or Theme.ACCENT\n local fill = opts.fill\n local grid = opts.grid or 0\n local baseline = opts.baseline or 0\n\n -- Plot area card\n Theme.rect(x, y, w, h, Theme.SURFACE)\n Theme.outline(x, y, w, h, Theme.BORDER)\n\n -- Grid divisions\n if grid > 0 then\n graphics.setColor(Theme.ELEVATED)\n for i = 1, grid - 1 do\n local gy = y + (h * i) // grid\n graphics.drawLine(x + 1, gy, x + w - 1, gy)\n end\n end\n\n if not points or #points < 2 then return end\n\n -- Map normalised point → screen pixel (flip y: 0=bottom, 1=top)\n local function toScreen(p)\n return x + p[1] * w, y + h - p[2] * h\n end\n\n -- Pre-compute screen-space points once (used by fill and trace).\n local sp = {}\n for i, p in ipairs(points) do sp[i] = { toScreen(p) } end\n\n -- Solid uniform fill under the curve — one fillRect per integer column.\n -- Integer coords avoid the sub-pixel \"dégradé\" artifact of drawLine stacking.\n if fill then\n graphics.setColor(color)\n local baseY = math.floor(y + h - baseline * h)\n local minX = math.floor(sp[1][1])\n local maxX = math.floor(sp[#sp][1])\n local seg = 1\n for px = minX, maxX do\n while seg < #sp - 1 and px > sp[seg + 1][1] do seg = seg + 1 end\n local x0, y0 = sp[seg][1], sp[seg][2]\n local x1, y1 = sp[seg + 1][1], sp[seg + 1][2]\n local dx = x1 - x0\n local t = (dx == 0) and 0 or (px - x0) / dx\n local curveY = math.floor(y0 + (y1 - y0) * t)\n local yTop = math.min(curveY, baseY)\n local yBot = math.max(curveY, baseY)\n graphics.fillRect(px, yTop, 1, yBot - yTop + 1)\n end\n end\n\n -- Contour trace + section markers share the same colour. When the area\n -- is filled, render in ACCENT_DIM (darker copper) for an engraved-edge\n -- look on top of the accent fill — stays in the warm family instead of\n -- going cool off-white which reads dingy on orange. Otherwise use the\n -- caller's trace colour.\n local traceColor = fill and Theme.ACCENT_DIM or color\n graphics.setColor(traceColor)\n\n -- Optional section markers — vertical 2px lines from baseline up to the\n -- curve at each requested x (never above the curve, never above the fill).\n if opts.markers then\n local baseY = math.floor(y + h - baseline * h)\n for _, mx in ipairs(opts.markers) do\n local px = x + mx * w\n for i = 1, #sp - 1 do\n if px >= sp[i][1] and px <= sp[i + 1][1] then\n local dx = sp[i + 1][1] - sp[i][1]\n local t = (dx == 0) and 0 or (px - sp[i][1]) / dx\n local curveY = math.floor(sp[i][2] + (sp[i + 1][2] - sp[i][2]) * t)\n local px_i = math.floor(px)\n local yTop = math.min(curveY, baseY)\n local yBot = math.max(curveY, baseY)\n graphics.drawLine(px_i, yTop, px_i, yBot)\n graphics.drawLine(px_i + 1, yTop, px_i + 1, yBot)\n break\n end\n end\n end\n end\n\n -- Contour trace (2px thick) — drawn last so it sits on top of markers.\n for i = 1, #sp - 1 do\n local x0, y0 = sp[i][1], sp[i][2]\n local x1, y1 = sp[i + 1][1], sp[i + 1][2]\n graphics.drawLine(x0, y0, x1, y1)\n graphics.drawLine(x0, y0 + 1, x1, y1 + 1)\n end\nend\n\n\n-- ----- lib/primitives/grid.lua -----\n-- electraone-widgets · primitive: grid\n-- Step-sequencer / drum-matrix tile grid. Each cell shows on/off state,\n-- optional velocity intensity, and the currently-playing step gets a\n-- prominent highlight bar.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.grid(x, y, w, h, cols, rows, cells, {\n-- color = Theme.ACCENT, -- on-cell colour (default ACCENT)\n-- active = 3, -- 1-based index of current step (optional)\n-- gap = 3, -- pixels between cells (default 3)\n-- disabledRows = { -- rows to render in a muted neutral so the\n-- [2] = true, -- pattern stays visible but clearly inactive\n-- },\n-- })\n-- `cells[idx]` where idx = (row-1) * cols + col:\n-- nil / false → off\n-- true → full velocity\n-- number 0..1 → velocity fraction\n\nfunction Theme.grid(x, y, w, h, cols, rows, cells, opts)\n opts = opts or {}\n local color = opts.color or Theme.ACCENT\n local colorDim = opts.colorDim or Theme.ACCENT_DIM\n local active = opts.active\n local gap = opts.gap or 3\n local disabledRows = opts.disabledRows or {}\n\n local cellW = (w - gap * (cols - 1)) / cols\n local cellH = (h - gap * (rows - 1)) / rows\n\n for r = 1, rows do\n local rowDisabled = disabledRows[r]\n -- Muted palette for disabled rows — keeps the pattern visible but reads\n -- as \"not playing\". Cool neutral contrasts the warm accent of live rows.\n local rowColor = rowDisabled and Theme.NEUTRAL_ACCENT or color\n local rowColorDim = rowDisabled and Theme.BORDER or colorDim\n\n for col = 1, cols do\n local cx = x + (col - 1) * (cellW + gap)\n local cy = y + (r - 1) * (cellH + gap)\n local idx = (r - 1) * cols + col\n local state = cells and cells[idx]\n -- Active-step highlight should only \"light up\" cells of live rows.\n local isActiveCell = (active and (active % cols == 0 and cols or active % cols) == col)\n local isActive = active == idx\n local intensity = 0\n if state == true then intensity = 1\n elseif type(state) == \"number\" then intensity = math.max(0, math.min(1, state)) end\n\n -- Base cell — SURFACE by default; ELEVATED on the active-column lane,\n -- but only if the row is live. Disabled rows stay fully static so no\n -- \"traveling\" highlight leaks through.\n local bg = (isActiveCell and not rowDisabled) and Theme.ELEVATED or Theme.SURFACE\n Theme.rect(cx, cy, cellW, cellH, bg)\n\n -- On-cell fill: colour strength follows velocity\n if intensity > 0 then\n local fc = intensity >= 0.66 and rowColor or rowColorDim\n local pad = 2\n local barH = math.max(3, math.floor((cellH - pad * 2) * intensity))\n Theme.rect(cx + pad, cy + cellH - pad - barH, cellW - pad * 2, barH, fc)\n end\n\n Theme.outline(cx, cy, cellW, cellH, Theme.BORDER)\n\n -- Active-step marker: bright top edge on every cell in the active\n -- column, but only if the row is live (else the highlight would\n -- suggest the row still plays).\n if isActiveCell and not rowDisabled then\n Theme.rect(cx, cy, cellW, 3, Theme.TEXT)\n end\n end\n end\nend\n\n\n-- ----- lib/primitives/button.lua -----\n-- electraone-widgets · primitive: button\n-- Console-tile style toggle / momentary button — SSL-inspired. The body\n-- is a fixed dark slab (ELEVATED) wrapped in a machined-metal double-\n-- stroke frame, carrying an LED window across the top that lights up in\n-- a semantic colour when state is true. For momentary presses, pass\n-- `flashing = true` and the whole body flips to WARNING amber with the\n-- label inverted to CANVAS black — a hardware \"pressed hard\" feel.\n-- Requires Theme.\n--\n-- Usage:\n-- Theme.button(x, y, w, h, {\n-- label = \"RUN\",\n-- state = true, -- toggle state (ignored for momentary)\n-- color = Theme.POSITIVE, -- LED window colour when state=true\n-- flashing = false, -- momentary: force pressed look\n-- })\n\nfunction Theme.button(x, y, w, h, opts)\n opts = opts or {}\n local label = opts.label or \"\"\n local state = opts.state\n local color = opts.color or Theme.ACCENT\n local flashing = opts.flashing\n\n -- Momentary pressed look: full WARNING body + inverted label\n if flashing then\n Theme.outline(x, y, w, h, Theme.BORDER)\n Theme.outline(x + 2, y + 2, w - 4, h - 4, Theme.BORDER)\n Theme.rect (x + 3, y + 3, w - 6, h - 6, Theme.WARNING)\n local tw = #label * 6\n -- Firmware requires integer coords for graphics.print\n local lx = math.floor(x + (w - tw) / 2)\n local ly = math.floor(y + h / 2 - 5)\n graphics.setColor(Theme.CANVAS)\n graphics.print(lx, ly, label, 9999, LEFT)\n graphics.print(lx + 1, ly, label, 9999, LEFT)\n return\n end\n\n -- Machined-metal double-stroke frame (outer + inner, 1px gap between)\n Theme.outline(x, y, w, h, Theme.BORDER)\n Theme.outline(x + 2, y + 2, w - 4, h - 4, Theme.BORDER)\n\n -- Inner body — brushed-steel ELEVATED fill\n local bx, by, bw, bh = x + 3, y + 3, w - 6, h - 6\n Theme.rect(bx, by, bw, bh, Theme.ELEVATED)\n\n -- Top LED window — 10px tall strip (scaled for compact button sizes)\n local winH = 10\n local winColor = state and color or Theme.CANVAS\n Theme.rect(bx, by, bw, winH, winColor)\n -- 1px BORDER separator between window and label area\n graphics.setColor(Theme.BORDER)\n graphics.drawLine(math.floor(bx), math.floor(by + winH),\n math.floor(bx + bw - 1), math.floor(by + winH))\n\n -- Label — centred in the area below the window, double-drawn for weight\n local labelAreaY = by + winH + 1\n local labelAreaH = bh - winH - 1\n local tw = #label * 6\n -- Firmware requires integer coords for graphics.print\n local lx = math.floor(x + (w - tw) / 2)\n local ly = math.floor(labelAreaY + (labelAreaH - 10) / 2)\n graphics.setColor(state and Theme.TEXT or Theme.TEXT_DIM)\n graphics.print(lx, ly, label, 9999, LEFT)\n graphics.print(lx + 1, ly, label, 9999, LEFT)\nend\n\n\n-- ----- widgets/note-list-16/widget.lua -----\n-- ===== note-list-16 · widget revision 149 =====\nWIDGET_REV = \"149\"\n\n-- Widget: 16-Step Note List — Waldorf Q-style reusable step list.\n-- Generalised to N lanes (2 ships by default). Each lane = one custom\n-- tile, one pot, 16 steps.\n--\n-- Pot rotation: in \"navigate\" mode steps through 1..16; in \"edit\" mode\n-- changes the selected step's value. Pot click (TOUCH→RELEASE without\n-- rotation) toggles between navigate and edit modes. Double-click on the\n-- pot mutes/unmutes the selected step.\n-- Tap a cell to select it directly. Drag vertically to edit the value.\n--\n-- External sync: `commonRange` and every step value listen to a virtual\n-- parameter (see PARAM_COMMON_RANGE and lanes[i].paramBase below). Move\n-- the matching fader from another page or send a CC mapped to that param\n-- and the widget repaints. See parameterMap.onChange at the bottom.\n\nTheme.require(\"0.3\")\n\n-- ===== Per-tile state (keyed by ctrl:getId() = 1 or 2) =====\n\n-- Empirically (firmware 4.1.4): each custom tile receives events from\n-- ONE physical encoder only — the col-0 pot of the OPPOSITE row. Tile\n-- in slot 1 (top) → pot in row 2 col 0 (ev.id=6). Tile in slot 7\n-- (bottom) → pot in row 1 col 0 (ev.id=0). The `inputs` array in the\n-- JSON is ignored for `type:\"custom\"` controls (works for ADSR etc.,\n-- per the doc — feature request for custom on forum #4172). With one\n-- pot per tile, we use a mode-switch state machine (click toggles\n-- NAV ↔ EDIT, double-click mutes the selected step).\n--\n-- Per-lane fields:\n-- name display name (header strip)\n-- color accent colour (Theme.*)\n-- kind \"note\" | \"pct\" | \"num\" — value formatter\n-- cells 16-entry initial value table (0..127)\n-- paramBase lowest virtual-param number; step N writes to\n-- virtual param (paramBase + N) on device 1.\n-- Set up the matching entries in the JSON\n-- so external faders / CC can read & write the steps.\n-- potEvId ev.id that firmware delivers to this tile's pot.\n-- Find by enabling the device logger and turning\n-- a pot — the \"pot tile=N ev.id=X\" print line tells\n-- you. For our default 2-tile layout: lane 1 = 6,\n-- lane 2 = 0. (Legacy alias: encEdit.)\n-- targetLane which lane this pot operates on (1-based). Default:\n-- 2↔1 cross-dispatch for 2 lanes (matches the\n-- \"top pot drives top tile\" feel), else self.\n-- gaugeOrientation \"h\" (default, thin bar at bottom of each cell)\n-- or \"v\" (thin bar on the right edge of each cell).\n-- initialMuted optional table { [3]=true, [7]=true, ... } listing\n-- step indices (1..16) that should start muted on\n-- preset load. Runtime double-click toggles each\n-- step's mute state; the underlying value is always\n-- preserved.\nlanes = {\n [1] = {\n name = \"NOTES\", color = Theme.ACCENT, kind = \"note\",\n cells = { 57, 60, 64, 67, 69, 67, 64, 60,\n 57, 60, 64, 69, 72, 69, 64, 60 },\n paramBase = 0, potEvId = 6, targetLane = 1,\n gaugeOrientation = \"h\",\n },\n [2] = {\n name = \"VELOCITY\", color = Theme.POSITIVE, kind = \"num\",\n cells = { 110, 80, 95, 70, 120, 70, 90, 65,\n 100, 75, 90, 80, 127, 75, 85, 60 },\n paramBase = 16, potEvId = 0, targetLane = 2,\n gaugeOrientation = \"h\",\n },\n}\n\n-- Backward-compat: older presets used `encEdit`. Promote it to `potEvId`\n-- so both names work.\nfor _, lane in ipairs(lanes) do\n lane.potEvId = lane.potEvId or lane.encEdit\nend\n\n-- Per-lane state tables — initialised for every lane declared above so\n-- adding lane[3] / [4] / ... requires no extra wiring here.\nselectedStep = {}\ndragging = {}\nmuted = {} -- per-step mute: muted[laneIdx][step] = true\nlaneMode = {} -- \"navigate\" | \"edit\"\npotState = {} -- per-lane pot click state machine\n\nfor i = 1, #lanes do\n selectedStep[i] = 1\n dragging[i] = nil\n -- Initial mute table: copy from lane.initialMuted if provided, else\n -- start with every step active (empty table). Each entry uses the\n -- step index as key (1..16) → true to mute.\n muted[i] = {}\n if lanes[i].initialMuted then\n for step, m in pairs(lanes[i].initialMuted) do muted[i][step] = m end\n end\n laneMode[i] = \"navigate\"\n potState[i] = {}\nend\n\nDOUBLE_CLICK_MS = 400\n\n-- commonRange = the playback length (steps 1..commonRange are \"in range\",\n-- the rest are visually dimmed and skipped at playback). Listens to the\n-- virtual parameter PARAM_COMMON_RANGE (default 33) so an external fader\n-- can drive it from any page.\ncommonRange = 16\nPARAM_COMMON_RANGE = 33\n\nCELLS = 16\nCELL_GAP = 2\nGROUP_GAP_EXTRA = 12\n\n-- ===== Helpers =====\n\nNOTE_NAMES = { \"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\" }\n\nfunction noteName(n)\n if n < 0 or n > 127 then return \"--\" end\n local octave = math.floor(n / 12) - 1\n return NOTE_NAMES[(n % 12) + 1] .. tostring(octave)\nend\n\n-- Label lookup for a step value. If the lane defines an `overlay` table\n-- ({ {value=N, label=\"X\"}, ... }), we look up the label there (matches the\n-- \"all 16 parameters share the same overlay list\" line of the forum spec).\n-- Otherwise we fall back to the built-in formatters per lane.kind. This\n-- lets a user define arbitrary step lists (scale degrees, drum kit pieces,\n-- chord names, syllables…) without changing the widget logic — just\n-- replace lanes[N].overlay in their preset.\nfunction overlayLabel(overlay, v)\n for _, item in ipairs(overlay) do\n if item.value == v then return item.label end\n end\n -- Range match (item with .from and .to defines an inclusive range)\n for _, item in ipairs(overlay) do\n if item.from and item.to and v >= item.from and v <= item.to then\n return item.label\n end\n end\n return tostring(v)\nend\n\nfunction formatCell(lane, v)\n if lane.overlay then return overlayLabel(lane.overlay, v) end\n if lane.kind == \"note\" then return noteName(v) end\n if lane.kind == \"pct\" then return string.format(\"%d%%\", math.floor(v * 100 / 127 + 0.5)) end\n return tostring(v)\nend\n\nfunction stepGeometry(x, w)\n local extra = GROUP_GAP_EXTRA * 3\n local innerW = w - extra\n return math.floor((innerW - CELL_GAP * (CELLS - 1)) / CELLS)\nend\n\nfunction stepX(x, w, i)\n local cellW = stepGeometry(x, w)\n local g = math.floor((i - 1) / 4)\n return math.floor(x + (i - 1) * (cellW + CELL_GAP) + g * GROUP_GAP_EXTRA)\nend\n\n-- ===== Paint =====\n\nfunction paintLane(ctrl)\n local id = ctrl:getId()\n local lane = lanes[id]\n if not lane then return end\n\n local b = ctrl:getBounds()\n local W, H = b[WIDTH], b[HEIGHT]\n\n -- Card surface — matches the design-system look (no rounded corners).\n Theme.rect(0, 0, W, H, Theme.SURFACE)\n\n local step = selectedStep[id]\n local range = commonRange\n local isEdit = (laneMode[id] == \"edit\")\n\n -- ===== Header strip =====\n local headerH = 22\n -- Subtle accent strip on the left edge marks the lane colour.\n Theme.rect(0, 0, 4, headerH, lane.color)\n\n -- Lane name (uppercase, dim) — matches modern-adsr/comp-meter style\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(14, 6, lane.name, 9999, LEFT)\n\n -- Mode pill — small framed indicator (Theme.outline + filled bg when EDIT)\n local pillX = 14 + #lane.name * 6 + 14\n local pillY = 4\n local pillW, pillH = 38, 14\n local modeStr = isEdit and \"EDIT\" or \"NAV\"\n if isEdit then\n Theme.rect(pillX, pillY, pillW, pillH, lane.color)\n graphics.setColor(Theme.CANVAS)\n else\n Theme.outline(pillX, pillY, pillW, pillH, Theme.NEUTRAL_ACCENT)\n graphics.setColor(Theme.NEUTRAL_ACCENT)\n end\n graphics.print(pillX + math.floor((pillW - #modeStr * 6) / 2), pillY + 3, modeStr, 9999, LEFT)\n\n -- Right side of header: STEP nn / value\n local v = lane.cells[step] or 0\n local stepStr = string.format(\"STEP %02d\", step)\n local isMutedSel = muted[id][step] == true\n local valueStr = isMutedSel and \"MUTED\" or formatCell(lane,v)\n local valueW = #valueStr * 8\n local stepW = #stepStr * 6\n graphics.setColor(Theme.TEXT_DIM)\n graphics.print(W - 14 - valueW - 12 - stepW, 6, stepStr, 9999, LEFT)\n graphics.setColor(isMutedSel and Theme.NEUTRAL_ACCENT or lane.color)\n graphics.print(W - 14 - valueW, 6, valueStr, 9999, LEFT)\n\n -- Hairline separator under header\n Theme.line(0, headerH, W, headerH, Theme.BORDER)\n\n -- Tiny revision tag, bottom-right corner of header — discreet\n graphics.setColor(Theme.NEUTRAL_ACCENT)\n graphics.print(W - 26, headerH + 2, \"r\" .. WIDGET_REV, 9999, LEFT)\n\n -- ===== Cells row =====\n local cellsY = headerH + 8\n local cellsH = math.floor(H - cellsY - 6)\n local cellsX = 10\n local cellsW = math.floor(W - 20)\n local cellW = stepGeometry(cellsX, cellsW)\n\n -- Group dividers — short vertical tick between every 4th cell\n for g = 1, 3 do\n local divX = math.floor(stepX(cellsX, cellsW, g * 4) + cellW + GROUP_GAP_EXTRA / 2)\n local top = math.floor(cellsY + 4)\n local bot = math.floor(cellsY + cellsH - 4)\n graphics.setColor(Theme.BORDER)\n graphics.drawLine(divX, top, divX, bot)\n graphics.drawLine(divX + 1, top, divX + 1, bot)\n end\n\n for i = 1, CELLS do\n local cx = stepX(cellsX, cellsW, i)\n local isActive = (i == step)\n local inRange = (i <= range)\n local isMuted = inRange and (muted[id][i] == true)\n local cv = lane.cells[i] or 0\n local norm = math.max(0, math.min(1, cv / 127))\n\n -- Background — ELEVATED on selected step, SURFACE in-range, CANVAS OOR\n local bg = Theme.SURFACE\n if not inRange then bg = Theme.CANVAS\n elseif isActive then bg = Theme.ELEVATED end\n Theme.rect(cx, cellsY, cellW, cellsH, bg)\n\n if inRange then\n -- Value gauge — bar showing the normalised value. Orientation\n -- defaults to \"h\" (thin horizontal bar at the bottom), \"v\" puts a\n -- thin vertical bar on the right edge instead. Lane colour when\n -- active, dim variant otherwise. Hidden if muted.\n if not isMuted and cv > 0 then\n local gaugeColor = isActive and lane.color or Theme.ACCENT_DIM\n if lane.color == Theme.POSITIVE and not isActive then gaugeColor = 0x3F6C53 end\n if lane.gaugeOrientation == \"v\" then\n local gaugeW = 4\n local gaugeH = math.max(2, math.floor((cellsH - 8) * norm))\n local gaugeX = cx + cellW - gaugeW - 4\n local gaugeY = cellsY + cellsH - gaugeH - 4\n Theme.rect(gaugeX, gaugeY, gaugeW, gaugeH, gaugeColor)\n else\n local gaugeH = 4\n local gaugeW = math.max(2, math.floor((cellW - 8) * norm))\n local gaugeY = cellsY + cellsH - gaugeH - 4\n Theme.rect(cx + 4, gaugeY, gaugeW, gaugeH, gaugeColor)\n end\n end\n\n -- Label (value text or mute marker)\n local label, lblColor\n if isMuted then\n label = \"!\"\n lblColor = Theme.NEUTRAL_ACCENT\n else\n label = formatCell(lane,cv)\n lblColor = isActive and Theme.TEXT\n or (cv == 0 and Theme.NEUTRAL_ACCENT or Theme.TEXT_DIM)\n end\n graphics.setColor(lblColor)\n local labelW = #label * 6\n graphics.print(cx + math.floor((cellW - labelW) / 2),\n cellsY + math.floor((cellsH - 14) / 2),\n label, 9999, LEFT)\n else\n -- Out-of-range: dim dot, very subtle\n graphics.setColor(Theme.NEUTRAL_ACCENT)\n graphics.print(math.floor(cx + cellW / 2 - 3),\n math.floor(cellsY + cellsH / 2 - 4),\n \"·\", 9999, LEFT)\n end\n\n -- Cell outline — bright TEXT on active, BORDER in-range, ELEVATED OOR\n local outlineCol = isActive and Theme.TEXT\n or (inRange and Theme.BORDER or Theme.ELEVATED)\n Theme.outline(cx, cellsY, cellW, cellsH, outlineCol)\n\n -- Active step gets a bright top edge (signature design-system marker)\n if isActive then\n Theme.rect(cx, cellsY, cellW, 2, Theme.TEXT)\n end\n end\nend\n\n-- ===== Hit-testing =====\n\nfunction hitCell(ctrl, eventX, eventY)\n local b = ctrl:getBounds()\n local W, H = b[WIDTH], b[HEIGHT]\n local cellsY = 26\n local cellsH = math.floor(H - 32)\n if eventY < cellsY or eventY > cellsY + cellsH then return nil end\n local cellsX = 10\n local cellsW = math.floor(W - 20)\n local cellW = stepGeometry(cellsX, cellsW)\n for i = 1, CELLS do\n local cx = stepX(cellsX, cellsW, i)\n if eventX >= cx and eventX <= cx + cellW then return i end\n end\n return nil\nend\n\n-- ===== Touch =====\n\nfunction touchLane(ctrl, event)\n local id = ctrl:getId()\n local lane = lanes[id]\n if not lane then return end\n\n if event.type == DOWN then\n local i = hitCell(ctrl, event.x, event.y)\n if i then\n selectedStep[id] = i\n dragging[id] = { idx = i, startY = event.y, startV = lane.cells[i] or 0 }\n ctrl:repaint()\n end\n elseif event.type == MOVE then\n local d = dragging[id]\n if d then\n local dy = d.startY - event.y\n local nv = math.max(0, math.min(127,\n math.floor(d.startV + dy * 0.635 + 0.5)))\n lane.cells[d.idx] = nv\n parameterMap.set(1, PT_VIRTUAL, lane.paramBase + d.idx, nv)\n ctrl:repaint()\n end\n elseif event.type == UP then\n dragging[id] = nil\n end\nend\n\n-- ===== Pot (state machine: rotation = navigate/edit, click = toggle mode,\n-- double-click = reset selected step to 0) =====\n\nfunction potLane(ctrl, ev)\n local sourceId = ctrl:getId()\n -- DIAGNOSTIC: log every pot event reaching this tile, before any filter.\n -- Useful when adding a new lane to find which ev.id the firmware\n -- dispatches to that tile — copy the value into lane.potEvId.\n print(string.format(\"pot tile=%d ev.id=%s type=%s delta=%s\",\n sourceId, tostring(ev.id), tostring(ev.type), tostring(ev.delta)))\n local sourceLane = lanes[sourceId]\n if not sourceLane then return end\n -- Filter against the SOURCE lane's potEvId (= the ev.id this tile\n -- actually receives from the firmware).\n if ev.id ~= sourceLane.potEvId then return end\n\n -- Optional cross-dispatch: events on tile X can drive a different lane\n -- (controls the \"top pot drives top tile\" feel). Default: each lane\n -- targets itself unless lane.targetLane points elsewhere.\n local targetId = sourceLane.targetLane or sourceId\n local lane = lanes[targetId]\n if not lane then return end\n local s = potState[targetId]\n local targetCtrl = controls.get(targetId)\n\n if ev.type == DOWN then\n s.rotatedDuringTouch = false\n elseif ev.type == MOVE then\n s.rotatedDuringTouch = true\n s.pendingClick = nil -- rotation invalidates pending single-click\n if laneMode[targetId] == \"edit\" then\n local step = selectedStep[targetId]\n local cv = lane.cells[step] or 0\n cv = math.max(0, math.min(127, cv + ev.delta))\n lane.cells[step] = cv\n parameterMap.set(1, PT_VIRTUAL, lane.paramBase + step, cv)\n else\n selectedStep[targetId] = math.max(1, math.min(CELLS,\n selectedStep[targetId] + (ev.delta > 0 and 1 or -1)))\n end\n targetCtrl:repaint()\n elseif ev.type == UP then\n if not s.rotatedDuringTouch then\n if s.pendingClick then\n -- Double-click: undo the first click's mode toggle, then toggle\n -- the mute state of the selected step. The underlying value is\n -- preserved; muted cells display \"!\" and emit 0 over MIDI.\n laneMode[targetId] = (laneMode[targetId] == \"navigate\") and \"edit\" or \"navigate\"\n local step = selectedStep[targetId]\n local nowMuted = not (muted[targetId][step] == true)\n muted[targetId][step] = nowMuted\n local outValue = nowMuted and 0 or (lane.cells[step] or 0)\n parameterMap.set(1, PT_VIRTUAL, lane.paramBase + step, outValue)\n s.pendingClick = nil\n else\n -- Single click: toggle mode, mark pending.\n laneMode[targetId] = (laneMode[targetId] == \"navigate\") and \"edit\" or \"navigate\"\n s.pendingClick = true\n end\n targetCtrl:repaint()\n end\n end\nend\n\n-- ===== Boot =====\n\nfunction preset.onLoad()\n print(\"note-list-16 rev \" .. WIDGET_REV .. \" loaded with \" .. #lanes .. \" lanes\")\n local PAGE_W = 1016\n local LANE_H = 100\n\n for i = 1, #lanes do\n local c = controls.get(i)\n if c then\n c:setBounds({0, (i - 1) * LANE_H, PAGE_W, LANE_H})\n c:setPaintCallback(paintLane)\n c:setTouchCallback(touchLane)\n c:setPotCallback(potLane)\n c:repaint()\n end\n end\nend\n\n-- ===== External sync via parameterMap.onChange =====\n--\n-- Fires every time a Parameter Map entry changes — whether from an\n-- external MIDI message, an internal firmware update, or a Lua-driven\n-- parameterMap.set call. We use it to:\n--\n-- 1) keep `commonRange` in sync with virtual param PARAM_COMMON_RANGE\n-- (so a fader on another page can change the step count live);\n-- 2) keep `lane.cells[step]` in sync with the per-step virtual params\n-- (so external automation moves the visible step values).\n--\n-- We skip our OWN writes by filtering on `origin == LUA` — otherwise\n-- every parameterMap.set in touchLane / potLane would loop back here.\nfunction parameterMap.onChange(valueObjects, origin, midiValue)\n if origin == LUA then return end\n\n for _, vo in ipairs(valueObjects) do\n local msg = vo:getMessage()\n if msg and msg:getType() == PT_VIRTUAL then\n local pn = msg:getParameterNumber()\n\n -- (1) Common range param\n if pn == PARAM_COMMON_RANGE then\n commonRange = math.max(1, math.min(CELLS, midiValue))\n for i = 1, #lanes do\n local c = controls.get(i); if c then c:repaint() end\n end\n return\n end\n\n -- (2) Per-step lane params: paramBase+1 .. paramBase+CELLS\n for laneIdx, lane in ipairs(lanes) do\n if pn >= lane.paramBase + 1 and pn <= lane.paramBase + CELLS then\n local step = pn - lane.paramBase\n lane.cells[step] = midiValue\n local c = controls.get(laneIdx)\n if c then c:repaint() end\n return\n end\n end\n end\n end\nend", "devices": [ { "id": 1, "name": "Local", "port": 1, "channel": 1 } ], "tiles": [ { "id": "note-list-16-notes", "reference": 1, "slotId": 1, "type": "custom", "deviceId": 1, "color": "FFFFFF", "name": "NOTES", "categoryId": "control", "values": [ { "message": { "type": "virtual", "deviceId": 1, "parameterNumber": 99 } } ], "visible": true }, { "id": "note-list-16-velocity", "reference": 2, "slotId": 7, "type": "custom", "deviceId": 1, "color": "FFFFFF", "name": "VELOCITY", "categoryId": "control", "values": [ { "message": { "type": "virtual", "deviceId": 1, "parameterNumber": 98 } } ], "visible": true } ], "pages": [ { "id": 1, "name": "Page 1" } ], "categories": [], "firstPageId": 1 }