From 555264cf5c120cbb21496d2650baf5c487a2dfb8 Mon Sep 17 00:00:00 2001 From: BenTalagan Date: Wed, 22 Oct 2025 18:50:52 +0200 Subject: [PATCH] Release Reannotate v0.3 (Initial Release) --- Various/talagan_Reannotate.lua | 34 + .../talagan_Reannotate Quick Preview.lua | 83 ++ .../classes/app_context.lua | 132 ++ .../classes/arrange_view_watcher.lua | 66 + Various/talagan_Reannotate/classes/color.lua | 253 ++++ .../classes/launch_context.lua | 76 ++ .../classes/mouse_observer.lua | 59 + Various/talagan_Reannotate/classes/notes.lua | 328 +++++ .../talagan_Reannotate/ext/dependencies.lua | 43 + Various/talagan_Reannotate/ext/imgui.lua | 8 + Various/talagan_Reannotate/ext/json.lua | 391 ++++++ .../talagan_Reannotate/images/settings.lua | 19 + Various/talagan_Reannotate/modules/debug.lua | 73 ++ .../talagan_Reannotate/modules/settings.lua | 108 ++ .../talagan_Reannotate/modules/unit_tests.lua | 11 + .../widgets/note_editor.lua | 227 ++++ .../widgets/quick_preview_overlay.lua | 1115 +++++++++++++++++ .../widgets/settings_editor.lua | 120 ++ 18 files changed, 3146 insertions(+) create mode 100644 Various/talagan_Reannotate.lua create mode 100644 Various/talagan_Reannotate/actions/talagan_Reannotate Quick Preview.lua create mode 100644 Various/talagan_Reannotate/classes/app_context.lua create mode 100644 Various/talagan_Reannotate/classes/arrange_view_watcher.lua create mode 100644 Various/talagan_Reannotate/classes/color.lua create mode 100644 Various/talagan_Reannotate/classes/launch_context.lua create mode 100644 Various/talagan_Reannotate/classes/mouse_observer.lua create mode 100644 Various/talagan_Reannotate/classes/notes.lua create mode 100644 Various/talagan_Reannotate/ext/dependencies.lua create mode 100644 Various/talagan_Reannotate/ext/imgui.lua create mode 100644 Various/talagan_Reannotate/ext/json.lua create mode 100644 Various/talagan_Reannotate/images/settings.lua create mode 100644 Various/talagan_Reannotate/modules/debug.lua create mode 100644 Various/talagan_Reannotate/modules/settings.lua create mode 100644 Various/talagan_Reannotate/modules/unit_tests.lua create mode 100644 Various/talagan_Reannotate/widgets/note_editor.lua create mode 100644 Various/talagan_Reannotate/widgets/quick_preview_overlay.lua create mode 100644 Various/talagan_Reannotate/widgets/settings_editor.lua diff --git a/Various/talagan_Reannotate.lua b/Various/talagan_Reannotate.lua new file mode 100644 index 000000000..256b6ea23 --- /dev/null +++ b/Various/talagan_Reannotate.lua @@ -0,0 +1,34 @@ +--[[ +@description Reannotate - Annotation tool for REAPER +@version 0.3.0 +@author Ben 'Talagan' Babut +@donation https://www.paypal.com/donate/?business=3YEZMY9D6U8NC&no_recurring=1¤cy_code=EUR +@license MIT +@screenshot + https://stash.reaper.fm/50870/reannotate_screenshot_reapack.png +@links + Forum Thread https://forum.cockos.com/showthread.php?t= TODO +@metapackage +@changelog + - First release +@provides + [nomain] talagan_Reannotate/ext/**/* + [nomain] talagan_Reannotate/classes/**/* + [nomain] talagan_Reannotate/images/**/* + [nomain] talagan_Reannotate/modules/**/* + [nomain] talagan_Reannotate/widgets/**/* + + [main=main] talagan_Reannotate/actions/talagan_Reannotate Quick Preview.lua > talagan_Reannotate Quick Preview.lua +@about + Reannotate is a visual annotation tool for Reaper. + + It comes as a modal overlay over Reaper, allowing to quickly consult and add some kind of colored "Post-It" notes over objects (tracks, envelopes, items, and project). These notes can be previewed as tooltips by hovering the mouse over objects and can use markdown syntax. + + Basic filtering by category and search is available (pretty basic for now but may evolve). + + SWS and Reaper notes may be edited with Reannotate. They will appear in a dedicated category, allowing retro-compatibility for older projects or projects written by users not using Reannotate. + + You can consult the forum thread for more info. + + Please note that this tool is very young and may contain bugs. +--]] diff --git a/Various/talagan_Reannotate/actions/talagan_Reannotate Quick Preview.lua b/Various/talagan_Reannotate/actions/talagan_Reannotate Quick Preview.lua new file mode 100644 index 000000000..48134bece --- /dev/null +++ b/Various/talagan_Reannotate/actions/talagan_Reannotate Quick Preview.lua @@ -0,0 +1,83 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local ACTION = debug.getinfo(1,"S").source +local ACTION_DIR = (ACTION:match[[^@?(.*[\/])[^\/]-$]]):gsub("talagan_Reannotate/actions/$","/") -- Works both in dev and prod + +package.path = package.path .. ";" .. ACTION_DIR .. "talagan_Reannotate/?.lua" +package.path = package.path .. ";" .. reaper.ImGui_GetBuiltinPath() .. '/?.lua' +package.path = package.path .. ";" .. (reaper.GetResourcePath() .. "/Scripts/ReaTeam Scripts/Development/talagan_ReaImGui Markdown") .. '/?.lua' + +local Dependencies = require "ext/dependencies" +if not Dependencies.checkDependencies() then + return +end + +local AppContext = require "classes/app_context" +local QuickPreviewOverlay = require "widgets/quick_preview_overlay" + +local S = require "modules/settings" +local D = require "modules/debug" + +S.setSetting("UseDebugger", false) +S.setSetting("UseProfiler", false) +D.LaunchDebugStubIfNeeded() +D.LaunchProfilerIfNeeded() + +local app_ctx = AppContext:new() +local overlay = QuickPreviewOverlay:new() + +-- Force focus on the main window to avoid glitches +reaper.JS_Window_SetFocus(app_ctx.mv.hwnd) + +if app_ctx.launch_context:isLaunchedByKeyboardShortcut() then + -- The action is launched by shortcut + -- Prevent the action to be re-launched (flag == 2). + -- We'll track the holding of the shortcut and quit when released + reaper.set_action_options(4|2) +else + -- The action is launched from a button... or something else + -- We want it to keep running until it's toggled off + reaper.set_action_options(1|4) +end + +--log("Launched") + +function MainLoop() + app_ctx:tick() + + if not app_ctx.launch_context:isShortcutStillPressed() then + app_ctx.shortcut_was_released_once = true + end + + --log("Running...") + app_ctx:updateWindowLayouts() + + if app_ctx.arrange_view_watcher:tick() then + overlay:updateVisibleThings() + end + + overlay:draw() + + if app_ctx.want_quit then + return + end + + -- Defer the loop + reaper.defer(MainLoop) +end + +-- Set focus to the arrange view. This prevent bugs +-- For example if the MIDI Editor has focus, the held shortcut +-- Will glitch +reaper.defer(MainLoop) + +-- Register cleanup function on script exit +reaper.atexit(function() + -- Restore focus. + reaper.JS_Window_SetFocus(app_ctx.launch_context.focused_hwnd) + reaper.set_action_options(8) + --log("Exiting.") +end) diff --git a/Various/talagan_Reannotate/classes/app_context.lua b/Various/talagan_Reannotate/classes/app_context.lua new file mode 100644 index 000000000..12a35fa30 --- /dev/null +++ b/Various/talagan_Reannotate/classes/app_context.lua @@ -0,0 +1,132 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local LaunchContext = require "classes/launch_context" +local ArrangeViewWatcher = require "classes/arrange_view_watcher" +local ImGui = require "ext/imgui" + +local Notes = require "classes/notes" + +local AppContext = {} +AppContext.__index = AppContext + +function AppContext:new() + local instance = {} + setmetatable(instance, self) + instance:_initialize() + return instance +end + +function AppContext:findMCPHwnds() + + local mixerHwnd = reaper.JS_Window_Find("Mixer",true) + local next_child = mixerHwnd + + local master_mcp_hwnd = nil + local other_mcp_hwnd = nil + while next_child do + next_child = reaper.JS_Window_FindEx(mixerHwnd, next_child, "REAPERMCPDisplay", "") + if next_child then + local title = reaper.JS_Window_GetTitle(next_child) + if title == "master" then + master_mcp_hwnd = next_child + else + other_mcp_hwnd = next_child + end + end + end + + return master_mcp_hwnd, other_mcp_hwnd +end + +function AppContext:getImage(image_name) + self.images = self.images or {} + local images = self.images + + if (not images[image_name]) or (not ImGui.ValidatePtr(images[image_name], 'ImGui_Image*')) then + local bin = require("images/" .. image_name) + images[image_name] = ImGui.CreateImageFromMem(bin) + -- Prevent the GC from freeing this image + ImGui.Attach(self.imgui_ctx, images[image_name]) + end + + return images[image_name] +end + +function AppContext:_initialize() + self.launch_context = LaunchContext:new() + self.arrange_view_watcher = ArrangeViewWatcher:new() + self.mv = { hwnd=reaper.GetMainHwnd() } + self.av = { hwnd=reaper.JS_Window_FindChildByID(self.mv.hwnd, 1000) } + self.tcp = { hwnd=reaper.JS_Window_FindEx(reaper.GetMainHwnd(), reaper.GetMainHwnd(), "REAPERTCPDisplay", "") } + self.main_toolbar = { hwnd=reaper.JS_Window_Find('Main toolbar', true)} + self.time_ruler = { hwnd=reaper.JS_Window_FindChildByID(self.mv.hwnd, 1005) } + + local master_mcp_hwnd, other_mcp_hwnd = self:findMCPHwnds() + + self.mcp_master = { hwnd=master_mcp_hwnd } + self.mcp_other = { hwnd=other_mcp_hwnd } + + self.imgui_ctx = ImGui.CreateContext("Reannotate") + + self.cursor_func = ImGui.CreateFunctionFromEEL([[ + (WANTED_CURSOR >= 0)?( + CursorPos = WANTED_CURSOR; + WANTED_CURSOR = -1; + ); + ]]) + + self.arial_font = ImGui.CreateFont("Arial", ImGui.FontFlags_None) + self.arial_font_italic = ImGui.CreateFont("Arial", ImGui.FontFlags_Italic | ImGui.FontFlags_Bold) + + self.enabled_category_filters = {} + for i=1, Notes.MAX_SLOTS do + self.enabled_category_filters[i] = true + end + + ImGui.Attach(self.imgui_ctx, self.cursor_func) + ImGui.Attach(self.imgui_ctx, self.arial_font) + + AppContext.__singleton = self +end + +function AppContext:retrieveCoordinates(sub, scrollbar_w, scrollbar_h) + if not sub.hwnd then return end + local _, x, y, r, b = reaper.JS_Window_GetRect(sub.hwnd) + + sub.x, sub.w, sub.h = x, r - x, math.abs(b - y) + sub.x, sub.y = ImGui.PointConvertNative(self.imgui_ctx, x, y, true ) + + if scrollbar_w then sub.w = sub.w - scrollbar_w end + if scrollbar_h then sub.h = sub.h - scrollbar_h end + +end + +function AppContext:updateWindowLayouts() + + self:retrieveCoordinates(self.av, 16, 16) + self:retrieveCoordinates(self.mv) + self:retrieveCoordinates(self.tcp) + self:retrieveCoordinates(self.mcp_master) + self:retrieveCoordinates(self.mcp_other) + self:retrieveCoordinates(self.main_toolbar) + self:retrieveCoordinates(self.time_ruler) + + self.av.start_time, self.av.end_time = reaper.GetSet_ArrangeView2(0, false, 0, 0) +end + +function AppContext:tick() + self.frame_time = reaper.time_precise() +end + +function AppContext:flog(txt) + --reaper.ShowConsoleMsg("[" .. string.format("%.3f",self.frame_time) .. "] " .. txt .. "\n") +end + +function AppContext.instance() + return AppContext.__singleton +end + +return AppContext diff --git a/Various/talagan_Reannotate/classes/arrange_view_watcher.lua b/Various/talagan_Reannotate/classes/arrange_view_watcher.lua new file mode 100644 index 000000000..5c36b9d35 --- /dev/null +++ b/Various/talagan_Reannotate/classes/arrange_view_watcher.lua @@ -0,0 +1,66 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +-- This class watches for changes in the arrange view. +local ArrangeViewWatcher = {} +ArrangeViewWatcher.__index = ArrangeViewWatcher + +function ArrangeViewWatcher:new(cb) + local instance = {} + setmetatable(instance, self) + instance:_initialize(cb) + return instance +end + +function ArrangeViewWatcher:_initialize(cb) + self.pcount = nil + self.astart = nil + self.aend = nil + self.last_track_y = nil + self.last_track_h = nil + self.cb = cb +end + +function ArrangeViewWatcher:tick() + local npcount = reaper.GetProjectStateChangeCount() + local nastart, naend = reaper.GetSet_ArrangeView2(0, false, 0, 0) + + local lt = reaper.GetTrack(0,reaper.CountTracks()-1) + local ly = nil + local lh = nil + local lx = nil + local lw = nil + + if lt then ly = reaper.GetMediaTrackInfo_Value(lt, "I_TCPY") end + if lt then lh = reaper.GetMediaTrackInfo_Value(lt, "I_TCPH") end + + -- Also detect MCP changes. + if lt then lx = reaper.GetMediaTrackInfo_Value(lt, "I_MCPX") end + if lt then lw = reaper.GetMediaTrackInfo_Value(lt, "I_MCPW") end + + if not (npcount == self.pcount) or + not (nastart == self.astart) or + not (naend == self.aend) or + not (lh == self.last_track_h) or + not (ly == self.last_track_y) or + not (lx == self.last_track_x) or + not (lw == self.last_track_w) then + if self.cb then self.cb() end + self.pcount = npcount + self.astart = nastart + self.aend = naend + self.last_track_y = ly + self.last_track_h = lh + self.last_track_x = lx + self.last_track_w = lw + return true + end + + return false +end + +return ArrangeViewWatcher + + diff --git a/Various/talagan_Reannotate/classes/color.lua b/Various/talagan_Reannotate/classes/color.lua new file mode 100644 index 000000000..441be74a4 --- /dev/null +++ b/Various/talagan_Reannotate/classes/color.lua @@ -0,0 +1,253 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local Color = {} +Color.__index = Color + +local KNOWN_LOOKUP = { + aqua = "#00ffff", + azure = "#f0ffff", + beige = "#f5f5dc", + bisque = "#ffe4c4", + blue = "#0000ff", + brown = "#a52a2a", + coral = "#ff7f50", + crimson = "#dc143c", + cyan = "#00ffff", + darkred = "#8b0000", + dimgray = "#696969", + dimgrey = "#696969", + gold = "#ffd700", + gray = "#808080", + green = "#008000", + grey = "#808080", + hotpink = "#ff69b4", + indigo = "#4b0082", + ivory = "#fffff0", + khaki = "#f0e68c", + lime = "#00ff00", + linen = "#faf0e6", + maroon = "#800000", + navy = "#000080", + oldlace = "#fdf5e6", + olive = "#808000", + orange = "#ffa500", + orchid = "#da70d6", + peru = "#cd853f", + pink = "#ffc0cb", + plum = "#dda0dd", + purple = "#800080", + red = "#ff0000", + salmon = "#fa8072", + sienna = "#a0522d", + silver = "#c0c0c0", + skyblue = "#87ceeb", + snow = "#fffafa", + tan = "#d2b48c", + teal = "#008080", + thistle = "#d8bfd8", + tomato = "#ff6347", + violet = "#ee82ee", + wheat = "#f5deb3", + white = "#ffffff" +} + +local function rgb2hsv( r, g, b ) + local M, m = math.max( r, g, b ), math.min( r, g, b ) + local C = M - m + local K = 1.0/(6.0 * C) + local h = 0.0 + if C ~= 0.0 then + if M == r then h = ((g - b) * K) % 1.0 + elseif M == g then h = (b - r) * K + 1.0/3.0 + else h = (r - g) * K + 2.0/3.0 + end + end + return h, M == 0.0 and 0.0 or C / M, M +end + +local function hsv2rgb( h, s, v ) + local C = v * s + local m = v - C + local r, g, b = m, m, m + if h == h then + local h_ = (h % 1.0) * 6 + local X = C * (1 - math.abs(h_ % 2 - 1)) + C, X = C + m, X + m + if h_ < 1 then r, g, b = C, X, m + elseif h_ < 2 then r, g, b = X, C, m + elseif h_ < 3 then r, g, b = m, C, X + elseif h_ < 4 then r, g, b = m, X, C + elseif h_ < 5 then r, g, b = X, m, C + else r, g, b = C, m, X + end + end + return r, g, b +end + +-- Taken from HSX, public domain +local function rgb2hsl( r, g, b ) + local M, m = math.max( r, g, b ), math.min( r, g, b ) + local C = M - m + local K = 1.0 / (6*C) + local h = 0 + if C ~= 0 then + if M == r then h = ((g - b) * K) % 1.0 + elseif M == g then h = (b - r) * K + 1.0/3.0 + else h = (r - g) * K + 2.0/3.0 + end + end + local l = 0.5 * (M + m) + local s = 0 + if l > 0 and l < 1 then + s = C / (1-math.abs(l + l - 1)) + end + return h, s, l +end + +-- Taken from HSX, public domain +local function hsl2rgb( h, s, l ) + local C = ( 1 - math.abs( l + l - 1 ))*s + local m = l - 0.5*C + local r, g, b = m, m, m + if h == h then + local h_ = (h % 1.0) * 6.0 + local X = C * (1 - math.abs(h_ % 2 - 1)) + C, X = C + m, X + m + if h_ < 1 then r, g, b = C, X, m + elseif h_ < 2 then r, g, b = X, C, m + elseif h_ < 3 then r, g, b = m, C, X + elseif h_ < 4 then r, g, b = m, X, C + elseif h_ < 5 then r, g, b = X, m, C + else r, g, b = C, m, X + end + end + return r, g, b +end + +local function _parse(hex) + + if type(hex) == "number" then + -- Assume it's RGB + return (hex & 0xFF0000) >> 16, (hex & 0xFF00) >> 8, (hex & 0xFF), nil + end + + local col = KNOWN_LOOKUP[hex] + if col then hex = col end + + hex = hex:gsub("^%#", "") + + local r,g,b,a = hex:match("^(%x%x)(%x%x)(%x%x)$") + if r then + r,g,b,a = tonumber(r,16), tonumber(g,16), tonumber(b,16), nil + else + a,r,g,b = hex:match("^(%x%x)(%x%x)(%x%x)(%x%x)$") + + if a then + r,g,b,a = tonumber(r,16), tonumber(g,16), tonumber(b,16), tonumber(a,16) + else + error("Wrong format") + end + end + + return r,g,b,a +end + +function Color:new(r_or_string_or_rgb_int,g,b,a) + local instance = {} + setmetatable(instance, self) + instance:_initialize(r_or_string_or_rgb_int,g,b,a) + return instance +end + +function Color:validateComponent(name, presence) + local val = self[name] + + if presence and not val then error("Missing " .. name .. " component") end + + if val then + if val < 0 then val = 0 end + if val > 255 then val = 255 end + + self[name] = math.floor(val + 0.5) + end +end + +function Color:_initialize(r,g,b,a) + + if r ~= nil and g == nil and b == nil and a == nil then + r,g,b,a = _parse(r) + end + + self.r = r + self.g = g + self.b = b + self.a = a + self:validateComponent('r', true) + self:validateComponent('g', true) + self:validateComponent('b', true) + self:validateComponent('a', false) +end + +function Color:hasAlpha() + return not (self.a == nil) +end + +function Color:opacity() + return (self:hasAlpha()) and (self.a/255.0) or (1.0) +end + +function Color:setOpacity(opacity) + self.a = opacity * 255 + self:validateComponent('a', true) +end + +function Color:hsl() + return rgb2hsl(self.r/255.0, self.g/255.0, self.b/255.0) +end +function Color:hsv() + return rgb2hsv(self.r/255.0, self.g/255.0, self.b/255.0) +end + +function Color:setHsl(h,s,l) + local r,g,b = hsl2rgb(h,s,l) + self.r, self.g, self.b = math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5) +end +function Color:setHsv(h,s,l) + local r,g,b = hsv2rgb(h,s,l) + self.r, self.g, self.b = math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5) +end + +function Color:to_irgb() + return (self.r << 16) | (self.g << 8) | (self.b << 0) +end + +function Color:to_iargb() + return ((self.a or 255) << 24) | self:to_irgb() +end + +function Color:to_irgba() + return (self:to_irgb() << 8) | ((self.a or 255) << 0) +end + + +function Color.irgba(str) + return Color.parse(str):to_irgba() +end + +function Color.irgb(str) + return Color.parse(str):to_irgb() +end + +function Color.iargb(str) + return Color.parse(str):to_iargb() +end + +function Color.parse(hex) + local r,g,b,a = _parse(hex) + return Color:new(r,g,b,a) +end + +return Color diff --git a/Various/talagan_Reannotate/classes/launch_context.lua b/Various/talagan_Reannotate/classes/launch_context.lua new file mode 100644 index 000000000..ec8ff65bc --- /dev/null +++ b/Various/talagan_Reannotate/classes/launch_context.lua @@ -0,0 +1,76 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local LaunchContext = {} +LaunchContext.__index = LaunchContext + +function LaunchContext:new() + local instance = {} + setmetatable(instance, self) + instance:_initialize() + return instance +end + +local function stringToBytes(str) + local bytes = {} + for i = 1, #str do + bytes[i] = string.byte(str, i) + end + return bytes +end + +local function bytesToString(bytes) + return string.char(table.unpack(bytes)) +end + +local function JS_VKeys_GetStateWithoutModifiers(cutoff) + local bs = stringToBytes(reaper.JS_VKeys_GetState(cutoff)) + bs[16] = 0 + bs[17] = 0 + bs[18] = 0 + bs[91] = 0 + return bytesToString(bs) +end + +function LaunchContext:_initialize() + local is_new_value,filename,section,cmd,mode,resolution,val ,ctxstr = reaper.get_action_context() + self.focused_hwnd = reaper.JS_Window_GetFocus() + + self.launch_time = reaper.time_precise() + self.shortcut_check_time = self.launch_time - 0.1 + + -- JS_VKeys_GetState is not reliable for modifiers, we split + -- What's down at startup into two : modifiers and the rest + self.calling_shortcut = JS_VKeys_GetStateWithoutModifiers(self.shortcut_check_time) + self.calling_modifiers = reaper.JS_Mouse_GetState(4|8|16|32) + + self.context_string = ctxstr + + self.key_down_count = 0 + for i=1, 255 do + if self.calling_shortcut:byte(i) ~= 0 then + self.key_down_count = self.key_down_count + 1 + end + end + + -- Following technique is not reliable if the MIDI Editor has focus + -- self.is_launched_by_keyboard_shortcut = ctxstr:find("key:") + + self.is_launched_by_keyboard_shortcut = (self.key_down_count > 0) +end + +function LaunchContext:isLaunchedByKeyboardShortcut() + return self.is_launched_by_keyboard_shortcut +end + +function LaunchContext:isShortcutStillPressed() + -- Compare modifiers / Compare the rest + local current_keymap = JS_VKeys_GetStateWithoutModifiers(self.shortcut_check_time) + local current_modifiers = reaper.JS_Mouse_GetState(4|8|16|32) + + return (current_keymap == self.calling_shortcut) and (current_modifiers == self.calling_modifiers) +end + +return LaunchContext diff --git a/Various/talagan_Reannotate/classes/mouse_observer.lua b/Various/talagan_Reannotate/classes/mouse_observer.lua new file mode 100644 index 000000000..f73757709 --- /dev/null +++ b/Various/talagan_Reannotate/classes/mouse_observer.lua @@ -0,0 +1,59 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local MouseObserver = {} +MouseObserver.__index = MouseObserver + +function MouseObserver:new(stable_time) + local instance = {} + setmetatable(instance, self) + instance:_initialize(stable_time) + return instance +end + +function MouseObserver:_initialize(stable_time) + self.stable_time = stable_time + self.x = -1 + self.y = -1 + self.stable = false + self.stable_at = nil + self.stabilizing_at = nil +end + +function MouseObserver:observe() + local x,y = reaper.GetMousePosition() + + if (x == self.x) and (y == self.y) then + local now = reaper.time_precise() + self.stabilizing = true + self.stable_x = self.stable_x or x + self.stable_y = self.stable_y or y + self.stabilizing_at = self.stabilizing_at or now + + if not self.stable and (now - self.stabilizing_at > self.stable_time) then + self.stable = true + self.stable_at = self.stabilizing_at + self.stable_x = x + self.stable_y = y + if self.onStable then + self.onStable(self) + end + end + else + if self.onUnstable then + self.onUnstable(self) + end + self.stable = false + self.stabilizing = false + self.stabilizing_at = nil + self.stable_x = nil + self.stable_y = nil + end + + self.x = x + self.y = y +end + +return MouseObserver diff --git a/Various/talagan_Reannotate/classes/notes.lua b/Various/talagan_Reannotate/classes/notes.lua new file mode 100644 index 000000000..5c7a759d8 --- /dev/null +++ b/Various/talagan_Reannotate/classes/notes.lua @@ -0,0 +1,328 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local JSON = require "ext/json" +local S = require "modules/settings" + +local TT_DEFAULT_W = 300 +local TT_DEFAULT_H = 100 +local MAX_SLOTS = 8 -- Slot 0 is counted + +local function normalizeNotes(str) + return str:gsub("\r","") +end + +local function normalizeCoordinate(c, default_val) + if (not c) or (type(c) ~= "number") then c = default_val end + return c +end + +local function normalizeReannotateData(data) + + if not data.tt_sizes then data.tt_sizes = {} end + if not data.slots then data.slots = {} end + + for i=1, MAX_SLOTS-1 do + data.tt_sizes[i] = data.tt_sizes[i] or {} + data.tt_sizes[i].w = normalizeCoordinate(data.tt_sizes[i].w, TT_DEFAULT_W) + data.tt_sizes[i].h = normalizeCoordinate(data.tt_sizes[i].h, TT_DEFAULT_H) + data.slots[i] = data.slots[i] or "" + end + + -- Special treatment for slot 0 + data.sws_reaper_tt_size = data.sws_reaper_tt_size or {} + data.sws_reaper_tt_size.w = normalizeCoordinate(data.sws_reaper_tt_size.w, TT_DEFAULT_W) + data.sws_reaper_tt_size.h = normalizeCoordinate(data.sws_reaper_tt_size.h, TT_DEFAULT_H) + + return data +end + +local function activeProject() + local p, _ = reaper.EnumProjects(-1) + return p +end + +local function AttributeGetterSetter(object) + local getter = nil + if reaper.ValidatePtr(object, "MediaTrack*") then + getter = reaper.GetSetMediaTrackInfo_String + elseif reaper.ValidatePtr(object, "TrackEnvelope*") then + getter = reaper.GetSetEnvelopeInfo_String + elseif reaper.ValidatePtr(object,"MediaItem*") then + getter = reaper.GetSetMediaItemInfo_String + elseif reaper.ValidatePtr(object, "ReaProject*") then + -- We'll use the master track with a different key + getter = reaper.GetSetMediaTrackInfo_String + else + error("Unhandled type for object") + end + return getter +end + +local function StoreKey(object) + if reaper.ValidatePtr(object, "ReaProject*") then + return "P_EXT:Reannotate_ProjectNotes" + else + return "P_EXT:Reannotate_Notes" + end +end + +local function StoreObject(object) + if reaper.ValidatePtr(object, "ReaProject*") then + return reaper.GetMasterTrack(activeProject()) + else + return object + end +end + +local function GetObjectNotes_Reannotate(object) + local getter = AttributeGetterSetter(object) + local store_object = StoreObject(object) + local store_key = StoreKey(object) + + local ret = {} + local b, v = getter(store_object, store_key, "", false) + if b then + ret = JSON.decode(v) + end + ret = normalizeReannotateData(ret) + return b, ret +end + +local function SetObjectNotes_Reannotate(object, data) + local setter = AttributeGetterSetter(object) + local store_object = StoreObject(object) + local store_key = StoreKey(object) + + data = normalizeReannotateData(data) + local data_str = JSON.encode(data) + + return setter(store_object, store_key, data_str, true) +end + +---------- +-- ITEM -- +---------- +local function GetItemNotes_SWS_Reaper(item) + local b, v = reaper.GetSetMediaItemInfo_String(item, "P_NOTES", "", false) + v = normalizeNotes(v) + b = (b and not (v == "")) + return b, v +end + +local function SetItemNotes_SWS_Reaper(item, str) + str = str:gsub("\r\n","\n") + return reaper.GetSetMediaItemInfo_String(item, "P_NOTES", str, true) +end + +----------- +-- TRACK -- +----------- +local function GetTrackNotes_SWS_Reaper(track) + local v = reaper.NF_GetSWSTrackNotes(track) or "" + v = normalizeNotes(v) + local b = (v ~= "") + return b, v +end +local function SetTrackNotes_SWS_Reaper(track, str) + return reaper.NF_SetSWSTrackNotes(track, str) +end + +----------- +-- ENVELOPE -- +----------- +local function GetEnvelopeNotes_SWS_Reaper(track) + -- Not handled by SWS + return true, "" +end +local function SetEnvelopeNotes_SWS_Reaper(envelope, str) + -- Not handled by SWS + return true, "" +end + +------------- +-- PROJECT -- +------------- + +local function GetActiveProjectNotes_SWS_Reaper() + local v = reaper.GetSetProjectNotes(activeProject(), false, "") + --reaper.JB_GetSWSExtraProjectNotes(activeProject()) + v = normalizeNotes(v) + local b = (v ~= "") + return b, v +end +local function SetActiveProjectNotes_SWS_Reaper(str) + local project = activeProject() + local notes = normalizeNotes(str) + -- reaper.JB_SetSWSExtraProjectNotes(project, notes) + return reaper.GetSetProjectNotes(project, true, notes) +end + +local function GetObjectNotes_SWS_Reaper(object) + if reaper.ValidatePtr(object, "MediaTrack*") then + return GetTrackNotes_SWS_Reaper(object) + elseif reaper.ValidatePtr(object, "TrackEnvelope*") then + return GetEnvelopeNotes_SWS_Reaper(object) + elseif reaper.ValidatePtr(object,"MediaItem*") then + return GetItemNotes_SWS_Reaper(object) + elseif reaper.ValidatePtr(object, "ReaProject*") then + return GetActiveProjectNotes_SWS_Reaper() + else + error("Unhandled type for object") + end +end + +local function SetObjectNotes_SWS_Reaper(object, str) + if reaper.ValidatePtr(object, "MediaTrack*") then + return SetTrackNotes_SWS_Reaper(object, str) + elseif reaper.ValidatePtr(object,"TrackEnvelope*") then + return SetEnvelopeNotes_SWS_Reaper(object, str) + elseif reaper.ValidatePtr(object,"MediaItem*") then + return SetItemNotes_SWS_Reaper(object, str) + elseif reaper.ValidatePtr(object, "ReaProject*") then + return SetActiveProjectNotes_SWS_Reaper(str) + else + error("Unhandled type for object") + end +end + +-------------- + +local Notes = {} +Notes.__index = Notes + +Notes.MAX_SLOTS = MAX_SLOTS + +Notes.POST_IT_COLORS = { + 0xFFFFFF, -- WHITE Slot 0 + 0x40acff, -- BLUE + 0x753ffc, -- VIOLET + 0xff40e5, -- PINK + 0xffe240, -- YELLOW + 0x3cf048, -- GREEN + 0xff9640, -- ORANGE + 0xff4040, -- RED Slot 7 +} + + +function Notes:new(object, should_pull) + local instance = {} + setmetatable(instance, self) + instance:_initialize(object, should_pull) + return instance +end + +function Notes:_initialize(object, should_pull) + self._object = object + + if should_pull == true or should_pull == nil then + self:pull() + end +end + +function Notes:pull() + local b, v = GetObjectNotes_Reannotate(self._object) + self.reannotate_notes = v + b, v = GetObjectNotes_SWS_Reaper(self._object) + self.sws_reaper_notes = v +end + +function Notes:commit() + SetObjectNotes_Reannotate(self._object, self.reannotate_notes) + SetObjectNotes_SWS_Reaper(self._object, self.sws_reaper_notes) +end + +function Notes:tooltipSize(slot) + if slot == 0 then + return self.reannotate_notes.sws_reaper_tt_size.w, self.reannotate_notes.sws_reaper_tt_size.h + else + return self.reannotate_notes.tt_sizes[slot].w, self.reannotate_notes.tt_sizes[slot].h + end +end + + +function Notes:setTooltipSize(slot, w, h) + w = normalizeCoordinate(w, TT_DEFAULT_W) + h = normalizeCoordinate(h, TT_DEFAULT_H) + + if slot == 0 then + self.reannotate_notes.sws_reaper_tt_size = { w = w, h = h } + else + self.reannotate_notes.tt_sizes[slot] = { w = w, h = h } + end +end + +function Notes:isBlank() + local ret = true + if self.sws_reaper_notes and self.sws_reaper_notes ~= "" then return false end + for k,v in pairs(self.reannotate_notes.slots) do + if v and v~= "" then return false end + end + return ret +end + +function Notes:slot(slot) + if slot == 0 then + return self.sws_reaper_notes or "" + else + return self.reannotate_notes.slots[slot] or "" + end +end + +function Notes:setSlot(slot, str) + if slot == 0 then + self.sws_reaper_notes = str or "" + else + self.reannotate_notes.slots[slot] = str or "" + end +end + +function Notes.RetrieveProjectSlotLabels() + local _, str = reaper.GetSetMediaTrackInfo_String(reaper.GetMasterTrack(activeProject()), "P_EXT:Reannotate_ProjectSlotLabels", "", false) + local slot_labels = {} + if str == "" or str == nil then + else + slot_labels = JSON.decode(str) + end + + -- Ensure labels have names by defaulting to global setting + for i = 0, Notes.MAX_SLOTS -1 do + slot_labels[i+1] = slot_labels[i+1] or S.getSetting("SlotLabel_" .. i) + end + + Notes.slot_labels = slot_labels +end + +function Notes.CommitProjectSlotLabels() + if not Notes.slot_labels_dirty then return end + if not Notes.slot_labels then Notes.RetrieveProjectSlotLabels() end + + local str = JSON.encode(Notes.slot_labels) + reaper.GetSetMediaTrackInfo_String(reaper.GetMasterTrack(activeProject()), "P_EXT:Reannotate_ProjectSlotLabels", str, true) + Notes.slot_labels_dirty = false +end + + +function Notes.SlotColor(slot) + return Notes.POST_IT_COLORS[slot+1] +end + +function Notes.SlotLabel(slot) + if not Notes.slot_labels then Notes.RetrieveProjectSlotLabels() end + return Notes.slot_labels[slot+1] +end + +function Notes.SetSlotLabel(slot, label) + if not Notes.slot_labels then Notes.RetrieveProjectSlotLabels() end + Notes.slot_labels[slot+1] = label + Notes.slot_labels_dirty = true + Notes.CommitProjectSlotLabels() +end + +function Notes.defaultTooltipSize() + return TT_DEFAULT_W, TT_DEFAULT_H +end + +return Notes diff --git a/Various/talagan_Reannotate/ext/dependencies.lua b/Various/talagan_Reannotate/ext/dependencies.lua new file mode 100644 index 000000000..b019da025 --- /dev/null +++ b/Various/talagan_Reannotate/ext/dependencies.lua @@ -0,0 +1,43 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local function CheckReapack(type, arg, api_name, search_string) + + local ok = true + if type == "API" then + if not reaper.APIExists(arg) then + ok = false + end + elseif type == "script" then + ok, _ = pcall(require, arg) + end + + if not ok then + reaper.MB( api_name .. " is required and you need to install it.\n\n\z + Right-click the entry in the next window and choose to install.", + api_name .. " not installed", + 0 ) + + -- Open reapack's package window + reaper.ReaPack_BrowsePackages( search_string ) + return false + end + + return true +end + +local function checkDependencies() + if not CheckReapack("API", "JS_ReaScriptAPI_Version", "JS_ReaScriptAPI", "js_ReaScriptAPI") then return false end + if not CheckReapack("API", "ImGui_CreateContext", "ReaImGUI", "ReaImGui:") then return false end + if not CheckReapack("API", "CF_ShellExecute", "SWS", "SWS/S&M Extension") then return false end + if not CheckReapack("script", reaper.GetResourcePath() .. "/Scripts/ReaTeam Scripts/Development/talagan_ReaImGui Markdown/reaimgui_markdown", "ReaImGui Markdown", "ReaImGui Markdown") then return false end + + return true +end + +return { + checkDependencies = checkDependencies +} + diff --git a/Various/talagan_Reannotate/ext/imgui.lua b/Various/talagan_Reannotate/ext/imgui.lua new file mode 100644 index 000000000..c6ad46151 --- /dev/null +++ b/Various/talagan_Reannotate/ext/imgui.lua @@ -0,0 +1,8 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local ImGui = require 'imgui' '0.10.0' + +return ImGui \ No newline at end of file diff --git a/Various/talagan_Reannotate/ext/json.lua b/Various/talagan_Reannotate/ext/json.lua new file mode 100644 index 000000000..cbc2606be --- /dev/null +++ b/Various/talagan_Reannotate/ext/json.lua @@ -0,0 +1,391 @@ +-- @noindex +-- @description This file is used by Reannotate "as is" except for the addition of this pre-header. + +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- 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. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +return json diff --git a/Various/talagan_Reannotate/images/settings.lua b/Various/talagan_Reannotate/images/settings.lua new file mode 100644 index 000000000..37c0832b8 --- /dev/null +++ b/Various/talagan_Reannotate/images/settings.lua @@ -0,0 +1,19 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +return "\z +\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D\x49\x48\x44\x52\x00\x00\x00\x14\x00\x00\x00\x14\x08\x06\x00\x00\x00\x8D\x89\x1D\x0D\x00\x00\x00\x09\x70\x48\x59\z +\x73\x00\x00\x0B\x13\x00\x00\x0B\x13\x01\x00\x9A\x9C\x18\x00\x00\x01\x6E\x49\x44\x41\x54\x38\x8D\xCD\xD4\x41\x4B\x95\x41\x14\x06\xE0\x47\x91\x92\x40\xC3\xE0\x2E\z +\x02\x41\x50\xD0\x56\xA1\x81\xA4\x10\xB4\x70\xE1\xC6\x70\xE1\x2E\xFD\x19\xD2\x4A\x68\x51\x20\xF8\x2F\x5C\x0A\x41\xDB\x22\x45\x74\xA1\xC2\x25\x44\x50\xD0\xC0\x14\z +\x6C\x21\xDC\x55\x8B\x50\x53\xBB\x2E\xBE\x63\x8C\x1F\xDF\x55\x17\x57\xF0\x6C\xE6\x9D\x39\x73\xDE\xF3\x9E\x99\x33\xD3\x50\xAD\x56\xD5\xD3\x1A\xEB\xCA\x76\x17\x84\z +\x4D\xE9\xA4\x52\xA9\x14\xED\x69\xC3\x67\x9C\x61\x0C\xBF\xF3\x1B\x4A\xA5\xD2\x7F\x5C\x4B\x61\x33\xFA\x02\x3F\xC3\x6B\x0C\xA1\x3B\xD6\xFA\xF1\xF0\x46\x85\x09\xD9\z +\x26\xBA\x30\x8B\x57\x89\x6F\x0E\xAB\x78\x8B\x6D\x3C\xC7\x69\x1A\xDC\x90\xDE\x72\x94\xDC\x8E\x83\x64\xCF\x5F\x7C\xC2\x63\x0C\xE7\x44\x3C\xC5\xE1\x4D\x25\xFF\xC2\z +\xFB\xC0\x0B\x68\xC5\x38\x46\xD0\x82\x6F\xE1\x9B\xC2\x61\x3E\x38\x4F\xF8\x44\x76\x56\xE3\xF8\x87\x37\x38\x49\xFC\xC7\x18\x0D\x3C\x81\xC1\x48\x58\x93\xF0\x03\xE6\z +\x65\x87\xBF\x84\xA3\x82\x0A\x8E\xB0\x2C\xBB\xAC\x15\x4C\x5F\x47\x98\x3E\x9B\x47\x05\x64\x97\xF6\xA0\x96\x23\x4F\x38\x19\x99\xCB\x78\x29\x57\x4E\x58\x0B\x06\xB0\z +\x8E\x5E\xBC\xBB\x8E\xF0\x18\x3B\xF8\x1A\xF3\xF9\x20\xB8\xB4\x56\x2C\x06\xFE\x82\x0D\xFC\x49\x09\x8A\xDA\xA6\x03\xFB\xB9\x44\x65\x9C\x87\xB2\xD4\x7A\xF0\xE3\x36\z +\x6D\xB3\x16\x78\x46\x56\x5A\x7F\x90\x95\xF1\x31\x7C\xEB\xD8\xCB\x07\x17\x29\x24\x6B\xDE\x0E\xEC\xE2\x05\xBE\xC7\xFA\x60\x24\xEB\x94\x35\xFF\x29\x57\xDF\x72\xD1\z +\xD3\x23\xFB\x08\x76\x03\xEF\x84\x52\xD8\x8A\xF1\x67\x8D\xB8\xAB\x0A\xEB\x61\xF7\xFF\x83\xBD\x00\xA3\x6A\x58\xA2\x8D\xAE\x8C\x8F\x00\x00\x00\x00\x49\x45\x4E\x44\z +\xAE\x42\x60\x82" +; \ No newline at end of file diff --git a/Various/talagan_Reannotate/modules/debug.lua b/Various/talagan_Reannotate/modules/debug.lua new file mode 100644 index 000000000..c1b25f4d7 --- /dev/null +++ b/Various/talagan_Reannotate/modules/debug.lua @@ -0,0 +1,73 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of MaCCLane + +local S = require "modules/settings" + +-- Debugger launcher +local function LaunchDebugStubIfNeeded() + + local should_debug = S.getSetting("UseDebugger") + + if should_debug then + reaper.ShowConsoleMsg("Beware, debugging is on. Loading VS debug extension ...") + + -- Use VSCode extension + local vscode_ext_path = os.getenv("HOME") .. "/.vscode/extensions/" + local p = 0 + local sdir = '' + while sdir do + sdir = reaper.EnumerateSubdirectories(vscode_ext_path, p) + if not sdir then + reaper.ShowConsoleMsg(" failed *******.\n") + break + else + if sdir:match("antoinebalaine%.reascript%-docs") then + dofile(vscode_ext_path .. "/" .. sdir .. "/debugger/LoadDebug.lua") + reaper.ShowConsoleMsg(" OK!\n") + break + end + end + p = p + 1 + end + end + + -- We override Reaper's defer method for two reasons : + -- We want the full trace on errors + -- We want the debugger to pause on errors + + local rdefer = reaper.defer + ---@diagnostic disable-next-line: duplicate-set-field + reaper.defer = function(c) + return rdefer(function() xpcall(c, + function(err) + reaper.ShowConsoleMsg(err .. '\n\n' .. debug.traceback()) + end) + end) + end +end + +-- Profiler launcher +local function LaunchProfilerIfNeeded() + if S.getSetting("UseProfiler") then + + -- Functions need to be preloaded for profiling to be able to instrument them + -- So, force preload DSP functions before "attach to world" + + -- Require here all files containing things to be profiled. + local ImGui = require "ext/imgui" + local QuickPreviewOverlay = require "widgets/quick_preview_overlay" + + local profiler = dofile(reaper.GetResourcePath() .. '/Scripts/ReaTeam Scripts/Development/cfillion_Lua profiler.lua') + reaper.defer = profiler.defer + profiler.attachTo('reaper') + profiler.attachToWorld() -- after all functions have been defined + profiler.run() + end +end + +return { + LaunchDebugStubIfNeeded = LaunchDebugStubIfNeeded, + LaunchProfilerIfNeeded = LaunchProfilerIfNeeded +} diff --git a/Various/talagan_Reannotate/modules/settings.lua b/Various/talagan_Reannotate/modules/settings.lua new file mode 100644 index 000000000..4aaffab0a --- /dev/null +++ b/Various/talagan_Reannotate/modules/settings.lua @@ -0,0 +1,108 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This is part of MaCCLane + +local os = reaper.GetOS() +local is_windows = os:match('Win') + +local SettingDefs = { + UseDebugger = { type = "bool", default = false }, + UseProfiler = { type = "bool", default = false }, + + SlotLabel_0 = { type = "string", default = "SWS/Reaper" }, + SlotLabel_1 = { type = "string", default = "Description"}, + SlotLabel_2 = { type = "string", default = "Comments"}, + SlotLabel_3 = { type = "string", default = "Other"}, + SlotLabel_4 = { type = "string", default = "Todos"}, + SlotLabel_5 = { type = "string", default = "Completed"}, + SlotLabel_6 = { type = "string", default = "Warnings"}, + SlotLabel_7 = { type = "string", default = "Problems"}, +}; + +local function unsafestr(str) + if str == "" then + return nil + end + return str +end + +local function serializedStringToValue(str, spec) + local val = unsafestr(str); + + if val == nil then + val = spec.default; + else + if spec.type == 'bool' then + val = (val == "true"); + elseif spec.type == 'int' then + val = tonumber(val) + if val then val = math.floor(val) end + elseif spec.type == 'double' then + val = tonumber(val); + elseif spec.type == 'string' then + -- No conversion needed + end + end + + return val +end + +local function valueToSerializedString(val, spec) + local str = '' + if spec.type == 'bool' then + str = (val == true) and "true" or "false"; + elseif spec.type == 'int' then + str = tostring(val); + elseif spec.type == 'double' then + str = tostring(val); + elseif spec.type == "string" then + -- No conversion needed + str = val + end + return str +end + +local function getSetting(setting) + local spec = SettingDefs[setting]; + + if spec == nil then + error("Trying to get unknown setting " .. setting); + end + + local str = reaper.GetExtState("MaCCLane", setting) + + return serializedStringToValue(str, spec) +end + +local function setSetting(setting, val) + local spec = SettingDefs[setting]; + + if spec == nil then + error("Trying to set unknown setting " .. setting); + end + + if val == nil then + reaper.DeleteExtState("MaCCLane", setting, true); + else + local str = valueToSerializedString(val, spec); + reaper.SetExtState("MaCCLane", setting, str, true); + end +end + +local function resetSetting(setting) + setSetting(setting, SettingDefs[setting].default) +end +local function getSettingSpec(setting) + return SettingDefs[setting] +end + +return { + SettingDefs = SettingDefs, + + getSetting = getSetting, + setSetting = setSetting, + resetSetting = resetSetting, + getSettingSpec = getSettingSpec, +} + diff --git a/Various/talagan_Reannotate/modules/unit_tests.lua b/Various/talagan_Reannotate/modules/unit_tests.lua new file mode 100644 index 000000000..f425bfdbc --- /dev/null +++ b/Various/talagan_Reannotate/modules/unit_tests.lua @@ -0,0 +1,11 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local function performTests() +end + +return { + performTests = performTests +} diff --git a/Various/talagan_Reannotate/widgets/note_editor.lua b/Various/talagan_Reannotate/widgets/note_editor.lua new file mode 100644 index 000000000..76170fb40 --- /dev/null +++ b/Various/talagan_Reannotate/widgets/note_editor.lua @@ -0,0 +1,227 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local ImGui = require "ext/imgui" +local AppContext = require "classes/app_context" +local Notes = require "classes/notes" +local Color = require "classes/color" + +local NoteEditor = {} +NoteEditor.__index = NoteEditor + +function NoteEditor:new() + local instance = {} + setmetatable(instance, self) + instance:_initialize() + self.editor_draw_count = 0 + self.show_editor = true + self.rand = math.random() + return instance +end + +function NoteEditor:_initialize() +end + +function NoteEditor:setPosition(x,y) + self.x, self.y = x, y +end + +function NoteEditor:setSize(w,h) + self.w, self.h = w, h +end + +function NoteEditor:setEditContext(edit_context) + self.edit_context = edit_context +end + +function NoteEditor:setEditedSlot(slot) + self.edited_slot = slot +end + +function NoteEditor:GrabFocus() + self.grab_focus = true +end + +function NoteEditor:title() + local t = "Editing annotations for " + + if self.edit_context.type == "track" then + t = t .. "Track" + elseif self.edit_context.type == "env" then + t = t .. "Envelope" + elseif self.edit_context.type == "item" then + t = t .. "Item" + elseif self.edit_context.type == "project" then + t = t .. "Project" + else + error("Unimplemented") + end + + t = t .. " " + t = t .. self.edit_context.name + + return t +end + +function NoteEditor:onSlotEditChange() + if self.slot_edit_change_callback then + self.slot_edit_change_callback() + end +end + +function NoteEditor:onSlotCommit() + if self.slot_commit_callback then + self.slot_commit_callback() + end +end + +function NoteEditor:draw() + local app_ctx = AppContext.instance() + local ctx = app_ctx.imgui_ctx + local cursor_func = app_ctx.cursor_func + + local x = self.x or 100 + local y = self.y or 100 + local w = self.w or 300 + local h = self.h or 200 + + local minw = math.max(w, 300) + local minh = math.max(h, 200) + + ImGui.SetNextWindowSize(ctx, w, h, ImGui.Cond_Appearing) + ImGui.SetNextWindowPos(ctx, x, y, ImGui.Cond_Appearing ) + + -- Don't save the settings + local b, is_open = ImGui.Begin(ctx, self:title() .. "##edit_notes_" .. self.rand, true, ImGui.WindowFlags_TopMost | ImGui.WindowFlags_NoSavedSettings) + if b then + + ImGui.PushID(ctx, "note_editor_" .. self.rand) + + local entry = self.edit_context.notes:slot(self.edited_slot) + + if ImGui.IsWindowAppearing(ctx) or self.grab_focus then + ImGui.SetKeyboardFocusHere(ctx) + ImGui.SetWindowFocus(ctx) + if self.hwnd then + reaper.JS_Window_SetFocus(self.hwnd) + reaper.JS_Window_SetForeground(self.hwnd) + end + else + if not self.hwnd then + self.hwnd = reaper.JS_Window_Find(self:title(), true) + if not self.hwnd then + error("CANNOT RETRIEVE HWND FOR NOTE EDITOR") + end + end + end + + if ImGui.IsWindowAppearing(ctx) then + self.editor_draw_count = 0 + end + + if self.editor_draw_count == 1 then + ImGui.Function_SetValue(app_ctx.cursor_func, "WANTED_CURSOR", string.len(entry)) + end + + local sel_col = Color:new(Notes.SlotColor(self.edited_slot)) + ImGui.PushStyleColor(ctx, ImGui.Col_TabSelected, sel_col:to_irgba()) + + if ImGui.BeginTabBar(ctx, "Test##note_editor_tab", ImGui.TabBarFlags_NoCloseWithMiddleMouseButton | ImGui.TabBarFlags_NoTabListScrollingButtons) then + + local selection_has_changed = (self.last_selected_tab ~= self.edited_slot) + + for i=0, Notes.MAX_SLOTS-1 do + local slot = (i==Notes.MAX_SLOTS - 1) and (0) or (i+1) -- Put SWS/Reaper at the end + + if slot == 0 and self.edit_context.type == "env" then + else + local col = Color:new(Notes.SlotColor(slot)) + local h, s, v = col:hsv() + + local tab_col = Color:new(0) + tab_col:setHsv(h,s,v*0.5) + + ImGui.PushStyleColor(ctx, ImGui.Col_Tab, tab_col:to_irgba()) + ImGui.PushStyleColor(ctx, ImGui.Col_Text, 0x000000FF) + ImGui.PushStyleColor(ctx, ImGui.Col_TabHovered, col:to_irgba()) + ImGui.PushStyleColor(ctx, ImGui.Col_TabSelected, col:to_irgba()) + + local flags = ImGui.TabItemFlags_NoAssumedClosure| ImGui.TabItemFlags_NoCloseWithMiddleMouseButton | ImGui.TabItemFlags_NoReorder + + if (slot == self.edited_slot) and selection_has_changed then + flags = flags | ImGui.TabItemFlags_SetSelected + end + + local e_vis, e_sel = ImGui.BeginTabItem(ctx, Notes.SlotLabel(slot), false, flags) + + if e_vis then + -- The tab api is awfull and needs to track things to avoid race conditions + if e_sel and not selection_has_changed then + if slot ~= self.edited_slot then + self.edited_slot = slot + self.grab_focus = true + self.editor_draw_count = 0 + self:onSlotEditChange() + end + end + ImGui.EndTabItem(ctx) + end + + ImGui.PopStyleColor(ctx, 4) + end + end + + self.last_selected_tab = self.edited_slot + + ImGui.EndTabBar(ctx) + end + + ImGui.PopStyleColor(ctx) + + local ax, ay = ImGui.GetContentRegionAvail(ctx) + if self.grab_focus then + ImGui.SetKeyboardFocusHere(ctx) + end + + b, entry = ImGui.InputTextMultiline(ctx, "##reannotate_note_edit_multiline_" .. self.rand, entry, ax , ay, ImGui.InputTextFlags_CallbackAlways, cursor_func) + + -- Because we're in auto commit, close on shift enter or escape + if ImGui.IsKeyChordPressed(ctx, ImGui.Key_Enter | ImGui.Mod_Shift) or ImGui.IsKeyPressed(ctx, ImGui.Key_Escape, false) then + is_open = false + end + + if b and is_open then + self.edit_context.notes:setSlot(self.edited_slot, entry) + self.edit_context.notes:commit() + + local alternate_entry = self.edit_context.mcp_entry or self.edit_context.tcp_entry + if alternate_entry then + alternate_entry.notes:pull() + end + self:onSlotCommit() + end + + if self.grab_focus then + self.grab_focus = false + end + + -- Remember positions + self.w, self.h = ImGui.GetWindowSize(ctx) + self.x, self.y = ImGui.GetWindowPos(ctx) + + ImGui.PopID(ctx) + ImGui.End(ctx) + + self.editor_draw_count = self.editor_draw_count + 1 + end + + if not is_open then + self.show_editor = false + end + + return self.show_editor +end + +return NoteEditor diff --git a/Various/talagan_Reannotate/widgets/quick_preview_overlay.lua b/Various/talagan_Reannotate/widgets/quick_preview_overlay.lua new file mode 100644 index 000000000..92a7ee486 --- /dev/null +++ b/Various/talagan_Reannotate/widgets/quick_preview_overlay.lua @@ -0,0 +1,1115 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local AppContext = require "classes/app_context" +local ImGui = require "ext/imgui" +local ImGuiMd = require "reaimgui_markdown" +local Notes = require "classes/notes" +local NoteEditor = require "widgets/note_editor" +local SettingsEditor = require "widgets/settings_editor" + +local S = require "modules/settings" + +local OS = reaper.GetOS() +local is_windows = OS:match('Win') +local is_macos = OS:match('OSX') or OS:match('macOS') +local is_linux = OS:match('Other') + +local function GetScreen(x, y) + local scr = {} + scr.l, scr.t, scr.r, scr.b = reaper.JS_Window_GetViewportFromRect(x, y, x, y, false) + scr.w = scr.r - scr.l + scr.h = math.abs(scr.t - scr.b) + return scr +end + +local QuickPreviewOverlay = {} +QuickPreviewOverlay.__index = QuickPreviewOverlay + +-- Force fonts to arial +QuickPreviewOverlay.markdownStyle = { + default = { font_family = "Arial", font_size = 13, base_color = "#CCCCCC", bold_color = "white", autopad = 5 }, + + h1 = { font_family = "Arial", font_size = 23, padding_left = 0, padding_top = 3, padding_bottom = 5, line_spacing = 5, base_color = "#288efa", bold_color = "#288efa" }, + h2 = { font_family = "Arial", font_size = 21, padding_left = 5, padding_top = 3, padding_bottom = 5, line_spacing = 5, base_color = "#4da3ff", bold_color = "#4da3ff" }, + h3 = { font_family = "Arial", font_size = 19, padding_left = 10, padding_top = 3, padding_bottom = 4, line_spacing = 5, base_color = "#65acf7", bold_color = "#65acf7" }, + h4 = { font_family = "Arial", font_size = 17, padding_left = 15, padding_top = 3, padding_bottom = 3, line_spacing = 5, base_color = "#85c0ff", bold_color = "#85c0ff" }, + h5 = { font_family = "Arial", font_size = 15, padding_left = 20, padding_top = 3, padding_bottom = 3, line_spacing = 5, base_color = "#9ecdff", bold_color = "#9ecdff" }, + + paragraph = { font_family = "Arial", font_size = 13, padding_left = 30, padding_top = 3, padding_bottom = 7, line_spacing = 3, padding_in_blockquote = 6 }, + table = { font_family = "Arial", font_size = 13, padding_left = 30, padding_top = 3, padding_bottom = 7, line_spacing = 3 }, + + code = { font_family = "monospace", font_size = 13, padding_left = 30, padding_top = 3, padding_bottom = 7, line_spacing = 3, padding_in_blockquote = 6 }, + + blockquote = { font_family = "Arial", font_size = 13, padding_left = 0, padding_top = 5, padding_bottom = 10, line_spacing = 3, padding_indent = 10 }, + list = { font_family = "Arial", font_size = 13, padding_left = 40, padding_top = 5, padding_bottom = 7, line_spacing = 3, padding_indent = 5 }, + link = { font_family = "Arial", font_size = 13, base_color = "orange", bold_color = "tomato"}, + + separator = { padding_top = 3, padding_bottom = 7 } +} + +function QuickPreviewOverlay:new() + local instance = {} + setmetatable(instance, self) + instance:_initialize() + + return instance +end + +function QuickPreviewOverlay:_initialize() + self.visible_things = {} +end + +function QuickPreviewOverlay:timeToPixels(app_ctx, time) + return math.floor((time - app_ctx.av.start_time) * reaper.GetHZoomLevel()) +end + +function QuickPreviewOverlay:getItemYBounds(track, item) + -- Get the item's Y position in pixels within the Arrange view + local track_y = reaper.GetMediaTrackInfo_Value(track, "I_TCPY") + local item_y_offset = reaper.GetMediaItemInfo_Value(item, "I_LASTY") + local item_h = reaper.GetMediaItemInfo_Value(item, "I_LASTH") + + return math.floor(track_y + item_y_offset + 0.5), math.floor(item_h + 0.5) +end + +function QuickPreviewOverlay:IsInReaper() + local fg = reaper.JS_Window_GetForeground() + if is_macos then + return fg ~= nil + else + local mainhwnd = reaper.GetMainHwnd() + return fg == mainhwnd or reaper.JS_Window_IsChild(mainhwnd, fg) or (self.note_editor and fg == self.note_editor.hwnd) or (self.settings_editor and fg == self.settings_editor.hwnd) or (self.hwnd == fg) + end +end + +function QuickPreviewOverlay:buildEditContextForThing(object, type, track_num, parent_widget_name, pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right) + + local app_ctx = AppContext.instance() + + local parent = app_ctx.av + + if parent_widget_name == 'arrange' then + parent = app_ctx.av + elseif parent_widget_name == 'tcp' then + parent = app_ctx.tcp + elseif parent_widget_name == 'mcp' then + parent = (track_num == -1) and app_ctx.mcp_master or app_ctx.mcp_other + elseif parent_widget_name == 'time_ruler' then + parent = app_ctx.time_ruler + end + + local name = "" + if type == 'track' then + local _, tname = reaper.GetTrackName(object) + name = tname + elseif type == 'item' then + local take = reaper.GetActiveTake(object) + if take then + name = reaper.GetTakeName(take) + end + elseif type == "env" then + local _, ename = reaper.GetEnvelopeName(object) + name = ename + elseif type == "project" then + name = "Project" + end + + local notes = Notes:new(object) + + return { + -- Basic info + object = object, + type = type, + name = name, + -- Parent info + parent = parent, + widget = parent_widget_name, + -- Position and size of the hint rect + pos_x = pos_x_pixels, + pos_y = pos_y_pixels, + width = len_x_pixels, + height = len_y_pixels, + clamped_left = clamped_left, + clamped_right = clamped_right, + -- Annotation info + notes = notes, + -- Track num + track_num = track_num + 1 + } +end + +function QuickPreviewOverlay:updateVisibleThings() + + local app_ctx = AppContext.instance() + local avi = app_ctx.av + local tcp = app_ctx.tcp + + self.visible_things = {} -- Reset visible items list + + -- Get total number of tracks + local track_count = reaper.CountTracks(0) + + local function block_clamp(minx,miny,w,h, limitx, limity) + local maxx = minx + w + local maxy = miny + h + + local clamped_left = false + local clamped_right = false + if minx < 0 then + minx = 0 + clamped_left = true + end + if maxx > limitx then + maxx = limitx + clamped_right = true + end + + if miny < 0 then miny = 0 end + if maxy > limity then maxy = limity end + + w = maxx - minx + h = maxy - miny + + return minx, miny, w, h, clamped_left, clamped_right + end + + -- Iterate through tracks + for i = -1, track_count - 1 do + local track = (i==-1) and reaper.GetMasterTrack(0) or reaper.GetTrack(0, i) + + local _, tname = reaper.GetTrackName(track) + + -- Loop on track envelopes + local ei = 0 + while true do + local envelope = reaper.GetTrackEnvelope(track, ei) + if not envelope then break end + + local env_vis = reaper.GetSetEnvelopeInfo_String(envelope, "VISIBLE", "", false) + if env_vis then + local env_top = reaper.GetEnvelopeInfo_Value(envelope, "I_TCPY") + local env_height = reaper.GetEnvelopeInfo_Value(envelope, "I_TCPH") + local env_bottom = env_top + env_height + local tcp_entry = nil + + if env_height > 0 and ((env_top >= 0 and env_top <= avi.h) or (env_bottom >= 0 and env_bottom <= avi.h)) then + + local pos_x_pixels = 0 + local len_x_pixels = tcp.w + local pos_y_pixels, len_y_pixels = env_top, env_height + local clamped_left, clamped_right = false, false + + pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right = block_clamp(pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, tcp.w, tcp.h) + + local env_entry = self:buildEditContextForThing(envelope, "env", i, "tcp", pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right) + + table.insert(self.visible_things, env_entry) + end + end + + ei = ei + 1 + end + + + local track_height = reaper.GetMediaTrackInfo_Value(track, "I_TCPH") + local track_top = reaper.GetMediaTrackInfo_Value(track, "I_TCPY") + local track_bottom = track_top + track_height + local tcp_entry = nil + + local track_is_visible_in_tcp = false + if (i==-1) then track_is_visible_in_tcp = (reaper.GetMasterTrackVisibility() & 1 ~= 0) else track_is_visible_in_tcp = (reaper.IsTrackVisible(track, false)) end + + -- Loop on items. Process item only if visible. + if track_height > 0 and ((track_top >= 0 and track_top <= avi.h) or (track_bottom >= 0 and track_bottom <= avi.h)) and track_is_visible_in_tcp then + + local pos_x_pixels = 0 + local len_x_pixels = tcp.w + local pos_y_pixels, len_y_pixels = track_top, track_height + local clamped_left, clamped_right = false, false + + pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right = block_clamp(pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, tcp.w, tcp.h) + + tcp_entry = self:buildEditContextForThing(track, "track", i, "tcp", pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right) + + table.insert(self.visible_things, tcp_entry) + + -- Now process items + local item_count = reaper.CountTrackMediaItems(track) + + for j = 0, item_count - 1 do + local item = reaper.GetTrackMediaItem(track, j) + local item_pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") + local item_len = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") + local item_end = item_pos + item_len + + -- Get Arrange view time range + local start_time, end_time = avi.start_time, avi.end_time + if start_time == end_time then + start_time, end_time = reaper.GetPlayPosition(), reaper.GetProjectLength(0) + end + + -- Check if item is visible + if item_end >= start_time and item_pos <= end_time then + local pos_x_pixels = self:timeToPixels(app_ctx, item_pos) + local len_x_pixels = math.floor(item_len * reaper.GetHZoomLevel()+0.5) + local pos_y_pixels, len_y_pixels = self:getItemYBounds(track, item) + local clamped_left, clamped_right = false, false + + pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right = block_clamp(pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, avi.w, avi.h) + + table.insert(self.visible_things, self:buildEditContextForThing(item, "item", i, "arrange", pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right)) + end + end + end + + local track_is_visible_in_mcp = false + if (i==-1) then track_is_visible_in_mcp = (reaper.GetMasterTrackVisibility() & 2 == 0) else track_is_visible_in_mcp = (reaper.IsTrackVisible(track, true)) end + + -- Handle track in mcp + local mcp = (i==-1) and app_ctx.mcp_master or app_ctx.mcp_other + if mcp.hwnd and reaper.JS_Window_IsVisible(mcp.hwnd) and track_is_visible_in_mcp then + + local mcp_track_left = reaper.GetMediaTrackInfo_Value(track, "I_MCPX") + local mcp_track_width = reaper.GetMediaTrackInfo_Value(track, "I_MCPW") + local mcp_full_track_top = reaper.GetMediaTrackInfo_Value(track, "I_MCPY") + local mcp_full_track_height = reaper.GetMediaTrackInfo_Value(track, "I_MCPH") + local mcp_track_height = mcp_full_track_height -- - space_for_fx_and_send + local mcp_track_top = mcp_full_track_top -- + space_for_fx_and_send + local pos_x_pixels = mcp_track_left + local len_x_pixels = mcp_track_width + local pos_y_pixels, len_y_pixels = mcp_track_top, mcp_track_height + local clamped_left, clamped_right = false, false + + pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right = block_clamp(pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, mcp.w, mcp.h) + + if pos_x_pixels >= 0 and len_x_pixels > 2 and pos_x_pixels + len_x_pixels <= mcp.w then + local mcp_entry = self:buildEditContextForThing(track, "track", i, "mcp", pos_x_pixels, pos_y_pixels, len_x_pixels, len_y_pixels, clamped_left, clamped_right) + table.insert(self.visible_things, mcp_entry) + if tcp_entry then + mcp_entry.tcp_entry = tcp_entry + tcp_entry.mcp_entry = mcp_entry + end + end + end + end + + if app_ctx.time_ruler.hwnd and reaper.JS_Window_IsVisible(app_ctx.time_ruler.hwnd) then + local pos_x_pixels = app_ctx.time_ruler.x + local pos_y_pixels = app_ctx.time_ruler.y + local len_x_pixels = app_ctx.time_ruler.w + local len_y_pixels = app_ctx.time_ruler.h + local proj = reaper.EnumProjects(-1) + local proj_entry, _ = self:buildEditContextForThing(proj, "project", -1, "time_ruler", 0, 0, len_x_pixels, len_y_pixels, false, false) + table.insert(self.visible_things, proj_entry) + end + + for _, thing in ipairs(self.visible_things) do + self:applySearchToThing(thing) + end +end + +function QuickPreviewOverlay:title() + return "Reannotate Quick Preview" +end + +function QuickPreviewOverlay:ensureHwnd() + if not self.hwnd then + -- Retrieve hwn on instanciation + self.hwnd = reaper.JS_Window_Find(self:title(), true) + self.parent_hwnd = reaper.JS_Window_GetParent(self.hwnd) + reaper.JS_WindowMessage_Intercept(self.hwnd, "WM_MOUSEWHEEL", false) + reaper.JS_WindowMessage_Intercept(self.hwnd, "WM_MOUSEHWHEEL", false) + end +end + +function QuickPreviewOverlay:forwardEvent(event) + local app_ctx = AppContext:instance() + + self.last_peeked_message_times = self.last_peeked_message_times or {} + + local message_is_new = true + + while message_is_new do + local b, pt, time, wpl, wph, lpl, lph = reaper.JS_WindowMessage_Peek(self.hwnd, event) + + message_is_new = not (time == self.last_peeked_message_times[event]) and not(time == 0) and not(reaper.time_precise() - time > 3.0) -- Avoid peeking old messages when relaunching in debug + if message_is_new then + local mx, my = reaper.GetMousePosition() + mx, my = ImGui.PointConvertNative(app_ctx.imgui_ctx, mx, my) + local target = reaper.GetMainHwnd() + + -- TODO : ATM mcp layout changes are not detected so this will scroll the MCP but tracks will be at the wrong place + if reaper.JS_Window_IsVisible(app_ctx.mcp_other.hwnd) and + (app_ctx.mcp_other.x <= mx and mx <= app_ctx.mcp_other.x + app_ctx.mcp_other.w) and + (app_ctx.mcp_other.y <= my and my <= app_ctx.mcp_other.y + app_ctx.mcp_other.h) then + target = app_ctx.mcp_other.hwnd + end + + reaper.JS_WindowMessage_Post(target, event, wpl, wph, lpl, lph) + self.last_peeked_message_times[event] = time + end + end +end + +function QuickPreviewOverlay:forwardMouseWheelEvents() + self:forwardEvent("WM_MOUSEWHEEL") + self:forwardEvent("WM_MOUSEHWHEEL") +end + +function QuickPreviewOverlay:minimizeTopWindowsAtLaunch() + local app_ctx = AppContext.instance() + + self.minimized_windows = {} + local c, l = reaper.JS_Window_ListAllTop() + for token in string.gmatch(l, "[^,]+") do + local subhwnd = reaper.JS_Window_HandleFromAddress(token) + if not subhwnd then return end + + if subhwnd ~= app_ctx.mv.hwnd and subhwnd ~= self.hwnd and reaper.JS_Window_IsVisible(subhwnd) then + + local owner = reaper.JS_Window_GetRelated(subhwnd, "OWNER") + + local is_minimized = (reaper.JS_Window_GetLong(subhwnd, "STYLE") & 0x20000000 ~= 0) + if not is_minimized and reaper.JS_Window_GetTitle(owner) == reaper.JS_Window_GetTitle(app_ctx.mv.hwnd) then + reaper.JS_Window_Show(subhwnd, "SHOWMINIMIZED") + self.minimized_windows[#self.minimized_windows+1] = subhwnd + end + end + end +end + +function QuickPreviewOverlay:restoreMinimizedWindowsAtExit() + for _, win in ipairs(self.minimized_windows) do + reaper.JS_Window_Show(win, "SHOWNOACTIVATE") + end +end + +function QuickPreviewOverlay:ensureZOrder() + local app_ctx = AppContext.instance() + local ctx = app_ctx.imgui_ctx + local t = reaper.time_precise() + + if S.getSetting("UseProfiler") then + return + end + + if (not self.was_in_reaper) and (self:IsInReaper()) then + -- Returning into reaper. Give focus to editor if there's one open + if self.note_editor then + reaper.JS_Window_SetForeground(self.note_editor.hwnd) + end + + -- Set main window as foreground. + reaper.JS_Window_SetForeground(app_ctx.mv.hwnd) + + -- Set our overlay as foreground + reaper.JS_Window_SetForeground(self.hwnd) + + if self.note_editor then + app_ctx:flog("Foregrounding note editor ...") + reaper.JS_Window_SetForeground(self.note_editor.hwnd) + self.note_editor:GrabFocus() + end + + else + if not self.note_editor and not self.settings_window then + -- If we don't have a note editor, then the overlay should always have focus in the imgui context + if self:IsInReaper() and not ImGui.IsWindowFocused(ctx) then + ImGui.SetWindowFocus(ctx) + reaper.JS_Window_SetFocus(self.hwnd) + app_ctx:flog("Overlay grabs focus") + end + end + end +end + +function QuickPreviewOverlay:garbageCollectNoteEditor() + -- Ensure note editor is deleted if not used anymore + if self.note_editor and not self.note_editor.show_editor then + self.note_editor = nil + AppContext:instance():flog("Garbage collected note editor") + end +end + +function QuickPreviewOverlay:currentTooltipSizeForThing(thing) + if thing.hovered_slot == -1 then + return Notes.defaultTooltipSize() + end + + return thing.notes:tooltipSize(thing.hovered_slot or 0) +end + + +function QuickPreviewOverlay:tooltipAdvisedPositionForThing(thing, mouse_x, mouse_y) + local w, h = self:currentTooltipSizeForThing(thing) + local screen = GetScreen(mouse_x, mouse_y) + + local x = mouse_x + 20 + local y = mouse_y + 20 + + if mouse_x + 20 + w > screen.w then + x = screen.w - w + end + + if mouse_y + 20 + h > screen.h then + y = screen.h - h + end + + return { x = x, y = y } +end + +function QuickPreviewOverlay:editorAdvisedPositionAndSizeForThing(thing, mouse_x, mouse_y) + local screen = GetScreen(mouse_x, mouse_y) + local ttpos = self:tooltipAdvisedPositionForThing(thing, mouse_x, mouse_y) + local ttw, tth = self:currentTooltipSizeForThing(thing) + + local w = ttw + local h = tth + + if w < 550 then + w = 550 + end + + if h < 200 then + h = 200 + end + + local x = ttpos.x - w + if x < 0 then + x = ttpos.x + ttw + end + + local y = ttpos.y + if y + h > screen.h then + y = screen.h - h + end + + return { x = x, y = y, w = w, h = h} +end + +function QuickPreviewOverlay:tooltipAdvisedPositionForEditedThingAndSlot(thing, slot, editor, mx, my) + local screen = GetScreen(mx, my) + local edx,edy,edw,edh = editor.x,editor.y,editor.w,editor.h + local ttw, tth = self:currentTooltipSizeForThing(thing) + + -- Detect if current tooltip is on the left or right, and stay left or right + local is_left = thing.capture_xy and thing.capture_xy.x < edx + + local x = thing.capture_xy.x + local y = thing.capture_xy.y + + if is_left then + x = edx - ttw + if x < 0 then + x = edx + edw + end + else + x = edx + edw + if x + ttw > screen.w then + x = edx - ttw + end + end + + y = edy + if y + tth > screen.h then + y = screen.h - tth + end + return { x = x, y = y } +end + +function QuickPreviewOverlay:applySearchToThing(thing) + thing.search_results = {} + self.filter_str = self.filter_str or '' + + for i=0, Notes.MAX_SLOTS - 1 do + local slot = i + local slotNotes = thing.notes:slot(slot) + + if self.filter_str == '' then + thing.search_results[slot+1] = true + else + thing.search_results[slot+1] = slotNotes:match(self.filter_str) + end + end +end + +function QuickPreviewOverlay:drawQuickSettings() + local app_ctx = AppContext.instance() + local ctx = app_ctx.imgui_ctx + local draw_list = ImGui.GetWindowDrawList(ctx) + local mx, my = ImGui.GetMousePos(ctx) + local is_clicked = ImGui.IsMouseClicked(ctx, ImGui.MouseButton_Left) + local margin_t = 8 + local margin_l = 8 + local spacing = 4 + local mid_spacing = spacing*0.5 + local r = 9.5 + local d = 2 * r + local header_l = 60 + local wpos_x, wpos_y = ImGui.GetWindowPos(ctx) + + local sinalpha = (0xFF - 0x40 + math.floor(0x40 * math.sin(reaper.time_precise()*10))) + + ImGui.DrawList_AddRectFilled(draw_list, app_ctx.main_toolbar.x, app_ctx.main_toolbar.y, app_ctx.main_toolbar.x + app_ctx.main_toolbar.w, app_ctx.main_toolbar.y + app_ctx.main_toolbar.h, 0x202020E0, 5) + ImGui.DrawList_AddRect (draw_list, app_ctx.main_toolbar.x, app_ctx.main_toolbar.y, app_ctx.main_toolbar.x + app_ctx.main_toolbar.w, app_ctx.main_toolbar.y + app_ctx.main_toolbar.h, 0xA0A0A0FF, 5) + + for i=0, Notes.MAX_SLOTS - 1 do + local slot = (i == Notes.MAX_SLOTS - 1) and (0) or (i+1) + local color = (Notes.SlotColor(slot) << 8) | 0xFF + local l = app_ctx.main_toolbar.x + (i * (2 * r + spacing)) + header_l + margin_l + local t = app_ctx.main_toolbar.y + margin_t + local hovered = (l - mid_spacing <= mx) and (mx <= l + mid_spacing + d) and (t - mid_spacing <= my) and (my <= t + mid_spacing + d) + + + if app_ctx.enabled_category_filters[slot+1] then + local fcol = color & 0xFFFFFF00 | 0x80 + if hovered then + fcol = (color & 0xFFFFFF00) | sinalpha + end + ImGui.DrawList_AddRectFilled(draw_list, l, t, l + d, t + d, fcol, 1, 0) + end + ImGui.DrawList_AddRect(draw_list, l, t, l + d, t + d, color, 1, 0, 1) + + if hovered then + ImGui.SetMouseCursor(ctx, ImGui.MouseCursor_Hand) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_WindowPadding, 3, 1) + ImGui.PushStyleColor(ctx, ImGui.Col_PopupBg, color ) + ImGui.PushStyleColor(ctx, ImGui.Col_Text, 0x000000FF) + ImGui.SetTooltip(ctx, Notes.SlotLabel(slot)) + ImGui.PopStyleColor(ctx, 2) + ImGui.PopStyleVar(ctx) + end + + if is_clicked and hovered then + app_ctx.enabled_category_filters[slot+1] = not app_ctx.enabled_category_filters[slot+1] + end + end + + ImGui.PushFont(ctx, app_ctx.arial_font, 12) + ImGui.DrawList_AddText(draw_list, app_ctx.main_toolbar.x + margin_l, app_ctx.main_toolbar.y + margin_t + 3, 0xA0A0A0FF, "Filter") + ImGui.PopFont(ctx) + + local px, py = ImGui.GetWindowPos(ctx) + ImGui.SetCursorPos(ctx, app_ctx.main_toolbar.x - px + 260, app_ctx.main_toolbar.y - py + margin_t ) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding, 4, 2) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FrameRounding, 2) + if ImGui.Button(ctx, "All") then + for i=0, Notes.MAX_SLOTS - 1 do + local slot = (i == Notes.MAX_SLOTS - 1) and (0) or (i+1) + app_ctx.enabled_category_filters[slot+1] = true + end + end + ImGui.PushStyleVar(ctx, ImGui.StyleVar_ItemSpacing, 3, 3) + ImGui.SameLine(ctx) + if ImGui.Button(ctx, "None") then + for i=0, Notes.MAX_SLOTS - 1 do + local slot = (i == Notes.MAX_SLOTS - 1) and (0) or (i+1) + app_ctx.enabled_category_filters[slot+1] = false + end + end + ImGui.PopStyleVar(ctx, 3) + + -- Search label + ImGui.PushFont(ctx, app_ctx.arial_font, 12) + ImGui.DrawList_AddText(draw_list, app_ctx.main_toolbar.x + margin_l, app_ctx.main_toolbar.y + margin_t + 27, 0xA0A0A0FF, "Search") + ImGui.PopFont(ctx) + + + ImGui.SetCursorPos(ctx, app_ctx.main_toolbar.x - wpos_x + margin_l + header_l, ImGui.GetCursorPosY(ctx) + 3) + self.filter_str = self.filter_str or "" + ImGui.SetNextItemWidth(ctx, 218) --math.min(220, app_ctx.main_toolbar.w - margin_l * 2 - header_l)) + local b, v = ImGui.InputText(ctx, "##search_input", self.filter_str, ImGui.InputTextFlags_NoHorizontalScroll | ImGui.InputTextFlags_AutoSelectAll | ImGui.InputTextFlags_ParseEmptyRefVal ) + if b then + self.filter_str = v + for ti, thing in ipairs(self.visible_things) do + self:applySearchToThing(thing) + end + end + if self.filter_str == "" then + local sx, sy = ImGui.GetItemRectMin(ctx) + ImGui.DrawList_AddText(draw_list, sx + 5, sy + 1, 0xA0A0A0FF, "Terms ...") + end + + ImGui.SameLine(ctx) + ImGui.SetCursorPosX(ctx, ImGui.GetCursorPosX(ctx) + 4) + + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FramePadding, 1, 1) + ImGui.PushStyleVar(ctx, ImGui.StyleVar_FrameRounding, 2) + if ImGui.ImageButton(ctx, "##settings_button", app_ctx:getImage("settings"), 17, 17) then + ImGui.SameLine(ctx) + if self.settings_window then + self.settings_window = nil + else + self.settings_window = SettingsEditor:new() + self.settings_window:setPosition(wpos_x + ImGui.GetCursorPosX(ctx) + 5, wpos_y + ImGui.GetCursorPosY(ctx) ) + end + end + if ImGui.IsItemHovered(ctx) then + ImGui.SetTooltip(ctx, "Settings") + end + ImGui.PopStyleVar(ctx, 2) + + local align = 320 + local remaining_space = app_ctx.main_toolbar.w - align + + local font_size = math.floor(remaining_space * 26/180.0 + 0.5) + if font_size > 26 then font_size = 26 end + if font_size < 16 then font_size = 16 end + + ImGui.PushFont(ctx, app_ctx.arial_font_italic, font_size) + local rw, rh = ImGui.CalcTextSize(ctx, "Reannotate") + + if rw + font_size < remaining_space then + -- Enough room to draw logo + local spacing = 0.5 * (remaining_space - rw) + local xpos = align + spacing + if spacing > font_size then + -- If space is big, align right, else center + xpos = app_ctx.main_toolbar.w - font_size - rw + end + ImGui.DrawList_AddText(draw_list, app_ctx.main_toolbar.x + xpos, app_ctx.main_toolbar.y + (app_ctx.main_toolbar.h - rh) * 0.5, 0xA0A0A0FF, "Reannotate") + end + ImGui.PopFont(ctx) +end + +function QuickPreviewOverlay:drawVisibleThing(thing, hovered_thing) + local app_ctx = AppContext.instance() + local ctx = app_ctx.imgui_ctx + local sinalpha = (0x50 + math.floor(0x40 * math.sin((reaper.time_precise() - (self.blink_start_time or 0))*10))) + local sin2alpha = (0x50 + math.floor(0x40 * math.sin((reaper.time_precise() - (self.blink_start_time or 0))*20))) + local draw_list = ImGui.GetWindowDrawList(ctx) + + -- The following method allows to work on an entry which is present in the MCP and TCP at the same time + local hovered = hovered_thing and hovered_thing.object == thing.object + local edited = self.note_editor and self.note_editor.edit_context.object == thing.object + + if not hovered then + -- reset hovered slot + thing.hovered_slot = nil + end + + -- Calculate the number of divisions + local divisions = 0 + local has_notes_to_show = false + local no_notes_message = "" + for i=0, Notes.MAX_SLOTS do + local slot_notes = thing.notes:slot(i) + local is_slot_enabled = app_ctx.enabled_category_filters[i + 1] + local the_slot_matches_the_search = (thing.search_results[i + 1]) + if slot_notes and slot_notes ~= "" and is_slot_enabled and the_slot_matches_the_search then + divisions = divisions + 1 + has_notes_to_show = true + end + end + + -- We should always show one division, even no note is shown + if divisions == 0 then + divisions = 1 + if thing.notes:isBlank() then + no_notes_message = "`:grey:No " .. thing.type .. " notes`" + else + no_notes_message = "`:grey:All notes hidden`" + end + end + + local mx, my = ImGui.GetMousePos(ctx) + local xs = thing.pos_x + thing.parent.x + local ys = thing.pos_y + thing.parent.y + local ww = thing.width + local hh = thing.height + + local hdivide = (thing.widget == "arrange") or (thing.widget == "tcp") or (thing.widget == "time_ruler") + local step = (hdivide) and (ww * 1.0 / divisions) or (hh * 1.0 / divisions) + + local div = -1 + for i=0, Notes.MAX_SLOTS-1 do + -- We change the slot span order (1,2,3... and then 0) + local is_no_note_slot = (i==0 and not has_notes_to_show) + + local slot = (i==Notes.MAX_SLOTS - 1) and (0) or (i+1) + local is_slot_enabled = (is_no_note_slot or app_ctx.enabled_category_filters[slot + 1]) + + local slot_notes = (is_no_note_slot and no_notes_message or thing.notes:slot(slot)) + local the_slot_matches_the_search = (thing.search_results[slot+1]) + + -- The empty slot is always visible + local is_slot_visible = (is_no_note_slot) or (slot_notes and slot_notes ~= "" and is_slot_enabled and the_slot_matches_the_search) + + if is_slot_visible then + div = div + 1 + -- Calculate this the first time we need it this frame + -- It should be calculated after the hover, we need the right date + + local base_color_no_note = 0x00000000 --0x90909000 + local border_width = 1 + local triangle_size = math.min(10, hdivide and step or ww, hdivide and hh or step) + local bg_color = base_color_no_note | 0x30 + local border_color = base_color_no_note | 0xF0 + + if not is_no_note_slot then + border_width = 2 + + local base_color_with_note = Notes.SlotColor(slot) << 8 --0xFF007000 + bg_color = base_color_with_note | 0x30 + border_color = base_color_with_note | 0xF0 + end + + local x1 = (hdivide) and (xs + (div) * step) or (xs) + local y1 = (hdivide) and (ys) or (ys + (div) * step) + local x2 = ((hdivide) and (x1 + step) or (x1 + ww)) + local y2 = ((hdivide) and (y1 + hh) or (y1 + step)) + + if hovered then + -- Hovering this slot + if (x1 <= mx and mx <= x2 and y1 <= my and my <= y2) then + if not is_no_note_slot then + thing.hovered_slot = slot + if thing.mcp_entry then + thing.mcp_entry.hovered_slot = thing.hovered_slot + elseif thing.tcp_entry then + thing.tcp_entry.hovered_slot = thing.hovered_slot + end + else + -- This is dangerous, but don't really have the choice with the current model :/ + thing.hovered_slot = -1 + thing.no_notes_message = no_notes_message + end + end + end + + local it_s_the_edited_slot = (edited and self.note_editor.edited_slot == slot) + local there_s_no_editor_open_but_the_slot_is_hovered = (not self.note_editor and hovered and thing.hovered_slot == slot) + local hovering_something_that_cant_have_notes = (hovered and is_no_note_slot) + + if it_s_the_edited_slot or there_s_no_editor_open_but_the_slot_is_hovered or hovering_something_that_cant_have_notes then + bg_color = (bg_color & 0xFFFFFF00) | sinalpha + border_color = (border_color & 0xFFFFFF00) | (sinalpha + 0x60) + end + + -- Background color. + ImGui.DrawList_AddRectFilled(draw_list, x1, y1, x2, y2, bg_color, 0, 0) + + if hdivide then + -- Left border + if div == 0 and not thing.clamped_left then + ImGui.DrawList_AddRectFilled(draw_list, x1, y1, x1 + border_width, y2, border_color, 0, 0) + end + -- Right border + if div == divisions - 1 and not thing.clamped_right then + ImGui.DrawList_AddRectFilled(draw_list, x2 - border_width, y1, x2, y2, border_color, 0, 0) + end + -- Top and bottom borders + ImGui.DrawList_AddRectFilled(draw_list, x1, y1, x2, y1 + border_width, border_color, 0, 0) + ImGui.DrawList_AddRectFilled(draw_list, x1, y2 - border_width, x2, y2, border_color, 0, 0) + else + if div == 0 then + ImGui.DrawList_AddRectFilled(draw_list, x1, y1, x2, y1 + border_width, border_color, 0, 0) + end + if div == divisions - 1 then + ImGui.DrawList_AddRectFilled(draw_list, x1, y2 - border_width, x2, y2, border_color, 0, 0) + end + ImGui.DrawList_AddRectFilled(draw_list, x1, y1, x1 + border_width, y2, border_color, 0, 0) + ImGui.DrawList_AddRectFilled(draw_list, x2 - border_width, y1, x2, y2, border_color, 0, 0) + end + + if not is_no_note_slot then + -- Label part + ImGui.PushFont(ctx, app_ctx.arial_font, 10) + local label = Notes.SlotLabel(slot) + local lw, lh = ImGui.CalcTextSize(ctx, label) + local padding_h = 4 + local padding_v = 3 + local margin = 5 + local available_width = x2 - x1 - 2 * margin - 2 * padding_h + + if lh + 2 * padding_v + 2 * margin < y2 - y1 then + local text_w = nil + local text = nil + if available_width > lw then + text = label + text_w = lw + else + local initial = label:sub(1,1) + local llw, llh = ImGui.CalcTextSize(ctx, initial) + if available_width > llw then + text = initial + text_w = llw + end + end + if text then + local rx = x2 - margin - 2 * padding_h - text_w + local ry = y1 + margin + local rx_r = x2 - margin + local rx_b = y1 + margin + lh + 2 * padding_v + ImGui.DrawList_AddRectFilled(draw_list, rx, ry, rx_r, rx_b, bg_color | 0xFF, 2) + ImGui.DrawList_AddRect(draw_list, rx, ry, rx_r, rx_b, 0x000000FF, 2) + ImGui.DrawList_AddText(draw_list, x2 - margin - padding_h - text_w, y1 + margin + padding_v, 0x000000FF, text) + end + end + ImGui.PopFont(ctx) + + -- Post-it triangles + if not (div == 0 and thing.clamped_left) then + ImGui.DrawList_AddTriangleFilled(draw_list, x1, y1, x1 + triangle_size, y1, x1, y1 + triangle_size, border_color) + ImGui.DrawList_AddTriangleFilled(draw_list, x1, y2, x1, y2 - triangle_size, x1 + triangle_size, y2, border_color) + end + + if not (div == divisions -1 and thing.clamped_right) then + ImGui.DrawList_AddTriangleFilled(draw_list, x2 - triangle_size, y1, x2, y1, x2, y1 + triangle_size, border_color) + ImGui.DrawList_AddTriangleFilled(draw_list, x2, y2, x2 - triangle_size, y2, x2, y2 - triangle_size, border_color) + end + end + end + end +end + +function QuickPreviewOverlay:drawTooltip(hovered_thing) + local app_ctx = AppContext.instance() + local ctx = app_ctx.imgui_ctx + local mx, my = ImGui.GetMousePos(ctx) + local tt_pos = {x = mx + 20, y = my + 20} + + local thing_to_tooltip = (self.note_editor and self.note_editor.edit_context) or (hovered_thing) + + if thing_to_tooltip then + if not self.mdwidget then + -- This will lag a bit. Do it on first capture. + self.mdwidget = ImGuiMd:new(ctx, "markdown_widget_1", { wrap = true, autopad = true, skip_last_whitespace = true }, QuickPreviewOverlay.markdownStyle ) + end + + local slot_to_tooltip = ((self.note_editor) and (self.note_editor.edited_slot)) or (thing_to_tooltip.hovered_slot) + local tttext = (slot_to_tooltip == -1) and (thing_to_tooltip.no_notes_message) or (thing_to_tooltip.notes:slot(slot_to_tooltip)) + if tttext == "" or tttext == nil then + tttext = "`:grey:No " .. thing_to_tooltip.type .. " notes for the `_:grey:" .. Notes.SlotLabel(slot_to_tooltip):lower() .. "_ `:grey:category`" + end + + self.mdwidget:setText(tttext) + + ImGui.SetNextWindowBgAlpha(ctx, 1) + + if self.note_editor then + -- If there's a note editor, fix the position + tt_pos = thing_to_tooltip.capture_xy + else + tt_pos = self:tooltipAdvisedPositionForThing(thing_to_tooltip, mx, my) + end + + local ttw, tth = self:currentTooltipSizeForThing(thing_to_tooltip) + + -- First draw, force coordinates and size + if self.tt_draw_count == 0 then + ImGui.SetNextWindowPos(ctx, tt_pos.x, tt_pos.y) + ImGui.SetNextWindowSize(ctx, ttw, tth) + end + + if ImGui.Begin(ctx, "Reannotate Notes (Tooltip)", true, ImGui.WindowFlags_NoFocusOnAppearing | ImGui.WindowFlags_NoTitleBar | ImGui.WindowFlags_TopMost | ImGui.WindowFlags_NoSavedSettings) then + local cur_x, cur_y = ImGui.GetWindowPos(ctx) + local cur_w, cur_h = ImGui.GetWindowSize(ctx) + local draw_list = ImGui.GetWindowDrawList(ctx) + + self.mdwidget:render(ctx) + + -- Border. Tooltip is shown when edited or hoevered + if (self.note_editor) or (not thing_to_tooltip.notes:isBlank() and ( thing_to_tooltip.hovered_slot ~= -1)) then + ImGui.DrawList_AddRect(draw_list, cur_x + 1, cur_y + 1, cur_x + cur_w - 1, cur_y + cur_h - 1, Notes.SlotColor(slot_to_tooltip) << 8 | 0xFF, 0, 0, 2) + end + + -- Resiszers + if self.note_editor then + local triangle_size = 13 + ImGui.DrawList_AddTriangleFilled(draw_list, cur_x, cur_y + cur_h, cur_x + triangle_size, cur_y + cur_h, cur_x, cur_y + cur_h - triangle_size, Notes.SlotColor(slot_to_tooltip) << 8 | 0xFF) + ImGui.DrawList_AddTriangleFilled(draw_list, cur_x + cur_w, cur_y + cur_h, cur_x + cur_w, cur_y + cur_h - triangle_size, cur_x + cur_w - triangle_size, cur_y + cur_h, Notes.SlotColor(slot_to_tooltip) << 8 | 0xFF) + end + + -- Save new sizes to items/tracks + + if ((cur_w ~= ttw) or (cur_h ~= tth)) and self.note_editor then + thing_to_tooltip.notes:setTooltipSize(self.note_editor.edited_slot, cur_w, cur_h) + thing_to_tooltip.notes:commit() + + -- Alternate entry (tcp/mcp clone) should be refreshed + local alternate_entry = thing_to_tooltip.mcp_entry or thing_to_tooltip.tcp_entry + if alternate_entry then + alternate_entry.notes:pull() + end + end + + if ImGui.IsWindowFocused(ctx) and self.note_editor and not ImGui.IsMouseDown(ctx, ImGui.MouseButton_Left) then + -- If we have a valid note editor and we're not resizing the tooltip, the note editor should always have focus + self.note_editor:GrabFocus() + end + + ImGui.End(ctx) + + self.tt_draw_count = (self.tt_draw_count or 0) + 1 + end + else + self.tt_draw_count = 0 + end +end + +function QuickPreviewOverlay:draw() + local app_ctx = AppContext.instance() + + local ctx = app_ctx.imgui_ctx + local mvi = app_ctx.mv + + -- Set ImGui window size and position to match Main view ... maybe we should remove the top bar on macos + ImGui.SetNextWindowSize(ctx, mvi.w, mvi.h) + ImGui.SetNextWindowPos(ctx, mvi.x, mvi.y) + + ImGui.PushStyleVar(ctx, ImGui.StyleVar_WindowBorderSize, 0) + ImGui.SetNextWindowBgAlpha(ctx, 0.4 * math.sin(math.pi * 0.5 * math.min(reaper.time_precise() - app_ctx.launch_context.launch_time, 0.3)/0.3)) + + -- Begin ImGui frame with visible background for debugging + local succ, is_open = ImGui.Begin(ctx, self:title() , nil, + ImGui.WindowFlags_NoTitleBar | + ImGui.WindowFlags_NoScrollWithMouse | + ImGui.WindowFlags_NoScrollbar | + ImGui.WindowFlags_NoResize | + ImGui.WindowFlags_NoNav | + ImGui.WindowFlags_NoNavInputs | + ImGui.WindowFlags_NoMove | + ImGui.WindowFlags_NoDocking | + ImGui.WindowFlags_NoDecoration) + + ImGui.PopStyleVar(ctx) + + local hovered_thing = nil + local captured_click = false + local mx, my = ImGui.GetMousePos(ctx) + + self.draw_count = self.draw_count or 0 + + if self.draw_count == 0 then + self:minimizeTopWindowsAtLaunch() + end + + if succ then + + self:ensureHwnd() + self:garbageCollectNoteEditor() + self:ensureZOrder() + self:forwardMouseWheelEvents() + + -- Handle overlay events. This should be done first, because escape will quit any item + if ImGui.IsWindowHovered(ctx, ImGui.HoveredFlags_RootWindow) and ImGui.IsWindowFocused(ctx) and not(ImGui.IsAnyItemActive(ctx)) then + if ImGui.IsKeyPressed(ctx, ImGui.Key_Escape, false) or (app_ctx.shortcut_was_released_once and app_ctx.launch_context:isShortcutStillPressed()) then + app_ctx.want_quit = true + end + end + + -- Black background, for alpha see above + ImGui.PushStyleColor(ctx, ImGui.Col_WindowBg, 0x000000FF) + + self:drawQuickSettings() + + -- Set draw list to Arrange view + local draw_list = ImGui.GetWindowDrawList(ctx) + + -- First pass to detect hovered thing + for _, thing in ipairs(self.visible_things) do + local x1 = thing.pos_x + thing.parent.x + local y1 = thing.pos_y + thing.parent.y + local x2 = x1 + thing.width + local y2 = y1 + thing.height + local hovered = x1 <= mx and mx <= x2 and y1 <= my and my <= y2 + if hovered and ImGui.IsWindowHovered(ctx) then + hovered_thing = thing + self.blink_start_time = self.blink_start_time or reaper.time_precise() + end + end + + -- Draw border + bg + for _, thing in ipairs(self.visible_things) do + self:drawVisibleThing(thing, hovered_thing) + end + + if hovered_thing and (hovered_thing ~= self.last_hovered_thing or hovered_thing.hovered_slot ~= self.last_hovered_slot) then + --self.blink_start_time = nil + end + + self:drawTooltip(hovered_thing) + + if ImGui.IsWindowHovered(ctx, ImGui.HoveredFlags_RootWindow) and ImGui.IsMouseClicked(ctx,0,false) then + captured_click = true + end + + ImGui.PopStyleColor(ctx) + ImGui.End(ctx) + + self.last_hovered_thing = hovered_thing + self.last_hovered_slot = hovered_thing and hovered_thing.hovered_slot + else + -- Emergency exit + app_ctx.want_quit = true + end + + if hovered_thing and captured_click then + -- Save the click point for fixing the position + hovered_thing.capture_xy = self:tooltipAdvisedPositionForThing(hovered_thing, mx, my) + hovered_thing.capture_slot = hovered_thing.hovered_slot + + self.tt_draw_count = 0 + + self.note_editor = NoteEditor:new() + self.note_editor:setEditContext(hovered_thing) + local ne_metrics = self:editorAdvisedPositionAndSizeForThing(hovered_thing, mx, my) + self.note_editor:setPosition(ne_metrics.x, ne_metrics.y) + self.note_editor:setSize(ne_metrics.w, ne_metrics.h) + self.note_editor:setEditedSlot(hovered_thing.hovered_slot == -1 and 1 or hovered_thing.hovered_slot) + + self.note_editor.slot_edit_change_callback = function() + -- We may need to reposition the tooltip since it's changed + hovered_thing.capture_xy = self:tooltipAdvisedPositionForEditedThingAndSlot(hovered_thing, self.note_editor.edited_slot, self.note_editor, mx, my) + -- Reset draw counter for tooltip to take position change into account + self.tt_draw_count = 0 + end + + self.note_editor.slot_commit_callback = function() + for _, thing in ipairs(self.visible_things) do + self:applySearchToThing(thing) + end + end + end + + if hovered_thing and not self.note_editor then + -- Reset tooltip draw count so that the tooltip is not fixed + self.tt_draw_count = 0 + end + + if not hovered_thing and captured_click then + -- Clicked on something which is not overable : remove editor + self.note_editor = nil + end + + if self.note_editor and self.note_editor.show_editor then + self.note_editor:draw() + end + + if self.settings_window and self.settings_window.open then + self.settings_window:draw() + else + self.settings_window = nil + end + + if self.draw_count > 0 then + -- Update "in reaper" state + self.was_in_reaper = (self:IsInReaper()) + end + + -- Update draw count + self.draw_count = self.draw_count + 1 + + if app_ctx.want_quit then + -- Restore windows before exiting + self:restoreMinimizedWindowsAtExit() + reaper.JS_Window_SetFocus(app_ctx.mv.hwnd) + reaper.JS_Window_SetForeground(app_ctx.mv.hwnd) + end +end + +return QuickPreviewOverlay diff --git a/Various/talagan_Reannotate/widgets/settings_editor.lua b/Various/talagan_Reannotate/widgets/settings_editor.lua new file mode 100644 index 000000000..bf2eb9e78 --- /dev/null +++ b/Various/talagan_Reannotate/widgets/settings_editor.lua @@ -0,0 +1,120 @@ +-- @noindex +-- @author Ben 'Talagan' Babut +-- @license MIT +-- @description This file is part of Reannotate + +local ImGui = require "ext/imgui" +local AppContext = require "classes/app_context" +local Notes = require "classes/notes" +local S = require "modules/settings" + +local SettingsEditor = {} +SettingsEditor.__index = SettingsEditor + +function SettingsEditor:new() + local instance = {} + setmetatable(instance, self) + instance:_initialize() + self.draw_count = 0 + return instance +end + +function SettingsEditor:_initialize() + self.open = true +end + +function SettingsEditor:setPosition(pos_x, pos_y) + self.pos = { x = pos_x, y = pos_y} +end + +function SettingsEditor:draw() + local app_ctx = AppContext:instance() + local ctx = app_ctx.imgui_ctx + + + local b, open = ImGui.Begin(ctx, "Reannotate Settings##reannotate_settings_editor", true, + ImGui.WindowFlags_AlwaysAutoResize | + ImGui.WindowFlags_NoDocking | + ImGui.WindowFlags_NoResize | + ImGui.WindowFlags_TopMost | + ImGui.WindowFlags_NoCollapse + ) + + -- Set initiial position + if ImGui.IsWindowAppearing(ctx) and self.pos then + ImGui.SetWindowPos(ctx, self.pos.x, self.pos.y) + end + + -- Close window on escape + if ImGui.IsWindowFocused(ctx) then + if ImGui.IsKeyChordPressed(ctx, ImGui.Key_Escape) then + open = false + end + end + + if b then + ImGui.SeparatorText(ctx, "Category Names") + + if ImGui.BeginTable(ctx, "aaaa##table", 3) then + ImGui.TableSetupColumn(ctx, "") + ImGui.TableSetupColumn(ctx, "New Project") + ImGui.TableSetupColumn(ctx, "Current Project") + ImGui.TableHeadersRow(ctx) + for i=0, Notes.MAX_SLOTS-1 do + local slot = (i==Notes.MAX_SLOTS - 1) and (0) or (i+1) + ImGui.TableNextRow(ctx) + ImGui.TableNextColumn(ctx) + + ImGui.PushItemFlag(ctx, ImGui.ItemFlags_NoTabStop, true) + ImGui.ColorEdit4(ctx, "##col_slot_" .. i, (Notes.SlotColor(slot) << 8) | 0xFF, ImGui.ColorEditFlags_NoPicker | ImGui.ColorEditFlags_NoDragDrop | ImGui.ColorEditFlags_NoInputs | ImGui.ColorEditFlags_NoBorder | ImGui.ColorEditFlags_NoTooltip) + ImGui.PopItemFlag(ctx) + + --ImGui.Text(ctx,"y1") + ImGui.TableNextColumn(ctx) + ImGui.SetNextItemWidth(ctx, 150) + if slot ~= 0 then + local lab = S.getSetting("SlotLabel_" .. slot) + local b, v = ImGui.InputText(ctx, "##new_proj_edit_slot_" .. i, lab) + if b then + S.setSetting("SlotLabel_" .. slot, v) + end + else + ImGui.Text(ctx, " " .. Notes.SlotLabel(slot)) + end + ImGui.TableNextColumn(ctx) + ImGui.SetNextItemWidth(ctx, 150) + if slot ~= 0 then + local b, v = ImGui.InputText(ctx, "##cur_proj_edit_slot_" .. i, Notes.SlotLabel(slot)) + if b then + Notes.SetSlotLabel(slot, v) + end + else + ImGui.Text(ctx, " " .. Notes.SlotLabel(slot)) + end + end + ImGui.TableNextRow(ctx) + ImGui.TableNextColumn(ctx) + ImGui.TableNextColumn(ctx) + if ImGui.Button(ctx, "Reset to defaults##reset_global_labels_to_defaults_button") then + for i = 0, Notes.MAX_SLOTS-1 do + S.resetSetting("SlotLabel_"..i) + end + end + ImGui.TableNextColumn(ctx) + if ImGui.Button(ctx, "Reset to defaults##reset_project_labels_to_defaults_button") then + for i = 0, Notes.MAX_SLOTS-1 do + Notes.SetSlotLabel(i, S.getSettingSpec("SlotLabel_"..i).default ) + end + end + + ImGui.EndTable(ctx) + end + + ImGui.End(ctx) + end + + self.open = open + self.draw_count = self.draw_count + 1 +end + +return SettingsEditor