From c7dd1a6be467a44721b0a6beba8b5208472e26d7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 6 Apr 2026 21:02:17 +1000 Subject: [PATCH] feat: Add Tag Color Editor GUI tool tkinter-based GUI for editing tag_colors.json: - Color picker with RGB sliders, hex input, and 25 preset colors - Effect selector (solid, flash, pulse, cycle) - Speed slider control - Add/remove/duplicate tags - Save/load JSON files - Presets tuned for deeper colors on portal LEDs --- tag_color_editor.py | 354 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 tag_color_editor.py diff --git a/tag_color_editor.py b/tag_color_editor.py new file mode 100644 index 0000000..ef48066 --- /dev/null +++ b/tag_color_editor.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +LEGO Dimensions Tag Color Editor +Visual tool for configuring tag colors, effects, and speeds. + +Reads and writes tag_colors.json for the LEGO Dimensions Portal CLI Client. + +Requirements: + Python 3.x with tkinter (usually included) + No additional packages needed. +""" + +import os +import sys +import json +import tkinter as tk +from tkinter import ttk, colorchooser, messagebox, filedialog + +# Default config file location (same directory as this script) +DEFAULT_CONFIG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tag_colors.json") + +# ---- Deeper, richer preset colors (less washed-out on portal LEDs) ---- +PRESET_COLORS = { + "Red": [255, 0, 0], + "Deep Red": [200, 0, 0], + "Fire Red": [255, 30, 0], + "Orange": [255, 80, 0], + "Amber": [255, 120, 0], + "Yellow": [200, 180, 0], + "Lime": [80, 200, 0], + "Green": [0, 200, 0], + "Deep Green": [0, 150, 0], + "Emerald": [0, 200, 60], + "Teal": [0, 180, 120], + "Cyan": [0, 180, 180], + "Sky Blue": [0, 80, 200], + "Blue": [0, 0, 255], + "Deep Blue": [0, 0, 180], + "Royal Blue": [30, 0, 220], + "Purple": [100, 0, 200], + "Deep Purple": [80, 0, 160], + "Violet": [140, 0, 200], + "Magenta": [200, 0, 140], + "Hot Pink": [200, 0, 80], + "Pink": [200, 60, 100], + "White": [200, 200, 200], + "Warm White": [200, 150, 80], + "Ice Blue": [100, 140, 200], +} + +EFFECTS = ["solid", "flash", "pulse", "cycle"] + +EFFECT_DESCRIPTIONS = { + "solid": "Constant color on the pad", + "flash": "Blinking on/off", + "pulse": "Smooth breathing/fading", + "cycle": "Revolving lights across ALL pads", +} + + +class TagColorEditor: + """Main GUI application for editing tag_colors.json.""" + + def __init__(self, root: tk.Tk): + self.root = root + self.root.title("LEGO Dimensions - Tag Color Editor") + self.root.geometry("820x640") + self.root.minsize(780, 580) + self.config_path = DEFAULT_CONFIG + self.data = {"default": {}, "tags": {}} + self.unsaved = False + self.current_color = [0, 180, 180] + self._build_ui() + self._load_config() + + def _build_ui(self): + menubar = tk.Menu(self.root) + file_menu = tk.Menu(menubar, tearoff=0) + file_menu.add_command(label="Open...", command=self._open_file, accelerator="Ctrl+O") + file_menu.add_command(label="Save", command=self._save_config, accelerator="Ctrl+S") + file_menu.add_command(label="Save As...", command=self._save_as) + file_menu.add_separator() + file_menu.add_command(label="Exit", command=self._on_close) + menubar.add_cascade(label="File", menu=file_menu) + self.root.config(menu=menubar) + self.root.bind("", lambda e: self._save_config()) + self.root.bind("", lambda e: self._open_file()) + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + main_frame = ttk.Frame(self.root, padding=8) + main_frame.pack(fill=tk.BOTH, expand=True) + + left_frame = ttk.LabelFrame(main_frame, text="Tags", padding=6) + left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 6)) + self.tag_list = tk.Listbox(left_frame, width=30, font=("Consolas", 10)) + self.tag_list.pack(fill=tk.BOTH, expand=True, pady=(0, 6)) + self.tag_list.bind("<>", self._on_tag_select) + btn_row = ttk.Frame(left_frame) + btn_row.pack(fill=tk.X) + ttk.Button(btn_row, text="+ Add", command=self._add_tag).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=1) + ttk.Button(btn_row, text="- Remove", command=self._remove_tag).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=1) + ttk.Button(btn_row, text="Duplicate", command=self._duplicate_tag).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=1) + + right_frame = ttk.LabelFrame(main_frame, text="Tag Settings", padding=10) + right_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + row = ttk.Frame(right_frame); row.pack(fill=tk.X, pady=3) + ttk.Label(row, text="Legacy Key:", width=12, anchor="e").pack(side=tk.LEFT) + self.key_var = tk.StringVar() + self.key_entry = ttk.Entry(row, textvariable=self.key_var, width=18, font=("Consolas", 11)) + self.key_entry.pack(side=tk.LEFT, padx=6) + ttk.Label(row, text="(shown when tag is scanned)", foreground="gray").pack(side=tk.LEFT) + + row = ttk.Frame(right_frame); row.pack(fill=tk.X, pady=3) + ttk.Label(row, text="Name:", width=12, anchor="e").pack(side=tk.LEFT) + self.name_var = tk.StringVar() + ttk.Entry(row, textvariable=self.name_var, width=30, font=("Consolas", 11)).pack(side=tk.LEFT, padx=6) + + row = ttk.Frame(right_frame); row.pack(fill=tk.X, pady=3) + ttk.Label(row, text="Effect:", width=12, anchor="e").pack(side=tk.LEFT) + self.effect_var = tk.StringVar(value="solid") + effect_combo = ttk.Combobox(row, textvariable=self.effect_var, values=EFFECTS, state="readonly", width=12, font=("Consolas", 11)) + effect_combo.pack(side=tk.LEFT, padx=6) + effect_combo.bind("<>", self._on_effect_change) + self.effect_desc = ttk.Label(row, text="", foreground="gray") + self.effect_desc.pack(side=tk.LEFT, padx=6) + + row = ttk.Frame(right_frame); row.pack(fill=tk.X, pady=3) + ttk.Label(row, text="Speed:", width=12, anchor="e").pack(side=tk.LEFT) + self.speed_var = tk.DoubleVar(value=1.0) + ttk.Scale(row, from_=0.1, to=3.0, variable=self.speed_var, orient=tk.HORIZONTAL, length=200).pack(side=tk.LEFT, padx=6) + self.speed_label = ttk.Label(row, text="1.0", width=4) + self.speed_label.pack(side=tk.LEFT) + self.speed_var.trace_add("write", self._update_speed_label) + ttk.Label(row, text="(0.1 = fast, 3.0 = slow)", foreground="gray").pack(side=tk.LEFT, padx=4) + + color_frame = ttk.LabelFrame(right_frame, text="Color", padding=8) + color_frame.pack(fill=tk.X, pady=(10, 4)) + preview_row = ttk.Frame(color_frame); preview_row.pack(fill=tk.X, pady=(0, 6)) + self.color_preview = tk.Canvas(preview_row, width=80, height=50, bd=2, relief="sunken", highlightthickness=0) + self.color_preview.pack(side=tk.LEFT, padx=(0, 10)) + self._update_preview() + picker_col = ttk.Frame(preview_row); picker_col.pack(side=tk.LEFT) + ttk.Button(picker_col, text="Pick Color...", command=self._pick_color).pack(anchor="w") + + rgb_frame = ttk.Frame(picker_col); rgb_frame.pack(anchor="w", pady=4) + self.r_var = tk.IntVar(value=0); self.g_var = tk.IntVar(value=180); self.b_var = tk.IntVar(value=180) + for label, var, fg in [("R", self.r_var, "#cc0000"), ("G", self.g_var, "#00aa00"), ("B", self.b_var, "#0000cc")]: + f = ttk.Frame(rgb_frame); f.pack(fill=tk.X, pady=1) + ttk.Label(f, text=label, width=2, foreground=fg, font=("Consolas", 10, "bold")).pack(side=tk.LEFT) + ttk.Scale(f, from_=0, to=255, variable=var, orient=tk.HORIZONTAL, length=180).pack(side=tk.LEFT, padx=4) + lbl = ttk.Label(f, text="0", width=4, font=("Consolas", 10)); lbl.pack(side=tk.LEFT) + var.trace_add("write", lambda *a, v=var, l=lbl: self._on_rgb_change(v, l)) + + hex_row = ttk.Frame(color_frame); hex_row.pack(fill=tk.X, pady=2) + ttk.Label(hex_row, text="Hex:").pack(side=tk.LEFT) + self.hex_var = tk.StringVar(value="#00B4B4") + hex_entry = ttk.Entry(hex_row, textvariable=self.hex_var, width=9, font=("Consolas", 11)) + hex_entry.pack(side=tk.LEFT, padx=6) + hex_entry.bind("", self._on_hex_enter) + ttk.Button(hex_row, text="Apply Hex", command=lambda: self._on_hex_enter(None)).pack(side=tk.LEFT) + + preset_frame = ttk.LabelFrame(color_frame, text="Presets (click to apply)", padding=4) + preset_frame.pack(fill=tk.X, pady=(6, 0)) + cols = 7 + for i, (name, rgb) in enumerate(PRESET_COLORS.items()): + r, g, b = rgb + hexc = f"#{r:02x}{g:02x}{b:02x}" + brightness = (r * 299 + g * 587 + b * 114) / 1000 + fg = "white" if brightness < 128 else "black" + tk.Button(preset_frame, text=name[:6], bg=hexc, fg=fg, width=7, font=("Arial", 7, "bold"), + bd=1, relief="raised", command=lambda rgb=rgb: self._apply_preset(rgb)).grid( + row=i // cols, column=i % cols, padx=1, pady=1) + + bottom = ttk.Frame(right_frame); bottom.pack(fill=tk.X, pady=(12, 0)) + ttk.Button(bottom, text="Apply to Selected Tag", command=self._apply_to_tag).pack(side=tk.LEFT, padx=4) + ttk.Button(bottom, text="Save to File", command=self._save_config).pack(side=tk.RIGHT, padx=4) + + self.status_var = tk.StringVar(value="Ready") + ttk.Label(self.root, textvariable=self.status_var, relief="sunken", anchor="w", padding=4).pack(fill=tk.X, side=tk.BOTTOM) + + def _rgb_to_hex(self, r, g, b): return f"#{r:02x}{g:02x}{b:02x}" + def _hex_to_rgb(self, h): + h = h.lstrip("#"); return [int(h[i:i+2], 16) for i in (0, 2, 4)] + def _update_preview(self): + r, g, b = self.current_color; self.color_preview.configure(bg=self._rgb_to_hex(r, g, b)) + def _apply_preset(self, rgb): + self.current_color = list(rgb); self.r_var.set(rgb[0]); self.g_var.set(rgb[1]); self.b_var.set(rgb[2]) + self._update_preview(); self._sync_hex_from_rgb() + def _pick_color(self): + r, g, b = self.current_color + result = colorchooser.askcolor(color=self._rgb_to_hex(r, g, b), title="Choose Tag Color") + if result and result[0]: self._apply_preset([int(c) for c in result[0]]) + def _on_rgb_change(self, var, label): + try: val = var.get() + except tk.TclError: return + label.config(text=str(val)) + self.current_color = [self.r_var.get(), self.g_var.get(), self.b_var.get()] + self._update_preview(); self._sync_hex_from_rgb() + def _sync_hex_from_rgb(self): + r, g, b = self.current_color; self.hex_var.set(self._rgb_to_hex(r, g, b)) + def _on_hex_enter(self, event): + try: self._apply_preset(self._hex_to_rgb(self.hex_var.get())) + except (ValueError, IndexError): messagebox.showerror("Invalid Hex", "Enter a valid hex color like #FF0000") + def _on_effect_change(self, event=None): + self.effect_desc.config(text=EFFECT_DESCRIPTIONS.get(self.effect_var.get(), "")) + def _update_speed_label(self, *args): + try: self.speed_label.config(text=f"{self.speed_var.get():.1f}") + except tk.TclError: pass + + def _load_config(self): + if os.path.exists(self.config_path): + try: + with open(self.config_path, "r", encoding="utf-8") as f: raw = json.load(f) + self.data = { + "default": raw.get("default", {"color": [0, 180, 180], "effect": "solid", "speed": 1.0, "name": "Default"}), + "tags": raw.get("tags", {}) + } + self.status_var.set(f"Loaded {self.config_path} ({len(self.data['tags'])} tags)") + except Exception as e: + messagebox.showerror("Load Error", f"Failed to load config:\n{e}") + self.data = {"default": {"color": [0, 180, 180], "effect": "solid", "speed": 1.0, "name": "Default"}, "tags": {}} + else: + self.data = {"default": {"color": [0, 180, 180], "effect": "solid", "speed": 1.0, "name": "Default"}, "tags": {}} + self.status_var.set(f"No config found - will create {self.config_path} on save") + self._refresh_list(); self.unsaved = False + + def _save_config(self): + out = { + "_comment": "Tag color mappings for LEGO Dimensions Portal Client", + "_instructions": ["Edited by tag_color_editor.py", "Effects: solid, pulse, flash, cycle", + "Colors: [R, G, B] values 0-255", "Speed: 0.1 (very fast) to 3.0 (slow)"], + "default": self.data["default"], "tags": self.data["tags"], + } + try: + with open(self.config_path, "w", encoding="utf-8") as f: json.dump(out, f, indent=4) + self.unsaved = False + self.status_var.set(f"Saved {len(self.data['tags'])} tags to {self.config_path}") + self.root.title("LEGO Dimensions - Tag Color Editor") + except Exception as e: messagebox.showerror("Save Error", f"Failed to save:\n{e}") + + def _save_as(self): + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*.*")], initialfile="tag_colors.json") + if path: self.config_path = path; self._save_config() + def _open_file(self): + path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")]) + if path: self.config_path = path; self._load_config() + + def _refresh_list(self): + self.tag_list.delete(0, tk.END) + for key, cfg in sorted(self.data["tags"].items(), key=lambda x: x[1].get("name", "")): + self.tag_list.insert(tk.END, f"{cfg.get('name', 'Unnamed')} [{cfg.get('effect', 'solid')}] (#{key})") + + def _get_selected_key(self): + sel = self.tag_list.curselection() + if not sel: return None + try: return self.tag_list.get(sel[0]).rsplit("(#", 1)[1].rstrip(")") + except: return None + + def _on_tag_select(self, event=None): + key = self._get_selected_key() + if key is None: return + cfg = self.data["tags"].get(key, {}) + self.key_var.set(key); self.name_var.set(cfg.get("name", "")) + self.effect_var.set(cfg.get("effect", "solid")); self._on_effect_change() + self.speed_var.set(cfg.get("speed", 1.0)) + self._apply_preset(cfg.get("color", [0, 180, 180])) + + def _add_tag(self): + dialog = _AddTagDialog(self.root) + self.root.wait_window(dialog.top) + if dialog.result is None: return + key, name = dialog.result + if key in self.data["tags"]: + messagebox.showwarning("Duplicate", f"Tag #{key} already exists."); return + self.data["tags"][key] = {"color": list(self.current_color), "effect": self.effect_var.get(), + "speed": round(self.speed_var.get(), 1), "name": name} + self._mark_unsaved(); self._refresh_list() + for i in range(self.tag_list.size()): + if f"(#{key})" in self.tag_list.get(i): self.tag_list.selection_set(i); self._on_tag_select(); break + + def _remove_tag(self): + key = self._get_selected_key() + if key is None: messagebox.showinfo("Select", "Select a tag first."); return + name = self.data["tags"].get(key, {}).get("name", key) + if messagebox.askyesno("Remove Tag", f"Remove '{name}' (#{key})?"): + del self.data["tags"][key]; self._mark_unsaved(); self._refresh_list() + + def _duplicate_tag(self): + key = self._get_selected_key() + if key is None: messagebox.showinfo("Select", "Select a tag to duplicate."); return + dialog = _AddTagDialog(self.root, title="Duplicate Tag - Enter New Key") + self.root.wait_window(dialog.top) + if dialog.result is None: return + new_key, new_name = dialog.result + if new_key in self.data["tags"]: + messagebox.showwarning("Duplicate", f"Tag #{new_key} already exists."); return + cfg = dict(self.data["tags"][key]); cfg["name"] = new_name or cfg.get("name", "Copy") + self.data["tags"][new_key] = cfg; self._mark_unsaved(); self._refresh_list() + + def _apply_to_tag(self): + old_key = self._get_selected_key(); new_key = self.key_var.get().strip() + if not new_key: messagebox.showinfo("Missing Key", "Enter a Legacy Key first."); return + if old_key and old_key != new_key: + if new_key in self.data["tags"]: messagebox.showwarning("Duplicate", f"Tag #{new_key} already exists."); return + del self.data["tags"][old_key] + self.data["tags"][new_key] = {"color": list(self.current_color), "effect": self.effect_var.get(), + "speed": round(self.speed_var.get(), 1), "name": self.name_var.get().strip() or "Unnamed"} + self._mark_unsaved(); self._refresh_list() + for i in range(self.tag_list.size()): + if f"(#{new_key})" in self.tag_list.get(i): self.tag_list.selection_set(i); break + self.status_var.set(f"Applied settings to tag #{new_key}") + + def _mark_unsaved(self): + self.unsaved = True + if not self.root.title().endswith("*"): self.root.title(self.root.title() + " *") + + def _on_close(self): + if self.unsaved: + ans = messagebox.askyesnocancel("Unsaved Changes", "You have unsaved changes.\nSave before exiting?") + if ans is None: return + if ans: self._save_config() + self.root.destroy() + + +class _AddTagDialog: + def __init__(self, parent, title="Add New Tag"): + self.result = None + self.top = tk.Toplevel(parent); self.top.title(title) + self.top.geometry("360x150"); self.top.resizable(False, False) + self.top.transient(parent); self.top.grab_set() + ttk.Label(self.top, text="Legacy Key (number from portal scan):").pack(padx=12, pady=(12, 2), anchor="w") + self.key_entry = ttk.Entry(self.top, width=24, font=("Consolas", 11)) + self.key_entry.pack(padx=12, anchor="w"); self.key_entry.focus_set() + ttk.Label(self.top, text="Name / Label:").pack(padx=12, pady=(6, 2), anchor="w") + self.name_entry = ttk.Entry(self.top, width=30, font=("Consolas", 11)); self.name_entry.pack(padx=12, anchor="w") + btn_row = ttk.Frame(self.top); btn_row.pack(pady=10) + ttk.Button(btn_row, text="OK", command=self._ok).pack(side=tk.LEFT, padx=6) + ttk.Button(btn_row, text="Cancel", command=self.top.destroy).pack(side=tk.LEFT, padx=6) + self.top.bind("", lambda e: self._ok()); self.top.bind("", lambda e: self.top.destroy()) + def _ok(self): + key = self.key_entry.get().strip(); name = self.name_entry.get().strip() + if not key: messagebox.showwarning("Missing Key", "Enter a legacy key.", parent=self.top); return + if not key.isdigit(): messagebox.showwarning("Invalid Key", "Legacy key must be a number.", parent=self.top); return + self.result = (key, name or "Unnamed"); self.top.destroy() + + +def main(): + root = tk.Tk() + TagColorEditor(root) + root.mainloop() + +if __name__ == "__main__": + main()