From 9cabe7dc45c2100c402dc22ffe3f23c939d2d5b9 Mon Sep 17 00:00:00 2001 From: axeman2u Date: Sun, 12 Jul 2026 13:04:25 -0400 Subject: [PATCH] Release CustomShortcuts - Create keyboard shortcuts with multiple characters, plus find & run any Action quickly. v1.1 --- Various/CustomShortcuts_Activate.lua | 456 +++++++++++++ .../CustomShortcuts_Create.lua | 603 ++++++++++++++++++ .../CustomShortcuts_capture.lua | 465 ++++++++++++++ .../CustomShortcuts_core.lua | 461 +++++++++++++ Various/CustomShortcuts_Activate/LICENSE.md | 21 + Various/CustomShortcuts_Activate/README.md | 145 +++++ 6 files changed, 2151 insertions(+) create mode 100644 Various/CustomShortcuts_Activate.lua create mode 100644 Various/CustomShortcuts_Activate/CustomShortcuts_Create.lua create mode 100644 Various/CustomShortcuts_Activate/CustomShortcuts_capture.lua create mode 100644 Various/CustomShortcuts_Activate/CustomShortcuts_core.lua create mode 100644 Various/CustomShortcuts_Activate/LICENSE.md create mode 100644 Various/CustomShortcuts_Activate/README.md diff --git a/Various/CustomShortcuts_Activate.lua b/Various/CustomShortcuts_Activate.lua new file mode 100644 index 000000000..d0d63b4ca --- /dev/null +++ b/Various/CustomShortcuts_Activate.lua @@ -0,0 +1,456 @@ +-- @description CustomShortcuts - Create keyboard shortcuts with multiple characters, plus find & run any Action quickly. +-- @author axeman2u +-- @version 1.1 +-- @provides +-- CustomShortcuts_Activate/CustomShortcuts_capture.lua +-- CustomShortcuts_Activate/CustomShortcuts_core.lua +-- CustomShortcuts_Activate/CustomShortcuts_Create.lua +-- CustomShortcuts_Activate/LICENSE.md +-- CustomShortcuts_Activate/README.md + +--[[ + * @description CustomShortcuts + * @version 1.1 + * @author Glenn Burgos + * @provides + * [nomain] CustomShortcuts_core.lua + * [nomain] CustomShortcuts_capture.lua + * [nomain] CustomShortcuts_Create.lua + * @about + * # CustomShortcuts + * + * A fast, searchable custom-shortcut system that lives outside + * REAPER's own keyboard shortcut list -- only one REAPER shortcut is + * ever needed, bound to this script. + * + * Activate mode: tap a modifier to search by your own custom shortcut + * phrase, or type to search action names (multi-word matching, like + * REAPER's own Action List). Enter or double-click runs a result; the + * first 10 results are always numbered, and Tab lets you type a digit + * to run one without touching the mouse. + * + * Create mode -- the shortcut editor -- is reached via the + * "Edit Shortcuts..." button inside Activate mode, so it never needs + * its own bound shortcut. + * + * Requires ReaImGui and js_ReaScriptAPI (both installable via + * ReaPack). + + CustomShortcuts_Activate.lua + + "Activate mode" -- the default/everyday entry point. Bind a REAPER + shortcut to THIS action (Actions List > find it > Add). Opening it: + - snapshots what context REAPER is in (Main vs. an open MIDI editor) + - shuts down the Create-mode script if it happens to be open + - opens a small window with a live search, bypassing REAPER's own + shortcuts for as long as the window has focus + - typing a lone modifier (tap, release) switches to shortcut-phrase + search; typing a letter first searches action names, same as + REAPER's own Action List + - Enter runs the top result and closes the window; double-click runs + whichever row you clicked; Escape closes without running anything + - pressing the same shortcut again closes the window (toggle) + + An "Edit Shortcuts..." button opens Create mode directly -- Create no + longer needs its own bound REAPER shortcut at all; this button + auto-registers it as an action the first time it's used. Create + relaunches this script automatically when it closes, so you land right + back here ready to run whatever you just assigned. + + Requires: ReaImGui, js_ReaScriptAPI (both via ReaPack -- see README). +--]] + +if not reaper.ImGui_GetBuiltinPath or not reaper.JS_VKeys_GetState then + reaper.ShowMessageBox( + "This script requires ReaImGui and js_ReaScriptAPI.\nInstall both via Extensions > ReaPack > Browse packages.", + "Missing dependency", 0) + return +end + +local info = debug.getinfo(1, "S") +local scriptDir = info.source:match("^@(.*[/\\])") +package.path = scriptDir .. "?.lua;" .. package.path + +local core = require("CustomShortcuts_core") +local capture = require("CustomShortcuts_capture") + +package.path = reaper.ImGui_GetBuiltinPath() .. "/?.lua;" .. package.path +local ImGui = require("imgui")("0.9") + +-- Toggle behavior: pressing this action's shortcut again terminates this +-- running instance instead of starting a second one. +reaper.set_action_options(1) + +-- Make sure Create mode isn't left open alongside us. +core.ShutDownSiblingIfRunning(core.CREATE_SCRIPT, 0) + +-- Snapshot context BEFORE the window steals focus. +local activeSection = core.SnapshotActiveSection() + +local data = core.LoadData() + +-- BUG FIX: the data file previously only ever got created/updated by +-- Create.lua -- if someone only ever uses Activate (the common case; +-- Create is opened rarely, just to browse/edit), any native REAPER +-- shortcuts added since Create last happened to run were invisible to +-- shortcut-phrase search here. Activate is the guaranteed entry point +-- (bound to the one REAPER shortcut, and also what every Create close +-- path relaunches into), so auto-seeding now runs here too, right at +-- startup, for the two sections Activate actually searches below -- +-- keeping the data file current on every normal launch with no +-- dependency on Create ever having been opened. (Media Explorer and MIDI +-- Event List Editor are seeded by Create instead -- see the note there +-- -- since Activate's own SnapshotActiveSection() can never select +-- them, so it'd have nothing to seed there anyway.) +do + local seededCount = core.AutoSeedSection(data, core.SECTION_MAIN) + if activeSection ~= core.SECTION_MAIN and core.IsSectionActive(activeSection) then + seededCount = seededCount + core.AutoSeedSection(data, activeSection) + end + if seededCount > 0 then + local ok, err = core.SaveData(data) + if not ok then + reaper.ShowConsoleMsg("CustomShortcuts: failed to auto-save seeded shortcuts: " .. tostring(err) .. "\n") + end + end +end + +-- Build the name-search action list: current section (if reachable and +-- not Main) plus Main, per the fallback design. +local nameSearchActions = {} +local seenSections = {} +local function addSectionActions(sectionId) + if seenSections[sectionId] then return end + seenSections[sectionId] = true + local list = core.EnumerateSectionActions(sectionId) + for _, a in ipairs(list) do + nameSearchActions[#nameSearchActions + 1] = { cmdId = a.cmdId, name = a.name, sectionId = sectionId } + end +end +if activeSection ~= core.SECTION_MAIN and core.IsSectionActive(activeSection) then + addSectionActions(activeSection) +end +addSectionActions(core.SECTION_MAIN) + +-- Shortcut-search source: same two sections' worth of saved custom +-- shortcuts, section-specific first, Main as fallback. Also builds a +-- reverse index (sectionId -> persistentId -> phrase) so name-search +-- results can show an action's assigned custom shortcut too, not just +-- shortcut-phrase-search results -- previously only the latter showed it. +local shortcutEntries = {} +local shortcutByPersistentId = {} +local function addSectionShortcuts(sectionId) + local sec = core.EnsureSection(data, sectionId) + shortcutByPersistentId[sectionId] = shortcutByPersistentId[sectionId] or {} + for phrase, entry in pairs(sec.shortcuts) do + shortcutEntries[#shortcutEntries + 1] = { + phrase = phrase, id = entry.id, name = entry.name, sectionId = sectionId, + } + shortcutByPersistentId[sectionId][entry.id] = phrase + end +end +if activeSection ~= core.SECTION_MAIN and core.IsSectionActive(activeSection) then + addSectionShortcuts(activeSection) +end +addSectionShortcuts(core.SECTION_MAIN) + +-- --------------------------------------------------------------------- + +local ctx = ImGui.CreateContext("Custom Shortcuts") +local captureState = capture.NewCaptureState() +local results = {} +local shouldClose = false +local shouldRun = nil -- a result row, set when Enter/double-click fires + +-- Tab enters a mode where the first 10 results are numbered 0-9 (row 1 +-- shows "0", row 2 shows "1", ... row 10 shows "9") and typing that digit +-- immediately runs the corresponding row -- lets you go from search +-- straight to running an action without reaching for the mouse. Any +-- other key (besides a matching digit, Tab, or Escape) is ignored while +-- this is active rather than silently falling through to normal typing, +-- so it has to be deliberately exited. +local numberSelectMode = false + +-- Literal phrase matching, used for shortcut-phrase search -- exact/ +-- prefix/substring, in that priority order, so typing the exact stored +-- phrase (modifiers included) scores highest and becomes the sole/top +-- result. +local function scoreMatch(haystack, needle) + haystack = haystack:lower() + needle = needle:lower() + if haystack == needle then return 3 end + if haystack:find(needle, 1, true) == 1 then return 2 end + if haystack:find(needle, 1, true) then return 1 end + return 0 +end + +local function recomputeResults() + results = {} + + if captureState.mode == "shortcut" then + -- FIX: this used to match on captureState.text alone (the typed + -- word), completely ignoring which modifier(s) were actually + -- tapped -- so tapping the wrong modifier but typing the right word + -- would still match. Using the full combined phrase (modifiers + + -- word) means the tapped modifiers actually have to correspond to + -- the stored shortcut, and typing the exact phrase then hitting + -- Enter reliably runs that exact shortcut. + local needle = core.CombinedPhrase(captureState) + if needle == "" then return end + for _, e in ipairs(shortcutEntries) do + local s = scoreMatch(e.phrase, needle) + if s > 0 then + results[#results + 1] = { + score = s, display = e.name, sub = core.SectionName(e.sectionId) .. " · " .. e.phrase, + sectionId = e.sectionId, persistentId = e.id, + } + end + end + elseif captureState.mode == "search" then + -- REAPER-Action-List-style matching: every space-separated word in + -- the typed text must appear somewhere in the action name, any + -- order (e.g. "track show" matches "Track: Show FX chain"). + local needle = captureState.text:lower() + if needle == "" then return end + for _, a in ipairs(nameSearchActions) do + local s = core.ScoreNameMatch(a.name, needle) + if s > 0 then + -- Show the action's assigned custom shortcut here too, if it + -- has one -- previously only shortcut-phrase-search results + -- showed this, so a name search never revealed an existing + -- assignment. + local sub = core.SectionName(a.sectionId) + local byId = shortcutByPersistentId[a.sectionId] + if byId then + local persistentId = core.GetPersistentId(a.cmdId) + local assignedPhrase = byId[persistentId] + if assignedPhrase then + sub = sub .. " · " .. assignedPhrase + end + end + results[#results + 1] = { + score = s, display = a.name, sub = sub, + sectionId = a.sectionId, cmdId = a.cmdId, + } + end + end + end + + table.sort(results, function(x, y) + if x.score ~= y.score then return x.score > y.score end + return x.display < y.display + end) + + -- Cap so we're not rendering thousands of rows for a broad match. + while #results > 50 do table.remove(results) end +end + +local function runResult(r) + if not r then return end + local runtimeId = r.cmdId + if not runtimeId and r.persistentId then + runtimeId = core.ResolveRuntimeId(r.persistentId) + if not runtimeId then + reaper.ShowMessageBox( + "Couldn't resolve this action anymore (it may have been uninstalled/renamed): " .. tostring(r.display), + "Custom Shortcuts", 0) + return + end + end + + local ok = core.RunAction(r.sectionId, runtimeId) + if ok then + shouldClose = true + else + -- Dispatch failed (e.g. a MIDI Editor / Media Explorer action whose + -- target window isn't open right now) -- leave the window open + -- rather than closing as if it worked. + reaper.ShowConsoleMsg("CustomShortcuts: couldn't run '" .. tostring(r.display) .. + "' -- its target window may not be open.\n") + end +end + +-- Returns true if the caller should keep deferring (i.e. keep running). +local function frame() + -- Wider/taller default than before, for the same reason as Create's + -- window -- action names and the section/shortcut column were getting + -- truncated. Cond_FirstUseEver means this only applies the very first + -- time the window is created; a manual resize is remembered after that. + ImGui.SetNextWindowSize(ctx, 640, 440, ImGui.Cond_FirstUseEver) + local visible, open = ImGui.Begin(ctx, "Custom Shortcuts##Activate", true) + + if visible then + if captureState.mode == "shortcut" then + ImGui.TextColored(ctx, 0x77CCFFFF, (captureState.heldModifierAtTap or "") .. " + " .. captureState.text) + ImGui.SameLine(ctx) + ImGui.TextDisabled(ctx, "(searching shortcuts, " .. (18 - #captureState.text) .. " left)") + elseif captureState.mode == "search" then + ImGui.Text(ctx, captureState.text) + ImGui.SameLine(ctx) + ImGui.TextDisabled(ctx, "(searching action names)") + else + ImGui.TextDisabled(ctx, "Tap a modifier for a shortcut, or start typing to search by name...") + end + + -- Buttons live on their OWN row, never sharing a line with the + -- variable-length status text above -- sharing a line was exactly + -- what caused Clear to land on top of Edit Shortcuts: a long + -- placeholder pushed Clear far enough right to collide with Edit + -- Shortcuts' fixed position. With nothing variable-length on this + -- row, plain SameLine is enough; no need for width-based anchoring. + -- Regular Button (not SmallButton) so it matches Edit Shortcuts' + -- height -- SmallButton uses tighter padding, which made the two + -- look mismatched side by side. + if ImGui.Button(ctx, "Clear##activateclear") then + capture.Reset(captureState) + recomputeResults() + numberSelectMode = false + end + ImGui.SameLine(ctx) + -- Launches Create directly; no REAPER-level shortcut needed for it + -- anymore, since GetSiblingCommandId auto-registers it as an action + -- on first use if it isn't already. Closes this window on click, and + -- Create's own closing paths relaunch Activate automatically, so + -- there's no separate shortcut to remember for either direction. + if ImGui.Button(ctx, "Edit Shortcuts...") then + local createCmdId = core.GetSiblingCommandId(core.CREATE_SCRIPT, 0) + if createCmdId and createCmdId ~= 0 then + reaper.Main_OnCommand(createCmdId, 0) + end + shouldClose = true + end + + -- Same row either way, so the layout doesn't shift when toggling: + -- before Tab is pressed, hint that it's available; after, explain + -- how to use it. + if numberSelectMode then + ImGui.TextColored(ctx, 0x77CCFFFF, "Press 0-9 to run a numbered result (Tab or Escape to cancel)") + elseif #results > 0 then + ImGui.TextDisabled(ctx, "Press Tab to select a numbered result") + end + + ImGui.Separator(ctx) + + if ImGui.BeginTable(ctx, "results", 3, ImGui.TableFlags_RowBg | ImGui.TableFlags_BordersInnerH) then + ImGui.TableSetupColumn(ctx, "#", ImGui.TableColumnFlags_WidthFixed, 24) + ImGui.TableSetupColumn(ctx, "Action", ImGui.TableColumnFlags_WidthStretch) + ImGui.TableSetupColumn(ctx, "Section / Shortcut", ImGui.TableColumnFlags_WidthFixed, 160) + for i, r in ipairs(results) do + ImGui.TableNextRow(ctx) + + -- Only the first 10 results get a number (row 1 = "0", ... + -- row 10 = "9") -- matches the digit that runs it in + -- numberSelectMode. + ImGui.TableNextColumn(ctx) + if i <= 10 then + local label = tostring(i - 1) + if numberSelectMode then ImGui.TextColored(ctx, 0xFFD966FF, label) + else ImGui.TextDisabled(ctx, label) end + end + + ImGui.TableNextColumn(ctx) + + -- Full-row hit target: an invisible Selectable spanning all + -- columns, drawn first, so any part of the row can be hovered/ + -- double-clicked -- not just whatever the last-drawn text widget + -- happened to be. + ImGui.Selectable(ctx, "##row" .. i, false, + ImGui.SelectableFlags_SpanAllColumns | ImGui.SelectableFlags_AllowDoubleClick) + if ImGui.IsItemHovered(ctx) and ImGui.IsMouseDoubleClicked(ctx, ImGui.MouseButton_Left) then + shouldRun = r + end + ImGui.SameLine(ctx) + + local isTop = (i == 1) + if isTop then ImGui.TextColored(ctx, 0xFFD966FF, r.display) else ImGui.Text(ctx, r.display) end + ImGui.TableNextColumn(ctx) + ImGui.TextDisabled(ctx, r.sub) + end + ImGui.EndTable(ctx) + end + end + + -- Must be queried BEFORE End(), while this window is still "current" on + -- the ImGui window stack. + local focused = ImGui.IsWindowFocused(ctx, ImGui.FocusedFlags_RootAndChildWindows) + + -- Must always be called, regardless of `visible` -- only the CONTENT + -- above is meant to be conditional on it. + ImGui.End(ctx) + + capture.SetFocused(focused) + + if focused then + local keys = capture.PollKeys() + + if numberSelectMode then + -- Digits are normally part of TEXT_VKEYS and would otherwise flow + -- into capture.Update() as search text -- deliberately NOT calling + -- that here, so a digit press in this mode selects a row instead + -- of typing. + local ranSomething = false + for digit = 0, 9 do + local vk = 0x30 + digit -- '0'..'9' + if keys.pressed[vk] then + if results[digit + 1] then + shouldRun = results[digit + 1] + end + numberSelectMode = false + ranSomething = true + break + end + end + if not ranSomething then + if keys.pressed[capture.VK_ESCAPE] or keys.pressed[capture.VK_TAB] then + numberSelectMode = false + end + -- Any other key is ignored while active -- exit via Escape/Tab + -- (or a matching digit) rather than falling through to typing. + end + else + local changed = capture.Update(captureState, keys) + if changed then recomputeResults() end + + if keys.pressed[capture.VK_RETURN] then + shouldRun = results[1] + elseif keys.pressed[capture.VK_ESCAPE] then + shouldClose = true + elseif keys.pressed[capture.VK_DELETE] then + -- Same as clicking Clear. + capture.Reset(captureState) + recomputeResults() + elseif keys.pressed[capture.VK_TAB] and #results > 0 then + numberSelectMode = true + end + end + end + + if shouldRun then + runResult(shouldRun) + shouldRun = nil + end + + return open and not shouldClose +end + +local function loop() + local ok, keepGoing = pcall(frame) + if not ok then + reaper.ShowConsoleMsg("CustomShortcuts Activate error: " .. tostring(keepGoing) .. "\n") + capture.ReleaseAll() -- release immediately, don't rely solely on atexit + return + end + if keepGoing then + reaper.defer(loop) + end +end + +reaper.atexit(function() + capture.ReleaseAll() -- must be first; also the backup net if pcall above didn't fire +end) + +-- Must happen before the first real PollKeys() call, i.e. before the +-- defer loop starts -- see capture.lua's PrimeKeyState() note. +capture.PrimeKeyState() + +reaper.defer(loop) diff --git a/Various/CustomShortcuts_Activate/CustomShortcuts_Create.lua b/Various/CustomShortcuts_Activate/CustomShortcuts_Create.lua new file mode 100644 index 000000000..fe97ed603 --- /dev/null +++ b/Various/CustomShortcuts_Activate/CustomShortcuts_Create.lua @@ -0,0 +1,603 @@ +-- @noindex + +--[[ + * @noindex + + CustomShortcuts_Create.lua + + "Create mode" -- the editor. Bind a separate REAPER shortcut to THIS + action. Lets you browse every action in a chosen section, see REAPER's + own native shortcut (read-only, reference only), and define/edit a + custom shortcut per action. + + WORKFLOW: pressing Enter on a successful commit (including clearing a + shortcut) automatically advances to the NEXT row and starts editing it, + so you can assign shortcuts down the list quickly without re-clicking + each one. Typing a letter with no modifier tap first (which is what + starting a search looks like) automatically backs out of row-editing + and hands that letter to the search box instead. + + To clear a shortcut: click its cell, backspace the text away (if any), + and press Enter with nothing typed. + + Custom shortcut entry uses the same "tap a modifier, then type a word" + capture as Activate mode. The full stored shortcut is + "+" (e.g. "Cmd+Ctrl+punch"), combined at commit time + from editCapture.heldModifierAtTap and editCapture.text. + + Saving is explicit -- nothing touches the data file until you click Save. + + NO SEPARATE SHORTCUT NEEDED: this script no longer needs its own bound + REAPER shortcut. It's normally reached via the "Edit Shortcuts..." + button inside Activate mode, which auto-registers this script as an + action on first use if it isn't already. Every path that closes this + window (Save, Discard, plain Escape/close) relaunches Activate as its + last step, so you land right back in a fresh Activate window ready to + run whatever you just assigned. + + Auto-seed: a blank custom-shortcut cell is seeded once from REAPER's own + native shortcut if it's a simple modifier(s)+single-character combo + (e.g. Cmd+M seeds "Cmd+m"). This only happens for rows that don't + already have a saved custom value -- if you ever see a wrong auto-seeded + value, delete CustomShortcuts_data.lua to force everything to reseed + from scratch. + + Requires: ReaImGui, js_ReaScriptAPI (both via ReaPack -- see README). +--]] + +if not reaper.ImGui_GetBuiltinPath or not reaper.JS_VKeys_GetState then + reaper.ShowMessageBox( + "This script requires ReaImGui and js_ReaScriptAPI.\nInstall both via Extensions > ReaPack > Browse packages.", + "Missing dependency", 0) + return +end + +local info = debug.getinfo(1, "S") +local scriptDir = info.source:match("^@(.*[/\\])") +package.path = scriptDir .. "?.lua;" .. package.path + +local core = require("CustomShortcuts_core") +local capture = require("CustomShortcuts_capture") + +package.path = reaper.ImGui_GetBuiltinPath() .. "/?.lua;" .. package.path +local ImGui = require("imgui")("0.9") + +reaper.set_action_options(1) +core.ShutDownSiblingIfRunning(core.ACTIVATE_SCRIPT, 0) + +local data = core.LoadData() + +local WINDOW_NAME = "Custom Shortcuts - Create##Create" + +local sectionRows = {} +local sectionReverseIdx = {} +local dirtyCount = 0 + +-- NOTE: single-char native-shortcut parsing and per-section auto-seeding +-- now live in core.lua (core.TryParseSingleCharShortcut / AutoSeedSection) +-- so Activate can use the exact same logic instead of a second, drifting +-- copy. Kept here only for the two sections Activate's own +-- SnapshotActiveSection() can never select on its own -- Media Explorer +-- and MIDI Event List Editor -- so it structurally can't seed them; this +-- file still owns persisting those. + +local function buildReverseIndex(sectionId) + local idx = {} + for i, row in ipairs(sectionRows[sectionId]) do + if row.customText ~= "" then idx[row.customText] = i end + end + sectionReverseIdx[sectionId] = idx +end + +local function buildRowsForSection(sectionId) + if sectionRows[sectionId] then return end + + local seededCount = core.AutoSeedSection(data, sectionId) + if seededCount > 0 then + local ok, err = core.SaveData(data) + if not ok then + reaper.ShowConsoleMsg("CustomShortcuts: failed to auto-save seeded shortcuts: " .. tostring(err) .. "\n") + end + end + + local sec = core.EnsureSection(data, sectionId) + local byPersistentId = {} + for phrase, entry in pairs(sec.shortcuts) do + byPersistentId[entry.id] = phrase + end + + local actions = core.EnumerateSectionActions(sectionId) + local rows = {} + for _, a in ipairs(actions) do + local persistentId = core.GetPersistentId(a.cmdId) + local native = core.GetNativeShortcuts(sectionId, a.cmdId) + rows[#rows + 1] = { + cmdId = a.cmdId, name = a.name, persistentId = persistentId, + native = native, customText = byPersistentId[persistentId] or "", dirty = false, + } + end + table.sort(rows, function(x, y) return x.name < y.name end) + sectionRows[sectionId] = rows + buildReverseIndex(sectionId) +end + +local function saveAll() + for sectionId, rows in pairs(sectionRows) do + local sec = core.EnsureSection(data, sectionId) + sec.shortcuts = {} + for _, row in ipairs(rows) do + if row.customText ~= "" then + sec.shortcuts[row.customText] = { id = row.persistentId, name = row.name } + end + row.dirty = false + end + end + dirtyCount = 0 + local ok, err = core.SaveData(data) + if not ok then + reaper.ShowMessageBox("Failed to save: " .. tostring(err), "Custom Shortcuts", 0) + end +end + +local ctx = ImGui.CreateContext("Custom Shortcuts - Create") +local FLT_MIN = ImGui.NumericLimits_Float() + +local clipper = ImGui.CreateListClipper(ctx) +ImGui.Attach(ctx, clipper) + +local sectionTabIdx = 1 +local currentSectionId = core.SECTIONS[sectionTabIdx].id +buildRowsForSection(currentSectionId) + +local searchText = "" +local editing = nil +local editCapture = nil + +local conflictModal = nil +local conflictModalOpened = false +local followupModal = nil +local followupModalOpened = false +local closeConfirm = false +local closeConfirmOpened = false +local shouldClose = false + +local wantEscapeConflict = false +local wantEscapeFollowup = false +local wantEscapeCloseConfirm = false +local wantRefocusMainWindow = false + +-- Only sets a flag -- consumed at the TOP of the next frame's loop(), +-- before Begin(), via SetNextWindowFocus. Only call this when nothing +-- else is about to open a new popup this same cycle (the Steal path +-- skips it, since it immediately opens a followup modal that needs to +-- own focus instead). +local function closePopupAndRefocus() + ImGui.CloseCurrentPopup(ctx) + wantRefocusMainWindow = true +end + +-- Closes this window AND relaunches Activate as the very last step, so +-- the user lands right back in a fresh Activate window ready to search +-- for (and immediately run) whatever they just assigned -- no need to +-- remember or press a separate shortcut to get back there. Safe to call +-- even if Activate wasn't the one that launched this session (e.g. Create +-- was opened directly via its own action) -- it just starts a new one. +local function closeAndRelaunchActivate() + shouldClose = true + local activateCmdId = core.GetSiblingCommandId(core.ACTIVATE_SCRIPT, 0) + if activateCmdId and activateCmdId ~= 0 then + reaper.Main_OnCommand(activateCmdId, 0) + end +end + +local function startEditingRow(rowIdx) + editing = rowIdx + editCapture = capture.NewCaptureState() +end + +local function cancelEditing() + editing = nil + editCapture = nil +end + +local function advanceToNextRow(fromIdx) + local rows = sectionRows[currentSectionId] + local nextIdx = fromIdx + 1 + if rows[nextIdx] then + startEditingRow(nextIdx) + else + cancelEditing() + end +end + +local function commitEdit() + local rows = sectionRows[currentSectionId] + local row = rows[editing] + local phrase = core.CombinedPhrase(editCapture) + local idx = sectionReverseIdx[currentSectionId] + local committedRowIdx = editing + + if phrase == "" then + if row.customText ~= "" then + idx[row.customText] = nil + row.customText = "" + if not row.dirty then row.dirty = true; dirtyCount = dirtyCount + 1 end + end + advanceToNextRow(committedRowIdx) + return + end + + local conflictRowIdx = idx[phrase] + if conflictRowIdx and conflictRowIdx ~= editing then + conflictModal = { rowIdx = editing, otherRowIdx = conflictRowIdx, phrase = phrase } + conflictModalOpened = false + return + end + + idx[row.customText] = nil + row.customText = phrase + if not row.dirty then row.dirty = true; dirtyCount = dirtyCount + 1 end + idx[phrase] = editing + advanceToNextRow(committedRowIdx) +end + +local function resolveSteal() + local rows = sectionRows[currentSectionId] + local idx = sectionReverseIdx[currentSectionId] + local row = rows[conflictModal.rowIdx] + local other = rows[conflictModal.otherRowIdx] + + idx[other.customText] = nil + other.customText = "" + if not other.dirty then other.dirty = true; dirtyCount = dirtyCount + 1 end + + idx[row.customText] = nil + row.customText = conflictModal.phrase + if not row.dirty then row.dirty = true; dirtyCount = dirtyCount + 1 end + idx[conflictModal.phrase] = conflictModal.rowIdx + + local orphanRowIdx = conflictModal.otherRowIdx + conflictModal = nil + cancelEditing() + followupModal = { orphanRowIdx = orphanRowIdx } + followupModalOpened = false +end + +local function resolveModify() + -- Leave `editing` pointed at the same row so the user can immediately + -- retype a shortcut for it. Reset the capture state so the rejected + -- (conflicting) phrase doesn't linger in the field. + conflictModal = nil + if editing then + editCapture = capture.NewCaptureState() + end +end + +-- Returns true if the caller should keep deferring (i.e. keep running). +local function frame() + -- Consume the refocus request BEFORE Begin(), so it applies to the + -- window we're about to create this frame -- not the popup that just + -- closed last frame. + if wantRefocusMainWindow then + wantRefocusMainWindow = false + ImGui.SetNextWindowFocus(ctx) + end + + -- Wider default than before -- many action names are long, and the + -- old 720px default truncated most of them, hiding native/custom + -- shortcuts off to the right. Cond_FirstUseEver means this only + -- applies the very first time the window is created; once resized by + -- hand, ReaImGui remembers that size on future launches. + ImGui.SetNextWindowSize(ctx, 1000, 600, ImGui.Cond_FirstUseEver) + local visible, open = ImGui.Begin(ctx, WINDOW_NAME, true) + + local focused = ImGui.IsWindowFocused(ctx, ImGui.FocusedFlags_RootAndChildWindows) + local modalOpen = (conflictModal ~= nil) or (followupModal ~= nil) or closeConfirm + + if visible then + for i, s in ipairs(core.SECTIONS) do + if i > 1 then ImGui.SameLine(ctx) end + local isCurrent = (i == sectionTabIdx) + if isCurrent then ImGui.PushStyleColor(ctx, ImGui.Col_Button, 0x5588BBFF) end + if ImGui.Button(ctx, s.name .. "##tab" .. i) and not isCurrent then + sectionTabIdx = i + currentSectionId = s.id + buildRowsForSection(currentSectionId) + cancelEditing() + end + if isCurrent then ImGui.PopStyleColor(ctx) end + end + + -- Save is right-aligned at a position based on WINDOW WIDTH, not on + -- how much precedes it on a line -- it used to share the search row + -- with the search text and hint, so a long search string pushed it + -- out of view. The tabs row's content is fixed-length, so anchoring + -- here keeps Save visible no matter what's typed into search. + do + local saveLabel = dirtyCount > 0 and ("Save (" .. dirtyCount .. " unsaved)") or "Save" + local saveWidth = 180 + ImGui.SameLine(ctx, ImGui.GetWindowWidth(ctx) - saveWidth) + local saveDisabled = (dirtyCount == 0) + if saveDisabled then ImGui.BeginDisabled(ctx) end + local saveClicked = ImGui.Button(ctx, saveLabel, saveWidth - 16, 0) + if saveDisabled then ImGui.EndDisabled(ctx) end + if saveClicked and not saveDisabled then + local ok, err = pcall(saveAll) + if not ok then + reaper.ShowConsoleMsg("CustomShortcuts save error: " .. tostring(err) .. "\n") + end + end + end + + ImGui.Separator(ctx) + + ImGui.Text(ctx, "Search: " .. searchText) + ImGui.SameLine(ctx) + if ImGui.SmallButton(ctx, "Clear##searchclear") then + searchText = "" + end + ImGui.SameLine(ctx) + ImGui.TextDisabled(ctx, "(click a cell to edit; Enter commits + advances; empty + Enter clears; typing a letter with no modifier jumps to search)") + + ImGui.Separator(ctx) + + local rows = sectionRows[currentSectionId] + local needle = searchText:lower() + + if ImGui.BeginTable(ctx, "actions", 3, + ImGui.TableFlags_RowBg | ImGui.TableFlags_BordersInnerH | ImGui.TableFlags_ScrollY, 0, -1) then + ImGui.TableSetupColumn(ctx, "Action", ImGui.TableColumnFlags_WidthStretch) + ImGui.TableSetupColumn(ctx, "REAPER shortcut", ImGui.TableColumnFlags_WidthFixed, 140) + ImGui.TableSetupColumn(ctx, "Custom shortcut", ImGui.TableColumnFlags_WidthFixed, 200) + ImGui.TableSetupScrollFreeze(ctx, 0, 1) + ImGui.TableHeadersRow(ctx) + + -- Multi-word, REAPER-Action-List-style filtering: every space- + -- separated word in the search box must appear somewhere in the + -- action name, in any order (e.g. "track show" matches "Track: + -- Show FX chain"). + local filtered = {} + if needle == "" then + for i = 1, #rows do filtered[#filtered + 1] = i end + else + for i, row in ipairs(rows) do + if core.ScoreNameMatch(row.name, needle) > 0 then filtered[#filtered + 1] = i end + end + end + + ImGui.ListClipper_Begin(clipper, #filtered) + while ImGui.ListClipper_Step(clipper) do + local first, last = ImGui.ListClipper_GetDisplayRange(clipper) + for f = first, last - 1 do + local rowIdx = filtered[f + 1] + local row = rows[rowIdx] + ImGui.TableNextRow(ctx) + + ImGui.TableNextColumn(ctx) + if row.dirty then ImGui.TextColored(ctx, 0xFFD966FF, "*" .. row.name) + else ImGui.Text(ctx, row.name) end + + ImGui.TableNextColumn(ctx) + ImGui.TextDisabled(ctx, row.native[1] or "") + + ImGui.TableNextColumn(ctx) + local isEditing = (editing == rowIdx) + local isConflictFlagged = false + local shownText = row.customText + if isEditing then + shownText = (editCapture.mode == "shortcut") + and ((editCapture.heldModifierAtTap or "") .. "+" .. editCapture.text) + or (editCapture.text == "" and "(tap a modifier, or Enter to clear)" or "(tap a modifier first)") + if editCapture.mode == "shortcut" and editCapture.text ~= "" then + local candidatePhrase = core.CombinedPhrase(editCapture) + local idx = sectionReverseIdx[currentSectionId] + local conflictOwner = idx[candidatePhrase] + isConflictFlagged = conflictOwner and conflictOwner ~= rowIdx + end + end + + if isConflictFlagged then + ImGui.PushStyleColor(ctx, ImGui.Col_Text, 0xFF6666FF) + elseif isEditing then + ImGui.PushStyleColor(ctx, ImGui.Col_Text, 0x77CCFFFF) + end + ImGui.Selectable(ctx, (shownText == "" and "(none)" or shownText) .. "##cell" .. rowIdx, isEditing) + if isConflictFlagged or isEditing then ImGui.PopStyleColor(ctx) end + + if ImGui.IsItemClicked(ctx) and not isEditing then + startEditingRow(rowIdx) + end + end + end + + ImGui.EndTable(ctx) + end + + -- Conflict modal + if conflictModal and not conflictModalOpened then + conflictModalOpened = true + ImGui.OpenPopup(ctx, "Shortcut conflict") + end + if ImGui.BeginPopupModal(ctx, "Shortcut conflict", nil, ImGui.WindowFlags_AlwaysAutoResize) then + if wantEscapeConflict then + wantEscapeConflict = false + closePopupAndRefocus() + resolveModify() + elseif conflictModal then + local rows2 = sectionRows[currentSectionId] + local otherName = rows2[conflictModal.otherRowIdx].name + ImGui.Text(ctx, "'" .. conflictModal.phrase .. "' is already assigned to:") + ImGui.TextColored(ctx, 0xFFD966FF, otherName) + ImGui.Spacing(ctx) + if ImGui.Button(ctx, "Steal it", 120, 0) then + -- Deliberately NOT closePopupAndRefocus() here: resolveSteal() + -- immediately opens the followup modal, so requesting a main- + -- window refocus this cycle would fight with that popup opening + -- next frame. The followup modal's own buttons request refocus + -- when THEY close instead. + ImGui.CloseCurrentPopup(ctx) + resolveSteal() + end + ImGui.SameLine(ctx) + if ImGui.Button(ctx, "Modify mine", 120, 0) then + closePopupAndRefocus() + resolveModify() + end + ImGui.Spacing(ctx) + ImGui.TextDisabled(ctx, "(Escape also backs out to keep editing)") + end + ImGui.EndPopup(ctx) + end + + -- Followup modal (after a steal) + if followupModal and not followupModalOpened then + followupModalOpened = true + ImGui.OpenPopup(ctx, "Assign new shortcut?") + end + if ImGui.BeginPopupModal(ctx, "Assign new shortcut?", nil, ImGui.WindowFlags_AlwaysAutoResize) then + if wantEscapeFollowup then + wantEscapeFollowup = false + closePopupAndRefocus() + followupModal = nil + elseif followupModal then + local rows2 = sectionRows[currentSectionId] + local orphanName = rows2[followupModal.orphanRowIdx].name + ImGui.Text(ctx, "'" .. orphanName .. "' just lost its shortcut.") + ImGui.Text(ctx, "Give it a new one now?") + ImGui.Spacing(ctx) + if ImGui.Button(ctx, "Assign now", 120, 0) then + local orphanIdx = followupModal.orphanRowIdx + closePopupAndRefocus() + followupModal = nil + startEditingRow(orphanIdx) + end + ImGui.SameLine(ctx) + if ImGui.Button(ctx, "Skip for now", 120, 0) then + closePopupAndRefocus() + followupModal = nil + end + ImGui.Spacing(ctx) + ImGui.TextDisabled(ctx, "(Escape also skips)") + end + ImGui.EndPopup(ctx) + end + + -- Close-with-unsaved-changes confirm + if closeConfirm and not closeConfirmOpened then + closeConfirmOpened = true + ImGui.OpenPopup(ctx, "Unsaved changes") + end + if ImGui.BeginPopupModal(ctx, "Unsaved changes", nil, ImGui.WindowFlags_AlwaysAutoResize) then + if wantEscapeCloseConfirm then + wantEscapeCloseConfirm = false + closePopupAndRefocus() + closeConfirm = false + closeConfirmOpened = false + else + ImGui.Text(ctx, "You have " .. dirtyCount .. " unsaved change(s).") + if ImGui.Button(ctx, "Save", 100, 0) then + pcall(saveAll); closePopupAndRefocus(); closeAndRelaunchActivate() + end + ImGui.SameLine(ctx) + if ImGui.Button(ctx, "Discard", 100, 0) then + closePopupAndRefocus(); closeAndRelaunchActivate() + end + ImGui.SameLine(ctx) + if ImGui.Button(ctx, "Cancel", 100, 0) then + closePopupAndRefocus(); closeConfirm = false; closeConfirmOpened = false + end + ImGui.Spacing(ctx) + ImGui.TextDisabled(ctx, "(Escape also cancels)") + end + ImGui.EndPopup(ctx) + end + end + + -- Must always be called, regardless of `visible` -- only the CONTENT + -- above (including the modals) is meant to be conditional on it. + ImGui.End(ctx) + + capture.SetFocused(focused) + + if focused and not modalOpen then + local keys = capture.PollKeys() + + if editing then + capture.Update(editCapture, keys) + if editCapture.mode == "search" then + -- A letter arrived with no modifier tap first -- not a valid + -- shortcut kickoff. Read this as "the user wants to search + -- instead": back out of row-editing and forward the letter(s) + -- captured so far into the global search box. + searchText = searchText .. editCapture.text + cancelEditing() + elseif keys.pressed[capture.VK_RETURN] then + commitEdit() + elseif keys.pressed[capture.VK_ESCAPE] then + cancelEditing() + end + else + if keys.pressed[capture.VK_BACK] and #searchText > 0 then + searchText = searchText:sub(1, -2) + end + if keys.pressed[capture.VK_DELETE] then + -- Same as clicking Clear. + searchText = "" + end + for _, vk in ipairs(capture.TEXT_VKEYS) do + if keys.pressed[vk] then + local isShift = keys.down[capture.VK_SHIFT] + local ch = capture.VkeyToChar(vk, isShift) + if ch then searchText = searchText .. ch end + end + end + if keys.pressed[capture.VK_ESCAPE] then + if dirtyCount > 0 then + closeConfirm = true + else + closeAndRelaunchActivate() + end + end + end + elseif focused and modalOpen then + local keys = capture.PollKeys() + if keys.pressed[capture.VK_ESCAPE] then + if conflictModal then wantEscapeConflict = true + elseif followupModal then wantEscapeFollowup = true + elseif closeConfirm then wantEscapeCloseConfirm = true + end + end + end + + if not open and not shouldClose then + if dirtyCount > 0 then + closeConfirm = true + else + closeAndRelaunchActivate() + end + end + + return not shouldClose +end + +local function loop() + local ok, keepGoing = pcall(frame) + if not ok then + reaper.ShowConsoleMsg("CustomShortcuts Create error: " .. tostring(keepGoing) .. "\n") + capture.ReleaseAll() -- release immediately, don't rely solely on atexit + return + end + if keepGoing then + reaper.defer(loop) + end +end + +reaper.atexit(function() + capture.ReleaseAll() -- must be first; also the backup net if pcall above didn't fire +end) + +-- Must happen before the first real PollKeys() call, i.e. before the +-- defer loop starts -- see capture.lua's PrimeKeyState() note. +capture.PrimeKeyState() + +reaper.defer(loop) diff --git a/Various/CustomShortcuts_Activate/CustomShortcuts_capture.lua b/Various/CustomShortcuts_Activate/CustomShortcuts_capture.lua new file mode 100644 index 000000000..6ba8d8258 --- /dev/null +++ b/Various/CustomShortcuts_Activate/CustomShortcuts_capture.lua @@ -0,0 +1,465 @@ +-- @noindex + +--[[ + * @noindex + + CustomShortcuts_capture.lua + + Shared capture logic used by both Activate and Create: + - VKeys-based bypass of REAPER's native shortcuts (via js_ReaScriptAPI), + engaged/released based on window focus rather than open/close. + - The "tap one or more modifiers, then type" shortcut-vs-search-mode + detector. + + VK codes are read directly by code (state:byte(vk)), confirmed via live + testing on macOS. Also confirmed empirically on macOS: physical Cmd + reports through the slot Windows calls VK_CONTROL, and physical Ctrl + reports through the slot Windows calls VK_LWIN (the Windows/Super key + slot) -- Option/Alt and Shift match their standard Windows slots + normally. Labels below reflect that, with a GetOS() check so this + doesn't mislabel things on Windows. + + Modifier accumulation: each individual clean tap (a modifier, or several + held together, then all released with nothing else pressed in between) + ADDS to the running set of selected modifiers, rather than replacing it. + That means "Ctrl+Shift+word" can be entered either by holding both at + once and releasing them together, OR by tapping Ctrl, then separately + tapping Shift -- both build the same combined set. The set locks in + (stops accepting more taps) the moment you type the first letter. + + PrimeKeyState(): call it once, synchronously, before a script's main + defer loop begins. Without it, the very first PollKeys() call has no + baseline (prevState starts as nil), so any key still physically held + down at that instant -- typically part of the very shortcut chord + REAPER just used to launch the script -- reads as a brand new keypress. + That's how e.g. a Cmd+Shift+A launch shortcut could leak a stray "A" + into the search box the instant the window opened. +--]] + +local M = {} + +-- Standard virtual-key codes for these slots (values are stable across +-- platforms -- what varies is which *physical* key lands in which slot). +local VK_SHIFT = 0x10 +local VK_CONTROL = 0x11 +local VK_MENU = 0x12 -- Alt/Option +local VK_LWIN = 0x5B +local VK_RWIN = 0x5C + +M.MODIFIER_VKEYS = { VK_SHIFT, VK_CONTROL, VK_MENU, VK_LWIN, VK_RWIN } + +-- Exposed so callers outside this module (e.g. Create.lua's own direct +-- key-to-text loop) can check shift state the same way capture.Update() +-- does internally, without duplicating the raw VK code. +M.VK_SHIFT = VK_SHIFT +-- Exposed so callers can look up this key's correct platform label (e.g. +-- "Opt" on Mac, "Alt" on Windows) via M.MODIFIER_LABELS[M.VK_MENU] +-- instead of hardcoding either name themselves. +M.VK_MENU = VK_MENU + +local osStr = reaper.GetOS() +local isMac = osStr:match("OSX") or osStr:match("macOS") +-- Deliberately "is it Windows" rather than "is it Mac", so an +-- unanticipated third platform (Linux) defaults to the SWELL/ASCII- +-- fallback behavior below, which is the SWELL-based one of the two. +local isWindows = osStr:match("Win") ~= nil + +-- --------------------------------------------------------------------- +-- Punctuation vkeys -- genuinely platform-different, not a layout guess. +-- +-- On WINDOWS, JS_VKeys_GetState reads real hardware VK codes directly, +-- and Windows has proper named constants for punctuation (the VK_OEM_* +-- range, 0xBA-0xDE) -- documented by Microsoft, confirmed correct. One +-- physical key = one fixed code regardless of Shift, same model as +-- letters/digits; Shift is tracked separately and picks which of the +-- two characters that key means. +-- +-- On MAC (and presumably Linux), js_ReaScriptAPI rides on SWELL, whose +-- own header documents SWELL_MacKeyToWindowsKey() as returning "a +-- windows VK_ keycode (OR ASCII)" -- i.e. for punctuation keys SWELL +-- hasn't given a dedicated VK_OEM_* slot to, it just reports the literal +-- ASCII code of whatever character the OS says that keystroke produced, +-- ALREADY reflecting Shift. That's not a per-key guess, it's the +-- documented fallback mechanism, and it explains the period/Delete bug +-- directly: ASCII "." is 46 (0x2E) -- the exact number this file had +-- already claimed for VK_DELETE. So on Mac, punctuation vkeys are read +-- as themselves (no separate shift lookup needed -- the OS already did +-- that part), and VK_DELETE is retired there entirely rather than risk +-- it colliding with whatever else lands on 0x2E (Backspace already +-- covers "delete text," and the on-screen Clear button still works). +-- --------------------------------------------------------------------- + +-- Windows-only: real VK_OEM_* constants, { unshifted, shifted } per key. +local OEM_CHARS = { + [0xBA] = { ";", ":" }, -- VK_OEM_1 + [0xBB] = { "=", "+" }, -- VK_OEM_PLUS + [0xBC] = { ",", "<" }, -- VK_OEM_COMMA + [0xBD] = { "-", "_" }, -- VK_OEM_MINUS + [0xBE] = { ".", ">" }, -- VK_OEM_PERIOD + [0xBF] = { "/", "?" }, -- VK_OEM_2 + [0xC0] = { "`", "~" }, -- VK_OEM_3 + [0xDB] = { "[", "{" }, -- VK_OEM_4 + [0xDC] = { "\\", "|" }, -- VK_OEM_5 + [0xDD] = { "]", "}" }, -- VK_OEM_6 + [0xDE] = { "'", "\"" }, -- VK_OEM_7 +} +local OEM_VKEYS = {} +for vk in pairs(OEM_CHARS) do OEM_VKEYS[#OEM_VKEYS + 1] = vk end + +-- Mac/Linux-only: every printable-ASCII punctuation code (everything in +-- 0x21-0x7E that isn't a letter, digit, or modifier -- which are handled +-- separately). Built programmatically rather than hand-listed since +-- these ARE just their own ASCII values here -- e.g. vk 44 IS "," already. +-- IMPORTANT: the modifier exclusion is not optional -- VK_LWIN (0x5B) +-- and VK_RWIN (0x5C), which is where physical Ctrl reports on Mac (see +-- MODIFIER_LABELS above), fall inside this same numeric range as ASCII +-- "[" and "\". Without excluding them, tapping Ctrl also got read as +-- typing "[", which kicked capture straight into "search" mode instead +-- of registering as a modifier tap -- exactly what broke shortcut entry. +local MODIFIER_VKEY_SET = {} +for _, vk in ipairs(M.MODIFIER_VKEYS) do MODIFIER_VKEY_SET[vk] = true end + +local MAC_ASCII_PUNCT_VKEYS = {} +for vk = 0x21, 0x7E do + local isLetter = vk >= 0x41 and vk <= 0x5A + local isDigit = vk >= 0x30 and vk <= 0x39 + if not isLetter and not isDigit and not MODIFIER_VKEY_SET[vk] then + MAC_ASCII_PUNCT_VKEYS[#MAC_ASCII_PUNCT_VKEYS + 1] = vk + end +end + +-- Shifted symbols on the digit row (US layout), e.g. Shift+1 -> "!"). +-- WINDOWS ONLY -- on Mac, shifted digit-row symbols come through the +-- ASCII-fallback path above instead (their own distinct ASCII codes), +-- same reasoning as the rest of punctuation. +local SHIFT_DIGIT_CHARS = { + [0x30] = ")", [0x31] = "!", [0x32] = "@", [0x33] = "#", [0x34] = "$", + [0x35] = "%", [0x36] = "^", [0x37] = "&", [0x38] = "*", [0x39] = "(", +} + +if isMac then + M.MODIFIER_LABELS = { + [VK_SHIFT] = "Shift", + [VK_CONTROL] = "Cmd", -- confirmed: physical Cmd reports here on macOS + [VK_MENU] = "Opt", -- Option key -- "Alt" is the Windows name for this slot + [VK_LWIN] = "Ctrl", -- confirmed: physical Ctrl reports here on macOS + [VK_RWIN] = "Ctrl", + } +else + M.MODIFIER_LABELS = { + [VK_SHIFT] = "Shift", + [VK_CONTROL] = "Ctrl", + [VK_MENU] = "Alt", + [VK_LWIN] = "Win", + [VK_RWIN] = "Win", + } +end + +-- Control keys we need to poll manually. IMPORTANT: while JS_VKeys_Intercept +-- is engaged, the key events never reach any window at all -- not even our +-- own ReaImGui window -- because the intercept happens at the OS hook +-- level, upstream of window messages entirely. That means ReaImGui's own +-- IsKeyPressed()/InputText() widgets see NOTHING while we're intercepting: +-- Enter, Escape, and Backspace all have to be polled here too, the same way +-- as the letter/digit keys, rather than relying on ImGui to notice them. +M.VK_BACK = 0x08 +M.VK_TAB = 0x09 +M.VK_RETURN = 0x0D +M.VK_ESCAPE = 0x1B +-- Real, reliable Forward-Delete code on Windows. On Mac, this same +-- number (0x2E) is where ASCII "." also lands (see above) -- rather +-- than have period silently clear the search field, Delete-as-Clear is +-- retired on Mac. -1 is a safe "never matches" sentinel: isDown() below +-- explicitly treats any vk < 1 as always-up. +M.VK_DELETE = isWindows and 0x2E or -1 +M.CONTROL_VKEYS = { M.VK_BACK, M.VK_TAB, M.VK_RETURN, M.VK_ESCAPE, M.VK_DELETE } + +-- Printable character vkeys we care about capturing into the text +-- buffer: letters, digits, space, plus punctuation via whichever of the +-- two platform-specific lists above actually applies. +local function buildTextVkeys() + local t = {} + for vk = 0x41, 0x5A do t[#t + 1] = vk end -- A-Z + for vk = 0x30, 0x39 do t[#t + 1] = vk end -- 0-9 + t[#t + 1] = 0x20 -- space + local punctSource = isWindows and OEM_VKEYS or MAC_ASCII_PUNCT_VKEYS + for _, vk in ipairs(punctSource) do t[#t + 1] = vk end + return t +end +M.TEXT_VKEYS = buildTextVkeys() + +-- isShift picks the shifted variant where the key produces a different +-- symbol under Shift. For letters and (on Windows) digits/punctuation, +-- one physical key reports one fixed code and Shift is read fresh off +-- keys.down every time, REGARDLESS of capture mode or how much text is +-- already typed -- unlike the other modifiers (which only matter for +-- the tap-to-build-a-shortcut-prefix system and stop mattering once +-- text has started), Shift's normal keyboard job never turns off. On +-- Mac, punctuation is the exception: the vk itself already reflects +-- Shift (see MAC_ASCII_PUNCT_VKEYS above), so isShift is simply ignored +-- for that branch -- passing it again would double-apply it. +local function vkeyToChar(vk, isShift) + if vk == 0x20 then return " " end + if vk >= 0x41 and vk <= 0x5A then -- A-Z + return isShift and string.char(vk) or string.char(vk + 32) + end + if vk >= 0x30 and vk <= 0x39 then -- 0-9 + if isWindows and isShift then return SHIFT_DIGIT_CHARS[vk] end + return string.char(vk) + end + if isWindows then + local oem = OEM_CHARS[vk] + if oem then return isShift and oem[2] or oem[1] end + return nil + else + if vk >= 0x21 and vk <= 0x7E then return string.char(vk) end + return nil + end +end +M.VkeyToChar = vkeyToChar + +-- --------------------------------------------------------------------- +-- VKeys bypass (js_ReaScriptAPI) +-- --------------------------------------------------------------------- + +local intercepting = false + +function M.SetFocused(isFocused) + if isFocused and not intercepting then + reaper.JS_VKeys_Intercept(-1, 1) + intercepting = true + elseif not isFocused and intercepting then + reaper.JS_VKeys_Intercept(-1, -1) + intercepting = false + end +end + +-- MUST be called from the script's reaper.atexit handler, as the very +-- first thing it does, before any other cleanup. If this doesn't run, +-- the intercepted keys stay blocked system-wide (every app, not just +-- REAPER) until REAPER is restarted. +function M.ReleaseAll() + if intercepting then + reaper.JS_VKeys_Intercept(-1, -1) + intercepting = false + end +end + +-- --------------------------------------------------------------------- +-- Key-state polling with edge detection +-- --------------------------------------------------------------------- + +local prevState = nil + +-- vk's that were already down the moment PrimeKeyState() ran -- normally +-- the modifier(s) from the very shortcut that just launched this script +-- (e.g. Cmd+Shift in a Cmd+Shift+A binding). Each is removed from this +-- set the FIRST time it's subsequently observed being released, and that +-- one "grace release" is NOT allowed to complete a tap -- it's just the +-- natural release of the launch chord, not a deliberate search gesture. +-- After that one grace release, the key behaves completely normally. +local residualModifiers = {} + +local function isDown(state, vk) + if not state then return false end + if vk < 1 then return false end + -- Confirmed via live testing: byte N (1-indexed, Lua string convention) + -- holds the state for VK code N directly -- no +1 needed. + local b = state:byte(vk) + return b ~= nil and b ~= 0 +end + +-- Call ONCE, synchronously, right when the script starts -- before the +-- main defer loop begins (and before the window is shown). Primes the +-- edge-detection baseline with whatever keys are ALREADY down at that +-- instant, which typically still includes the modifier+letter combo the +-- user just used to invoke this very script via its REAPER shortcut +-- binding. Without this, the first real PollKeys() call has nothing to +-- compare against (prevState is nil), so an already-held key reads as a +-- brand new press. Also records which modifiers are part of that residue +-- (see residualModifiers above). +function M.PrimeKeyState() + prevState = reaper.JS_VKeys_GetState(0) + residualModifiers = {} + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if isDown(prevState, vk) then + residualModifiers[vk] = true + end + end +end + +-- Call once per frame. Returns a table: { down = {[vk]=true,...}, pressed +-- = {[vk]=true,...} (down this frame, up last frame), released = {...} } +function M.PollKeys() + local state = reaper.JS_VKeys_GetState(0) + local down, pressed, released = {}, {}, {} + + local function check(vk) + local nowDown = isDown(state, vk) + local wasDown = isDown(prevState, vk) + if nowDown then down[vk] = true end + if nowDown and not wasDown then pressed[vk] = true end + if wasDown and not nowDown then released[vk] = true end + end + + for _, vk in ipairs(M.MODIFIER_VKEYS) do check(vk) end + for _, vk in ipairs(M.TEXT_VKEYS) do check(vk) end + for _, vk in ipairs(M.CONTROL_VKEYS) do check(vk) end + + prevState = state + return { down = down, pressed = pressed, released = released } +end + +-- --------------------------------------------------------------------- +-- Capture state machine +-- +-- Usage: call NewCaptureState() once, then Update(state, keys) every +-- frame UNCONDITIONALLY -- don't skip frames where nothing is down; the +-- release that completes a modifier tap happens on exactly that kind of +-- frame. Inspect state.mode / state.text / state.heldModifierAtTap. +-- +-- mode: "idle" -- nothing entered yet this "session" (since last Reset) +-- "shortcut" -- at least one modifier has been tapped; still +-- collecting more modifier taps, or building the word +-- once text starts +-- "search" -- a text key came first; building an action-name search +-- --------------------------------------------------------------------- + +local MAX_SHORTCUT_LEN = 18 + +function M.NewCaptureState() + return { + mode = "idle", + text = "", + heldModifierAtTap = nil, -- combined label string, e.g. "Ctrl+Shift" + selectedModifiers = {}, -- persistent accumulated set of modifier vkeys + tappedModifiers = {}, -- vkeys currently down since THIS tap sequence started + anyOtherKeyDuringHold = false, + } +end + +function M.Reset(state) + state.mode = "idle" + state.text = "" + state.heldModifierAtTap = nil + state.selectedModifiers = {} + state.tappedModifiers = {} + state.anyOtherKeyDuringHold = false +end + +local function recomputeLabel(state) + local labels = {} + for vk in pairs(state.selectedModifiers) do + labels[#labels + 1] = M.MODIFIER_LABELS[vk] or "?" + end + table.sort(labels) + state.heldModifierAtTap = table.concat(labels, "+") +end + +-- Returns true if the text buffer changed (caller can use this to know +-- when to re-run the search filter). +function M.Update(state, keys) + local changed = false + + -- Still allowed to add more modifier taps as long as nothing's been + -- typed yet -- true whether this is the very first tap (mode "idle") + -- or a later one (mode already "shortcut" from an earlier tap). + local canStillCollectModifiers = (state.mode == "idle") or (state.mode == "shortcut" and state.text == "") + + -- Track by keys.down (currently held), not keys.pressed (fresh edge + -- this frame), so a modifier already held on the very first polled + -- frame (e.g. still physically down from the Cmd+Shift+A chord that + -- just launched this script) still counts toward a tap -- its press + -- edge was "consumed" by PrimeKeyState() so it wouldn't leak a stray + -- letter into search, but that shouldn't also stop it from completing + -- a tap. EXCEPT: skip residual modifiers entirely here -- see below, + -- their first release is a no-op grace period, not a real tap. + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if keys.down[vk] and not residualModifiers[vk] then + state.tappedModifiers[vk] = true + end + end + + -- A residual modifier (still down from the launch shortcut itself) + -- gets exactly one "grace release" that does NOT count as a completed + -- tap -- it's just the natural release of the keys used to open this + -- window, not a deliberate search gesture. After this, it's cleared + -- and behaves like any other modifier from here on. + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if residualModifiers[vk] and keys.released[vk] then + residualModifiers[vk] = nil + end + end + + local anyModifierHeld = false + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if keys.down[vk] then anyModifierHeld = true end + end + if anyModifierHeld then + for _, vk in ipairs(M.TEXT_VKEYS) do + if keys.pressed[vk] then state.anyOtherKeyDuringHold = true end + end + -- a second (or third) distinct modifier held together counts as part + -- of the same chord tap, not a disqualifier. + end + + local stillAnyModifierHeld = false + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if keys.down[vk] then stillAnyModifierHeld = true end + end + local releasedAModifier = false + for _, vk in ipairs(M.MODIFIER_VKEYS) do + if keys.released[vk] then releasedAModifier = true end + end + + if releasedAModifier and not stillAnyModifierHeld then + if canStillCollectModifiers and not state.anyOtherKeyDuringHold and next(state.tappedModifiers) ~= nil then + -- Merge this tap's modifiers into the running set (doesn't replace + -- what was already collected from an earlier, separate tap). + for vk in pairs(state.tappedModifiers) do + state.selectedModifiers[vk] = true + end + state.mode = "shortcut" + recomputeLabel(state) + end + state.tappedModifiers = {} + state.anyOtherKeyDuringHold = false + end + + -- Text keys: append to buffer (only meaningful once we're not idle, + -- or to kick off "search" mode if idle and this is the first input). + for _, vk in ipairs(M.TEXT_VKEYS) do + if keys.pressed[vk] then + if state.mode == "idle" then + state.mode = "search" + end + if #state.text < MAX_SHORTCUT_LEN or state.mode == "search" then + local isShift = keys.down[VK_SHIFT] + local ch = vkeyToChar(vk, isShift) + if ch then + state.text = state.text .. ch + changed = true + end + end + end + end + + -- Backspace: trims the buffer. If that empties it back to nothing while + -- in "shortcut" mode, the modifier set stays intact (you can keep + -- adding more modifier taps or just retype the word) -- only a full + -- Reset() clears the modifier set itself. + if keys.pressed[M.VK_BACK] and #state.text > 0 then + state.text = state.text:sub(1, -2) + changed = true + end + + return changed +end + +-- Render state.text yourself as a plain Text widget (not an ImGui +-- InputText) -- see the note above about why ImGui widgets don't receive +-- keystrokes while the intercept is active. Enter/Escape should be read +-- by the caller directly off keys.pressed[capture.VK_RETURN] / +-- keys.pressed[capture.VK_ESCAPE] each frame, same reasoning. + +return M diff --git a/Various/CustomShortcuts_Activate/CustomShortcuts_core.lua b/Various/CustomShortcuts_Activate/CustomShortcuts_core.lua new file mode 100644 index 000000000..2e55d6cb6 --- /dev/null +++ b/Various/CustomShortcuts_Activate/CustomShortcuts_core.lua @@ -0,0 +1,461 @@ +-- @noindex + +--[[ + * @noindex + + CustomShortcuts_core.lua + + Shared library for the custom shortcut system. Both entry-point scripts + (Activate and Create) require() this file. Nothing in here opens a window + or runs a UI loop -- it's pure data/logic so both scripts stay in sync. + + Depends on: ReaImGui, js_ReaScriptAPI (checked by the entry-point scripts, + not here). +--]] + +local M = {} + +-- Needed for MODIFIER_LABELS (platform-correct "Opt" vs "Alt", etc.) in +-- TryParseSingleCharShortcut below. Safe to require here: capture.lua +-- doesn't require core.lua, so there's no cycle, and package.path is +-- already set up by whichever entry-point script (Activate/Create) +-- required this file first. +local capture = require("CustomShortcuts_capture") + +-- --------------------------------------------------------------------- +-- Sections +-- --------------------------------------------------------------------- +-- NOTE: "MIDI Inline Editor" (section 32062) is intentionally left out. +-- There is no known API call that can dispatch an action into that specific +-- context, so custom shortcuts for it would be unrunnable. If that turns +-- out to be wrong, it can be added back in. +M.SECTIONS = { + { id = 0, name = "Main" }, + { id = 100, name = "Main (alt recording)" }, + { id = 32060, name = "MIDI Editor" }, + { id = 32061, name = "MIDI Event List Editor" }, + { id = 32063, name = "Media Explorer" }, +} + +M.SECTION_MAIN = 0 +M.SECTION_MAIN_ALT = 100 +M.SECTION_MIDI_EDITOR = 32060 +M.SECTION_MIDI_EVENTLIST = 32061 +M.SECTION_MEDIA_EXPLORER = 32063 + +function M.SectionName(id) + for _, s in ipairs(M.SECTIONS) do + if s.id == id then return s.name end + end + return "Unknown (" .. tostring(id) .. ")" +end + +-- --------------------------------------------------------------------- +-- Paths +-- --------------------------------------------------------------------- +-- Use this file's OWN location (not reaper.get_action_context(), which +-- describes whichever script is running as an action, not necessarily +-- reliable to call from inside a required module) -- core.lua always +-- lives in the same folder as both entry-point scripts. +local selfInfo = debug.getinfo(1, "S") +M.SCRIPT_DIR = selfInfo.source:match("^@(.*[/\\])") or (reaper.GetResourcePath() .. "/Scripts/CustomShortcuts/") +M.DATA_FILE = M.SCRIPT_DIR .. "CustomShortcuts_data.lua" +M.ACTIVATE_SCRIPT = M.SCRIPT_DIR .. "CustomShortcuts_Activate.lua" +M.CREATE_SCRIPT = M.SCRIPT_DIR .. "CustomShortcuts_Create.lua" + +-- --------------------------------------------------------------------- +-- Persistent command-ID resolution +-- +-- kbd_enumerateActions gives back a numeric command ID that is only valid +-- for the current REAPER session -- ReaScript/custom-action numeric IDs are +-- reassigned on every restart. To store something in a file that's still +-- correct next week, native (built-in) actions can use their raw numeric ID +-- directly (those ARE stable across restarts), but scripts/custom actions +-- need their string identifier instead, reconstructed via +-- ReverseNamedCommandLookup. +-- --------------------------------------------------------------------- + +-- Returns (persistentIdString, isNative) +function M.GetPersistentId(cmdId) + local lookup = reaper.ReverseNamedCommandLookup(cmdId) + if lookup == nil then + return tostring(cmdId), true + else + return "_" .. lookup, false + end +end + +-- Turns a persistent ID string back into a session-valid numeric command ID. +-- Returns nil if it can no longer be resolved (e.g. the script/extension +-- that provided it was removed). +function M.ResolveRuntimeId(persistentId) + if not persistentId or persistentId == "" then return nil end + if persistentId:sub(1, 1) == "_" then + local id = reaper.NamedCommandLookup(persistentId) + if id == 0 then return nil end + return id + else + return tonumber(persistentId) + end +end + +-- --------------------------------------------------------------------- +-- Action enumeration +-- --------------------------------------------------------------------- + +-- Returns an array of { cmdId = , name = } +-- for every action registered in the given section. +function M.EnumerateSectionActions(sectionId) + local actions = {} + local idx = 0 + while true do + local cmdId, name = reaper.kbd_enumerateActions(sectionId, idx) + if not cmdId or cmdId == 0 then break end + actions[#actions + 1] = { cmdId = cmdId, name = name } + idx = idx + 1 + end + return actions +end + +-- --------------------------------------------------------------------- +-- Native REAPER shortcuts (read-only reference / seed source) +-- --------------------------------------------------------------------- + +-- Returns an array of shortcut description strings REAPER already has +-- bound to this command in this section (usually 0 or 1 entries, but an +-- action can have more than one). +function M.GetNativeShortcuts(sectionId, cmdId) + local out = {} + local ok, count = pcall(reaper.CountActionShortcuts, sectionId, cmdId) + if not ok or not count then return out end + for i = 0, count - 1 do + local ok2, retval, desc = pcall(reaper.GetActionShortcutDesc, sectionId, cmdId, i) + if ok2 and retval and desc and desc ~= "" then + out[#out + 1] = desc + end + end + return out +end + +-- --------------------------------------------------------------------- +-- Action dispatch (varies by section) +-- --------------------------------------------------------------------- + +-- Returns true if a MIDI editor is currently open (regardless of whether +-- it's frontmost). +function M.GetActiveMidiEditor() + return reaper.MIDIEditor_GetActive() +end + +-- Attempts to find the Media Explorer window via js_ReaScriptAPI. +-- Returns nil if it's not open or js_ReaScriptAPI isn't available. +function M.GetMediaExplorerWindow() + if not reaper.JS_Window_Find then return nil end + return reaper.JS_Window_Find("Media Explorer", true) +end + +-- Runs the given action. sectionId + cmdId must both be session-valid +-- (i.e. already resolved via ResolveRuntimeId if they came from disk). +-- Returns true/false for whether it was actually able to dispatch it. +function M.RunAction(sectionId, cmdId) + if not cmdId then return false end + + if sectionId == M.SECTION_MAIN or sectionId == M.SECTION_MAIN_ALT then + reaper.Main_OnCommand(cmdId, 0) + return true + + elseif sectionId == M.SECTION_MIDI_EDITOR or sectionId == M.SECTION_MIDI_EVENTLIST then + local editor = M.GetActiveMidiEditor() + if not editor then return false end + reaper.MIDIEditor_OnCommand(editor, cmdId) + return true + + elseif sectionId == M.SECTION_MEDIA_EXPLORER then + local wnd = M.GetMediaExplorerWindow() + if not wnd or not reaper.JS_Window_OnCommand then return false end + reaper.JS_Window_OnCommand(wnd, cmdId) + return true + end + + return false +end + +-- Is the given section currently "reachable" -- i.e. is there actually a +-- window open right now that an action in this section could run against? +-- Main is always reachable. Used to filter search results so we never show +-- something the user can't currently execute. +function M.IsSectionActive(sectionId) + if sectionId == M.SECTION_MAIN or sectionId == M.SECTION_MAIN_ALT then + return true + elseif sectionId == M.SECTION_MIDI_EDITOR or sectionId == M.SECTION_MIDI_EVENTLIST then + return M.GetActiveMidiEditor() ~= nil + elseif sectionId == M.SECTION_MEDIA_EXPLORER then + return M.GetMediaExplorerWindow() ~= nil + end + return false +end + +-- Snapshot "what context is REAPER in right now" -- must be called BEFORE +-- your ReaImGui window is created/focused, since after that point the +-- foreground window is your script, not REAPER's, and this signal is lost. +-- Returns the specific section id if one is active, else Main. +-- +-- SIMPLIFICATION: this treats "a MIDI editor is open at all" as the +-- signal, rather than confirming it's specifically the frontmost/focused +-- window at this instant. If you have a MIDI editor open in the +-- background while working in the arrange view, this will still prefer +-- the MIDI editor section. +function M.SnapshotActiveSection() + local editor = M.GetActiveMidiEditor() + if editor then + return M.SECTION_MIDI_EDITOR + end + return M.SECTION_MAIN +end + +-- --------------------------------------------------------------------- +-- Data file (plain Lua table, not JSON -- no parser needed, and it's +-- human-readable/editable in a pinch) +-- +-- Shape: +-- { +-- [sectionId] = { +-- shortcuts = { +-- ["punch out"] = { id = "_RS1a2b3c...", name = "Transport: ..." }, +-- ... +-- } +-- }, +-- ... +-- } +-- --------------------------------------------------------------------- + +local function serialize(val, indent) + indent = indent or "" + local t = type(val) + if t == "string" then + return string.format("%q", val) + elseif t == "number" or t == "boolean" then + return tostring(val) + elseif t == "table" then + local child_indent = indent .. " " + local parts = {} + for k, v in pairs(val) do + local key_str + if type(k) == "number" then + key_str = "[" .. k .. "]" + else + key_str = "[" .. string.format("%q", tostring(k)) .. "]" + end + parts[#parts + 1] = child_indent .. key_str .. " = " .. serialize(v, child_indent) + end + if #parts == 0 then return "{}" end + return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}" + else + return "nil" + end +end + +function M.SaveData(data) + local f, err = io.open(M.DATA_FILE, "w") + if not f then return false, err end + f:write("-- Auto-generated by CustomShortcuts. Editable by hand if needed,\n") + f:write("-- but back it up before doing so.\n") + f:write("return " .. serialize(data) .. "\n") + f:close() + return true +end + +function M.LoadData() + local f = io.open(M.DATA_FILE, "r") + if not f then + return { } + end + f:close() + local chunk, err = loadfile(M.DATA_FILE) + if not chunk then + reaper.ShowConsoleMsg("CustomShortcuts: failed to parse data file: " .. tostring(err) .. "\n") + return { } + end + local ok, result = pcall(chunk) + if not ok or type(result) ~= "table" then + reaper.ShowConsoleMsg("CustomShortcuts: data file did not evaluate to a table\n") + return { } + end + return result +end + +-- Ensures data.sections style access always works even for a brand new file. +function M.EnsureSection(data, sectionId) + if not data[sectionId] then + data[sectionId] = { shortcuts = {} } + end + if not data[sectionId].shortcuts then + data[sectionId].shortcuts = {} + end + return data[sectionId] +end + +-- --------------------------------------------------------------------- +-- Auto-seeding from REAPER's own native shortcuts +-- +-- Moved here from Create.lua (build 8) so Activate can use the exact +-- same logic instead of a second, drifting copy -- both entry points +-- need it now: Activate so the data file stays current on every normal +-- launch without depending on Create ever having run, and Create for +-- the two sections (Media Explorer, MIDI Event List Editor) that +-- Activate's own SnapshotActiveSection() can never select on its own, +-- so it structurally can't seed them. +-- --------------------------------------------------------------------- + +-- Splits REAPER's own shortcut description (e.g. "Cmd+M", "Ctrl+Shift+M", +-- "Cmd+Opt+Z") into modifier tokens + a trailing single character. +-- Returns a phrase in OUR OWN modifier-label format (e.g. "Cmd+m", +-- "Cmd+Opt+z" -> "Cmd+Opt+z" on Mac, "Alt+Cmd+z" on Windows -- see +-- capture.MODIFIER_LABELS), or nil if the native shortcut isn't a simple +-- modifier(s)+single-char combo, or if a modifier token isn't recognized +-- (better to skip auto-seeding than guess wrong). +function M.TryParseSingleCharShortcut(desc) + local parts = {} + for tok in desc:gmatch("[^%+]+") do parts[#parts + 1] = tok end + local last = parts[#parts] + if not last or #last ~= 1 then return nil end + + if #parts == 1 then + return last:lower() -- bare single-char native shortcut, no modifiers + end + + local labels = {} + for i = 1, #parts - 1 do + local tok = parts[i]:lower() + local label = nil + if tok:find("cmd") or tok:find("command") then label = "Cmd" + elseif tok:find("ctrl") or tok:find("control") then label = "Ctrl" + elseif tok:find("shift") then label = "Shift" + elseif tok:find("alt") or tok:find("option") or tok:find("opt") then label = capture.MODIFIER_LABELS[capture.VK_MENU] + elseif tok:find("win") then label = "Win" + end + if label then labels[label] = true end + end + + local sorted = {} + for l in pairs(labels) do sorted[#sorted + 1] = l end + table.sort(sorted) + + if #sorted ~= (#parts - 1) then + -- At least one modifier token wasn't recognized -- don't guess; + -- skip auto-seeding this one rather than risk an incomplete phrase. + return nil + end + + return table.concat(sorted, "+") .. "+" .. last:lower() +end + +-- Auto-seeds any of this section's actions that have a simple native +-- REAPER shortcut but no saved custom one yet, merging directly into +-- `data`. Does NOT write to disk -- Activate and Create each call this +-- at different points and only want one SaveData() per pass, so the +-- caller decides when to actually persist. Returns the count of newly +-- seeded phrases (0 if nothing changed, so callers can skip the write +-- entirely when there's nothing new). +function M.AutoSeedSection(data, sectionId) + local sec = M.EnsureSection(data, sectionId) + local byPersistentId = {} + for phrase, entry in pairs(sec.shortcuts) do + byPersistentId[entry.id] = phrase + end + + local actions = M.EnumerateSectionActions(sectionId) + -- Collected separately from sec.shortcuts and merged in afterward, so + -- two actions that happen to native-seed the same phrase in this same + -- pass don't silently overwrite each other -- the first one to claim + -- a phrase keeps it, same as the manual-edit path's conflict handling + -- (auto-seed just has no interactive resolution to offer, so leaving + -- the second one unseeded is the safer default). + local newlySeeded = {} + for _, a in ipairs(actions) do + local persistentId = M.GetPersistentId(a.cmdId) + if not byPersistentId[persistentId] then + local native = M.GetNativeShortcuts(sectionId, a.cmdId) + if native[1] then + local parsed = M.TryParseSingleCharShortcut(native[1]) + if parsed and not sec.shortcuts[parsed] and not newlySeeded[parsed] then + newlySeeded[parsed] = { id = persistentId, name = a.name } + end + end + end + end + + local count = 0 + for phrase, entry in pairs(newlySeeded) do + sec.shortcuts[phrase] = entry + count = count + 1 + end + return count +end + +-- --------------------------------------------------------------------- +-- Search matching +-- --------------------------------------------------------------------- + +-- Combines a capture state's tapped-modifier label and typed word into +-- the single phrase format used for storage/lookup (e.g. "Cmd+punch"). +-- Works for either Activate's live captureState or Create's per-row +-- editCapture -- both share the same { heldModifierAtTap, text } shape. +function M.CombinedPhrase(captureState) + if not captureState or captureState.text == "" then return "" end + if captureState.heldModifierAtTap and captureState.heldModifierAtTap ~= "" then + return captureState.heldModifierAtTap .. "+" .. captureState.text + end + return captureState.text +end + +-- REAPER Action List style matching: splits `needle` on whitespace into +-- separate words and requires EVERY word to appear somewhere in +-- `haystack` (case-insensitive, any order) -- so typing "track show" +-- matches "Track: Show FX chain", same as typing the same words into +-- REAPER's own Action List would. Returns a score for ranking (0 = no +-- match, higher = a "better" match) rather than just a boolean, so +-- callers that want a sensible "top result" (e.g. Enter-to-run) can sort +-- by it; callers that just want a filter can simply check `> 0`. +-- `needle` should already be lowercase; `haystack` doesn't need to be. +function M.ScoreNameMatch(haystack, needle) + if needle == "" then return 0 end + local h = haystack:lower() + local words = {} + for w in needle:gmatch("%S+") do words[#words + 1] = w end + if #words == 0 then return 0 end + + for _, w in ipairs(words) do + if not h:find(w, 1, true) then return 0 end + end + + if h == needle then return 4 end + if h:find(needle, 1, true) == 1 then return 3 end + if h:find(words[1], 1, true) == 1 then return 2 end + return 1 +end + +-- --------------------------------------------------------------------- +-- Cross-script coordination (mutual shutdown) +-- --------------------------------------------------------------------- + +-- Resolves a sibling script's command ID (registers it if it isn't +-- registered yet -- idempotent, safe to call every time). +function M.GetSiblingCommandId(scriptPath, sectionId) + return reaper.AddRemoveReaScript(true, sectionId or 0, scriptPath, true) +end + +-- If the sibling script is currently running (as a toggle action via +-- set_action_options(1)), invoke it once to make REAPER terminate it. +-- Does nothing if it's not running, so this is always safe to call. +function M.ShutDownSiblingIfRunning(scriptPath, sectionId) + sectionId = sectionId or 0 + local cmdId = M.GetSiblingCommandId(scriptPath, sectionId) + if not cmdId or cmdId == 0 then return end + local state = reaper.GetToggleCommandStateEx(sectionId, cmdId) + if state == 1 then + reaper.Main_OnCommand(cmdId, 0) + end +end + +return M diff --git a/Various/CustomShortcuts_Activate/LICENSE.md b/Various/CustomShortcuts_Activate/LICENSE.md new file mode 100644 index 000000000..c491dcedf --- /dev/null +++ b/Various/CustomShortcuts_Activate/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Glenn Burgos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Various/CustomShortcuts_Activate/README.md b/Various/CustomShortcuts_Activate/README.md new file mode 100644 index 000000000..4ec69a617 --- /dev/null +++ b/Various/CustomShortcuts_Activate/README.md @@ -0,0 +1,145 @@ +# CustomShortcuts + +A fast, searchable custom-shortcut system for REAPER that lives outside REAPER's +own keyboard shortcut list. Only one REAPER shortcut is ever needed, bound to +`CustomShortcuts_Activate.lua`. + +## Requirements + +- **ReaImGui** and **js_ReaScriptAPI**, both installable via ReaPack + (Extensions > ReaPack > Browse packages). +- REAPER on Windows or macOS. (Linux uses the same SWELL-based keyboard layer + as macOS internally, so it should behave like macOS below, but hasn't been + tested.) + +## Files + +| File | Purpose | +|---|---| +| `CustomShortcuts_Activate.lua` | The everyday entry point. Bind your one REAPER shortcut to this. | +| `CustomShortcuts_Create.lua` | The shortcut editor. Opened via the "Edit Shortcuts..." button in Activate -- never needs its own bound shortcut. | +| `CustomShortcuts_core.lua` | Shared data/logic (sections, persistence, auto-seeding, action dispatch). | +| `CustomShortcuts_capture.lua` | Shared keyboard-capture engine (modifier-tap detection, text entry, platform-specific key handling). | +| `CustomShortcuts_data.lua` | Auto-generated. Holds your saved custom-shortcut phrases. Created/updated automatically -- see below. | + +## Setup + +1. Install ReaImGui and js_ReaScriptAPI via ReaPack if you haven't already. +2. Actions List > find "Script: CustomShortcuts_Activate.lua" > assign it a + keyboard shortcut. This is the only REAPER shortcut you need. +3. Run it. A small search window opens. + +## Using Activate mode + +- **Tap a modifier** (Cmd/Ctrl/Shift/Opt, or a combination -- tap each one and + release before typing) to search by your own custom shortcut phrase. +- **Type a letter first** to search action names instead, the same + multi-word matching REAPER's own Action List uses. +- **Enter** runs the top result and closes the window. **Double-click** runs + whichever row you clicked. **Escape** closes without running anything. +- The first 10 results are numbered -- **Tab** then a digit runs one without + touching the mouse. +- Pressing your bound shortcut again while the window is open closes it + (toggle). + +## Using Create mode + +Opened via "Edit Shortcuts..." in Activate. Lists every action in a section; +click a row's custom-shortcut cell and type a phrase the same way you would +in Activate (tap modifiers, then type a word). Press Enter to confirm a row +and move to the next, Escape to cancel editing that row. + +**Auto-seeding:** if an action already has a simple native REAPER shortcut +(one or more modifiers plus a single character, e.g. `Cmd+M`) and no custom +phrase yet, Create (and Activate -- see below) fills it in automatically using +that same combination, translated into this system's phrase format. + +**Saving:** typed edits are explicit -- nothing is written to disk until you +click Save (or close the window, which prompts if there are unsaved changes). +Auto-seeded values are the exception: since you never typed or confirmed +those yourself, they're written to disk immediately at the moment they're +seeded, with nothing for you to save. + +## The data file + +`CustomShortcuts_data.lua`, saved next to the scripts, is plain Lua (not +JSON) so it's readable/editable by hand in a pinch -- back it up first if you +do. Activate loads it fresh every launch; Create loads it once per session +and writes back explicit edits plus any auto-seeding it does itself. + +**Activate creates and updates this file too, not just Create.** Every time +Activate runs, it auto-seeds and saves any missing custom shortcuts for the +current section (Main, plus whatever context is active, e.g. an open MIDI +editor) from REAPER's own native bindings -- so newly added REAPER shortcuts +become available here without ever needing to open Create. Two sections +(Media Explorer, MIDI Event List Editor) are only ever reachable by browsing +to them in Create, since Activate has no way to detect that context on its +own -- those two are seeded/saved by Create instead. + +## Known REAPER interaction: Enter/Return sometimes needs two presses + +If pressing Enter in Activate doesn't run the top result the first time, but +does on a second press, this is very likely **not a bug in this script** -- +it's a collision with a native REAPER keyboard shortcut. + +**What's happening:** REAPER's Main section, out of the box, often has +something bound to the bare Enter/Return key, and depending on how that +binding is scoped, REAPER can dispatch it as a **Global** shortcut -- meaning +it fires system-wide, regardless of which window has OS focus, through a +higher-priority path than normal keyboard input. js_ReaScriptAPI's key +interception (`JS_VKeys_Intercept`), which this script relies on to capture +keystrokes while its window is focused, cannot block a Global shortcut -- +confirmed directly by the extension's author: + +> "Global keystrokes override all other plugins, and AFAIK cannot temporarily +> be deactivated... Global keystrokes can be intercepted without getting +> passed through as long as they are not Global+text fields." + +In practice this means the first Enter press gets consumed by REAPER's native +binding -- invisibly, since it happens before this script (or even ReaImGui +itself) ever sees the keystroke -- and only the second press reaches this +script normally. + +**How to check/fix it:** + +1. Open REAPER's Actions List, make sure the section is set to **Main**. +2. Look through the Key Shortcuts for whatever is bound to plain Enter/Return + (no modifiers). Sorting or searching the shortcut column may help find it. +3. Check whether that binding is scoped as **Global**. +4. Either remove/rebind that native shortcut, or change its scope, depending + on which behavior you'd rather keep. This is a REAPER keymap setting, not + something this script can override or work around from ReaScript -- by + design, Global shortcuts are meant to be unblockable. + +## REAPER Preferences that affect ReaImGui window behavior + +A couple of settings under **Options > Preferences > General > Advanced +UI/system tweaks** are worth knowing about if this script's window ever +behaves oddly: + +- **Allow keyboard commands when mouse-editing** -- ReaImGui's own changelog + documents a fix for "Alt key input while holding down a mouse button when + 'Allow keyboard commands when mouse-editing' is disabled" (Windows). If + Alt/Opt-tap detection in this script ever seems to misbehave specifically + while a mouse button is also held, check this setting. +- **HiDPI mode** -- affects how ReaImGui (and REAPER generally) scales on + high-resolution displays. Shouldn't affect keyboard behavior, but can affect + whether the window's size/position looks right. +- **Modal window positioning** -- affects where floating/utility windows like + this one open on screen. + +More generally: any REAPER preference that changes how keyboard shortcuts are +scoped or dispatched (Main vs. Global, per the Enter issue above) is the more +likely culprit for keyboard-specific oddities than anything under Advanced +UI/system tweaks, since those are mostly rendering/positioning related. + +## Platform notes + +- **Modifier labels** differ by OS to match what's actually printed on the + key: Option shows as "Opt" on Mac, "Alt" on Windows. Phrases are stored + using these labels, so a shortcut saved on one OS won't automatically match + on the other if it uses this key -- re-save it on each machine you use. +- **Punctuation** in typed phrases/searches is handled differently per + platform under the hood (Windows uses real hardware VK codes; Mac uses + SWELL's ASCII-fallback behavior for punctuation keys) but should produce + the same visible characters either way.