Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
"""Modal browser for the BT-Keyboard macro library (bt_macros).
Two modes:
* manage (default): used from the BT Keyboard window. Bottom row exposes
Quick Run / Load / Delete and a Loop checkbox.
* select : used from the node editor's macro node to pick a macro to
embed. Bottom row exposes Select / Cancel.
Folder management (Create / Rename / Delete) is available in both modes,
visually separated from the macro actions. Macros are NOT renameable.
Callbacks (all optional):
on_quick_run(folder, name, events, loop) — Quick Run clicked (stays open)
on_load(folder, name, events, loop) — Load clicked (closes)
on_select(folder, name, events) — Select clicked (closes)
"""
from __future__ import annotations
import tkinter as tk
from tkinter import simpledialog, messagebox
import bt_macros
_BG = "#2D2D3D"
_PANEL = "#1E1E2E"
_SEL = "#3B82F6"
class MacroLibraryPicker(tk.Toplevel):
def __init__(self, parent, *, select_mode: bool = False,
on_quick_run=None, on_load=None, on_select=None):
super().__init__(parent)
self._select_mode = select_mode
self._on_quick_run = on_quick_run
self._on_load = on_load
self._on_select = on_select
self._cur_folder = None
self.title("Macro library")
self.configure(bg=_BG)
self.transient(parent)
self.grab_set()
self.geometry("620x440")
self.loop_var = tk.BooleanVar(value=False)
self._build()
self._refresh_folders()
# ---- layout ----
def _build(self):
tk.Label(self, text="Macro library", bg=_BG, fg="white",
font=("Segoe UI", 12, "bold")).pack(anchor="w",
padx=14, pady=(12, 2))
# Folder management toolbar — its own clearly separated group.
fbar = tk.Frame(self, bg=_BG)
fbar.pack(fill="x", padx=14, pady=(2, 6))
tk.Label(fbar, text="Folders:", bg=_BG, fg="#9CA3AF",
font=("Segoe UI", 9, "bold")).pack(side="left", padx=(0, 8))
tk.Button(fbar, text=" New", bg="#0F766E", fg="white",
activebackground="#0D5C56", font=("Segoe UI", 9),
relief="flat", padx=10, command=self._create_folder).pack(
side="left")
tk.Button(fbar, text="Rename", bg="#4A4A6A", fg="white",
font=("Segoe UI", 9), relief="flat", padx=10,
command=self._rename_folder).pack(side="left", padx=(6, 0))
tk.Button(fbar, text="Delete", bg="#7F1D1D", fg="white",
font=("Segoe UI", 9), relief="flat", padx=10,
command=self._delete_folder).pack(side="left", padx=(6, 0))
# Two side-by-side lists: folders | macros.
lists = tk.Frame(self, bg=_BG)
lists.pack(fill="both", expand=True, padx=14, pady=(0, 8))
lists.columnconfigure(0, weight=1, uniform="lb")
lists.columnconfigure(1, weight=2, uniform="lb")
lists.rowconfigure(1, weight=1)
tk.Label(lists, text="Folder", bg=_BG, fg="#9CA3AF",
font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w")
tk.Label(lists, text="Macro", bg=_BG, fg="#9CA3AF",
font=("Segoe UI", 9)).grid(row=0, column=1, sticky="w")
self.folder_lb = tk.Listbox(lists, bg=_PANEL, fg="white",
selectbackground=_SEL,
font=("Consolas", 10), activestyle="none",
relief="flat", exportselection=False)
self.folder_lb.grid(row=1, column=0, sticky="nsew", padx=(0, 6))
self.folder_lb.bind("<<ListboxSelect>>", self._on_folder_select)
self.macro_lb = tk.Listbox(lists, bg=_PANEL, fg="white",
selectbackground=_SEL,
font=("Consolas", 10), activestyle="none",
relief="flat", exportselection=False)
self.macro_lb.grid(row=1, column=1, sticky="nsew")
# Bottom action row.
actions = tk.Frame(self, bg=_BG)
actions.pack(fill="x", padx=14, pady=(0, 12))
tk.Button(actions, text="Close" if not self._select_mode else "Cancel",
bg="#4A4A6A", fg="white", font=("Segoe UI", 9),
relief="flat", padx=14, command=self.destroy).pack(
side="right")
if self._select_mode:
tk.Button(actions, text="Select", bg="#22C55E", fg="white",
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
command=self._do_select).pack(side="right", padx=(0, 6))
else:
tk.Checkbutton(actions, text="Loop", variable=self.loop_var,
bg=_BG, fg="white", selectcolor=_PANEL,
activebackground=_BG, activeforeground="white",
font=("Segoe UI", 9)).pack(side="right",
padx=(0, 12))
tk.Button(actions, text="Delete", bg="#7F1D1D", fg="white",
font=("Segoe UI", 9), relief="flat", padx=12,
command=self._do_delete).pack(side="left")
tk.Button(actions, text="↻ Quick Run", bg="#7C3AED", fg="white",
activebackground="#6D28D9", font=("Segoe UI", 9, "bold"),
relief="flat", padx=14, command=self._do_quick_run).pack(
side="right", padx=(0, 6))
tk.Button(actions, text="Load", bg="#1D4ED8", fg="white",
activebackground="#1E40AF", font=("Segoe UI", 9, "bold"),
relief="flat", padx=14, command=self._do_load).pack(
side="right", padx=(0, 6))
# ---- folder / macro listing ----
def _refresh_folders(self, select: str | None = None):
folders = bt_macros.list_folders()
self.folder_lb.delete(0, "end")
for f in folders:
self.folder_lb.insert("end", f)
target = select if (select in folders) else (
self._cur_folder if self._cur_folder in folders else
(folders[0] if folders else None))
if target is not None:
idx = folders.index(target)
self.folder_lb.selection_set(idx)
self.folder_lb.see(idx)
self._cur_folder = target
self._refresh_macros()
def _selected_folder(self):
sel = self.folder_lb.curselection()
if sel:
return self.folder_lb.get(sel[0])
return self._cur_folder
def _refresh_macros(self):
self.macro_lb.delete(0, "end")
folder = self._selected_folder()
self._cur_folder = folder
if not folder:
return
for name in bt_macros.list_macros(folder):
m = bt_macros.load_macro(folder, name) or {}
n = len(m.get("events", []))
dur = (m.get("duration_ms", 0) or 0) / 1000.0
self.macro_lb.insert("end", f"{name} ({n} ev, {dur:.1f}s)")
def _on_folder_select(self, _e=None):
self._refresh_macros()
def _selected_macro(self):
sel = self.macro_lb.curselection()
folder = self._selected_folder()
if not sel or not folder:
return None, None
names = bt_macros.list_macros(folder)
idx = sel[0]
if idx >= len(names):
return folder, None
return folder, names[idx]
def _selected_events(self):
folder, name = self._selected_macro()
if not name:
messagebox.showinfo("No macro selected",
"Select a macro first.", parent=self)
return None
m = bt_macros.load_macro(folder, name)
if not m:
return None
return folder, name, m["events"]
# ---- folder ops ----
def _create_folder(self):
name = simpledialog.askstring("New folder", "Folder name:",
parent=self)
if name and bt_macros.create_folder(name):
self._refresh_folders(select=name.strip())
def _rename_folder(self):
folder = self._selected_folder()
if not folder:
return
new = simpledialog.askstring("Rename folder",
f"New name for '{folder}':", parent=self)
if new and bt_macros.rename_folder(folder, new):
self._refresh_folders(select=new.strip())
def _delete_folder(self):
folder = self._selected_folder()
if not folder:
return
if not messagebox.askyesno(
"Delete folder?",
f"Delete folder '{folder}' and ALL macros inside it?",
parent=self):
return
bt_macros.delete_folder(folder)
self._cur_folder = None
self._refresh_folders()
# ---- macro actions ----
def _do_quick_run(self):
res = self._selected_events()
if res and self._on_quick_run:
folder, name, events = res
self._on_quick_run(folder, name, events, self.loop_var.get())
def _do_load(self):
res = self._selected_events()
if res and self._on_load:
folder, name, events = res
self._on_load(folder, name, events, self.loop_var.get())
self.destroy()
def _do_select(self):
res = self._selected_events()
if res and self._on_select:
folder, name, events = res
self._on_select(folder, name, events)
self.destroy()
def _do_delete(self):
folder, name = self._selected_macro()
if not name:
return
if not messagebox.askyesno("Delete macro?",
f"Delete macro '{name}'?", parent=self):
return
bt_macros.delete_macro(folder, name)
self._refresh_macros()