@mthines/reaper-mcp 0.16.0 → 0.17.0-beta.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/claude-agents/mixer.md +2 -1
- package/main.js +45 -25
- package/package.json +1 -1
- package/reaper/CLAUDE.md +1 -0
- package/reaper/install.sh +3 -1
- package/reaper/mcp_bridge.lua +20 -0
- package/reaper/mcp_snapshot_manager.lua +997 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npx @mthines/reaper-mcp init --yes
|
|
|
22
22
|
The `init` wizard walks you through:
|
|
23
23
|
1. Installing the REAPER bridge (Lua + JSFX analyzers)
|
|
24
24
|
2. Installing AI mix knowledge and agents
|
|
25
|
-
3. Configuring Claude Code settings (auto-allows all
|
|
25
|
+
3. Configuring Claude Code settings (auto-allows all 80 REAPER tools)
|
|
26
26
|
4. Optionally creating a project-local `.mcp.json`
|
|
27
27
|
|
|
28
28
|
Then load `mcp_bridge.lua` in REAPER and open Claude Code — you're ready to mix.
|
|
@@ -31,7 +31,7 @@ Then load `mcp_bridge.lua` in REAPER and open Claude Code — you're ready to mi
|
|
|
31
31
|
|
|
32
32
|
```
|
|
33
33
|
Claude Code
|
|
34
|
-
├── MCP Tools (
|
|
34
|
+
├── MCP Tools (80) ──→ controls REAPER in real-time
|
|
35
35
|
│ ├── Track management (list, get/set properties, arm, phase, input)
|
|
36
36
|
│ ├── FX management (add/remove, get/set parameters, enable/offline, presets)
|
|
37
37
|
│ ├── Transport (play, stop, record, cursor position)
|
|
@@ -43,7 +43,7 @@ Claude Code
|
|
|
43
43
|
│ ├── MIDI editing (14 tools: notes, CC, items, analysis, batch ops)
|
|
44
44
|
│ ├── Media items (11 tools: properties, split, move, trim, stretch)
|
|
45
45
|
│ ├── Plugin discovery (list installed FX, search, presets)
|
|
46
|
-
│ ├── Snapshots (save/restore mixer state for A/B comparison)
|
|
46
|
+
│ ├── Snapshots (save/restore/delete mixer state for A/B comparison)
|
|
47
47
|
│ └── Routing (sends, receives, bus structure)
|
|
48
48
|
│
|
|
49
49
|
└── Mix Engineer Knowledge ──→ knows HOW to use those tools
|
|
@@ -81,7 +81,7 @@ npx @mthines/reaper-mcp init
|
|
|
81
81
|
The wizard guides you through selecting which components to install:
|
|
82
82
|
- **REAPER Bridge** — Lua bridge + JSFX analyzers (copied to your REAPER resource folder)
|
|
83
83
|
- **AI Skills & Agents** — knowledge base, mix agents, rules, skills (global or project-local)
|
|
84
|
-
- **Claude Code Settings** — auto-allows all
|
|
84
|
+
- **Claude Code Settings** — auto-allows all 80 REAPER tools (no permission prompts)
|
|
85
85
|
- **Project Config** — `.mcp.json` for the current directory (opt-in)
|
|
86
86
|
|
|
87
87
|
For CI/automation, use `--yes` to skip prompts and install everything with defaults:
|
|
@@ -108,6 +108,7 @@ npx @mthines/reaper-mcp install-skills --project # project-local alternative
|
|
|
108
108
|
|
|
109
109
|
The `setup` command copies into your REAPER resource folder:
|
|
110
110
|
- `mcp_bridge.lua` — persistent Lua bridge script
|
|
111
|
+
- `mcp_snapshot_manager.lua` — snapshot manager GUI (save/restore/delete from REAPER)
|
|
111
112
|
- `mcp_analyzer.jsfx` — FFT spectrum analyzer
|
|
112
113
|
- `mcp_lufs_meter.jsfx` — ITU-R BS.1770 LUFS meter
|
|
113
114
|
- `mcp_correlation_meter.jsfx` — stereo correlation analyzer
|
|
@@ -138,7 +139,7 @@ npx @mthines/reaper-mcp doctor
|
|
|
138
139
|
|
|
139
140
|
Checks that the bridge is connected, knowledge is installed, and MCP config exists.
|
|
140
141
|
|
|
141
|
-
## MCP Tools (
|
|
142
|
+
## MCP Tools (80)
|
|
142
143
|
|
|
143
144
|
### Project & Tracks
|
|
144
145
|
|
|
@@ -250,6 +251,7 @@ Checks that the bridge is connected, knowledge is installed, and MCP config exis
|
|
|
250
251
|
| `snapshot_save` | Save current mixer state (volumes, pans, FX, mutes) |
|
|
251
252
|
| `snapshot_restore` | Restore a saved snapshot |
|
|
252
253
|
| `snapshot_list` | List all saved snapshots |
|
|
254
|
+
| `snapshot_delete` | Delete a saved snapshot by name |
|
|
253
255
|
|
|
254
256
|
### Routing
|
|
255
257
|
|
|
@@ -373,7 +375,7 @@ Processing decisions adapt to the genre:
|
|
|
373
375
|
|
|
374
376
|
## Autonomous Mode (Allow All Tools)
|
|
375
377
|
|
|
376
|
-
By default Claude Code asks permission for each MCP tool call. The `init` command (and `install-skills`) automatically configures `settings.json` to allow all
|
|
378
|
+
By default Claude Code asks permission for each MCP tool call. The `init` command (and `install-skills`) automatically configures `settings.json` to allow all 80 REAPER tools. If you need to set this up manually, add to your project's `.claude/settings.json` (or `~/.claude/settings.json` for global):
|
|
377
379
|
|
|
378
380
|
```json
|
|
379
381
|
{
|
|
@@ -404,6 +406,7 @@ By default Claude Code asks permission for each MCP tool call. The `init` comman
|
|
|
404
406
|
"mcp__reaper__snapshot_save",
|
|
405
407
|
"mcp__reaper__snapshot_restore",
|
|
406
408
|
"mcp__reaper__snapshot_list",
|
|
409
|
+
"mcp__reaper__snapshot_delete",
|
|
407
410
|
"mcp__reaper__get_track_routing",
|
|
408
411
|
"mcp__reaper__set_fx_enabled",
|
|
409
412
|
"mcp__reaper__set_fx_offline",
|
|
@@ -525,7 +528,7 @@ Platform defaults:
|
|
|
525
528
|
```
|
|
526
529
|
reaper-mcp/
|
|
527
530
|
├── apps/
|
|
528
|
-
│ ├── reaper-mcp-server/ # MCP server (
|
|
531
|
+
│ ├── reaper-mcp-server/ # MCP server (80 tools, esbuild bundle)
|
|
529
532
|
│ └── reaper-mix-agent/ # AI mix agent (knowledge loader, plugin resolver)
|
|
530
533
|
├── libs/protocol/ # Shared TypeScript types
|
|
531
534
|
├── knowledge/ # AI mix engineer knowledge base
|
|
@@ -535,6 +538,7 @@ reaper-mcp/
|
|
|
535
538
|
│ └── reference/ # Frequency, compression, metering, perceived loudness cheat sheets
|
|
536
539
|
├── reaper/ # Files installed into REAPER
|
|
537
540
|
│ ├── mcp_bridge.lua # Persistent Lua bridge
|
|
541
|
+
│ ├── mcp_snapshot_manager.lua # Snapshot manager GUI (save/restore/delete)
|
|
538
542
|
│ ├── mcp_analyzer.jsfx # FFT spectrum analyzer
|
|
539
543
|
│ ├── mcp_lufs_meter.jsfx # LUFS meter (BS.1770)
|
|
540
544
|
│ ├── mcp_correlation_meter.jsfx # Stereo correlation meter
|
package/claude-agents/mixer.md
CHANGED
|
@@ -28,7 +28,7 @@ You are a professional mix engineer with 20 years of experience working inside R
|
|
|
28
28
|
|
|
29
29
|
## Available MCP Tools
|
|
30
30
|
|
|
31
|
-
You have access to
|
|
31
|
+
You have access to 80 REAPER tools via the `reaper` MCP server:
|
|
32
32
|
|
|
33
33
|
### Session Info
|
|
34
34
|
- `get_project_info` — project name, tempo, time sig, sample rate, transport
|
|
@@ -70,6 +70,7 @@ You have access to 67 REAPER tools via the `reaper` MCP server:
|
|
|
70
70
|
- `snapshot_save` — save mixer state
|
|
71
71
|
- `snapshot_restore` — restore saved state
|
|
72
72
|
- `snapshot_list` — list all snapshots
|
|
73
|
+
- `snapshot_delete` — delete a saved snapshot
|
|
73
74
|
|
|
74
75
|
### Markers & Regions
|
|
75
76
|
- `list_markers` — all project markers (index, name, position, color)
|
package/main.js
CHANGED
|
@@ -907,6 +907,20 @@ function registerSnapshotTools(server) {
|
|
|
907
907
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
908
908
|
}
|
|
909
909
|
);
|
|
910
|
+
server.tool(
|
|
911
|
+
"snapshot_delete",
|
|
912
|
+
"Delete a named mixer snapshot. Removes the snapshot file from .reaper-mcp/snapshots/ alongside the project file (or global bridge dir for unsaved projects). This action is permanent and cannot be undone.",
|
|
913
|
+
{
|
|
914
|
+
name: z7.string().min(1).describe("Name of the snapshot to delete")
|
|
915
|
+
},
|
|
916
|
+
async ({ name }) => {
|
|
917
|
+
const res = await sendCommand("snapshot_delete", { name });
|
|
918
|
+
if (!res.success) {
|
|
919
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
|
|
920
|
+
}
|
|
921
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
922
|
+
}
|
|
923
|
+
);
|
|
910
924
|
}
|
|
911
925
|
|
|
912
926
|
// apps/reaper-mcp-server/src/tools/routing.ts
|
|
@@ -2292,7 +2306,8 @@ var REAPER_ASSETS = [
|
|
|
2292
2306
|
"mcp_analyzer.jsfx",
|
|
2293
2307
|
"mcp_lufs_meter.jsfx",
|
|
2294
2308
|
"mcp_correlation_meter.jsfx",
|
|
2295
|
-
"mcp_crest_factor.jsfx"
|
|
2309
|
+
"mcp_crest_factor.jsfx",
|
|
2310
|
+
"mcp_snapshot_manager.lua"
|
|
2296
2311
|
];
|
|
2297
2312
|
var MCP_TOOL_NAMES = [
|
|
2298
2313
|
// project
|
|
@@ -2330,6 +2345,7 @@ var MCP_TOOL_NAMES = [
|
|
|
2330
2345
|
"snapshot_save",
|
|
2331
2346
|
"snapshot_restore",
|
|
2332
2347
|
"snapshot_list",
|
|
2348
|
+
"snapshot_delete",
|
|
2333
2349
|
// routing
|
|
2334
2350
|
"get_track_routing",
|
|
2335
2351
|
// midi
|
|
@@ -2479,21 +2495,23 @@ async function runInit(opts, dirResolver) {
|
|
|
2479
2495
|
const scriptsDir = getReaperScriptsPath();
|
|
2480
2496
|
mkdirSync2(scriptsDir, { recursive: true });
|
|
2481
2497
|
const reaperDir = resolveAssetDir(__dirname2, "reaper");
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2498
|
+
for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
|
|
2499
|
+
const src = join4(reaperDir, luaFile);
|
|
2500
|
+
const dest = join4(scriptsDir, luaFile);
|
|
2501
|
+
if (installFile(src, dest)) {
|
|
2502
|
+
console.log(` Installed: ${luaFile}`);
|
|
2503
|
+
} else {
|
|
2504
|
+
console.log(` Not found: ${src}`);
|
|
2505
|
+
}
|
|
2488
2506
|
}
|
|
2489
2507
|
const effectsDir = join4(getReaperEffectsPath(), "reaper-mcp");
|
|
2490
2508
|
mkdirSync2(effectsDir, { recursive: true });
|
|
2491
|
-
for (const
|
|
2492
|
-
if (
|
|
2493
|
-
const src = join4(reaperDir,
|
|
2494
|
-
const dest = join4(effectsDir,
|
|
2509
|
+
for (const asset of REAPER_ASSETS) {
|
|
2510
|
+
if (asset.endsWith(".lua")) continue;
|
|
2511
|
+
const src = join4(reaperDir, asset);
|
|
2512
|
+
const dest = join4(effectsDir, asset);
|
|
2495
2513
|
if (installFile(src, dest)) {
|
|
2496
|
-
console.log(` Installed: reaper-mcp/${
|
|
2514
|
+
console.log(` Installed: reaper-mcp/${asset}`);
|
|
2497
2515
|
} else {
|
|
2498
2516
|
console.log(` Not found: ${src}`);
|
|
2499
2517
|
}
|
|
@@ -2596,23 +2614,25 @@ async function setup() {
|
|
|
2596
2614
|
const scriptsDir = getReaperScriptsPath();
|
|
2597
2615
|
mkdirSync3(scriptsDir, { recursive: true });
|
|
2598
2616
|
const reaperDir = resolveAssetDir(__dirname, "reaper");
|
|
2599
|
-
|
|
2600
|
-
const
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2617
|
+
console.log("Installing Lua scripts...");
|
|
2618
|
+
for (const luaFile of ["mcp_bridge.lua", "mcp_snapshot_manager.lua"]) {
|
|
2619
|
+
const src = join5(reaperDir, luaFile);
|
|
2620
|
+
const dest = join5(scriptsDir, luaFile);
|
|
2621
|
+
if (installFile(src, dest)) {
|
|
2622
|
+
console.log(` Installed: ${luaFile}`);
|
|
2623
|
+
} else {
|
|
2624
|
+
console.log(` Not found: ${src}`);
|
|
2625
|
+
}
|
|
2606
2626
|
}
|
|
2607
2627
|
const effectsDir = join5(getReaperEffectsPath(), "reaper-mcp");
|
|
2608
2628
|
mkdirSync3(effectsDir, { recursive: true });
|
|
2609
2629
|
console.log("\nInstalling JSFX analyzers...");
|
|
2610
|
-
for (const
|
|
2611
|
-
if (
|
|
2612
|
-
const src = join5(reaperDir,
|
|
2613
|
-
const dest = join5(effectsDir,
|
|
2630
|
+
for (const asset of REAPER_ASSETS) {
|
|
2631
|
+
if (asset.endsWith(".lua")) continue;
|
|
2632
|
+
const src = join5(reaperDir, asset);
|
|
2633
|
+
const dest = join5(effectsDir, asset);
|
|
2614
2634
|
if (installFile(src, dest)) {
|
|
2615
|
-
console.log(` Installed: reaper-mcp/${
|
|
2635
|
+
console.log(` Installed: reaper-mcp/${asset}`);
|
|
2616
2636
|
} else {
|
|
2617
2637
|
console.log(` Not found: ${src}`);
|
|
2618
2638
|
}
|
|
@@ -2621,7 +2641,7 @@ async function setup() {
|
|
|
2621
2641
|
console.log("Next steps:");
|
|
2622
2642
|
console.log(" 1. Open REAPER");
|
|
2623
2643
|
console.log(" 2. Actions > Show action list > Load ReaScript");
|
|
2624
|
-
console.log(` 3. Select: ${
|
|
2644
|
+
console.log(` 3. Select: ${join5(scriptsDir, "mcp_bridge.lua")}`);
|
|
2625
2645
|
console.log(" 4. Run the script (it will keep running in background via defer loop)");
|
|
2626
2646
|
console.log(" 5. Add reaper-mcp to your Claude Code config (see: npx @mthines/reaper-mcp doctor)");
|
|
2627
2647
|
}
|
package/package.json
CHANGED
package/reaper/CLAUDE.md
CHANGED
|
@@ -7,6 +7,7 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
|
|
|
7
7
|
| File | Language | Purpose |
|
|
8
8
|
|------|----------|---------|
|
|
9
9
|
| `mcp_bridge.lua` | Lua | Persistent bridge: polls for JSON commands, executes ReaScript API, writes responses |
|
|
10
|
+
| `mcp_snapshot_manager.lua` | Lua | Standalone snapshot manager GUI (gfx-based, save/restore/delete snapshots) |
|
|
10
11
|
| `mcp_analyzer.jsfx` | JSFX/EEL2 | Real-time FFT spectrum analyzer, writes to gmem[] |
|
|
11
12
|
| `mcp_lufs_meter.jsfx` | JSFX/EEL2 | LUFS loudness metering |
|
|
12
13
|
| `mcp_correlation_meter.jsfx` | JSFX/EEL2 | Stereo correlation and width analysis |
|
package/reaper/install.sh
CHANGED
|
@@ -23,11 +23,13 @@ fi
|
|
|
23
23
|
|
|
24
24
|
echo "REAPER resource path: $REAPER_PATH"
|
|
25
25
|
|
|
26
|
-
# Install Lua
|
|
26
|
+
# Install Lua scripts
|
|
27
27
|
SCRIPTS_DIR="$REAPER_PATH/Scripts"
|
|
28
28
|
mkdir -p "$SCRIPTS_DIR"
|
|
29
29
|
cp "$SCRIPT_DIR/mcp_bridge.lua" "$SCRIPTS_DIR/mcp_bridge.lua"
|
|
30
30
|
echo "Installed: $SCRIPTS_DIR/mcp_bridge.lua"
|
|
31
|
+
cp "$SCRIPT_DIR/mcp_snapshot_manager.lua" "$SCRIPTS_DIR/mcp_snapshot_manager.lua"
|
|
32
|
+
echo "Installed: $SCRIPTS_DIR/mcp_snapshot_manager.lua"
|
|
31
33
|
|
|
32
34
|
# Install JSFX analyzers
|
|
33
35
|
EFFECTS_DIR="$REAPER_PATH/Effects/reaper-mcp"
|
package/reaper/mcp_bridge.lua
CHANGED
|
@@ -1426,6 +1426,26 @@ function handlers.snapshot_list(params)
|
|
|
1426
1426
|
return { snapshots = snapshots, total = #snapshots, storageLocation = get_snapshot_storage_location() }
|
|
1427
1427
|
end
|
|
1428
1428
|
|
|
1429
|
+
function handlers.snapshot_delete(params)
|
|
1430
|
+
local name = params.name
|
|
1431
|
+
if not name or name == "" then
|
|
1432
|
+
return nil, "name required"
|
|
1433
|
+
end
|
|
1434
|
+
|
|
1435
|
+
local path = snapshot_path(name)
|
|
1436
|
+
local content = read_file(path)
|
|
1437
|
+
if not content then
|
|
1438
|
+
return nil, "Snapshot not found: " .. name
|
|
1439
|
+
end
|
|
1440
|
+
|
|
1441
|
+
local ok, err = os.remove(path)
|
|
1442
|
+
if not ok then
|
|
1443
|
+
return nil, "Failed to delete snapshot: " .. (err or "unknown error")
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
return { name = name, deleted = true }
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1429
1449
|
-- =============================================================================
|
|
1430
1450
|
-- Routing handler
|
|
1431
1451
|
-- =============================================================================
|
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MCP Snapshot Manager for REAPER
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
-- A standalone gfx.* UI for managing mixer snapshots created by the MCP server.
|
|
5
|
+
-- Uses only built-in REAPER APIs (no ReaImGui, no SWS required).
|
|
6
|
+
--
|
|
7
|
+
-- Install: Actions > Show action list > Load ReaScript > select this file > Run
|
|
8
|
+
-- =============================================================================
|
|
9
|
+
|
|
10
|
+
local TITLE = "MCP Snapshot Manager"
|
|
11
|
+
local WIN_W = 820
|
|
12
|
+
local WIN_H = 520
|
|
13
|
+
local PADDING = 10
|
|
14
|
+
local ROW_H = 26
|
|
15
|
+
local HEADER_H = 34
|
|
16
|
+
local BUTTON_H = 32
|
|
17
|
+
local BUTTON_W = 120
|
|
18
|
+
local FOOTER_H = BUTTON_H + PADDING * 2
|
|
19
|
+
local STATUS_H = 22
|
|
20
|
+
local LIST_TOP = HEADER_H + PADDING
|
|
21
|
+
local LIST_BOTTOM_MARGIN = FOOTER_H + STATUS_H + PADDING
|
|
22
|
+
local SCROLL_BAR_W = 14
|
|
23
|
+
|
|
24
|
+
-- Column widths
|
|
25
|
+
local COL_NAME_W = 220
|
|
26
|
+
local COL_DESC_W = 280
|
|
27
|
+
local COL_DATE_W = 160
|
|
28
|
+
local COL_TRACKS_W = 60
|
|
29
|
+
|
|
30
|
+
-- Colors (R, G, B, A as 0-1 floats)
|
|
31
|
+
local C_BG = { 0.13, 0.13, 0.15, 1.0 }
|
|
32
|
+
local C_HEADER_BG = { 0.18, 0.18, 0.22, 1.0 }
|
|
33
|
+
local C_ROW_EVEN = { 0.15, 0.15, 0.18, 1.0 }
|
|
34
|
+
local C_ROW_ODD = { 0.17, 0.17, 0.20, 1.0 }
|
|
35
|
+
local C_ROW_SEL = { 0.20, 0.38, 0.60, 1.0 }
|
|
36
|
+
local C_ROW_HOVER = { 0.22, 0.22, 0.28, 1.0 }
|
|
37
|
+
local C_TEXT = { 0.90, 0.90, 0.90, 1.0 }
|
|
38
|
+
local C_TEXT_DIM = { 0.55, 0.55, 0.60, 1.0 }
|
|
39
|
+
local C_TEXT_SEL = { 1.00, 1.00, 1.00, 1.0 }
|
|
40
|
+
local C_TEXT_HEADER = { 0.75, 0.75, 0.80, 1.0 }
|
|
41
|
+
local C_BTN_NORMAL = { 0.25, 0.25, 0.30, 1.0 }
|
|
42
|
+
local C_BTN_HOVER = { 0.30, 0.45, 0.65, 1.0 }
|
|
43
|
+
local C_BTN_PRESS = { 0.20, 0.35, 0.55, 1.0 }
|
|
44
|
+
local C_BTN_DELETE = { 0.55, 0.20, 0.20, 1.0 }
|
|
45
|
+
local C_BTN_DEL_HOV = { 0.70, 0.25, 0.25, 1.0 }
|
|
46
|
+
local C_BTN_RESTORE = { 0.20, 0.50, 0.25, 1.0 }
|
|
47
|
+
local C_BTN_RES_HOV = { 0.25, 0.62, 0.30, 1.0 }
|
|
48
|
+
local C_DIVIDER = { 0.30, 0.30, 0.35, 1.0 }
|
|
49
|
+
local C_SCROLLBAR = { 0.35, 0.35, 0.40, 1.0 }
|
|
50
|
+
local C_SCROLLTHUMB = { 0.50, 0.50, 0.58, 1.0 }
|
|
51
|
+
local C_STATUS_OK = { 0.35, 0.75, 0.40, 1.0 }
|
|
52
|
+
local C_STATUS_ERR = { 0.85, 0.35, 0.35, 1.0 }
|
|
53
|
+
local C_STATUS_INFO = { 0.65, 0.65, 0.70, 1.0 }
|
|
54
|
+
|
|
55
|
+
-- =============================================================================
|
|
56
|
+
-- State
|
|
57
|
+
-- =============================================================================
|
|
58
|
+
|
|
59
|
+
local snapshots = {} -- list of snapshot tables
|
|
60
|
+
local selected_idx = 0 -- 1-based, 0 = none
|
|
61
|
+
local scroll_offset = 0 -- rows scrolled from top
|
|
62
|
+
local hover_row = 0 -- 1-based row under mouse, 0 = none
|
|
63
|
+
local hover_btn = nil -- "save" | "restore" | "delete" | "refresh" | "close"
|
|
64
|
+
local btn_pressed = nil
|
|
65
|
+
local status_msg = ""
|
|
66
|
+
local status_type = "info" -- "ok" | "err" | "info"
|
|
67
|
+
local status_timer = 0
|
|
68
|
+
local last_mouse_x = 0
|
|
69
|
+
local last_mouse_y = 0
|
|
70
|
+
local last_mouse_cap = 0
|
|
71
|
+
local mouse_was_down = false
|
|
72
|
+
local is_running = true
|
|
73
|
+
|
|
74
|
+
-- =============================================================================
|
|
75
|
+
-- Path helpers (duplicated from mcp_bridge.lua — standalone script)
|
|
76
|
+
-- =============================================================================
|
|
77
|
+
|
|
78
|
+
local function get_snapshot_dir()
|
|
79
|
+
local proj_path = reaper.GetProjectPath()
|
|
80
|
+
if proj_path and proj_path ~= "" then
|
|
81
|
+
return proj_path .. "/.reaper-mcp/snapshots/"
|
|
82
|
+
end
|
|
83
|
+
-- Fallback for unsaved projects: use bridge data dir
|
|
84
|
+
return reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/snapshots/"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local function ensure_snapshot_dir()
|
|
88
|
+
reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
local function snapshot_path(name)
|
|
92
|
+
local safe = name:gsub("[^%w%-_%.%s]", "_"):gsub("%s+", "_")
|
|
93
|
+
return get_snapshot_dir() .. safe .. ".json"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
-- =============================================================================
|
|
97
|
+
-- Minimal JSON helpers (enough for snapshot format)
|
|
98
|
+
-- =============================================================================
|
|
99
|
+
|
|
100
|
+
local function json_encode_string(s)
|
|
101
|
+
s = tostring(s)
|
|
102
|
+
s = s:gsub('\\', '\\\\')
|
|
103
|
+
s = s:gsub('"', '\\"')
|
|
104
|
+
s = s:gsub('\n', '\\n')
|
|
105
|
+
s = s:gsub('\r', '\\r')
|
|
106
|
+
s = s:gsub('\t', '\\t')
|
|
107
|
+
return '"' .. s .. '"'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
local function json_encode(val)
|
|
111
|
+
local t = type(val)
|
|
112
|
+
if t == "nil" then
|
|
113
|
+
return "null"
|
|
114
|
+
elseif t == "boolean" then
|
|
115
|
+
return val and "true" or "false"
|
|
116
|
+
elseif t == "number" then
|
|
117
|
+
if val ~= val then return "null" end -- NaN
|
|
118
|
+
return tostring(val)
|
|
119
|
+
elseif t == "string" then
|
|
120
|
+
return json_encode_string(val)
|
|
121
|
+
elseif t == "table" then
|
|
122
|
+
-- Check if array
|
|
123
|
+
local is_array = true
|
|
124
|
+
local max_i = 0
|
|
125
|
+
for k, _ in pairs(val) do
|
|
126
|
+
if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
|
|
127
|
+
is_array = false
|
|
128
|
+
break
|
|
129
|
+
end
|
|
130
|
+
if k > max_i then max_i = k end
|
|
131
|
+
end
|
|
132
|
+
if is_array and max_i == #val then
|
|
133
|
+
local parts = {}
|
|
134
|
+
for i = 1, #val do
|
|
135
|
+
parts[i] = json_encode(val[i])
|
|
136
|
+
end
|
|
137
|
+
return "[" .. table.concat(parts, ",") .. "]"
|
|
138
|
+
else
|
|
139
|
+
local parts = {}
|
|
140
|
+
for k, v in pairs(val) do
|
|
141
|
+
parts[#parts + 1] = json_encode_string(tostring(k)) .. ":" .. json_encode(v)
|
|
142
|
+
end
|
|
143
|
+
return "{" .. table.concat(parts, ",") .. "}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
return "null"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
-- Extract a string value from JSON (handles escape sequences)
|
|
150
|
+
local function extract_json_string(str, pos)
|
|
151
|
+
local result = {}
|
|
152
|
+
local i = pos
|
|
153
|
+
while i <= #str do
|
|
154
|
+
local ch = str:sub(i, i)
|
|
155
|
+
if ch == '\\' then
|
|
156
|
+
local next_ch = str:sub(i + 1, i + 1)
|
|
157
|
+
if next_ch == '"' then result[#result + 1] = '"'
|
|
158
|
+
elseif next_ch == '\\' then result[#result + 1] = '\\'
|
|
159
|
+
elseif next_ch == 'n' then result[#result + 1] = '\n'
|
|
160
|
+
elseif next_ch == 't' then result[#result + 1] = '\t'
|
|
161
|
+
elseif next_ch == '/' then result[#result + 1] = '/'
|
|
162
|
+
else result[#result + 1] = next_ch
|
|
163
|
+
end
|
|
164
|
+
i = i + 2
|
|
165
|
+
elseif ch == '"' then
|
|
166
|
+
return table.concat(result), i + 1
|
|
167
|
+
else
|
|
168
|
+
result[#result + 1] = ch
|
|
169
|
+
i = i + 1
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
return nil, i
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
-- Parse a flat JSON object (sufficient for top-level snapshot metadata)
|
|
176
|
+
local function parse_flat_object(str)
|
|
177
|
+
local obj = {}
|
|
178
|
+
local i = 1
|
|
179
|
+
while i <= #str do
|
|
180
|
+
local key_start = str:find('"', i)
|
|
181
|
+
if not key_start then break end
|
|
182
|
+
local key, after_key = extract_json_string(str, key_start + 1)
|
|
183
|
+
if not key then break end
|
|
184
|
+
i = after_key
|
|
185
|
+
|
|
186
|
+
local colon = str:find(':', i)
|
|
187
|
+
if not colon then break end
|
|
188
|
+
local val_start = str:match('^%s*()', colon + 1)
|
|
189
|
+
|
|
190
|
+
local ch = str:sub(val_start, val_start)
|
|
191
|
+
if ch == '"' then
|
|
192
|
+
local val, next_pos = extract_json_string(str, val_start + 1)
|
|
193
|
+
if val then
|
|
194
|
+
obj[key] = val
|
|
195
|
+
i = next_pos
|
|
196
|
+
else
|
|
197
|
+
i = val_start + 1
|
|
198
|
+
end
|
|
199
|
+
elseif ch == '{' or ch == '[' then
|
|
200
|
+
-- Skip nested objects/arrays — not needed for top-level metadata
|
|
201
|
+
local depth = 1
|
|
202
|
+
local open_c = ch
|
|
203
|
+
local close_c = ch == '{' and '}' or ']'
|
|
204
|
+
local j = val_start + 1
|
|
205
|
+
while j <= #str and depth > 0 do
|
|
206
|
+
local c = str:sub(j, j)
|
|
207
|
+
if c == open_c then depth = depth + 1
|
|
208
|
+
elseif c == close_c then depth = depth - 1 end
|
|
209
|
+
j = j + 1
|
|
210
|
+
end
|
|
211
|
+
-- Store raw (won't be used for top-level display)
|
|
212
|
+
i = j
|
|
213
|
+
else
|
|
214
|
+
-- Number, boolean, null
|
|
215
|
+
local val_end = str:find('[,}%]]', val_start)
|
|
216
|
+
if not val_end then val_end = #str + 1 end
|
|
217
|
+
local raw = str:sub(val_start, val_end - 1):match("^%s*(.-)%s*$")
|
|
218
|
+
if raw == "true" then obj[key] = true
|
|
219
|
+
elseif raw == "false" then obj[key] = false
|
|
220
|
+
elseif raw == "null" then obj[key] = nil
|
|
221
|
+
else obj[key] = tonumber(raw)
|
|
222
|
+
end
|
|
223
|
+
i = val_end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
return obj
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
-- Count tracks in mixerState.tracks array (rough count from string)
|
|
230
|
+
local function count_tracks_in_snapshot(content)
|
|
231
|
+
local mixer_start = content:find('"mixerState"')
|
|
232
|
+
if not mixer_start then return nil end
|
|
233
|
+
local tracks_start = content:find('"tracks"', mixer_start)
|
|
234
|
+
if not tracks_start then return nil end
|
|
235
|
+
local arr_start = content:find('%[', tracks_start)
|
|
236
|
+
if not arr_start then return nil end
|
|
237
|
+
-- Count top-level objects in the array
|
|
238
|
+
local count = 0
|
|
239
|
+
local depth = 0
|
|
240
|
+
local i = arr_start
|
|
241
|
+
while i <= #arr_start + 50000 and i <= #content do
|
|
242
|
+
local ch = content:sub(i, i)
|
|
243
|
+
if ch == '[' or ch == '{' then
|
|
244
|
+
depth = depth + 1
|
|
245
|
+
if ch == '{' and depth == 2 then count = count + 1 end
|
|
246
|
+
elseif ch == ']' or ch == '}' then
|
|
247
|
+
depth = depth - 1
|
|
248
|
+
if depth == 0 then break end
|
|
249
|
+
end
|
|
250
|
+
i = i + 1
|
|
251
|
+
end
|
|
252
|
+
return count > 0 and count or nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
-- =============================================================================
|
|
256
|
+
-- Snapshot I/O
|
|
257
|
+
-- =============================================================================
|
|
258
|
+
|
|
259
|
+
local function read_file(path)
|
|
260
|
+
local f = io.open(path, "rb")
|
|
261
|
+
if not f then return nil end
|
|
262
|
+
local content = f:read("*a")
|
|
263
|
+
f:close()
|
|
264
|
+
return content
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
local function write_file(path, content)
|
|
268
|
+
local f = io.open(path, "wb")
|
|
269
|
+
if not f then return false end
|
|
270
|
+
f:write(content)
|
|
271
|
+
f:close()
|
|
272
|
+
return true
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
local function load_snapshots()
|
|
276
|
+
ensure_snapshot_dir()
|
|
277
|
+
local snap_dir = get_snapshot_dir()
|
|
278
|
+
local result = {}
|
|
279
|
+
|
|
280
|
+
local i = 0
|
|
281
|
+
while true do
|
|
282
|
+
local fn = reaper.EnumerateFiles(snap_dir, i)
|
|
283
|
+
if not fn then break end
|
|
284
|
+
if fn:match("%.json$") then
|
|
285
|
+
local path = snap_dir .. fn
|
|
286
|
+
local content = read_file(path)
|
|
287
|
+
if content then
|
|
288
|
+
local ok, snap = pcall(parse_flat_object, content)
|
|
289
|
+
if ok and snap and snap.name then
|
|
290
|
+
-- Try to count tracks
|
|
291
|
+
local track_count = nil
|
|
292
|
+
pcall(function()
|
|
293
|
+
track_count = count_tracks_in_snapshot(content)
|
|
294
|
+
end)
|
|
295
|
+
result[#result + 1] = {
|
|
296
|
+
name = snap.name,
|
|
297
|
+
description = snap.description or "",
|
|
298
|
+
timestamp = tonumber(snap.timestamp) or 0,
|
|
299
|
+
trackCount = track_count,
|
|
300
|
+
path = path,
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
i = i + 1
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
-- Sort by timestamp descending (newest first)
|
|
309
|
+
table.sort(result, function(a, b) return a.timestamp > b.timestamp end)
|
|
310
|
+
return result
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
local function format_timestamp(ts_ms)
|
|
314
|
+
if not ts_ms or ts_ms == 0 then return "Unknown" end
|
|
315
|
+
local ts = math.floor(ts_ms / 1000)
|
|
316
|
+
return os.date("%Y-%m-%d %H:%M", ts)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
-- =============================================================================
|
|
320
|
+
-- Capture mixer state (standalone — duplicated from bridge for self-containment)
|
|
321
|
+
-- =============================================================================
|
|
322
|
+
|
|
323
|
+
local function capture_mixer_state()
|
|
324
|
+
local state = { version = 2, tracks = {} }
|
|
325
|
+
local count = reaper.CountTracks(0)
|
|
326
|
+
for i = 0, count - 1 do
|
|
327
|
+
local track = reaper.GetTrack(0, i)
|
|
328
|
+
local _, name = reaper.GetTrackName(track)
|
|
329
|
+
local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
|
|
330
|
+
local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
|
|
331
|
+
local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
|
|
332
|
+
local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
|
|
333
|
+
local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
|
|
334
|
+
|
|
335
|
+
local fx_count = reaper.TrackFX_GetCount(track)
|
|
336
|
+
local fx_states = {}
|
|
337
|
+
for j = 0, fx_count - 1 do
|
|
338
|
+
local enabled = reaper.TrackFX_GetEnabled(track, j)
|
|
339
|
+
local offline = reaper.TrackFX_GetOffline(track, j)
|
|
340
|
+
local _, preset = reaper.TrackFX_GetPreset(track, j)
|
|
341
|
+
local _, fx_name = reaper.TrackFX_GetFXName(track, j)
|
|
342
|
+
local params_data = {}
|
|
343
|
+
local param_count = reaper.TrackFX_GetNumParams(track, j)
|
|
344
|
+
local limit = math.min(param_count, 500)
|
|
345
|
+
for p = 0, limit - 1 do
|
|
346
|
+
params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
|
|
347
|
+
end
|
|
348
|
+
fx_states[j + 1] = {
|
|
349
|
+
name = fx_name, enabled = enabled, offline = offline,
|
|
350
|
+
preset = preset or "", params = params_data,
|
|
351
|
+
}
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
355
|
+
local sends = {}
|
|
356
|
+
for s = 0, send_count - 1 do
|
|
357
|
+
local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
358
|
+
local dest_idx = -1
|
|
359
|
+
local dest_name = ""
|
|
360
|
+
if dest_track then
|
|
361
|
+
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
362
|
+
local _, dname = reaper.GetTrackName(dest_track)
|
|
363
|
+
dest_name = dname or ""
|
|
364
|
+
end
|
|
365
|
+
sends[s + 1] = {
|
|
366
|
+
destTrackIndex = dest_idx, destTrackName = dest_name,
|
|
367
|
+
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
368
|
+
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
369
|
+
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
370
|
+
}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
local fx_enabled = {}
|
|
374
|
+
for j = 1, #fx_states do fx_enabled[j] = fx_states[j].enabled end
|
|
375
|
+
|
|
376
|
+
state.tracks[i + 1] = {
|
|
377
|
+
index = i, name = name, color = color,
|
|
378
|
+
volume = vol, pan = pan,
|
|
379
|
+
mute = mute ~= 0, solo = solo ~= 0,
|
|
380
|
+
fx = fx_states, sends = sends, fxEnabled = fx_enabled,
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
return state
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
local function do_save_snapshot(name, description)
|
|
387
|
+
ensure_snapshot_dir()
|
|
388
|
+
local timestamp = os.time() * 1000
|
|
389
|
+
local snapshot = {
|
|
390
|
+
name = name,
|
|
391
|
+
description = description or "",
|
|
392
|
+
timestamp = timestamp,
|
|
393
|
+
mixerState = capture_mixer_state(),
|
|
394
|
+
}
|
|
395
|
+
local path = snapshot_path(name)
|
|
396
|
+
local ok = write_file(path, json_encode(snapshot))
|
|
397
|
+
if not ok then
|
|
398
|
+
return false, "Failed to write file: " .. path
|
|
399
|
+
end
|
|
400
|
+
return true, nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
local function do_restore_snapshot(snap)
|
|
404
|
+
local content = read_file(snap.path)
|
|
405
|
+
if not content then
|
|
406
|
+
return false, "File not found: " .. snap.path
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
-- We need deeper parsing for restore — use the full flat parser on the file
|
|
410
|
+
-- For simplicity, show a message and use the MCP bridge if available;
|
|
411
|
+
-- otherwise we do a best-effort restore.
|
|
412
|
+
local ok_parse, parsed = pcall(parse_flat_object, content)
|
|
413
|
+
if not ok_parse or not parsed then
|
|
414
|
+
return false, "Could not parse snapshot file"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
-- The mixerState is a nested object — not directly accessible via parse_flat_object.
|
|
418
|
+
-- We'll do a targeted string extraction of the "mixerState" block and parse it.
|
|
419
|
+
-- For v2 snapshots this is complex; we perform a simplified restore of
|
|
420
|
+
-- volume/pan/mute/solo from a targeted parse.
|
|
421
|
+
local mixer_block_start = content:find('"mixerState"%s*:%s*{')
|
|
422
|
+
if not mixer_block_start then
|
|
423
|
+
return false, "No mixerState found in snapshot"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
-- Find "tracks" array
|
|
427
|
+
local tracks_start = content:find('"tracks"%s*:%s*%[', mixer_block_start)
|
|
428
|
+
if not tracks_start then
|
|
429
|
+
return false, "No tracks found in snapshot"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
local arr_open = content:find('%[', tracks_start)
|
|
433
|
+
if not arr_open then return false, "Invalid tracks format" end
|
|
434
|
+
|
|
435
|
+
-- Extract each track object
|
|
436
|
+
local track_objects = {}
|
|
437
|
+
local depth = 0
|
|
438
|
+
local obj_start = nil
|
|
439
|
+
local i = arr_open
|
|
440
|
+
|
|
441
|
+
while i <= #content do
|
|
442
|
+
local ch = content:sub(i, i)
|
|
443
|
+
if ch == '{' then
|
|
444
|
+
depth = depth + 1
|
|
445
|
+
if depth == 1 then obj_start = i end
|
|
446
|
+
elseif ch == '}' then
|
|
447
|
+
depth = depth - 1
|
|
448
|
+
if depth == 0 and obj_start then
|
|
449
|
+
track_objects[#track_objects + 1] = content:sub(obj_start, i)
|
|
450
|
+
obj_start = nil
|
|
451
|
+
end
|
|
452
|
+
elseif ch == ']' and depth == 0 then
|
|
453
|
+
break
|
|
454
|
+
end
|
|
455
|
+
i = i + 1
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
if #track_objects == 0 then
|
|
459
|
+
return false, "No track data found in snapshot"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
reaper.Undo_BeginBlock()
|
|
463
|
+
local restored = 0
|
|
464
|
+
|
|
465
|
+
for _, track_json in ipairs(track_objects) do
|
|
466
|
+
local ok_t, track_data = pcall(parse_flat_object, track_json)
|
|
467
|
+
if ok_t and track_data and track_data.index then
|
|
468
|
+
local track_idx = tonumber(track_data.index)
|
|
469
|
+
local track = reaper.GetTrack(0, track_idx)
|
|
470
|
+
if track then
|
|
471
|
+
if track_data.volume then
|
|
472
|
+
reaper.SetMediaTrackInfo_Value(track, "D_VOL", tonumber(track_data.volume))
|
|
473
|
+
end
|
|
474
|
+
if track_data.pan then
|
|
475
|
+
reaper.SetMediaTrackInfo_Value(track, "D_PAN", tonumber(track_data.pan))
|
|
476
|
+
end
|
|
477
|
+
if track_data.mute ~= nil then
|
|
478
|
+
local mute_val = (track_data.mute == true or track_data.mute == "true") and 1 or 0
|
|
479
|
+
reaper.SetMediaTrackInfo_Value(track, "B_MUTE", mute_val)
|
|
480
|
+
end
|
|
481
|
+
if track_data.solo ~= nil then
|
|
482
|
+
local solo_val = (track_data.solo == true or track_data.solo == "true") and 1 or 0
|
|
483
|
+
reaper.SetMediaTrackInfo_Value(track, "I_SOLO", solo_val)
|
|
484
|
+
end
|
|
485
|
+
restored = restored + 1
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
reaper.Undo_EndBlock("MCP Snapshot Manager: Restore '" .. snap.name .. "'", -1)
|
|
491
|
+
reaper.TrackList_AdjustWindows(false)
|
|
492
|
+
reaper.UpdateArrange()
|
|
493
|
+
|
|
494
|
+
return true, nil, restored
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
local function do_delete_snapshot(snap)
|
|
498
|
+
local ok, err = os.remove(snap.path)
|
|
499
|
+
if not ok then
|
|
500
|
+
return false, "Failed to delete: " .. (err or "unknown error")
|
|
501
|
+
end
|
|
502
|
+
return true, nil
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
-- =============================================================================
|
|
506
|
+
-- UI helpers
|
|
507
|
+
-- =============================================================================
|
|
508
|
+
|
|
509
|
+
local function set_color(c)
|
|
510
|
+
gfx.r, gfx.g, gfx.b, gfx.a = c[1], c[2], c[3], c[4]
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
local function fill_rect(x, y, w, h)
|
|
514
|
+
gfx.rect(x, y, w, h, 1)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
local function draw_rect(x, y, w, h)
|
|
518
|
+
gfx.rect(x, y, w, h, 0)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
local function draw_text(text, x, y, color, align_right, max_w)
|
|
522
|
+
set_color(color or C_TEXT)
|
|
523
|
+
if max_w and max_w > 0 then
|
|
524
|
+
-- Clip text to max_w (simple truncation)
|
|
525
|
+
while #text > 1 do
|
|
526
|
+
local tw, _ = gfx.measurestr(text)
|
|
527
|
+
if tw <= max_w then break end
|
|
528
|
+
text = text:sub(1, -2)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
if align_right then
|
|
532
|
+
local tw, _ = gfx.measurestr(text)
|
|
533
|
+
x = x - tw
|
|
534
|
+
end
|
|
535
|
+
gfx.x, gfx.y = x, y
|
|
536
|
+
gfx.drawstr(text)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
local function draw_button(label, x, y, w, h, bg_normal, bg_hover, is_hover, is_pressed)
|
|
540
|
+
local bg = bg_normal
|
|
541
|
+
if is_pressed then bg = C_BTN_PRESS
|
|
542
|
+
elseif is_hover then bg = bg_hover or C_BTN_HOVER
|
|
543
|
+
end
|
|
544
|
+
set_color(bg)
|
|
545
|
+
fill_rect(x, y, w, h)
|
|
546
|
+
-- Border
|
|
547
|
+
set_color(C_DIVIDER)
|
|
548
|
+
draw_rect(x, y, w, h)
|
|
549
|
+
-- Label centered
|
|
550
|
+
local tw, th = gfx.measurestr(label)
|
|
551
|
+
draw_text(label, x + math.floor((w - tw) / 2), y + math.floor((h - th) / 2), C_TEXT_SEL)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
local function point_in_rect(px, py, rx, ry, rw, rh)
|
|
555
|
+
return px >= rx and px < rx + rw and py >= ry and py < ry + rh
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
-- =============================================================================
|
|
559
|
+
-- Layout calculations
|
|
560
|
+
-- =============================================================================
|
|
561
|
+
|
|
562
|
+
local function get_list_rect()
|
|
563
|
+
local w = gfx.w
|
|
564
|
+
local h = gfx.h
|
|
565
|
+
local list_x = PADDING
|
|
566
|
+
local list_y = LIST_TOP
|
|
567
|
+
local list_w = w - PADDING * 2
|
|
568
|
+
local list_h = h - LIST_TOP - LIST_BOTTOM_MARGIN - PADDING
|
|
569
|
+
return list_x, list_y, list_w, list_h
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
local function get_visible_rows(list_h)
|
|
573
|
+
return math.floor(list_h / ROW_H)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
local function get_button_rects()
|
|
577
|
+
local h = gfx.h
|
|
578
|
+
local w = gfx.w
|
|
579
|
+
local btn_y = h - FOOTER_H + math.floor((FOOTER_H - BUTTON_H) / 2)
|
|
580
|
+
local btns = {}
|
|
581
|
+
local x = PADDING
|
|
582
|
+
|
|
583
|
+
btns.save = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Save New" }
|
|
584
|
+
x = x + BUTTON_W + PADDING
|
|
585
|
+
|
|
586
|
+
btns.restore = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Restore" }
|
|
587
|
+
x = x + BUTTON_W + PADDING
|
|
588
|
+
|
|
589
|
+
btns.delete = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Delete" }
|
|
590
|
+
x = x + BUTTON_W + PADDING
|
|
591
|
+
|
|
592
|
+
btns.refresh = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Refresh" }
|
|
593
|
+
|
|
594
|
+
-- Close button on right side
|
|
595
|
+
btns.close = { x = w - BUTTON_W - PADDING, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Close" }
|
|
596
|
+
|
|
597
|
+
return btns
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
-- =============================================================================
|
|
601
|
+
-- Status message helpers
|
|
602
|
+
-- =============================================================================
|
|
603
|
+
|
|
604
|
+
local function set_status(msg, stype, duration)
|
|
605
|
+
status_msg = msg
|
|
606
|
+
status_type = stype or "info"
|
|
607
|
+
status_timer = reaper.time_precise() + (duration or 4.0)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
-- =============================================================================
|
|
611
|
+
-- Main draw function
|
|
612
|
+
-- =============================================================================
|
|
613
|
+
|
|
614
|
+
local function draw()
|
|
615
|
+
local w = gfx.w
|
|
616
|
+
local h = gfx.h
|
|
617
|
+
local mx = gfx.mouse_x
|
|
618
|
+
local my = gfx.mouse_y
|
|
619
|
+
local mouse_cap = gfx.mouse_cap
|
|
620
|
+
local mouse_down = (mouse_cap & 1) ~= 0
|
|
621
|
+
|
|
622
|
+
-- Background
|
|
623
|
+
set_color(C_BG)
|
|
624
|
+
fill_rect(0, 0, w, h)
|
|
625
|
+
|
|
626
|
+
-- Header bar
|
|
627
|
+
set_color(C_HEADER_BG)
|
|
628
|
+
fill_rect(0, 0, w, HEADER_H)
|
|
629
|
+
gfx.setfont(1, "Arial", 16, string.byte('b'))
|
|
630
|
+
draw_text(TITLE, PADDING, math.floor((HEADER_H - 16) / 2), C_TEXT_SEL)
|
|
631
|
+
gfx.setfont(1, "Arial", 12, 0)
|
|
632
|
+
|
|
633
|
+
-- Snapshot dir info
|
|
634
|
+
local snap_dir = get_snapshot_dir()
|
|
635
|
+
local dir_label = "Dir: " .. snap_dir
|
|
636
|
+
draw_text(dir_label, PADDING + 200, math.floor((HEADER_H - 12) / 2) + 2, C_TEXT_DIM, false, w - 220 - PADDING)
|
|
637
|
+
|
|
638
|
+
-- Column headers
|
|
639
|
+
local lx, ly, lw, lh = get_list_rect()
|
|
640
|
+
local col_header_y = ly - ROW_H - 2
|
|
641
|
+
set_color(C_HEADER_BG)
|
|
642
|
+
fill_rect(lx, col_header_y, lw - SCROLL_BAR_W, ROW_H)
|
|
643
|
+
gfx.setfont(1, "Arial", 11, string.byte('b'))
|
|
644
|
+
local cx = lx + 4
|
|
645
|
+
draw_text("Name", cx, col_header_y + 7, C_TEXT_HEADER)
|
|
646
|
+
cx = cx + COL_NAME_W
|
|
647
|
+
draw_text("Description", cx, col_header_y + 7, C_TEXT_HEADER)
|
|
648
|
+
cx = cx + COL_DESC_W
|
|
649
|
+
draw_text("Saved", cx, col_header_y + 7, C_TEXT_HEADER)
|
|
650
|
+
cx = cx + COL_DATE_W
|
|
651
|
+
draw_text("Tracks", cx, col_header_y + 7, C_TEXT_HEADER)
|
|
652
|
+
gfx.setfont(1, "Arial", 12, 0)
|
|
653
|
+
|
|
654
|
+
-- Divider under col headers
|
|
655
|
+
set_color(C_DIVIDER)
|
|
656
|
+
fill_rect(lx, col_header_y + ROW_H - 1, lw, 1)
|
|
657
|
+
|
|
658
|
+
-- Snapshot list
|
|
659
|
+
local visible_rows = get_visible_rows(lh)
|
|
660
|
+
local total_rows = #snapshots
|
|
661
|
+
|
|
662
|
+
-- Clamp scroll
|
|
663
|
+
local max_scroll = math.max(0, total_rows - visible_rows)
|
|
664
|
+
if scroll_offset > max_scroll then scroll_offset = max_scroll end
|
|
665
|
+
if scroll_offset < 0 then scroll_offset = 0 end
|
|
666
|
+
|
|
667
|
+
hover_row = 0
|
|
668
|
+
|
|
669
|
+
for row = 1, visible_rows do
|
|
670
|
+
local snap_idx = row + scroll_offset
|
|
671
|
+
local ry = ly + (row - 1) * ROW_H
|
|
672
|
+
if ry + ROW_H > ly + lh then break end
|
|
673
|
+
|
|
674
|
+
local row_w = lw - SCROLL_BAR_W
|
|
675
|
+
|
|
676
|
+
if snap_idx <= total_rows then
|
|
677
|
+
local snap = snapshots[snap_idx]
|
|
678
|
+
local is_sel = snap_idx == selected_idx
|
|
679
|
+
local is_hov = point_in_rect(mx, my, lx, ry, row_w, ROW_H)
|
|
680
|
+
|
|
681
|
+
if is_hov then hover_row = snap_idx end
|
|
682
|
+
|
|
683
|
+
-- Row background
|
|
684
|
+
if is_sel then set_color(C_ROW_SEL)
|
|
685
|
+
elseif is_hov then set_color(C_ROW_HOVER)
|
|
686
|
+
elseif row % 2 == 0 then set_color(C_ROW_EVEN)
|
|
687
|
+
else set_color(C_ROW_ODD)
|
|
688
|
+
end
|
|
689
|
+
fill_rect(lx, ry, row_w, ROW_H)
|
|
690
|
+
|
|
691
|
+
-- Row content
|
|
692
|
+
local text_color = is_sel and C_TEXT_SEL or C_TEXT
|
|
693
|
+
local dim_color = is_sel and C_TEXT_SEL or C_TEXT_DIM
|
|
694
|
+
local text_y = ry + math.floor((ROW_H - 12) / 2)
|
|
695
|
+
|
|
696
|
+
local cell_x = lx + 4
|
|
697
|
+
draw_text(snap.name, cell_x, text_y, text_color, false, COL_NAME_W - 8)
|
|
698
|
+
|
|
699
|
+
cell_x = cell_x + COL_NAME_W
|
|
700
|
+
draw_text(snap.description, cell_x, text_y, dim_color, false, COL_DESC_W - 8)
|
|
701
|
+
|
|
702
|
+
cell_x = cell_x + COL_DESC_W
|
|
703
|
+
draw_text(format_timestamp(snap.timestamp), cell_x, text_y, dim_color, false, COL_DATE_W - 8)
|
|
704
|
+
|
|
705
|
+
cell_x = cell_x + COL_DATE_W
|
|
706
|
+
local tc_str = snap.trackCount and tostring(snap.trackCount) or "-"
|
|
707
|
+
draw_text(tc_str, cell_x, text_y, dim_color)
|
|
708
|
+
|
|
709
|
+
-- Row separator
|
|
710
|
+
set_color(C_DIVIDER)
|
|
711
|
+
fill_rect(lx, ry + ROW_H - 1, row_w, 1)
|
|
712
|
+
else
|
|
713
|
+
-- Empty row
|
|
714
|
+
local bg = (row % 2 == 0) and C_ROW_EVEN or C_ROW_ODD
|
|
715
|
+
set_color(bg)
|
|
716
|
+
fill_rect(lx, ry, row_w, ROW_H)
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
-- Empty state message
|
|
721
|
+
if total_rows == 0 then
|
|
722
|
+
local msg = "No snapshots found. Use 'Save New' to create one."
|
|
723
|
+
gfx.setfont(1, "Arial", 13, 0)
|
|
724
|
+
local tw, _ = gfx.measurestr(msg)
|
|
725
|
+
draw_text(msg, lx + math.floor((lw - tw) / 2), ly + math.floor(lh / 2) - 8, C_TEXT_DIM)
|
|
726
|
+
gfx.setfont(1, "Arial", 12, 0)
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
-- Scrollbar
|
|
730
|
+
if total_rows > visible_rows then
|
|
731
|
+
local sb_x = lx + lw - SCROLL_BAR_W
|
|
732
|
+
local sb_y = ly
|
|
733
|
+
local sb_h = lh
|
|
734
|
+
|
|
735
|
+
set_color(C_SCROLLBAR)
|
|
736
|
+
fill_rect(sb_x, sb_y, SCROLL_BAR_W, sb_h)
|
|
737
|
+
|
|
738
|
+
local thumb_h = math.max(20, math.floor(sb_h * visible_rows / total_rows))
|
|
739
|
+
local thumb_y = sb_y + math.floor((sb_h - thumb_h) * scroll_offset / math.max(1, max_scroll))
|
|
740
|
+
|
|
741
|
+
set_color(C_SCROLLTHUMB)
|
|
742
|
+
fill_rect(sb_x + 2, thumb_y + 1, SCROLL_BAR_W - 4, thumb_h - 2)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
-- Footer divider
|
|
746
|
+
set_color(C_DIVIDER)
|
|
747
|
+
fill_rect(0, h - FOOTER_H - STATUS_H, w, 1)
|
|
748
|
+
|
|
749
|
+
-- Status bar
|
|
750
|
+
local status_y = h - STATUS_H - 2
|
|
751
|
+
set_color(C_BG)
|
|
752
|
+
fill_rect(0, status_y, w, STATUS_H)
|
|
753
|
+
|
|
754
|
+
if status_msg ~= "" and reaper.time_precise() < status_timer then
|
|
755
|
+
local sc = C_STATUS_INFO
|
|
756
|
+
if status_type == "ok" then sc = C_STATUS_OK
|
|
757
|
+
elseif status_type == "err" then sc = C_STATUS_ERR
|
|
758
|
+
end
|
|
759
|
+
gfx.setfont(1, "Arial", 11, 0)
|
|
760
|
+
draw_text(status_msg, PADDING, status_y + 4, sc)
|
|
761
|
+
gfx.setfont(1, "Arial", 12, 0)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
-- Footer buttons
|
|
765
|
+
set_color(C_BG)
|
|
766
|
+
fill_rect(0, h - FOOTER_H, w, FOOTER_H)
|
|
767
|
+
set_color(C_DIVIDER)
|
|
768
|
+
fill_rect(0, h - FOOTER_H, w, 1)
|
|
769
|
+
|
|
770
|
+
local btns = get_button_rects()
|
|
771
|
+
hover_btn = nil
|
|
772
|
+
|
|
773
|
+
local has_selection = selected_idx > 0 and selected_idx <= #snapshots
|
|
774
|
+
|
|
775
|
+
for btn_name, btn in pairs(btns) do
|
|
776
|
+
local is_hov = point_in_rect(mx, my, btn.x, btn.y, btn.w, btn.h)
|
|
777
|
+
if is_hov then hover_btn = btn_name end
|
|
778
|
+
local is_press = btn_pressed == btn_name
|
|
779
|
+
|
|
780
|
+
-- Disable restore/delete if nothing selected
|
|
781
|
+
local disabled = (btn_name == "restore" or btn_name == "delete") and not has_selection
|
|
782
|
+
|
|
783
|
+
local bg_n = C_BTN_NORMAL
|
|
784
|
+
local bg_h = C_BTN_HOVER
|
|
785
|
+
if btn_name == "delete" then
|
|
786
|
+
bg_n = has_selection and C_BTN_DELETE or C_BTN_NORMAL
|
|
787
|
+
bg_h = C_BTN_DEL_HOV
|
|
788
|
+
elseif btn_name == "restore" then
|
|
789
|
+
bg_n = has_selection and C_BTN_RESTORE or C_BTN_NORMAL
|
|
790
|
+
bg_h = C_BTN_RES_HOV
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
if disabled then
|
|
794
|
+
set_color({ bg_n[1] * 0.6, bg_n[2] * 0.6, bg_n[3] * 0.6, 1.0 })
|
|
795
|
+
fill_rect(btn.x, btn.y, btn.w, btn.h)
|
|
796
|
+
set_color(C_DIVIDER)
|
|
797
|
+
draw_rect(btn.x, btn.y, btn.w, btn.h)
|
|
798
|
+
local tw, th = gfx.measurestr(btn.label)
|
|
799
|
+
draw_text(btn.label, btn.x + math.floor((btn.w - tw) / 2), btn.y + math.floor((btn.h - th) / 2), C_TEXT_DIM)
|
|
800
|
+
else
|
|
801
|
+
draw_button(btn.label, btn.x, btn.y, btn.w, btn.h, bg_n, bg_h, is_hov and not disabled, is_press)
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
-- Mouse interaction
|
|
806
|
+
local mouse_clicked = mouse_was_down and not mouse_down
|
|
807
|
+
|
|
808
|
+
if mouse_clicked then
|
|
809
|
+
-- Check if clicked on list row
|
|
810
|
+
if hover_row > 0 and hover_row <= total_rows then
|
|
811
|
+
selected_idx = hover_row
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
-- Check button clicks
|
|
815
|
+
if hover_btn and btn_pressed == hover_btn then
|
|
816
|
+
local has_sel = selected_idx > 0 and selected_idx <= #snapshots
|
|
817
|
+
|
|
818
|
+
if hover_btn == "save" then
|
|
819
|
+
-- Show save dialog
|
|
820
|
+
local retval, inputs = reaper.GetUserInputs(
|
|
821
|
+
"Save Snapshot", 2,
|
|
822
|
+
"Name:,Description:,extrawidth=200",
|
|
823
|
+
","
|
|
824
|
+
)
|
|
825
|
+
if retval then
|
|
826
|
+
local fields = {}
|
|
827
|
+
for part in (inputs .. ","):gmatch("([^,]*),") do
|
|
828
|
+
fields[#fields + 1] = part
|
|
829
|
+
end
|
|
830
|
+
local sname = fields[1] and fields[1]:match("^%s*(.-)%s*$") or ""
|
|
831
|
+
local sdesc = fields[2] and fields[2]:match("^%s*(.-)%s*$") or ""
|
|
832
|
+
if sname == "" then
|
|
833
|
+
set_status("Name cannot be empty.", "err")
|
|
834
|
+
else
|
|
835
|
+
local ok, err = do_save_snapshot(sname, sdesc)
|
|
836
|
+
if ok then
|
|
837
|
+
set_status("Saved snapshot: " .. sname, "ok")
|
|
838
|
+
snapshots = load_snapshots()
|
|
839
|
+
-- Select the newly saved snapshot
|
|
840
|
+
for j, s in ipairs(snapshots) do
|
|
841
|
+
if s.name == sname then selected_idx = j; break end
|
|
842
|
+
end
|
|
843
|
+
else
|
|
844
|
+
set_status("Save failed: " .. (err or "unknown error"), "err")
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
elseif hover_btn == "restore" and has_sel then
|
|
850
|
+
local snap = snapshots[selected_idx]
|
|
851
|
+
local confirm = reaper.ShowMessageBox(
|
|
852
|
+
"Restore snapshot '" .. snap.name .. "'?\n\nThis will overwrite current mixer state.\nAn undo point will be created.",
|
|
853
|
+
"Restore Snapshot", 1 -- MB_OKCANCEL
|
|
854
|
+
)
|
|
855
|
+
if confirm == 1 then
|
|
856
|
+
local ok, err, count = do_restore_snapshot(snap)
|
|
857
|
+
if ok then
|
|
858
|
+
set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
|
|
859
|
+
else
|
|
860
|
+
set_status("Restore failed: " .. (err or "unknown error"), "err")
|
|
861
|
+
end
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
elseif hover_btn == "delete" and has_sel then
|
|
865
|
+
local snap = snapshots[selected_idx]
|
|
866
|
+
local confirm = reaper.ShowMessageBox(
|
|
867
|
+
"Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
|
|
868
|
+
"Delete Snapshot", 1 -- MB_OKCANCEL
|
|
869
|
+
)
|
|
870
|
+
if confirm == 1 then
|
|
871
|
+
local ok, err = do_delete_snapshot(snap)
|
|
872
|
+
if ok then
|
|
873
|
+
set_status("Deleted snapshot: " .. snap.name, "ok")
|
|
874
|
+
snapshots = load_snapshots()
|
|
875
|
+
if selected_idx > #snapshots then
|
|
876
|
+
selected_idx = #snapshots
|
|
877
|
+
end
|
|
878
|
+
else
|
|
879
|
+
set_status("Delete failed: " .. (err or "unknown error"), "err")
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
elseif hover_btn == "refresh" then
|
|
884
|
+
snapshots = load_snapshots()
|
|
885
|
+
set_status("Refreshed — " .. #snapshots .. " snapshot(s) found.", "info", 2.5)
|
|
886
|
+
|
|
887
|
+
elseif hover_btn == "close" then
|
|
888
|
+
is_running = false
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
btn_pressed = nil
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
if mouse_down and not mouse_was_down then
|
|
896
|
+
-- Start press
|
|
897
|
+
if hover_btn then
|
|
898
|
+
btn_pressed = hover_btn
|
|
899
|
+
end
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
mouse_was_down = mouse_down
|
|
903
|
+
last_mouse_x = mx
|
|
904
|
+
last_mouse_y = my
|
|
905
|
+
last_mouse_cap = mouse_cap
|
|
906
|
+
|
|
907
|
+
-- Keyboard navigation
|
|
908
|
+
local char = gfx.getchar()
|
|
909
|
+
if char == -1 then
|
|
910
|
+
is_running = false -- Window closed
|
|
911
|
+
elseif char == 27 then -- Escape
|
|
912
|
+
is_running = false
|
|
913
|
+
elseif char == 1685026670 or char == 30064 then -- Up arrow
|
|
914
|
+
if selected_idx > 1 then
|
|
915
|
+
selected_idx = selected_idx - 1
|
|
916
|
+
if selected_idx <= scroll_offset then
|
|
917
|
+
scroll_offset = selected_idx - 1
|
|
918
|
+
end
|
|
919
|
+
end
|
|
920
|
+
elseif char == 1685026669 or char == 30065 then -- Down arrow
|
|
921
|
+
if selected_idx < #snapshots then
|
|
922
|
+
selected_idx = selected_idx + 1
|
|
923
|
+
local _, _, _, lh2 = get_list_rect()
|
|
924
|
+
local vr = get_visible_rows(lh2)
|
|
925
|
+
if selected_idx > scroll_offset + vr then
|
|
926
|
+
scroll_offset = selected_idx - vr
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
elseif char == 13 then -- Enter = restore
|
|
930
|
+
if selected_idx > 0 and selected_idx <= #snapshots then
|
|
931
|
+
local snap = snapshots[selected_idx]
|
|
932
|
+
local ok, err, count = do_restore_snapshot(snap)
|
|
933
|
+
if ok then
|
|
934
|
+
set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
|
|
935
|
+
else
|
|
936
|
+
set_status("Restore failed: " .. (err or "unknown error"), "err")
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
elseif char == 6579564 then -- Delete key
|
|
940
|
+
if selected_idx > 0 and selected_idx <= #snapshots then
|
|
941
|
+
local snap = snapshots[selected_idx]
|
|
942
|
+
local confirm = reaper.ShowMessageBox(
|
|
943
|
+
"Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
|
|
944
|
+
"Delete Snapshot", 1
|
|
945
|
+
)
|
|
946
|
+
if confirm == 1 then
|
|
947
|
+
local ok, err = do_delete_snapshot(snap)
|
|
948
|
+
if ok then
|
|
949
|
+
set_status("Deleted snapshot: " .. snap.name, "ok")
|
|
950
|
+
snapshots = load_snapshots()
|
|
951
|
+
if selected_idx > #snapshots then selected_idx = #snapshots end
|
|
952
|
+
else
|
|
953
|
+
set_status("Delete failed: " .. (err or "unknown error"), "err")
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
-- Mouse wheel scroll
|
|
960
|
+
local wheel = gfx.mouse_wheel
|
|
961
|
+
if wheel ~= 0 then
|
|
962
|
+
local scroll_lines = wheel > 0 and -3 or 3
|
|
963
|
+
scroll_offset = math.max(0, math.min(scroll_offset + scroll_lines, math.max(0, #snapshots - get_visible_rows((select(4, get_list_rect()) )))))
|
|
964
|
+
gfx.mouse_wheel = 0
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
gfx.update()
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
-- =============================================================================
|
|
971
|
+
-- Defer loop
|
|
972
|
+
-- =============================================================================
|
|
973
|
+
|
|
974
|
+
local function loop()
|
|
975
|
+
if not is_running then
|
|
976
|
+
gfx.quit()
|
|
977
|
+
return
|
|
978
|
+
end
|
|
979
|
+
draw()
|
|
980
|
+
reaper.defer(loop)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
-- =============================================================================
|
|
984
|
+
-- Initialization
|
|
985
|
+
-- =============================================================================
|
|
986
|
+
|
|
987
|
+
gfx.init(TITLE, WIN_W, WIN_H, 0)
|
|
988
|
+
gfx.setfont(1, "Arial", 12, 0)
|
|
989
|
+
|
|
990
|
+
snapshots = load_snapshots()
|
|
991
|
+
if #snapshots > 0 then
|
|
992
|
+
selected_idx = 1
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
set_status("Loaded " .. #snapshots .. " snapshot(s) from " .. get_snapshot_dir(), "info", 5.0)
|
|
996
|
+
|
|
997
|
+
reaper.defer(loop)
|