diff --git a/Python_Engine/Python/src/python_toolkit/bhom/custom_cmaps.py b/Python_Engine/Python/src/python_toolkit/bhom/custom_cmaps.py new file mode 100644 index 0000000..2648b79 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom/custom_cmaps.py @@ -0,0 +1,151 @@ +"""Persistence for user-defined colormaps. + +Custom colormaps created via the CmapBuilder UI are stored in the colour scale library at +``~/.bhom/custom_cmaps.json`` (see ``python_toolkit.plot.colour_scales``) so they survive +across sessions and can be reloaded by any CmapSelector that opts in via ``allow_custom``. + +These functions keep the original list-of-colormaps interface; new code should use +``ColourScaleLibrary`` directly, which keeps thresholds in data units and supports sets. +""" + +from typing import List, Tuple + +from matplotlib.colors import Colormap, LinearSegmentedColormap + +from python_toolkit.plot.colour_scales import ( + ColourScaleLibrary, + GradientScale, + ThresholdScale, +) + + +# ------------------------------------------------------------------ +# Internal helpers (mirror the builder's normalisation logic) +# ------------------------------------------------------------------ + +def _norm(val: float, vmin: float, vmax: float) -> float: + span = vmax - vmin + if span == 0: + return 0.0 + return max(0.0, min(1.0, (val - vmin) / span)) + + +def _build_interpolation_cmap( + name: str, + colours: list, + positions: list, + vmin: float, + vmax: float, +) -> LinearSegmentedColormap: + stops = sorted( + ((_norm(p, vmin, vmax), c) for p, c in zip(positions, colours)), + key=lambda x: x[0], + ) + return LinearSegmentedColormap.from_list(name, stops) + + +def _build_bins_cmap( + name: str, + colours: list, + lower_bounds: list, + vmin: float, + vmax: float, +) -> LinearSegmentedColormap: + paired = sorted(zip(lower_bounds, colours), key=lambda x: x[0]) + sorted_starts = [p[0] for p in paired] + sorted_colours = [p[1] for p in paired] + + # End of each bin = start of the next; last bin always ends at vmax + ends = sorted_starts[1:] + [vmax] + norm_starts = [_norm(s, vmin, vmax) for s in sorted_starts] + norm_ends = [_norm(e, vmin, vmax) for e in ends] + norm_starts[0] = 0.0 + norm_ends[-1] = 1.0 + + stops = [] + for i, colour in enumerate(sorted_colours): + stops.append((norm_starts[i], colour)) + if i < len(sorted_colours) - 1: + stops.append((norm_ends[i], colour)) + stops.append((1.0, sorted_colours[-1])) + return LinearSegmentedColormap.from_list(name, stops) + + +def _as_colormap(scale) -> Tuple[Colormap, Tuple[float, float]]: + """A 0-1 colormap plus the data range it represents, for CmapSelector previews.""" + if isinstance(scale, ThresholdScale): + preview = scale.preview_data() + vmin, vmax = float(preview[0]), float(preview[-1]) + return _build_bins_cmap(scale.name, scale.colours, [vmin] + scale.thresholds, vmin, vmax), (vmin, vmax) + positions = [p for p, _ in scale.stops] + colours = [c for _, c in scale.stops] + vmin, vmax = (0.0, 1.0) if scale.relative else (positions[0], positions[-1]) + return _build_interpolation_cmap(scale.name, colours, positions, vmin, vmax), (vmin, vmax) + + +# ------------------------------------------------------------------ +# Public API +# ------------------------------------------------------------------ + +def save_custom_cmap( + name: str, + cmap_type: str, + colours: list, + positions: list, + vmin: float, + vmax: float, +) -> None: + """Save or overwrite a custom colormap definition in the user file. + + Args: + name: Display name for the colormap. + cmap_type: ``"interpolation"`` or ``"bins"``. + colours: Ordered list of hex colour strings. + positions: For ``"interpolation"``: stop positions in data units. + For ``"bins"``: lower boundary (start) of each bin in data units. + vmin: Lower bound of the data range. + vmax: Upper bound of the data range. + """ + if cmap_type == "bins": + pairs = sorted(zip(positions, colours)) + scale = ThresholdScale(name, [p for p, _ in pairs[1:]], [c for _, c in pairs]) + else: + scale = GradientScale(name, list(zip(positions, colours)), relative=False) + ColourScaleLibrary().save(scale, overwrite=True) + + +def delete_custom_cmap(name: str) -> bool: + """Delete a single saved colormap by name. + + Returns: + ``True`` if the entry was found and removed, ``False`` otherwise. + """ + library = ColourScaleLibrary() + if name not in library: + return False + library.delete(name) + return True + + +def clear_custom_cmaps() -> None: + """Remove all saved custom colormaps.""" + ColourScaleLibrary().clear() + + +def list_custom_cmap_names() -> list: + """Return the names of all saved custom colormaps.""" + return ColourScaleLibrary().names() + + +def load_custom_cmaps() -> List[Tuple[str, Colormap, Tuple[float, float]]]: + """Load all user-defined colormaps. + + Returns: + List of ``(name, colormap, (vmin, vmax))`` tuples. Entries that cannot be read + are reported by the library (logged), not silently skipped. + """ + result = [] + for scale in ColourScaleLibrary().scales(): + cmap, bounds = _as_colormap(scale) + result.append((scale.name, cmap, bounds)) + return result diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py index 0dbeb30..bc861a2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -1,3 +1,4 @@ +from .bhom_base_popup import BHoMBasePopup from .widgets import ( BHoMBaseWidget, PackingOptions, @@ -30,6 +31,7 @@ ) __all__ = [ + "BHoMBasePopup", "BHoMBaseWidget", "PackingOptions", "CalendarWidget", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_popup.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_popup.py new file mode 100644 index 0000000..3b78d2b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_popup.py @@ -0,0 +1,156 @@ +"""Lightweight themed Toplevel popup window without the BHoM banner.""" + +import ctypes +import platform +import tkinter as tk +from tkinter import ttk +from typing import Callable, Optional + + +class BHoMBasePopup(tk.Toplevel): + """ + A lightweight themed popup window for transient interactions. + + Unlike BHoMBaseWindow it has no branded banner and is intended for quick, + modal (or non-modal) popups such as colour pickers, confirmations, and + inline editors. The active ttk theme is inherited automatically from the + root Tk window. + """ + + def __init__( + self, + parent: tk.Misc, + title: str = "", + modal: bool = True, + resizable: bool = False, + on_close: Optional[Callable] = None, + center_on_parent: bool = True, + padding: int = 10, + **kwargs, + ) -> None: + """ + Args: + parent: Parent widget. The popup is made transient to the root window. + title: Window title text. + modal: Whether the popup grabs input focus and blocks the parent. + resizable: Whether the window can be resized by the user. + on_close: Optional callback invoked *after* the popup is destroyed. + center_on_parent: Centre the popup over the parent root window. + padding: Padding applied to the content frame. + **kwargs: Additional Toplevel options. + """ + root = parent.winfo_toplevel() + super().__init__(root, **kwargs) + + self._parent = parent + self._on_close_callback = on_close + self._modal = modal + self._should_center = center_on_parent + + self.title(title) + self.transient(root) + self.resizable(resizable, resizable) + self.protocol("WM_DELETE_WINDOW", self.close) + + self.content_frame = ttk.Frame(self, padding=padding) + self.content_frame.pack(fill=tk.BOTH, expand=True) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def show(self, focus_widget: Optional[tk.Widget] = None) -> None: + """Finalise the popup: size, position, theme, and grab input. + + Call this *after* all content has been added to ``content_frame``. + + Args: + focus_widget: Widget that receives keyboard focus on open. + """ + self.update_idletasks() + + if self._should_center: + self._center_on_parent_window() + + self._set_titlebar_theme() + + if self._modal: + self.grab_set() + + if focus_widget is not None: + focus_widget.focus_set() + + def close(self) -> None: + """Destroy the popup and invoke the on_close callback (if any).""" + if self.winfo_exists(): + try: + if self._modal: + self.grab_release() + except tk.TclError: + pass + self.destroy() + + if self._on_close_callback: + self._on_close_callback() + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _center_on_parent_window(self) -> None: + """Position the popup centred over its parent's root window.""" + root = self._parent.winfo_toplevel() + self.update_idletasks() + + popup_width = self.winfo_reqwidth() + popup_height = self.winfo_reqheight() + + parent_x = root.winfo_rootx() + parent_y = root.winfo_rooty() + parent_width = root.winfo_width() + parent_height = root.winfo_height() + + x = parent_x + (parent_width - popup_width) // 2 + y = parent_y + (parent_height - popup_height) // 2 + + # Clamp to screen bounds so the popup is always fully visible. + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = max(0, min(x, screen_width - popup_width)) + y = max(0, min(y, screen_height - popup_height)) + + self.geometry(f"+{x}+{y}") + + def _set_titlebar_theme(self) -> None: + """Apply dark/light titlebar colouring on Windows via DWM API.""" + try: + style = ttk.Style() + bg = style.lookup("TFrame", "background") or "" + use_dark = 1 if self._is_dark_colour(bg) else 0 + + if platform.system() == "Windows" and ctypes is not None and self.winfo_exists(): + hwnd = ctypes.windll.user32.GetParent(self.winfo_id()) + if hwnd: + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(ctypes.c_int(use_dark)), + ctypes.sizeof(ctypes.c_int), + ) + except Exception: + pass + + @staticmethod + def _is_dark_colour(colour: str) -> bool: + """Return True when the colour's perceived luminance is below the midpoint.""" + colour = (colour or "").strip().lstrip("#") + if len(colour) == 3: + colour = "".join(ch * 2 for ch in colour) + if len(colour) != 6: + return False + try: + r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16) + return (0.299 * r + 0.587 * g + 0.114 * b) < 128 + except ValueError: + return False diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/WA_cmap.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/WA_cmap.py new file mode 100644 index 0000000..a2f08d9 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/WA_cmap.py @@ -0,0 +1,306 @@ +import tkinter as tk +from tkinter import ttk, colorchooser, filedialog, messagebox +from PIL import Image, ImageDraw + + +# --- Color Math Helpers --- +def hex_to_rgb(hex_color): + """Convert a hex color string to an RGB tuple (0-255).""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) +def rgb_to_hex(rgb): + """Convert an RGB tuple to a hex color string.""" + return f"#{int(rgb[0]):02x}{int(rgb[1]):02x}{int(rgb[2]):02x}" +def interpolate(c1, c2, t): + """Linearly interpolate between two RGB tuples based on a factor t (0.0 to 1.0).""" + return tuple(c1[i] + (c2[i] - c1[i]) * t for i in range(3)) +def get_color_at(t, sorted_stops): + """Returns the interpolated RGB color for a given position t (0.0 to 1.0).""" + if t <= sorted_stops[0]['pos'].get(): + return hex_to_rgb(sorted_stops[0]['color']) + if t >= sorted_stops[-1]['pos'].get(): + return hex_to_rgb(sorted_stops[-1]['color']) + for i in range(len(sorted_stops) - 1): + s1 = sorted_stops[i] + s2 = sorted_stops[i+1] + p1, p2 = s1['pos'].get(), s2['pos'].get() + if p1 <= t <= p2: + span = p2 - p1 + local_t = 0 if span == 0 else (t - p1) / span + rgb1 = hex_to_rgb(s1['color']) + rgb2 = hex_to_rgb(s2['color']) + return interpolate(rgb1, rgb2, local_t) + return (0, 0, 0) +# --- Main Application --- +class GradientDesigner(tk.Tk): + def __init__(self): + super().__init__() + self.title("Advanced Gradient Designer") + self.geometry("650x800") + self.configure(padx=20, pady=20) + self.swatches_var = tk.IntVar(value=256) + self.swatches_var.trace_add("write", lambda *args: self.render_gradient()) + # Initialize stops using our new helper method + self.stops = [ + self._create_stop("#4c71ff", 0.0), + self._create_stop("#0025b3", 0.25), + self._create_stop("#000000", 0.50), + self._create_stop("#c7030d", 0.75), + self._create_stop("#fc4a53", 1.0), + ] + # --- Add Preset Palettes --- + self.preset_palettes = { + "Default": ["#4c71ff", "#0025b3", "#000000", "#c7030d", "#fc4a53"], + "Okabe-Ito (Colorblind Safe)": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], + "Viridis (Colorblind Safe)": ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], + "Cividis (Colorblind Safe)": ["#00204c", "#31446b", "#666870", "#988e5d", "#ffe945"], + "LTC Heatmap0": ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"], + "LTC Maya": ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"], + "LTC Paloma": ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"] + } + # --------------------------- + self.setup_ui() + self.render_gradient() + def _create_stop(self, color, pos_val): + """Helper to create a stop dictionary with a traced DoubleVar.""" + var = tk.DoubleVar(value=pos_val) + var.trace_add("write", lambda *args: self.render_gradient()) + return {"color": color, "pos": var} + def setup_ui(self): + # 1. Canvas & Swatches Control + header_frame = tk.Frame(self) + header_frame.pack(fill="x", pady=(0, 5)) + tk.Label(header_frame, text="Gradient Preview", font=("Arial", 12, "bold")).pack(side="left") + tk.Entry(header_frame, textvariable=self.swatches_var, width=5).pack(side="right") + tk.Label(header_frame, text="Swatches:").pack(side="right", padx=5) + self.canvas_width = 600 + self.canvas_height = 100 + self.canvas = tk.Canvas(self, width=self.canvas_width, height=self.canvas_height, bd=2, relief="sunken") + self.canvas.pack(pady=(0, 10)) + # 2. Tool Buttons (Distribute, Reverse, Mirror) + # --- Palette Presets Dropdown --- + preset_frame = tk.Frame(self) + preset_frame.pack(fill="x", pady=(0, 10)) + tk.Label(preset_frame, text="Load Preset:").pack(side="left") + self.preset_var = tk.StringVar() + preset_dropdown = ttk.Combobox(preset_frame, textvariable=self.preset_var, state="readonly", width=35) + preset_dropdown['values'] = list(self.preset_palettes.keys()) + preset_dropdown.current(0) + preset_dropdown.pack(side="left", padx=5) + preset_dropdown.bind("<>", self.load_preset) + # -------------------------------- + tools_frame = tk.Frame(self) + tools_frame.pack(fill="x", pady=(0, 10)) + tk.Button(tools_frame, text="↔ Distribute Evenly", command=self.distribute_evenly).pack(side="left", padx=(0, 5)) + tk.Button(tools_frame, text="🔁 Reverse", command=self.reverse_gradient).pack(side="left", padx=5) + tk.Button(tools_frame, text="🪞 Mirror", command=self.mirror_gradient).pack(side="left", padx=5) + tk.Button(tools_frame, text="➕ Add Node", command=self.add_stop).pack(side="right") + # 3. Controls Area + tk.Label(self, text="Adjust Color Nodes", font=("Arial", 12, "bold")).pack(anchor="w", pady=(10, 0)) + # --- Scrollable Container Setup --- + container = tk.Frame(self) + container.pack(fill="both", expand=True, pady=5) + self.canvas_scroll = tk.Canvas(container, highlightthickness=0) + scrollbar = tk.Scrollbar(container, orient="vertical", command=self.canvas_scroll.yview) + self.controls_frame = tk.Frame(self.canvas_scroll) + self.canvas_scroll.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side="right", fill="y") + self.canvas_scroll.pack(side="left", fill="both", expand=True) + # Place the frame inside the canvas + self.canvas_window = self.canvas_scroll.create_window((0, 0), window=self.controls_frame, anchor="nw") + # Update scroll region when the internal frame resizes (e.g., adding/removing nodes) + self.controls_frame.bind( + "", + lambda e: self.canvas_scroll.configure(scrollregion=self.canvas_scroll.bbox("all")) + ) + # Keep the internal frame width synced with the canvas width + self.canvas_scroll.bind( + "", + lambda e: self.canvas_scroll.itemconfig(self.canvas_window, width=e.width) + ) + # Mouse wheel bindings (Covers Windows, macOS, and Linux) + self.bind_all("", lambda e: self.canvas_scroll.yview_scroll(int(-1*(e.delta/120)), "units")) + self.bind_all("", lambda e: self.canvas_scroll.yview_scroll(-1, "units")) + self.bind_all("", lambda e: self.canvas_scroll.yview_scroll(1, "units")) + # ---------------------------------- + self.build_control_rows() + # 4. Image Export Area + tk.Label(self, text="Export", font=("Arial", 12, "bold")).pack(anchor="w", pady=(15, 5)) + tk.Button(self, text="💾 Export as PNG Image", bg="#4c71ff", fg="white", + font=("Arial", 10, "bold"), command=self.export_image).pack(anchor="w") + def build_control_rows(self): + for widget in self.controls_frame.winfo_children(): + widget.destroy() + for i, stop in enumerate(self.stops): + row = tk.Frame(self.controls_frame) + row.pack(fill="x", pady=4) + btn = tk.Button(row, bg=stop["color"], width=5, command=lambda idx=i: self.choose_color(idx)) + # Color Chooser Button (Existing) + btn = tk.Button(row, bg=stop["color"], width=5, command=lambda idx=i: self.choose_color(idx)) + btn.pack(side="left", padx=5) + # --- ADD THIS NEW BUTTON --- + pal_btn = tk.Button(row, text="🎨", command=lambda idx=i: self.choose_from_palette(idx)) + pal_btn.pack(side="left", padx=(0, 5)) + # --------------------------- + btn.pack(side="left", padx=5) + scale = tk.Scale(row, from_=0.0, to=1.0, resolution=0.001, orient="horizontal", + length=350, showvalue=False, variable=stop["pos"]) + scale.pack(side="left", padx=10) + entry = tk.Entry(row, textvariable=stop["pos"], width=6) + entry.pack(side="left", padx=5) + if len(self.stops) > 2: + del_btn = tk.Button(row, text="❌", command=lambda idx=i: self.remove_stop(idx)) + del_btn.pack(side="left") + def choose_from_palette(self, idx): + # Create a new top-level window + palette_win = tk.Toplevel(self) + palette_win.title("Colour Palettes") + palette_win.geometry("350x450") + palette_win.transient(self) + palette_win.grab_set() # Lock focus to this window + # Set up a scrollable canvas in case you add lots of palettes + container = tk.Frame(palette_win) + container.pack(fill="both", expand=True, padx=10, pady=10) + canvas = tk.Canvas(container, highlightthickness=0) + scrollbar = tk.Scrollbar(container, orient="vertical", command=canvas.yview) + scrollable_frame = tk.Frame(canvas) + # Configure scrolling + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + # Enable mouse wheel scrolling for this new window + palette_win.bind("", lambda e: canvas.yview_scroll(int(-1*(e.delta/120)), "units")) + palette_win.bind("", lambda e: canvas.yview_scroll(-1, "units")) + palette_win.bind("", lambda e: canvas.yview_scroll(1, "units")) + # Function to apply the clicked color + def apply_color(hex_val): + self.stops[idx]["color"] = hex_val + self.build_control_rows() + self.render_gradient() + palette_win.destroy() + # Loop through your dictionary of palettes and build groups + for name, colors in self.preset_palettes.items(): + group_frame = tk.Frame(scrollable_frame) + group_frame.pack(fill="x", pady=(0, 15), anchor="w") + # Palette Name + tk.Label(group_frame, text=name, font=("Arial", 10, "bold")).pack(anchor="w") + # Row of Color Buttons + swatch_frame = tk.Frame(group_frame) + swatch_frame.pack(anchor="w", pady=2) + for i, hex_code in enumerate(colors): + btn = tk.Button( + swatch_frame, + bg=hex_code, + width=3, + height=1, + command=lambda c=hex_code: apply_color(c) + ) + btn.grid(row=0, column=i, padx=2) + + # --- Tool Actions --- + def distribute_evenly(self): + if len(self.stops) < 2: return + # Sort visually first so colors stay in their current order + self.stops.sort(key=lambda s: s['pos'].get()) + n = len(self.stops) + for i, stop in enumerate(self.stops): + stop['pos'].set(i / (n - 1)) + self.build_control_rows() + def reverse_gradient(self): + # Invert all positions (1.0 becomes 0.0, 0.25 becomes 0.75) + for stop in self.stops: + stop['pos'].set(1.0 - stop['pos'].get()) + # Re-sort to keep UI rows cleanly ordered from 0.0 to 1.0 + self.stops.sort(key=lambda s: s['pos'].get()) + self.build_control_rows() + def mirror_gradient(self): + # Save current layout strictly ordered + original = sorted([(s['color'], s['pos'].get()) for s in self.stops], key=lambda x: x[1]) + # Clear current stops + self.stops.clear() + # 1. Compress the original gradient into the first half (0.0 to 0.5) + for color, p in original: + self.stops.append(self._create_stop(color, p / 2.0)) + # 2. Append the reversed gradient into the second half (0.5 to 1.0) + # We skip the very last original node ([:-1]) so we don't put two identical nodes at exactly 0.5 + for color, p in reversed(original[:-1]): + self.stops.append(self._create_stop(color, 1.0 - (p / 2.0))) + self.build_control_rows() + self.render_gradient() + def load_preset(self, event=None): + palette_name = self.preset_var.get() + colors = self.preset_palettes.get(palette_name) + if not colors: + return + self.stops.clear() + # Distribute the loaded colors evenly across the 0.0 to 1.0 range + n = len(colors) + for i, color in enumerate(colors): + pos = i / (n - 1) if n > 1 else 0.5 + self.stops.append(self._create_stop(color, pos)) + self.build_control_rows() + self.render_gradient() + # --- Standard Actions --- + def choose_color(self, idx): + current_color = self.stops[idx]["color"] + _, hex_color = colorchooser.askcolor(initialcolor=current_color, title="Select Color") + if hex_color: + self.stops[idx]["color"] = hex_color + self.build_control_rows() + self.render_gradient() + def add_stop(self): + self.stops.append(self._create_stop("#ffffff", 1.0)) + self.build_control_rows() + self.render_gradient() + def remove_stop(self, idx): + if len(self.stops) > 2: + self.stops.pop(idx) + self.build_control_rows() + self.render_gradient() + def render_gradient(self): + try: + num_swatches = self.swatches_var.get() + if num_swatches < 1: return + except tk.TclError: + return + self.canvas.delete("gradient") + sorted_stops = sorted(self.stops, key=lambda s: s['pos'].get()) + for x in range(self.canvas_width): + swatch_index = int((x / self.canvas_width) * num_swatches) + t = swatch_index / (num_swatches - 1) if num_swatches > 1 else 0.5 + rgb = get_color_at(t, sorted_stops) + hex_col = rgb_to_hex(rgb) + self.canvas.create_line(x, 0, x, self.canvas_height, fill=hex_col, tags="gradient") + def export_image(self): + try: + num_swatches = self.swatches_var.get() + except tk.TclError: + num_swatches = 256 + file_path = filedialog.asksaveasfilename( + defaultextension=".png", + filetypes=[("PNG Image", "*.png"), ("JPEG Image", "*.jpg")], + title="Save Gradient Image" + ) + if not file_path: return + img_width, img_height = 1024, 256 + image = Image.new("RGB", (img_width, img_height)) + draw = ImageDraw.Draw(image) + sorted_stops = sorted(self.stops, key=lambda s: s['pos'].get()) + for x in range(img_width): + swatch_index = int((x / img_width) * num_swatches) + t = swatch_index / (num_swatches - 1) if num_swatches > 1 else 0.5 + rgb = get_color_at(t, sorted_stops) + draw.line([(x, 0), (x, img_height)], fill=tuple(int(c) for c in rgb)) + try: + image.save(file_path) + messagebox.showinfo("Success", f"Gradient exported successfully to:\n{file_path}") + except Exception as e: + messagebox.showerror("Export Error", f"Failed to save image:\n{e}") +if __name__ == "__main__": + app = GradientDesigner() + app.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py index 7327d53..0a5788f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py @@ -8,12 +8,14 @@ from .drop_down_selection import DropDownSelection from .figure_container import FigureContainer from .list_box import ScrollableListBox +from .searchable_list_box import SearchableListBox from .check_box_selection import CheckboxSelection as MultiBoxSelection from .path_selector import PathSelector from .radio_selection import RadioSelection from .validated_entry_box import ValidatedEntryBox from .button import Button from .label import Label +from .slider import Slider from .spinbox import Spinbox __all__ = [ @@ -28,10 +30,12 @@ "DropDownSelection", "FigureContainer", "ScrollableListBox", + "SearchableListBox", "PathSelector", "RadioSelection", "ValidatedEntryBox", "Button", "Label", + "Slider", "Spinbox", ] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py index 4d53594..bc0dd0b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -9,8 +9,11 @@ from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.widgets.custom_cmap_builder import CmapBuilder from python_toolkit.bhom.analytics import CONSOLE_LOGGER +from python_toolkit.bhom.custom_cmaps import load_custom_cmaps +from python_toolkit.bhom_tkinter.widgets.button import Button from python_toolkit.bhom_tkinter.theming.scaling import scale_pad # Accepted colormap input types for colormaps parameter and set()/add_cmap() @@ -77,7 +80,10 @@ def __init__( cmap_bins: int = 256, default_cmap: Optional[CmapInput] = None, plot_size: tuple[int, int] = (400, 50), - dropdown_position: Literal["n", "e", "s", "w"] = "n", + dropdown_position: Literal["n", "e", "s", "w", "top", "bottom", "left", "right"] = "w", + allow_custom: bool = False, + control_width: Optional[int] = None, + control_spacing: int = 4, **kwargs ) -> None: """ @@ -95,9 +101,20 @@ def __init__( default_cmap: Optional default colormap to pre-select. Accepts the same input types as items in `colormaps`. plot_size: `(width, height)` in pixels for the preview swatch. - dropdown_position: Position of the dropdown relative to the - preview swatch. "n" = above, "s" = below, - "w" = left, "e" = right. + dropdown_position: Where the dropdown (and "Add Custom" button) sits + relative to the preview swatch. Accepts cardinal directions + ("n", "e", "s", "w") or full words + ("top", "right", "bottom", "left"). + Default "w" places the controls on the left with the + preview swatch to the right. + allow_custom: When True, user-saved colormaps from + ~/.bhom/custom_cmaps.json are prepended to the list and + an "Add Custom" button is shown below the dropdown. + control_width: Width of the dropdown and button in characters. + Both controls share this width. When None the combobox + sizes to its content and the button matches. + control_spacing: Pixel gap between the controls column and the + preview swatch. Default 4. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -130,24 +147,27 @@ def __init__( self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) - pos = dropdown_position.lower() + _aliases = {"top": "n", "bottom": "s", "left": "w", "right": "e"} + pos = _aliases.get(dropdown_position.lower(), dropdown_position.lower()) is_horizontal = pos in ("w", "e") pack_side_combo = {"n": tk.TOP, "s": tk.BOTTOM, "w": tk.LEFT, "e": tk.RIGHT}[pos] # type: ignore[assignment] pack_side_figure = {"n": tk.TOP, "s": tk.TOP, "w": tk.LEFT, "e": tk.LEFT}[pos] # type: ignore[assignment] - combo_padx = (0, 4) if is_horizontal else 0 - combo_pady = (8, 4) if not is_horizontal else 0 + combo_padx = (0, control_spacing) if is_horizontal else 0 + combo_pady = (0, control_spacing) if not is_horizontal else 0 - header = ttk.Frame(content) - header.pack(side=pack_side_combo, anchor=self._pack_anchor, padx=combo_padx, pady=combo_pady) + self._header = ttk.Frame(content) + self._header.pack(side=pack_side_combo, anchor=self._pack_anchor, padx=combo_padx, pady=combo_pady) self.cmap_combobox = ttk.Combobox( - header, + self._header, textvariable=self.colormap_var, state="readonly", justify=self._text_justify, ) - self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=scale_pad(0)) + if control_width is not None: + self.cmap_combobox.configure(width=control_width) + self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=scale_pad(0), fill="x") self.cmap_combobox.bind("<>", self._on_cmap_selected) fill_mode: Literal["x", "y"] = "y" if is_horizontal else "x" @@ -166,12 +186,65 @@ def __init__( else: current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) + self._allow_custom = allow_custom + + # Prepend user-saved custom colormaps when requested. + if allow_custom: + try: + for _name, _cmap, _bounds in load_custom_cmaps(): + _label = self._unique_label(_name, current_colormaps) + self._custom_cmaps[_label] = _cmap + current_colormaps.insert(0, _label) + except Exception: + CONSOLE_LOGGER.warning("Failed to load custom colormaps from file.", exc_info=True) + self._populate_cmap_list(current_colormaps) # Resolve default_cmap: may itself be a Colormap object or colour list self._default_label = self._resolve_default_label(default_cmap, current_colormaps) self._select_default_cmap(current_colormaps) + # "Add Custom" button — shown below the dropdown when allow_custom=True. + if allow_custom: + _btn_width = control_width if control_width is not None else self.cmap_combobox.cget("width") + Button( + self._header, + text="+ Add Custom", + command=self._open_cmap_builder, + width=_btn_width, + ).pack(side=tk.TOP, anchor=self._pack_anchor, pady=(4, 0)) + + # ------------------------------------------------------------------ + # Custom cmap builder integration + # ------------------------------------------------------------------ + + def _open_cmap_builder(self) -> None: + """Open the CmapBuilder popup. The widget itself is not displayed.""" + builder = CmapBuilder(self, command=self._on_custom_cmap_applied) + builder._open_builder() + + def _on_custom_cmap_applied(self, cmap: Colormap, bounds: tuple) -> None: + """Called when the user saves a new cmap via the builder popup.""" + self._reload_custom_cmaps() + + def _reload_custom_cmaps(self) -> None: + """Re-read saved colormaps from file and add any new ones to the combobox.""" + current = list(self.cmap_combobox["values"]) + added = False + try: + for _name, _cmap, _bounds in load_custom_cmaps(): + if _name not in self._custom_cmaps and _name not in current: + label = self._unique_label(_name, current) + self._custom_cmaps[label] = _cmap + current.insert(0, label) + added = True + except Exception: + CONSOLE_LOGGER.warning("Failed to reload custom colormaps from file.", exc_info=True) + if added: + self._populate_cmap_list(current) + self.colormap_var.set(current[0]) + self._update_cmap_sample() + def _get_all_colormaps(self) -> List[str]: """Return all registered colormap names, including reversed variants. @@ -532,7 +605,10 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni helper_text="Standard matplotlib colormap names.", build_options=PackingOptions(fill="both", expand=True), cmap_bins=64, + allow_custom=True, plot_size=(400, 40), + control_width = 20, + control_spacing= 55 ) selector_strings.build() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/custom_cmap_builder.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/custom_cmap_builder.py new file mode 100644 index 0000000..6119e97 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/custom_cmap_builder.py @@ -0,0 +1,93 @@ +from tkinter import ttk +from typing import Callable, Iterable, Literal, Optional + +from matplotlib.colors import Colormap + +from python_toolkit.bhom.custom_cmaps import _as_colormap +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.windows.colour_scale_builder import ColourScaleBuilder +from python_toolkit.plot.colour_scales import ColourScale + + +class CmapBuilder(BHoMBaseWidget): + """A button that opens the ColourScaleBuilder window and reports the saved scale as a colormap.""" + + def __init__( + self, + parent: ttk.Frame, + data: Optional[Iterable] = None, + units: Optional[str] = None, + command: Optional[Callable[[Colormap, tuple], None]] = None, + **kwargs, + ) -> None: + """ + Args: + parent: Parent widget. + data: Values the scale will colour; the builder suggests bands from them. + units: Units of the data, stored with the scale. + command: Called with (colormap, (vmin, vmax)) when a scale is saved. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.command = command + self.data = data + self.units = units + self._last_scale: Optional[ColourScale] = None + self._last_cmap: Optional[Colormap] = None + self._last_bounds: tuple[float, float] = (0.0, 1.0) + + self.builder_button = Button( + self.content_frame, + text="Add new Colour Collection", + command=self._open_builder, + ) + self.builder_button.pack(side="top", fill="x") + + def _open_builder(self) -> None: + """Open the builder window; returns once it is closed.""" + builder = ColourScaleBuilder(self.winfo_toplevel(), data=self.data, units=self.units, on_save=self._on_saved) + builder.run() + + def _on_saved(self, scale: ColourScale) -> None: + self._last_scale = scale + self._last_cmap, self._last_bounds = _as_colormap(scale) + if self.command is not None: + self.command(self._last_cmap, self._last_bounds) + self._fire_on_change(self._last_cmap) + + # ------------------------------------------------------------------ + # BHoMBaseWidget interface + # ------------------------------------------------------------------ + + def get(self) -> Optional[Colormap]: + """Return the last saved colormap, or None if none has been built.""" + return self._last_cmap + + def get_bounds(self) -> tuple[float, float]: + """Return the (vmin, vmax) range of the last saved colormap.""" + return self._last_bounds + + def get_scale(self) -> Optional[ColourScale]: + """Return the last saved ColourScale (thresholds, labels and set included).""" + return self._last_scale + + def set(self, value: Colormap) -> None: + """Store a colormap as the current result.""" + self._last_cmap = value + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal["info", "warning", "error"]]]: + if self._last_cmap is None: + return self.apply_validation((False, "No colormap has been built yet.", "error")) + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow() + builder = CmapBuilder(root.content_frame, build_options=PackingOptions(padx=10, pady=10)) + builder.build() + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py index f5afb4d..47b6083 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py @@ -4,6 +4,7 @@ from tkinter import ttk from typing import Optional, Callable, List, Literal +from python_toolkit.bhom.logging import CONSOLE_LOGGER from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget class DropDownSelection(BHoMBaseWidget): diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py new file mode 100644 index 0000000..cec23f5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py @@ -0,0 +1,291 @@ +"""Scrollable listbox with a search filter that keeps selections while items are filtered out.""" + +from __future__ import annotations + +import os +import re +import sys +import tkinter as tk +from tkinter import ttk +from typing import Callable, Iterable, List, Literal, Optional + +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox + +Scorer = Callable[[str, str], Optional[float]] # (query, item) -> score (higher is better), or None for no match + +_NON_ALNUM = re.compile(r"[\W_]+") +_BHOM_SEARCH = None # (Compute, Scorer) from the BHoM Search_Engine, loaded on first use + + +def _load_bhom_search(): + """Load the BHoM Search_Engine through pythonnet the first time it is needed.""" + global _BHOM_SEARCH # pylint: disable=global-statement + if _BHOM_SEARCH is None: + import clr # pylint: disable=import-outside-toplevel,import-error + + sys.path.append(os.path.expandvars("%ProgramData%\\BHoM\\Assemblies")) + clr.AddReference("Search_Engine") + clr.AddReference("Search_oM") + from BH.Engine.Search import Compute # pylint: disable=import-outside-toplevel,import-error + from BH.oM.Search import Scorer as BHoMScorer # pylint: disable=import-outside-toplevel,import-error + + _BHOM_SEARCH = (Compute, BHoMScorer) + return _BHOM_SEARCH + + +def default_scorer(query: str, text: str, min_score: float = 75) -> Optional[float]: + """Score how well `query` matches `text` using the BHoM Search_Engine fuzzy match, or None if it is a weak match. + + Uses BH.Engine.Search.Compute.MatchScore with the WeightedRatio scorer. Case and punctuation are ignored. + + Args: + query: The text typed into the filter. + text: The item being tested. + min_score: Similarity (0-100) an item needs to be shown. To use a different value, bind it with + `functools.partial(default_scorer, min_score=...)` and pass that as `scorer`. + """ + compute, bhom_scorer = _load_bhom_search() + query = _NON_ALNUM.sub(" ", query.lower()).strip() + text = _NON_ALNUM.sub(" ", text.lower()).strip() + score = compute.MatchScore(query, text, bhom_scorer.WeightedRatio) + return float(score) if score >= min_score else None + + +class SearchableListBox(BHoMBaseWidget): + """A listbox with a filter entry beneath it, for picking from long lists. + + The filter sits in a row under the list. + Typing in the filter narrows the list to fuzzy matches (typo-tolerant, case-insensitive), best first. + Matching uses `default_scorer` (BHoM Search_Engine); pass `scorer` to use another algorithm. + By default the selection is kept for items hidden by the filter, so several searches can build up one + selection; `get()` always returns the full selection in item order. + """ + + def __init__( + self, + parent: ttk.Frame, + items=None, + selectmode=tk.MULTIPLE, + height: int = 8, + width: Optional[int] = None, + show_selection_controls: bool = False, + keep_hidden_selection: bool = True, + show_summary: Optional[bool] = None, + filter_label: str = "Filter", + scorer: Scorer = default_scorer, + **kwargs): + """ + Args: + parent (ttk.Frame): The parent widget. + items (list, optional): Items to choose from. + selectmode (str): tk.MULTIPLE / tk.EXTENDED for multi-select, tk.SINGLE / tk.BROWSE for one item. + height (int): Visible rows in the list. + width (int, optional): Width of the list in characters. If None, it expands to fill the space. + show_selection_controls (bool): Show Select All / Deselect All buttons. They act on the items + currently shown by the filter. + keep_hidden_selection (bool): Keep items selected while the filter hides them. If False, the + selection is limited to the visible items each time the filter changes. + show_summary (bool, optional): Show a caption listing the selected items. Defaults to True for + multi-select lists. + filter_label (str): Caption shown above the filter entry. + scorer (Callable): Called as scorer(query, item); returns a score (higher ranks first) or None to + hide the item. Defaults to the BHoM Search_Engine fuzzy match. + **kwargs: Additional BHoMBaseWidget / Frame options (id, item_title, helper_text, on_change, ...). + """ + super().__init__(parent, **kwargs) + + self.scorer = scorer + self._all_items: List[str] = [str(item) for item in (items or [])] + self._shown: List[str] = list(self._all_items) + self._multi = selectmode in (tk.MULTIPLE, tk.EXTENDED, "multiple", "extended") + self._keep_hidden = bool(keep_hidden_selection) + self._selected: set[str] = set() + self._show_summary = self._multi if show_summary is None else bool(show_summary) + + self.filter_var = tk.StringVar() + + # List + self.list_box = ScrollableListBox( + self.content_frame, + items=list(self._all_items), + selectmode=selectmode, + height=height, + width=width, + on_change=lambda _value: self._on_list_change(), + build_options=PackingOptions(side="top", fill=tk.BOTH, expand=True), + ) + self.list_box.build() + + # Filter entry + filter_frame = ttk.Frame(self.content_frame) + filter_frame.pack(side="top", fill=tk.X, pady=(6, 0)) + ttk.Label(filter_frame, text=filter_label, style="Caption.TLabel").pack(side="top", anchor=self._pack_anchor) + self.filter_entry = ttk.Entry(filter_frame, textvariable=self.filter_var) + self.filter_entry.pack(side="top", fill=tk.X, pady=(2, 0)) + self.filter_var.trace_add("write", lambda *_: self._apply_filter()) + + if show_selection_controls and self._multi: + controls = ttk.Frame(self.content_frame) + controls.pack(side="top", fill=tk.X, pady=(6, 0)) + controls.columnconfigure(0, weight=1) + controls.columnconfigure(1, weight=1) + ttk.Button(controls, text="Select All", command=self.select_all).grid( + row=0, column=0, sticky="ew", padx=(0, 4)) + ttk.Button(controls, text="Deselect All", command=self.deselect_all).grid( + row=0, column=1, sticky="ew", padx=(4, 0)) + + self.summary_label: Optional[ttk.Label] = None + if self._show_summary: + self.summary_label = ttk.Label(self.content_frame, text="", style="Caption.TLabel", justify="left") + self.summary_label.pack(side="top", anchor=self._pack_anchor, pady=(4, 0)) + self.content_frame.bind( + "", lambda e: self.summary_label.configure(wraplength=max(e.width - 4, 100)), add="+") + self._update_summary() + + # ------------------------------------------------------------------ internals + + def _matching(self, query: str) -> List[str]: + query = query.strip().lower() + if not query: + return list(self._all_items) + scored = [(self.scorer(query, item), index, item) for index, item in enumerate(self._all_items)] + return [item for score, index, item in sorted((s for s in scored if s[0] is not None), + key=lambda s: (-s[0], s[1]))] + + def _apply_filter(self) -> None: + shown = self._matching(self.filter_var.get()) + self._shown = shown + self.list_box.set_options(shown) + if not self._keep_hidden: + self._selected &= set(shown) + self.list_box.set_selections([item for item in shown if item in self._selected]) + self._update_summary() + if not self._keep_hidden: + self._fire_on_change(self.get()) + + def _on_list_change(self) -> None: + visible = set(self.get_visible()) + chosen = set(self.list_box.get_selection()) + if self._multi: + self._selected = (self._selected - visible) | chosen + else: + # Single select: a visible choice replaces the previous one; clicking away keeps it. + if chosen: + self._selected = chosen + self._update_summary() + self._fire_on_change(self.get()) + + def _update_summary(self) -> None: + if self.summary_label is None: + return + selected = self.get() + visible = set(self.get_visible()) + hidden = [item for item in selected if item not in visible] + if not selected: + text = "None selected." + else: + text = f"{len(selected)} selected: {', '.join(selected)}" + if hidden: + text += f" ({len(hidden)} hidden by the filter)" + self.summary_label.configure(text=text) + + # ------------------------------------------------------------------ public API + + def get(self) -> List[str]: + """Return all selected items (including any hidden by the filter), in item order.""" + return [item for item in self._all_items if item in self._selected] + + def get_selection(self) -> List[str]: + """Alias of get() for parity with ScrollableListBox.""" + return self.get() + + def set(self, value) -> None: + """Set the selection. Accepts one item, a list of items, or None to clear.""" + if value is None: + wanted: Iterable[str] = [] + elif isinstance(value, (list, tuple, set)): + wanted = [str(v) for v in value] + else: + wanted = [str(value)] + wanted = [item for item in self._all_items if item in set(wanted)] + if not self._multi: + wanted = wanted[:1] + self._selected = set(wanted) + self.list_box.set_selections([item for item in self.get_visible() if item in self._selected]) + self._update_summary() + self._fire_on_change(self.get()) + + def get_options(self) -> List[str]: + """Return every item, whatever the filter shows.""" + return list(self._all_items) + + def get_visible(self) -> List[str]: + """Return the items currently shown by the filter.""" + return list(self._shown) + + def set_options(self, items) -> None: + """Replace the items. Selected items that still exist stay selected.""" + self._all_items = [str(item) for item in (items or [])] + self._selected &= set(self._all_items) + self._apply_filter() + self._fire_on_change(self.get()) + + def set_filter(self, text: str) -> None: + """Set the filter text (as if typed).""" + self.filter_var.set(text or "") + + def get_filter(self) -> str: + return self.filter_var.get() + + def clear_filter(self) -> None: + self.set_filter("") + + def select_all(self) -> None: + """Select every item currently shown by the filter.""" + if not self._multi: + return + self._selected |= set(self.get_visible()) + self.list_box.select_all() + self._update_summary() + self._fire_on_change(self.get()) + + def deselect_all(self) -> None: + """Deselect every item currently shown by the filter.""" + self._selected -= set(self.get_visible()) + self.list_box.deselect_all() + self._update_summary() + self._fire_on_change(self.get()) + + def clear(self) -> None: + """Deselect everything, including items hidden by the filter.""" + self._selected = set() + self.list_box.deselect_all() + self._update_summary() + self._fire_on_change(self.get()) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """All states are valid unless a custom validation says otherwise.""" + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + + root = BHoMBaseWindow(title="SearchableListBox demo", theme_mode="light") + widget = SearchableListBox( + root.content_frame, + items=[f"{city}_{kind}.epw" for city in ["London", "Birmingham", "Manchester", "Glasgow", "Belfast", "Cardiff"] + for kind in ["TRY", "DSY1", "DSY2", "DSY3"]], + height=8, + width=40, + show_selection_controls=True, + item_title="Weather files", + helper_text="Type to filter. Selections are kept while filtering.", + on_change=lambda value: print("selected:", value), + build_options=PackingOptions(padx=10, pady=10, fill="both", expand=True), + ) + widget.build() + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/slider.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/slider.py new file mode 100644 index 0000000..c20f997 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/slider.py @@ -0,0 +1,168 @@ +"""Horizontal slider widget with a linked editable value display.""" + +import tkinter as tk +from tkinter import ttk +from typing import Callable, Optional, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Slider(BHoMBaseWidget): + """A horizontal slider (ttk.Scale) with a linked editable numeric entry.""" + + def __init__( + self, + parent, + from_: float = 0.0, + to: float = 1.0, + default: float = 0.0, + resolution: float = 0.01, + show_value: bool = True, + value_width: int = 5, + command: Optional[Callable[[float], None]] = None, + **kwargs, + ) -> None: + """ + Args: + parent: Parent widget. + from_: Minimum slider value. + to: Maximum slider value. + default: Initial value. + resolution: Snap increment. Set to 0 to disable snapping. + show_value: When True, an editable entry is shown beside the slider. + value_width: Character width of the value entry. + command: Callback invoked with the current float value on every change. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.from_ = float(from_) + self.to = float(to) + self.resolution = float(resolution) + self.command = command + + self._var = tk.DoubleVar(value=self._snap(float(default))) + + row = ttk.Frame(self.content_frame) + row.pack(side="top", fill="x") + + self._scale = ttk.Scale( + row, + from_=self.from_, + to=self.to, + orient=tk.HORIZONTAL, + variable=self._var, + command=self._on_scale_move, + ) + self._scale.pack(side=tk.LEFT, fill=tk.X, expand=True) + + if show_value: + self._display_var = tk.StringVar(value=f"{self._snap(float(default)):.2f}") + self._value_entry = ttk.Entry(row, textvariable=self._display_var, width=value_width) + self._value_entry.pack(side=tk.LEFT, padx=(4, 0)) + self._value_entry.bind("", self._on_entry_changed) + self._value_entry.bind("", self._on_entry_changed) + else: + self._display_var = None + self._value_entry = None + + # ------------------------------------------------------------------ + # Event handlers + # ------------------------------------------------------------------ + + def _on_scale_move(self, raw: str) -> None: + """Called by ttk.Scale when the slider moves.""" + value = self._snap(float(raw)) + self._var.set(value) + if self._display_var is not None: + self._display_var.set(f"{value:.2f}") + if self.command: + self.command(value) + self._fire_on_change(value) + + def _on_entry_changed(self, _event=None) -> None: + """Called when the user edits the value entry.""" + if self._display_var is None: + return + try: + value = float(self._display_var.get()) + except ValueError: + value = self.get() + value = self._snap(max(self.from_, min(self.to, value))) + self._var.set(value) + self._display_var.set(f"{value:.2f}") + if self.command: + self.command(value) + self._fire_on_change(value) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _snap(self, value: float) -> float: + """Snap a value to the nearest resolution step.""" + if self.resolution <= 0: + return value + snapped = round(round(value / self.resolution) * self.resolution, 10) + return max(self.from_, min(self.to, snapped)) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def disable(self) -> None: + """Disable the slider and entry (for locked colour stops).""" + self._scale.configure(state="disabled") + if self._value_entry: + self._value_entry.configure(state="disabled") + + def enable(self) -> None: + """Re-enable the slider and entry.""" + self._scale.configure(state="normal") + if self._value_entry: + self._value_entry.configure(state="normal") + + def get(self) -> float: + """Return the current slider value as a float. + + Returns: + float: Current value, snapped to resolution. + """ + return self._snap(self._var.get()) + + def set(self, value: float) -> None: + """Set the slider position. + + Args: + value: New value, clamped to [from\\_, to]. + """ + value = self._snap(max(self.from_, min(self.to, float(value)))) + self._var.set(value) + if self._display_var is not None: + self._display_var.set(f"{value:.2f}") + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal["info", "warning", "error"]]]: + v = self.get() + if v < self.from_ or v > self.to: + return self.apply_validation( + (False, f"Value must be between {self.from_} and {self.to}.", "error") + ) + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions + + root = BHoMBaseWindow(title="Slider Test") + + slider = Slider( + root.content_frame, + from_=0.0, to=1.0, default=0.5, resolution=0.01, + item_title="Position", + command=lambda v: print(f"Value: {v:.2f}"), + build_options=PackingOptions(side="top", fill="x", padx=20, pady=20), + ) + root.widgets.append(slider) + root.build() + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/test.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/test.py new file mode 100644 index 0000000..7d3a933 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/test.py @@ -0,0 +1,136 @@ +import tkinter as tk +from tkinter import colorchooser +# --- Color Math Helpers --- +def hex_to_rgb(hex_color): + """Convert a hex color string to an RGB tuple.""" + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) +def rgb_to_hex(rgb): + """Convert an RGB tuple to a hex color string.""" + return f"#{int(rgb[0]):02x}{int(rgb[1]):02x}{int(rgb[2]):02x}" +def interpolate(c1, c2, t): + """Linearly interpolate between two RGB tuples based on a factor t (0.0 to 1.0).""" + return tuple(c1[i] + (c2[i] - c1[i]) * t for i in range(3)) +# --- Main Application --- +class GradientDesigner(tk.Tk): + def __init__(self): + super().__init__() + self.title("Tkinter Gradient Designer") + self.geometry("600x700") + self.configure(padx=20, pady=20) + # Initial colors based on your URL + self.stops = [ + {"color": "#4c71ff", "pos": 0.0}, + {"color": "#0025b3", "pos": 0.25}, + {"color": "#000000", "pos": 0.50}, + {"color": "#c7030d", "pos": 0.75}, + {"color": "#fc4a53", "pos": 1.0}, + ] + self.setup_ui() + self.render_gradient() + self.update_export_code() + def setup_ui(self): + # 1. Canvas for Preview + tk.Label(self, text="Gradient Preview", font=("Arial", 12, "bold")).pack(anchor="w") + self.canvas_width = 560 + self.canvas_height = 100 + self.canvas = tk.Canvas(self, width=self.canvas_width, height=self.canvas_height, bd=2, relief="sunken") + self.canvas.pack(pady=(5, 20)) + # 2. Controls Area + tk.Label(self, text="Adjust Color Nodes", font=("Arial", 12, "bold")).pack(anchor="w") + # Frame to hold dynamic rows of sliders/buttons + self.controls_frame = tk.Frame(self) + self.controls_frame.pack(fill="x", pady=5) + self.build_control_rows() + # 3. Add Node Button + tk.Button(self, text="➕ Add Color Node", command=self.add_stop).pack(pady=10) + # 4. Code Export Area + tk.Label(self, text="CSS Export", font=("Arial", 12, "bold")).pack(anchor="w", pady=(15, 5)) + self.export_text = tk.Text(self, height=3, width=65, bg="#f0f0f0") + self.export_text.pack() + def build_control_rows(self): + # Clear existing controls + for widget in self.controls_frame.winfo_children(): + widget.destroy() + # Create a row for each stop + for i, stop in enumerate(self.stops): + row = tk.Frame(self.controls_frame) + row.pack(fill="x", pady=2) + # Color Chooser Button + btn = tk.Button(row, bg=stop["color"], width=5, + command=lambda idx=i: self.choose_color(idx)) + btn.pack(side="left", padx=5) + # Position Slider + scale = tk.Scale(row, from_=0.0, to=1.0, resolution=0.01, orient="horizontal", + length=350, showvalue=True, + command=lambda val, idx=i: self.update_pos(idx, val)) + scale.set(stop["pos"]) + scale.pack(side="left", padx=10) + # Delete Button + if len(self.stops) > 2: + del_btn = tk.Button(row, text="❌", command=lambda idx=i: self.remove_stop(idx)) + del_btn.pack(side="left") + def choose_color(self, idx): + current_color = self.stops[idx]["color"] + # Open Tkinter's native color picker + _, hex_color = colorchooser.askcolor(initialcolor=current_color, title="Select Color") + if hex_color: + self.stops[idx]["color"] = hex_color + self.build_control_rows() + self.render_gradient() + self.update_export_code() + def update_pos(self, idx, val): + self.stops[idx]["pos"] = float(val) + self.render_gradient() + self.update_export_code() + def add_stop(self): + # Add a new stop at the end + self.stops.append({"color": "#ffffff", "pos": 1.0}) + self.build_control_rows() + self.render_gradient() + self.update_export_code() + def remove_stop(self, idx): + if len(self.stops) > 2: + self.stops.pop(idx) + self.build_control_rows() + self.render_gradient() + self.update_export_code() + def render_gradient(self): + self.canvas.delete("gradient") + # Sort stops strictly by position for rendering + sorted_stops = sorted(self.stops, key=lambda s: s['pos']) + # Draw the gradient line by line + for x in range(self.canvas_width): + t = x / self.canvas_width # Global position from 0.0 to 1.0 + # Handle edge cases (before first stop or after last stop) + if t <= sorted_stops[0]['pos']: + color = sorted_stops[0]['color'] + elif t >= sorted_stops[-1]['pos']: + color = sorted_stops[-1]['color'] + else: + # Find which two stops this pixel falls between + for i in range(len(sorted_stops) - 1): + s1 = sorted_stops[i] + s2 = sorted_stops[i+1] + if s1['pos'] <= t <= s2['pos']: + span = s2['pos'] - s1['pos'] + if span == 0: # Prevent division by zero if sliders overlap + local_t = 0 + else: + local_t = (t - s1['pos']) / span + rgb1 = hex_to_rgb(s1['color']) + rgb2 = hex_to_rgb(s2['color']) + interp_rgb = interpolate(rgb1, rgb2, local_t) + color = rgb_to_hex(interp_rgb) + break + # Draw a 1-pixel wide vertical line on the canvas + self.canvas.create_line(x, 0, x, self.canvas_height, fill=color, tags="gradient") + def update_export_code(self): + sorted_stops = sorted(self.stops, key=lambda s: s['pos']) + css_stops = ", ".join([f"{s['color']} {s['pos']*100:.1f}%" for s in sorted_stops]) + css_code = f"background: linear-gradient(90deg, {css_stops});" + self.export_text.delete(1.0, tk.END) + self.export_text.insert(tk.END, css_code) +if __name__ == "__main__": + app = GradientDesigner() + app.mainloop() \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py index cdd1dab..a23d2c0 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -1,4 +1,5 @@ from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from .colour_scale_builder import ColourScaleBuilder from .directory_file_selector import DirectoryFileSelector from .landing_page import LandingPage from .modal_window import BHoMModalWindow @@ -8,6 +9,7 @@ __all__ = [ "BHoMBaseChildWindow", "BHoMModalWindow", + "ColourScaleBuilder", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py new file mode 100644 index 0000000..bf2e531 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py @@ -0,0 +1,358 @@ +"""Window for building colour scales from the data they will colour. + +Pass the data in and the window shows its distribution, suggests band thresholds, and +lets the user colour, label and save the scale into a named set of the colour scale +library (``python_toolkit.plot.colour_scales``):: + + builder = ColourScaleBuilder(parent, data=room_temperatures, units="degC") + builder.run() + scale = builder.saved_scale # None if closed without saving +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import Callable, Iterable, List, Optional + +import numpy as np +from matplotlib.figure import Figure + +from python_toolkit.bhom.logging import CONSOLE_LOGGER +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.windows.warning_box import WarningBox +from python_toolkit.bhom_tkinter.widgets._grid_options import GridOptions +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.check_box_selection import CheckboxSelection +from python_toolkit.bhom_tkinter.widgets.colour_picker import ColourPicker +from python_toolkit.bhom_tkinter.widgets.drop_down_selection import DropDownSelection +from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer +from python_toolkit.bhom_tkinter.widgets.label import Label +from python_toolkit.bhom_tkinter.widgets.radio_selection import RadioSelection +from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox +from python_toolkit.bhom_tkinter.widgets.validated_entry_box import ValidatedEntryBox +from python_toolkit.plot.colour_scales import ( + DEFAULT_SET, + PALETTES, + SUGGESTION_METHODS, + ColourScale, + ColourScaleError, + ColourScaleLibrary, + DataSummary, + GradientScale, + ThresholdScale, + clean_data, + palette_colours, + palette_names, + suggest_thresholds, +) + +THRESHOLDS = "Threshold bands" +GRADIENT = "Gradient" +MAX_BANDS = 12 +ERROR_COLOUR = "#c0392b" + + +class ColourScaleBuilder(BHoMBaseChildWindow): + """Build, preview and save a ThresholdScale or GradientScale against real data.""" + + def __init__( + self, + parent=None, + data: Optional[Iterable] = None, + units: Optional[str] = None, + scale: Optional[ColourScale] = None, + library: Optional[ColourScaleLibrary] = None, + on_save: Optional[Callable[[ColourScale], None]] = None, + palette: Optional[str] = None, + **kwargs, + ): + """ + Args: + parent: Owning window (None for a standalone window). + data: Values the scale will colour; drives the histogram and suggestions. + units: Units of the data, stored with the scale. + scale: An existing scale to edit (saving replaces it). + library: Library to save into (defaults to the user's library). + on_save: Called with the saved scale. + palette: Palette or matplotlib colour map to start from (e.g. the plot's current one). + """ + self.values = clean_data(data) + self.summary = DataSummary.from_data(self.values) if self.values.size else None + self.units = units if units is not None else (scale.units if scale else None) + self.library = library if library is not None else ColourScaleLibrary() + self.editing = scale + self.on_save = on_save + self.saved_scale: Optional[ColourScale] = None + self._band_rows: List[dict] = [] + self._pending = None + self._start_palette = palette + super().__init__( + parent, + title="Colour scale", + submit_text="Save scale", + submit_command=self._save, + close_on_submit=False, + close_text="Close", + modal=parent is not None, + defer_show=True, + resizable=True, + min_width=kwargs.pop("min_width", 620), + min_height=kwargs.pop("min_height", 800), + **kwargs, + ) + self._build_contents() + self.build() + self._load(scale) + + # ── layout ───────────────────────────────────────────────────────────── + + def _build_contents(self) -> None: + frame = self.content_frame + sets = self.library.groups() or [DEFAULT_SET] + default_set = self.editing.group if self.editing else sets[0] + + self.name = ValidatedEntryBox(frame, id="name", helper_text="Name", required=False, width=30, + on_change=lambda _v: self._schedule_preview(), # re-checks, clearing old errors + build_options=GridOptions(row=0, column=0, sticky="w", padx=6, pady=3)) + self.group = DropDownSelection(frame, id="group", helper_text="Set (type a new name to start a set)", + options=sets, default=default_set, state="normal", width=28, + build_options=GridOptions(row=0, column=1, sticky="w", padx=6, pady=3)) + self.kind = RadioSelection(frame, id="kind", fields=[THRESHOLDS, GRADIENT], default=THRESHOLDS, + orient="horizontal", command=lambda _v: self._on_kind_changed(), + build_options=GridOptions(row=1, column=0, columnspan=2, sticky="w", padx=6, pady=3)) + self.data_text = Label(frame, text=self._summary_text(), + build_options=GridOptions(row=2, column=0, columnspan=2, sticky="w", padx=6)) + self.preview = FigureContainer(frame, id="preview", rigid_width=560, rigid_height=130, + build_options=GridOptions(row=3, column=0, columnspan=2, sticky="w", padx=6, pady=4)) + + suggest = ttk.Frame(frame) + suggest.grid(row=5, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.band_count = Spinbox(suggest, id="bands", helper_text="Bands", values=list(range(2, MAX_BANDS + 1)), + default=5, width=5, build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 8))) + self.method = DropDownSelection(suggest, id="method", helper_text="Breaks", options=list(SUGGESTION_METHODS), + default="nice", state="readonly", width=10, + build_options=GridOptions(row=0, column=1, sticky="w", padx=(0, 8))) + palettes = palette_names() + self.palette = DropDownSelection(suggest, id="palette", helper_text="Palette / colour map", options=palettes, + default=self._start_palette if self._start_palette in palettes else None, + state="readonly", width=28, command=lambda _v: self._recolour(), + build_options=GridOptions(row=0, column=2, sticky="w", padx=(0, 8))) + self.suggest_button = Button(suggest, text="Suggest from data", command=self._suggest, width=18, + build_options=GridOptions(row=0, column=3, sticky="sw")) + for widget in (self.band_count, self.method, self.palette, self.suggest_button): + widget.build() + if self.summary is None: + self.suggest_button.button.configure(state="disabled") + + self.bands_frame = ttk.Frame(frame) + self.bands_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.gradient_frame = ttk.Frame(frame) + self.gradient_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.reverse = CheckboxSelection(self.gradient_frame, id="reverse", fields=["Reverse"], + command=lambda _v: self._schedule_preview(), + build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 12))) + self.steps = Spinbox(self.gradient_frame, id="steps", helper_text="Steps (0 = smooth)", + values=[0] + list(range(2, 21)), default=0, width=5, + command=lambda _v: self._schedule_preview(), + build_options=GridOptions(row=0, column=1, sticky="w")) + self.reverse.build() + self.steps.build() + + self.status = Label(frame, text="", foreground=ERROR_COLOUR, wraplength=560, justify="left", + build_options=GridOptions(row=4, column=0, columnspan=2, sticky="w", padx=6)) + self.widgets = [self.name, self.group, self.kind, self.data_text, self.preview, self.status] + + def _summary_text(self) -> str: + if self.summary is None: + return "No data passed in: set the thresholds by hand." + s = self.summary + units = f" {self.units}" if self.units else "" + return (f"{s.count} values from {s.minimum:.4g} to {s.maximum:.4g}{units}; " + f"median {s.quartiles[1]:.4g}, middle half {s.quartiles[0]:.4g} to {s.quartiles[2]:.4g}") + + # ── state ────────────────────────────────────────────────────────────── + + def _load(self, scale: Optional[ColourScale]) -> None: + if isinstance(scale, GradientScale): + self.name.set(scale.name) + self.kind.set(GRADIENT) + self.steps.set(scale.bins or 0) + elif isinstance(scale, ThresholdScale): + self.name.set(scale.name) + self._set_bands(scale.thresholds, scale.colours, scale.labels) + elif self.summary is not None: + self.name.set(self._unused_name("New scale")) + self._suggest() + else: + self.name.set(self._unused_name("New scale")) + self._set_bands([1.0, 2.0], palette_colours(self.palette.get(), 3), None) + self._on_kind_changed() + + def _on_kind_changed(self) -> None: + threshold = self.kind.get() == THRESHOLDS + if threshold: + self.gradient_frame.grid_remove() + self.bands_frame.grid() + else: + self.bands_frame.grid_remove() + self.gradient_frame.grid() + self._schedule_preview() + + def _suggest(self) -> None: + try: + bands = int(self.band_count.get()) + thresholds = suggest_thresholds(self.values, bands, self.method.get()) + except (ColourScaleError, ValueError) as e: + self.status.set(str(e)) + return + self._set_bands(thresholds, palette_colours(self.palette.get(), len(thresholds) + 1), None) + + def _recolour(self) -> None: + """A new palette recolours the bands, keeping their thresholds and labels.""" + if not self._band_rows: + return + for row, colour in zip(self._band_rows, palette_colours(self.palette.get(), len(self._band_rows))): + row["colour"].set(colour) + self._schedule_preview() + + def _set_bands(self, thresholds, colours, labels) -> None: + for child in self.bands_frame.winfo_children(): + child.destroy() + self._band_rows = [] + n = len(colours) + headers = ("Colour", "Label (legend)", "From", "Up to") + for col, text in enumerate(headers): + ttk.Label(self.bands_frame, text=text, style="Caption.TLabel").grid(row=0, column=col, sticky="w", padx=4) + for i in range(n): + picker = ColourPicker(self.bands_frame, default_colour=colours[i], swatch_width=36, swatch_height=20, + command=lambda _c: self._schedule_preview()) + picker.grid(row=i + 1, column=0, sticky="nw", padx=4, pady=(6, 0)) + label = ValidatedEntryBox(self.bands_frame, required=False, width=22, + on_change=lambda _v: self._schedule_preview()) + label.grid(row=i + 1, column=1, sticky="w", padx=4) + label.set((labels[i] or "") if labels else "") + lower = ttk.Label(self.bands_frame, text="") + lower.grid(row=i + 1, column=2, sticky="nw", padx=4, pady=(6, 0)) + upper = None + if i < n - 1: + upper = ValidatedEntryBox(self.bands_frame, required=False, width=10, + on_change=lambda _v: self._schedule_preview()) + upper.grid(row=i + 1, column=3, sticky="w", padx=4) + upper.set(f"{thresholds[i]:.6g}") + else: + ttk.Label(self.bands_frame, text="and above").grid(row=i + 1, column=3, sticky="nw", padx=4, pady=(6, 0)) + self._band_rows.append({"colour": picker, "label": label, "lower": lower, "upper": upper}) + self.band_count.set(n) + self._schedule_preview() + + def current_scale(self) -> ColourScale: + """The scale described by the window right now (raises ColourScaleError if invalid).""" + name = (self.name.get() or "").strip() + group = (self.group.get() or "").strip() or DEFAULT_SET + if self.kind.get() == GRADIENT: + name = self.palette.get() + colours = PALETTES.get(name) or palette_colours(name, 9) + if self.reverse.get(): + colours = colours[::-1] + n = len(colours) + steps = int(self.steps.get() or 0) + return GradientScale(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, + bins=steps or None, units=self.units, group=group) + thresholds = [] + for i, row in enumerate(self._band_rows[:-1]): + text = (row["upper"].get() or "").strip() + try: + thresholds.append(float(text)) + except ValueError: + raise ColourScaleError(f"Band {i + 1}: '{text}' is not a number.") from None + colours = [row["colour"].get() for row in self._band_rows] + labels = [(row["label"].get() or "").strip() or None for row in self._band_rows] + return ThresholdScale(name, thresholds, colours, labels=labels if any(labels) else None, + units=self.units, group=group) + + def _unused_name(self, base: str) -> str: + names = set(self.library.names()) + n = 1 + name = base + while name in names: + n += 1 + name = f"{base} {n}" + return name + + def _save(self) -> None: + """Validate, save to the library and close; errors are shown in a pop-up and stay in the window.""" + try: + scale = self.current_scale() + self.library.save(scale, overwrite=self.editing is not None and self.editing.name == scale.name) + except (ColourScaleError, OSError) as e: + message = str(e) + if "already exists" in message: + message += " Give this scale another name, or edit the saved one instead." + self.status.set(message) + box = WarningBox(title="Scale not saved", errors=[message], parent=self) + box.build() + box.run() + return + CONSOLE_LOGGER.info(f"Saved colour scale '{scale.name}' in set '{scale.group}'.") + self.saved_scale = scale + if self.on_save is not None: + self.on_save(scale) + self.destroy_root() + + # ── preview ──────────────────────────────────────────────────────────── + + def _schedule_preview(self) -> None: + if self._pending is not None: + try: + self.after_cancel(self._pending) + except tk.TclError: + pass + self._pending = self.after(250, self._update_preview) + + def _update_preview(self) -> None: + self._pending = None + try: + scale = self.current_scale() + except ColourScaleError as e: + self.status.set(str(e)) + return + self.status.set("") + if isinstance(scale, ThresholdScale): + for row, (lower, _upper) in zip(self._band_rows, scale.band_ranges()): + row["lower"].configure(text="below" if lower is None else f"{lower:.6g}") + self.preview.embed_figure(preview_figure(scale, self.values, self.summary)) + + +def preview_figure(scale: ColourScale, values: np.ndarray, summary: Optional[DataSummary]) -> Figure: + """Histogram of the data over the scale's colours (or the colours alone without data).""" + fig = Figure(figsize=(5.6, 1.3), dpi=100) + ax = fig.add_axes([0.04, 0.2, 0.94, 0.76]) + sample = values if values.size else scale.preview_data() + mapping = scale.mapping(sample) + lo, hi = (summary.minimum, summary.maximum) if summary else (float(sample.min()), float(sample.max())) + if lo == hi: + lo, hi = lo - 0.5, hi + 0.5 + pad = (hi - lo) * 0.04 + xs = np.linspace(lo - pad, hi + pad, 400) + colours = mapping.cmap(mapping.norm(xs)) + ax.imshow(colours[np.newaxis, :, :], aspect="auto", extent=(xs[0], xs[-1], 0, 1), alpha=0.85, + origin="lower", zorder=0) + if summary is not None: + counts, edges = summary.histogram + heights = counts / max(counts.max(), 1) + ax.bar(edges[:-1], heights, width=np.diff(edges), align="edge", color="#202020", alpha=0.55, + edgecolor="#ffffff", linewidth=0.6, zorder=1) + if isinstance(scale, ThresholdScale): + for t in scale.thresholds: + if xs[0] <= t <= xs[-1]: + ax.axvline(t, color="#000000", linewidth=1.2, zorder=2) + ax.set_xlim(xs[0], xs[-1]) + ax.set_ylim(0, 1.05) + ax.set_yticks([]) + ax.tick_params(labelsize=7) + for side in ("top", "right", "left"): + ax.spines[side].set_visible(False) + return fig + + diff --git a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py new file mode 100644 index 0000000..3cba15c --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -0,0 +1,575 @@ +"""Colour scales defined in data units, with user-defined sets saved for reuse. + +A matplotlib Colormap only knows positions from 0 to 1, so a saved "18 / 21 / 24 degC" band +set drifts whenever the data range changes. A ColourScale keeps its definition in data +units and produces the matching (Colormap, Normalize) pair for whatever data is passed in: + + scale = ThresholdScale("Comfort", thresholds=[18, 21, 24], + colours=["#3b4cc0", "#8db0fe", "#f4987a", "#b40426"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") + mapping = scale.mapping(room_temperatures) # pass the data in + ax.scatter(x, y, c=room_temperatures, cmap=mapping.cmap, norm=mapping.norm) + +Scales can be suggested from the data (equal interval, quantile, or rounded "nice" breaks), +and are saved in a ColourScaleLibrary under named sets (e.g. "TM59", "Part L"). +""" + +from __future__ import annotations + +import json +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, Type + +import numpy as np +from matplotlib import colormaps +from matplotlib.colors import ( + BoundaryNorm, + Colormap, + LinearSegmentedColormap, + ListedColormap, + Normalize, + to_hex, + to_rgba, +) + +from python_toolkit.bhom.logging import CONSOLE_LOGGER + +LIBRARY_FILE = Path.home() / ".bhom" / "custom_cmaps.json" +LIBRARY_VERSION = 2 +DEFAULT_SET = "My scales" + +# Preset palettes for building scales (colour-blind safe sets first). +PALETTES: Dict[str, List[str]] = { + "Okabe-Ito (colour-blind safe)": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], + "Viridis (colour-blind safe)": ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], + "Cividis (colour-blind safe)": ["#00204c", "#31446b", "#666870", "#988e5d", "#ffe945"], + "Cool to warm": ["#3b4cc0", "#8db0fe", "#dddddd", "#f4987a", "#b40426"], + "Traffic light": ["#1a9850", "#fee08b", "#d73027"], + "LTC Heatmap": ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"], + "LTC Maya": ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"], + "LTC Paloma": ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"], +} + +# Palettes registered as matplotlib colour maps (with _r), so every colour map list offers them. +# Viridis and Cividis are already matplotlib maps. +PALETTE_CMAPS: Dict[str, str] = { + "Okabe-Ito (colour-blind safe)": "okabe_ito", + "Cool to warm": "cool_to_warm", + "Traffic light": "traffic_light", + "LTC Heatmap": "ltc_heatmap", + "LTC Maya": "ltc_maya", + "LTC Paloma": "ltc_paloma", +} + +SUGGESTION_METHODS = ("equal", "quantile", "nice") + + +class ColourScaleError(ValueError): + """A colour scale definition, or the saved library, is invalid.""" + + +# --------------------------------------------------------------------------- +# Data passed in +# --------------------------------------------------------------------------- + +def clean_data(data: Optional[Iterable]) -> np.ndarray: + """Finite float values from any iterable; None, NaN and non-numbers are dropped.""" + if data is None: + return np.empty(0) + numbers = np.array([_to_float(v) for v in np.ravel(np.asarray(list(data), dtype=object))], dtype=float) + return numbers[np.isfinite(numbers)] + + +@dataclass(frozen=True) +class DataSummary: + """What a scale needs to know about the data: range, spread and distribution.""" + + count: int + minimum: float + maximum: float + mean: float + quartiles: Tuple[float, float, float] + histogram: Tuple[np.ndarray, np.ndarray] # (counts, bin edges) + + @classmethod + def from_data(cls, data: Iterable, bins: int = 30) -> "DataSummary": + values = clean_data(data) + if values.size == 0: + raise ColourScaleError("No numeric values in the data passed in.") + lo, hi = float(values.min()), float(values.max()) + if lo == hi: + lo, hi = lo - 0.5, hi + 0.5 + counts, edges = np.histogram(values, bins=bins, range=(lo, hi)) + q1, q2, q3 = (float(q) for q in np.quantile(values, [0.25, 0.5, 0.75])) + return cls(int(values.size), float(values.min()), float(values.max()), float(values.mean()), + (q1, q2, q3), (counts, edges)) + + +def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List[float]: + """Inner boundaries splitting the data into `bands` bands. + + equal: equal-width bands across the data range + quantile: roughly the same number of values in each band + nice: equal-width bands on rounded 1 / 2 / 5 x 10^n steps + """ + if bands < 2: + raise ColourScaleError("A threshold scale needs at least 2 bands.") + if method not in SUGGESTION_METHODS: + raise ColourScaleError(f"Method must be one of {SUGGESTION_METHODS}, not '{method}'.") + values = clean_data(data) + if values.size == 0: + raise ColourScaleError("No numeric values to suggest thresholds from.") + lo, hi = float(values.min()), float(values.max()) + if lo == hi: + return [lo + i for i in range(1, bands)] + + if method == "equal": + return [float(v) for v in np.linspace(lo, hi, bands + 1)[1:-1]] + if method == "quantile": + cuts = np.quantile(values, np.linspace(0, 1, bands + 1)[1:-1]) + unique = sorted(set(float(c) for c in cuts)) + if len(unique) < bands - 1: + raise ColourScaleError("Too many repeated values for that many quantile bands; use fewer bands.") + return unique + + return _nice_thresholds(lo, hi, bands - 1) + + +def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: + """Rounded breaks (multiples of 1, 2, 2.5 or 5 x 10^n) strictly inside the data range. + + Picks the step whose breaks come closest to `wanted`, then keeps the middle `wanted` + of them, so every band contains data rather than extending beyond it.""" + raw = (hi - lo) / (wanted + 1) + exponent = math.floor(math.log10(raw)) + best = None + for power in (exponent - 1, exponent, exponent + 1): + for factor in (1, 2, 2.5, 5): + step = factor * 10 ** power + first, last = math.floor(lo / step) + 1, math.ceil(hi / step) - 1 + inner = [round(k * step, 10) for k in range(first, last + 1)] + if not inner: + continue + score = abs(len(inner) - wanted) + (0.5 if factor == 2.5 else 0.0) # prefer rounder steps + if best is None or score < best[0]: + best = (score, inner) + if best is None: # range too narrow for rounded breaks + return [float(v) for v in np.linspace(lo, hi, wanted + 2)[1:-1]] + inner = best[1] + if len(inner) > wanted: + start = (len(inner) - wanted) // 2 + inner = inner[start:start + wanted] + return inner + + +def register_palette_cmaps() -> None: + """Add the preset palettes to matplotlib's colour maps (idempotent).""" + for palette, name in PALETTE_CMAPS.items(): + if name not in colormaps: + cmap = LinearSegmentedColormap.from_list(name, PALETTES[palette]) + colormaps.register(cmap, name=name) + colormaps.register(cmap.reversed(), name=f"{name}_r") + + +def palette_names() -> List[str]: + """Preset palettes first, then every matplotlib colour map (no reversed copies).""" + preset = set(PALETTE_CMAPS.values()) + return list(PALETTES) + sorted((n for n in colormaps if not n.endswith("_r") and n not in preset), key=str.lower) + + +def palette_colours(palette: str, count: int) -> List[str]: + """`count` colours sampled evenly from a preset palette or a matplotlib colour map.""" + if palette in PALETTES: + cmap = LinearSegmentedColormap.from_list(palette, PALETTES[palette]) + elif palette in colormaps: + cmap = colormaps[palette] + else: + raise ColourScaleError(f"Unknown palette or colour map '{palette}'.") + if count == 1: + return [to_hex(cmap(0.5))] + return [to_hex(cmap(i / (count - 1))) for i in range(count)] + + +# --------------------------------------------------------------------------- +# Scales +# --------------------------------------------------------------------------- + +@dataclass +class ScaleMapping: + """Everything a plot needs to colour values with a scale.""" + + cmap: Colormap + norm: Normalize + ticks: List[float] + tick_labels: List[str] + band_labels: Optional[List[str]] = None # threshold scales: one per band, for legends + + def colours_for(self, values: Iterable) -> np.ndarray: + """RGBA per value (NaN for values that cannot be read).""" + data = np.array([_to_float(v) for v in values], dtype=float) + out = np.full((data.size, 4), np.nan) + ok = np.isfinite(data) + if ok.any(): + out[ok] = self.cmap(self.norm(data[ok])) + return out + + +class ColourScale(ABC): + """Base class: a named colour scale defined in data units. + + Subclasses produce a ScaleMapping from the data passed in, and round-trip through + plain dicts so they can be saved. `group` is the user-defined set the scale belongs to. + """ + + kind: ClassVar[str] = "" + _registry: ClassVar[Dict[str, Type["ColourScale"]]] = {} + + def __init__(self, name: str, units: Optional[str] = None, description: str = "", + group: str = DEFAULT_SET): + self.name = validate_name(name) + self.units = units + self.description = description + self.group = group or DEFAULT_SET + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls.kind: + ColourScale._registry[cls.kind] = cls + + @abstractmethod + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + """Colormap and normaliser for this scale, fitted to `data` where the scale is relative.""" + + @abstractmethod + def _definition(self) -> Dict[str, Any]: + """Subclass-specific fields for to_dict.""" + + def to_dict(self) -> Dict[str, Any]: + return {"type": self.kind, "name": self.name, "units": self.units, + "description": self.description, "group": self.group, **self._definition()} + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ColourScale": + kind = data.get("type") + scale_cls = ColourScale._registry.get(kind) + if scale_cls is None: + raise ColourScaleError(f"Unknown colour scale type '{kind}'.") + return scale_cls._from_definition(data) + + @classmethod + @abstractmethod + def _from_definition(cls, data: Dict[str, Any]) -> "ColourScale": + """Build from a dict written by to_dict.""" + + def preview_data(self) -> np.ndarray: + """Representative values spanning the scale, for swatches without real data.""" + return np.linspace(0.0, 1.0, 11) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.name!r}, set={self.group!r})" + + +class GradientScale(ColourScale): + """A continuous gradient through colour stops. + + Stop positions are either absolute data values, or relative (0 = data minimum, + 1 = data maximum) so the gradient stretches to whatever data is passed in. + """ + + kind = "gradient" + + def __init__(self, name: str, stops: Sequence[Tuple[float, str]], relative: bool = True, + bins: Optional[int] = None, **kwargs): + super().__init__(name, **kwargs) + if len(stops) < 2: + raise ColourScaleError("A gradient needs at least 2 colour stops.") + self.stops = sorted(((float(p), _hex(c)) for p, c in stops), key=lambda s: s[0]) + self.relative = bool(relative) + self.bins = bins # None for smooth, or a number of discrete steps + positions = [p for p, _ in self.stops] + if positions[0] == positions[-1]: + raise ColourScaleError("Gradient stops must span a range.") + if self.relative and (positions[0] < 0 or positions[-1] > 1): + raise ColourScaleError("Relative stop positions must lie between 0 and 1.") + + @classmethod + def from_palette(cls, name: str, palette: str, **kwargs) -> "GradientScale": + if palette not in PALETTES: + raise ColourScaleError(f"Unknown palette '{palette}'.") + colours = PALETTES[palette] + n = len(colours) + return cls(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, **kwargs) + + def value_range(self, data: Optional[Iterable] = None) -> Tuple[float, float]: + if not self.relative: + return self.stops[0][0], self.stops[-1][0] + values = clean_data(data) + if values.size == 0: + return 0.0, 1.0 + lo, hi = float(values.min()), float(values.max()) + return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi) + + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + vmin, vmax = self.value_range(data) + first, last = self.stops[0][0], self.stops[-1][0] + nodes = [((p - first) / (last - first), c) for p, c in self.stops] + cmap = LinearSegmentedColormap.from_list(self.name, nodes) + if self.bins: + cmap = cmap.resampled(int(self.bins)) + edges = list(np.linspace(vmin, vmax, int(self.bins) + 1)) + norm = BoundaryNorm(edges, ncolors=int(self.bins), clip=True) + ticks = edges + else: + norm = Normalize(vmin=vmin, vmax=vmax, clip=True) + ticks = list(np.linspace(vmin, vmax, 6)) + return ScaleMapping(cmap, norm, ticks, [_fmt(t) for t in ticks]) + + def _definition(self) -> Dict[str, Any]: + return {"stops": [[p, c] for p, c in self.stops], "relative": self.relative, "bins": self.bins} + + @classmethod + def _from_definition(cls, data: Dict[str, Any]) -> "GradientScale": + return cls(data["name"], [tuple(s) for s in data["stops"]], relative=data.get("relative", True), + bins=data.get("bins"), units=data.get("units"), description=data.get("description", ""), + group=data.get("group") or DEFAULT_SET) + + +class ThresholdScale(ColourScale): + """Bands with fixed boundaries in data units: one colour (and optional label) per band. + + `thresholds` are the inner boundaries; values below the first threshold take the first + colour and values at or above the last take the last, so the bands hold for any data. + """ + + kind = "thresholds" + + def __init__(self, name: str, thresholds: Sequence[float], colours: Sequence[str], + labels: Optional[Sequence[Optional[str]]] = None, **kwargs): + super().__init__(name, **kwargs) + self.thresholds = [float(t) for t in thresholds] + self.colours = [_hex(c) for c in colours] + if not self.thresholds: + raise ColourScaleError("A threshold scale needs at least one threshold.") + if any(b <= a for a, b in zip(self.thresholds, self.thresholds[1:])): + raise ColourScaleError("Thresholds must be strictly increasing.") + if len(self.colours) != len(self.thresholds) + 1: + raise ColourScaleError( + f"{len(self.thresholds)} threshold(s) make {len(self.thresholds) + 1} bands, " + f"but {len(self.colours)} colour(s) were given.") + if labels is not None and len(labels) != len(self.colours): + raise ColourScaleError(f"Give one label per band ({len(self.colours)}), or none.") + self.labels = [None if not l else str(l) for l in labels] if labels is not None else None + + @classmethod + def from_data(cls, name: str, data: Iterable, bands: int = 5, method: str = "nice", + palette: str = "Viridis (colour-blind safe)", **kwargs) -> "ThresholdScale": + """Suggest thresholds from the data passed in, coloured from a preset palette.""" + thresholds = suggest_thresholds(data, bands, method) + return cls(name, thresholds, palette_colours(palette, len(thresholds) + 1), **kwargs) + + @property + def bands(self) -> int: + return len(self.colours) + + def band_ranges(self) -> List[Tuple[Optional[float], Optional[float]]]: + """(lower, upper) per band; None for the open ends.""" + edges: List[Optional[float]] = [None] + list(self.thresholds) + [None] + return list(zip(edges[:-1], edges[1:])) + + def band_labels(self) -> List[str]: + """Labels as given, else ranges such as '< 18', '18 - 21', '>= 24'.""" + auto = [] + for lower, upper in self.band_ranges(): + if lower is None: + auto.append(f"< {_fmt(upper)}") + elif upper is None: + auto.append(f">= {_fmt(lower)}") + else: + auto.append(f"{_fmt(lower)} - {_fmt(upper)}") + if self.labels is None: + return auto + return [given or default for given, default in zip(self.labels, auto)] + + def band_of(self, value: float) -> int: + """Index of the band a value falls in.""" + return int(np.searchsorted(self.thresholds, float(value), side="right")) + + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + values = clean_data(data) + span = self.thresholds[-1] - self.thresholds[0] or 1.0 + lo = min(self.thresholds[0] - span / max(self.bands - 2, 1), float(values.min()) if values.size else math.inf) + hi = max(self.thresholds[-1] + span / max(self.bands - 2, 1), float(values.max()) if values.size else -math.inf) + edges = [lo] + self.thresholds + [hi + 1e-9] + cmap = ListedColormap(self.colours, name=self.name) + norm = BoundaryNorm(edges, ncolors=self.bands, clip=True) + return ScaleMapping(cmap, norm, list(self.thresholds), [_fmt(t) for t in self.thresholds], + band_labels=self.band_labels()) + + def preview_data(self) -> np.ndarray: + span = self.thresholds[-1] - self.thresholds[0] or 1.0 + pad = span / max(self.bands - 2, 1) + return np.linspace(self.thresholds[0] - pad, self.thresholds[-1] + pad, 101) + + def _definition(self) -> Dict[str, Any]: + return {"thresholds": self.thresholds, "colours": self.colours, "labels": self.labels} + + @classmethod + def _from_definition(cls, data: Dict[str, Any]) -> "ThresholdScale": + return cls(data["name"], data["thresholds"], data["colours"], labels=data.get("labels"), + units=data.get("units"), description=data.get("description", ""), + group=data.get("group") or DEFAULT_SET) + + +# --------------------------------------------------------------------------- +# Library: saved scales grouped in user-defined sets +# --------------------------------------------------------------------------- + +class ColourScaleLibrary: + """Saved colour scales, grouped into named sets, stored as JSON. + + Reads the original custom-colormap list format too (converted on load; written back + in the current format on the next save). Entries that cannot be read are kept on + disk, reported in `problems` and logged, never silently dropped. + """ + + def __init__(self, path: Optional[Path] = None): + self.path = Path(path) if path is not None else LIBRARY_FILE + self.problems: List[str] = [] + self._scales: Dict[str, ColourScale] = {} + self._unreadable: List[Dict[str, Any]] = [] + self.reload() + + def reload(self) -> None: + self._scales.clear() + self._unreadable.clear() + self.problems = [] + if not self.path.exists(): + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + raise ColourScaleError(f"Cannot read the colour scale library {self.path}: {e}") from e + + entries = raw.get("scales", []) if isinstance(raw, dict) else raw + if not isinstance(entries, list): + raise ColourScaleError(f"{self.path} is not a colour scale library.") + for entry in entries: + try: + scale = _from_legacy(entry) if "type" in entry and entry["type"] in ("bins", "interpolation") \ + else ColourScale.from_dict(entry) + except (KeyError, TypeError, ValueError) as e: + name = entry.get("name", "?") if isinstance(entry, dict) else "?" + self.problems.append(f"Could not read saved scale '{name}': {e}") + self._unreadable.append(entry) + continue + self._scales[scale.name] = scale + for problem in self.problems: + CONSOLE_LOGGER.warning(problem) + + # -- queries ------------------------------------------------------------ + + def names(self, group: Optional[str] = None) -> List[str]: + return [s.name for s in self._scales.values() if group is None or s.group == group] + + def groups(self) -> List[str]: + """The user-defined sets, in first-seen order.""" + return list(dict.fromkeys(s.group for s in self._scales.values())) + + def scales(self, group: Optional[str] = None) -> List[ColourScale]: + return [s for s in self._scales.values() if group is None or s.group == group] + + def get(self, name: str) -> ColourScale: + if name not in self._scales: + raise KeyError(f"No saved colour scale named '{name}'.") + return self._scales[name] + + def __contains__(self, name: str) -> bool: + return name in self._scales + + def __len__(self) -> int: + return len(self._scales) + + # -- changes ------------------------------------------------------------ + + def save(self, scale: ColourScale, overwrite: bool = False) -> None: + if scale.name in self._scales and not overwrite: + raise ColourScaleError(f"A scale named '{scale.name}' already exists.") + self._scales[scale.name] = scale + self._write() + + def delete(self, name: str) -> None: + if name not in self._scales: + raise KeyError(f"No saved colour scale named '{name}'.") + del self._scales[name] + self._write() + + def rename_group(self, old: str, new: str) -> None: + for scale in self.scales(old): + scale.group = new + self._write() + + def delete_group(self, group: str) -> None: + for name in self.names(group): + del self._scales[name] + self._write() + + def clear(self) -> None: + self._scales.clear() + self._write() + + def _write(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = {"version": LIBRARY_VERSION, + "scales": [s.to_dict() for s in self._scales.values()] + self._unreadable} + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp.replace(self.path) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_INVALID_NAME_CHARS = set(r'\/:*?"<>|') + + +def validate_name(name: str) -> str: + name = (name or "").strip() + if not name: + raise ColourScaleError("A colour scale needs a name.") + if any(c in _INVALID_NAME_CHARS for c in name): + raise ColourScaleError("Names cannot contain \\ / : * ? \" < > |") + return name + + +def _from_legacy(entry: Dict[str, Any]) -> ColourScale: + """Original custom_cmaps.json entries: 'bins' (lower bounds) or 'interpolation' (stops).""" + name, colours, positions = entry["name"], list(entry["colours"]), [float(p) for p in entry["positions"]] + if entry["type"] == "bins": + pairs = sorted(zip(positions, colours)) + return ThresholdScale(name, [p for p, _ in pairs[1:]], [c for _, c in pairs]) + return GradientScale(name, list(zip(positions, colours)), relative=False) + + +def _hex(colour: str) -> str: + try: + return to_hex(to_rgba(colour)) + except ValueError as e: + raise ColourScaleError(f"'{colour}' is not a colour.") from e + + +def _to_float(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return math.nan + + +def _fmt(value: float) -> str: + return f"{value:.4g}" + + +register_palette_cmaps() diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 932a18f..9379fe3 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -21,6 +21,7 @@ PathSelector, RadioSelection, ScrollableListBox, + SearchableListBox, ValidatedEntryBox, ) from python_toolkit.bhom_tkinter.windows import ( @@ -139,6 +140,17 @@ def run_widget_gallery(auto_close_ms: int | None = None) -> None: build_options=PackingOptions(fill="x", pady=6), ).build() + SearchableListBox( + parent, + item_title="SearchableListBox", + helper_text="Type to filter; selections are kept while filtering", + items=[f"{city}_TRY.epw" for city in ["London", "Birmingham", "Manchester", "Glasgow", "Belfast", "Cardiff"]], + height=4, + show_selection_controls=True, + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ).build() + PathSelector( parent, item_title="PathSelector", @@ -348,3 +360,104 @@ def test_modal_window_is_themed_toplevel(): modal.close() host.destroy_root() + +def test_searchable_list_box(): + """Selections survive filtering; select/deselect all act on visible items; single mode keeps one item.""" + root = BHoMBaseWindow(title="SearchableListBox test") + changes = [] + items = ["London_TRY.epw", "London_DSY1.epw", "Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + box = SearchableListBox(root.content_frame, items=items, show_selection_controls=True, + on_change=lambda value: changes.append(list(value))) + box.build() + + # Build a selection across two searches + box.set_filter("gib") + assert box.get_visible() == ["Gibraltar_TRY.epw"] + box.list_box.set_selections(["Gibraltar_TRY.epw"]) + box._on_list_change() + box.set_filter("belfast") + box.list_box.set_selections(["Belfast_TRY.epw"]) + box._on_list_change() + assert box.get() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + assert changes[-1] == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # Clearing the filter shows the hidden selections as selected + box.clear_filter() + assert box.get_visible() == items + assert box.list_box.get_selection() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # Select/deselect all only affect visible items + box.set_filter("london") + box.select_all() + assert box.get() == items + box.deselect_all() + assert box.get() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # set / clear / set_options + box.set(["London_TRY.epw"]) + assert box.get() == ["London_TRY.epw"] + box.clear() + assert box.get() == [] + box.set(items) + box.set_options(["London_TRY.epw", "Cardiff_TRY.epw"]) + assert box.get() == ["London_TRY.epw"] + + # keep_hidden_selection=False drops selections the filter hides + strict = SearchableListBox(root.content_frame, items=items, keep_hidden_selection=False) + strict.build() + strict.set(["London_TRY.epw", "Belfast_TRY.epw"]) + strict.set_filter("london") + assert strict.get() == ["London_TRY.epw"] + + # Single select keeps at most one item, and a hidden choice survives filtering + single = SearchableListBox(root.content_frame, items=items, selectmode=tk.SINGLE) + single.build() + single.set(items) + assert single.get() == ["London_TRY.epw"] + single.set_filter("gib") + assert single.get() == ["London_TRY.epw"] + single.list_box.set_selections(["Gibraltar_TRY.epw"]) + single._on_list_change() + assert single.get() == ["Gibraltar_TRY.epw"] + + root.destroy_root() + + +def test_searchable_list_box_fuzzy_matching(): + """Default scorer: substrings and typos match, best first; min_score and a custom scorer can be supplied.""" + from functools import partial + from python_toolkit.bhom_tkinter.widgets.searchable_list_box import default_scorer + + assert default_scorer("lon", "London_TRY.epw") is not None + assert default_scorer("birmigham", "Birmingham_TRY.epw") is not None # typo + assert default_scorer("xyzq", "London_TRY.epw") is None + assert default_scorer("london", "LOS-ANGELES-DOWNTOWN.epw") is None + assert default_scorer("birmigham", "Birmingham_TRY.epw", min_score=95) is None # stricter threshold + + root = BHoMBaseWindow(title="SearchableListBox fuzzy test") + items = ["Country_Club.epw", "London_TRY.epw", "Birmingham_TRY.epw", "Gibraltar_TRY.epw"] + + fuzzy = SearchableListBox(root.content_frame, items=items) + fuzzy.build() + fuzzy.set_filter("birmigham") + assert fuzzy.get_visible() == ["Birmingham_TRY.epw"] + fuzzy.set_filter("try") + assert set(fuzzy.get_visible()) == set(items) # "try" is a substring of every item; ties keep original order + fuzzy.set_filter("xyzq") + assert fuzzy.get_visible() == [] + + # min_score is an argument of the scorer + strict = SearchableListBox(root.content_frame, items=items, scorer=partial(default_scorer, min_score=95)) + strict.build() + strict.set_filter("birmigham") + assert strict.get_visible() == [] + + # A custom scorer replaces the default + only_london = SearchableListBox( + root.content_frame, items=items, scorer=lambda query, item: 1.0 if item.startswith("London") else None) + only_london.build() + only_london.set_filter("anything") + assert only_london.get_visible() == ["London_TRY.epw"] + + root.destroy_root() diff --git a/Python_Engine/Python/tests/test_colour_scale_builder.py b/Python_Engine/Python/tests/test_colour_scale_builder.py new file mode 100644 index 0000000..7fe8ab1 --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scale_builder.py @@ -0,0 +1,123 @@ +import numpy as np +import pytest + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.custom_cmap_builder import CmapBuilder +from python_toolkit.bhom_tkinter.windows import ColourScaleBuilder +from python_toolkit.bhom_tkinter.windows.colour_scale_builder import GRADIENT, preview_figure +from python_toolkit.plot.colour_scales import ColourScaleLibrary, DataSummary, GradientScale, ThresholdScale + + +@pytest.fixture +def host(): + root = BHoMBaseWindow(title="Builder host", show_submit=False, show_close=False) + root.withdraw() + yield root + root.destroy_root() + + +@pytest.fixture(autouse=True) +def warnings_shown(monkeypatch): + """Record warning pop-ups instead of blocking on them.""" + from python_toolkit.bhom_tkinter.windows import colour_scale_builder + + shown = [] + + class _Box: + def __init__(self, title, errors, parent=None): + shown.append(errors) + + def build(self): + pass + + def run(self): + pass + + monkeypatch.setattr(colour_scale_builder, "WarningBox", _Box) + return shown + + +@pytest.fixture +def library(tmp_path): + return ColourScaleLibrary(tmp_path / "scales.json") + + +def test_builder_suggests_bands_from_data(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), units="degC", library=library) + scale = builder.current_scale() + assert isinstance(scale, ThresholdScale) + assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.units == "degC" + assert "200 values from 16 to 30 degC" in builder.data_text.get() + + +def test_builder_saves_labels_into_a_set(host, library): + saved = [] + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library, on_save=saved.append) + builder.name.set("Comfort") + builder.group.value_var.set("Overheating") # typed into the editable set box + builder._band_rows[0]["label"].set("Cold") + builder._save() + reopened = ColourScaleLibrary(library.path) + scale = reopened.get("Comfort") + assert saved and saved[0].name == "Comfort" + assert scale.group == "Overheating" and scale.band_labels()[0] == "Cold" + + +def test_builder_reports_bad_thresholds_without_saving(host, library, warnings_shown): + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + builder._band_rows[0]["upper"].set("warm") + builder._save() + assert builder.saved_scale is None and "not a number" in builder.status.get() + assert warnings_shown and "not a number" in warnings_shown[0][0] # shown in a pop-up, not only in the window + assert len(library) == 0 + + +def test_builder_name_clash_is_explained_and_default_names_are_free(host, library, warnings_shown): + library.save(ThresholdScale("New scale", [1, 2], ["#000000", "#808080", "#ffffff"])) + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + assert builder.name.get() == "New scale 2" + builder.name.set("New scale") + builder._save() + assert builder.saved_scale is None and "another name" in warnings_shown[-1][0] + builder.name.set("Renamed") + builder._update_preview() # what the name change schedules + assert builder.status.get() == "" # the old error does not linger + + +def test_builder_gradient_and_edit(host, library): + builder = ColourScaleBuilder(host, data=[0, 1, 2], library=library) + builder.kind.set(GRADIENT) + builder._on_kind_changed() + assert isinstance(builder.current_scale(), GradientScale) + existing = ThresholdScale("Existing", [1, 2], ["#000000", "#808080", "#ffffff"], labels=["a", "b", "c"]) + editor = ColourScaleBuilder(host, scale=existing, library=library) + assert editor.current_scale().band_labels() == ["a", "b", "c"] + + +def test_preview_with_and_without_data(): + scale = ThresholdScale("T", [20, 25], ["#0000ff", "#00ff00", "#ff0000"]) + values = np.linspace(16, 30, 50) + assert preview_figure(scale, values, DataSummary.from_data(values)).axes + assert preview_figure(scale, np.array([]), None).axes + + +def test_cmap_builder_reports_saved_scale_as_colormap(host): + received = [] + widget = CmapBuilder(host.content_frame, command=lambda cmap, bounds: received.append((cmap, bounds))) + widget._on_saved(ThresholdScale("T", [20, 25], ["#0000ff", "#00ff00", "#ff0000"])) + cmap, bounds = received[0] + assert widget.get() is cmap and widget.get_bounds() == bounds and bounds[0] < 20 < 25 < bounds[1] + assert widget.get_scale().name == "T" + + +def test_builder_starts_from_a_colour_map_and_recolours(host, library): + from python_toolkit.plot.colour_scales import palette_colours + + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 50), library=library, palette="magma") + assert builder.palette.get() == "magma" + assert [r["colour"].get() for r in builder._band_rows] == palette_colours("magma", 5) + builder.palette.set("Traffic light") + builder._recolour() + scale = builder.current_scale() + assert scale.colours == palette_colours("Traffic light", 5) and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) diff --git a/Python_Engine/Python/tests/test_colour_scales.py b/Python_Engine/Python/tests/test_colour_scales.py new file mode 100644 index 0000000..64a9f60 --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -0,0 +1,207 @@ +import json + +import numpy as np +import pytest +from matplotlib.colors import to_hex + +from python_toolkit.bhom import custom_cmaps +from python_toolkit.plot.colour_scales import ( + ColourScale, + ColourScaleError, + ColourScaleLibrary, + DataSummary, + GradientScale, + PALETTES, + ThresholdScale, + palette_colours, + suggest_thresholds, +) + +COMFORT = dict(thresholds=[18, 21, 24], colours=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC", group="TM59") + + +def colour_of(mapping, value): + return to_hex(mapping.cmap(mapping.norm(value))) + + +# -- thresholds hold in data units, whatever data is passed in ---------------- + +@pytest.mark.parametrize("data", [[19, 20, 22], np.linspace(-50, 200, 30), [23.9], None]) +def test_bands_fixed_for_any_data(data): + mapping = ThresholdScale("Comfort", **COMFORT).mapping(data) + assert colour_of(mapping, 17.99) == "#0000ff" + assert colour_of(mapping, 18.0) == "#00ffff" # boundary belongs to the band above + assert colour_of(mapping, 22.0) == "#00ff00" + assert colour_of(mapping, 24.0) == "#ff0000" + assert colour_of(mapping, -1000) == "#0000ff" and colour_of(mapping, 1000) == "#ff0000" + assert mapping.band_labels == ["Cold", "Cool", "Comfortable", "Hot"] + + +def test_auto_band_labels_and_band_of(): + scale = ThresholdScale("Plain", [10, 20], ["red", "green", "blue"]) + assert scale.band_labels() == ["< 10", "10 - 20", ">= 20"] + assert [scale.band_of(v) for v in (5, 10, 19.9, 20, 99)] == [0, 1, 1, 2, 2] + partly = ThresholdScale("Partly", [10, 20], ["red", "green", "blue"], labels=["Low", None, ""]) + assert partly.band_labels() == ["Low", "10 - 20", ">= 20"] + + +@pytest.mark.parametrize("kwargs,message", [ + (dict(thresholds=[], colours=["red"]), "at least one"), + (dict(thresholds=[2, 1], colours=["r", "g", "b"]), "increasing"), + (dict(thresholds=[1, 2], colours=["red", "green"]), "3 bands"), + (dict(thresholds=[1], colours=["red", "green"], labels=["a"]), "one label per band"), + (dict(thresholds=[1], colours=["red", "notacolour"]), "not a colour"), +]) +def test_threshold_validation(kwargs, message): + with pytest.raises(ColourScaleError, match=message): + ThresholdScale("Bad", **kwargs) + + +def test_gradient_relative_stretches_to_data_absolute_does_not(): + relative = GradientScale.from_palette("Rel", "Traffic light") + assert relative.mapping([10, 30]).norm.vmin == 10 and relative.mapping([10, 30]).norm.vmax == 30 + absolute = GradientScale("Abs", [(0, "blue"), (100, "red")], relative=False) + assert (absolute.mapping([10, 30]).norm.vmin, absolute.mapping([10, 30]).norm.vmax) == (0, 100) + stepped = GradientScale("Steps", [(0, "blue"), (1, "red")], bins=4).mapping([0, 8]) + assert len({colour_of(stepped, v) for v in np.linspace(0, 8, 50)}) == 4 + with pytest.raises(ColourScaleError): + GradientScale("Bad", [(0, "red"), (2, "blue")], relative=True) + + +def test_mapping_colours_for_handles_missing_values(): + rgba = ThresholdScale("C", **COMFORT).mapping().colours_for([17, None, "x", 30]) + assert np.isnan(rgba[1]).all() and np.isnan(rgba[2]).all() + assert to_hex(rgba[3]) == "#ff0000" + + +# -- data passed in ---------------------------------------------------------- + +def test_data_summary_ignores_unreadable_values(): + summary = DataSummary.from_data([1, 2, None, "n/a", float("nan"), 3, 10]) + assert summary.count == 4 and summary.minimum == 1 and summary.maximum == 10 + assert summary.histogram[0].sum() == 4 + with pytest.raises(ColourScaleError): + DataSummary.from_data([None, "x"]) + + +def test_suggest_thresholds_methods(): + data = np.concatenate([np.full(90, 20.0), np.linspace(20, 30, 10)]) + assert suggest_thresholds([0, 100], 4, "equal") == [25, 50, 75] + assert suggest_thresholds([0.3, 9.7], 5, "nice") == [2, 4, 6, 8] + with pytest.raises(ColourScaleError, match="repeated values"): + suggest_thresholds(data, 4, "quantile") + q = suggest_thresholds(np.arange(100), 4, "quantile") + assert len(q) == 3 and q[0] < q[1] < q[2] + + +@pytest.mark.parametrize("data,bands,expected", [ + ([16, 30], 5, [17.5, 20, 22.5, 25]), + ([8, 24], 4, [10, 15, 20]), + ([0, 95], 4, [25, 50, 75]), + ([1000, 250000], 5, [50000, 100000, 150000, 200000]), +]) +def test_nice_thresholds_fall_inside_the_data(data, bands, expected): + thresholds = suggest_thresholds(data, bands, "nice") + assert thresholds == pytest.approx(expected) + assert all(min(data) < t < max(data) for t in thresholds) + + +def test_threshold_scale_from_data(): + scale = ThresholdScale.from_data("Loads", np.linspace(0, 95, 50), bands=4, method="nice", + palette="Okabe-Ito (colour-blind safe)", group="Loads") + assert scale.thresholds == [25, 50, 75] and scale.bands == 4 and scale.group == "Loads" + assert len(palette_colours("Traffic light", 7)) == 7 + + +# -- persistence ------------------------------------------------------------- + +def test_round_trip_through_dict(): + for scale in (ThresholdScale("Comfort", **COMFORT), GradientScale.from_palette("G", "LTC Maya", bins=6)): + rebuilt = ColourScale.from_dict(json.loads(json.dumps(scale.to_dict()))) + assert type(rebuilt) is type(scale) and rebuilt.to_dict() == scale.to_dict() + + +def test_library_sets(tmp_path): + library = ColourScaleLibrary(tmp_path / "scales.json") + library.save(ThresholdScale("Comfort", **COMFORT)) + library.save(GradientScale.from_palette("Heat", "LTC Heatmap", group="Loads")) + library.save(ThresholdScale("Overheating", [26, 28], ["green", "orange", "red"], group="TM59")) + reopened = ColourScaleLibrary(tmp_path / "scales.json") + assert reopened.groups() == ["TM59", "Loads"] + assert reopened.names("TM59") == ["Comfort", "Overheating"] + assert reopened.get("Comfort").labels[-1] == "Hot" + with pytest.raises(ColourScaleError, match="already exists"): + reopened.save(ThresholdScale("Comfort", **COMFORT)) + reopened.rename_group("TM59", "Overheating checks") + reopened.delete_group("Loads") + final = ColourScaleLibrary(tmp_path / "scales.json") + assert final.groups() == ["Overheating checks"] and len(final) == 2 + + +def test_library_reads_original_format(tmp_path): + path = tmp_path / "old.json" + path.write_text(json.dumps([ + {"name": "OldBins", "type": "bins", "vmin": 0, "vmax": 30, + "colours": ["#0000ff", "#00ff00", "#ff0000"], "positions": [0, 10, 20]}, + {"name": "OldGrad", "type": "interpolation", "vmin": 0, "vmax": 5, + "colours": ["#000000", "#ffffff"], "positions": [0, 5]}, + ]), encoding="utf-8") + library = ColourScaleLibrary(path) + bins = library.get("OldBins") + assert isinstance(bins, ThresholdScale) and bins.thresholds == [10, 20] + assert colour_of(bins.mapping(), 25) == "#ff0000" + grad = library.get("OldGrad") + assert isinstance(grad, GradientScale) and grad.mapping().norm.vmax == 5 + + +def test_unreadable_entries_reported_and_kept(tmp_path): + path = tmp_path / "mixed.json" + path.write_text(json.dumps({"version": 2, "scales": [ + ThresholdScale("Good", [1], ["red", "blue"]).to_dict(), + {"type": "thresholds", "name": "Broken", "thresholds": [2, 1], "colours": ["a"]}, + ]}), encoding="utf-8") + library = ColourScaleLibrary(path) + assert library.names() == ["Good"] and "Broken" in library.problems[0] + library.save(ThresholdScale("New", [5], ["red", "blue"])) + names_on_disk = [e["name"] for e in json.loads(path.read_text(encoding="utf-8"))["scales"]] + assert "Broken" in names_on_disk # never silently deleted + + +def test_corrupt_library_raises(tmp_path): + path = tmp_path / "bad.json" + path.write_text("{not json", encoding="utf-8") + with pytest.raises(ColourScaleError, match="Cannot read"): + ColourScaleLibrary(path) + + +def test_custom_cmaps_wrappers_still_work(tmp_path, monkeypatch): + from python_toolkit.plot import colour_scales + monkeypatch.setattr(colour_scales, "LIBRARY_FILE", tmp_path / "c.json") + custom_cmaps.save_custom_cmap("Bins", "bins", ["#0000ff", "#ff0000"], [0, 50], 0, 100) + custom_cmaps.save_custom_cmap("Grad", "interpolation", ["#000000", "#ffffff"], [0, 10], 0, 10) + assert custom_cmaps.list_custom_cmap_names() == ["Bins", "Grad"] + loaded = {name: (cmap, bounds) for name, cmap, bounds in custom_cmaps.load_custom_cmaps()} + assert set(loaded) == {"Bins", "Grad"} and loaded["Grad"][1] == (0, 10) + assert custom_cmaps.delete_custom_cmap("Bins") and not custom_cmaps.delete_custom_cmap("Bins") + custom_cmaps.clear_custom_cmaps() + assert custom_cmaps.list_custom_cmap_names() == [] + + +def test_palettes_are_valid(): + for name, colours in PALETTES.items(): + assert len(colours) >= 3, name + GradientScale.from_palette(name, name) + + +def test_palettes_registered_as_colour_maps(): + from matplotlib import colormaps + from python_toolkit.plot.colour_scales import PALETTE_CMAPS, palette_names + + for name in PALETTE_CMAPS.values(): + assert name in colormaps and f"{name}_r" in colormaps + names = palette_names() + assert names[:len(PALETTES)] == list(PALETTES) and "magma" in names and "okabe_ito" not in names + assert palette_colours("magma", 3)[0] == "#000004" + with pytest.raises(ColourScaleError, match="Unknown palette"): + palette_colours("no such map", 3)