From 18245cb83c7da8b3b8948cfc1435f221468a32ac Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 20 May 2026 09:53:48 +0100 Subject: [PATCH 1/5] small fixes --- .../bhom_tkinter/widgets/cmap_selector.py | 18 +++++++++--------- .../widgets/custom_cmap_builder.py | 11 ++++++----- 2 files changed, 15 insertions(+), 14 deletions(-) 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 3a8c31e..fcaa30d 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 @@ -101,18 +101,18 @@ def __init__( plot_size: `(width, height)` in pixels for the preview swatch. 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 + ("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 + 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 + 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``. + preview swatch. Default 4. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -194,7 +194,7 @@ def __init__( self._custom_cmaps[_label] = _cmap current_colormaps.insert(0, _label) except Exception: - pass + CONSOLE_LOGGER.warning("Failed to load custom colormaps from file.", exc_info=True) self._populate_cmap_list(current_colormaps) @@ -238,7 +238,7 @@ def _reload_custom_cmaps(self) -> None: current.insert(0, label) added = True except Exception: - pass + 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]) 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 index 7063db5..dd483df 100644 --- 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 @@ -13,6 +13,7 @@ from python_toolkit.bhom_tkinter.widgets.slider import Slider from python_toolkit.plot.cmap_sample import cmap_sample_plot from python_toolkit.bhom.custom_cmaps import save_custom_cmap, delete_custom_cmap, clear_custom_cmaps, list_custom_cmap_names +from python_toolkit.bhom.analytics import CONSOLE_LOGGER _DEFAULT_COLOURS = [ "#FF0000", "#FFD000", "#09FF00", "#00A2FF", @@ -163,7 +164,7 @@ def _clear_all_saved(self) -> None: def _open_builder(self) -> None: """Open the cmap builder popup.""" - if self._cmap_builder_window and self._cmap_builder_window.winfo_exists(): + if self._cmap_builder_window is not None and self._cmap_builder_window.winfo_exists(): self._cmap_builder_window.focus_force() return @@ -242,7 +243,7 @@ def _open_builder(self) -> None: ) self._stops_canvas.configure(yscrollcommand=self._stops_scrollbar.set) self._stops_scrollbar.pack(side="right", fill="y") - self._stops_canvas.pack(side="left", fill="x") # NO expand, NO fill="both" + self._stops_canvas.pack(side="left", fill="x", expand=True) # expand claims full cavity width self._colour_rows_frame = ttk.Frame(self._stops_canvas) self._canvas_window = self._stops_canvas.create_window( @@ -538,13 +539,13 @@ def _apply_cmap(self) -> None: lower_bounds[0] = vmin save_custom_cmap(name, "bins", colours, lower_bounds, vmin, vmax) except Exception: - pass + CONSOLE_LOGGER.warning("Failed to persist colormap to file.", exc_info=True) - if self.command: + if self.command is not None: self.command(cmap, (vmin, vmax)) self._fire_on_change(cmap) - if self._cmap_builder_window: + if self._cmap_builder_window is not None: self._cmap_builder_window.close() def _close_builder(self) -> None: From a305356de0e46e659ea5a8380f2881f5b4ae1c8b Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 3 Aug 2026 12:59:26 +0100 Subject: [PATCH 2/5] test --- .../src/python_toolkit/bhom/__init__.py | 7 ++- .../bhom_tkinter/widgets/cmap_selector.py | 2 +- .../widgets/custom_cmap_builder.py | 62 ++++++++++--------- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py index bc0efd2..7bbbbbe 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/__init__.py @@ -8,7 +8,12 @@ BHOM_LOG_FOLDER = Path(path.expandvars("%PROGRAMDATA%/BHoM/Logs")) TOOLKIT_NAME = "Python_Toolkit" -BHOM_VERSION = importlib.metadata.version("python_toolkit") + +# Try to get package version, fallback to unknown if not installed +try: + BHOM_VERSION = importlib.metadata.version("python_toolkit") +except importlib.metadata.PackageNotFoundError: + BHOM_VERSION = "unknown" #Environment variable that if set disables BHoM analytics logging. DISABLE_ANALYTICS = os.environ.get("DISABLE_BHOM_ANALYTICS", None) 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 fcaa30d..7b00da9 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,6 +9,7 @@ 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 @@ -218,7 +219,6 @@ def __init__( def _open_cmap_builder(self) -> None: """Open the CmapBuilder popup. The widget itself is not displayed.""" - from python_toolkit.bhom_tkinter.widgets.custom_cmap_builder import CmapBuilder builder = CmapBuilder(self, command=self._on_custom_cmap_applied) builder._open_builder() 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 index dd483df..84eb2ed 100644 --- 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 @@ -6,10 +6,14 @@ import matplotlib.pyplot as plt from matplotlib.colors import LinearSegmentedColormap, Colormap, to_rgba -from python_toolkit.bhom_tkinter.widgets import ( - Button, ColourPicker, Spinbox, FigureContainer, RadioSelection, PackingOptions, -) -from python_toolkit.bhom_tkinter import BHoMBasePopup, BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.colour_picker import ColourPicker +from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox +from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer +from python_toolkit.bhom_tkinter.widgets.radio_selection import RadioSelection +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.bhom_base_popup import BHoMBasePopup from python_toolkit.bhom_tkinter.widgets.slider import Slider from python_toolkit.plot.cmap_sample import cmap_sample_plot from python_toolkit.bhom.custom_cmaps import save_custom_cmap, delete_custom_cmap, clear_custom_cmaps, list_custom_cmap_names @@ -20,7 +24,6 @@ "#9400FF", "#FF69B4", "#00FFFF", "#FF8C00", ] - class CmapBuilder(BHoMBaseWidget): """A cmap builder popup. Builds and previews custom linear or binned colormaps.""" @@ -59,14 +62,19 @@ def __init__( # ------------------------------------------------------------------ def _validate_name(self, value: str) -> tuple[bool, str]: - if not value.strip(): + """Validate a name for the custom colormap, checking for emptiness, invalid characters, and duplicates.""" + + if value.strip() is None or value.strip() == "": return False, "Name cannot be empty." + invalid_chars = set(r'\/:*?"<>|') if any(c in invalid_chars for c in value): return False, "Name contains invalid characters." + # Block clashes with matplotlib's registered colormaps. if value in matplotlib.colormaps: return False, f"'{value}' is already a matplotlib colormap name." + # Block duplicate saved names. if value in list_custom_cmap_names(): return False, f"'{value}' already exists in saved collections. Delete it first." @@ -498,16 +506,13 @@ def _update_cmap_preview(self) -> None: """Regenerate the preview figure from the current row state.""" if not hasattr(self, "_preview_container"): return - try: - cmap = self._build_current_cmap() - if cmap is None: - return - vmin, vmax = self._get_range() - fig = cmap_sample_plot(cmap, bounds=(vmin, vmax), figsize=(6, 0.5), bins=256) - self._preview_container.embed_figure(fig) - plt.close(fig) - except Exception: - pass + cmap = self._build_current_cmap() + if cmap is None: + return + vmin, vmax = self._get_range() + fig = cmap_sample_plot(cmap, bounds=(vmin, vmax), figsize=(6, 0.5), bins=256) + self._preview_container.embed_figure(fig) + plt.close(fig) def _apply_cmap(self) -> None: """Validate, build the colormap, fire the command, and close the popup.""" @@ -526,20 +531,17 @@ def _apply_cmap(self) -> None: self._last_cmap = cmap self._last_bounds = (vmin, vmax) - # Persist the colormap definition to the user file. - try: - colours = [r["colour_picker"].get() for r in self._colour_rows] - if self._is_interpolation(): - positions = [r["pos_var"].get() for r in self._colour_rows] - positions[0] = vmin - positions[-1] = vmax - save_custom_cmap(name, "interpolation", colours, positions, vmin, vmax) - else: - lower_bounds = [r["pos_var"].get() for r in self._colour_rows] - lower_bounds[0] = vmin - save_custom_cmap(name, "bins", colours, lower_bounds, vmin, vmax) - except Exception: - CONSOLE_LOGGER.warning("Failed to persist colormap to file.", exc_info=True) + colours = [r["colour_picker"].get() for r in self._colour_rows] + if self._is_interpolation(): + positions = [r["pos_var"].get() for r in self._colour_rows] + positions[0] = vmin + positions[-1] = vmax + save_custom_cmap(name, "interpolation", colours, positions, vmin, vmax) + else: + lower_bounds = [r["pos_var"].get() for r in self._colour_rows] + lower_bounds[0] = vmin + save_custom_cmap(name, "bins", colours, lower_bounds, vmin, vmax) + if self.command is not None: self.command(cmap, (vmin, vmax)) From 8971b9c801be66046c709966051b3c66b3de03d2 Mon Sep 17 00:00:00 2001 From: Wale2480 Date: Tue, 4 Aug 2026 14:51:09 +0100 Subject: [PATCH 3/5] colour map generator --- .../bhom_tkinter/widgets/WA_cmap.py | 214 ++++++++++++++++++ .../bhom_tkinter/widgets/test.py | 136 +++++++++++ 2 files changed, 350 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/WA_cmap.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/test.py 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..369dc60 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/WA_cmap.py @@ -0,0 +1,214 @@ +import tkinter as tk +from tkinter import 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), + ] + 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) + 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)) + 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") + # --- 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() + # --- 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/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 From a2da3d108baa56b98ee5132402a8d18eb3c8925d Mon Sep 17 00:00:00 2001 From: Wale2480 Date: Tue, 4 Aug 2026 15:01:51 +0100 Subject: [PATCH 4/5] Update WA_cmap.py Added accessible colours --- .../bhom_tkinter/widgets/WA_cmap.py | 102 +++++++++++++++++- 1 file changed, 97 insertions(+), 5 deletions(-) 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 index 369dc60..a2f08d9 100644 --- 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 @@ -1,6 +1,8 @@ import tkinter as tk -from tkinter import colorchooser, filedialog, messagebox +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).""" @@ -46,6 +48,17 @@ def __init__(self): 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): @@ -65,6 +78,17 @@ def setup_ui(self): 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)) @@ -111,6 +135,13 @@ def build_control_rows(self): 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"]) @@ -120,6 +151,57 @@ def build_control_rows(self): 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 @@ -150,6 +232,19 @@ def mirror_gradient(self): 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"] @@ -208,7 +303,4 @@ def export_image(self): messagebox.showerror("Export Error", f"Failed to save image:\n{e}") if __name__ == "__main__": app = GradientDesigner() - app.mainloop() - - - \ No newline at end of file + app.mainloop() \ No newline at end of file From 9878f273092c13d83d6d3746fe2f4b47d6a9f413 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 28 Oct 2026 11:13:42 +0000 Subject: [PATCH 5/5] Merge remote-tracking branch 'origin/develop' into Python_Toolkit-#217-CustomCmapGenerator --- .../bhom_tkinter/widgets/__init__.py | 2 + .../widgets/custom_cmap_builder.py | 562 +----------------- .../widgets/drop_down_selection.py | 1 + .../widgets/searchable_list_box.py | 291 +++++++++ .../bhom_tkinter/windows/__init__.py | 2 + .../windows/colour_scale_builder.py | 358 +++++++++++ .../src/python_toolkit/plot/colour_scales.py | 77 ++- .../Python/tests/test_bhom_tkinter_ui.py | 113 ++++ .../Python/tests/test_colour_scale_builder.py | 123 ++++ .../Python/tests/test_colour_scales.py | 25 + 10 files changed, 1007 insertions(+), 547 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py create mode 100644 Python_Engine/Python/tests/test_colour_scale_builder.py 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 41dd9de..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,6 +8,7 @@ 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 @@ -29,6 +30,7 @@ "DropDownSelection", "FigureContainer", "ScrollableListBox", + "SearchableListBox", "PathSelector", "RadioSelection", "ValidatedEntryBox", 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 index 84eb2ed..6119e97 100644 --- 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 @@ -1,54 +1,42 @@ -import tkinter as tk from tkinter import ttk -from typing import Callable, Optional, Literal +from typing import Callable, Iterable, Literal, Optional -import matplotlib -import matplotlib.pyplot as plt -from matplotlib.colors import LinearSegmentedColormap, Colormap, to_rgba +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.colour_picker import ColourPicker -from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox -from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer -from python_toolkit.bhom_tkinter.widgets.radio_selection import RadioSelection -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.bhom_base_popup import BHoMBasePopup -from python_toolkit.bhom_tkinter.widgets.slider import Slider -from python_toolkit.plot.cmap_sample import cmap_sample_plot -from python_toolkit.bhom.custom_cmaps import save_custom_cmap, delete_custom_cmap, clear_custom_cmaps, list_custom_cmap_names -from python_toolkit.bhom.analytics import CONSOLE_LOGGER +from python_toolkit.bhom_tkinter.windows.colour_scale_builder import ColourScaleBuilder +from python_toolkit.plot.colour_scales import ColourScale -_DEFAULT_COLOURS = [ - "#FF0000", "#FFD000", "#09FF00", "#00A2FF", - "#9400FF", "#FF69B4", "#00FFFF", "#FF8C00", -] class CmapBuilder(BHoMBaseWidget): - """A cmap builder popup. Builds and previews custom linear or binned colormaps.""" + """A button that opens the ColourScaleBuilder window and reports the saved scale as a colormap.""" def __init__( self, parent: ttk.Frame, - no_colours: int = 4, + data: Optional[Iterable] = None, + units: Optional[str] = None, command: Optional[Callable[[Colormap, tuple], None]] = None, **kwargs, ) -> None: """ Args: parent: Parent widget. - no_colours: Initial number of colour stops (minimum 2). - command: Callback invoked with the built Colormap when Apply is pressed. + 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._cmap_builder_window: Optional[BHoMBasePopup] = None - self.no_colours = max(2, int(no_colours)) + 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._colour_rows: list[dict] = [] self.builder_button = Button( self.content_frame, @@ -57,516 +45,34 @@ def __init__( ) self.builder_button.pack(side="top", fill="x") - # ------------------------------------------------------------------ - # Validators - # ------------------------------------------------------------------ - - def _validate_name(self, value: str) -> tuple[bool, str]: - """Validate a name for the custom colormap, checking for emptiness, invalid characters, and duplicates.""" - - if value.strip() is None or value.strip() == "": - return False, "Name cannot be empty." - - invalid_chars = set(r'\/:*?"<>|') - if any(c in invalid_chars for c in value): - return False, "Name contains invalid characters." - - # Block clashes with matplotlib's registered colormaps. - if value in matplotlib.colormaps: - return False, f"'{value}' is already a matplotlib colormap name." - - # Block duplicate saved names. - if value in list_custom_cmap_names(): - return False, f"'{value}' already exists in saved collections. Delete it first." - return True, "" - - # ------------------------------------------------------------------ - # Manager popup - # ------------------------------------------------------------------ - - def _open_manager(self) -> None: - """Open the saved-colourmap manager popup.""" - self._manager_window = BHoMBasePopup( - self, - title="Manage Saved Colour Collections", - resizable=True, - ) - self._build_manager_contents() - self._manager_window.show() - - def _build_manager_contents(self) -> None: - """Populate (or repopulate) the manager popup body.""" - container = self._manager_window.content_frame - for widget in container.winfo_children(): - widget.destroy() - - names = list_custom_cmap_names() - - if not names: - ttk.Label(container, text="No saved colour collections found.").pack( - padx=12, pady=12 - ) - else: - # Scrollable list — canvas has a fixed height with NO expand=True so it - # cannot grow to match its content (which would kill scrolling). - list_frame = ttk.LabelFrame(container, text="Saved Collections", padding=6) - list_frame.pack(side="top", fill="x", pady=(0, 8)) - - ROW_H = 34 - canvas_h = min(len(names), 8) * ROW_H - canvas = tk.Canvas(list_frame, highlightthickness=0, height=canvas_h) - scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) - canvas.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side="right", fill="y") - canvas.pack(side="left", fill="x") # fill="x" only — height stays fixed - - inner = ttk.Frame(canvas) - win = canvas.create_window((0, 0), window=inner, anchor="nw") - inner.bind( - "", - lambda e: canvas.configure(scrollregion=(0, 0, e.width, e.height)), - ) - canvas.bind( - "", - lambda e: canvas.itemconfigure(win, width=e.width), - ) - canvas.bind( - "", - lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"), - ) - - for name in names: - row = ttk.Frame(inner) - row.pack(fill="x", pady=2) - ttk.Label(row, text=name).pack(side="left", padx=(4, 0)) - Button( - row, - text="Delete", - command=lambda n=name: self._delete_saved(n), - ).pack(side="right", padx=(0, 4)) - - # Bottom buttons - btn_frame = ttk.Frame(container) - btn_frame.pack(side="bottom", fill="x", pady=(4, 0)) - Button( - btn_frame, text="Close", command=self._manager_window.close - ).pack(side="right", padx=(4, 0)) - if names: - Button( - btn_frame, text="Clear All", command=self._clear_all_saved - ).pack(side="right") - - def _delete_saved(self, name: str) -> None: - """Delete a single saved cmap and refresh the manager list.""" - delete_custom_cmap(name) - self._build_manager_contents() - - def _clear_all_saved(self) -> None: - """Clear all saved cmaps and refresh the manager list.""" - clear_custom_cmaps() - self._build_manager_contents() - - # ------------------------------------------------------------------ - # Builder popup - # ------------------------------------------------------------------ - def _open_builder(self) -> None: - """Open the cmap builder popup.""" - if self._cmap_builder_window is not None and self._cmap_builder_window.winfo_exists(): - self._cmap_builder_window.focus_force() - return - - self._cmap_builder_window = BHoMBasePopup( - self, - title="Colour Collection Builder", - on_close=self._close_builder, - resizable=True, - ) - container = self._cmap_builder_window.content_frame - - # --- Settings ------------------------------------------------ - settings_frame = ttk.LabelFrame(container, text="Settings", padding=6) - settings_frame.pack(side="top", fill="x", pady=(0, 8)) - - name_row = ttk.Frame(settings_frame) - name_row.pack(fill="x", pady=2) - ttk.Label(name_row, text="Name:").pack(side="left") - self._name_var = tk.StringVar(value="MyColourCollection") - self._name_entry_widget = ttk.Entry(name_row, textvariable=self._name_var) - self._name_entry_widget.pack(side="left", fill="x", expand=True, padx=(8, 0)) - - # Inline validation status shown below the name field. - self._name_status_var = tk.StringVar(value="") - self._name_status_label = ttk.Label( - settings_frame, textvariable=self._name_status_var, foreground="red" - ) - self._name_status_label.pack(fill="x", padx=(0, 0)) - self._name_var.trace_add("write", lambda *_: self._on_name_changed()) - - colours_row = ttk.Frame(settings_frame) - colours_row.pack(fill="x", pady=2) - ttk.Label(colours_row, text="Colours:").pack(side="left") - self._no_colours_spin = Spinbox( - colours_row, from_=2, to=20, default=self.no_colours, - command=self._on_no_colours_changed, - ) - self._no_colours_spin.pack(side="left", padx=(8, 0)) - - type_row = ttk.Frame(settings_frame) - type_row.pack(fill="x", pady=2) - ttk.Label(type_row, text="Type:").pack(side="left") - self._type_radio = RadioSelection( - type_row, - fields=["Interpolation", "Bins"], - default="Interpolation", - command=self._on_type_changed, - orient="horizontal", - ) - self._type_radio.pack(side="left", padx=(8, 0)) - - range_row = ttk.Frame(settings_frame) - range_row.pack(fill="x", pady=2) - ttk.Label(range_row, text="Range:").pack(side="left") - self._vmin_var = tk.DoubleVar(value=0.0) - self._vmax_var = tk.DoubleVar(value=1.0) - vmin_entry = ttk.Entry(range_row, textvariable=self._vmin_var, width=8) - vmin_entry.pack(side="left", padx=(8, 4)) - ttk.Label(range_row, text="to").pack(side="left") - vmax_entry = ttk.Entry(range_row, textvariable=self._vmax_var, width=8) - vmax_entry.pack(side="left", padx=(4, 0)) - for _entry in (vmin_entry, vmax_entry): - _entry.bind("", lambda _e: self._on_range_changed()) - _entry.bind("", lambda _e: self._on_range_changed()) - - # --- Colour stops (scrollable) -------------------------------- - stops_frame = ttk.LabelFrame(container, text="Colour Stops", padding=6) - stops_frame.pack(side="top", fill="x", pady=(0, 8)) - - # Canvas uses fill="x" with NO expand so its height is always exactly - # what we configure — it cannot grow to match content, which is what - # makes the scrollbar usable. - self._stops_canvas = tk.Canvas(stops_frame, highlightthickness=0, height=160) - self._stops_scrollbar = ttk.Scrollbar( - stops_frame, orient="vertical", command=self._stops_canvas.yview - ) - self._stops_canvas.configure(yscrollcommand=self._stops_scrollbar.set) - self._stops_scrollbar.pack(side="right", fill="y") - self._stops_canvas.pack(side="left", fill="x", expand=True) # expand claims full cavity width - - self._colour_rows_frame = ttk.Frame(self._stops_canvas) - self._canvas_window = self._stops_canvas.create_window( - (0, 0), window=self._colour_rows_frame, anchor="nw" - ) - - # e.height from is the actual settled height — more reliable - # than winfo_reqheight() called asynchronously. - self._stops_content_h: int = 1 - def _on_rows_configure(e): - self._stops_content_h = e.height - self._stops_canvas.configure(scrollregion=(0, 0, e.width, e.height)) - self._colour_rows_frame.bind("", _on_rows_configure) - self._stops_canvas.bind( - "", - lambda e: self._stops_canvas.itemconfigure(self._canvas_window, width=e.width), - ) - self._stops_canvas.bind( - "", - lambda e: self._stops_canvas.yview_scroll(-1 * (e.delta // 120), "units"), - ) - - self._colour_rows = [] - self._build_colour_rows() - - # --- Preview ------------------------------------------------- - preview_frame = ttk.LabelFrame(container, text="Preview", padding=6) - preview_frame.pack(side="top", fill="x", pady=(0, 8)) - self._preview_container = FigureContainer( - preview_frame, - rigid_width=400, - rigid_height=50, - build_options=PackingOptions(side="top", fill="x"), - ) - self._preview_container.build() - - # --- Buttons ------------------------------------------------- - btn_frame = ttk.Frame(container) - btn_frame.pack(side="bottom", fill="x", pady=(4, 0)) - Button(btn_frame, text="Cancel", command=self._cmap_builder_window.close).pack( - side="right", padx=(4, 0) - ) - Button(btn_frame, text="Add to Collection", command=self._apply_cmap, style="Primary.TButton").pack(side="right") - Button(btn_frame, text="Manage Saved", command=self._open_manager).pack(side="left") - - self._update_cmap_preview() - self._cmap_builder_window.show(focus_widget=self._name_entry_widget) - - def _build_colour_rows(self) -> None: - """Destroy and rebuild all colour stop rows from current state.""" - old_colours = [r["colour_picker"].get() for r in self._colour_rows] - old_positions = [r["pos_var"].get() for r in self._colour_rows] - - for widget in self._colour_rows_frame.winfo_children(): - widget.destroy() - self._colour_rows.clear() - - n = max(2, int(self._no_colours_spin.get())) - is_interp = self._is_interpolation() - vmin, vmax = self._get_range() - resolution = max(1e-6, (vmax - vmin) / 100) - - for i in range(n): - colour = ( - old_colours[i] if i < len(old_colours) - else _DEFAULT_COLOURS[i % len(_DEFAULT_COLOURS)] - ) - - if is_interp: - # First and last stops are locked to vmin / vmax - if i == 0: - pos, locked = vmin, True - elif i == n - 1: - pos, locked = vmax, True - else: - raw = float(old_positions[i]) if i < len(old_positions) else vmin + i * (vmax - vmin) / (n - 1) - pos = max(vmin, min(vmax, raw)) - locked = False - else: - # Bins: pos_var stores the LOWER boundary (start) of each bin. - # Bin i covers [lower_i, lower_{i+1}) where lower_{n} = vmax. - # The first row is locked at vmin. - if i == 0: - pos, locked = vmin, True - else: - raw = float(old_positions[i]) if i < len(old_positions) else vmin + i * (vmax - vmin) / n - pos = max(vmin, min(vmax, raw)) - locked = False - - row_frame = ttk.Frame(self._colour_rows_frame) - row_frame.pack(fill="x", pady=1) - - picker = ColourPicker( - row_frame, - default_colour=colour, - swatch_width=32, - swatch_height=20, - command=lambda _c: self._update_cmap_preview(), - ) - picker.pack(side="left", padx=(0, 8)) - - slider = Slider( - row_frame, - from_=vmin, to=vmax, default=pos, - resolution=resolution, - command=lambda _v: self._update_cmap_preview(), - ) - slider.pack(side="left", fill="x", expand=True) - if locked: - slider.disable() - pos_var = slider._var - - self._colour_rows.append({ - "frame": row_frame, - "colour_picker": picker, - "pos_var": pos_var, - "slider": slider, - "locked": locked, - }) - - # Bins: remind the user that the last bin always ends at vmax - if not is_interp: - ttk.Label( - self._colour_rows_frame, - text=f" (Slider = start value of each bin; last bin ends at {vmax:.4g})", - style="Caption.TLabel", - ).pack(anchor="w", pady=(2, 0)) - - # Resize the viewport after the window is on screen. - if hasattr(self, "_stops_canvas"): - self._stops_canvas.after(10, self._fit_stops_canvas) - - def _on_name_changed(self) -> None: - """Real-time validation feedback as the user types a name.""" - if not hasattr(self, "_name_status_var"): - return - name = self._name_var.get().strip() - valid, msg = self._validate_name(name) - self._name_status_var.set("" if valid else msg) - - def _fit_stops_canvas(self) -> None: - """Resize the canvas height to show up to 4 rows; more activates the scrollbar.""" - if not hasattr(self, "_stops_canvas") or not hasattr(self, "_colour_rows_frame"): - return - n = len(self._colour_rows) - content_h = getattr(self, "_stops_content_h", 0) - if content_h <= 1: - self._stops_canvas.after(20, self._fit_stops_canvas) - return - visible_h = content_h if n <= 4 else max(40, round(content_h * 4 / n)) - self._stops_canvas.configure(height=visible_h) - self._stops_canvas.configure( - scrollregion=(0, 0, self._colour_rows_frame.winfo_width(), content_h) - ) - - def _is_interpolation(self) -> bool: - if not hasattr(self, "_type_radio"): - return True - return self._type_radio.get() == "Interpolation" - - def _get_range(self) -> tuple[float, float]: - """Return (vmin, vmax) from the range entries, guaranteed vmin < vmax.""" - try: - vmin = float(self._vmin_var.get()) - except (ValueError, AttributeError, tk.TclError): - vmin = 0.0 - try: - vmax = float(self._vmax_var.get()) - except (ValueError, AttributeError, tk.TclError): - vmax = 1.0 - if vmax <= vmin: - vmax = vmin + 1.0 - return vmin, vmax - - # ------------------------------------------------------------------ - # Change handlers - # ------------------------------------------------------------------ - - def _on_no_colours_changed(self, _value) -> None: - if not hasattr(self, "_colour_rows_frame"): - return - self._build_colour_rows() - self._update_cmap_preview() - - def _on_type_changed(self, _value: str) -> None: - if not hasattr(self, "_colour_rows_frame"): - return - self._build_colour_rows() - self._update_cmap_preview() - - def _on_range_changed(self) -> None: - if not hasattr(self, "_colour_rows_frame"): - return - self._build_colour_rows() - self._update_cmap_preview() - - # ------------------------------------------------------------------ - # Colormap construction - # ------------------------------------------------------------------ - - def _build_current_cmap(self) -> Optional[Colormap]: - """Construct a Colormap from the current row state, always mapped to [0, 1].""" - if not self._colour_rows: - return None - - name = self._name_var.get().strip() if hasattr(self, "_name_var") else "custom" - colours = [r["colour_picker"].get() for r in self._colour_rows] - vmin, vmax = self._get_range() - span = vmax - vmin - - def _norm(val: float) -> float: - """Normalise a user-unit value to [0, 1].""" - return max(0.0, min(1.0, (val - vmin) / span)) - - if self._is_interpolation(): - positions = [r["pos_var"].get() for r in self._colour_rows] - positions[0] = vmin - positions[-1] = vmax - stops = sorted( - ((_norm(p), c) for p, c in zip(positions, colours)), - key=lambda x: x[0], - ) - return LinearSegmentedColormap.from_list(name, stops) - else: - # Bins: pos_var stores the LOWER boundary (start) of each bin. - # Bin i covers [lower_i, lower_{i+1}) where lower_{n} = vmax. - raw_starts = [r["pos_var"].get() for r in self._colour_rows] - raw_starts[0] = vmin # Enforce locked first boundary - - # Sort (lower_boundary, colour) pairs to guarantee monotonicity - paired = sorted(zip(raw_starts, colours), key=lambda x: x[0]) - sorted_starts = [p[0] for p in paired] - sorted_colours = [p[1] for p in paired] - - # Derive end boundaries: bin i ends where bin i+1 starts; last ends at vmax - ends = sorted_starts[1:] + [vmax] - norm_starts = [_norm(s) for s in sorted_starts] - norm_ends_n = [_norm(e) for e in ends] - norm_starts[0] = 0.0 - norm_ends_n[-1] = 1.0 - - # Build piecewise-constant cmap: doubled boundary points create hard edges - stops = [] - for i, colour in enumerate(sorted_colours): - stops.append((norm_starts[i], colour)) - if i < len(sorted_colours) - 1: - stops.append((norm_ends_n[i], colour)) # Hard edge at boundary - stops.append((1.0, sorted_colours[-1])) - return LinearSegmentedColormap.from_list(name, stops) - - def _update_cmap_preview(self) -> None: - """Regenerate the preview figure from the current row state.""" - if not hasattr(self, "_preview_container"): - return - cmap = self._build_current_cmap() - if cmap is None: - return - vmin, vmax = self._get_range() - fig = cmap_sample_plot(cmap, bounds=(vmin, vmax), figsize=(6, 0.5), bins=256) - self._preview_container.embed_figure(fig) - plt.close(fig) - - def _apply_cmap(self) -> None: - """Validate, build the colormap, fire the command, and close the popup.""" - name = self._name_var.get().strip() if hasattr(self, "_name_var") else "" - valid, msg = self._validate_name(name) - if not valid: - if hasattr(self, "_name_status_var"): - self._name_status_var.set(msg) - return - - cmap = self._build_current_cmap() - if cmap is None: - return - - vmin, vmax = self._get_range() - self._last_cmap = cmap - self._last_bounds = (vmin, vmax) - - colours = [r["colour_picker"].get() for r in self._colour_rows] - if self._is_interpolation(): - positions = [r["pos_var"].get() for r in self._colour_rows] - positions[0] = vmin - positions[-1] = vmax - save_custom_cmap(name, "interpolation", colours, positions, vmin, vmax) - else: - lower_bounds = [r["pos_var"].get() for r in self._colour_rows] - lower_bounds[0] = vmin - save_custom_cmap(name, "bins", colours, lower_bounds, vmin, vmax) - + """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(cmap, (vmin, vmax)) - self._fire_on_change(cmap) - - if self._cmap_builder_window is not None: - self._cmap_builder_window.close() - - def _close_builder(self) -> None: - """Reset popup state after the builder has been closed.""" - self._cmap_builder_window = None - self._colour_rows = [] + 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 applied colormap, or None if none has been built.""" + """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 applied colormap.""" + """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 @@ -576,18 +82,12 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal["info", "warni 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() - parent_container = root.content_frame - - colour_picker = CmapBuilder( - parent_container, - alignment="center", - build_options=PackingOptions(padx=10, pady=10), - ) - colour_picker.build() - + 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/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 index e8beedf..3cba15c 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -24,6 +24,7 @@ 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, @@ -52,6 +53,17 @@ "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") @@ -123,27 +135,59 @@ def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List raise ColourScaleError("Too many repeated values for that many quantile bands; use fewer bands.") return unique - step = _nice_step((hi - lo) / bands) - start = math.floor(lo / step) * step - thresholds = [round(start + step * i, 10) for i in range(1, bands)] - while thresholds and thresholds[-1] >= hi and len(thresholds) > 1: # keep the top band populated - thresholds = [round(t - step, 10) for t in thresholds] - return thresholds + 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 _nice_step(raw: float) -> float: - magnitude = 10 ** math.floor(math.log10(raw)) - for factor in (1, 2, 2.5, 5, 10): - if raw <= factor * magnitude: - return factor * magnitude - return 10 * magnitude +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 (interpolated if needed).""" - if palette not in PALETTES: - raise ColourScaleError(f"Unknown palette '{palette}'.") - cmap = LinearSegmentedColormap.from_list(palette, PALETTES[palette]) + """`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)] @@ -528,3 +572,4 @@ 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 index 5af1ff2..64a9f60 100644 --- a/Python_Engine/Python/tests/test_colour_scales.py +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -95,6 +95,18 @@ def test_suggest_thresholds_methods(): 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") @@ -180,3 +192,16 @@ 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)