Initial public release
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user