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:
@@ -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()
|
||||
Reference in New Issue
Block a user