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
+374
View File
@@ -0,0 +1,374 @@
"""Left sidebar - macro list with reorder, add, delete."""
import tkinter as tk
from tkinter import filedialog
from utils.image_converter import copy_image_to_appdata, create_thumbnail, generate_gradient_image
class MacroListPanel(tk.Frame):
"""Sidebar showing list of macros with management controls."""
def __init__(self, parent, on_select=None, on_change=None):
super().__init__(parent, bg="#252535", width=200)
self.on_select = on_select
self.on_change = on_change
self.project = None
self.profile_manager = None
self.selected_index = -1
self._thumbnails = [] # Hold refs so Tk PhotoImages aren't garbage-collected
self.pack_propagate(False)
self._build_ui()
def set_profile_manager(self, profile_manager):
"""Inject the ProfileManager so the Duplicate button can list and
write to other profiles. Without this, the duplicate dialog falls
back to "current profile only"."""
self.profile_manager = profile_manager
def _build_ui(self):
header = tk.Frame(self, bg="#1E1E2E")
header.pack(fill="x")
tk.Label(header, text="Routines", bg="#1E1E2E", fg="white",
font=("Segoe UI", 11, "bold"), pady=8).pack(side="left", padx=10)
list_frame = tk.Frame(self, bg="#252535")
list_frame.pack(fill="both", expand=True)
self.list_canvas = tk.Canvas(list_frame, bg="#252535", highlightthickness=0)
scrollbar = tk.Scrollbar(list_frame, orient="vertical", command=self.list_canvas.yview)
self.list_inner = tk.Frame(self.list_canvas, bg="#252535")
self.list_inner.bind("<Configure>",
lambda e: self.list_canvas.configure(scrollregion=self.list_canvas.bbox("all")))
self.list_canvas.create_window((0, 0), window=self.list_inner, anchor="nw")
self.list_canvas.configure(yscrollcommand=scrollbar.set)
self.list_canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
btn_frame = tk.Frame(self, bg="#1E1E2E")
btn_frame.pack(fill="x", side="bottom")
btn_style = {"bg": "#3D3D5C", "fg": "white", "font": ("Segoe UI", 9),
"relief": "flat", "pady": 4}
btn_row1 = tk.Frame(btn_frame, bg="#1E1E2E")
btn_row1.pack(fill="x", padx=4, pady=2)
tk.Button(btn_row1, text="+ Add", command=self._add_macro, **btn_style).pack(
side="left", fill="x", expand=True, padx=2)
tk.Button(btn_row1, text="- Delete", command=self._delete_macro, **btn_style).pack(
side="left", fill="x", expand=True, padx=2)
btn_row2 = tk.Frame(btn_frame, bg="#1E1E2E")
btn_row2.pack(fill="x", padx=4, pady=2)
tk.Button(btn_row2, text="\u25b2 Up", command=self._move_up, **btn_style).pack(
side="left", fill="x", expand=True, padx=2)
tk.Button(btn_row2, text="\u25bc Down", command=self._move_down, **btn_style).pack(
side="left", fill="x", expand=True, padx=2)
btn_row3 = tk.Frame(btn_frame, bg="#1E1E2E")
btn_row3.pack(fill="x", padx=4, pady=(0, 4))
tk.Button(btn_row3, text="\u29c9 Duplicate", command=self._duplicate_macro, **btn_style).pack(
side="left", fill="x", expand=True, padx=2)
def set_project(self, project):
self.project = project
self.selected_index = 0 if project.macros else -1
self.refresh()
def refresh(self):
"""Rebuild the macro list UI."""
for widget in self.list_inner.winfo_children():
widget.destroy()
self._thumbnails.clear()
if not self.project:
return
for i, macro in enumerate(self.project.macros):
self._create_item(i, macro)
def _create_item(self, index, macro):
is_selected = (index == self.selected_index)
bg = "#3D3D5C" if is_selected else "#252535"
hover_bg = "#4A4A6A"
item = tk.Frame(self.list_inner, bg=bg, cursor="hand2")
item.pack(fill="x", padx=4, pady=1)
if macro.image_path:
try:
thumb = create_thumbnail(macro.image_path, (32, 32))
self._thumbnails.append(thumb)
thumb_label = tk.Label(item, image=thumb, bg=bg, cursor="hand2")
thumb_label.pack(side="left", padx=4, pady=4)
thumb_label.bind("<Button-2>", lambda event, idx=index: self._remove_image(idx))
except Exception:
tk.Label(item, text="\u25a1", bg=bg, fg="#888888",
font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4)
else:
tk.Label(item, text="\u25a1", bg=bg, fg="#888888",
font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4)
name_frame = tk.Frame(item, bg=bg)
name_frame.pack(side="left", fill="x", expand=True, padx=4, pady=6)
name_label = tk.Label(name_frame, text=macro.name, bg=bg, fg="white",
font=("Segoe UI", 9), anchor="w")
name_label.pack(fill="x")
# Left click sets image, middle click removes it
img_btn = tk.Button(item, text="\U0001f4f7" if macro.image_path else "\U0001f5bc",
bg=bg, fg="#AAAAAA", font=("Segoe UI", 10), relief="flat",
command=lambda idx=index: self._set_image(idx))
img_btn.pack(side="right", padx=4)
img_btn.bind("<Button-2>", lambda event, idx=index: self._remove_image(idx))
def select(event, idx=index):
self.selected_index = idx
self.refresh()
if self.on_select:
self.on_select(idx)
for widget in [item, name_label, name_frame]:
widget.bind("<Button-1>", select)
def rename(event, idx=index):
self._rename_macro(idx)
name_label.bind("<Double-Button-1>", rename)
def on_enter(event, frame=item, is_sel=is_selected):
if not is_sel:
frame.config(bg=hover_bg)
for child in frame.winfo_children():
try:
child.config(bg=hover_bg)
for grandchild in child.winfo_children():
try:
grandchild.config(bg=hover_bg)
except tk.TclError:
pass
except tk.TclError:
pass
def on_leave(event, frame=item, orig_bg=bg, is_sel=is_selected):
if not is_sel:
frame.config(bg=orig_bg)
for child in frame.winfo_children():
try:
child.config(bg=orig_bg)
for grandchild in child.winfo_children():
try:
grandchild.config(bg=orig_bg)
except tk.TclError:
pass
except tk.TclError:
pass
item.bind("<Enter>", on_enter)
item.bind("<Leave>", on_leave)
def _add_macro(self):
if not self.project:
return
macro = self.project.add_macro()
macro.image_path = generate_gradient_image()
self.selected_index = len(self.project.macros) - 1
self.refresh()
if self.on_select:
self.on_select(self.selected_index)
if self.on_change:
self.on_change()
def _delete_macro(self):
if not self.project or self.selected_index < 0:
return
self.project.remove_macro(self.selected_index)
if self.selected_index >= len(self.project.macros):
self.selected_index = len(self.project.macros) - 1
self.refresh()
if self.on_select:
self.on_select(self.selected_index)
if self.on_change:
self.on_change()
def _duplicate_macro(self):
"""Prompt for a target profile and duplicate the selected routine into it."""
if not self.project or self.selected_index < 0:
return
if self.selected_index >= len(self.project.macros):
return
source = self.project.macros[self.selected_index]
# Without a profile_manager (e.g. unit-test wiring) we can still
# duplicate inside the current profile — degrade gracefully.
if self.profile_manager is None:
clone = source.clone()
self.project.add_macro(clone)
self.selected_index = len(self.project.macros) - 1
self.refresh()
if self.on_select:
self.on_select(self.selected_index)
if self.on_change:
self.on_change()
return
self._open_duplicate_dialog(source)
def _open_duplicate_dialog(self, source_macro):
names = self.profile_manager.profile_names()
if not names:
return
active = self.profile_manager.active_name
dialog = tk.Toplevel(self)
dialog.title("Duplicate Routine")
dialog.geometry("280x260")
dialog.resizable(False, False)
dialog.configure(bg="#2D2D3D")
dialog.transient(self)
dialog.grab_set()
tk.Label(dialog, text=f"Duplicate '{source_macro.name}' to:",
bg="#2D2D3D", fg="white", font=("Segoe UI", 10),
anchor="w", wraplength=260, justify="left").pack(
anchor="w", padx=10, pady=(10, 4))
list_wrap = tk.Frame(dialog, bg="#1E1E2E")
list_wrap.pack(fill="both", expand=True, padx=10, pady=4)
listbox = tk.Listbox(list_wrap, bg="#1E1E2E", fg="white",
selectbackground="#3D3D5C", selectforeground="white",
highlightthickness=0, relief="flat",
font=("Segoe UI", 10), activestyle="none",
exportselection=False)
scrollbar = tk.Scrollbar(list_wrap, orient="vertical", command=listbox.yview)
listbox.configure(yscrollcommand=scrollbar.set)
listbox.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
for name in names:
display = f"{name} (current)" if name == active else name
listbox.insert("end", display)
# Default selection: current profile (most common case)
try:
default_idx = names.index(active)
except ValueError:
default_idx = 0
listbox.selection_set(default_idx)
listbox.see(default_idx)
def do_duplicate(event=None):
sel = listbox.curselection()
if not sel:
return
target = names[sel[0]]
try:
clone, is_active = self.profile_manager.duplicate_macro_to_profile(
source_macro, target)
except (ValueError, OSError) as exc:
from tkinter import messagebox
messagebox.showerror("Duplicate failed", str(exc), parent=dialog)
return
dialog.destroy()
if is_active:
# Live project mutated — focus the new routine and let
# the host autosave/refresh chain catch up.
self.selected_index = len(self.project.macros) - 1
self.refresh()
if self.on_select:
self.on_select(self.selected_index)
if self.on_change:
self.on_change()
listbox.bind("<Double-Button-1>", do_duplicate)
listbox.bind("<Return>", do_duplicate)
listbox.focus_set()
btn_bar = tk.Frame(dialog, bg="#2D2D3D")
btn_bar.pack(fill="x", padx=10, pady=(4, 10))
tk.Button(btn_bar, text="Cancel", command=dialog.destroy,
bg="#3D3D5C", fg="white", relief="flat",
font=("Segoe UI", 9), padx=10).pack(side="right", padx=(4, 0))
tk.Button(btn_bar, text="Duplicate", command=do_duplicate,
bg="#3D3D5C", fg="white", relief="flat",
font=("Segoe UI", 9), padx=10).pack(side="right")
def _move_up(self):
if not self.project or self.selected_index <= 0:
return
self.project.move_macro(self.selected_index, self.selected_index - 1)
self.selected_index -= 1
self.refresh()
if self.on_change:
self.on_change()
def _move_down(self):
if not self.project or self.selected_index >= len(self.project.macros) - 1:
return
self.project.move_macro(self.selected_index, self.selected_index + 1)
self.selected_index += 1
self.refresh()
if self.on_change:
self.on_change()
def _set_image(self, index):
if not self.project:
return
path = filedialog.askopenfilename(
title="Select Routine Image",
filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif"), ("All", "*.*")],
)
if path:
saved_path = copy_image_to_appdata(path)
self.project.macros[index].image_path = saved_path
self.refresh()
if self.on_change:
self.on_change()
def _remove_image(self, index):
if not self.project or index < 0 or index >= len(self.project.macros):
return
self.project.macros[index].image_path = None
self.refresh()
if self.on_change:
self.on_change()
def _rename_macro(self, index):
if not self.project or index < 0 or index >= len(self.project.macros):
return
dialog = tk.Toplevel(self)
dialog.title("Rename Routine")
dialog.geometry("250x100")
dialog.resizable(False, False)
dialog.configure(bg="#2D2D3D")
dialog.transient(self)
dialog.grab_set()
tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 10)).pack(anchor="w", padx=10, pady=(10, 2))
var = tk.StringVar(value=self.project.macros[index].name)
entry = tk.Entry(dialog, textvariable=var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
entry.pack(fill="x", padx=10, pady=4)
entry.select_range(0, "end")
entry.focus_set()
def save(event=None):
self.project.macros[index].name = var.get()
dialog.destroy()
self.refresh()
if self.on_change:
self.on_change()
entry.bind("<Return>", save)
tk.Button(dialog, text="OK", command=save, bg="#3D3D5C", fg="white",
relief="flat").pack(pady=6)