"""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("", 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("", 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("", 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("", select) def rename(event, idx=index): self._rename_macro(idx) name_label.bind("", 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("", on_enter) item.bind("", 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("", do_duplicate) listbox.bind("", 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("", save) tk.Button(dialog, text="OK", command=save, bg="#3D3D5C", fg="white", relief="flat").pack(pady=6)