-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcoordinator.lua
More file actions
304 lines (266 loc) · 12.4 KB
/
coordinator.lua
File metadata and controls
304 lines (266 loc) · 12.4 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
-- See docs/coordinator.md for the model.
--invariant: owns the active tracker take; polls REAPER's selection each frame while tracker page is active (sticky on no-item or non-MIDI)
--invariant: no teardown path — quit() sets a flag that stops scheduling further defers; REAPER reclaims state on script unload
--invariant: errors inside the defer loop surface through the same xpcall frame because each iteration reschedules itself
local util = require 'util'
if not reaper.ImGui_GetBuiltinPath then
reaper.MB('ReaImGui is required. Install it via ReaPack.', 'Continuum', 0)
return
end
package.path = reaper.ImGui_GetBuiltinPath() .. '/?.lua;' .. package.path
local ImGui = require 'imgui' '0.10'
local cm, cmgr, gui = (...).cm, (...).cmgr, (...).gui
local ctx, uiFont = gui.ctx, gui.uiFont
local chrome = util.instantiate('chrome', { cm = cm, ctx = ctx })
local CHROME_PAD_X, CHROME_PAD_Y = 8, 4
local pages, active = {}, nil
local quitting = false
--contract: owns the active sampler track — the picker on samplePage delegates here, and first sample-page activation seeds the default from pages.sample:listTracks()
local samplerTrack = nil
--contract: owns currentTake — refreshTakeFromReaper polls GetSelectedMediaItem→GetActiveTake→TakeIsMIDI, mutates currentTake only on a real MIDI take that differs (sticky on nothing-selected or non-MIDI), and returns true so the caller can rebind
local currentTake = nil
--contract: external-mutation watcher — captured at end of every tracker-active frame; top-of-tick diff triggers tp:reloadFromReaper. Cleared on take swap (bind path is the reload). Nil disables the diff check (one-frame grace after swap/first activation).
local lastTakeHash = nil
----- Keyboard router
-- Capture digits and '/' into the prefix buffer; Esc cancels. Returns
-- 'consumed' if a prefix-accumulating key fired this frame; 'fall' to
-- finishPrefix and let the normal keychain walk handle this key; nil if
-- prefix mode is inactive.
local function handlePrefixCapture(cmgr, ctx)
if not cmgr:isPrefixActive() then return nil end
for d = 0, 9 do
if ImGui.IsKeyPressed(ctx, ImGui.Key_0 + d) and ImGui.GetKeyMods(ctx) == ImGui.Mod_None then
cmgr:appendPrefix(tostring(d)); return 'consumed'
end
end
if ImGui.IsKeyPressed(ctx, ImGui.Key_Slash) and ImGui.GetKeyMods(ctx) == ImGui.Mod_None then
cmgr:appendPrefix('/'); return 'consumed'
end
if ImGui.IsKeyPressed(ctx, ImGui.Key_Escape) then
cmgr:cancelPrefix(); return 'consumed'
end
-- Any non-accumulating press freezes the buffer so the dispatched
-- command can consumePrefix() on this same frame.
cmgr:finishPrefix()
return 'fall'
end
--shape: dispatchResult = { consumed: bool, commandHeld: bool }
--contract: returns early (no dispatch) when state.suppressKbd or not state.acceptCmds
--contract: state.pageSuppressed shrinks the walk to the root keymap only — body-region editors (swing, tuning) suppress page bindings without shadowing globals like playPause/quit
--contract: first-hit wins across the keychain; a command returning false declines and releases the key (clearing commandHeld) so the page char queue sees it
--contract: while cmgr:isPrefixActive(), digits and '/' are captured (no dispatch); Esc cancels; any other key freezes the prefix and falls through to the keychain walk so commands can consumePrefix()
local function dispatchKeys(state, cmgr, ctx)
if state.suppressKbd or not state.acceptCmds then
return { consumed = false, commandHeld = false }
end
local cap = handlePrefixCapture(cmgr, ctx)
if cap == 'consumed' then
return { consumed = true, commandHeld = false }
end
local commandHeld = false
local keychain = state.pageSuppressed and { cmgr:rootKeymap() } or cmgr:keychain()
for _, keymap in ipairs(keychain) do
for command, keys in pairs(keymap) do
for _, spec in ipairs(keys) do
local key, mods = cmgr:keySpec(spec, ImGui)
if ImGui.IsKeyDown(ctx, key) and mods == ImGui.Mod_None then
commandHeld = true
end
if ImGui.IsKeyPressed(ctx, key) and ImGui.GetKeyMods(ctx) == mods then
if cmgr:invoke(command) == false then
commandHeld = false
else
return { consumed = true, commandHeld = commandHeld }
end
end
end
end
end
return { consumed = false, commandHeld = commandHeld }
end
----- Coordinator
local function refreshTakeFromReaper()
local item = reaper.GetSelectedMediaItem(0, 0)
if not item then return false end
local t = reaper.GetActiveTake(item)
if not t or not reaper.TakeIsMIDI(t) then return false end
if t == currentTake then return false end
currentTake = t
return true
end
--contract: owns the cross-cutting tracker-scope command (loadSampleAtCurrentSlot) but dispatches into samplePage which owns sm; coord never speaks sm directly
cmgr:scope('tracker'):register('loadSampleAtCurrentSlot', function()
if not cm:get('trackerMode') then return end
if pages.sample and currentTake then
pages.sample:loadSampleIntoSlot(currentTake, cm:get('currentSample'))
end
end)
local function takeMidiHash()
if not currentTake then return nil end
local ok, h = reaper.MIDI_GetHash(currentTake, false)
return ok and h or nil
end
--contract: tick() runs once per frame before the page draws; setPrefix is republished only when the project path changes (one mailbox cell shared across instances)
--contract: tracker-active branch — take swap takes priority (clears the watcher so the post-bind end-of-frame capture is the new baseline); otherwise a hash diff signals an external mutation (REAPER Ctrl-Z, external script) and we reload the bound take
local function tick()
if active == 'tracker' and pages.tracker then
if refreshTakeFromReaper() then
pages.tracker:bind(currentTake)
lastTakeHash = nil
elseif lastTakeHash then
local h = takeMidiHash()
if h and h ~= lastTakeHash then
pages.tracker:reloadFromReaper()
lastTakeHash = takeMidiHash()
end
end
end
if pages.sample and currentTake then pages.sample:tick(currentTake) end
end
local function drawSwitcher()
local function pageButton(label, name)
local isActive = active == name
if isActive then
ImGui.PushStyleColor(ctx, ImGui.Col_Button, chrome.colour('toolbar.buttonActive'))
end
if ImGui.Button(ctx, label) and not isActive then
cmgr:invoke('switchPage', name)
end
if isActive then ImGui.PopStyleColor(ctx, 1) end
end
pageButton('Tracker', 'tracker')
ImGui.SameLine(ctx, 0, 4)
pageButton('Sample', 'sample')
end
local function dispatch(state) return dispatchKeys(state, cmgr, ctx) end
local function frame()
tick()
local page = pages[active]
ImGui.PushFont(ctx, uiFont, 13)
ImGui.PushStyleColor(ctx, ImGui.Col_WindowBg, chrome.colour('bg'))
ImGui.PushStyleColor(ctx, ImGui.Col_TitleBg, chrome.colour('toolbar.bg'))
ImGui.PushStyleColor(ctx, ImGui.Col_TitleBgActive,chrome.colour('toolbar.bg'))
ImGui.PushStyleColor(ctx, ImGui.Col_ScrollbarBg, chrome.colour('scrollBg'))
ImGui.PushStyleColor(ctx, ImGui.Col_ScrollbarGrab,chrome.colour('scrollHandle'))
ImGui.PushStyleVar(ctx, ImGui.StyleVar_WindowPadding, 0, 0)
local visible, open = ImGui.Begin(ctx, 'Continuum', true,
ImGui.WindowFlags_NoScrollbar
| ImGui.WindowFlags_NoScrollWithMouse
| ImGui.WindowFlags_NoDocking
| ImGui.WindowFlags_NoNav
| ImGui.WindowFlags_NoMove)
ImGui.PopStyleVar(ctx)
-- Active-item drags (e.g. the lane strip's curve editor) can otherwise
-- accumulate auto-scroll on the parent window, pushing the grid below
-- the visible region for the duration of the drag.
if visible then ImGui.SetScrollY(ctx, 0); ImGui.SetScrollX(ctx, 0) end
if visible and page then
-- Toolbar band
ImGui.PushStyleColor(ctx, ImGui.Col_ChildBg, chrome.colour('toolbar.bg'))
ImGui.PushStyleVar(ctx, ImGui.StyleVar_WindowPadding, CHROME_PAD_X, CHROME_PAD_Y)
if ImGui.BeginChild(ctx, '##toolbar', 0, 0,
ImGui.ChildFlags_AutoResizeY | ImGui.ChildFlags_AlwaysUseWindowPadding,
ImGui.WindowFlags_NoScrollbar | ImGui.WindowFlags_NoNav) then
chrome.pushChromeStyles()
ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding, 10, 3)
drawSwitcher()
ImGui.SameLine(ctx, 0, 12)
chrome.verticalSeparator()
ImGui.SameLine(ctx, 0, 12)
page:renderToolbarBits(ctx)
ImGui.PopStyleVar(ctx, 1)
chrome.popChromeStyles()
end
ImGui.EndChild(ctx)
ImGui.PopStyleVar(ctx)
ImGui.PopStyleColor(ctx)
-- Body region: reserve a fixed footer for the status bar; the
-- page paints into the remaining viewport at (CHROME_PAD_X,
-- toolbarBottom + CHROME_PAD_Y).
local cursorY = ImGui.GetCursorPosY(ctx)
local availW0, availH = ImGui.GetContentRegionAvail(ctx)
local footerH = ImGui.GetFrameHeightWithSpacing(ctx) + 4
local bodyH = availH - footerH
ImGui.Indent(ctx, CHROME_PAD_X)
ImGui.SetCursorPosY(ctx, ImGui.GetCursorPosY(ctx) + CHROME_PAD_Y)
page:renderBody(ctx,
availW0 - CHROME_PAD_X * 2,
bodyH - CHROME_PAD_Y,
dispatch)
ImGui.Unindent(ctx, CHROME_PAD_X)
-- Status band pinned to (toolbarBottom + bodyH); the parchment
-- gap above is the leftover.
ImGui.SetCursorPosY(ctx, cursorY + bodyH)
ImGui.PushStyleColor(ctx, ImGui.Col_ChildBg, chrome.colour('statusBar.bg'))
ImGui.PushStyleColor(ctx, ImGui.Col_Text, chrome.colour('statusBar.text'))
ImGui.PushStyleVar(ctx, ImGui.StyleVar_WindowPadding, CHROME_PAD_X + 4, CHROME_PAD_Y)
if ImGui.BeginChild(ctx, '##statusBar', 0, footerH,
ImGui.ChildFlags_AlwaysUseWindowPadding,
ImGui.WindowFlags_NoScrollbar) then
page:renderStatusBar(ctx)
end
ImGui.EndChild(ctx)
ImGui.PopStyleVar(ctx)
ImGui.PopStyleColor(ctx, 2)
elseif visible then
ImGui.Text(ctx, 'Select a MIDI item to begin.')
end
ImGui.End(ctx)
if page and page.renderFloating then page:renderFloating(ctx) end
ImGui.PopStyleColor(ctx, 5)
ImGui.PopFont(ctx)
-- End-of-frame baseline for the external-mutation watcher: by now any
-- user-triggered mutation this frame has flushed through mm:modify, so the
-- hash reflects truth. Next frame's tick() diffs against this value; a
-- difference can only have come from outside Continuum.
if active == 'tracker' and currentTake then lastTakeHash = takeMidiHash() end
if open and not quitting then reaper.defer(frame) end
end
---------- PUBLIC
--shape: page = { renderToolbarBits(ctx), renderBody(ctx,w,h,dispatch), renderStatusBar(ctx), bind(...), unbind(), [renderFloating(ctx)] }
--contract: pages must be registered via coord:register(name,page); first registered becomes active
local coord = {}
function coord:register(name, page)
pages[name] = page
if not active then self:setActive(name) end
end
--contract: setActive(name) is a no-op when name == active; otherwise unbinds the outgoing page, swaps cmgr scope, and binds the incoming page (tracker→currentTake, sample→samplerTrack)
function coord:setActive(name)
if active == name then return end
if active and pages[active] then
pages[active]:unbind()
cmgr:pop(active)
end
active = name
cmgr:push(name)
if name == 'tracker' then
refreshTakeFromReaper()
pages.tracker:bind(currentTake)
elseif name == 'sample' then
if samplerTrack == nil then
local tracks = pages.sample:listTracks()
samplerTrack = tracks[1] and tracks[1].track or nil
end
pages.sample:bind(samplerTrack)
end
end
--contract: stores the active sampler track and re-binds the sample page if currently active; safe to call before sample page is registered (state stashes; bind happens on next activation)
function coord:setSamplerTrack(t)
samplerTrack = t
if active == 'sample' and pages.sample then
pages.sample:bind(t)
end
end
function coord:togglePage()
self:setActive(active == 'tracker' and 'sample' or 'tracker')
end
--contract: invoke after firing a REAPER action that mutates the bound take from inside a frame (Ctrl-Z, Ctrl-Shift-Z). The watcher's end-of-frame baseline would otherwise absorb the mutation; this reloads now so tm/vm stay coherent with the take.
function coord:reloadAfterExternalMutation()
if active == 'tracker' and pages.tracker and currentTake then
pages.tracker:reloadFromReaper()
end
end
function coord:quit() quitting = true end
function coord:chrome() return chrome end
function coord:run() frame() end
return coord