From f70a772babc2b3c67eca140076553263fee5ce7c Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 30 Apr 2026 14:46:06 +0100 Subject: [PATCH 1/6] fixed user entered path being mismatched to the var --- .../bhom_tkinter/widgets/path_selector.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py index 2f0164cf..e9335543 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py @@ -40,8 +40,7 @@ def __init__( self.mode = mode self.initialdir = initialdir self.filetypes = filetypes if filetypes is not None else [("All Files", "*.*")] - self.display_name = tk.StringVar() - self.entry = ttk.Entry(self.content_frame, textvariable=self.display_name, width=entry_width) + self.entry = ttk.Entry(self.content_frame, textvariable=self.path_var, width=entry_width) self.entry.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True) # Use Button wrapper but expose inner ttk.Button for backward compatibility @@ -62,10 +61,6 @@ def _on_click(self): if path: selected_path = Path(path) self.path_var.set(str(selected_path)) - if self.mode == "directory": - self.display_name.set(str(selected_path)) - else: - self.display_name.set(selected_path.name) if self.command: self.command(str(selected_path)) @@ -85,16 +80,9 @@ def set(self, value: Optional[str]): """ if not value: self.path_var.set("") - self.display_name.set("") return - selected_path = Path(value) - self.path_var.set(str(selected_path)) - if self.mode == "directory": - self.display_name.set(str(selected_path)) - - else: - self.display_name.set(selected_path.name) + self.path_var.set(str(Path(value))) def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the currently selected path. From c2315f94d1fa31256609d77cb5654996b51d118b Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 30 Apr 2026 15:04:41 +0100 Subject: [PATCH 2/6] bug review, for null / get / set / validation issues --- .../bhom_tkinter/bhom_base_window.py | 4 +++ .../bhom_tkinter/widgets/_widgets_base.py | 9 +++--- .../bhom_tkinter/widgets/button.py | 12 ++++---- .../widgets/check_box_selection.py | 9 ------ .../bhom_tkinter/widgets/colour_picker.py | 10 +++++-- .../widgets/drop_down_selection.py | 4 +-- .../bhom_tkinter/widgets/figure_container.py | 10 +------ .../bhom_tkinter/widgets/list_box.py | 2 ++ .../bhom_tkinter/widgets/spinbox.py | 30 ++++++++++++++++--- .../widgets/validated_entry_box.py | 21 ------------- .../bhom_tkinter/widgets/widget_calendar.py | 7 +++++ 11 files changed, 60 insertions(+), 58 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 86580666..fdb07980 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 @@ -555,6 +555,10 @@ def refresh_sizing(self) -> None: """Recalculate and apply window sizing (useful after adding widgets).""" self._apply_sizing() + def close(self) -> None: + """Close and destroy the window. Override in subclasses for custom close behaviour.""" + self.destroy_root() + def destroy_root(self) -> None: """Safely terminate and destroy the Tk root window.""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py index 8dc3c52b..b2959362 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py @@ -212,10 +212,11 @@ def align_child_text(self, widget: tk.Widget, alignment: Optional[Literal['left' if alignment is not None: self.alignment = self._normalise_alignment(alignment) - self._apply_text_alignment(widget) - - if alignment is not None: - self.alignment = previous_alignment + try: + self._apply_text_alignment(widget) + finally: + if alignment is not None: + self.alignment = previous_alignment def set_alignment(self, alignment: Literal['left', 'center', 'right']) -> None: """Set widget-wide alignment and refresh built-in labels. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py index 1c57523c..d51c7542 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/button.py @@ -28,6 +28,7 @@ def __init__( super().__init__(parent, **kwargs) self._user_command = command + self._click_count = 0 self.button = ttk.Button( self.content_frame, text=text, @@ -40,20 +41,21 @@ def __init__( def _on_click(self): """Internal click handler increments counter and calls user callback.""" + self._click_count += 1 if self._user_command: try: self._user_command() except Exception as e: CONSOLE_LOGGER.error(f"Unhandled exception when trying to perform custom command: {e}", exc_info=True) - def get(self): - """Return None as nothing to get.""" - return None + """Return the number of times the button has been clicked.""" + return self._click_count def set(self, value): - """No set method.""" - pass + """Update the button label text when passed a string.""" + if isinstance(value, str): + self.button.configure(text=value) def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Button has no user-editable state, so validation is always valid unless overridden.""" 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 e17bbe26..9f8f900b 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 @@ -5,7 +5,6 @@ from typing import Optional, List, Callable, Tuple, Literal from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget -from python_toolkit.bhom_tkinter.widgets.button import Button class CheckboxSelection(BHoMBaseWidget): """A reusable checkbox selection widget built from a list of fields, allowing multiple selections.""" @@ -151,14 +150,6 @@ def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): if defaults: self.set(defaults) - def pack(self, **kwargs): - """Pack the widget with the given options. - - Args: - **kwargs: Pack geometry manager options. - """ - super().pack(**kwargs) - def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the current selection against min/max constraints. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py index b0f498e0..6e26e167 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -233,15 +233,19 @@ def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warni `(is_valid, message, severity)` where severity is `None` when valid, or `"error"` for an invalid colour. """ - colour = self.get() if not colour: return self.apply_validation((False, "No colour selected.", "error")) + stripped = colour.strip().lstrip("#") + if len(stripped) == 3: + stripped = "".join(ch * 2 for ch in stripped) + if len(stripped) != 6: + return self.apply_validation((False, f"Invalid colour value: '{colour}'.", "error")) try: - self._hex_to_rgb(colour) - return self.apply_validation((True, None, None)) + int(stripped, 16) except ValueError: return self.apply_validation((False, f"Invalid colour value: '{colour}'.", "error")) + return self.apply_validation((True, None, None)) if __name__ == "__main__": 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 aa326ceb..19612293 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 @@ -75,15 +75,13 @@ def get(self) -> str: return self.value_var.get() def set(self, value: str): - """Set the selected value. + """Set the selected value. Silently ignores values not in the current options. Args: value: Option value to select. """ if value in self.options: self.value_var.set(value) - else: - raise ValueError(f"Value '{value}' not in available options: {self.options}") def set_options(self, options: List[str], default: Optional[str] = None): """Replace the available options and optionally set a new default. 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 ad0282e6..b7297b70 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 @@ -50,15 +50,6 @@ def __init__( self._fit_after_id: Optional[str] = None self._fit_attempts: int = 0 - if self.image: - self.embed_image(self.image) - - elif self.figure: - self.embed_figure(self.figure) - - elif self.image_file: - self.embed_image_file(self.image_file) - def _clear_children(self) -> None: """Destroy any child widgets hosted by the content frame only.""" if self._fit_after_id is not None: @@ -296,6 +287,7 @@ def clear(self) -> None: self.image = None self.image_label = None self._original_pil_image = None + self.image_file = None def get(self): """Return the currently embedded figure or image. 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 72478d73..bf70de2a 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 @@ -86,7 +86,9 @@ def _on_configure(self, event=None): """Hide scrollbar if all items fit in the visible area.""" if self.listbox.size() <= int(self.listbox.cget("height")): self.scrollbar.grid_forget() + self.listbox.grid_configure(columnspan=2) else: + self.listbox.grid_configure(columnspan=1) self.scrollbar.grid(row=0, column=1, sticky="ns") def _on_selection_change(self, _event=None): 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 8154c32f..fe084a51 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 @@ -43,6 +43,11 @@ def __init__( self.command = command self._value_var = tk.StringVar() + # Store range/list constraints for validation + self._from = from_ + self._to = to + self._allowed_values: Optional[list] = [str(v) for v in values] if values else None + # Determine the native type for get() coercion if values and len(values) > 0: self._value_type = type(values[0]) @@ -69,8 +74,6 @@ def __init__( 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: @@ -78,6 +81,10 @@ def __init__( elif from_ is not None: self._value_var.set(str(from_)) + # Attach the trace after the initial value is set so the callback + # does not fire during construction. + self._value_var.trace_add("write", self._on_change) + def _on_change(self, *_): """Fire the command callback when the value changes.""" if self.command: @@ -126,14 +133,29 @@ def set(self, value: Union[str, int, float]): 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. + """Validate that the current value is non-empty and within any configured range. Returns: tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: ``(is_valid, message, severity)``. """ - if not str(self.get()).strip(): + raw = str(self.get()).strip() + if not raw: return getattr(self, "apply_validation")((False, "A value is required.", "error")) + + if self._value_type in (int, float): + try: + numeric_val = self._value_type(raw) + except (ValueError, TypeError): + label = "integer" if self._value_type == int else "number" + return getattr(self, "apply_validation")((False, f"Must be a valid {label}.", "error")) + if self._from is not None and numeric_val < self._from: + return getattr(self, "apply_validation")((False, f"Must be >= {self._from}.", "error")) + if self._to is not None and numeric_val > self._to: + return getattr(self, "apply_validation")((False, f"Must be <= {self._to}.", "error")) + elif self._allowed_values is not None and raw not in self._allowed_values: + return getattr(self, "apply_validation")((False, f"Value must be one of: {', '.join(self._allowed_values)}.", "error")) + return getattr(self, "apply_validation")((True, None, None)) 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 e735c6e9..183aee3a 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 @@ -265,12 +265,10 @@ def _validate_string(self, value: str) -> bool: # Check length constraints if self.min_length is not None and len(value) < self.min_length: self._show_error(f"Minimum length: {self.min_length}") - self._call_validate_callback(False) return False if self.max_length is not None and len(value) > self.max_length: self._show_error(f"Maximum length: {self.max_length}") - self._call_validate_callback(False) return False # Custom validation @@ -278,11 +276,8 @@ def _validate_string(self, value: str) -> bool: is_valid, error_msg = self.custom_validator(value) 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 _validate_int(self, value_str: str) -> bool: @@ -298,18 +293,15 @@ def _validate_int(self, value_str: str) -> bool: value = int(value_str) except ValueError: self._show_error("Must be a valid integer") - self._call_validate_callback(False) return False # Check range constraints if self.min_value is not None and value < self.min_value: self._show_error(f"Must be >= {self.min_value}") - self._call_validate_callback(False) return False if self.max_value is not None and value > self.max_value: self._show_error(f"Must be <= {self.max_value}") - self._call_validate_callback(False) return False # Custom validation @@ -317,11 +309,8 @@ def _validate_int(self, value_str: str) -> bool: is_valid, error_msg = self.custom_validator(value) 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 _validate_float(self, value_str: str) -> bool: @@ -337,7 +326,6 @@ def _validate_float(self, value_str: str) -> bool: value = float(value_str) except ValueError: self._show_error("Must be a valid number") - self._call_validate_callback(False) return False # Check range constraints @@ -346,7 +334,6 @@ def _validate_float(self, value_str: str) -> bool: self._show_error(f"Must be between {self.min_value} and {self.max_value}") else: self._show_error(f"Must be >= {self.min_value}") - self._call_validate_callback(False) return False if self.max_value is not None and value > self.max_value: @@ -354,7 +341,6 @@ def _validate_float(self, value_str: str) -> bool: self._show_error(f"Must be between {self.min_value} and {self.max_value}") else: self._show_error(f"Must be <= {self.max_value}") - self._call_validate_callback(False) return False # Custom validation @@ -362,11 +348,8 @@ def _validate_float(self, value_str: str) -> bool: is_valid, error_msg = self.custom_validator(value) 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 _validate_bool(self, value_str: str) -> bool: @@ -380,7 +363,6 @@ def _validate_bool(self, value_str: str) -> bool: """ 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: @@ -388,11 +370,8 @@ def _validate_bool(self, value_str: str) -> bool: 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: 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 cc46d6c8..122161de 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 @@ -146,6 +146,8 @@ def set_day(self, num): Args: num: Day of month to mark as selected. """ + if not num or num <= 0: + return self.day = num for child in self.date_frame.winfo_children(): @@ -181,7 +183,12 @@ def set(self, value: datetime.date): self.year = value.year self.month = value.month self.day = value.day + if hasattr(self, 'year_dropdown'): + self.year_dropdown.set(str(self.year)) + if hasattr(self, 'month_dropdown'): + self.month_dropdown.set(self.months[self.month - 1]) self.redraw() + self.set_day(self.day) def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the currently selected date. From 54d1bd1001eca3a400b0c15f1d61108d888eb118 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 30 Apr 2026 15:10:36 +0100 Subject: [PATCH 3/6] fix alignment / visual bugs --- .../bhom_tkinter/widgets/check_box_selection.py | 2 +- .../src/python_toolkit/bhom_tkinter/widgets/colour_picker.py | 2 +- .../bhom_tkinter/widgets/drop_down_selection.py | 2 +- .../python_toolkit/bhom_tkinter/widgets/radio_selection.py | 5 ++++- .../python_toolkit/bhom_tkinter/widgets/widget_calendar.py | 5 ++++- 5 files changed, 11 insertions(+), 5 deletions(-) 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 9f8f900b..1a81801a 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 @@ -50,7 +50,7 @@ def __init__( # Sub-frame for checkbox controls self.buttons_frame = ttk.Frame(self.content_frame) - self.buttons_frame.pack(side="top", fill="x", expand=True) + self.buttons_frame.pack(side="top", anchor=self._pack_anchor) self._build_buttons() diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py index 6e26e167..f0f36f07 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -53,7 +53,7 @@ def __init__( highlightthickness=1, cursor="hand2", ) - self.preview.pack() + self.preview.pack(anchor=self._pack_anchor) self._swatch = self.preview.create_rectangle(0, 0, self.swatch_width, self.swatch_height, outline="#666666") self.preview.bind("", lambda _event: self._select_colour()) 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 19612293..9cd258ca 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 @@ -46,7 +46,7 @@ def __init__( state=state, justify=self._text_justify, ) - self.combobox.pack(side="top", anchor=self._pack_anchor, fill="x") + self.combobox.pack(side="top", fill="x") # Bind selection event self.combobox.bind("<>", self._on_select) 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 e03a2d58..c061b4dc 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 @@ -45,7 +45,10 @@ def __init__( # Sub-frame for radio button controls self.buttons_frame = ttk.Frame(self.content_frame) - self.buttons_frame.pack(side="top", fill="x", expand=True) + if self.options_fill_extents: + self.buttons_frame.pack(side="top", fill="x", expand=True) + else: + self.buttons_frame.pack(side="top", anchor=self._pack_anchor) self._build_buttons() 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 122161de..0b984ef3 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 @@ -56,7 +56,7 @@ def __init__( self.cal_frame.pack(side="top", fill="x") self.month_frame = ttk.Frame(self.content_frame) - self.month_frame.pack(side="top", fill="x") + self.month_frame.pack(side="top", anchor=self._pack_anchor) self.date_frame = ttk.Frame(self.content_frame) self.date_frame.pack(side="top", fill="x") @@ -114,6 +114,9 @@ def redraw(self): for child in self.cal_frame.winfo_children(): child.destroy() + for col in range(7): + self.cal_frame.columnconfigure(col, weight=1) + for col, day in enumerate(("Mo", "Tu", "We", "Th", "Fr", "Sa", "Su")): label = Label(self.cal_frame, text=day) self.align_child_text(label) From f393d6a5f23009fcf1e9ce7a7e679e5188bd321f Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 30 Apr 2026 15:49:11 +0100 Subject: [PATCH 4/6] log file bug --- .../src/python_toolkit/bhom/analytics.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py index 4b684a3e..843c1e69 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom/analytics.py +++ b/Python_Engine/Python/src/python_toolkit/bhom/analytics.py @@ -201,15 +201,18 @@ def wrapper(*args, **kwargs) -> Any: exec_metadata["Errors"].extend(convert_exc_info_to_bhom_error(sys.exc_info())) raise exc finally: - log_file = BHOM_LOG_FOLDER / f"Usage_{function.__module__.split('.')[0]}_{datetime.now().strftime('%Y%m%d')}.log" - - if ANALYTICS_LOGGER.handlers[0].baseFilename != str(log_file): - ANALYTICS_LOGGER.handlers[0].close() - ANALYTICS_LOGGER.handlers[0].baseFilename = str(log_file) - - ANALYTICS_LOGGER.info( - json.dumps(exec_metadata, default=str, indent=None) - ) + try: + log_file = BHOM_LOG_FOLDER / f"Usage_{function.__module__.split('.')[0]}_{datetime.now().strftime('%Y%m%d')}.log" + + if ANALYTICS_LOGGER.handlers[0].baseFilename != str(log_file): + ANALYTICS_LOGGER.handlers[0].close() + ANALYTICS_LOGGER.handlers[0].baseFilename = str(log_file) + + ANALYTICS_LOGGER.info( + json.dumps(exec_metadata, default=str, indent=None) + ) + except Exception: + pass return result From 30c871faad6da5a0a4d16cc10efa671320b0784a Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Thu, 30 Apr 2026 16:44:24 +0100 Subject: [PATCH 5/6] cmap selector can be initialised with colours and cmaps rather than string identifiers --- .../bhom_tkinter/widgets/cmap_selector.py | 442 +++++++++++++++--- .../bhom_tkinter/windows/landing_page.py | 2 +- 2 files changed, 370 insertions(+), 74 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py index 410b7bb9..94c1ba21 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 @@ -1,17 +1,32 @@ """Colormap selector widget with embedded matplotlib preview.""" -from typing import Dict, List, Optional, Literal +from typing import Dict, List, Optional, Literal, Union from tkinter import ttk import tkinter as tk import matplotlib as mpl +from matplotlib.colors import Colormap, ListedColormap from python_toolkit.plot.cmap_sample import cmap_sample_plot from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +# Accepted colormap input types for colormaps parameter and set()/add_cmap() +CmapInput = Union[str, Colormap, List[str]] + class CmapSelector(BHoMBaseWidget): - """ - A widget for selecting and previewing a matplotlib colormap. + """A widget for selecting and previewing a matplotlib colormap. + + The ``colormaps`` parameter accepts a mixed list of three input types which + may be freely combined: + + - ``str`` — a registered matplotlib colormap name (e.g. ``"viridis"``). + - ``Colormap`` — any matplotlib ``Colormap``, ``ListedColormap`` or + ``LinearSegmentedColormap`` object. + - ``List[str]`` — a list of colour strings (hex codes or named colours) + that is auto-converted to a ``ListedColormap``. + + All three types are fully backwards-compatible; existing code that passes + only string names continues to work without any changes. """ CATEGORICAL_CMAPS = [ @@ -59,10 +74,10 @@ class CmapSelector(BHoMBaseWidget): def __init__( self, parent: ttk.Frame, - colormaps: Optional[List[str]] = None, + colormaps: Optional[List[CmapInput]] = None, cmap_set: str = "all", cmap_bins: int = 256, - default_cmap: Optional[str] = None, + default_cmap: Optional[CmapInput] = None, plot_size: tuple[int, int] = (400, 50), dropdown_position: Literal["n", "e", "s", "w"] = "n", **kwargs @@ -71,24 +86,30 @@ def __init__( Initialize the CmapSelector widget. Args: - parent: Parent widget - colormaps: Optional explicit list of colormap names to include. - If provided, preset set selection is disabled. - 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 + parent: Parent widget. + colormaps: Optional list of colormaps to populate the selector. + Each item may be a ``str`` name, a ``Colormap`` object, or a + ``List[str]`` of colour strings. When provided the preset + ``cmap_set`` selection is ignored. + cmap_set: Preset colormap set used when ``colormaps`` is ``None``. + Allowed values: ``"all"``, ``"continuous"``, ``"categorical"``. + cmap_bins: Number of discrete gradient bands in the preview swatch. + default_cmap: Optional default colormap to pre-select. Accepts + the same input types as items in ``colormaps``. + plot_size: ``(width, height)`` in pixels for the preview swatch. + dropdown_position: Position of the dropdown relative to the + preview swatch. ``"n"`` = above, ``"s"`` = below, + ``"w"`` = left, ``"e"`` = right. + **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) - #set custom cmap args self.cmap_bins = cmap_bins - self.default_cmap = default_cmap self.plot_size = plot_size + # Registry of custom (non-matplotlib-named) colormaps: label → Colormap + self._custom_cmaps: Dict[str, Colormap] = {} + # Create frame for cmap selection content self.cmap_frame = ttk.Frame(self.content_frame) self.cmap_frame.pack(side="top", fill="both", expand=True, anchor=self._pack_anchor) @@ -113,8 +134,8 @@ def __init__( 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] + pack_side_combo: Literal["left", "right", "top", "bottom"] = {"n": "top", "s": "bottom", "w": "left", "e": "right"}[pos] # type: ignore[assignment] + pack_side_figure: Literal["left", "right", "top", "bottom"] = {"n": "top", "s": "top", "w": "left", "e": "left"}[pos] # type: ignore[assignment] combo_padx = (0, 4) if is_horizontal else 0 combo_pady = (8, 4) if not is_horizontal else 0 @@ -131,7 +152,7 @@ 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 + fill_mode: Literal["x", "y"] = "y" if is_horizontal else "x" self.figure_widget = FigureContainer( content, width=plot_size[0], @@ -141,11 +162,16 @@ def __init__( self.figure_widget.build() if self._uses_explicit_colormaps: - current_colormaps = self._with_reversed(self._filter_available(colormaps or [])) + display_names, custom_cmaps = self._resolve_colormaps(colormaps or []) + self._custom_cmaps.update(custom_cmaps) + current_colormaps = display_names else: current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) self._populate_cmap_list(current_colormaps) + + # Resolve default_cmap: may itself be a Colormap object or colour list + self._default_label = self._resolve_default_label(default_cmap, current_colormaps) self._select_default_cmap(current_colormaps) def _get_all_colormaps(self) -> List[str]: @@ -209,6 +235,97 @@ def _preset_colormaps(self, cmap_set: str) -> List[str]: key = (cmap_set or "all").lower() return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) + def _resolve_colormaps(self, colormaps: List[CmapInput]) -> tuple[List[str], Dict[str, Colormap]]: + """Normalise a mixed list of colormap inputs into display labels and a custom-cmap registry. + + Each item is handled as follows: + + - ``str``: treated as a named matplotlib colormap; silently skipped if + not registered in the current build. + - ``Colormap``: registered under ``cmap.name`` (or an auto-generated + label when the name is empty or conflicts with an existing entry). + - ``List[str]``: converted to a ``ListedColormap``; auto-labelled. + + Args: + colormaps: Mixed list of colormap inputs. + + Returns: + tuple[List[str], Dict[str, Colormap]]: Ordered display label list + and a mapping of label → Colormap for non-standard entries. + """ + display_names: List[str] = [] + custom_cmaps: Dict[str, Colormap] = {} + list_counter = 0 + + for item in colormaps: + if isinstance(item, str): + if item in self._all_colormaps and item not in display_names: + display_names.append(item) + elif isinstance(item, Colormap): + label = self._unique_label(item.name or "custom", display_names, custom_cmaps) + display_names.append(label) + custom_cmaps[label] = item + elif isinstance(item, list): + list_counter += 1 + try: + cmap = ListedColormap(item, name=f"custom_{list_counter}") + label = self._unique_label(cmap.name, display_names, custom_cmaps) + display_names.append(label) + custom_cmaps[label] = cmap + except Exception: + pass # Invalid colour strings - skip silently + + return display_names, custom_cmaps + + def _unique_label(self, candidate: str, existing: List[str], custom_map: Dict[str, Colormap]) -> str: + """Return a label that is unique within ``existing`` and ``custom_map``. + + Args: + candidate: Preferred label string. + existing: Already-used display labels. + custom_map: Already-registered custom colormap entries. + + Returns: + str: Unique label derived from ``candidate``. + """ + base = (candidate or "custom").strip() or "custom" + label = base + counter = 1 + while label in existing or label in custom_map: + label = f"{base}_{counter}" + counter += 1 + return label + + def _resolve_default_label(self, default: Optional[CmapInput], available: List[str]) -> Optional[str]: + """Resolve a ``default_cmap`` value of any input type to a display label. + + Args: + default: The raw ``default_cmap`` argument passed by the caller. + available: Currently populated display label list. + + Returns: + Optional[str]: Matching display label, or ``None`` when not found. + """ + if default is None: + return None + if isinstance(default, str): + return default if default in available else None + if isinstance(default, Colormap): + for label, cmap in self._custom_cmaps.items(): + if cmap is default: + return label + return None + if isinstance(default, list): + for label, cmap in self._custom_cmaps.items(): + if isinstance(cmap, ListedColormap): + try: + if list(cmap.colors) == default: # type: ignore[arg-type] + return label + except Exception: + pass + return None + return None + def _populate_cmap_list(self, colormaps: List[str]) -> None: """Replace the combobox options with the provided colormap names.""" self.cmap_combobox["values"] = tuple(colormaps) @@ -219,8 +336,9 @@ def _select_default_cmap(self, colormaps: List[str]) -> None: self.figure_widget.clear() self.colormap_var.set("") return - - self.colormap_var.set(self.default_cmap if self.default_cmap in colormaps else colormaps[0]) + + label = self._default_label if self._default_label in colormaps else colormaps[0] + self.colormap_var.set(label) self._update_cmap_sample() def _on_cmap_selected(self, event=None) -> None: @@ -230,91 +348,269 @@ def _on_cmap_selected(self, event=None) -> None: def _update_cmap_sample(self, *args) -> None: """Update the colormap sample plot. + Resolves the selected label to a ``Colormap`` object (for custom entries) + or a name string (for registered matplotlib colormaps), then delegates + to ``cmap_sample_plot``. + Args: *args: Unused callback arguments from Tk traces/events. """ - cmap_name = self.colormap_var.get() - if not cmap_name: + name = self.colormap_var.get() + if not name: self.figure_widget.clear() return - fig = cmap_sample_plot(cmap_name, figsize=(self.plot_size[0]/100, self.plot_size[1]/100), bins = self.cmap_bins) + # Use the stored Colormap object for custom entries; fall back to the + # name string for standard matplotlib colormaps. + cmap: Union[str, Colormap] = self._custom_cmaps.get(name, name) + fig = cmap_sample_plot(cmap, figsize=(self.plot_size[0] / 100, self.plot_size[1] / 100), bins=self.cmap_bins) self.figure_widget.embed_figure(fig) - def get_selected_cmap(self) -> Optional[str]: - """Return the currently selected colormap name, or `None` if empty. + def get_selected_cmap(self) -> Optional[Union[str, Colormap]]: + """Return the currently selected colormap. + + Returns the ``Colormap`` object for custom entries or the name string + for registered matplotlib colormaps. Returns ``None`` when nothing + is selected. Returns: - Optional[str]: Current colormap name. + Optional[Union[str, Colormap]]: Selected colormap or ``None``. """ - cmap_name = self.colormap_var.get() - return cmap_name if cmap_name else None + name = self.colormap_var.get() + if not name: + return None + return self._custom_cmaps.get(name, name) + + def get(self) -> Optional[Union[str, Colormap]]: + """Return the currently selected colormap. - def get(self) -> Optional[str]: - """Get the currently selected colormap name. + - For standard matplotlib colormaps: returns the name string (backwards + compatible with existing callers). + - For custom colormaps (objects or colour lists): returns the + ``Colormap`` object so it can be passed directly to matplotlib. Returns: - Optional[str]: Current colormap name. + Optional[Union[str, Colormap]]: Selected colormap or ``None``. """ return self.get_selected_cmap() - - def set(self, value: Optional[str]): - """Set the selected colormap by name. + + def get_cmap(self) -> Optional[Colormap]: + """Return the selected colormap always as a resolved ``Colormap`` object. + + Unlike ``get()``, this method resolves named matplotlib colormaps to + their ``Colormap`` object, making it suitable for direct use in + matplotlib calls regardless of input type. + + Returns: + Optional[Colormap]: Resolved ``Colormap``, or ``None`` when empty. + """ + name = self.colormap_var.get() + if not name: + return None + if name in self._custom_cmaps: + return self._custom_cmaps[name] + try: + return mpl.colormaps[name] + except KeyError: + return None + + def add_cmap(self, cmap: CmapInput, label: Optional[str] = None) -> Optional[str]: + """Dynamically add a new colormap entry to the selector. + + The new entry is appended to the combobox and becomes immediately + selectable. If ``label`` is not provided, one is derived from + ``cmap.name`` (for ``Colormap`` objects) or auto-generated. Args: - value: Colormap name to select. Clears selection when invalid. + cmap: Colormap to add. May be a ``str`` name, ``Colormap`` + object, or ``List[str]`` of colour strings. + label: Optional display label override. + + Returns: + Optional[str]: The display label used for the new entry, or + ``None`` if the input was invalid or already present. """ - if value and value in self.cmap_combobox["values"]: - self.colormap_var.set(value) - self._update_cmap_sample() + existing = list(self.cmap_combobox["values"]) + + if isinstance(cmap, str): + if cmap not in self._all_colormaps or cmap in existing: + return None + existing.append(cmap) + self._populate_cmap_list(existing) + return cmap + + if isinstance(cmap, Colormap): + base = label or cmap.name or "custom" + elif isinstance(cmap, list): + base = label or "custom" + try: + cmap = ListedColormap(cmap, name=base) + except Exception: + return None else: + return None + + final_label = self._unique_label(base, existing, self._custom_cmaps) + self._custom_cmaps[final_label] = cmap + existing.append(final_label) + self._populate_cmap_list(existing) + return final_label + + def set(self, value: Optional[CmapInput]) -> None: + """Set the selected colormap. + + Accepts the same input types as ``colormaps`` list items: + + - ``str``: selects by name if present in the combobox. + - ``Colormap``: selects the matching registered custom entry by object + identity. + - ``List[str]``: selects the matching ``ListedColormap`` entry by + colour list equality. + - ``None``: clears the selection. + + Args: + value: Colormap to select, or ``None`` to clear. + """ + if value is None: + self.figure_widget.clear() + self.colormap_var.set("") + return + + if isinstance(value, str): + if value in self.cmap_combobox["values"]: + self.colormap_var.set(value) + self._update_cmap_sample() + else: + self.figure_widget.clear() + self.colormap_var.set("") + return + + if isinstance(value, Colormap): + for label, cmap in self._custom_cmaps.items(): + if cmap is value: + self.colormap_var.set(label) + self._update_cmap_sample() + return + self.figure_widget.clear() + self.colormap_var.set("") + return + + if isinstance(value, list): + for label, cmap in self._custom_cmaps.items(): + if isinstance(cmap, ListedColormap): + try: + if list(cmap.colors) == value: # type: ignore[arg-type] + self.colormap_var.set(label) + self._update_cmap_sample() + return + except Exception: + pass self.figure_widget.clear() self.colormap_var.set("") def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: """Validate the current colormap selection. + Returns: tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: - `(is_valid, message, severity)` where severity is `None` when - valid, or `"error"` for an invalid selection. + ``(is_valid, message, severity)`` where severity is ``None`` + when valid, or ``"error"`` for an invalid selection. """ - selected_cmap = self.get_selected_cmap() - if selected_cmap is None: + name = self.colormap_var.get() + if not name: return self.apply_validation((False, "No colormap selected.", "error")) - if selected_cmap not in self.cmap_combobox["values"]: - return self.apply_validation((False, f"Selected colormap '{selected_cmap}' is not available.", "error")) + if name not in self.cmap_combobox["values"]: + return self.apply_validation((False, f"Selected colormap '{name}' is not available.", "error")) return self.apply_validation((True, None, None)) if __name__ == "__main__": from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + from matplotlib.colors import LinearSegmentedColormap + root = BHoMBaseWindow() parent_container = root.content_frame - cmap_selector = CmapSelector( - parent_container, - cmap_set="all", - item_title="Colormap Selector", - helper_text="Select a colormap from the list.", - build_options=PackingOptions(fill='both', expand=True), - cmap_bins=6, - plot_size=(400, 50), - alignment="left", + # Example 1 — Backwards-compatible: named strings only (existing behaviour) + selector_strings = CmapSelector( + parent_container, + colormaps=["viridis", "plasma", "inferno"], + default_cmap="plasma", + item_title="1. Named strings", + helper_text="Standard matplotlib colormap names.", + build_options=PackingOptions(fill="both", expand=True), + cmap_bins=64, + plot_size=(400, 40), ) - - cmap_selector_2 = CmapSelector( - parent_container, - cmap_set="categorical", - item_title="Categorical Colormap Selector", - helper_text="Select a categorical colormap from the list.", - build_options=PackingOptions(fill='both', expand=True), - cmap_bins=6, - plot_size=(50, 25), - alignment="right", + selector_strings.build() + + # Example 2 — Colour lists auto-converted to ListedColormap + bhom_blues = ["#cce5ff", "#66b2ff", "#0066cc", "#003d7a"] + selector_colours = CmapSelector( + parent_container, + colormaps=[ + bhom_blues, # list of hex strings + ["red", "orange", "yellow", "green"], # list of named colours + "tab10", # mixed with a named cmap + ], + default_cmap=bhom_blues, + item_title="2. Colour lists", + helper_text="Lists of hex / named colours are auto-converted to ListedColormap.", + build_options=PackingOptions(fill="both", expand=True), + cmap_bins=4, + plot_size=(400, 40), ) - - - - cmap_selector.build() - cmap_selector_2.build() - - root.mainloop() \ No newline at end of file + selector_colours.build() + + # Example 3 — Colormap objects (ListedColormap / LinearSegmentedColormap) + custom_listed = ListedColormap(["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728"], name="bhom_cat4") + custom_linear = LinearSegmentedColormap.from_list("warm_to_cool", ["#d73027", "#f46d43", "#abd9e9", "#4575b4"]) + selector_objects = CmapSelector( + parent_container, + colormaps=[ + custom_listed, # ListedColormap object + custom_linear, # LinearSegmentedColormap object + "coolwarm", # standard cmap alongside custom ones + ], + default_cmap=custom_linear, + item_title="3. Colormap objects", + helper_text="Colormap objects are accepted directly alongside named strings.", + build_options=PackingOptions(fill="both", expand=True), + cmap_bins=64, + plot_size=(400, 40), + ) + selector_objects.build() + + # Example 4 — Preset set (no explicit colormaps list); get_cmap() demo + selector_preset = CmapSelector( + parent_container, + cmap_set="categorical", + item_title="4. Preset set + get_cmap()", + helper_text="get() returns the name string; get_cmap() always returns a Colormap object.", + build_options=PackingOptions(fill="both", expand=True), + cmap_bins=12, + plot_size=(400, 40), + ) + selector_preset.build() + + # Example 5 — add_cmap() used after construction + selector_dynamic = CmapSelector( + parent_container, + colormaps=["viridis"], + item_title="5. Dynamic add_cmap()", + helper_text="add_cmap() appends a new entry at runtime.", + build_options=PackingOptions(fill="both", expand=True), + cmap_bins=32, + plot_size=(400, 40), + ) + selector_dynamic.build() + selector_dynamic.add_cmap(["#003366", "#0066cc", "#99ccff"], label="brand_blue") + selector_dynamic.add_cmap(custom_listed) + + root.mainloop() + + # After mainloop — demonstrate get() vs get_cmap() return types + print("selector_strings.get() :", selector_strings.get()) # str + print("selector_colours.get() :", selector_colours.get()) # Colormap object + print("selector_objects.get() :", selector_objects.get()) # Colormap object + print("selector_preset.get() :", selector_preset.get()) # str + print("selector_preset.get_cmap():", selector_preset.get_cmap()) # Colormap object \ No newline at end of file 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 a2b0045c..bebabbf6 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 @@ -120,6 +120,6 @@ def on_button_click_2(): 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", command=on_button_click, width=100) landing_page.add_custom_button(text="Click Me 2", command=on_button_click_2) landing_page.mainloop() \ No newline at end of file From ccec3818d5f83651b3ebe1efe6b5abcfdeafbfa2 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Fri, 1 May 2026 11:02:08 +0100 Subject: [PATCH 6/6] ensure the trace / on change commands are consistent wired previous unique methods in base class one to ensure backwards compatibility --- .../bhom_tkinter/widgets/_widgets_base.py | 21 +++++++++++++++++++ .../widgets/check_box_selection.py | 4 ++++ .../bhom_tkinter/widgets/cmap_selector.py | 1 + .../bhom_tkinter/widgets/colour_picker.py | 1 + .../widgets/drop_down_selection.py | 1 + .../bhom_tkinter/widgets/list_box.py | 1 + .../bhom_tkinter/widgets/path_selector.py | 1 + .../bhom_tkinter/widgets/radio_selection.py | 1 + .../bhom_tkinter/widgets/spinbox.py | 1 + .../widgets/validated_entry_box.py | 8 +++++++ .../bhom_tkinter/widgets/widget_calendar.py | 5 +++++ 11 files changed, 45 insertions(+) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py index b2959362..e89390cd 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/_widgets_base.py @@ -30,6 +30,7 @@ def __init__( custom_validation: Optional[Callable[[object], Tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]]] = None, disable_validation: bool = False, build_options: BuildOptions = PackingOptions(), + on_change: Optional[Callable] = None, **kwargs): """ Initialize the widget base. @@ -46,6 +47,10 @@ def __init__( Receives the current widget value and must return `(is_valid, message, severity)`. disable_validation: When `True`, all validation returns valid. + on_change: Optional callback invoked whenever the widget value + changes. The current value is passed as the single argument. + Supported by all widgets; supplements widget-specific ``command`` + parameters where those exist. **kwargs: Additional Frame options """ super().__init__(parent, **kwargs) @@ -57,6 +62,7 @@ def __init__( self.fill_extents = self._normalise_bool(fill_extents) self.custom_validation = custom_validation self.disable_validation = bool(disable_validation) + self.on_change: Optional[Callable] = on_change if id is None: self.id = str(uuid4()) @@ -248,6 +254,21 @@ def set_fill_extents(self, fill_extents: bool) -> None: label.pack_configure(anchor=self._pack_anchor) self._apply_text_alignment(label) + def _fire_on_change(self, value: object) -> None: + """Invoke the ``on_change`` callback with the current widget value. + + This is called internally by each widget subclass whenever its value + changes. It is safe to call even when ``on_change`` is ``None``. + + Args: + value: The current widget value to pass to the callback. + """ + if self.on_change is not None: + try: + self.on_change(value) + except Exception: + pass + @abstractmethod def get(self): """Get the current value of the widget.""" 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 1a81801a..c3f9651d 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 @@ -97,6 +97,7 @@ def _on_select_field(self, field): """Handle checkbox selection change.""" if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def get(self) -> List[str]: """Return a list of currently selected values. @@ -122,6 +123,7 @@ def select_all(self): var.set(True) if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def deselect_all(self): """Deselect all checkboxes.""" @@ -129,6 +131,7 @@ def deselect_all(self): var.set(False) if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def toggle_all(self): """Toggle all checkbox states.""" @@ -136,6 +139,7 @@ def toggle_all(self): var.set(not var.get()) if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def set_fields(self, fields: List[str], defaults: Optional[List[str]] = None): """Replace the available fields and rebuild the widget. 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 94c1ba21..594e510e 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 @@ -344,6 +344,7 @@ def _select_default_cmap(self, colormaps: List[str]) -> None: def _on_cmap_selected(self, event=None) -> None: """Handle combobox selection changes.""" self._update_cmap_sample() + self._fire_on_change(self.get()) def _update_cmap_sample(self, *args) -> None: """Update the colormap sample plot. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py index f0f36f07..952aab2e 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/colour_picker.py @@ -171,6 +171,7 @@ def _apply_popup_colour(self) -> None: self.set(selected) if self.command: self.command(selected) + self._fire_on_change(selected) self._close_picker() def _close_picker(self) -> None: 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 9cd258ca..fc7564fc 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 @@ -65,6 +65,7 @@ def _on_select(self, event=None): """ if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def get(self) -> str: """Return the currently selected value. 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 bf70de2a..0d1113ba 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 @@ -94,6 +94,7 @@ def _on_configure(self, event=None): def _on_selection_change(self, _event=None): """Track selection changes so values remain available after teardown.""" self._sync_cache_from_widget() + self._fire_on_change(self.get_selection()) def _is_listbox_alive(self) -> bool: """Return whether the underlying Tk listbox command still exists.""" diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py index e9335543..663a16ff 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/path_selector.py @@ -63,6 +63,7 @@ def _on_click(self): self.path_var.set(str(selected_path)) if self.command: self.command(str(selected_path)) + self._fire_on_change(str(selected_path)) def get(self) -> str: """Return the currently selected file path. 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 c061b4dc..b01b6c25 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 @@ -119,6 +119,7 @@ def _select_field(self, field): """Handle radio button selection.""" if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def get(self): """Return the currently selected value. 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 fe084a51..b0c1a869 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 @@ -89,6 +89,7 @@ def _on_change(self, *_): """Fire the command callback when the value changes.""" if self.command: self.command(self.get()) + self._fire_on_change(self.get()) def get(self) -> Union[str, int, float]: """Return the current value cast to its original type. 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 183aee3a..56bc5bcd 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 @@ -133,6 +133,14 @@ def __init__( if default_value is not None: self.set(default_value) + # Attach trace after initial value is set so callback does not fire + # during construction. + self.variable.trace_add("write", self._on_value_change) + + def _on_value_change(self, *_) -> None: + """Fire the on_change callback when the entry value changes.""" + self._fire_on_change(self.get()) + def _sync_to_external(self, *_) -> None: """Write the current StringVar text back to the external typed variable.""" if self._external_var is None: 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 0b984ef3..a0b7109d 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 @@ -64,8 +64,10 @@ def __init__( if self.show_year_selector: self.year_selector() self.month_selector() + self._initialized = False self.set_day(def_day) self.redraw() + self._initialized = True def year_selector(self): """Build the year dropdown selector.""" @@ -160,6 +162,9 @@ def set_day(self, num): label = Label(self.date_frame, text=f"Selected Date: {date}") self.align_child_text(label) label.pack(anchor=self._pack_anchor, padx=4, pady=4) + + if getattr(self, "_initialized", False): + self._fire_on_change(self.get()) def get_date(self): """Return the selected date as a `datetime.date` instance.