-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patheditCursor.lua
More file actions
498 lines (439 loc) · 17.2 KB
/
editCursor.lua
File metadata and controls
498 lines (439 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
-- See docs/editCursor.md for the model.
--invariant: cursor lives at (row, col, stop) — stop is a char-offset index into col.stopPos, not a part index
--invariant: cursorRow is 0-indexed; cursorCol/cursorStop are 1-indexed (Lua-array)
--invariant: ec owns caret, selection, and clipboard; no MIDI event state — MIDI mutations go via tm/cm
--invariant: selection is anchor + cursor; selAnchor is the fixed end, cursor is the moving end; sel is the resolved rect
--invariant: sel == nil iff no selection; hasSelection() / region() degenerate-to-cursor is a callsite policy, not stored
--invariant: sticky scopes are orthogonal: hBlockScope ∈ {0=free,1=col,2=channel,3=all-cols}, vBlockScope ∈ {0=free,1=beat,2=bar,3=all-rows}
--invariant: sticky == any nonzero scope; cursor moves while sticky update sel rather than clearing it
--invariant: HBlock=2/3 use sentinel part1=part2='*' that no real part name matches — selectionStopSpan falls through to whole-col
--invariant: within a single col, parts order via col.partStart (lower partStart = earlier part); not stop index, since parts may be multi-stop
--invariant: moveHook fires after every position-changing operation (clampPos+moveHook are paired)
--shape: selection = { row1, row2, col1, col2, part1, part2 } -- inclusive on both axes; part1/part2 are part names or '*' sentinel
--shape: selAnchor = { row, col, stop } -- fixed end of an active selection
--shape: clip.single = { mode='single', type='note'|'7bit'|'pb', numRows, events=[clipEvent,...] }
--shape: clip.multi = { mode='multi', numRows, startType, cols=[clipColEntry,...] }
--shape: clipColEntry = { type, chanDelta, key, events=[clipEvent,...] } -- key: note=lane-pos, cc=cc#, singleton=nil
--shape: clipEvent = source-event minus CLIP_RESERVED, plus { row, [endRow] } -- row is 0-relative to clip top
local util = require 'util'
local deps = ...
---------- PRIVATE
local grid = deps.grid
local cm = deps.cm
local cmgr = deps.cmgr
local getRPBar = deps.rowPerBar
local logPerRow = deps.logPerRow or function () return 1 end
local moveHook = deps.moveHook or function () end
local cursorRow, cursorCol, cursorStop = 0, 1, 1
local hBlockScope, vBlockScope = 0, 0
local sel, selAnchor
----- Position
--contract: clamps to grid extents; idempotent; stop clamped against current col's stopPos length so col-changes must precede stop reads
local function clampPos()
local maxRow = math.max(0, (grid.numRows or 1) - 1)
cursorRow = util.clamp(cursorRow, 0, maxRow)
cursorCol = util.clamp(cursorCol, 1, #grid.cols)
cursorStop = util.clamp(cursorStop, 1, #grid.cols[cursorCol].stopPos)
end
----- Parts
local function partAt(col, stop)
local c = grid.cols[col]
return c and c.partAt and c.partAt[stop] or 'val'
end
local function cursorPart() return partAt(cursorCol, cursorStop) end
--contract: returns first stop whose partAt name matches; used by selection-set and regionStart to land the caret on the part's leading char
local function firstStopForPart(col, part)
local c = grid.cols[col]
if not c then return 1 end
for s, name in ipairs(c.partAt) do
if name == part then return s end
end
return 1
end
----- Selection
local function isSticky() return hBlockScope > 0 or vBlockScope > 0 end
--contract: pins anchor at current cursor and produces a 1x1 sel; precondition for any selUpdate that follows
local function selStart()
selAnchor = { row = cursorRow, col = cursorCol, stop = cursorStop }
local p = cursorPart()
sel = { row1 = cursorRow, row2 = cursorRow, col1 = cursorCol, col2 = cursorCol, part1 = p, part2 = p }
end
--contract: recomputes sel from anchor + cursor + scopes; rows snap to beat/bar under vBlock=1/2; cols expand to channel/all under hBlock=2/3; no-op when selAnchor is nil
local function selUpdate()
local a = selAnchor
if not a then return end
local numRows = grid.numRows or 1
local r1, r2
if vBlockScope == 1 or vBlockScope == 2 then
local unit = vBlockScope == 1 and cm:get('rowPerBeat') or getRPBar()
r1 = math.floor(cursorRow / unit) * unit
r2 = math.min(r1 + unit - 1, numRows - 1)
elseif vBlockScope == 3 then
r1, r2 = 0, numRows - 1
else
r1, r2 = a.row, cursorRow
if r1 > r2 then r1, r2 = r2, r1 end
end
local c1, c2, p1, p2
if hBlockScope == 2 then
local chan = grid.cols[cursorCol].midiChan
c1, c2 = grid.chanFirstCol[chan], grid.chanLastCol[chan]
p1, p2 = '*', '*'
elseif hBlockScope == 3 then
c1, c2 = 1, #grid.cols
p1, p2 = '*', '*'
else
c1, c2 = a.col, cursorCol
p1, p2 = partAt(a.col, a.stop), cursorPart()
if c1 > c2 then c1, c2, p1, p2 = c2, c1, p2, p1
elseif c1 == c2 then
-- normalise so p1 is at-or-before p2 (lower partStart = earlier part)
local col = grid.cols[c1]
if col and col.partStart[a.stop] > col.partStart[cursorStop] then
p1, p2 = p2, p1
end
end
end
sel = { row1 = r1, row2 = r2, col1 = c1, col2 = c2, part1 = p1, part2 = p2 }
end
--contract: drops sel + anchor + both sticky scopes; the only path that turns sticky off without explicit unstick()
local function selClear()
sel = nil; selAnchor = nil
hBlockScope = 0; vBlockScope = 0
end
--contract: first call seeds anchor and sets scope=1 (col); subsequent cycles 1->2->3->1 (col->channel->all-cols)
local function cycleHBlock()
if not isSticky() then
selAnchor = { row = cursorRow, col = cursorCol, stop = cursorStop }
hBlockScope = 1
else
hBlockScope = (hBlockScope % 3) + 1
end
selUpdate()
end
--contract: first call seeds anchor and sets scope=1 (beat); subsequent cycles 1->2->3->1 (beat->bar->all-rows); no-op on empty grid
local function cycleVBlock()
if (grid.numRows or 0) == 0 then return end
if not isSticky() then
selAnchor = { row = cursorRow, col = cursorCol, stop = cursorStop }
vBlockScope = 1
else
vBlockScope = (vBlockScope % 3) + 1
end
selUpdate()
end
--contract: swaps anchor<->cursor on whichever axes aren't scope-locked (vBlock<1 swaps row, hBlock<2 swaps col+stop); no-op without an active selection
local function swapEnds()
if not (sel and selAnchor) then return end
if vBlockScope < 1 then
selAnchor.row, cursorRow = cursorRow, selAnchor.row
end
if hBlockScope < 2 then
selAnchor.col, cursorCol = cursorCol, selAnchor.col
selAnchor.stop, cursorStop = cursorStop, selAnchor.stop
end
clampPos(); moveHook()
selUpdate()
end
----- Movement
--contract: selecting=true (or sticky) extends sel; otherwise clears sel before moving; clamps at top/bottom edges (no wrap)
local function moveRow(n, selecting)
if selecting or isSticky() then
if not sel then selStart() end
else selClear() end
cursorRow = cursorRow + n
clampPos(); moveHook()
if selecting or isSticky() then selUpdate() end
end
--contract: walks stops within col, crossing into adjacent col at the boundary; stops at first/last col (no wrap); under hBlock>=2 collapses scope to 1 and re-anchors at current cursor first
local function moveStop(n, selecting)
if selecting or isSticky() then
if not sel then selStart() end
else selClear() end
if hBlockScope >= 2 and selAnchor then
selAnchor.col = cursorCol
selAnchor.stop = cursorStop
hBlockScope = 1
end
local dir = n > 0 and 1 or -1
for _ = 1, math.abs(n) do
local s = cursorStop + dir
if s > #grid.cols[cursorCol].stopPos then
if cursorCol >= #grid.cols then break end
cursorCol = cursorCol + 1
cursorStop = 1
elseif s < 1 then
if cursorCol <= 1 then break end
cursorCol = cursorCol - 1
cursorStop = #grid.cols[cursorCol].stopPos
else
cursorStop = s
end
end
clampPos(); moveHook()
if selecting or isSticky() then selUpdate() end
end
local moveCol, moveChannel do
local function moveUnit(n, toFirstStop, toLastStop)
if not isSticky() then selClear() end
local sgn = n > 0 and 1 or -1
local land = sgn > 0 and toLastStop or toFirstStop
if isSticky() then
for _ = 1, math.abs(n) do
local extending = (sgn > 0 and cursorCol >= selAnchor.col)
or (sgn < 0 and cursorCol <= selAnchor.col)
if extending then moveStop(sgn); land()
else land(); moveStop(sgn) end
end
else
for _ = 1, math.abs(n) do
if sgn > 0 then toLastStop(); moveStop(1)
else toFirstStop(); moveStop(-1); toFirstStop()
end
end
end
if isSticky() then selUpdate() end
end
--contract: jumps one col per step, landing on first/last stop of the destination col; under sticky, extends/contracts selection one col per step
function moveCol(n)
moveUnit(n,
function()
cursorStop = 1
if isSticky() and cursorCol == selAnchor.col and #grid.cols[cursorCol].parts == 1 then
moveStop(1)
cursorStop = #grid.cols[cursorCol].stopPos
end
end,
function()
cursorStop = #grid.cols[cursorCol].stopPos
if isSticky() and cursorCol == selAnchor.col and #grid.cols[cursorCol].parts == 1 then
moveStop(1)
cursorStop = #grid.cols[cursorCol].stopPos
end
end)
end
--contract: jumps one channel per step; non-sticky lands on the first note col of the destination channel (skipping pc/pb sentinels); sticky preserves the raw chan-edge land
function moveChannel(n)
local function chanRange()
local chan = grid.cols[cursorCol].midiChan
return grid.chanFirstCol[chan], grid.chanLastCol[chan]
end
moveUnit(n,
function()
local first, _ = chanRange()
cursorCol, cursorStop = first, 1
end,
function()
local _, last = chanRange()
cursorCol = last
cursorStop = #grid.cols[cursorCol].stopPos
end)
-- pc/pb sit left of the note column, so the raw scroll lands on pc.
-- Snap forward to the first note column of the landing channel.
if not isSticky() then
local first, last = chanRange()
for ci = first, last do
if grid.cols[ci].type == 'note' then
cursorCol, cursorStop = ci, 1
break
end
end
end
end
end
---------- PUBLIC
local ec = {}
----- Position
function ec:row() return cursorRow end
function ec:col() return cursorCol end
function ec:pos() return cursorRow, cursorCol, cursorStop end
--contract: any nil arg leaves that axis untouched; clamps and fires moveHook unconditionally
function ec:setPos(row, col, stop)
if row then cursorRow = row end
if col then cursorCol = col end
if stop then cursorStop = stop end
clampPos(); moveHook()
end
function ec:clampPos() clampPos() end
--contract: scales cursorRow by newRPB/oldRPB; called by vm when rowPerBeat changes mid-session so caret stays at the same musical time
function ec:rescaleRow(oldRPB, newRPB)
cursorRow = math.floor(cursorRow * newRPB / oldRPB)
end
function ec:reset()
cursorRow, cursorCol, cursorStop = 0, 1, 1
selClear()
end
----- Part & Region
function ec:cursorPart() return cursorPart() end
function ec:hasSelection() return sel ~= nil end
function ec:isSticky() return isSticky() end
function ec:anchorRow() return selAnchor and selAnchor.row end
function ec:region()
if sel then
return sel.row1, sel.row2, sel.col1, sel.col2, sel.part1, sel.part2
end
local p = cursorPart()
return cursorRow, cursorRow, cursorCol, cursorCol, p, p
end
-- Pair so callers can splat: `ec:setPos(row, ec:regionStart())`.
function ec:regionStart()
if not sel then return cursorCol, cursorStop end
return sel.col1, firstStopForPart(sel.col1, sel.part1)
end
function ec:eachSelectedCol()
if not sel then
local col, ci = grid.cols[cursorCol], cursorCol
local done = col == nil
return function()
if done then return end
done = true
return col, ci
end
end
local ci = sel.col1 - 1
return function()
ci = ci + 1
while ci <= sel.col2 do
local col = grid.cols[ci]
if col then return col, ci end
ci = ci + 1
end
end
end
--contract: installs a selection from a part-typed record; clears sticky scopes; anchor lands at top-left part-start so subsequent extends behave like a fresh drag
function ec:setSelection(r)
sel = { row1 = r.row1, row2 = r.row2, col1 = r.col1, col2 = r.col2,
part1 = r.part1, part2 = r.part2 }
selAnchor = { row = r.row1, col = r.col1, stop = firstStopForPart(r.col1, r.part1) }
hBlockScope, vBlockScope = 0, 0
end
function ec:selectionStopSpan(col)
if not sel then return nil end
local c = grid.cols[col]
if not c then return nil end
local s1, s2 = 1, #c.stopPos
if col == sel.col1 then
for s, name in ipairs(c.partAt) do
if name == sel.part1 then s1 = s; break end
end
end
if col == sel.col2 then
for s = #c.partAt, 1, -1 do
if c.partAt[s] == sel.part2 then s2 = s; break end
end
end
return s1, s2
end
function ec:selClear() selClear() end
function ec:unstick() hBlockScope, vBlockScope = 0, 0 end
-- Mouse shift-click and drag both speak this verb.
function ec:extendTo(row, col, stop)
if not sel then selStart() end
self:setPos(row, col, stop)
selUpdate()
end
--contract: shifts both selection rows + anchor + cursor by rowDelta; clamps each independently (selection can compress against the edge while cursor doesn't)
function ec:shiftSelection(rowDelta)
local maxRow = grid.numRows - 1
sel.row1 = util.clamp(sel.row1 + rowDelta, 0, maxRow)
sel.row2 = util.clamp(sel.row2 + rowDelta, 0, maxRow)
selAnchor.row = util.clamp(selAnchor.row + rowDelta, 0, maxRow)
cursorRow = cursorRow + rowDelta
clampPos(); moveHook()
end
----- Motion
--contract: advances by cm.advanceBy rows; the per-keystroke auto-step after a write
function ec:advance() moveRow(cm:get('advanceBy')) end
do
local function selectSpan(scope, col, stop1, stop2)
cursorCol, cursorStop = col, stop2
selAnchor = { row = cursorRow, col = col, stop = stop1 }
hBlockScope, vBlockScope = scope, 3
selUpdate()
end
function ec:selectChannel(chan)
local first = grid.chanFirstCol[chan]
if first then selectSpan(2, first, 1, 1) end
end
function ec:selectColumn(col)
local c = grid.cols[col]
if c then selectSpan(1, col, 1, #c.stopPos) end
end
end
----- Commands
function ec:registerCommands(scope)
scope:registerAll{
cursorDown = function() moveRow(1) end,
cursorUp = function() moveRow(-1) end,
pageDown = function() moveRow(getRPBar()) end,
pageUp = function() moveRow(-getRPBar()) end,
goTop = function() moveRow(-cursorRow) end,
goBottom = function() moveRow((grid.numRows or 1) - cursorRow) end,
goLeft = function() moveCol(-cursorCol) end,
goRight = function() moveCol(#grid.cols - cursorCol) end,
cursorRight = function() moveStop(1) end,
cursorLeft = function() moveStop(-1) end,
selectDown = function() moveRow(1, true) end,
selectUp = function() moveRow(-1, true) end,
selectRight = function() moveStop(1, true) end,
selectLeft = function() moveStop(-1, true) end,
selectClear = function() selClear() end,
colRight = function() moveCol(1) end,
colLeft = function() moveCol(-1) end,
channelRight = function() moveChannel(1) end,
channelLeft = function() moveChannel(-1) end,
cycleBlock = function() cycleHBlock() end,
cycleVBlock = function() cycleVBlock() end,
swapBlockEnds = function() swapEnds() end,
}
end
----- Decoration
do
-- Part primitives: char `width` and `stops` (cursor offsets within the
-- part). pitch's middle char ('-' between letter and octave) is
-- skipped — width 3, only 2 stops.
local PARTS = {
pitch = { width = 3, stops = {0, 2} }, -- C-4
sample = { width = 2, stops = {0, 1} }, -- 7F (tracker mode)
vel = { width = 2, stops = {0, 1} }, -- 30
delay = { width = 3, stops = {0, 1, 2} }, -- 040
pb = { width = 4, stops = {0, 1, 2, 3} },
val = { width = 2, stops = {0, 1} },
}
-- One char of separator between adjacent parts in the rendered cell.
local function partsFor(type, showDelay, trackerMode)
if type == 'note' then
local p = {'pitch'}
if trackerMode then util.add(p, 'sample') end
util.add(p, 'vel')
if showDelay then util.add(p, 'delay') end
return p
elseif type == 'pb' then
return {'pb'}
else
return {'val'}
end
end
--contract: stamps col.parts/stopPos/partAt/partStart/width derived from col.type + showDelay + trackerMode; ec is the sole writer of these fields, addGridCol never names them
function ec:decorateCol(col)
local parts = partsFor(col.type, col.showDelay, col.trackerMode)
col.parts = parts
local stopPos, partAt, partStart = {}, {}, {}
local x = 0
for _, name in ipairs(parts) do
local p = PARTS[name]
local first = #stopPos + 1
for _, off in ipairs(p.stops) do
util.add(stopPos, x + off)
util.add(partAt, name)
util.add(partStart, first)
end
x = x + p.width + 1 -- +1 inter-part separator
end
col.stopPos = stopPos
col.partAt = partAt
col.partStart = partStart
col.width = x - 1 -- last separator was speculative
end
end
return ec