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
This commit is contained in:
2026-04-06 21:02:17 +10:00
parent ac190d49c1
commit c7dd1a6be4
+354
View File
@@ -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("<Control-s>", lambda e: self._save_config())
self.root.bind("<Control-o>", 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("<<ListboxSelect>>", 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("<<ComboboxSelected>>", 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("<Return>", 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("<Return>", lambda e: self._ok()); self.top.bind("<Escape>", 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()