From f309a15550659dab59642a512be4745e83c09274 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 12 Mar 2026 12:47:03 +0000 Subject: [PATCH 01/13] patched ; wrong tcl path, logo import issue, logged widgets bug --- .../python_toolkit/bhom_tkinter/__init__.py | 9 +++++++++ .../bhom_tkinter/bhom_base_window.py | 13 +++++++++---- .../bhom_tkinter/theming/__init__.py | 1 + .../bhom_tkinter/theming/theme.py | 4 ++-- .../bhom_tkinter/widgets/label.py | 19 +++++++++++++++++-- .../widgets/validated_entry_box.py | 1 + 6 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py index 8a5bc0a..db2b582 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/__init__.py @@ -20,6 +20,13 @@ WarningBox, ) +from .theming import ( + TclTheme, + ThemeManager, + LIGHT, + DARK, +) + __all__ = [ "BHoMBaseWidget", "PackingOptions", @@ -38,4 +45,6 @@ "LandingPage", "ProcessingWindow", "WarningBox", + "TclTheme", + "ThemeManager" ] diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index cae5b6a..6ff40a9 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -24,7 +24,7 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button import python_toolkit -from theming.theme import ThemeManager +from python_toolkit.bhom_tkinter.theming.theme import ThemeManager class BHoMBaseWindow(tk.Tk): """ @@ -49,7 +49,7 @@ def __init__( close_command: Optional[Callable] = None, on_close_window: Optional[Callable] = None, theme_mode:str = "auto", - widgets: List[BHoMBaseWidget] = [], + widgets: Optional[List[BHoMBaseWidget]] = None, top_most: bool = True, buttons_side: Literal["left", "right"] = "right", grid_dimensions: Optional[tuple[int, int]] = None, @@ -91,7 +91,8 @@ def __init__( if self.top_most: self.attributes("-topmost", True) - self.widgets = widgets + # Avoid sharing widget instances across windows/runs. + self.widgets = list(widgets) if widgets is not None else [] # Hide window during setup to prevent flash self.withdraw() @@ -337,9 +338,13 @@ def _build_banner(self, parent: ttk.Frame, title: str, logo_path: Optional[Path] from PIL import Image, ImageTk img = Image.open(logo_path) img.thumbnail((80, 80), Image.Resampling.LANCZOS) - self.logo_image = ImageTk.PhotoImage(img) + # Bind image to this root explicitly to avoid stale image handles + # when previous runs failed and tore down a different Tk interpreter. + self.logo_image = ImageTk.PhotoImage(img, master=self) logo_label = Label(logo_container, image=self.logo_image) logo_label.pack(fill=tk.BOTH, expand=True) + except tk.TclError: + pass except ImportError: pass # PIL not available, skip logo diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py new file mode 100644 index 0000000..db0139e --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/__init__.py @@ -0,0 +1 @@ +from .theme import TclTheme, ThemeManager, LIGHT, DARK \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py index f55990d..4143869 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/theme.py @@ -20,7 +20,7 @@ def __post_init__(self): LIGHT = TclTheme( name="light", - path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "bhom_light_theme.tcl", + path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom_tkinter" / "theming" / "bhom_light_theme.tcl", logo_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "BHoM_Logo.png", icon_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "bhom_icon.png", dark_theme=False @@ -28,7 +28,7 @@ def __post_init__(self): DARK = TclTheme( name="dark", - path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "bhom_dark_theme.tcl", + path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom_tkinter" / "theming" / "bhom_dark_theme.tcl", logo_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "BHoM_Logo.png", icon_path=Path(list(python_toolkit.__path__)[0]).absolute() / "bhom" / "assets" / "bhom_icon.png", dark_theme=True diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py index 66de729..ca4d1c6 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py @@ -1,4 +1,5 @@ from tkinter import ttk +import tkinter as tk from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -65,6 +66,10 @@ def resolve(explicit_value, key: str, default=None): ) self.text = label_options.get("text", "") + image_ref = label_options.get("image") + if image_ref is not None: + # Keep a Python-side reference so Tk image objects are not garbage-collected. + self._image_ref = image_ref style_name = label_options.get("style") if "font" not in label_options and style_name: try: @@ -73,8 +78,18 @@ def resolve(explicit_value, key: str, default=None): label_options["font"] = style_font except Exception: pass - # Create inner ttk.Label with the collected options - self.label = ttk.Label(self.content_frame, **label_options) + # Create inner ttk.Label with the collected options. + # If a stale Tk image handle is passed from a previous failed run, + # recover by dropping the image and creating a text-only label. + try: + self.label = ttk.Label(self.content_frame, **label_options) + except tk.TclError as ex: + message = str(ex).lower() + if "image" in label_options and "image" in message and "doesn't exist" in message: + safe_options = {k: v for k, v in label_options.items() if k != "image"} + self.label = ttk.Label(self.content_frame, **safe_options) + else: + raise self.align_child_text(self.label) # Allow the inner label to expand horizontally so parent frames # using grid/pack with `fill='x'` will cause this label to fill diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py index c0f607a..dcea02e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -22,6 +22,7 @@ def __init__( self, parent, variable: Optional[tk.StringVar] = None, + default_value: Optional[Union[str, int, float]] = None, width: int = 15, value_type: type = str, min_value: Optional[Union[int, float]] = None, From 7321d7d4dc0c271d277bb729cb6e3d0637fa23e2 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Tue, 3 Mar 2026 14:07:46 +0000 Subject: [PATCH 02/13] move bhom.mplstyle to parent directory, and use a style context to plot with matplotlib --- .../python_toolkit/{bhom => }/bhom.mplstyle | 0 .../Python/src/python_toolkit/plot/diurnal.py | 415 +++++++++--------- .../Python/src/python_toolkit/plot/heatmap.py | 101 ++--- .../src/python_toolkit/plot/histogram.py | 118 ++--- .../python_toolkit/plot/spatial_heatmap.py | 130 +++--- .../src/python_toolkit/plot/timeseries.py | 39 +- 6 files changed, 417 insertions(+), 386 deletions(-) rename Python_Engine/Python/src/python_toolkit/{bhom => }/bhom.mplstyle (100%) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/bhom.mplstyle b/Python_Engine/Python/src/python_toolkit/bhom.mplstyle similarity index 100% rename from Python_Engine/Python/src/python_toolkit/bhom/bhom.mplstyle rename to Python_Engine/Python/src/python_toolkit/bhom.mplstyle diff --git a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py index 17b41f0..9eb2c6f 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/diurnal.py +++ b/Python_Engine/Python/src/python_toolkit/plot/diurnal.py @@ -35,187 +35,191 @@ def diurnal( Additional keyword arguments to pass to the matplotlib plotting function. legend (bool, optional): If True, show the legend. Defaults to True. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: A matplotlib Axes object. """ - if ax is None: - ax = plt.gca() if not isinstance(series.index, pd.DatetimeIndex): raise ValueError("Series passed is not datetime indexed.") show_legend = kwargs.pop("legend", True) - - # NOTE - no checks here for missing days, weeks, or months, it should be evident from the plot - - # obtain plotting parameters - minmax_range = kwargs.pop("minmax_range", [0.0001, 0.9999]) - if minmax_range[0] > minmax_range[1]: - raise ValueError("minmax_range must be increasing.") - minmax_alpha = kwargs.pop("minmax_alpha", 0.1) - - quantile_range = kwargs.pop("quantile_range", [0.05, 0.95]) - if quantile_range[0] > quantile_range[1]: - raise ValueError("quantile_range must be increasing.") - if quantile_range[0] < minmax_range[0] or quantile_range[1] > minmax_range[1]: - raise ValueError("quantile_range must be within minmax_range.") - quantile_alpha = kwargs.pop("quantile_alpha", 0.3) - - color = kwargs.pop("color", "slategray") - - # resample to hourly to ensuure hour alignment - # TODO - for now we only resample to hourly, but this could be made more flexible by allowing any subset of period - series = series.resample("h").mean() - - # remove nan/inf - series = series.replace([-np.inf, np.inf], np.nan).dropna() - - # Remove outliers - series = series[ - (series >= series.quantile(minmax_range[0])) - & (series <= series.quantile(minmax_range[1])) - ] - - # group data - if period == "daily": - group = series.groupby(series.index.hour) - target_idx = range(24) - major_ticks = target_idx[::3] - minor_ticks = target_idx - major_ticklabels = [f"{i:02d}:00" for i in major_ticks] - elif period == "weekly": - group = series.groupby([series.index.dayofweek, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(7), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::3] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.day_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - elif period == "monthly": - group = series.groupby([series.index.month, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(1, 13, 1), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::6] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.month_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - else: - raise ValueError("period must be one of 'daily', 'weekly', or 'monthly'") - - samples_per_timestep = group.count().mean() - ax.set_title( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile (≈{samples_per_timestep:0.0f} samples per timestep)", - ) - ) - - # Get values to plot - minima = group.min() - lower = group.quantile(quantile_range[0]) - median = group.median() - mean = group.mean() - upper = group.quantile(quantile_range[1]) - maxima = group.max() - - # create df for re-indexing - df = pd.concat( - [minima, lower, median, mean, upper, maxima], - axis=1, - keys=["minima", "lower", "median", "mean", "upper", "maxima"], - ).reindex(target_idx) - - # populate plot - for n, i in enumerate(range(len(df) + 1)[::24]): - if n == len(range(len(df) + 1)[::24]) - 1: - continue - # q-q - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - alpha=quantile_alpha, - color=color, - lw=None, - ec=None, - label=f"{quantile_range[0]:0.0%}-{quantile_range[1]:0.0%}ile" - if n == 0 - else "_nolegend_", - ) - # q-extreme - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24] - + [(df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - label="Range" if n == 0 else "_nolegend_", - ) - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - (df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24] - + [(df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - label="_nolegend_", - ) - # mean/median - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] - + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], - c=color, - ls="-", - lw=1, - label="Average" if n == 0 else "_nolegend_", - ) - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] - + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], - c=color, - ls="--", - lw=1, - label="Median" if n == 0 else "_nolegend_", + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() + # NOTE - no checks here for missing days, weeks, or months, it should be evident from the plot + + # obtain plotting parameters + minmax_range = kwargs.pop("minmax_range", [0.0001, 0.9999]) + if minmax_range[0] > minmax_range[1]: + raise ValueError("minmax_range must be increasing.") + minmax_alpha = kwargs.pop("minmax_alpha", 0.1) + + quantile_range = kwargs.pop("quantile_range", [0.05, 0.95]) + if quantile_range[0] > quantile_range[1]: + raise ValueError("quantile_range must be increasing.") + if quantile_range[0] < minmax_range[0] or quantile_range[1] > minmax_range[1]: + raise ValueError("quantile_range must be within minmax_range.") + quantile_alpha = kwargs.pop("quantile_alpha", 0.3) + + color = kwargs.pop("color", "slategray") + + # resample to hourly to ensuure hour alignment + # TODO - for now we only resample to hourly, but this could be made more flexible by allowing any subset of period + series = series.resample("h").mean() + + # remove nan/inf + series = series.replace([-np.inf, np.inf], np.nan).dropna() + + # Remove outliers + series = series[ + (series >= series.quantile(minmax_range[0])) + & (series <= series.quantile(minmax_range[1])) + ] + + # group data + if period == "daily": + group = series.groupby(series.index.hour) + target_idx = range(24) + major_ticks = target_idx[::3] + minor_ticks = target_idx + major_ticklabels = [f"{i:02d}:00" for i in major_ticks] + elif period == "weekly": + group = series.groupby([series.index.dayofweek, series.index.hour]) + target_idx = pd.MultiIndex.from_product([range(7), range(24)]) + major_ticks = range(len(target_idx))[::12] + minor_ticks = range(len(target_idx))[::3] + major_ticklabels = [] + for i in target_idx: + if i[1] == 0: + major_ticklabels.append(f"{calendar.day_abbr[i[0]]}") + elif i[1] == 12: + major_ticklabels.append("") + elif period == "monthly": + group = series.groupby([series.index.month, series.index.hour]) + target_idx = pd.MultiIndex.from_product([range(1, 13, 1), range(24)]) + major_ticks = range(len(target_idx))[::12] + minor_ticks = range(len(target_idx))[::6] + major_ticklabels = [] + for i in target_idx: + if i[1] == 0: + major_ticklabels.append(f"{calendar.month_abbr[i[0]]}") + elif i[1] == 12: + major_ticklabels.append("") + else: + raise ValueError("period must be one of 'daily', 'weekly', or 'monthly'") + + samples_per_timestep = group.count().mean() + ax.set_title( + create_title( + kwargs.pop("title", None), + f"Average {period} diurnal profile (≈{samples_per_timestep:0.0f} samples per timestep)", + ) ) - # format axes - ax.set_xlim(0, len(df)) - ax.xaxis.set_major_locator(mticker.FixedLocator(major_ticks)) - ax.xaxis.set_minor_locator(mticker.FixedLocator(minor_ticks)) - ax.set_xticklabels( - major_ticklabels, - minor=False, - ha="left", - ) - if show_legend: - ax.legend( - bbox_to_anchor=(0.5, -0.2), - loc=8, - ncol=6, - borderaxespad=0, + # Get values to plot + minima = group.min() + lower = group.quantile(quantile_range[0]) + median = group.median() + mean = group.mean() + upper = group.quantile(quantile_range[1]) + maxima = group.max() + + # create df for re-indexing + df = pd.concat( + [minima, lower, median, mean, upper, maxima], + axis=1, + keys=["minima", "lower", "median", "mean", "upper", "maxima"], + ).reindex(target_idx) + + # populate plot + for n, i in enumerate(range(len(df) + 1)[::24]): + if n == len(range(len(df) + 1)[::24]) - 1: + continue + # q-q + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] + + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], + (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] + + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], + alpha=quantile_alpha, + color=color, + lw=None, + ec=None, + label=f"{quantile_range[0]:0.0%}-{quantile_range[1]:0.0%}ile" + if n == 0 + else "_nolegend_", + ) + # q-extreme + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] + + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], + (df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24] + + [(df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24][0]], + alpha=minmax_alpha, + color=color, + lw=None, + ec=None, + label="Range" if n == 0 else "_nolegend_", + ) + ax.fill_between( + range(len(df) + 1)[i : i + 25], + (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] + + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], + (df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24] + + [(df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24][0]], + alpha=minmax_alpha, + color=color, + lw=None, + ec=None, + label="_nolegend_", + ) + # mean/median + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] + + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], + c=color, + ls="-", + lw=1, + label="Average" if n == 0 else "_nolegend_", + ) + ax.plot( + range(len(df) + 1)[i : i + 25], + (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] + + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], + c=color, + ls="--", + lw=1, + label="Median" if n == 0 else "_nolegend_", + ) + + # format axes + ax.set_xlim(0, len(df)) + ax.xaxis.set_major_locator(mticker.FixedLocator(major_ticks)) + ax.xaxis.set_minor_locator(mticker.FixedLocator(minor_ticks)) + ax.set_xticklabels( + major_ticklabels, + minor=False, + ha="left", ) + if show_legend: + ax.legend( + bbox_to_anchor=(0.5, -0.2), + loc=8, + ncol=6, + borderaxespad=0, + ) - ax.set_ylabel(series.name) + ax.set_ylabel(series.name) return ax @@ -235,6 +239,8 @@ def stacked_diurnals( Additional keyword arguments to pass to the matplotlib plotting function. colors (list[str], optional): A list of colors to use for the plots. Defaults to None, which uses the default diurnal color. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Figure: @@ -243,48 +249,51 @@ def stacked_diurnals( if len(datasets) <= 1: raise ValueError("stacked_diurnals requires at least two datasets.") + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + fig, axes = plt.subplots( + len(datasets), 1, figsize=(12, 2 * len(datasets)), sharex=True + ) - fig, axes = plt.subplots( - len(datasets), 1, figsize=(12, 2 * len(datasets)), sharex=True - ) - - for n, (ax, series) in enumerate(zip(axes, datasets)): - if "colors" in kwargs: - kwargs["color"] = kwargs["colors"][n] - diurnal(series, ax=ax, period=period, **kwargs) - ax.set_title(None) - ax.get_legend().remove() - ax.set_ylabel(textwrap.fill(ax.get_ylabel(), 20)) - - handles, labels = axes[-1].get_legend_handles_labels() - new_handles = [] - for handle in handles: - if isinstance(handle, mcollections.PolyCollection): - new_handles.append( - mpatches.Patch( - color="slategray", alpha=handle.get_alpha(), edgecolor=None + for n, (ax, series) in enumerate(zip(axes, datasets)): + if "colors" in kwargs: + kwargs["color"] = kwargs["colors"][n] + diurnal(series, ax=ax, period=period, **kwargs) + ax.set_title(None) + ax.get_legend().remove() + ax.set_ylabel(textwrap.fill(ax.get_ylabel(), 20)) + + handles, labels = axes[-1].get_legend_handles_labels() + new_handles = [] + for handle in handles: + if isinstance(handle, mcollections.PolyCollection): + new_handles.append( + mpatches.Patch( + color="slategray", alpha=handle.get_alpha(), edgecolor=None + ) ) - ) - if isinstance(handle, mlines.Line2D): - new_handles.append( - mlines.Line2D( - (0,), (0,), color="slategray", linestyle=handle.get_linestyle() + if isinstance(handle, mlines.Line2D): + new_handles.append( + mlines.Line2D( + (0,), (0,), color="slategray", linestyle=handle.get_linestyle() + ) ) - ) - plt.legend( - new_handles, labels, bbox_to_anchor=(0.5, -0.12), loc="upper center", ncol=4 - ) + plt.legend( + new_handles, labels, bbox_to_anchor=(0.5, -0.12), loc="upper center", ncol=4 + ) - fig.suptitle( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile" + "s" if len(datasets) > 1 else "", - ), - x=fig.subplotpars.left, - ha="left", - ) + fig.suptitle( + create_title( + kwargs.pop("title", None), + f"Average {period} diurnal profile" + "s" if len(datasets) > 1 else "", + ), + x=fig.subplotpars.left, + ha="left", + ) - plt.tight_layout() + plt.tight_layout() return fig diff --git a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py index a521d00..845fad8 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/heatmap.py @@ -30,17 +30,15 @@ def heatmap( The title of the plot. Defaults to None. mask (List[bool], optional): A list of booleans to mask the data. Defaults to None. - + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: The populated plt.Axes object. """ - validate_timeseries(series) - - if ax is None: - ax = plt.gca() - + + day_time_matrix = ( series.dropna() .to_frame() @@ -66,50 +64,55 @@ def heatmap( extend = kwargs.pop("extend", "neither") title = kwargs.pop("title", series.name) show_colorbar = kwargs.pop("show_colorbar", True) - - # Plot data - pcm = ax.pcolormesh( - x, - y, - z[:-1, :-1], - **kwargs, - ) - - ax.xaxis_date() - if len(set(series.index.year)) > 1: - date_formatter = mdates.DateFormatter("%b %Y") - else: - date_formatter = mdates.DateFormatter("%b") - ax.xaxis.set_major_formatter(date_formatter) - - ax.yaxis_date() - ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) - - ax.tick_params(labelleft=True, labelbottom=True) - plt.setp(ax.get_xticklabels(), ha="left") - - for spine in ["top", "bottom", "left", "right"]: - ax.spines[spine].set_visible(False) - - for i in ax.get_xticks(): - ax.axvline(i, color="w", ls=":", lw=0.5, alpha=0.5) - for i in ax.get_yticks(): - ax.axhline(i, color="w", ls=":", lw=0.5, alpha=0.5) - - if show_colorbar: - cb = plt.colorbar( - pcm, - ax=ax, - orientation="horizontal", - drawedges=False, - fraction=0.05, - aspect=100, - pad=0.075, - extend=extend, - label=series.name, + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + # Plot data + if ax is None: + ax = plt.gca() + + pcm = ax.pcolormesh( + x, + y, + z[:-1, :-1], + **kwargs, ) - cb.outline.set_visible(False) - ax.set_title(title) + ax.xaxis_date() + if len(set(series.index.year)) > 1: + date_formatter = mdates.DateFormatter("%b %Y") + else: + date_formatter = mdates.DateFormatter("%b") + ax.xaxis.set_major_formatter(date_formatter) + + ax.yaxis_date() + ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) + + ax.tick_params(labelleft=True, labelbottom=True) + plt.setp(ax.get_xticklabels(), ha="left") + + for spine in ["top", "bottom", "left", "right"]: + ax.spines[spine].set_visible(False) + + for i in ax.get_xticks(): + ax.axvline(i, color="w", ls=":", lw=0.5, alpha=0.5) + for i in ax.get_yticks(): + ax.axhline(i, color="w", ls=":", lw=0.5, alpha=0.5) + + if show_colorbar: + cb = plt.colorbar( + pcm, + ax=ax, + orientation="horizontal", + drawedges=False, + fraction=0.05, + aspect=100, + pad=0.075, + extend=extend, + label=series.name, + ) + cb.outline.set_visible(False) + + ax.set_title(title) return ax \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/histogram.py b/Python_Engine/Python/src/python_toolkit/plot/histogram.py index cc2ecb7..97a3b83 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/histogram.py +++ b/Python_Engine/Python/src/python_toolkit/plot/histogram.py @@ -30,6 +30,8 @@ def histogram( Whether to show the legend. Defaults to False. **kwargs: Additional keyword arguments to pass to plt.hist. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -39,16 +41,19 @@ def histogram( bins = np.linspace(series.values.min(), series.values.max(), 31) elif len(bins) <= 1: bins = np.linspace(series.values.min(), series.values.max(), 31) + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") - if ax is None: - ax = plt.gca() + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() - show_legend = kwargs.pop("show_legend", True) + show_legend = kwargs.pop("show_legend", True) - ax.hist(series.values, bins=bins, label = series.name, density=False, **kwargs) + ax.hist(series.values, bins=bins, label = series.name, density=False, **kwargs) - if show_legend: - ax.legend() + if show_legend: + ax.legend() return ax @@ -82,6 +87,8 @@ def monthly_proportional_histogram( Whether to show the legend. Defaults to False. **kwargs: Additional keyword arguments to pass to plt.bar. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -89,59 +96,62 @@ def monthly_proportional_histogram( """ validate_timeseries(series) - - if ax is None: - ax = plt.gca() - counts = timeseries_summary_monthly(series, bins, labels, density=True) - - if show_year_in_label: - counts.columns = [ - f"{year}\n{calendar.month_abbr[month]}" for year, month in counts.columns.values - ] - - counts.plot( - ax = ax, - kind = "bar", - stacked = True, - width = kwargs.pop("width", 1), - legend = False, - **kwargs - ) - - ax.set_xlim(-0.5, len(counts) - 0.5) - ax.set_ylim(0, 1) + style_context = kwargs.pop("style_context", "python_toolkit.bhom") - ax.set_xticklabels( - [calendar.month_abbr[int(i._text)] for i in ax.get_xticklabels()], - ha="center", - rotation=0, - ) + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() - for spine in ["top", "right", "left", "bottom"]: - ax.spines[spine].set_visible(False) - ax.yaxis.set_major_formatter(mticker.PercentFormatter(1)) - - if show_legend: - ax.legend( - bbox_to_anchor=(1, 1), - loc="upper left", - borderaxespad=0.0, - frameon=False, - ) + counts = timeseries_summary_monthly(series, bins, labels, density=True) - if show_labels: - for i, c in enumerate(ax.containers): - label_colors = [contrasting_colour(i.get_facecolor()) for i in c.patches] - labels = [ - f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c + if show_year_in_label: + counts.columns = [ + f"{year}\n{calendar.month_abbr[month]}" for year, month in counts.columns.values ] - ax.bar_label( - c, - labels=labels, - label_type="center", - color=label_colors[i], - fontsize="x-small", + + counts.plot( + ax = ax, + kind = "bar", + stacked = True, + width = kwargs.pop("width", 1), + legend = False, + **kwargs ) + ax.set_xlim(-0.5, len(counts) - 0.5) + ax.set_ylim(0, 1) + + ax.set_xticklabels( + [calendar.month_abbr[int(i._text)] for i in ax.get_xticklabels()], + ha="center", + rotation=0, + ) + + for spine in ["top", "right", "left", "bottom"]: + ax.spines[spine].set_visible(False) + ax.yaxis.set_major_formatter(mticker.PercentFormatter(1)) + + if show_legend: + ax.legend( + bbox_to_anchor=(1, 1), + loc="upper left", + borderaxespad=0.0, + frameon=False, + ) + + if show_labels: + for i, c in enumerate(ax.containers): + label_colors = [contrasting_colour(i.get_facecolor()) for i in c.patches] + labels = [ + f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c + ] + ax.bar_label( + c, + labels=labels, + label_type="center", + color=label_colors[i], + fontsize="x-small", + ) + return ax \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py b/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py index 0e205c2..8861ffa 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py +++ b/Python_Engine/Python/src/python_toolkit/plot/spatial_heatmap.py @@ -28,6 +28,7 @@ def spatial_heatmap( highlight_pts: dict[str, tuple[int]] = None, show_legend_title: bool = True, clabels: bool = False, + style_context:str = "python_toolkit.bhom", ) -> Figure: """Plot a spatial map of a variable using a triangulation and associated values. @@ -67,6 +68,8 @@ def spatial_heatmap( A convenient flag to hide the legend and title. clabels (bool, optional): A flag to show contour labels. Defaults to False. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: Figure: A matplotlib Figure object. @@ -93,77 +96,78 @@ def spatial_heatmap( min(i.y.min() for i in triangulations), max(i.y.max() for i in triangulations), ] + + with plt.style.context(style_context): + fig, ax = plt.subplots(1, 1, figsize=(8, 8)) - fig, ax = plt.subplots(1, 1, figsize=(8, 8)) + ax.set_aspect("equal") + ax.axis("off") - ax.set_aspect("equal") - ax.axis("off") + ax.set_xlim(xlims) + ax.set_ylim(ylims) - ax.set_xlim(xlims) - ax.set_ylim(ylims) - - tcls = [] - for tri, zs in list(zip(*[triangulations, values])): - tcf = ax.tricontourf( - tri, zs, extend=extend, cmap=cmap, levels=levels, norm=norm - ) - # add contour lines - if contours is not None: - if not ( - all(i < np.amin(zs) for i in contours) - or all(i > np.amax(zs) for i in contours) - ): - if contour_widths is None: - contour_widths = [1.5] * len(contours) - if contour_colors is None: - contour_colors = ["k"] * len(contours) - if len(contour_colors) != len(contours) != len(contour_widths): - raise ValueError("contour vars must be same length") - tcl = ax.tricontour( - tri, - zs, - levels=contours, - colors=contour_colors, - linewidths=contour_widths, - ) - if clabels: - ax.clabel(tcl, inline=1, fontsize="small", colors=contour_colors) - tcls.append(tcl) - - if highlight_pts is not None: - if len(triangulations) > 1: - raise ValueError( - "Point highlighting is only possible for 1-length triangulations." - ) - pt_size = (xlims[1] - xlims[0]) / 5 - for k, v in highlight_pts.items(): - ax.scatter( - triangulations[0].x[v], triangulations[0].y[v], s=pt_size, c="red" - ) - ax.text( - triangulations[0].x[v] + (pt_size / 10), - triangulations[0].y[v], - k, - ha="left", - va="center", + tcls = [] + for tri, zs in list(zip(*[triangulations, values])): + tcf = ax.tricontourf( + tri, zs, extend=extend, cmap=cmap, levels=levels, norm=norm ) + # add contour lines + if contours is not None: + if not ( + all(i < np.amin(zs) for i in contours) + or all(i > np.amax(zs) for i in contours) + ): + if contour_widths is None: + contour_widths = [1.5] * len(contours) + if contour_colors is None: + contour_colors = ["k"] * len(contours) + if len(contour_colors) != len(contours) != len(contour_widths): + raise ValueError("contour vars must be same length") + tcl = ax.tricontour( + tri, + zs, + levels=contours, + colors=contour_colors, + linewidths=contour_widths, + ) + if clabels: + ax.clabel(tcl, inline=1, fontsize="small", colors=contour_colors) + tcls.append(tcl) + + if highlight_pts is not None: + if len(triangulations) > 1: + raise ValueError( + "Point highlighting is only possible for 1-length triangulations." + ) + pt_size = (xlims[1] - xlims[0]) / 5 + for k, v in highlight_pts.items(): + ax.scatter( + triangulations[0].x[v], triangulations[0].y[v], s=pt_size, c="red" + ) + ax.text( + triangulations[0].x[v] + (pt_size / 10), + triangulations[0].y[v], + k, + ha="left", + va="center", + ) - if show_legend_title: - # Plot colorbar - divider = make_axes_locatable(ax) - cax = divider.append_axes("right", size="5%", pad=0.1, aspect=20) + if show_legend_title: + # Plot colorbar + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.1, aspect=20) - cbar = plt.colorbar( - tcf, cax=cax # , format=mticker.StrMethodFormatter("{x:04.1f}") - ) - cbar.outline.set_visible(False) - cbar.set_label(colorbar_label) + cbar = plt.colorbar( + tcf, cax=cax # , format=mticker.StrMethodFormatter("{x:04.1f}") + ) + cbar.outline.set_visible(False) + cbar.set_label(colorbar_label) - for tcl in tcls: - cbar.add_lines(tcl) + for tcl in tcls: + cbar.add_lines(tcl) - ax.set_title(title, ha="left", va="bottom", x=0) + ax.set_title(title, ha="left", va="bottom", x=0) - plt.tight_layout() + plt.tight_layout() return fig diff --git a/Python_Engine/Python/src/python_toolkit/plot/timeseries.py b/Python_Engine/Python/src/python_toolkit/plot/timeseries.py index c0cedb0..3557778 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/timeseries.py +++ b/Python_Engine/Python/src/python_toolkit/plot/timeseries.py @@ -29,6 +29,8 @@ def timeseries( Set the y-limits. Defaults to None. **kwargs: Additional keyword arguments to pass to the plt.plot() function. + style_context (string, optional): + The matplotlib style to use. Defaults to python_toolkit.bhom Returns: plt.Axes: @@ -36,22 +38,25 @@ def timeseries( """ validate_timeseries(series) - - if ax is None: - ax = plt.gca() - - ax.plot(series.index, series.values, **kwargs) ## example plot here - - # TODO - add cmap arg to color line by y value - - # https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html - - if xlims is None: - ax.set_xlim(series.index.min(), series.index.max()) - else: - ax.set_xlim(xlims) - if ylims is None: - ax.set_ylim(ax.get_ylim()) - else: - ax.set_ylim(ylims) + + style_context = kwargs.pop("style_context", "python_toolkit.bhom") + + with plt.style.context(style_context): + if ax is None: + ax = plt.gca() + + ax.plot(series.index, series.values, **kwargs) ## example plot here + + # TODO - add cmap arg to color line by y value - + # https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html + + if xlims is None: + ax.set_xlim(series.index.min(), series.index.max()) + else: + ax.set_xlim(xlims) + if ylims is None: + ax.set_ylim(ax.get_ylim()) + else: + ax.set_ylim(ylims) return ax \ No newline at end of file From 3f5c5a0d17534607796a9c207770e0d7fbe0a6b6 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Mon, 9 Mar 2026 10:43:52 +0000 Subject: [PATCH 03/13] add a style for use with dark backgrounds --- .../src/python_toolkit/bhom_dark.mplstyle | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle diff --git a/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle new file mode 100644 index 0000000..5ad5127 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle @@ -0,0 +1,87 @@ +# Default matplotlib settings for this toolkit. + +# Set custom colors. All colors are in web style hex format. +axes.prop_cycle: cycler('color', ['702F8A', 'E63187', '00A9E0', 'FFCF04', '6CC24E', 'EB671C', '00A499', 'D50032', '24135F', '6D104E', '006DA8', 'D06A13', '5D822D', 'F0AC1B', '1C3660', 'BC204B', '8F72B0', 'FCD16D', '8DB9CA', 'EE7837', 'AFC1A2', 'B72B77', 'A0D2C9', 'E6484D']) + +# Face settings +axes.facecolor: black +axes.edgecolor: white + +# Style spines +axes.linewidth: 0.8 +axes.spines.top: False +axes.spines.left: True +axes.spines.right: False +axes.spines.bottom: True + +# Set line styling for line plots +lines.linewidth: 1 +lines.solid_capstyle: round +lines.dash_capstyle: round + +# Grid style +axes.axisbelow: True +axes.grid: true +axes.grid.axis: both +grid.color: 958B82 +grid.linestyle: -- +grid.linewidth: 0.5 + +# Setting font sizes and spacing +axes.labelsize: medium +axes.labelweight: semibold +axes.ymargin: 0.1 +font.family: sans-serif +font.sans-serif: Segoe UI +font.size: 10 +xtick.labelsize: medium +xtick.labelcolor: white +xtick.major.pad: 3.5 +ytick.labelsize: medium +ytick.labelcolor: white +ytick.major.pad: 3.5 + +# date formatter +date.autoformatter.day: %b-%d +date.autoformatter.hour: %b-%d %H +date.autoformatter.microsecond: %M:%S.%f +date.autoformatter.minute: %d %H:%M +date.autoformatter.month: %b +date.autoformatter.second: %H:%M:%S +date.autoformatter.year: %Y + +# Title +axes.titlelocation: left +axes.titlepad: 6 +axes.titlesize: large +axes.titleweight: bold + +# Remove major and minor ticks except for on the x-axis. +xtick.color: white +xtick.major.size: 3 +xtick.minor.size: 2 +ytick.color: white +ytick.major.size: 3 +ytick.minor.size: 2 + +# Set spacing for figure and also DPI. +figure.subplot.left: 0.08 +figure.subplot.right: 0.95 +figure.subplot.bottom: 0.07 +figure.figsize: 12, 5 +figure.dpi: 150 +figure.facecolor: black + +# Properties for saving the figure. Ensure a high DPI when saving so we have a good resolution. +savefig.dpi: 300 +savefig.facecolor: black +savefig.bbox: tight +savefig.pad_inches: 0.2 + +# Legend Styling +legend.framealpha: 0 +legend.frameon: False +legend.facecolor: inherit + +# Text +text.color: white \ No newline at end of file From 14b67425613d16dd45278d0b8cc302d406790557 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Mon, 9 Mar 2026 11:16:45 +0000 Subject: [PATCH 04/13] don't globally change plt.style, and use the bhom style for the figure test --- .../Python/src/python_toolkit/__init__.py | 4 ---- .../bhom_tkinter/widgets/figure_container.py | 16 +++++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/__init__.py b/Python_Engine/Python/src/python_toolkit/__init__.py index 3127261..3e022cf 100644 --- a/Python_Engine/Python/src/python_toolkit/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/__init__.py @@ -18,7 +18,3 @@ if os.name == "nt": # override "HOME" in case this is set to something other than default for windows os.environ["HOME"] = (Path("C:/Users/") / getpass.getuser()).as_posix() - - -# set plotting style for modules within this toolkit -plt.style.use(BHOM_DIRECTORY / "bhom.mplstyle") diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py index 14437bb..7797882 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -9,6 +9,7 @@ import matplotlib.pyplot as plt from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget import matplotlib as mpl +import darkdetect class FigureContainer(BHoMBaseWidget): @@ -355,13 +356,18 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni build_options=PackingOptions(padx=10, pady=10, fill='both', expand=True) ) figure_container.build() + + style = "python_toolkit.bhom" + if darkdetect.isDark(): + style = "python_toolkit.bhom_dark" # Create and embed the initial matplotlib figure - fig_initial, ax_initial = plt.subplots(figsize=(5, 4), dpi=80) - ax_initial.plot([1, 2, 3, 4], [1, 4, 2, 3], marker='o') - ax_initial.set_title("Initial Plot") - ax_initial.set_xlabel("X") - ax_initial.set_ylabel("Y") + with plt.style.context(style): + fig_initial, ax_initial = plt.subplots(figsize=(5, 4), dpi=80) + ax_initial.plot([1, 2, 3, 4], [1, 4, 2, 3], marker='o') + ax_initial.set_title("Initial Plot") + ax_initial.set_xlabel("X") + ax_initial.set_ylabel("Y") figure_container.embed_figure(fig_initial) def push_new_plot() -> None: From c60e441cdaa7cc698d46f8a64d6dc329ddf7325b Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Wed, 11 Mar 2026 15:34:54 +0000 Subject: [PATCH 05/13] update dark style axis title colour --- .../Python/src/python_toolkit/bhom_dark.mplstyle | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle index 5ad5127..105e497 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle +++ b/Python_Engine/Python/src/python_toolkit/bhom_dark.mplstyle @@ -1,5 +1,8 @@ # Default matplotlib settings for this toolkit. +# Text +text.color: white + # Set custom colors. All colors are in web style hex format. axes.prop_cycle: cycler('color', ['702F8A', 'E63187', '00A9E0', 'FFCF04', '6CC24E', 'EB671C', '00A499', 'D50032', '24135F', '6D104E', '006DA8', 'D06A13', '5D822D', 'F0AC1B', '1C3660', 'BC204B', '8F72B0', 'FCD16D', '8DB9CA', 'EE7837', 'AFC1A2', 'B72B77', 'A0D2C9', 'E6484D']) @@ -30,6 +33,7 @@ grid.linewidth: 0.5 # Setting font sizes and spacing axes.labelsize: medium axes.labelweight: semibold +axes.labelcolor: white axes.ymargin: 0.1 font.family: sans-serif font.sans-serif: Segoe UI @@ -81,7 +85,4 @@ savefig.pad_inches: 0.2 # Legend Styling legend.framealpha: 0 legend.frameon: False -legend.facecolor: inherit - -# Text -text.color: white \ No newline at end of file +legend.facecolor: inherit \ No newline at end of file From c3875596d563fba95859e40bbf21118346d66920 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Thu, 12 Mar 2026 13:03:22 +0000 Subject: [PATCH 06/13] remove darkdetect from test --- .../python_toolkit/bhom_tkinter/widgets/figure_container.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py index 7797882..ad0282e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/figure_container.py @@ -9,7 +9,6 @@ import matplotlib.pyplot as plt from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget import matplotlib as mpl -import darkdetect class FigureContainer(BHoMBaseWidget): @@ -357,9 +356,7 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni ) figure_container.build() - style = "python_toolkit.bhom" - if darkdetect.isDark(): - style = "python_toolkit.bhom_dark" + style = "python_toolkit.bhom_dark" # Create and embed the initial matplotlib figure with plt.style.context(style): From c38a3242a1dddcec21a9a9dc41c13f51989c09d9 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 12 Mar 2026 14:49:42 +0000 Subject: [PATCH 07/13] added global get method --- .../python_toolkit/bhom_tkinter/bhom_base_window.py | 13 +++++++++++++ .../bhom_tkinter/widgets/check_box_selection.py | 1 + .../bhom_tkinter/widgets/validated_entry_box.py | 1 + 3 files changed, 15 insertions(+) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index 6ff40a9..4da286d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -586,6 +586,18 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: """Handle window X button click.""" self._exit("window_closed", callback) + def get(self): + widget_values = {} + + for widget in self.widgets: + + if hasattr(widget, "get"): + try: + widget_values[widget.id] = widget.get() + except Exception as ex: + print(f"Warning: Failed to get value from widget {widget}: {ex}") + return widget_values + if __name__ == "__main__": @@ -604,3 +616,4 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: test.build() test.mainloop() + print(test.get()) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py index 1b35684..b0dee20 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py @@ -234,6 +234,7 @@ def on_selection(values): defaults=["Option B", "Option D"], orient="vertical", max_per_line=6, + min_selections=2, item_title="Choose Options", helper_text="Select one or more options below:", build_options=PackingOptions(padx=20, pady=20) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py index dcea02e..65b1d8d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -61,6 +61,7 @@ def __init__( self.required = required self.custom_validator = custom_validator self.on_validate = on_validate + self.default_value = default_value # Create or use provided StringVar self.variable = variable if variable is not None else tk.StringVar(value="") From b9e504bcbe715ba1a3a8ec88ad40541456b93db4 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 12 Mar 2026 15:53:59 +0000 Subject: [PATCH 08/13] test warning s --- .../Python/src/python_toolkit/bhom_tkinter/widgets/label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py index ca4d1c6..244c610 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/label.py @@ -156,7 +156,7 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni label_widget = Label(parent_frame, text="Hello, World!", build_options=PackingOptions(anchor="n", padx=10, pady=10), alignment="right") label_widget.build() - label_widget2 = Label(parent_frame, text="This is a BHoM Label widget.", build_options=PackingOptions(anchor="n", padx=10, pady=10), alignment="left") + label_widget2 = Label(parent_frame, text="Warning: This is a BHoM warning label.", build_options=PackingOptions(anchor="n", padx=10, pady=10), alignment="left", style="Warning.TLabel") label_widget2.build() root.mainloop() \ No newline at end of file From f415bbbe85cec485ca9a58d3e2f8c160bf21d9fe Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 13 Mar 2026 11:56:49 +0000 Subject: [PATCH 09/13] list box width --- .../bhom_tkinter/widgets/list_box.py | 21 ++++++++++++------- .../bhom_tkinter/widgets/widget_calendar.py | 8 +++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py index 41403e8..4d78755 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -17,7 +17,8 @@ def __init__( parent: ttk.Frame, items=None, selectmode=tk.MULTIPLE, - height=None, + height=None, + width=None, show_selection_controls=False, **kwargs): """ @@ -26,6 +27,7 @@ def __init__( items (list, optional): List of items to populate the listbox. selectmode (str): Selection mode for the listbox (SINGLE, MULTIPLE, etc.). height (int, optional): Height of the listbox. Defaults to number of items. + width (int, optional): Width of the listbox in characters. If None, listbox expands to fill available space. show_selection_controls (bool): Show Select All and Deselect All buttons. **kwargs: Additional Frame options. """ @@ -44,13 +46,16 @@ def __init__( self.content_frame.rowconfigure(1, weight=1) # Create listbox - self.listbox = tk.Listbox( - self.content_frame, - selectmode=selectmode, - height=height, - yscrollcommand=self.scrollbar.set, - exportselection=False, - ) + listbox_options = { + "selectmode": selectmode, + "height": height, + "yscrollcommand": self.scrollbar.set, + "exportselection": False, + } + if width is not None: + listbox_options["width"] = width + + self.listbox = tk.Listbox(self.content_frame, **listbox_options) self.listbox.grid(row=0, column=0, columnspan=2, sticky="nsew") self.scrollbar.config(command=self.listbox.yview) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py index c4bf586..7c07ff1 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/widget_calendar.py @@ -16,9 +16,9 @@ class CalendarWidget(BHoMBaseWidget): def __init__( self, parent: ttk.Frame, - def_year: int, - def_month: int, - def_day: int, + def_year: int = 2026, + def_month: int = 1, + def_day: int = 1, show_year_selector: bool = True, year_min: int = 1900, year_max: int = 2100, @@ -218,7 +218,7 @@ def pack(self, **kwargs): def_year=2024, def_month=6, def_day=15, - day_button_width=3, + day_button_width=2, day_button_padx=2, day_button_pady=2, day_button_text_alignment="center", From c071b3314560d40de292a260d0096a5b32fee079 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 13 Mar 2026 16:28:42 +0000 Subject: [PATCH 10/13] fixed get methods to cache values for dead widgets --- .../bhom_tkinter/bhom_base_window.py | 18 +++- .../widgets/drop_down_selection.py | 5 +- .../bhom_tkinter/widgets/list_box.py | 98 +++++++++++++++++-- .../Python/tests/test_bhom_tkinter_ui.py | 40 ++++++++ 4 files changed, 149 insertions(+), 12 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index 4da286d..e7c7813 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -121,6 +121,7 @@ def __init__( self._auto_fit_height = height is None self._post_show_size_applied = False self.grid_dimensions = grid_dimensions + self._cached_widget_values: dict[str, object] = {} # Handle window close (X button) self.protocol("WM_DELETE_WINDOW", lambda: self._on_close_window(on_close_window)) @@ -572,6 +573,9 @@ def _exit(self, result: str, callback: Optional[Callable] = None) -> None: except Exception as ex: print(f"Warning: Exit callback raised an exception: {ex}") finally: + # Capture values while widgets still exist so `get()` remains usable + # after root teardown. + self._cached_widget_values = self._collect_widget_values() self.destroy_root() def _on_submit(self) -> None: @@ -587,7 +591,19 @@ def _on_close_window(self, callback: Optional[Callable]) -> None: self._exit("window_closed", callback) def get(self): - widget_values = {} + try: + if not self.winfo_exists(): + return dict(self._cached_widget_values) + except Exception: + return dict(self._cached_widget_values) + + widget_values = self._collect_widget_values() + self._cached_widget_values = dict(widget_values) + return widget_values + + def _collect_widget_values(self) -> dict[str, object]: + """Collect values from all registered widgets.""" + widget_values: dict[str, object] = {} for widget in self.widgets: 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 89abaed..c7185e0 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 @@ -16,7 +16,8 @@ def __init__( command: Optional[Callable[[str], None]] = None, default: Optional[str] = None, width: int = 20, - state: str = "readonly", + state: str = "normal", + value_var: Optional[tk.StringVar] = None, **kwargs): """ Args: @@ -34,7 +35,7 @@ def __init__( self.options = [str(option) for option in (options or [])] self.command = command - self.value_var = tk.StringVar() + self.value_var = value_var or tk.StringVar() # Create the combobox in the content frame self.combobox = ttk.Combobox( diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py index 4d78755..daba36d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -34,6 +34,9 @@ def __init__( super().__init__(parent, **kwargs) self.items = items or [] + self._cached_options = [str(item) for item in self.items] + self._cached_selection: list[str] = [] + self._cached_selection_indices: tuple[int, ...] = () if height is None: height = len(self.items) if self.items else 5 @@ -65,6 +68,8 @@ def __init__( # Auto-hide scrollbar when not needed self.listbox.bind("", self._on_configure) + self.listbox.bind("<>", self._on_selection_change) + self._sync_cache_from_widget() self._on_configure() if show_selection_controls: @@ -84,6 +89,26 @@ def _on_configure(self, event=None): else: self.scrollbar.grid(row=0, column=1, sticky="ns") + def _on_selection_change(self, _event=None): + """Track selection changes so values remain available after teardown.""" + self._sync_cache_from_widget() + + def _is_listbox_alive(self) -> bool: + """Return whether the underlying Tk listbox command still exists.""" + try: + return bool(self.listbox.winfo_exists()) + except Exception: + return False + + def _sync_cache_from_widget(self) -> None: + """Synchronize cached options and selections from the live listbox.""" + if not self._is_listbox_alive(): + return + self._cached_options = [self.listbox.get(i) for i in range(self.listbox.size())] + self.items = list(self._cached_options) + self._cached_selection_indices = tuple(self.listbox.curselection()) + self._cached_selection = [self.listbox.get(i) for i in self._cached_selection_indices] + def set_selections(self, items): """Set the selection to the specified items. @@ -91,9 +116,10 @@ def set_selections(self, items): items: Item values to select. """ self.listbox.selection_clear(0, tk.END) - for index, item in enumerate(self.items): + for index, item in enumerate(self.get_options()): if item in items: self.listbox.selection_set(index) + self._sync_cache_from_widget() def get_selection(self): """Return a list of selected items. @@ -101,6 +127,8 @@ def get_selection(self): Returns: list: Selected item values. """ + if not self._is_listbox_alive(): + return list(self._cached_selection) selected_indices = self.listbox.curselection() return [self.listbox.get(i) for i in selected_indices] @@ -110,15 +138,19 @@ def get_selection_indices(self): Returns: tuple: Indices of selected entries. """ + if not self._is_listbox_alive(): + return self._cached_selection_indices return self.listbox.curselection() def select_all(self): """Select all items in the listbox.""" self.listbox.selection_set(0, tk.END) + self._sync_cache_from_widget() def deselect_all(self): """Clear all selected items in the listbox.""" self.listbox.selection_clear(0, tk.END) + self._sync_cache_from_widget() def insert(self, index, item): """Insert an item at the specified index. @@ -128,6 +160,7 @@ def insert(self, index, item): item: Item value to insert. """ self.listbox.insert(index, item) + self._sync_cache_from_widget() self._on_configure() def delete(self, index): @@ -137,11 +170,16 @@ def delete(self, index): index: Position of item to delete. """ self.listbox.delete(index) + self._sync_cache_from_widget() self._on_configure() def clear(self): """Clear all items from the listbox.""" self.listbox.delete(0, tk.END) + self._cached_options = [] + self._cached_selection = [] + self._cached_selection_indices = () + self.items = [] self._on_configure() def pack(self, **kwargs): @@ -154,22 +192,49 @@ def pack(self, **kwargs): self._on_configure() # Ensure scrollbar visibility is updated when packed def set(self, value): - """Set the listbox items to the provided list. + """Set the currently selected item. Args: - value: Iterable of item values to display. + value: Item value to select. If `None`, clears all selection. """ - self.clear() - for item in value: - self.listbox.insert(tk.END, item) - self._on_configure() + if not self._is_listbox_alive(): + self._cached_selection = [] if value is None else [str(value)] + self._cached_selection_indices = () + return + + self.listbox.selection_clear(0, tk.END) + if value is None: + self._sync_cache_from_widget() + return + + options = self.get_options() + selected_value = str(value) + if selected_value in options: + index = options.index(selected_value) + self.listbox.selection_set(index) + self.listbox.activate(index) + self.listbox.see(index) + self._sync_cache_from_widget() def get(self): - """Get the current list of items in the listbox. + """Get the currently selected item. + + Returns: + Optional[str]: First selected item, or `None` when no selection exists. + """ + selection = self.get_selection() + if not selection: + return None + return selection[0] + + def get_options(self): + """Get all options currently displayed in the listbox. Returns: - list: Current listbox items in display order. + list[str]: Current listbox options in display order. """ + if not self._is_listbox_alive(): + return list(self._cached_options) return [self.listbox.get(i) for i in range(self.listbox.size())] def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: @@ -183,6 +248,21 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni # In this simple implementation, all states are valid, so we return True. return self.apply_validation((True, None, None)) + def set_options(self, options): + """Replace the listbox items with new options. + + Args: + options: Iterable of item values to display. + """ + self.clear() + for item in options: + self.listbox.insert(tk.END, item) + self._sync_cache_from_widget() + self._on_configure() + + def _sync_items_from_listbox(self) -> None: + """Synchronize cached items with the listbox contents.""" + self._sync_cache_from_widget() if __name__ == "__main__": diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 60c3e15..5586c8d 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -276,3 +276,43 @@ def fail_validator(_v): ok, msg, sev = custom_box.validate() assert ok is False and msg == "Custom failed" + +def test_rebuild(): + """Verify that rebuild() re-places widgets and reflects mutations to self.widgets.""" + root = BHoMBaseWindow(title="Rebuild test") + parent = root.content_frame + + label_a = Label(parent, text="Widget A", build_options=PackingOptions(fill="x")) + label_b = Label(parent, text="Widget B", build_options=PackingOptions(fill="x")) + + root.widgets.extend([label_a, label_b]) + root.build() + + # Both widgets should be managed after build + managed_after_build = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_build) == 2 + + # Add a third widget and rebuild + label_c = Label(parent, text="Widget C", build_options=PackingOptions(fill="x")) + root.widgets.append(label_c) + root.rebuild() + + managed_after_rebuild = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_rebuild) == 3 + + # Remove a widget and rebuild — it should no longer be managed + root.widgets.remove(label_a) + root.rebuild() + + managed_after_remove = [ + c for c in parent.winfo_children() if c.winfo_manager() + ] + assert len(managed_after_remove) == 2 + assert label_a not in [w for w in root.widgets] + + root.destroy_root() + From 3edc3b0f7d98cdc47dbe03fc4e24bbcf998818e2 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 16 Mar 2026 13:48:30 +0000 Subject: [PATCH 11/13] format changes and spin box. fixed list return on liost box --- .../bhom_tkinter/theming/bhom_dark_theme.tcl | 36 +++- .../bhom_tkinter/theming/bhom_light_theme.tcl | 36 +++- .../bhom_tkinter/widgets/__init__.py | 2 + .../widgets/check_box_selection.py | 57 +----- .../bhom_tkinter/widgets/list_box.py | 14 +- .../bhom_tkinter/widgets/spinbox.py | 167 ++++++++++++++++++ .../widgets/validated_entry_box.py | 96 ++++++++-- .../bhom_tkinter/windows/landing_page.py | 7 + 8 files changed, 346 insertions(+), 69 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl index 888a368..d0b571b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -385,7 +385,41 @@ namespace eval ttk::theme::bhom_dark { active $colors(-hover-bg) \ disabled $colors(-disabled-bg)] - # Radiobutton - sleek hover effect with bold font + # Larger checkbutton variant used by CheckboxSelection widget + ttk::style layout Checkbox.TCheckbutton { + Checkbutton.padding -sticky nswe -children { + Checkbutton.indicator -side left -sticky {} + Checkbutton.label -side left -sticky w + } + } + + ttk::style configure Checkbox.TCheckbutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorrelief flat \ + -indicatorsize 18 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) + + ttk::style map Checkbox.TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl index bc0f9f0..c5c6a2b 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -388,7 +388,41 @@ namespace eval ttk::theme::bhom_light { active $colors(-hover-bg) \ disabled $colors(-disabled-bg)] - # Radiobutton - sleek hover effect with bold font + # Larger checkbutton variant used by CheckboxSelection widget + ttk::style layout Checkbox.TCheckbutton { + Checkbutton.padding -sticky nswe -children { + Checkbutton.indicator -side left -sticky {} + Checkbutton.label -side left -sticky w + } + } + + ttk::style configure Checkbox.TCheckbutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorrelief flat \ + -indicatorsize 18 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorcolor $colors(-inputbg) \ + -indicatorbackground $colors(-inputbg) + + ttk::style map Checkbox.TCheckbutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorcolor [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] ttk::style configure TRadiobutton \ -background $colors(-bg) \ -foreground $colors(-fg) \ 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 b5cae62..7327d53 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 @@ -14,6 +14,7 @@ from .validated_entry_box import ValidatedEntryBox from .button import Button from .label import Label +from .spinbox import Spinbox __all__ = [ "BHoMBaseWidget", @@ -32,4 +33,5 @@ "ValidatedEntryBox", "Button", "Label", + "Spinbox", ] \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py index b0dee20..e17bbe2 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/check_box_selection.py @@ -6,7 +6,6 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets.button import Button -from python_toolkit.bhom_tkinter.widgets.label import Label class CheckboxSelection(BHoMBaseWidget): """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" @@ -70,25 +69,16 @@ def _build_buttons(self): for index, field in enumerate(self.fields): var = tk.BooleanVar(value=False) self.value_vars[field] = var - - button = Label( + + button = ttk.Checkbutton( self.buttons_frame, - text=f"□ {field}", - style="Body.TLabel", + text=field, + variable=var, + style="Checkbox.TCheckbutton", + command=lambda f=field: self._on_select_field(f), ) self.align_child_text(button) - # Make both the wrapper frame and the inner ttk.Label clickable - button.bind("", lambda e, f=field: self._toggle_field(f)) - try: - button.label.configure(cursor="hand2") - except Exception: - pass - # Ensure clicks on the inner label also toggle the field - try: - button.label.bind("", lambda e, f=field: self._toggle_field(f)) - except Exception: - pass - + if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": row = index // self.max_per_line @@ -104,20 +94,8 @@ def _build_buttons(self): self._buttons.append(button) self._field_buttons[field] = button - def _toggle_field(self, field): - """Toggle a field's state when clicked.""" - self.value_vars[field].set(not self.value_vars[field].get()) - self._on_select_field(field) - def _on_select_field(self, field): - """Handle checkbox selection change and update visual indicator.""" - button = self._field_buttons.get(field) - if button is not None: - if self.value_vars[field].get(): - button.set(f"■ {field}") - else: - button.set(f"□ {field}") - + """Handle checkbox selection change.""" if self.command: self.command(self.get()) @@ -138,21 +116,11 @@ def set(self, value: List[str]): values = [str(v) for v in (value or [])] for field, var in self.value_vars.items(): var.set(field in values) - - # Update visual indicators - for field, button in self._field_buttons.items(): - if field in values: - button.set(f"■ {field}") - else: - button.set(f"□ {field}") def select_all(self): """Select all checkboxes.""" for var in self.value_vars.values(): var.set(True) - # Update visual indicators - for field, button in self._field_buttons.items(): - button.set(f"■ {field}") if self.command: self.command(self.get()) @@ -160,9 +128,6 @@ def deselect_all(self): """Deselect all checkboxes.""" for var in self.value_vars.values(): var.set(False) - # Update visual indicators - for field, button in self._field_buttons.items(): - button.set(f"□ {field}") if self.command: self.command(self.get()) @@ -170,12 +135,6 @@ def toggle_all(self): """Toggle all checkbox states.""" for var in self.value_vars.values(): var.set(not var.get()) - # Update visual indicators - for field, button in self._field_buttons.items(): - if self.value_vars[field].get(): - button.set(f"■ {field}") - else: - button.set(f"□ {field}") if self.command: self.command(self.get()) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py index daba36d..f37ea21 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/list_box.py @@ -217,15 +217,12 @@ def set(self, value): self._sync_cache_from_widget() def get(self): - """Get the currently selected item. + """Get all currently selected items. Returns: - Optional[str]: First selected item, or `None` when no selection exists. + list[str]: Selected item values, or an empty list when nothing is selected. """ - selection = self.get_selection() - if not selection: - return None - return selection[0] + return self.get_selection() def get_options(self): """Get all options currently displayed in the listbox. @@ -284,6 +281,7 @@ def _sync_items_from_listbox(self) -> None: )) root.widgets[-1].build() - print("Selected items:", root.widgets[-1].get()) - root.mainloop() \ No newline at end of file + root.mainloop() + + print("Selected items:", root.widgets[-1].get()) \ No newline at end of file diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py new file mode 100644 index 0000000..b1ae63f --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py @@ -0,0 +1,167 @@ +"""Spinbox widget for numeric or list-based stepped input.""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, List, Union, Literal + +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget + + +class Spinbox(BHoMBaseWidget): + """A spinbox widget supporting numeric ranges or explicit value lists.""" + + def __init__( + self, + parent, + values: Optional[List[str]] = None, + from_: Optional[Union[int, float]] = None, + to: Optional[Union[int, float]] = None, + increment: Union[int, float] = 1, + default: Optional[Union[str, int, float]] = None, + command: Optional[Callable[[str], None]] = None, + width: int = 10, + wrap: bool = False, + **kwargs): + """ + Args: + parent (tk.Widget): The parent widget. + values (list, optional): Explicit list of string values to step through. + If provided, ``from_``, ``to`` and ``increment`` are ignored. + from_ (int | float, optional): Minimum value for numeric mode. + to (int | float, optional): Maximum value for numeric mode. + increment (int | float): Step size for numeric mode. Defaults to 1. + default (str | int | float, optional): Initial value. + command (callable, optional): Called with the current value (str) on change. + width (int): Width of the entry in characters. + wrap (bool): Whether stepping wraps around at the limits. + item_title (str, optional): Optional header text. + helper_text (str, optional): Optional helper text. + **kwargs: Additional Frame options. + """ + super().__init__(parent, **kwargs) + + self.command = command + self._value_var = tk.StringVar() + + spinbox_kwargs: dict = dict( + textvariable=self._value_var, + width=width, + wrap=wrap, + ) + + if values: + spinbox_kwargs["values"] = [str(v) for v in values] + else: + if from_ is not None: + spinbox_kwargs["from_"] = from_ + if to is not None: + spinbox_kwargs["to"] = to + spinbox_kwargs["increment"] = increment + + self.spinbox = ttk.Spinbox(self.content_frame, **spinbox_kwargs) + self.spinbox.pack(side="top", anchor=self._pack_anchor) + + self._value_var.trace_add("write", self._on_change) + + if default is not None: + self.set(default) + elif values: + self._value_var.set(str(values[0])) + elif from_ is not None: + self._value_var.set(str(from_)) + + def _on_change(self, *_): + """Fire the command callback when the value changes.""" + if self.command: + self.command(self.get()) + + def get(self) -> str: + """Return the current value as a string. + + Returns: + str: Current spinbox value. + """ + return self._value_var.get() + + def get_int(self) -> int: + """Return the current value as an integer. + + Returns: + int: Current spinbox value. + + Raises: + ValueError: If the value cannot be converted to int. + """ + return int(self.get()) + + def get_float(self) -> float: + """Return the current value as a float. + + Returns: + float: Current spinbox value. + + Raises: + ValueError: If the value cannot be converted to float. + """ + return float(self.get()) + + def set(self, value: Union[str, int, float]): + """Set the spinbox value. + + Args: + value: New value to display. + """ + self._value_var.set(str(value)) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """Validate that the current value is non-empty. + + Returns: + tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + ``(is_valid, message, severity)``. + """ + if not self.get().strip(): + return getattr(self, "apply_validation")((False, "A value is required.", "error")) + return getattr(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 + + def on_change(value): + print(f"Value: {value}") + + root = BHoMBaseWindow() + parent_frame = root.content_frame + + # Numeric spinbox + numeric = Spinbox( + parent_frame, + from_=0, + to=100, + increment=5, + default=25, + command=on_change, + item_title="Numeric", + helper_text="Pick a value between 0 and 100", + build_options=PackingOptions(padx=20, pady=(20, 8)), + width = 1, + ) + numeric.build() + + # List-based spinbox + list_spin = Spinbox( + parent_frame, + values=["Small", "Medium", "Large", "X-Large"], + default="Medium", + command=on_change, + item_title="Size", + helper_text="Step through available sizes", + build_options=PackingOptions(padx=20, pady=(0, 20)), + width=100 + ) + list_spin.build() + + root.mainloop() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py index 65b1d8d..e735c6e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/validated_entry_box.py @@ -7,6 +7,8 @@ from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +_TkVar = Union[tk.StringVar, tk.IntVar, tk.DoubleVar, tk.BooleanVar] + class ValidatedEntryBox(BHoMBaseWidget): """ A reusable entry box component with built-in validation for different data types. @@ -21,8 +23,8 @@ class ValidatedEntryBox(BHoMBaseWidget): def __init__( self, parent, - variable: Optional[tk.StringVar] = None, - default_value: Optional[Union[str, int, float]] = None, + variable: Optional[_TkVar] = None, + default_value: Optional[Union[str, int, float, bool]] = None, width: int = 15, value_type: type = str, min_value: Optional[Union[int, float]] = None, @@ -41,9 +43,11 @@ def __init__( parent: Parent item_title: Optional header text shown at the top of the widget frame requirements_text: Optional helper text shown above the entry box - variable: StringVar to bind to the entry (creates one if not provided) + variable: StringVar, IntVar, DoubleVar or BooleanVar to bind to the entry + (creates a StringVar if not provided). width: Width of the entry widget - value_type: Type to validate against (str, int, float) + value_type: Type to validate against (str, int, float, bool). + Inferred automatically when an IntVar, DoubleVar or BooleanVar is passed. min_value: Minimum value for numeric types max_value: Maximum value for numeric types min_length: Minimum length for string type @@ -53,6 +57,16 @@ def __init__( on_validate: Callback function called after validation with validation result """ super().__init__(parent, **kwargs) + + # Infer value_type from the variable kind when not explicitly set + if variable is not None and value_type is str: + if isinstance(variable, tk.IntVar): + value_type = int + elif isinstance(variable, tk.DoubleVar): + value_type = float + elif isinstance(variable, tk.BooleanVar): + value_type = bool + self.value_type = value_type self.min_value = min_value self.max_value = max_value @@ -63,9 +77,23 @@ def __init__( self.on_validate = on_validate self.default_value = default_value - # Create or use provided StringVar - self.variable = variable if variable is not None else tk.StringVar(value="") - + # Store the external variable (any tkinter var type) + self._external_var: Optional[_TkVar] = variable + + # ttk.Entry only works with StringVar — always use one internally + self.variable = tk.StringVar(value="") + + if variable is not None: + if isinstance(variable, tk.StringVar): + # Use it directly instead of bridging + self.variable = variable + self._external_var = None + else: + # Seed the StringVar from the external var's current value + self.variable.set(str(variable.get())) + # Write back to external var when the StringVar changes + self.variable.trace_add("write", self._sync_to_external) + # Create frame for entry and success indicator self.entry_frame = ttk.Frame(self.content_frame) self.entry_frame.pack(side="top", fill="x", anchor=self._pack_anchor) @@ -101,6 +129,24 @@ def __init__( # Bind validation events self.entry.bind("", lambda _: self.validate()) self.entry.bind("", lambda _: self.validate()) + + if default_value is not None: + self.set(default_value) + + def _sync_to_external(self, *_) -> None: + """Write the current StringVar text back to the external typed variable.""" + if self._external_var is None: + return + raw = self.variable.get() + try: + if isinstance(self._external_var, tk.IntVar): + self._external_var.set(int(raw)) + elif isinstance(self._external_var, tk.DoubleVar): + self._external_var.set(float(raw)) + elif isinstance(self._external_var, tk.BooleanVar): + self._external_var.set(raw.lower() in ("1", "true", "yes")) + except (ValueError, TypeError): + pass # Ignore mid-edit invalid states def get(self) -> str: """Get the current value as a string. @@ -110,11 +156,11 @@ def get(self) -> str: """ return self.variable.get().strip() - def get_value(self) -> Optional[Union[str, int, float]]: + def get_value(self) -> Optional[Union[str, int, float, bool]]: """Get the current value converted to the specified type. Returns: - Optional[Union[str, int, float]]: Parsed value, or `None` when empty/invalid. + Optional[Union[str, int, float, bool]]: Parsed value, or ``None`` when empty/invalid. """ value_str = self.get() if not value_str: @@ -125,12 +171,14 @@ def get_value(self) -> Optional[Union[str, int, float]]: return int(value_str) elif self.value_type == float: return float(value_str) + elif self.value_type == bool: + return value_str.lower() in ("1", "true", "yes") else: return value_str except (ValueError, TypeError): return None - def set(self, value: Union[str, int, float]) -> None: + def set(self, value: Union[str, int, float, bool]) -> None: """Set the entry value. Args: @@ -176,6 +224,8 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni is_valid = self._validate_int(value_str) elif self.value_type == float: is_valid = self._validate_float(value_str) + elif self.value_type == bool: + is_valid = self._validate_bool(value_str) else: self._show_error(f"Unsupported type: {self.value_type}") self._call_validate_callback(False) @@ -319,6 +369,32 @@ def _validate_float(self, value_str: str) -> bool: self._call_validate_callback(True) return True + def _validate_bool(self, value_str: str) -> bool: + """Validate boolean value (accepts 1/0, true/false, yes/no). + + Args: + value_str: Raw entry text to interpret as boolean. + + Returns: + bool: ``True`` when valid, otherwise ``False``. + """ + if value_str.lower() not in ("1", "0", "true", "false", "yes", "no"): + self._show_error("Must be true/false, yes/no, or 1/0") + self._call_validate_callback(False) + return False + + if self.custom_validator: + parsed = value_str.lower() in ("1", "true", "yes") + is_valid, error_msg = self.custom_validator(parsed) + if not is_valid: + self._show_error(error_msg) + self._call_validate_callback(False) + return False + + self._show_success() + self._call_validate_callback(True) + return True + def _show_error(self, message: str) -> None: """Display error message.""" self.error_label.set(message) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py index a0b2849..a2b0045 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/landing_page.py @@ -107,12 +107,19 @@ def add_custom_button(self, text: str, command: Callable, **kwargs) -> ttk.Butto def on_button_click(): """Handle demo button clicks in the standalone example.""" print("Button clicked!") + + def on_button_click_2(): + """Handle demo button clicks in the standalone example.""" + print("Second button clicked!") landing_page = LandingPage( title="Welcome to the BHoM Toolkit", header="Welcome to the BHoM Toolkit", message="This is a landing page example. You can add custom buttons below.", sub_title="Please click the button to proceed.", + show_close=True, + show_submit=False, ) landing_page.add_custom_button(text="Click Me", command=on_button_click) + landing_page.add_custom_button(text="Click Me 2", command=on_button_click_2) landing_page.mainloop() \ No newline at end of file From 67c4f2f8b8fae4bf32dd84c188af516cafc2e1da Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 16 Mar 2026 14:41:14 +0000 Subject: [PATCH 12/13] radio styling change. --- .../bhom_tkinter/theming/bhom_dark_theme.tcl | 45 +++++++++++++++++++ .../bhom_tkinter/theming/bhom_light_theme.tcl | 45 +++++++++++++++++++ .../bhom_tkinter/widgets/cmap_selector.py | 21 +++++++-- .../bhom_tkinter/widgets/radio_selection.py | 39 ++++------------ .../bhom_tkinter/widgets/spinbox.py | 22 ++++++--- 5 files changed, 133 insertions(+), 39 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl index d0b571b..acedbf7 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_dark_theme.tcl @@ -444,6 +444,51 @@ namespace eval ttk::theme::bhom_dark { active $colors(-hover-bg) \ disabled $colors(-disabled-bg)] + # Larger radiobutton variant used by RadioSelection widget + ttk::style layout Radio.TRadiobutton { + Radiobutton.padding -sticky nswe -children { + Radiobutton.indicator -side left -sticky {} + Radiobutton.label -side left -sticky w + } + } + + ttk::style configure Radio.TRadiobutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorsize 15 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorbackground $colors(-inputbg) \ + -indicatorforeground $colors(-inputbg) \ + -upperbordercolor $colors(-border) \ + -lowerbordercolor $colors(-border) + + ttk::style map Radio.TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorforeground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -upperbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] \ + -lowerbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] + # Scrollbar - minimal sleek design without arrows ttk::style configure TScrollbar \ -background $colors(-border) \ diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl index c5c6a2b..5730d3c 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/theming/bhom_light_theme.tcl @@ -447,6 +447,51 @@ namespace eval ttk::theme::bhom_light { active $colors(-hover-bg) \ disabled $colors(-disabled-bg)] + # Larger radiobutton variant used by RadioSelection widget + ttk::style layout Radio.TRadiobutton { + Radiobutton.padding -sticky nswe -children { + Radiobutton.indicator -side left -sticky {} + Radiobutton.label -side left -sticky w + } + } + + ttk::style configure Radio.TRadiobutton \ + -font {{Segoe UI} 11} \ + -padding {6 8} \ + -indicatormargin {0 0 10 0} \ + -indicatorsize 15 \ + -borderwidth 0 \ + -relief flat \ + -focusthickness 0 \ + -indicatorbackground $colors(-inputbg) \ + -indicatorforeground $colors(-inputbg) \ + -upperbordercolor $colors(-border) \ + -lowerbordercolor $colors(-border) + + ttk::style map Radio.TRadiobutton \ + -background [list \ + active $colors(-bg) \ + selected $colors(-bg)] \ + -foreground [list \ + active $colors(-primary) \ + disabled $colors(-disabled-fg)] \ + -indicatorbackground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -indicatorforeground [list \ + selected $colors(-primary) \ + active $colors(-inputbg) \ + disabled $colors(-disabled-bg)] \ + -upperbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] \ + -lowerbordercolor [list \ + selected $colors(-primary) \ + active $colors(-border) \ + disabled $colors(-disabled-bg)] + # Scrollbar - minimal sleek design without arrows ttk::style configure TScrollbar \ -background $colors(-border) \ 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 7f47f47..410b7bb 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 @@ -64,6 +64,7 @@ def __init__( cmap_bins: int = 256, default_cmap: Optional[str] = None, plot_size: tuple[int, int] = (400, 50), + dropdown_position: Literal["n", "e", "s", "w"] = "n", **kwargs ) -> None: """ @@ -76,6 +77,9 @@ def __init__( cmap_set: Preset colormap set to use when colormaps is None. Allowed values: "all", "continuous", "categorical". default_cmap: Optional default colormap to select. + dropdown_position: Position of the dropdown relative to the plot. + "n" = above, "s" = below, "w" = left, "e" = right. + Defaults to "w". **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) @@ -105,11 +109,19 @@ def __init__( content.grid(row=0, column=0, padx=0, pady=4, sticky=self._grid_sticky) content.grid_propagate(False) - header = ttk.Frame(content) - header.pack(fill=tk.X, anchor=self._pack_anchor, padx=0, pady=(8, 4)) - self.cmap_set_var = tk.StringVar(value=cmap_set.lower()) + pos = dropdown_position.lower() + is_horizontal = pos in ("w", "e") + pack_side_combo = {"n": tk.TOP, "s": tk.BOTTOM, "w": tk.LEFT, "e": tk.RIGHT}[pos] + pack_side_figure = {"n": tk.TOP, "s": tk.TOP, "w": tk.LEFT, "e": tk.LEFT}[pos] + + combo_padx = (0, 4) if is_horizontal else 0 + combo_pady = (8, 4) if not is_horizontal else 0 + + header = ttk.Frame(content) + header.pack(side=pack_side_combo, anchor=self._pack_anchor, padx=combo_padx, pady=combo_pady) + self.cmap_combobox = ttk.Combobox( header, textvariable=self.colormap_var, @@ -119,11 +131,12 @@ def __init__( self.cmap_combobox.pack(side=tk.TOP, anchor=self._pack_anchor, padx=0) self.cmap_combobox.bind("<>", self._on_cmap_selected) + fill_mode = tk.Y if is_horizontal else tk.X self.figure_widget = FigureContainer( content, width=plot_size[0], height=plot_size[1], - build_options=PackingOptions(anchor=self._pack_anchor, padx=0, pady=(0, 8)), + build_options=PackingOptions(side=pack_side_figure, anchor=self._pack_anchor, fill=fill_mode, padx=0, pady=(0, 8)), ) self.figure_widget.build() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py index 28c3632..e03a2d5 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/radio_selection.py @@ -1,8 +1,7 @@ -"""Single-select radio-style widget built from clickable labels.""" +"""Single-select radio-style widget built from ttk Radiobuttons.""" import tkinter as tk from tkinter import ttk -from python_toolkit.bhom_tkinter.widgets.label import Label from typing import Optional, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget @@ -65,22 +64,16 @@ def _build_buttons(self): for index, field in enumerate(self.fields): sticky = self._grid_sticky - button = Label( + button = ttk.Radiobutton( self.buttons_frame, - text=f"○ {field}", - style="Body.TLabel", + text=field, + variable=self.value_var, + value=field, + style="Radio.TRadiobutton", + command=lambda f=field: self._select_field(f), ) self.align_child_text(button) - # Bind clicks on both wrapper and inner label so user clicks register - button.bind("", lambda _event, f=field: self._select_field(f)) - try: - button.label.configure(cursor="hand2") - except Exception: - pass - try: - button.label.bind("", lambda _event, f=field: self._select_field(f)) - except Exception: - pass + if self.max_per_line and self.max_per_line > 0: if self.orient == "horizontal": row = index // self.max_per_line @@ -120,23 +113,10 @@ def _build_buttons(self): self._buttons.append(button) def _select_field(self, field): - """Select a field when clicked.""" - self.value_var.set(field) - self._update_visual_state() + """Handle radio button selection.""" if self.command: self.command(self.get()) - def _update_visual_state(self): - """Update visual indicators for all buttons.""" - selected_value = self.value_var.get() - for button in self._buttons: - button_text = button.get() - current_field = button_text[2:] - if current_field == selected_value: - button.set(f"● {current_field}") - else: - button.set(f"○ {current_field}") - def get(self): """Return the currently selected value. @@ -154,7 +134,6 @@ def set(self, value): value = str(value) if value in self.fields: self.value_var.set(value) - self._update_visual_state() def set_fields(self, fields, default=None): """Replace the available fields and rebuild the widget. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py index b1ae63f..7c7fddc 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py @@ -13,7 +13,7 @@ class Spinbox(BHoMBaseWidget): def __init__( self, parent, - values: Optional[List[str]] = None, + values: Optional[Union[List[str], List[int], List[float]]] = None, from_: Optional[Union[int, float]] = None, to: Optional[Union[int, float]] = None, increment: Union[int, float] = 1, @@ -43,6 +43,14 @@ def __init__( self.command = command self._value_var = tk.StringVar() + # Determine the native type for get() coercion + if values and len(values) > 0: + self._value_type = type(values[0]) + elif from_ is not None: + self._value_type = type(from_) + else: + self._value_type = str + spinbox_kwargs: dict = dict( textvariable=self._value_var, width=width, @@ -75,13 +83,17 @@ def _on_change(self, *_): if self.command: self.command(self.get()) - def get(self) -> str: - """Return the current value as a string. + def get(self) -> Union[str, int, float]: + """Return the current value cast to its original type. Returns: - str: Current spinbox value. + str | int | float: Current spinbox value in its original type. """ - return self._value_var.get() + raw = self._value_var.get() + try: + return self._value_type(raw) + except (ValueError, TypeError): + return raw def get_int(self) -> int: """Return the current value as an integer. From bd492217597128064cd1e184e86dd225974e57a4 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 16 Mar 2026 17:00:36 +0000 Subject: [PATCH 13/13] spin box fix --- .../python_toolkit/bhom_tkinter/bhom_base_window.py | 11 +++++++++++ .../python_toolkit/bhom_tkinter/widgets/spinbox.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py index e7c7813..5b5bd07 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/bhom_base_window.py @@ -51,6 +51,7 @@ def __init__( theme_mode:str = "auto", widgets: Optional[List[BHoMBaseWidget]] = None, top_most: bool = True, + fullscreen: bool = False, buttons_side: Literal["left", "right"] = "right", grid_dimensions: Optional[tuple[int, int]] = None, **kwargs @@ -77,6 +78,7 @@ def __init__( on_close_window (callable, optional): Command when X is pressed. theme_path (Path, optional): Path to custom TCL theme file. If None, uses default style.tcl. theme_mode (str): Theme mode - "light", "dark", or "auto" to detect from system (default: "auto"). + fullscreen (bool): Whether the window starts in fullscreen mode (default: False). buttons_side (str): Side for buttons - "left" or "right" (default: "right"). grid_dimensions (tuple[int, int], optional): If provided, configures content area with specified rows and columns for grid layout. **kwargs @@ -91,6 +93,8 @@ def __init__( if self.top_most: self.attributes("-topmost", True) + self.fullscreen = fullscreen + # Avoid sharing widget instances across windows/runs. self.widgets = list(widgets) if widgets is not None else [] @@ -465,6 +469,13 @@ def _apply_sizing(self) -> None: else: final_height = max(self.min_height, required_height) + # Fullscreen overrides normal sizing/positioning + if self.fullscreen: + self.attributes("-fullscreen", True) + self.after(0, self._show_window_with_styling) + self._is_resizing = False + return + # Position if self.center_on_screen and not self._has_been_shown: screen_width = self.winfo_screenwidth() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py index 7c7fddc..8154c32 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/spinbox.py @@ -132,7 +132,7 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: ``(is_valid, message, severity)``. """ - if not self.get().strip(): + if not str(self.get()).strip(): return getattr(self, "apply_validation")((False, "A value is required.", "error")) return getattr(self, "apply_validation")((True, None, None))